From e4d46b170ca6207499fcb9fd3820dbc3748017ce Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:13:49 +0100 Subject: [PATCH 01/49] Extract engine-construction factory from Sender.build() Pull the lock/quarantine engine-construction block out of build() into LineSenderBuilder.constructEngineOnSlotLocked()/constructEngineOnSlot(), and expose a QwpWebSocketSender.EngineRebuildFactory seam that build() installs on the connected sender once connect() succeeds. Pure refactor, zero behavior change: build() keeps its wide logical-lock scope spanning the connect loop; the standalone constructEngineOnSlot() acquires the lock only around construction, for a later symbol-dictionary epoch rebuild to reuse the identical construct/quarantine code path. --- .../main/java/io/questdb/client/Sender.java | 166 ++++++++++++------ .../qwp/client/QwpWebSocketSender.java | 16 ++ 2 files changed, 131 insertions(+), 51 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index bfef5189..cae6e746 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1619,57 +1619,11 @@ public Sender build() { // directory with nothing left to skip -- it cannot throw the same way // twice, which is what makes looping unnecessary here. boolean quarantined = false; - CursorSendEngine cursorEngine; - try { - try { - cursorEngine = new CursorSendEngine( - slotPath, actualSfMaxSegmentBytes, - actualSfMaxTotalBytes, actualSfAppendDeadlineNanos, - actualSfSyncIntervalNanos); - } catch (SfSanitizedResidueException first) { - // NOT terminal, and it must be intercepted ahead of its - // SfRecoveryException parent below. Recovery durably zeroed - // proven-dead sealed residue BEFORE failing closed, so the - // chain on disk is already healed: quarantining here would - // set aside a slot whose backlog replays perfectly. Retry - // once over the healed chain; a repeat is genuine and takes - // the terminal arm. - LOG.info("sf slot {}: sealed residue sanitized during recovery ({}); " - + "retrying over the healed chain", - slotPath, first.getMessage()); - cursorEngine = new CursorSendEngine( - slotPath, actualSfMaxSegmentBytes, - actualSfMaxTotalBytes, actualSfAppendDeadlineNanos, - actualSfSyncIntervalNanos); - } - } catch (UnreplayableSlotException | SfRecoveryException - | MmapSegmentCorruptionException e) { - // The terminal recovery verdicts, and the only ones build() - // sets a slot aside for. UnreplayableSlotException says the - // symbol dictionary cannot be rebuilt from any source; - // SfRecoveryException and MmapSegmentCorruptionException say - // the durable chain itself is proven corrupt or incomplete. - // None of the three clears on a retry, and senderId is stable - // with a not-fully-drained slot retained on close -- so - // without this arm every restart re-recovers the same slot and - // throws again, and the application cannot construct a Sender - // at all, not even to BUFFER new rows. - // - // Deliberately NOT catching plain MmapSegmentException or - // SfOperationalException: those are operational (EMFILE, - // ENOMEM, an unreadable-but-possibly-intact file). Aborting - // startup on them is correct; quarantining on them would - // convert a transient into the permanent loss of a healthy - // slot's durable frames. - if (slotPath == null) { - throw e; - } - quarantined = true; - cursorEngine = quarantineTornSlot( - null, e, sfDir, senderId, slotPath, actualSfMaxSegmentBytes, - actualSfMaxTotalBytes, actualSfAppendDeadlineNanos, - actualSfSyncIntervalNanos, errorHandler); - } + CursorSendEngine cursorEngine = constructEngineOnSlotLocked( + sfDir, senderId, slotPath, + actualSfMaxSegmentBytes, actualSfMaxTotalBytes, + actualSfAppendDeadlineNanos, actualSfSyncIntervalNanos, + errorHandler); int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY ? errorInboxCapacity : io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher.DEFAULT_CAPACITY; @@ -1765,6 +1719,11 @@ public Sender build() { // dispatcher daemon, drainer pool, microbatch buffers and // WebSocketClient inside the abandoned `connected`. connected.setTransactional(transactional); + connected.setEngineRebuildFactory(() -> LineSenderBuilder.constructEngineOnSlot( + sfDir, senderId, slotPath, + actualSfMaxSegmentBytes, actualSfMaxTotalBytes, + actualSfAppendDeadlineNanos, actualSfSyncIntervalNanos, + errorHandler)); try { // Install the drainer listener BEFORE startOrphanDrainers // below: drainers must see the listener at submit time so @@ -3132,6 +3091,111 @@ private static long parseSizeValue(@NotNull StringSink value, @NotNull String na } } + /** + * Constructs a {@code CursorSendEngine} on {@code slotPath}, quarantining a torn + * slot exactly as {@link #build}'s connect loop does when the constructor itself + * hits a terminal recovery verdict. Assumes the caller already holds + * {@code slotPath}'s logical lock (or {@code slotPath == null}, memory mode). + */ + static CursorSendEngine constructEngineOnSlotLocked( + String sfDir, String senderId, String slotPath, + long maxSegmentBytes, long maxTotalBytes, + long appendDeadlineNanos, long syncIntervalNanos, + SenderErrorHandler errorHandler) { + // The constructor's own recovery seed can also fail terminally, and + // not only as UnreplayableSlotException: when SegmentRing.openExisting + // had to skip an unreadable segment it throws SfRecoveryException (it + // constructs UnreplayableSlotException nowhere), and where it cannot + // even prove the chain's identity -- no manifest -- it quarantines the + // corrupt files and returns an EMPTY recovery rather than refusing. + // Either way the frame range cannot be shown already-acked, so recovery + // sets the slot aside rather than risk seeding the ack cursor past + // frames that were never delivered. All three types below are load + // bearing; narrowing this catch to UnreplayableSlotException would + // restore the permanent build() brick for the segment-skip case. That verdict gets + // the exact same quarantine-and-continue treatment as the connect()-time + // verdict below -- constructing cursorEngine is not inside the loop below, + // so a throw here would otherwise escape build() entirely, uncaught. + // quarantineTornSlot(null, ...) renames the WHOLE slot directory aside + // (not just the unreadable segment file) before building the replacement + // at the original slotPath, so the replacement starts on a genuinely empty + // directory with nothing left to skip -- it cannot throw the same way + // twice, which is what makes looping unnecessary here. + boolean quarantined = false; + CursorSendEngine cursorEngine; + try { + try { + cursorEngine = new CursorSendEngine( + slotPath, maxSegmentBytes, + maxTotalBytes, appendDeadlineNanos, + syncIntervalNanos); + } catch (SfSanitizedResidueException first) { + // NOT terminal, and it must be intercepted ahead of its + // SfRecoveryException parent below. Recovery durably zeroed + // proven-dead sealed residue BEFORE failing closed, so the + // chain on disk is already healed: quarantining here would + // set aside a slot whose backlog replays perfectly. Retry + // once over the healed chain; a repeat is genuine and takes + // the terminal arm. + LOG.info("sf slot {}: sealed residue sanitized during recovery ({}); " + + "retrying over the healed chain", + slotPath, first.getMessage()); + cursorEngine = new CursorSendEngine( + slotPath, maxSegmentBytes, + maxTotalBytes, appendDeadlineNanos, + syncIntervalNanos); + } + } catch (UnreplayableSlotException | SfRecoveryException + | MmapSegmentCorruptionException e) { + // The terminal recovery verdicts, and the only ones build() + // sets a slot aside for. UnreplayableSlotException says the + // symbol dictionary cannot be rebuilt from any source; + // SfRecoveryException and MmapSegmentCorruptionException say + // the durable chain itself is proven corrupt or incomplete. + // None of the three clears on a retry, and senderId is stable + // with a not-fully-drained slot retained on close -- so + // without this arm every restart re-recovers the same slot and + // throws again, and the application cannot construct a Sender + // at all, not even to BUFFER new rows. + // + // Deliberately NOT catching plain MmapSegmentException or + // SfOperationalException: those are operational (EMFILE, + // ENOMEM, an unreadable-but-possibly-intact file). Aborting + // startup on them is correct; quarantining on them would + // convert a transient into the permanent loss of a healthy + // slot's durable frames. + if (slotPath == null) { + throw e; + } + quarantined = true; + cursorEngine = quarantineTornSlot( + null, e, sfDir, senderId, slotPath, maxSegmentBytes, + maxTotalBytes, appendDeadlineNanos, + syncIntervalNanos, errorHandler); + } + return cursorEngine; + } + + /** + * {@link #constructEngineOnSlotLocked} wrapped in its own narrow acquisition of + * {@code slotPath}'s logical lock. {@link #build} itself does not call this -- + * its own lock spans the connect loop too, see the comment at its call site -- + * this entry point is for callers that only need a freshly (re)built engine on + * an already-owned slot, such as a symbol-dictionary epoch rebuild. + */ + static CursorSendEngine constructEngineOnSlot( + String sfDir, String senderId, String slotPath, + long maxSegmentBytes, long maxTotalBytes, + long appendDeadlineNanos, long syncIntervalNanos, + SenderErrorHandler errorHandler) { + try (SlotLock logicalSlotLock = slotPath == null + ? null : SlotLock.acquireLogical(slotPath)) { + return constructEngineOnSlotLocked(sfDir, senderId, slotPath, + maxSegmentBytes, maxTotalBytes, appendDeadlineNanos, + syncIntervalNanos, errorHandler); + } + } + /** * Sets a slot aside that either connect() (a symbol dictionary that cannot cover its * surviving frames, {@code UnreplayableSlotException}) or the diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 7aed1419..48826c10 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -286,6 +286,18 @@ public class QwpWebSocketSender implements Sender { // while the producer thread reads it from sendRow without // holding the sender monitor. private volatile int effectiveAutoFlushBytes; + /** + * Rebuilds a fresh {@link CursorSendEngine} on this sender's own slot, going + * through the identical construct/quarantine code path + * {@link Sender.LineSenderBuilder#build} uses. + */ + public interface EngineRebuildFactory { + CursorSendEngine rebuild(); + } + + // Installed by build() once connect() succeeds; null for a sender that + // has never connected. See setEngineRebuildFactory. + private EngineRebuildFactory engineRebuildFactory; private volatile SenderErrorDispatcher errorDispatcher; // Async-delivery sink for SenderError notifications. Default-constructed // here with the loud-not-silent default handler; a builder hook can swap @@ -2533,6 +2545,10 @@ public synchronized void setDrainerListener(BackgroundDrainerListener listener) } } + public void setEngineRebuildFactory(EngineRebuildFactory factory) { + this.engineRebuildFactory = factory; + } + /** * Configure the user-supplied error handler. May be called either before * or after {@code connect()} — when called after, the change propagates From 6f3a3cfa437d0749c8b450eb9d09095fda30260f Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:33:39 +0100 Subject: [PATCH 02/49] Restore shared quarantined counter across construction and connect constructEngineOnSlotLocked() now returns a small ConstructedEngine result (engine + whether construction itself quarantined the slot) instead of a bare CursorSendEngine. build() seeds its own quarantined local from that verdict, restoring the pre-refactor invariant that a construction-time quarantine counts toward the one quarantine per build() attempt the connect loop's retry guard allows. Without this, a construction-time quarantine followed by an UnreplayableSlotException from connect() would take a second quarantineTornSlot pass instead of the original close-and-rethrow. constructEngineOnSlot(), the public factory entry Task 5 consumes, keeps its 8-arg signature and CursorSendEngine return type: it just unwraps ConstructedEngine.engine and discards the quarantined verdict, since the recycle path latches terminal on connect failure rather than quarantining. --- .../main/java/io/questdb/client/Sender.java | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index cae6e746..bf6bbecc 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1618,12 +1618,20 @@ public Sender build() { // at the original slotPath, so the replacement starts on a genuinely empty // directory with nothing left to skip -- it cannot throw the same way // twice, which is what makes looping unnecessary here. - boolean quarantined = false; - CursorSendEngine cursorEngine = constructEngineOnSlotLocked( + ConstructedEngine constructed = constructEngineOnSlotLocked( sfDir, senderId, slotPath, actualSfMaxSegmentBytes, actualSfMaxTotalBytes, actualSfAppendDeadlineNanos, actualSfSyncIntervalNanos, errorHandler); + // Seeded from constructEngineOnSlotLocked's own verdict, not + // hardcoded false: if construction already quarantined this + // slot, the connect loop below must count that as the one + // quarantine build() allows per attempt (see its "quarantined + // || slotPath == null" guard) rather than starting blind and + // risking a second quarantineTornSlot pass on what should be + // an immediate close-and-rethrow. + boolean quarantined = constructed.quarantined; + CursorSendEngine cursorEngine = constructed.engine; int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY ? errorInboxCapacity : io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher.DEFAULT_CAPACITY; @@ -3091,13 +3099,31 @@ private static long parseSizeValue(@NotNull StringSink value, @NotNull String na } } + /** + * Result of {@link #constructEngineOnSlotLocked}: the constructed engine, plus + * whether construction itself had to quarantine a torn slot to produce it. + * {@link #build} folds {@code quarantined} into its own connect-loop retry + * guard, so a construction-time quarantine still counts toward the one + * quarantine build() allows per attempt -- the invariant a single shared + * {@code quarantined} local enforced before this method existed. + */ + static final class ConstructedEngine { + final CursorSendEngine engine; + final boolean quarantined; + + ConstructedEngine(CursorSendEngine engine, boolean quarantined) { + this.engine = engine; + this.quarantined = quarantined; + } + } + /** * Constructs a {@code CursorSendEngine} on {@code slotPath}, quarantining a torn * slot exactly as {@link #build}'s connect loop does when the constructor itself * hits a terminal recovery verdict. Assumes the caller already holds * {@code slotPath}'s logical lock (or {@code slotPath == null}, memory mode). */ - static CursorSendEngine constructEngineOnSlotLocked( + static ConstructedEngine constructEngineOnSlotLocked( String sfDir, String senderId, String slotPath, long maxSegmentBytes, long maxTotalBytes, long appendDeadlineNanos, long syncIntervalNanos, @@ -3173,7 +3199,7 @@ static CursorSendEngine constructEngineOnSlotLocked( maxTotalBytes, appendDeadlineNanos, syncIntervalNanos, errorHandler); } - return cursorEngine; + return new ConstructedEngine(cursorEngine, quarantined); } /** @@ -3181,7 +3207,9 @@ static CursorSendEngine constructEngineOnSlotLocked( * {@code slotPath}'s logical lock. {@link #build} itself does not call this -- * its own lock spans the connect loop too, see the comment at its call site -- * this entry point is for callers that only need a freshly (re)built engine on - * an already-owned slot, such as a symbol-dictionary epoch rebuild. + * an already-owned slot, such as a symbol-dictionary epoch rebuild. Discards the + * quarantined verdict: a recycle rebuild latches terminal on connect failure + * rather than quarantining, so it has no connect-loop retry guard to seed. */ static CursorSendEngine constructEngineOnSlot( String sfDir, String senderId, String slotPath, @@ -3192,7 +3220,7 @@ static CursorSendEngine constructEngineOnSlot( ? null : SlotLock.acquireLogical(slotPath)) { return constructEngineOnSlotLocked(sfDir, senderId, slotPath, maxSegmentBytes, maxTotalBytes, appendDeadlineNanos, - syncIntervalNanos, errorHandler); + syncIntervalNanos, errorHandler).engine; } } From 3a30e4d90eca1f8f853d46a4535095e3e60199e7 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:57:18 +0100 Subject: [PATCH 03/49] Add symbol_dict_reset config knobs Adds three connect-string keys for the upcoming QWP symbol-dictionary recycle feature: symbol_dict_reset (on/off, default on), symbol_dict_reset_threshold (distinct-symbol count that triggers a recycle, default 100_000, bounded by QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE), and symbol_dict_reset_max_wait_millis (upper bound on how long a triggered recycle waits for an opportunistic window before forcing, default 30_000, 0 means opportunistic-only). This is config plumbing only: the three values land on QwpWebSocketSender as resetEnabled/resetThresholdSymbols/resetMaxWaitMillis fields with @TestOnly getters, following the catch_up_cap_gap_min_escalation_window_millis knob end-to-end (ConfigSchema registry, builder methods with WS-transport guards, both connect-string parse paths, wsConfigSnapshotForTest). Actual recycle behavior is a follow-up task. --- .../main/java/io/questdb/client/Sender.java | 111 +++++++++++++++++- .../qwp/client/QwpWebSocketSender.java | 58 ++++++++- .../io/questdb/client/impl/ConfigSchema.java | 3 + .../LineSenderBuilderWebSocketTest.java | 65 ++++++++++ .../test/impl/WsSenderConfigHonoredTest.java | 3 + 5 files changed, 236 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index bf6bbecc..27198845 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -45,6 +45,7 @@ import io.questdb.client.cutlass.qwp.client.sf.cursor.SfRecoveryException; import io.questdb.client.cutlass.qwp.client.sf.cursor.SfSanitizedResidueException; import io.questdb.client.cutlass.qwp.client.sf.cursor.UnreplayableSlotException; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; import io.questdb.client.impl.ConfStringParser; import io.questdb.client.impl.ConfigString; import io.questdb.client.impl.ConfigView; @@ -1087,6 +1088,9 @@ final class LineSenderBuilder { private int maxFrameRejections = PARAMETER_NOT_SET_EXPLICITLY; private long poisonMinEscalationWindowMillis = PARAMETER_NOT_SET_EXPLICITLY; private long catchUpCapGapMinEscalationWindowMillis = PARAMETER_NOT_SET_EXPLICITLY; + private boolean symbolDictReset = true; + private int symbolDictResetThreshold = PARAMETER_NOT_SET_EXPLICITLY; + private long symbolDictResetMaxWaitMillis = PARAMETER_NOT_SET_EXPLICITLY; private String httpPath; private String httpSettingsPath; private int httpTimeout = PARAMETER_NOT_SET_EXPLICITLY; @@ -1545,6 +1549,12 @@ public Sender build() { catchUpCapGapMinEscalationWindowMillis != PARAMETER_NOT_SET_EXPLICITLY ? catchUpCapGapMinEscalationWindowMillis : CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS; + int actualSymbolDictResetThreshold = symbolDictResetThreshold != PARAMETER_NOT_SET_EXPLICITLY + ? symbolDictResetThreshold + : QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_THRESHOLD_SYMBOLS; + long actualSymbolDictResetMaxWaitMillis = symbolDictResetMaxWaitMillis != PARAMETER_NOT_SET_EXPLICITLY + ? symbolDictResetMaxWaitMillis + : QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS; // sfDir is the parent (group root); the actual slot lives // under sfDir/senderId. This is what the engine sees — the @@ -1674,7 +1684,10 @@ public Sender build() { actualConnectionListenerInboxCapacity, actualMaxFrameRejections, actualPoisonMinEscalationWindowMillis, - actualCatchUpCapGapMinEscalationWindowMillis + actualCatchUpCapGapMinEscalationWindowMillis, + symbolDictReset, + actualSymbolDictResetThreshold, + actualSymbolDictResetMaxWaitMillis ); } catch (UnreplayableSlotException e) { // The one failure build() recovers from. The slot's frames reference ids @@ -1867,6 +1880,59 @@ public LineSenderBuilder catchUpCapGapMinEscalationWindowMillis(long millis) { return this; } + /** + * Enables periodic recycling (rebuilding) of the sender's symbol dictionary + * once it reaches {@link #symbolDictResetThreshold(int)} distinct symbols, + * so a long-lived sender's dictionary does not grow without bound. + *

+ * Default {@code true} (on). WebSocket transport only. + */ + public LineSenderBuilder symbolDictReset(boolean enabled) { + if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) { + throw new LineSenderException("symbol_dict_reset is only supported for WebSocket transport"); + } + this.symbolDictReset = enabled; + return this; + } + + /** + * Number of distinct symbols the sender's dictionary may accumulate before + * {@link #symbolDictReset(boolean)} triggers a recycle. Must be greater than + * {@code 0} and no larger than {@link QwpConstants#MAX_SYMBOL_DICTIONARY_SIZE}. + *

+ * Default {@code 100_000}. WebSocket transport only. + */ + public LineSenderBuilder symbolDictResetThreshold(int threshold) { + if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) { + throw new LineSenderException("symbol_dict_reset_threshold is only supported for WebSocket transport"); + } + if (threshold <= 0 || threshold > QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE) { + throw new LineSenderException("symbol_dict_reset_threshold must be > 0 and <= ") + .put(QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE).put(": ").put(threshold); + } + this.symbolDictResetThreshold = threshold; + return this; + } + + /** + * Upper bound, in milliseconds, a triggered symbol-dictionary recycle waits + * for an opportunistic (idle) window before forcing the rebuild. {@code 0} + * means opportunistic-only: the recycle never forces, it only takes idle + * windows as they occur. + *

+ * Default {@code 30_000} (30 s). WebSocket transport only. + */ + public LineSenderBuilder symbolDictResetMaxWaitMillis(long maxWaitMillis) { + if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) { + throw new LineSenderException("symbol_dict_reset_max_wait_millis is only supported for WebSocket transport"); + } + if (maxWaitMillis < 0) { + throw new LineSenderException("symbol_dict_reset_max_wait_millis must be >= 0: ").put(maxWaitMillis); + } + this.symbolDictResetMaxWaitMillis = maxWaitMillis; + return this; + } + /** * close() drain timeout in milliseconds. The sender's {@code close()} * method blocks up to this many millis waiting for the server to ACK @@ -3870,6 +3936,30 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { } pos = getValue(configurationString, pos, sink, "catch_up_cap_gap_min_escalation_window_millis"); catchUpCapGapMinEscalationWindowMillis(parseLongValue(sink, "catch_up_cap_gap_min_escalation_window_millis")); + } else if (Chars.equals("symbol_dict_reset", sink)) { + if (protocol != PROTOCOL_WEBSOCKET) { + throw new LineSenderException("symbol_dict_reset is only supported for WebSocket transport"); + } + pos = getValue(configurationString, pos, sink, "symbol_dict_reset"); + if (Chars.equalsIgnoreCase("on", sink)) { + symbolDictReset(true); + } else if (Chars.equalsIgnoreCase("off", sink)) { + symbolDictReset(false); + } else { + throw new LineSenderException("invalid symbol_dict_reset [value=").put(sink).put(", allowed-values=[on, off]]"); + } + } else if (Chars.equals("symbol_dict_reset_threshold", sink)) { + if (protocol != PROTOCOL_WEBSOCKET) { + throw new LineSenderException("symbol_dict_reset_threshold is only supported for WebSocket transport"); + } + pos = getValue(configurationString, pos, sink, "symbol_dict_reset_threshold"); + symbolDictResetThreshold(parseIntValue(sink, "symbol_dict_reset_threshold")); + } else if (Chars.equals("symbol_dict_reset_max_wait_millis", sink)) { + if (protocol != PROTOCOL_WEBSOCKET) { + throw new LineSenderException("symbol_dict_reset_max_wait_millis is only supported for WebSocket transport"); + } + pos = getValue(configurationString, pos, sink, "symbol_dict_reset_max_wait_millis"); + symbolDictResetMaxWaitMillis(parseLongValue(sink, "symbol_dict_reset_max_wait_millis")); } else if (Chars.equals("initial_connect_retry", sink)) { if (protocol != PROTOCOL_WEBSOCKET) { throw new LineSenderException("initial_connect_retry is only supported for WebSocket transport"); @@ -4145,6 +4235,12 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString) if (view.has("catch_up_cap_gap_min_escalation_window_millis")) { catchUpCapGapMinEscalationWindowMillis(wsLong(view, v, "catch_up_cap_gap_min_escalation_window_millis")); } + if (view.has("symbol_dict_reset_threshold")) { + symbolDictResetThreshold(wsInt(view, v, "symbol_dict_reset_threshold")); + } + if (view.has("symbol_dict_reset_max_wait_millis")) { + symbolDictResetMaxWaitMillis(wsLong(view, v, "symbol_dict_reset_max_wait_millis")); + } if (view.has("sf_append_deadline_millis")) { sfAppendDeadlineMillis(wsLong(view, v, "sf_append_deadline_millis")); } @@ -4214,6 +4310,16 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString) throw new LineSenderException("invalid initial_connect_retry [value=").put(s).put(", allowed-values=[on, off, true, false, sync, async]]"); } } + s = view.getStr("symbol_dict_reset"); + if (s != null) { + if (s.equalsIgnoreCase("on")) { + symbolDictReset(true); + } else if (s.equalsIgnoreCase("off")) { + symbolDictReset(false); + } else { + throw new LineSenderException("invalid symbol_dict_reset [value=").put(s).put(", allowed-values=[on, off]]"); + } + } return this; } catch (IllegalArgumentException e) { throw new LineSenderException(e.getMessage()); @@ -4329,6 +4435,9 @@ public java.util.Map wsConfigSnapshotForTest() { m.put("max_frame_rejections", maxFrameRejections); m.put("poison_min_escalation_window_millis", poisonMinEscalationWindowMillis); m.put("catch_up_cap_gap_min_escalation_window_millis", catchUpCapGapMinEscalationWindowMillis); + m.put("symbol_dict_reset", symbolDictReset); + m.put("symbol_dict_reset_threshold", symbolDictResetThreshold); + m.put("symbol_dict_reset_max_wait_millis", symbolDictResetMaxWaitMillis); m.put("error_inbox_capacity", errorInboxCapacity); m.put("connection_listener_inbox_capacity", connectionListenerInboxCapacity); m.put("token", httpToken); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 48826c10..8c2b3608 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -137,6 +137,17 @@ public class QwpWebSocketSender implements Sender { // Finite fallback (ms) for BACKGROUND (drainer) TCP connects when the // user left connect_timeout unset. See effectiveConnectTimeoutMs. public static final int DEFAULT_BACKGROUND_CONNECT_TIMEOUT_MS = 15_000; + // Default for symbol_dict_reset -- periodic symbol-dictionary recycling is + // on by default so a long-lived sender's dictionary does not grow without + // bound. + public static final boolean DEFAULT_SYMBOL_DICT_RESET_ENABLED = true; + // Default for symbol_dict_reset_max_wait_millis: upper bound, in millis, the + // recycle waits for an opportunistic (idle) window before forcing the + // rebuild. 0 means opportunistic-only -- never forced. + public static final long DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS = 30_000L; + // Default for symbol_dict_reset_threshold: distinct-symbol count that + // triggers a recycle once symbol_dict_reset is on. + public static final int DEFAULT_SYMBOL_DICT_RESET_THRESHOLD_SYMBOLS = 100_000; private static final int DEFAULT_BUFFER_SIZE = 8192; private static final int DEFAULT_MICROBATCH_BUFFER_SIZE = 1024 * 1024; // 1MB private static final Logger LOG = LoggerFactory.getLogger(QwpWebSocketSender.class); @@ -379,6 +390,18 @@ public interface EngineRebuildFactory { // CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS. private long catchUpCapGapMinEscalationWindowMillis = CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS; + // Whether the producer periodically recycles (rebuilds) its symbol + // dictionary once resetThresholdSymbols distinct symbols have been + // registered, bounding unbounded dictionary growth on a long-lived sender + // (connect-string key symbol_dict_reset). + private boolean resetEnabled = DEFAULT_SYMBOL_DICT_RESET_ENABLED; + // Upper bound, in millis, the recycle waits for an opportunistic (idle) + // window before forcing the rebuild; 0 means opportunistic-only, never + // forced (connect-string key symbol_dict_reset_max_wait_millis). + private long resetMaxWaitMillis = DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS; + // Distinct-symbol count that triggers a recycle once resetEnabled is on + // (connect-string key symbol_dict_reset_threshold). + private int resetThresholdSymbols = DEFAULT_SYMBOL_DICT_RESET_THRESHOLD_SYMBOLS; private long reconnectInitialBackoffMillis = CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS; private long reconnectMaxBackoffMillis = @@ -758,12 +781,17 @@ public static QwpWebSocketSender connect( connectionListener, connectionListenerInboxCapacity, CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS, - CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS); + CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS, + DEFAULT_SYMBOL_DICT_RESET_ENABLED, + DEFAULT_SYMBOL_DICT_RESET_THRESHOLD_SYMBOLS, + DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS); } /** * Master connect overload — also accepts the poison-frame detector - * threshold ({@code max_frame_rejections}): consecutive server-active + * threshold ({@code max_frame_rejections}) and the symbol-dictionary + * recycle knobs ({@code symbol_dict_reset}, {@code symbol_dict_reset_threshold}, + * {@code symbol_dict_reset_max_wait_millis}): consecutive server-active * rejections of the same head-of-line frame, with no ack progress in * between, before the loop escalates to a typed terminal. */ @@ -790,7 +818,10 @@ public static QwpWebSocketSender connect( int connectionListenerInboxCapacity, int maxFrameRejections, long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis + long catchUpCapGapMinEscalationWindowMillis, + boolean symbolDictResetEnabled, + int symbolDictResetThresholdSymbols, + long symbolDictResetMaxWaitMillis ) { QwpWebSocketSender sender = new QwpWebSocketSender( endpoints, tlsConfig, @@ -809,6 +840,9 @@ public static QwpWebSocketSender connect( sender.maxFrameRejections = maxFrameRejections; sender.poisonMinEscalationWindowMillis = poisonMinEscalationWindowMillis; sender.catchUpCapGapMinEscalationWindowMillis = catchUpCapGapMinEscalationWindowMillis; + sender.resetEnabled = symbolDictResetEnabled; + sender.resetThresholdSymbols = symbolDictResetThresholdSymbols; + sender.resetMaxWaitMillis = symbolDictResetMaxWaitMillis; sender.initialConnectMode = initialConnectMode == null ? Sender.InitialConnectMode.OFF : initialConnectMode; @@ -1978,6 +2012,18 @@ public int getServerMaxBatchSize() { return serverMaxBatchSize; } + /** Resolved value of {@code symbol_dict_reset_max_wait_millis}. */ + @TestOnly + public long getSymbolDictResetMaxWaitMillis() { + return resetMaxWaitMillis; + } + + /** Resolved value of {@code symbol_dict_reset_threshold}. */ + @TestOnly + public int getSymbolDictResetThreshold() { + return resetThresholdSymbols; + } + @TestOnly public QwpTableBuffer getTableBuffer(String tableName) { QwpTableBuffer buffer = tableBuffers.get(tableName); @@ -2002,6 +2048,12 @@ public boolean isDeltaDictEnabledForTest() { return deltaDictEnabled; } + /** Resolved value of {@code symbol_dict_reset}. */ + @TestOnly + public boolean isSymbolDictResetEnabled() { + return resetEnabled; + } + /** * Total binary frames whose ACKs have been received and applied. */ diff --git a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java index c9529a13..b9725608 100644 --- a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java +++ b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java @@ -90,6 +90,9 @@ public final class ConfigSchema { str("sf_max_segment_bytes", Side.INGRESS); str("sf_max_total_bytes", Side.INGRESS); str("sf_sync_interval_millis", Side.INGRESS); + str("symbol_dict_reset", Side.INGRESS); + str("symbol_dict_reset_max_wait_millis", Side.INGRESS); + str("symbol_dict_reset_threshold", Side.INGRESS); str("transaction", Side.INGRESS); // EGRESS -- the QwpQueryClient applies. Typed where there is a range or diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java index 5d2b9976..0bb792e3 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java @@ -27,12 +27,16 @@ import io.questdb.client.Sender; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; import io.questdb.client.test.AbstractTest; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; import io.questdb.client.test.tools.TestUtils; import org.junit.Assert; import org.junit.Ignore; import org.junit.Test; +import java.util.concurrent.TimeUnit; + import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; /** @@ -269,6 +273,67 @@ public void testCatchUpCapGapMinEscalationWindowUnsetInSnapshot() { .get("catch_up_cap_gap_min_escalation_window_millis")); } + @Test + public void testSymbolDictResetDefaults() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() { + })) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + try (Sender sender = Sender.fromConfig("ws::addr=" + LOCALHOST + ":" + port + ";")) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + Assert.assertTrue(ws.isSymbolDictResetEnabled()); + Assert.assertEquals(100_000, ws.getSymbolDictResetThreshold()); + Assert.assertEquals(30_000L, ws.getSymbolDictResetMaxWaitMillis()); + } + } + }); + } + + @Test + public void testSymbolDictResetConfigStringRoundTrip() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() { + })) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + try (Sender sender = Sender.fromConfig("ws::addr=" + LOCALHOST + ":" + port + + ";symbol_dict_reset=off;symbol_dict_reset_threshold=500;" + + "symbol_dict_reset_max_wait_millis=0;")) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + Assert.assertFalse(ws.isSymbolDictResetEnabled()); + Assert.assertEquals(500, ws.getSymbolDictResetThreshold()); + Assert.assertEquals(0L, ws.getSymbolDictResetMaxWaitMillis()); + } + } + }); + } + + @Test + public void testSymbolDictResetThresholdRejectsBadValues() { + assertThrows("symbol_dict_reset_threshold must be > 0", + () -> Sender.builder("ws::addr=" + LOCALHOST + ";symbol_dict_reset_threshold=0;")); + assertThrows("symbol_dict_reset_threshold must be > 0", + () -> Sender.builder("ws::addr=" + LOCALHOST + ";symbol_dict_reset_threshold=-5;")); + assertThrows("symbol_dict_reset_threshold must be > 0 and <= " + QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE, + () -> Sender.builder("ws::addr=" + LOCALHOST + ";symbol_dict_reset_threshold=" + + (QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE + 1) + ";")); + assertThrows("symbol_dict_reset_max_wait_millis must be >= 0: -1", + () -> Sender.builder("ws::addr=" + LOCALHOST + ";symbol_dict_reset_max_wait_millis=-1;")); + } + + @Test + public void testSymbolDictResetRejectedForNonWebSocketTransport() { + assertThrows("symbol_dict_reset is only supported for WebSocket transport", + () -> Sender.builder("http::addr=" + LOCALHOST + ":9000;symbol_dict_reset=on;")); + assertThrows("symbol_dict_reset_threshold is only supported for WebSocket transport", + () -> Sender.builder("http::addr=" + LOCALHOST + ":9000;symbol_dict_reset_threshold=500;")); + assertThrows("symbol_dict_reset_max_wait_millis is only supported for WebSocket transport", + () -> Sender.builder("http::addr=" + LOCALHOST + ":9000;symbol_dict_reset_max_wait_millis=0;")); + } + @Test public void testConnectionRefused() throws Exception { assertMemoryLeak(() -> { diff --git a/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java index 3c257827..771179ca 100644 --- a/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java @@ -78,6 +78,9 @@ public void testEveryIngressKeyIsHonored() { assertHonored("poison_min_escalation_window_millis=7500", "poison_min_escalation_window_millis", 7500L); assertHonored("catch_up_cap_gap_min_escalation_window_millis=90000", "catch_up_cap_gap_min_escalation_window_millis", 90000L); + assertHonored("symbol_dict_reset=off", "symbol_dict_reset", false); + assertHonored("symbol_dict_reset_threshold=250000", "symbol_dict_reset_threshold", 250000); + assertHonored("symbol_dict_reset_max_wait_millis=45000", "symbol_dict_reset_max_wait_millis", 45000L); assertHonored("error_inbox_capacity=128", "error_inbox_capacity", 128); assertHonored("connection_listener_inbox_capacity=64", "connection_listener_inbox_capacity", 64); assertHonored("token=ey.abc", "token", "ey.abc"); From dc8a3f23e8c6ce76bef281ce1e0c25961a35ea1c Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:17:19 +0100 Subject: [PATCH 04/49] Arm symbol-dict recycle at flush; add advisory reset API Adds Sender.resetSymbolDictionary(), an advisory request to start a fresh symbol-dictionary epoch (default no-op; QwpWebSocketSender overrides it). QwpWebSocketSender.armIfEligible() re-evaluates arming at the tail of resetTableBuffersAfterFlush() -- the shared exit point for the plain flush, split flush, and close-path callers -- so the recycle arms once symbol_dict_reset is enabled and either the global dictionary reaches symbol_dict_reset_threshold distinct entries or a caller requested a manual reset. Arming deliberately ignores deltaDictEnabled: a sender degraded to full self-sufficient frames still benefits from bounding dictionary growth, and a manual request is honoured regardless of mode. Covered by SymbolDictRecycleArmingTest: threshold crossing, symbol_dict_reset=off never arming, the manual advisory API (both immediate and mid-batch-deferred arming), the split-flush path sharing the same arming tail, arming in full-dict (degraded) mode, and the no-op default on a non-WebSocket sender. --- .../main/java/io/questdb/client/Sender.java | 9 + .../qwp/client/QwpWebSocketSender.java | 72 +++++ .../client/SymbolDictRecycleArmingTest.java | 273 ++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 27198845..e5873a23 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -689,6 +689,15 @@ default Sender long256Column(CharSequence name, long l0, long l1, long l2, long Sender longColumn(CharSequence name, long value); + /** + * Advisory request to start a fresh symbol-dictionary epoch. The reset + * happens at the next safe point (all published data acknowledged, no row + * in progress); it may be deferred indefinitely under sustained load. No-op + * on transports without a symbol dictionary. + */ + default void resetSymbolDictionary() { + } + /** * Clear the internal buffers, discarding any unsent data. *
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 8c2b3608..1f3bf438 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -402,6 +402,24 @@ public interface EngineRebuildFactory { // Distinct-symbol count that triggers a recycle once resetEnabled is on // (connect-string key symbol_dict_reset_threshold). private int resetThresholdSymbols = DEFAULT_SYMBOL_DICT_RESET_THRESHOLD_SYMBOLS; + // Wall-clock time (System.nanoTime()) at which resetArmed last flipped + // false -> true. Recorded by armIfEligible so a later task's opportunistic + // wait can measure how long the recycle has been armed against + // resetMaxWaitMillis. + private long armedSinceNanos; + // Set by resetSymbolDictionary() (the public advisory API) and never + // cleared by armIfEligible itself -- once a caller asks for a fresh epoch, + // every later armIfEligible call keeps arming until the recycle actually + // runs and consumes the request. + private boolean manualResetRequested; + // True once armIfEligible has determined a recycle should happen. Consumed + // by the (later-task) recycle trigger; set only at the tail of + // resetTableBuffersAfterFlush, never on the per-symbol registration path. + private boolean resetArmed; + // Cleared on the false -> true armed transition; a later task's + // opportunistic-wait step sets it once it has waited out its window for + // THIS arm cycle, so a subsequent forced-wait check does not re-wait. + private boolean starvationWaitDoneThisArm; private long reconnectInitialBackoffMillis = CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS; private long reconnectMaxBackoffMillis = @@ -2048,6 +2066,16 @@ public boolean isDeltaDictEnabledForTest() { return deltaDictEnabled; } + /** + * Whether the symbol-dictionary recycle is currently armed. Set by + * {@link #armIfEligible()} at the tail of every flush (and immediately by + * {@link #resetSymbolDictionary()} when no row or flush is in progress). + */ + @TestOnly + public boolean isResetArmed() { + return resetArmed; + } + /** Resolved value of {@code symbol_dict_reset}. */ @TestOnly public boolean isSymbolDictResetEnabled() { @@ -2466,6 +2494,24 @@ public void reset() { cachedTimestampNanosColumn = null; } + /** + * Advisory request to start a fresh symbol-dictionary epoch. Sets + * {@link #manualResetRequested}; if no row is currently in progress and no + * flush is in flight ({@code pendingRowCount == 0}), re-evaluates arming + * immediately so a caller that requests a reset between batches does not + * have to wait for a later flush to observe {@code isResetArmed()}. A + * request made mid-batch is picked up by the next + * {@code resetTableBuffersAfterFlush} instead. + */ + @Override + public void resetSymbolDictionary() { + checkNotClosed(); + manualResetRequested = true; + if (pendingRowCount == 0) { + armIfEligible(); + } + } + /** * Register an async listener for connection-state transitions: initial * connect, primary failover, endpoint attempt failures, the full address @@ -4354,6 +4400,32 @@ private void resetTableBuffersAfterFlush() { currentTableBufferSnapshotBytes = 0; pendingRowCount = 0; firstPendingRowTimeNanos = 0; + armIfEligible(); + } + + /** + * Re-evaluates whether the symbol-dictionary recycle should be armed: + * {@code resetEnabled} is on AND either the global dictionary has reached + * {@code resetThresholdSymbols} distinct entries or a caller requested a + * reset via {@link #resetSymbolDictionary()}. Deliberately ignores + * {@code deltaDictEnabled} -- a producer degraded to full self-sufficient + * frames still benefits from bounding its dictionary size, and a manual + * request is honoured regardless of mode. + *

+ * Called only from the tail of {@link #resetTableBuffersAfterFlush()} (a + * safe point: no row in progress, this flush's data already handed to the + * engine), never from the per-symbol registration path + * ({@link #getOrAddGlobalSymbol}) -- arming mid-row or mid-encode would + * observe a dictionary size that has not yet settled for this batch. + */ + private void armIfEligible() { + boolean shouldArm = resetEnabled + && (globalSymbolDictionary.size() >= resetThresholdSymbols || manualResetRequested); + if (shouldArm && !resetArmed) { + armedSinceNanos = System.nanoTime(); + starvationWaitDoneThisArm = false; + } + resetArmed = shouldArm; } /** diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java new file mode 100644 index 00000000..009c6fb4 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java @@ -0,0 +1,273 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.DelegatingFilesFacade; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Covers the arming half of the symbol-dictionary recycle feature: + * {@code QwpWebSocketSender.armIfEligible()}, called at the tail of + * {@code resetTableBuffersAfterFlush()}, and the manual advisory API + * {@link Sender#resetSymbolDictionary()}. + */ +public class SymbolDictRecycleArmingTest { + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + @Test + public void testArmsAtThreshold() throws Exception { + // threshold=3, send rows with symbols a,b -> flush -> not armed; + // add c -> flush -> armed + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(cfg(server) + "symbol_dict_reset_threshold=3;")) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1).atNow(); + sender.flush(); + Assert.assertFalse(ws.isResetArmed()); + sender.table("t").symbol("s", "c").longColumn("v", 1).atNow(); + sender.flush(); + Assert.assertTrue(ws.isResetArmed()); + } + } + }); + } + + /** + * Decision 5: arming ignores {@code deltaDictEnabled}. Proving this by + * crossing {@code symbol_dict_reset_threshold} while degraded (as + * {@link #testArmsAtThreshold} does for the default delta-dict mode) would + * additionally need a custom low threshold on a hand-built + * {@code CursorSendEngine} carrying the fault-injecting {@code FilesFacade} + * (fault injection requires bypassing {@code Sender.fromConfig}, which + * offers no {@code FilesFacade} seam) -- reaching both together needs + * {@code QwpWebSocketSender}'s widest 27-parameter {@code connect()} + * overload. Substituting the manual {@link Sender#resetSymbolDictionary()} + * advisory request for "cross the threshold" reaches the identical + * {@code armIfEligible()} branch -- the other arm of the same {@code ||} -- + * through the same 9-parameter {@code connect()} overload + * {@code MmapFaultDegradesTest} itself uses, with no loss of coverage: + * {@code armIfEligible()} does not special-case either trigger on + * {@code deltaDictEnabled}. + */ + @Test + public void testArmsInFullDictMode() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("arm-full-dict-sf").toString(); + String slot = Paths.get(sfDir, "default").toString(); + Assert.assertEquals(0, io.questdb.client.std.Files.mkdir(sfDir, + io.questdb.client.std.Files.DIR_MODE_DEFAULT)); + + try (TestWebSocketServer server = ackingServer()) { + int port = server.getPort(); + + MmapFaultDictFacade ff = new MmapFaultDictFacade(); + CursorSendEngine engine = new CursorSendEngine( + slot, 4L * 1024 * 1024, 64L * 1024 * 1024, + CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff); + QwpWebSocketSender sender = QwpWebSocketSender.connect( + "localhost", port, null, 0, 0, 0L, null, false, engine); + try { + ff.armed = true; // next dictionary mmap growth raises a recognised fault + sender.table("m").symbol("s", "boom").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("expected the injected mmap fault to fail this flush"); + } catch (LineSenderException expected) { + // Same guard MmapFaultDegradesTest pins: the fault degrades the + // sender to full self-sufficient frames instead of propagating raw. + } + Assert.assertFalse("a recognised mmap access fault must degrade the sender " + + "to full-dict mode", + sender.isDeltaDictEnabledForTest()); + + // The fault facade disarms itself after firing once, so this retry + // succeeds and clears pendingRowCount back to 0. + sender.flush(); + Assert.assertFalse("neither threshold nor manual request has fired yet", + sender.isResetArmed()); + + sender.resetSymbolDictionary(); + Assert.assertTrue("manual reset request must arm even in full-dict mode", + sender.isResetArmed()); + } finally { + sender.close(); + } + } + }); + } + + @Test + public void testDoesNotArmWhenDisabled() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig( + cfg(server) + "symbol_dict_reset=off;symbol_dict_reset_threshold=2;")) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + sender.flush(); + Assert.assertFalse("symbol_dict_reset=off must never arm", ws.isResetArmed()); + sender.table("t").symbol("s", "c").longColumn("v", 1L).atNow(); + sender.flush(); + Assert.assertFalse("symbol_dict_reset=off must never arm", ws.isResetArmed()); + } + } + }); + } + + @Test + public void testManualResetRequestArms() throws Exception { + assertMemoryLeak(() -> { + // pendingRowCount == 0: resetSymbolDictionary() arms immediately. + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(cfg(server))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + } + } + + // Mid-batch: a request while a row is buffered (pendingRowCount != 0) + // only arms once the next flush runs armIfEligible() at its tail. + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(cfg(server))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.resetSymbolDictionary(); + Assert.assertFalse("a mid-batch request must not arm before the next flush", + ws.isResetArmed()); + sender.flush(); + Assert.assertTrue(ws.isResetArmed()); + } + } + }); + } + + @Test + public void testResetSymbolDictionaryOnNonWsSenderIsNoOp() throws Exception { + assertMemoryLeak(() -> { + // protocolVersion(2) skips the eager server-side settings detection + // connect that build() otherwise performs, so no live server is needed + // (see LineSenderBuilderTest.testCustomPemRootsDoNotRequirePassword). + try (Sender sender = Sender.builder(Sender.Transport.HTTP) + .address("localhost") + .protocolVersion(2) + .build()) { + sender.resetSymbolDictionary(); + } + }); + } + + @Test + public void testSplitFlushPathArms() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + server.setAdvertisedMaxBatchSize(150); // forces the two-table batch to split + // Padding inflates each table past half the cap, so the combined + // two-table message exceeds it while each single-table split frame fits. + String pad = TestUtils.repeat("x", 60); + try (Sender sender = Sender.fromConfig(cfg(server) + "symbol_dict_reset_threshold=2;")) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t1").symbol("s", "a").stringColumn("p", pad).longColumn("v", 1L).atNow(); + sender.table("t2").symbol("s", "b").stringColumn("p", pad).longColumn("v", 2L).atNow(); + sender.flush(); + Assert.assertTrue("the split-flush path shares resetTableBuffersAfterFlush's tail", + ws.isResetArmed()); + } + } + }); + } + + private static TestWebSocketServer ackingServer() throws Exception { + TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + private static String cfg(TestWebSocketServer server) { + return "ws::addr=localhost:" + server.getPort() + ";"; + } + + /** + * ACKs every frame it receives; does not otherwise inspect the wire. + */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Raises a RECOGNISED mmap access fault out of the persisted dictionary's next + * mmap growth, once, when {@link #armed}. Copied from + * {@code MmapFaultDegradesTest.MmapFaultDictFacade}. + */ + private static final class MmapFaultDictFacade extends DelegatingFilesFacade { + boolean armed; + + @Override + public boolean isMmapAllowed() { + return true; + } + + @Override + public long mmap(int fd, long len, long offset, int flags, int memoryTag) { + if (armed) { + armed = false; + throw new InternalError( + "a fault occurred in a recent unsafe memory access operation in compiled Java code"); + } + return INSTANCE.mmap(fd, len, offset, flags, memoryTag); + } + } +} From 8f90f4559321d9de580305acedb6d11237665083 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:25:43 +0100 Subject: [PATCH 05/49] Prove threshold arming survives full-dict degradation testArmsInFullDictMode previously substituted the manual resetSymbolDictionary() advisory request for crossing symbol_dict_reset_threshold, which never touches globalSymbolDictionary and so left a future deltaDictEnabled-conditioned regression in threshold-based arming undetected. Rewrite it to construct the sender through the widest connect(List, ...) overload, which accepts both a custom symbolDictResetThresholdSymbols and the fault-injecting CursorSendEngine, and genuinely cross the threshold (registering a, b, c) while the sender stays degraded to full self-sufficient frames. No manual reset call remains in the test. --- .../client/SymbolDictRecycleArmingTest.java | 77 ++++++++++++++----- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java index 009c6fb4..e83dfa45 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java @@ -28,6 +28,9 @@ import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderConnectionDispatcher; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; import io.questdb.client.test.tools.DelegatingFilesFacade; import io.questdb.client.test.tools.TestUtils; @@ -38,6 +41,7 @@ import java.io.IOException; import java.nio.file.Paths; +import java.util.Collections; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; @@ -75,21 +79,17 @@ public void testArmsAtThreshold() throws Exception { } /** - * Decision 5: arming ignores {@code deltaDictEnabled}. Proving this by - * crossing {@code symbol_dict_reset_threshold} while degraded (as - * {@link #testArmsAtThreshold} does for the default delta-dict mode) would - * additionally need a custom low threshold on a hand-built - * {@code CursorSendEngine} carrying the fault-injecting {@code FilesFacade} - * (fault injection requires bypassing {@code Sender.fromConfig}, which - * offers no {@code FilesFacade} seam) -- reaching both together needs - * {@code QwpWebSocketSender}'s widest 27-parameter {@code connect()} - * overload. Substituting the manual {@link Sender#resetSymbolDictionary()} - * advisory request for "cross the threshold" reaches the identical - * {@code armIfEligible()} branch -- the other arm of the same {@code ||} -- - * through the same 9-parameter {@code connect()} overload - * {@code MmapFaultDegradesTest} itself uses, with no loss of coverage: - * {@code armIfEligible()} does not special-case either trigger on - * {@code deltaDictEnabled}. + * Decision 5: arming ignores {@code deltaDictEnabled} -- threshold-based + * arming must still fire once the sender has degraded to full self-sufficient + * frames. Reaching a custom low {@code symbol_dict_reset_threshold} on a + * sender that also carries the fault-injecting {@code FilesFacade} needs + * {@code QwpWebSocketSender}'s widest {@code connect(List, ...)} + * overload: {@code Sender.fromConfig} has no {@code FilesFacade} seam, and + * every narrower {@code connect(host, port, ...)} overload hard-codes the + * default threshold (100,000). That overload sets + * {@code sender.resetThresholdSymbols} directly and also accepts the + * hand-built {@code CursorSendEngine}, so both requirements are reachable + * together. */ @Test public void testArmsInFullDictMode() throws Exception { @@ -107,10 +107,33 @@ public void testArmsInFullDictMode() throws Exception { slot, 4L * 1024 * 1024, 64L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff); QwpWebSocketSender sender = QwpWebSocketSender.connect( - "localhost", port, null, 0, 0, 0L, null, false, engine); + Collections.singletonList(new QwpWebSocketSender.Endpoint("localhost", port)), + null, // tlsConfig + 0, 0, 0L, // autoFlushRows, autoFlushBytes, autoFlushIntervalNanos + null, // authorizationHeader + false, // requestDurableAck + engine, + 5_000L, // closeFlushTimeoutMillis + CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS, + CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS, + CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_BACKOFF_MILLIS, + Sender.InitialConnectMode.OFF, + null, // errorHandler + SenderErrorDispatcher.DEFAULT_CAPACITY, + CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, + QwpWebSocketSender.DEFAULT_AUTH_TIMEOUT_MS, + 0, // connectTimeoutMs + null, // connectionListener + SenderConnectionDispatcher.DEFAULT_CAPACITY, + CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, + CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS, + CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS, + true, // symbolDictResetEnabled + 3, // symbolDictResetThresholdSymbols -- low, deliberately crossed below + QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS); try { ff.armed = true; // next dictionary mmap growth raises a recognised fault - sender.table("m").symbol("s", "boom").longColumn("v", 1L).atNow(); + sender.table("m").symbol("s", "a").longColumn("v", 1L).atNow(); try { sender.flush(); Assert.fail("expected the injected mmap fault to fail this flush"); @@ -121,15 +144,27 @@ public void testArmsInFullDictMode() throws Exception { Assert.assertFalse("a recognised mmap access fault must degrade the sender " + "to full-dict mode", sender.isDeltaDictEnabledForTest()); + Assert.assertFalse("dictionary has only 1 entry, below the threshold of 3", + sender.isResetArmed()); // The fault facade disarms itself after firing once, so this retry - // succeeds and clears pendingRowCount back to 0. + // succeeds and clears pendingRowCount back to 0; "a" is now published. + sender.flush(); + Assert.assertFalse("still degraded, dictionary still below threshold", + sender.isDeltaDictEnabledForTest()); + Assert.assertFalse(sender.isResetArmed()); + + sender.table("m").symbol("s", "b").longColumn("v", 2L).atNow(); sender.flush(); - Assert.assertFalse("neither threshold nor manual request has fired yet", + Assert.assertFalse("dictionary has 2 entries, still below the threshold of 3", sender.isResetArmed()); - sender.resetSymbolDictionary(); - Assert.assertTrue("manual reset request must arm even in full-dict mode", + // No manual resetSymbolDictionary() call anywhere in this test: crossing + // the threshold alone must arm the recycle, even while degraded. + sender.table("m").symbol("s", "c").longColumn("v", 3L).atNow(); + sender.flush(); + Assert.assertTrue("threshold-based arming must fire even in full-dict mode " + + "(Decision 5: arming ignores deltaDictEnabled)", sender.isResetArmed()); } finally { sender.close(); From 59559cf1f3005afde68220eb5e1e3454d223b35e Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:55:05 +0100 Subject: [PATCH 06/49] Translate user-visible FSNs by a recycle epoch base --- .../qwp/client/QwpWebSocketSender.java | 57 ++- .../client/sf/cursor/BackgroundDrainer.java | 3 +- .../sf/cursor/CursorWebSocketSendLoop.java | 58 ++- .../SymbolDictRecycleFsnContinuityTest.java | 451 ++++++++++++++++++ .../sf/cursor/CloseOwnershipRaceTest.java | 3 +- ...WebSocketSendLoopCatchUpAlignmentTest.java | 6 +- ...SendLoopForegroundReconnectPolicyTest.java | 9 +- ...CursorWebSocketSendLoopMirrorLeakTest.java | 2 +- 8 files changed, 557 insertions(+), 32 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 1f3bf438..4a19c094 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -316,6 +316,17 @@ public interface EngineRebuildFactory { private SenderErrorHandler errorHandler = DefaultSenderErrorHandler.INSTANCE; private int errorInboxCapacity = SenderErrorDispatcher.DEFAULT_CAPACITY; private long firstPendingRowTimeNanos; + // Additive offset applied to every user-visible FSN this sender reports + // (flushAndGetSequence, awaitAckedFsn's target, getAckedFsn, drain's + // watermark, and every FSN the I/O loop surfaces through the progress + // and error dispatchers). Stays 0 until a later symbol-dict recycle + // rebuilds the cursor engine and restarts its internal FSNs at 0 -- + // rollFsnEpochBaseForTest (and its production counterpart in the + // recycle path) advance it past every FSN already handed out, so the + // external sequence stays strictly monotone across the internal reset. + // Rule everywhere it is applied: external = fsnEpochBase + raw: raw + // -1 (no-data) sentinels are never translated. + private long fsnEpochBase = 0; private boolean hasDeferredMessages; // FSN of the last commit-bearing (non-FLAG_DEFER_COMMIT) frame this session // published, or -1 when none. Frames above it are deferred and uncommitted: @@ -1015,6 +1026,14 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { cursorSendLoop.checkError(); } checkConnectionError(); + if (targetFsn >= 0) { + long internalTarget = targetFsn - fsnEpochBase; + if (internalTarget < 0) { + // target belongs to a pre-recycle epoch: proven acked before the swap + return true; + } + targetFsn = internalTarget; + } if (cursorEngine.ackedFsn() >= targetFsn) { return true; } @@ -1722,7 +1741,7 @@ public long flushAndGetSequence() { checkConnectionError(); long afterFsn = cursorEngine != null ? cursorEngine.publishedFsn() : -1L; - return afterFsn > beforeFsn ? afterFsn : -1L; + return afterFsn > beforeFsn ? fsnEpochBase + afterFsn : -1L; } /** @@ -1752,7 +1771,8 @@ public long flushAndGetSequence() { @Override public boolean drain(long timeoutMillis) { flush(); - long targetFsn = cursorEngine != null ? cursorEngine.publishedFsn() : -1L; + long targetRaw = cursorEngine != null ? cursorEngine.publishedFsn() : -1L; + long targetFsn = targetRaw < 0 ? targetRaw : fsnEpochBase + targetRaw; return awaitAckedFsn(targetFsn, timeoutMillis); } @@ -1839,7 +1859,7 @@ public QwpWebSocketSender geoHashColumn(CharSequence columnName, CharSequence va */ @Override public long getAckedFsn() { - return cursorEngine != null ? cursorEngine.ackedFsn() : -1L; + return cursorEngine != null ? fsnEpochBase + cursorEngine.ackedFsn() : -1L; } /** @@ -2082,6 +2102,34 @@ public boolean isSymbolDictResetEnabled() { return resetEnabled; } + /** Current value of {@link #fsnEpochBase}. */ + @TestOnly + public long getFsnEpochBaseForTest() { + return fsnEpochBase; + } + + /** + * Test-only entry point for {@link #rollFsnEpochBase}, the same private + * roll the symbol-dict recycle swap calls in production once the engine + * rebuild has committed. + */ + @TestOnly + public void rollFsnEpochBaseForTest(long lastPublishedFsn) { + rollFsnEpochBase(lastPublishedFsn); + } + + /** + * Advances {@link #fsnEpochBase} past every FSN handed out under the + * epoch that just ended. {@code lastPublishedFsn} is the highest raw FSN + * the outgoing cursor engine ever published ({@code -1} if it published + * nothing), so the next raw FSN the fresh engine hands out -- + * {@code 0} -- maps to external {@code lastPublishedFsn + 1 + 0}, one + * past the last external FSN this sender ever reported. + */ + private void rollFsnEpochBase(long lastPublishedFsn) { + fsnEpochBase += lastPublishedFsn + 1L; + } + /** * Total binary frames whose ACKs have been received and applied. */ @@ -3932,7 +3980,8 @@ private void ensureConnected() { maxFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, - CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND); + CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND, + fsnEpochBase); // Plug the async-delivery sink before start() so the I/O thread // never observes a null dispatcher between recordFatal and // notification — the test for null in dispatchError handles diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java index a9b5545e..13e80da3 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java @@ -789,7 +789,8 @@ public void run() { maxHeadFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, - CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN); + CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN, + 0L); loop.start(); while (!stopRequestedOrInterrupted()) { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java index 5a66c302..8b7c9955 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java @@ -415,6 +415,14 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { // it is engine.ackedFsn() + 1, so the first replayed frame on the new // connection is wireSeq=0 and server-side cumulative ACKs still line up. private long fsnAtZero; + // Third coordinate: additive offset applied on top of the engine FSN + // (fsnAtZero already folded in) to produce the FSN this loop hands to a + // user-visible surface -- the progress dispatcher and every SenderError + // [fromFsn,toFsn] span. Fixed for the lifetime of one loop instance: 0 + // for a loop built directly against a live engine, or the sender's + // fsnEpochBase snapshot when a symbol-dict recycle rebuilt the engine and + // restarted its internal FSNs at 0. Rule: external = externalFsnBase + raw. + private final long externalFsnBase; // Bounded-await backstop budget for close() (see // DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS). Overridable via // setShutdownAwaitTimeoutMillis so tests can exercise the timeout branch @@ -712,7 +720,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, - CatchUpCapGapPolicy.RETRY_FOREVER); + CatchUpCapGapPolicy.RETRY_FOREVER, 0L); } /** @@ -730,7 +738,8 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, int maxHeadFrameRejections, long poisonMinEscalationWindowMillis, long catchUpCapGapMinEscalationWindowMillis, - CatchUpCapGapPolicy catchUpCapGapPolicy) { + CatchUpCapGapPolicy catchUpCapGapPolicy, + long externalFsnBase) { if (maxHeadFrameRejections < 1) { throw new IllegalArgumentException( "maxHeadFrameRejections must be >= 1: " + maxHeadFrameRejections); @@ -882,6 +891,7 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, // always outlive their borrower. Any growth copy-on-writes into loop-owned memory // (ensureSentDictCapacity), and releaseSentDictBytes frees only what the loop owns. this.fsnAtZero = fsnAtZero; + this.externalFsnBase = externalFsnBase; this.parkNanos = parkNanos; this.reconnectFactory = reconnectFactory; this.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis; @@ -923,6 +933,11 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, * establishing its first connection, then retries endpoint-policy failures * indefinitely after it has been live. An orphan drainer returns such failures * to its owner so the slot can follow its settle/quarantine policy. + *

+ * {@code externalFsnBase} is the additive offset this loop folds into every + * user-visible FSN it produces (progress-dispatcher advances and + * {@link SenderError} spans) -- see {@link #externalFsnBase}. Pass {@code 0L} + * unless the caller is replacing an engine a symbol-dict recycle rebuilt. */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, long fsnAtZero, long parkNanos, @@ -934,13 +949,14 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, int maxHeadFrameRejections, long poisonMinEscalationWindowMillis, long catchUpCapGapMinEscalationWindowMillis, - ReconnectPolicy reconnectPolicy) { + ReconnectPolicy reconnectPolicy, + long externalFsnBase) { this(client, engine, fsnAtZero, parkNanos, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, - catchUpPolicyFor(reconnectPolicy)); + catchUpPolicyFor(reconnectPolicy), externalFsnBase); } private static CatchUpCapGapPolicy catchUpPolicyFor(ReconnectPolicy reconnectPolicy) { @@ -1786,8 +1802,8 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM // role rejects are classified into the transient branch below. LOG.error("terminal upgrade error during {} -- won't retry: {}", phase, e.getMessage()); - long fromFsn = engine.ackedFsn() + 1L; - long toFsn = Math.max(fromFsn, engine.publishedFsn()); + long fromFsn = externalFsnBase + engine.ackedFsn() + 1L; + long toFsn = Math.max(fromFsn, externalFsnBase + engine.publishedFsn()); SenderError err = new SenderError( SenderError.Category.SECURITY_ERROR, SenderError.Policy.TERMINAL, @@ -1826,8 +1842,8 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM // volatile first-writer-wins latch observed by the owner. capabilityGapTerminal = e; } - long fromFsn = engine.ackedFsn() + 1L; - long toFsn = Math.max(fromFsn, engine.publishedFsn()); + long fromFsn = externalFsnBase + engine.ackedFsn() + 1L; + long toFsn = Math.max(fromFsn, externalFsnBase + engine.publishedFsn()); SenderError err = new SenderError( SenderError.Category.PROTOCOL_VIOLATION, SenderError.Policy.TERMINAL, @@ -1958,7 +1974,7 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM * producer stays alive and no data is at risk. */ private void dispatchRetriedEndpointPolicyFailure(SenderError.Category category, String message) { - long fromFsn = engine.ackedFsn() + 1L; + long fromFsn = externalFsnBase + engine.ackedFsn() + 1L; dispatchError(new SenderError( category, SenderError.Policy.RETRIABLE, @@ -1966,7 +1982,7 @@ private void dispatchRetriedEndpointPolicyFailure(SenderError.Category category, message, SenderError.NO_MESSAGE_SEQUENCE, fromFsn, - Math.max(fromFsn, engine.publishedFsn()), + Math.max(fromFsn, externalFsnBase + engine.publishedFsn()), null, System.nanoTime() )); @@ -2072,9 +2088,11 @@ private void haltOnPoisonedFrame(String lastRejection, long toFsnHint) { // the operator at those bytes would misattribute the poison. The // caller supplies the span end: a NACK names the exact frame, so the // span is that single frame; a non-orderly close cannot single one - // out, so it spans to publishedFsn. - long fromFsn = poisonFsn; - long toFsn = Math.max(fromFsn, toFsnHint); + // out, so it spans to publishedFsn. poisonFsn and toFsnHint are both + // raw internal FSNs (fsnAtZero already folded in by the caller where + // relevant); rebase both by externalFsnBase here. + long fromFsn = externalFsnBase + poisonFsn; + long toFsn = Math.max(fromFsn, externalFsnBase + toFsnHint); String msg = "frame at fsn=" + fromFsn + " rejected " + poisonStrikes + " consecutive times with no acceptance at or beyond it -- poisoned frame, replay cannot succeed (last: " + lastRejection + ')'; @@ -2098,12 +2116,14 @@ private void haltOnPoisonedFrame(String lastRejection, long toFsnHint) { * Notify the progress dispatcher that the ack watermark advanced to * {@code ackedFsn}. Caller must already have observed the advance via * {@link CursorSendEngine#acknowledge}'s boolean return; this method - * does no further filtering. + * does no further filtering. {@code ackedFsn} is the engine-relative FSN + * (fsnAtZero already folded in by the caller); this rebases it by + * {@link #externalFsnBase} before it reaches the user-visible dispatcher. */ private void dispatchProgress(long ackedFsn) { SenderProgressDispatcher d = progressDispatcher; if (d != null) { - d.offer(ackedFsn); + d.offer(externalFsnBase + ackedFsn); } } @@ -3842,8 +3862,8 @@ private void handlePreSendRejection(long wireSeq, byte status, // protocol-violation close path uses (see onClose above): there // is no FSN we can attribute the rejection to, so we report // the unacked range the producer can correlate against. - long fromFsn = engine.ackedFsn() + 1L; - long toFsn = Math.max(fromFsn, engine.publishedFsn()); + long fromFsn = externalFsnBase + engine.ackedFsn() + 1L; + long toFsn = Math.max(fromFsn, externalFsnBase + engine.publishedFsn()); String tableName = response.getTableEntryCount() == 1 ? response.getTableName(0) : null; @@ -3966,8 +3986,8 @@ private void handleServerRejection(long wireSeq) { status & 0xFF, response.getErrorMessage(), wireSeq, - fsn, - fsn, + externalFsnBase + fsn, + externalFsnBase + fsn, tableName, System.nanoTime() ); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java new file mode 100644 index 00000000..da886eeb --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java @@ -0,0 +1,451 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.SenderError; +import io.questdb.client.SenderProgressHandler; +import io.questdb.client.LineSenderServerException; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.WebSocketResponse; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * FSN epoch-base continuity across a symbol-dict recycle. Task 5's engine + * rebuild restarts the internal cursor engine's raw FSNs at 0; every + * user-visible FSN surface must stay strictly monotone across that boundary + * by translating {@code external = fsnEpochBase + raw} (negative sentinels + * pass untranslated). The recycle machinery itself lands in a later task -- + * these tests exercise the translation seam ({@code rollFsnEpochBaseForTest}) + * directly, either on a single already-connected sender (for the sender-level + * surfaces, which read the live {@code fsnEpochBase} field regardless of when + * the I/O loop was built) or by rolling a fresh sender's base BEFORE its + * first connect (for the loop-level surfaces -- the progress dispatcher and + * {@link SenderError} spans -- whose {@code externalFsnBase} is frozen at + * loop construction). + */ +public class SymbolDictRecycleFsnContinuityTest { + + @Test + public void testPreRollTargetAnswersTrueAfterRoll() throws Exception { + try (TestWebSocketServer server = ackingServer()) { + try (QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender.table("t").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: the batch must actually be acked before the roll", + sender.drain(5_000)); + + sender.rollFsnEpochBaseForTest(fsn1); + + long t0 = System.nanoTime(); + Assert.assertTrue("a target FSN from a pre-recycle epoch must be reported acked " + + "immediately -- it was proven acked before the swap", + sender.awaitAckedFsn(fsn1, 0)); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + Assert.assertTrue("must short-circuit, not poll: took " + elapsedMs + "ms", + elapsedMs < 200); + } + } + } + + @Test + public void testPostRollSequencesExceedAllPreRoll() throws Exception { + try (TestWebSocketServer server = ackingServer()) { + try (QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender.table("t").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.drain(5_000)); + + sender.rollFsnEpochBaseForTest(fsn1); + long newBase = sender.getFsnEpochBaseForTest(); + Assert.assertEquals(fsn1 + 1, newBase); + + sender.table("t").longColumn("v", 2L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.drain(5_000)); + + Assert.assertTrue("post-roll FSN must exceed every pre-roll FSN: fsn2=" + fsn2 + + " fsn1=" + fsn1, + fsn2 > fsn1); + // Raw FSNs increment by 1 per single-row flush, so the raw watermark right + // after this second flush is fsn1+1 (the same raw value the roll consumed + // plus one) -- fsn2 must be the epoch base plus that raw value, not just + // greater than fsn1 (a bug that dropped the translation entirely would still + // pass the ">" check above because the underlying raw engine never resets). + Assert.assertEquals(newBase + fsn1 + 1, fsn2); + } + } + } + + @Test + public void testGetAckedFsnMonotoneAcrossRoll() throws Exception { + try (TestWebSocketServer server = ackingServer()) { + long w; + long lastPublishedFsn; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.table("t").longColumn("v", 1L).atNow(); + long fsn1 = sender1.flushAndGetSequence(); + Assert.assertTrue(sender1.drain(5_000)); + w = sender1.getAckedFsn(); + lastPublishedFsn = fsn1; + Assert.assertEquals("sanity: single-batch acked watermark must match its own FSN", + fsn1, w); + } + + // A fresh sender/engine models the post-recycle engine that restarts its + // internal FSNs at 0; rolling its epoch base by the outgoing epoch's last + // published FSN is exactly what the recycle swap does in production. + QwpWebSocketSender sender2 = createRolledSender(server, lastPublishedFsn); + try { + long newBase = sender2.getFsnEpochBaseForTest(); + Assert.assertEquals(lastPublishedFsn + 1, newBase); + + Assert.assertEquals("before any new ack, getAckedFsn must read the synthetic " + + "watermark: one past the last external FSN the outgoing epoch " + + "ever reported", + newBase - 1, sender2.getAckedFsn()); + Assert.assertTrue(sender2.getAckedFsn() >= w); + + sender2.table("t").longColumn("v", 2L).atNow(); + sender2.flush(); + Assert.assertTrue(sender2.drain(5_000)); + Assert.assertTrue("a new ack must advance the watermark past the synthetic " + + "post-roll value", + sender2.getAckedFsn() > newBase - 1); + } finally { + sender2.close(); + } + } + } + + /** + * The raw-feed bug test: without the {@code drain()} fix, a rolled epoch base makes + * the raw {@code cursorEngine.publishedFsn()} target look like it belongs to a + * pre-recycle epoch (its raw value is smaller than the rolled base), so the fixed + * {@code awaitAckedFsn} would short-circuit {@code true} on an un-rebased target -- + * even though the frame was never actually acked. Must fail (spurious true) before + * {@code drain()} translates its target by {@code fsnEpochBase}. + */ + @Test + public void testDrainAfterRollWaitsForNewFrames() throws Exception { + GatedAckHandler handler = new GatedAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg(server)); + try { + // Roll well past the raw FSNs this fresh engine will ever publish, so a + // missing translation in drain() would make its raw target look pre-roll. + sender.rollFsnEpochBaseForTest(999L); + + sender.table("foo").longColumn("v", 1L).atNow(); + boolean drainedEarly = sender.drain(200); + Assert.assertFalse("drain() must not spuriously report the new frame acked just " + + "because its raw FSN is smaller than the rolled epoch base", + drainedEarly); + + handler.releaseAcks(); + Assert.assertTrue("drain() must return true once the real ack arrives", + sender.drain(5_000)); + } finally { + handler.releaseAcks(); + sender.close(); + } + } + } + + /** + * {@link SenderError#getFromFsn()} / {@link SenderError#getToFsn()} surface synchronously + * via {@link LineSenderServerException#getServerError()}, unreachable by any + * dispatcher-side rebase -- the loop must rebase the span itself. Rolls the epoch base + * BEFORE the sender's first connect (the loop's {@code externalFsnBase} is frozen at + * construction) so the terminal NACK's span is built under a nonzero base. + */ + @Test + public void testSenderErrorSpansCarryExternalFsns() throws Exception { + TerminalNackHandler handler = new TerminalNackHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + AtomicReference asyncError = new AtomicReference<>(); + QwpWebSocketSender sender = createRolledSender(server, 41L); + long base = sender.getFsnEpochBaseForTest(); + sender.setErrorHandler(e -> asyncError.compareAndSet(null, e)); + try { + sender.table("foo").longColumn("v", 1L).atNow(); + sender.flush(); + + waitFor(() -> handler.totalBinaryReceived.get() >= 1, 5_000); + waitFor(() -> sender.getLastTerminalError() != null, 5_000); + + LineSenderServerException thrown = null; + try { + sender.table("foo").longColumn("v", 2L).atNow(); + sender.flush(); + } catch (LineSenderServerException e) { + thrown = e; + } + Assert.assertNotNull("expected the latched terminal to surface synchronously", + thrown); + SenderError err = thrown.getServerError(); + Assert.assertEquals("fromFsn must be the external (epoch-rebased) FSN, not raw", + base, err.getFromFsn()); + Assert.assertEquals("toFsn must be the external (epoch-rebased) FSN, not raw", + base, err.getToFsn()); + + waitFor(() -> asyncError.get() != null, 5_000); + SenderError asyncErr = asyncError.get(); + Assert.assertEquals("async error handler must observe the same fromFsn as the " + + "synchronous throw", + err.getFromFsn(), asyncErr.getFromFsn()); + Assert.assertEquals("async error handler must observe the same toFsn as the " + + "synchronous throw", + err.getToFsn(), asyncErr.getToFsn()); + } finally { + try { + sender.close(); + } catch (LineSenderException ignored) { + } + } + } + } + + @Test + public void testProgressStreamMonotoneAcrossRoll() throws Exception { + try (TestWebSocketServer server = ackingServer()) { + List observed = Collections.synchronizedList(new ArrayList()); + SenderProgressHandler collector = observed::add; + + long lastPublishedFsn; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.setProgressHandler(collector); + sender1.table("t").longColumn("v", 1L).atNow(); + lastPublishedFsn = sender1.flushAndGetSequence(); + Assert.assertTrue(sender1.drain(5_000)); + } + waitFor(() -> !observed.isEmpty(), 5_000); + int preRollCount = observed.size(); + + // Roll BEFORE this sender's first connect so its loop's frozen + // externalFsnBase actually carries the roll (see class javadoc). + QwpWebSocketSender sender2 = createRolledSender(server, lastPublishedFsn); + sender2.setProgressHandler(collector); + try { + sender2.table("t").longColumn("v", 2L).atNow(); + sender2.flush(); + Assert.assertTrue(sender2.drain(5_000)); + waitFor(() -> observed.size() > preRollCount, 5_000); + + List snapshot = new ArrayList<>(observed); + for (int i = 1; i < snapshot.size(); i++) { + Assert.assertTrue("progress stream must be non-decreasing across the roll, " + + "got " + snapshot, + snapshot.get(i) >= snapshot.get(i - 1)); + } + Assert.assertTrue("post-roll progress must exceed the pre-roll watermark", + snapshot.get(snapshot.size() - 1) > lastPublishedFsn); + } finally { + sender2.close(); + } + } + } + + @Test + public void testLatchedErrorStillThrowsForOldEpochTarget() throws Exception { + AckFirstThenTerminalNackHandler handler = new AckFirstThenTerminalNackHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg(server)); + try { + sender.table("foo").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.drain(5_000)); + + sender.rollFsnEpochBaseForTest(fsn1); + + sender.table("foo").longColumn("v", 2L).atNow(); + sender.flush(); + waitFor(() -> sender.getLastTerminalError() != null, 5_000); + + LineSenderException thrown = null; + try { + boolean acked = sender.awaitAckedFsn(fsn1, 0); + Assert.fail("awaitAckedFsn must throw on a latched terminal error, but " + + "returned " + acked); + } catch (LineSenderException e) { + thrown = e; + } + Assert.assertNotNull("a latched terminal error must surface even for a " + + "pre-recycle-epoch target -- the error check must run before the " + + "pre-roll short-circuit", thrown); + } finally { + try { + sender.close(); + } catch (LineSenderException ignored) { + } + } + } + } + + private static TestWebSocketServer ackingServer() throws Exception { + TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + private static String cfg(TestWebSocketServer server) { + return "ws::addr=localhost:" + server.getPort() + ";"; + } + + /** + * An unconnected memory-mode sender with a freshly-attached (never published-to) + * {@link CursorSendEngine}, its {@link QwpWebSocketSender#getFsnEpochBaseForTest()} + * rolled by {@code rollAmount} before the first connect. Models the sender a symbol-dict + * recycle swap hands off to: a fresh raw engine paired with an already-advanced epoch + * base, so the loop this sender builds on first use gets that base baked into its + * {@code externalFsnBase} from construction. + */ + private static QwpWebSocketSender createRolledSender(TestWebSocketServer server, long rollAmount) { + QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", server.getPort()); + CursorSendEngine engine = new CursorSendEngine( + null, 4L * 1024 * 1024, 128L * 1024 * 1024, + CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS); + sender.setCursorEngine(engine, true); + sender.rollFsnEpochBaseForTest(rollAmount); + return sender; + } + + private static void waitFor(BoolCondition cond, long timeoutMillis) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + if (cond.test()) { + return; + } + Thread.sleep(20); + } + Assert.fail("waitFor timed out after " + timeoutMillis + "ms"); + } + + @FunctionalInterface + private interface BoolCondition { + boolean test(); + } + + /** ACKs every frame it receives; does not otherwise inspect the wire. */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** ACKs the first frame it receives, then terminal-NACKs every frame after it. */ + private static class AckFirstThenTerminalNackHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + private final AtomicLong receivedCount = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + if (receivedCount.getAndIncrement() == 0) { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } else { + client.sendBinary(QwpWireTestUtils.buildNack( + nextSeq.getAndIncrement(), WebSocketResponse.STATUS_PARSE_ERROR)); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Receives frames but withholds every ack until {@link #releaseAcks()} is called, so a + * drain provably has an unacknowledged target to wait on. Mirrors + * {@code CloseDrainTest.GatedAckHandler}. + */ + private static class GatedAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + private final CountDownLatch released = new CountDownLatch(1); + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + if (!released.await(20, TimeUnit.SECONDS)) { + throw new AssertionError("close-drain witness never released the ack gate"); + } + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + void releaseAcks() { + released.countDown(); + } + } + + /** Terminal-NACKs (STATUS_PARSE_ERROR) every frame it receives. */ + private static class TerminalNackHandler implements TestWebSocketServer.WebSocketServerHandler { + final AtomicLong totalBinaryReceived = new AtomicLong(); + private final AtomicLong nextSeq = new AtomicLong(); + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + totalBinaryReceived.incrementAndGet(); + try { + client.sendBinary(QwpWireTestUtils.buildNack( + nextSeq.getAndIncrement(), WebSocketResponse.STATUS_PARSE_ERROR)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java index fc1b9257..da3c79e9 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java @@ -76,7 +76,8 @@ public void closeOwnershipSnapshotNeverClaimsAnUnsurfacedError() { CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0, 0, - CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN); + CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN, + 0L); loop.start(); // Race close()'s exact ownership snapshot against the latch // transition, stopping once the latch has landed. Nothing in diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java index d9aa508d..00d7a2bd 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java @@ -1177,7 +1177,7 @@ private void assertUnrelatedReconnectStateRestartsCapGapEpisode(boolean roleReje CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0L, TimeUnit.HOURS.toMillis(1), - CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN); + CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN, 0L); loopRef[0] = loop; try { seedMirror(loop, TestUtils.repeat("x", 200)); @@ -1331,7 +1331,7 @@ private CursorWebSocketSendLoop newLoop( CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0L, capGapWindowMillis, - CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN); + CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN, 0L); } private CursorWebSocketSendLoop newForegroundLoop( @@ -1437,7 +1437,7 @@ private void assertConnectLoopEntry(boolean reenterWithCapGap) throws Exception CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0L, 0L, - CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN); + CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN, 0L); loopRef[0] = loop; try { seedMirror(loop, TestUtils.repeat("x", 200)); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java index 902df846..5aff5e5f 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java @@ -100,7 +100,8 @@ public void testFirstConnectCatchUpFailureKeepsStartupTerminalArmed() throws Exc CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0L, 0L, - CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND); + CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND, + 0L); try { seedMirror(loop, "sym0"); // non-empty mirror => swapClient runs the catch-up appendFrame(engine, (byte) 1); @@ -170,7 +171,8 @@ private void assertAsyncInitialForegroundSurfacesTerminal( CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0L, 0L, - CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND); + CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND, + 0L); try { appendFrame(engine, (byte) 1); loop.start(); @@ -227,7 +229,8 @@ private void assertForegroundRecovers(boolean durableAck, FailureSupplier failur CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0L, 0L, - CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND); + CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND, + 0L); // Wire an error sink. Retrying is what the store-and-forward contract // demands, but until dispatchRetriedEndpointPolicyFailure existed the retry // was programmatically INVISIBLE: dispatchError ran only in the terminal diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java index b215e476..5de5ada4 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java @@ -341,7 +341,7 @@ private static CursorWebSocketSendLoop newRecoveryLoop(CursorSendEngine engine) }, 0, 1, false, 0L, 3, 0L, 0L, - CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN); + CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN, 0L); } private static CursorWebSocketSendLoop newForegroundLoop(CursorSendEngine engine) { From 69255bd282ad4fdf8aca4307ec2b11d75bb68d2c Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:17:28 +0100 Subject: [PATCH 07/49] Enforce roll precondition and fix a non-discriminating test - rollFsnEpochBase now throws IllegalStateException when cursorSendLoop is non-null: the loop's externalFsnBase is a construction-time snapshot, never updated on a live loop, so rolling with a loop attached would silently desync sender-level FSN accessors from loop-level FSN emission. - testPreRollTargetAnswersTrueAfterRoll rolled the same already-connected sender that produced fsn1, so its raw engine watermark never reset and the pre-fix comparison (ackedFsn() == fsn1 >= fsn1) was also true -- the test could not fail. Rebuilt around a fresh rolled sender/engine via the existing createRolledSender helper, matching tests 3/5/6. Tests 2, 4, and 7 also rolled an already-connected sender (now rejected by the new guard) and are rewritten the same way. --- .../qwp/client/QwpWebSocketSender.java | 19 ++- .../SymbolDictRecycleFsnContinuityTest.java | 121 +++++++++++------- 2 files changed, 94 insertions(+), 46 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 4a19c094..25335076 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -2111,7 +2111,9 @@ public long getFsnEpochBaseForTest() { /** * Test-only entry point for {@link #rollFsnEpochBase}, the same private * roll the symbol-dict recycle swap calls in production once the engine - * rebuild has committed. + * rebuild has committed. See that method's precondition: {@code cursorSendLoop} + * must be {@code null} -- roll before the sender's first connect (e.g. via + * {@link #createForTesting}), never on an already-connected sender. */ @TestOnly public void rollFsnEpochBaseForTest(long lastPublishedFsn) { @@ -2125,8 +2127,23 @@ public void rollFsnEpochBaseForTest(long lastPublishedFsn) { * nothing), so the next raw FSN the fresh engine hands out -- * {@code 0} -- maps to external {@code lastPublishedFsn + 1 + 0}, one * past the last external FSN this sender ever reported. + *

+ * Precondition: {@code cursorSendLoop} must be {@code null}. A live loop's + * {@code externalFsnBase} is a construction-time snapshot -- it is never updated + * on an already-built loop -- so rolling while one is attached would silently + * desynchronize the two: {@link #getAckedFsn()} / {@link #flushAndGetSequence()} + * would report post-roll values while every {@code SenderProgressHandler} advance + * and {@link SenderError} span the loop emits would stay pinned at pre-roll + * values. The recycle swap must call this strictly between tearing the old loop + * down and constructing the new one. */ private void rollFsnEpochBase(long lastPublishedFsn) { + if (cursorSendLoop != null) { + throw new IllegalStateException("rollFsnEpochBase must run while cursorSendLoop" + + " is null -- the loop's externalFsnBase is a construction-time snapshot," + + " never updated on a live loop; roll strictly between tearing the old" + + " loop down and building the new one"); + } fsnEpochBase += lastPublishedFsn + 1L; } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java index da886eeb..afe5d21e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java @@ -52,33 +52,54 @@ * by translating {@code external = fsnEpochBase + raw} (negative sentinels * pass untranslated). The recycle machinery itself lands in a later task -- * these tests exercise the translation seam ({@code rollFsnEpochBaseForTest}) - * directly, either on a single already-connected sender (for the sender-level - * surfaces, which read the live {@code fsnEpochBase} field regardless of when - * the I/O loop was built) or by rolling a fresh sender's base BEFORE its - * first connect (for the loop-level surfaces -- the progress dispatcher and - * {@link SenderError} spans -- whose {@code externalFsnBase} is frozen at - * loop construction). + * directly. + *

+ * Every test that rolls the base does so on a sender BEFORE its first connect + * (via {@link #createRolledSender}), never on an already-connected one: + * {@code rollFsnEpochBase}'s precondition forbids rolling while a live + * {@code CursorWebSocketSendLoop} is attached (its {@code externalFsnBase} is a + * construction-time snapshot, never updated on a live loop -- see that method's + * javadoc). Tests that need a realistic pre-roll FSN to roll by first drive a + * SEPARATE, ordinarily-connected sender against the same server to publish and + * ack a batch, close it, then hand that FSN to {@code createRolledSender} for a + * second, fresh sender/engine -- modelling the post-recycle engine that + * restarts its raw FSNs at 0. */ public class SymbolDictRecycleFsnContinuityTest { + /** + * Rolls a FRESH sender/engine (never published-to raw watermark starts at -1), not + * the already-connected one that produced {@code fsn1}: {@code rollFsnEpochBase}'s + * precondition forbids rolling while a live loop is attached (see its javadoc), and + * -- independent of that -- an already-connected sender's engine keeps its raw + * watermark across the roll, which would make this test pass even with the + * translation deleted (raw {@code ackedFsn() == fsn1 >= fsn1} regardless of any + * epoch math). Only a genuinely fresh engine (raw {@code ackedFsn() == -1}) makes + * the pre-roll short-circuit the ONLY way {@code awaitAckedFsn(fsn1, 0)} can return + * true here. + */ @Test public void testPreRollTargetAnswersTrueAfterRoll() throws Exception { try (TestWebSocketServer server = ackingServer()) { - try (QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { - sender.table("t").longColumn("v", 1L).atNow(); - long fsn1 = sender.flushAndGetSequence(); + long fsn1; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.table("t").longColumn("v", 1L).atNow(); + fsn1 = sender1.flushAndGetSequence(); Assert.assertTrue("setup: the batch must actually be acked before the roll", - sender.drain(5_000)); - - sender.rollFsnEpochBaseForTest(fsn1); + sender1.drain(5_000)); + } + QwpWebSocketSender sender2 = createRolledSender(server, fsn1); + try { long t0 = System.nanoTime(); Assert.assertTrue("a target FSN from a pre-recycle epoch must be reported acked " + "immediately -- it was proven acked before the swap", - sender.awaitAckedFsn(fsn1, 0)); + sender2.awaitAckedFsn(fsn1, 0)); long elapsedMs = (System.nanoTime() - t0) / 1_000_000; Assert.assertTrue("must short-circuit, not poll: took " + elapsedMs + "ms", elapsedMs < 200); + } finally { + sender2.close(); } } } @@ -86,28 +107,33 @@ public void testPreRollTargetAnswersTrueAfterRoll() throws Exception { @Test public void testPostRollSequencesExceedAllPreRoll() throws Exception { try (TestWebSocketServer server = ackingServer()) { - try (QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { - sender.table("t").longColumn("v", 1L).atNow(); - long fsn1 = sender.flushAndGetSequence(); - Assert.assertTrue(sender.drain(5_000)); + long fsn1; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.table("t").longColumn("v", 1L).atNow(); + fsn1 = sender1.flushAndGetSequence(); + Assert.assertTrue(sender1.drain(5_000)); + } - sender.rollFsnEpochBaseForTest(fsn1); - long newBase = sender.getFsnEpochBaseForTest(); + QwpWebSocketSender sender2 = createRolledSender(server, fsn1); + try { + long newBase = sender2.getFsnEpochBaseForTest(); Assert.assertEquals(fsn1 + 1, newBase); - sender.table("t").longColumn("v", 2L).atNow(); - long fsn2 = sender.flushAndGetSequence(); - Assert.assertTrue(sender.drain(5_000)); + sender2.table("t").longColumn("v", 2L).atNow(); + long fsn2 = sender2.flushAndGetSequence(); + Assert.assertTrue(sender2.drain(5_000)); Assert.assertTrue("post-roll FSN must exceed every pre-roll FSN: fsn2=" + fsn2 + " fsn1=" + fsn1, fsn2 > fsn1); - // Raw FSNs increment by 1 per single-row flush, so the raw watermark right - // after this second flush is fsn1+1 (the same raw value the roll consumed - // plus one) -- fsn2 must be the epoch base plus that raw value, not just - // greater than fsn1 (a bug that dropped the translation entirely would still - // pass the ">" check above because the underlying raw engine never resets). - Assert.assertEquals(newBase + fsn1 + 1, fsn2); + // sender2's engine is genuinely fresh (raw publishedFsn() starts at -1), so + // its first-ever flush publishes raw 0. The exact-equality check is strictly + // stronger than ">" alone: it also catches an off-by-one in the roll formula + // (e.g. fsnEpochBase += lastPublishedFsn instead of + 1L), which the ">" + // check above would not. + Assert.assertEquals(newBase, fsn2); + } finally { + sender2.close(); } } } @@ -168,12 +194,12 @@ public void testDrainAfterRollWaitsForNewFrames() throws Exception { server.start(); Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); - QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg(server)); + // Roll well past the raw FSNs this fresh engine will ever publish, so a + // missing translation in drain() would make its raw target look pre-roll. + // Must roll before the sender's first connect (see rollFsnEpochBase's + // precondition: cursorSendLoop must be null). + QwpWebSocketSender sender = createRolledSender(server, 999L); try { - // Roll well past the raw FSNs this fresh engine will ever publish, so a - // missing translation in drain() would make its raw target look pre-roll. - sender.rollFsnEpochBaseForTest(999L); - sender.table("foo").longColumn("v", 1L).atNow(); boolean drainedEarly = sender.drain(200); Assert.assertFalse("drain() must not spuriously report the new frame acked just " @@ -289,26 +315,31 @@ public void testProgressStreamMonotoneAcrossRoll() throws Exception { @Test public void testLatchedErrorStillThrowsForOldEpochTarget() throws Exception { + // Acks the first frame it ever receives (from sender1, producing fsn1), then + // terminal-NACKs everything after (sender2's frame, on its own fresh connection). AckFirstThenTerminalNackHandler handler = new AckFirstThenTerminalNackHandler(); try (TestWebSocketServer server = new TestWebSocketServer(handler)) { server.start(); Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); - QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg(server)); - try { - sender.table("foo").longColumn("v", 1L).atNow(); - long fsn1 = sender.flushAndGetSequence(); - Assert.assertTrue(sender.drain(5_000)); - - sender.rollFsnEpochBaseForTest(fsn1); + long fsn1; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.table("foo").longColumn("v", 1L).atNow(); + fsn1 = sender1.flushAndGetSequence(); + Assert.assertTrue(sender1.drain(5_000)); + } - sender.table("foo").longColumn("v", 2L).atNow(); - sender.flush(); - waitFor(() -> sender.getLastTerminalError() != null, 5_000); + // Roll before sender2's first connect (rollFsnEpochBase's precondition), then + // force sender2's own loop to latch a terminal via the handler's NACK. + QwpWebSocketSender sender2 = createRolledSender(server, fsn1); + try { + sender2.table("foo").longColumn("v", 2L).atNow(); + sender2.flush(); + waitFor(() -> sender2.getLastTerminalError() != null, 5_000); LineSenderException thrown = null; try { - boolean acked = sender.awaitAckedFsn(fsn1, 0); + boolean acked = sender2.awaitAckedFsn(fsn1, 0); Assert.fail("awaitAckedFsn must throw on a latched terminal error, but " + "returned " + acked); } catch (LineSenderException e) { @@ -319,7 +350,7 @@ public void testLatchedErrorStillThrowsForOldEpochTarget() throws Exception { + "pre-roll short-circuit", thrown); } finally { try { - sender.close(); + sender2.close(); } catch (LineSenderException ignored) { } } From f9e986f0705c25e7439af4a15d69f915bb3f5291 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:43:29 +0100 Subject: [PATCH 08/49] Recycle the send stack at the empty-backlog barrier Implement the table() barrier hook and recycleForDictReset() swap: once the symbol-dictionary recycle is armed (Task 3) and the ring is proven drained, table() tears the cursor I/O loop and engine down, rolls the FSN epoch base (Task 4), replaces the producer's symbol dictionary, and rebuilds the engine via the Task 1 EngineRebuildFactory before reconnecting -- all synchronously inside a single call. A failed rebuild latches recycleFailure as a terminal state: every frame existed before the swap was already proven acked, so no data is at risk, but the sender that observed the torn-down engine/loop refuses further use. checkRecycleFailure() covers table() and the flush-family entry points (flush, flushAndGetSequence, drain, awaitAckedFsn) -- deliberately not close(), which must still be able to tear down a latched sender. --- .../qwp/client/QwpWebSocketSender.java | 185 ++++++++ .../qwp/client/SymbolDictRecycleTest.java | 448 ++++++++++++++++++ 2 files changed, 633 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 25335076..bc4f2e45 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -431,6 +431,17 @@ public interface EngineRebuildFactory { // opportunistic-wait step sets it once it has waited out its window for // THIS arm cycle, so a subsequent forced-wait check does not re-wait. private boolean starvationWaitDoneThisArm; + // Set (once) by recycleForDictReset's catch block when the recycle swap + // itself fails -- everything was acked before the swap tore the old + // engine down, so no data is at risk, but this sender can no longer make + // progress (no cursor engine, no I/O loop) and refuses further use. + // checkRecycleFailure() rethrows a fresh LineSenderException wrapping + // this cause on every later table()/flush-family call; close() still + // works normally. + private Throwable recycleFailure; + // Incremented once per completed symbol-dictionary recycle. 0 until the + // first recycle commits. + private long symbolDictEpoch; private long reconnectInitialBackoffMillis = CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS; private long reconnectMaxBackoffMillis = @@ -1013,6 +1024,7 @@ public void atNow() { @Override public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { checkNotClosed(); + checkRecycleFailure(); if (cursorEngine == null) { return targetFsn < 0L; } @@ -1692,6 +1704,7 @@ public QwpWebSocketSender floatColumn(CharSequence columnName, float value) { */ @Override public void flush() { + checkRecycleFailure(); flushAndGetSequence(); } @@ -1710,6 +1723,7 @@ public void flush() { @Override public long flushAndGetSequence() { checkNotClosed(); + checkRecycleFailure(); if (cursorEngine != null) { cursorEngine.checkDurability(); } @@ -1770,6 +1784,7 @@ public long flushAndGetSequence() { */ @Override public boolean drain(long timeoutMillis) { + checkRecycleFailure(); flush(); long targetRaw = cursorEngine != null ? cursorEngine.publishedFsn() : -1L; long targetFsn = targetRaw < 0 ? targetRaw : fsnEpochBase + targetRaw; @@ -2108,6 +2123,15 @@ public long getFsnEpochBaseForTest() { return fsnEpochBase; } + /** + * Number of symbol-dictionary recycles this sender has completed. + * Incremented by {@link #recycleForDictReset()} once the swap commits. + */ + @TestOnly + public long getSymbolDictEpochForTest() { + return symbolDictEpoch; + } + /** * Test-only entry point for {@link #rollFsnEpochBase}, the same private * roll the symbol-dict recycle swap calls in production once the engine @@ -2920,6 +2944,10 @@ public QwpWebSocketSender symbol(CharSequence columnName, CharSequence value) { @Override public QwpWebSocketSender table(CharSequence tableName) { checkNotClosed(); + checkRecycleFailure(); + if (resetArmed) { + maybeRecycleForDictReset(); + } // Fast path: if table name matches current, skip hashmap lookup if (currentTableName != null && currentTableBuffer != null && Chars.equals(tableName, currentTableName)) { return this; @@ -3622,6 +3650,24 @@ private void checkNotClosed() { checkConnectionError(); } + /** + * Terminal latch for a failed symbol-dictionary recycle swap + * ({@link #recycleForDictReset()}). Everything was acked before the swap + * tore the old engine down, so no data is at risk -- but the swap itself + * left this sender without a cursor engine or I/O loop, so it refuses + * further use. Checked by {@link #table(CharSequence)} and the + * flush-family entry points ({@link #flush()}, {@link #flushAndGetSequence()}, + * {@link #drain(long)}, {@link #awaitAckedFsn(long, long)}) -- deliberately + * NOT by {@link #close()}, which must still be able to tear down a + * latched sender. + */ + private void checkRecycleFailure() { + if (recycleFailure != null) { + throw new LineSenderException(recycleFailure) + .put("sender is terminal: symbol dictionary recycle failed"); + } + } + private void checkTableSelected() { if (currentTableBuffer == null) { throw new LineSenderException("table() must be called before adding columns"); @@ -4494,6 +4540,145 @@ private void armIfEligible() { resetArmed = shouldArm; } + /** + * True once every FSN this engine has published has also been + * server-acknowledged (or nothing has been published yet). The barrier + * {@link #recycleForDictReset()} waits for: the swap tears the cursor + * engine down, so it must never run while a frame is still in flight. + *

+ * Read order matters: {@code publishedFsn} (producer-written, cannot move + * during this call -- we ARE the producer) first, then {@code ackedFsn} + * (monotone, I/O-thread-written) second. Reading them in the other order + * could observe a published advance without its matching ack and falsely + * report drained. + */ + private boolean isRingDrained() { + long published = cursorEngine.publishedFsn(); + return published < 0 || cursorEngine.ackedFsn() >= published; + } + + /** + * Placeholder for the opportunistic/forced starvation-wait policy a later + * task fills in: when the ring is NOT drained at arming time, this + * decides whether to wait out an idle window before forcing the recycle + * regardless. No-op here -- an armed sender with a non-empty backlog + * simply stays armed and re-checks on the next {@link #table(CharSequence)} + * call. + */ + private void maybeBlockForStarvedReset() { + } + + /** + * Evaluates whether the barrier in {@link #table(CharSequence)} may run + * the symbol-dictionary recycle right now. Only ever called with + * {@link #resetArmed} true. + *

+ * Refuses when there is producer-side state the swap cannot safely tear + * down: no connection yet (a V4 sender that has never sent is never + * pre-connected here), a flush in flight ({@code pendingRowCount != 0}), + * or a row under construction. Otherwise proceeds to the ring-drained + * check: if the backlog is empty, recycle immediately; if not, defer to + * the (later-task) starvation-wait policy instead of blocking the caller + * indefinitely here. + */ + private void maybeRecycleForDictReset() { + if (!connected + || pendingRowCount != 0 + || (currentTableBuffer != null && currentTableBuffer.hasInProgressRow())) { + return; + } + if (isRingDrained()) { + recycleForDictReset(); + } else { + maybeBlockForStarvedReset(); + } + } + + /** + * The symbol-dictionary recycle swap. Runs synchronously on the producer + * thread from the {@link #table(CharSequence)} barrier, once + * {@link #maybeRecycleForDictReset()} has proven the ring is drained. + * Eight steps, strictly ordered: + *

    + *
  1. Snapshot the outgoing epoch's last published (raw) FSN.
  2. + *
  3. Close and null the cursor I/O loop -- joins the I/O thread and + * closes the WebSocket client.
  4. + *
  5. Fully-drained close of the outgoing cursor engine. Everything was + * proven acked by the barrier, so {@code close()} (== {@code close(true)}) + * takes the reclaim branch: it empties the slot AND unlinks the + * parent-anchored logical slot lock (see {@code CursorSendEngine.close}'s + * javadoc) -- this sender holds no other lock on the slot at this + * point, so releasing it here is safe.
  6. + *
  7. Roll {@link #fsnEpochBase} past every FSN the outgoing epoch ever + * handed out. Must run with {@code cursorSendLoop == null} (step 2 + * already guarantees this -- see {@link #rollFsnEpochBase}'s + * precondition).
  8. + *
  9. Producer-side state swap: a fresh {@link GlobalSymbolDictionary} + * (replaced, not cleared -- nothing else retains the old instance), + * both symbol-id watermarks reset, the epoch counter advanced, and + * the arming flags consumed.
  10. + *
  11. Rebuild the cursor engine on the now-empty slot via + * {@link #engineRebuildFactory}, the identical construct path + * {@code Sender.build()} uses. {@code deltaDictEnabled} is re-derived + * from the fresh engine, mirroring (not calling) {@link #setCursorEngine} + * -- that method's guards refuse a second engine.
  12. + *
  13. Reconnect: {@link #ensureConnected()} builds a fresh I/O loop + * against the rolled {@link #fsnEpochBase}.
  14. + *
+ * A throw at any step is caught, latches {@link #recycleFailure} (step 8), + * and rethrows: every frame that existed before this call was already + * proven acked, so no data is at risk, but the sender that made the throw + * observe a torn-down engine/loop refuses further use from here on -- + * {@link #checkRecycleFailure()} enforces that at every later + * {@link #table(CharSequence)} and flush-family call. + */ + private void recycleForDictReset() { + final long lastPublishedFsn = cursorEngine.publishedFsn(); // step 1 + final int dictSizeAtSwap = globalSymbolDictionary.size(); + final long startNanos = System.nanoTime(); + try { + // step 2: close the loop - joins the I/O thread, closes the client + if (cursorSendLoop != null) { + cursorSendLoop.close(); + cursorSendLoop = null; + } + client = null; + // step 3: fully-drained close of the engine - empties the slot. + // Holds NO logical slot lock here: close(true) unlinks the logical + // lock file (CursorSendEngine close javadoc). + cursorEngine.close(); + cursorEngine = null; + // step 4: roll the external FSN base (-1 no-publish case adds 0) + rollFsnEpochBase(lastPublishedFsn); + // step 5: producer state swap - replace, don't clear() + globalSymbolDictionary = new GlobalSymbolDictionary(); + sentMaxSymbolId = -1; + currentBatchMaxSymbolId = -1; + symbolDictEpoch++; + resetArmed = false; + manualResetRequested = false; + // step 6: rebuild the engine on the now-empty slot + cursorEngine = engineRebuildFactory.rebuild(); + ownsCursorEngine = true; + deltaDictEnabled = cursorEngine.isDeltaDictEnabled(); + // step 7: reconnect - rebuilds the loop with the rolled base + connected = false; + ensureConnected(); + LOG.info("symbol dictionary recycled [epoch={}, dictSizeAtSwap={}, pauseMicros={}]", + symbolDictEpoch, dictSizeAtSwap, (System.nanoTime() - startNanos) / 1000L); + } catch (Throwable t) { + // step 8: terminal latch - everything was acked before step 2, + // so no data is at risk; the sender refuses further use. + recycleFailure = t; + LOG.error("symbol dictionary recycle failed; sender is now terminal " + + "[epoch={}, dictSizeAtSwap={}]", symbolDictEpoch, dictSizeAtSwap, t); + if (t instanceof LineSenderException) { + throw (LineSenderException) t; + } + throw new LineSenderException(t).put("symbol dictionary recycle failed"); + } + } + /** * Sends an empty QWP message without FLAG_DEFER_COMMIT to trigger * the server-side commit of all previously deferred rows. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java new file mode 100644 index 00000000..9c08192b --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -0,0 +1,448 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.std.Files; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * The symbol-dictionary recycle swap ({@code QwpWebSocketSender.table()}'s + * barrier hook + {@code recycleForDictReset()}): tears the cursor engine and + * I/O loop down once the ring is proven drained, replaces the producer's + * global symbol dictionary, and rebuilds the engine on the same (now-empty) + * slot before reconnecting -- all synchronously inside a single + * {@code table()} call. + */ +public class SymbolDictRecycleTest { + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + @Test + public void testRecycleAtEmptyBacklog() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("recycle-empty-backlog").toString(); + RecycleHandler handler = new RecycleHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + Assert.assertEquals(1, handler.connectionsAccepted.get()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // The ring is drained (everything acked) and no row is in + // progress, so this table() call must recycle synchronously. + // ensureConnected() performs the fresh WebSocket handshake + // synchronously as part of the swap, before any data is sent -- + // handshakeCount (not the handler's own onBinaryMessage-driven + // counter, which only advances once a frame actually arrives) + // observes that handshake immediately. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle batch must still get acked", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN " + + "[fsn1=" + fsn1 + ", fsn2=" + fsn2 + ']', + fsn2 > fsn1); + } + + Assert.assertEquals("exactly 2 connections total", 2, handler.connectionsAccepted.get()); + Assert.assertEquals("connection 2's first data frame must carry deltaStart == 0 " + + "(a fresh, empty dictionary)", + 0, handler.conn2FirstFrameDeltaStart); + Assert.assertEquals("connection 2's dictionary must hold only the post-recycle " + + "symbols, not a, b", + Arrays.asList("c", "d"), handler.dictFor(2)); + } + }); + } + + @Test + public void testPostRecycleSlotContents() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("recycle-slot-contents").toString(); + String slot = Paths.get(sfDir, "default").toString(); + try (TestWebSocketServer server = ackingServer()) { + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue(ws.isResetArmed()); + + CursorSendEngine before = ws.getCursorEngineForTesting(); + + // Synchronous swap: by the time table() returns, the old engine + // is gone and a fresh one is rebuilt and reconnected. Asserting + // right here needs no polling -- there is no window to race. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + + CursorSendEngine after = ws.getCursorEngineForTesting(); + Assert.assertNotSame("recycle must swap in a fresh engine instance", + before, after); + Assert.assertTrue("the rebuilt engine must create its own initial segment", + Files.exists(slot + "/sf-initial.sfa")); + Assert.assertEquals("post-recycle dictionary must start empty, not continue " + + "the outgoing epoch's 2 entries", + 0, after.getPersistedSymbolDict().size()); + + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + + // The new epoch's persisted dictionary holds exactly c, d -- + // proof it is a genuinely fresh dictionary, not a, b continued. + Assert.assertEquals("post-recycle dictionary must hold only the new epoch's " + + "symbols", + 2, after.getPersistedSymbolDict().size()); + } + } + }); + } + + @Test + public void testRecycleUnderDurableAck() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("recycle-durable-ack").toString(); + DurableAckHandler handler = new DurableAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;request_durable_ack=on;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: the batch must be DURABLY acked before the recycle " + + "-- isRingDrained() reads the durable-ack-gated watermark", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue(ws.isResetArmed()); + Assert.assertEquals(1, handler.connectionsAccepted.get()); + + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); + + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle batch must still get durably acked on the " + + "fresh connection", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue(fsn2 > fsn1); + } + } + }); + } + + /** + * Exercises the same code path {@code engineRebuildFactory.rebuild()} calls + * in production ({@code LineSenderBuilder.constructEngineOnSlot}) -- that + * method is package-private to {@code io.questdb.client} and unreachable + * directly from this package, so this observes its result through the + * sender's own {@code @TestOnly} engine accessor instead of calling it in + * isolation. + */ + @Test + public void testFactoryRebuildsOnEmptySlot() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("recycle-factory-rebuild").toString(); + try (TestWebSocketServer server = ackingServer()) { + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue(ws.isResetArmed()); + + sender.table("t"); + + CursorSendEngine rebuilt = ws.getCursorEngineForTesting(); + Assert.assertTrue("a freshly rebuilt slot must support delta encoding", + rebuilt.isDeltaDictEnabled()); + Assert.assertFalse("a freshly emptied slot has nothing to recover", + rebuilt.wasRecoveredFromDisk()); + Assert.assertEquals(-1L, rebuilt.recoveredMaxSymbolId()); + Assert.assertEquals("a freshly rebuilt engine has published nothing yet", + -1L, rebuilt.publishedFsn()); + } + } + }); + } + + /** + * A step-6 rebuild failure (the {@link QwpWebSocketSender.EngineRebuildFactory} + * itself throwing) must latch the sender terminal rather than leaving it in + * the torn-down state the swap's earlier steps produced. Uses + * {@code setEngineRebuildFactory} directly to inject the fault -- the real + * factory has no seam for a custom {@code FilesFacade} (it always goes + * through {@code LineSenderBuilder.constructEngineOnSlot} against the real + * filesystem), and this isolates the exception-path behaviour under test + * from any particular failure cause. + */ + @Test + public void testFailedRebuildLatchesTerminal() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(cfg(server))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + RuntimeException fault = new RuntimeException("injected engine rebuild fault"); + ws.setEngineRebuildFactory(() -> { + throw fault; + }); + + // pendingRowCount == 0 -> resetSymbolDictionary() arms immediately. + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + + LineSenderException triggering = null; + try { + sender.table("t"); + Assert.fail("expected the triggering table() call to throw"); + } catch (LineSenderException e) { + triggering = e; + } + Assert.assertNotNull(triggering); + Assert.assertSame("the latched failure must be the exact rebuild fault", + fault, triggering.getCause()); + + // Every subsequent table()/flush-family call must rethrow -- + // never touch the torn-down (null cursorEngine/cursorSendLoop) state. + assertRethrowsWithCause(fault, () -> sender.table("t")); + assertRethrowsWithCause(fault, sender::flush); + assertRethrowsWithCause(fault, sender::flushAndGetSequence); + assertRethrowsWithCause(fault, () -> sender.drain(0)); + assertRethrowsWithCause(fault, () -> sender.awaitAckedFsn(0, 0)); + + // close() must still work despite the latched terminal failure. + sender.close(); + } + } + }); + } + + private static TestWebSocketServer ackingServer() throws Exception { + TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + private static void assertRethrowsWithCause(Throwable expectedCause, ThrowingRunnable action) + throws Exception { + try { + action.run(); + Assert.fail("expected a latched LineSenderException to rethrow"); + } catch (LineSenderException e) { + Assert.assertSame("a latched terminal sender must keep rethrowing the same cause", + expectedCause, e.getCause()); + } + } + + private static String cfg(TestWebSocketServer server) { + return "ws::addr=localhost:" + server.getPort() + ";"; + } + + private static byte[] buildDurableAckFrame(String tableName, long seqTxn) { + byte[] name = tableName.getBytes(StandardCharsets.UTF_8); + ByteBuffer bb = ByteBuffer.allocate(1 + 2 + 2 + name.length + 8).order(ByteOrder.LITTLE_ENDIAN); + bb.put((byte) 0x02); // STATUS_DURABLE_ACK + bb.putShort((short) 1); // tableCount + bb.putShort((short) name.length); + bb.put(name); + bb.putLong(seqTxn); + return bb.array(); + } + + private static byte[] buildOkFrame(String tableName, long wireSeq, long seqTxn) { + byte[] name = tableName.getBytes(StandardCharsets.UTF_8); + ByteBuffer bb = ByteBuffer.allocate(1 + 8 + 2 + 2 + name.length + 8).order(ByteOrder.LITTLE_ENDIAN); + bb.put((byte) 0x00); // STATUS_OK + bb.putLong(wireSeq); + bb.putShort((short) 1); // tableCount + bb.putShort((short) name.length); + bb.put(name); + bb.putLong(seqTxn); + return bb.array(); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } + + /** ACKs every frame it receives; does not otherwise inspect the wire. */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Immediately follows every OK ack with a durable ack for the same + * transaction, so a durable-ack-mode sender's {@code ackedFsn} advances + * without a separate release phase. Counters reset per connection, since + * the recycle's fresh loop restarts its own wire sequence at 0. + */ + private static class DurableAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private static final String TABLE_NAME = "t"; + final AtomicInteger connectionsAccepted = new AtomicInteger(); + private TestWebSocketServer.ClientHandler currentClient; + private long nextSeqTxn; + private long nextWireSeq; + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (currentClient != client) { + currentClient = client; + connectionsAccepted.incrementAndGet(); + nextWireSeq = 0; + nextSeqTxn = 0; + } + try { + long wireSeq = nextWireSeq++; + long seqTxn = nextSeqTxn++; + client.sendBinary(buildOkFrame(TABLE_NAME, wireSeq, seqTxn)); + client.sendBinary(buildDurableAckFrame(TABLE_NAME, seqTxn)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Reconstructs each connection's per-connection delta dictionary (mirrors + * {@code DeltaDictCatchUpTest.CatchUpHandler}) and records the delta-start + * id of connection 2's first non-empty data frame. + */ + private static class RecycleHandler implements TestWebSocketServer.WebSocketServerHandler { + final AtomicInteger connectionsAccepted = new AtomicInteger(); + volatile int conn2FirstFrameDeltaStart = -1; + private boolean conn2SeenFirstDataFrame; + private TestWebSocketServer.ClientHandler currentClient; + private final List> dictsByConn = new CopyOnWriteArrayList<>(); + private final AtomicLong nextSeq = new AtomicLong(0); + + synchronized List dictFor(int connNumber) { + return connNumber <= dictsByConn.size() + ? new ArrayList<>(dictsByConn.get(connNumber - 1)) + : new ArrayList<>(); + } + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + boolean newConnection = currentClient != client; + if (newConnection) { + currentClient = client; + connectionsAccepted.incrementAndGet(); + dictsByConn.add(new ArrayList<>()); + nextSeq.set(0); + conn2SeenFirstDataFrame = false; + } + int connNumber = dictsByConn.size(); + List dict = dictsByConn.get(connNumber - 1); + QwpWireTestUtils.accumulateDeltaDictionary(data, dict); + if (connNumber == 2 && !conn2SeenFirstDataFrame && QwpWireTestUtils.tableCount(data) > 0) { + conn2SeenFirstDataFrame = true; + if (QwpWireTestUtils.hasDelta(data)) { + int[] pos = {HEADER_SIZE}; + conn2FirstFrameDeltaStart = QwpWireTestUtils.readVarint(data, pos); + } + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} From a2a12400346c395ac113648e3a000ee1d01a84a5 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:13:13 +0100 Subject: [PATCH 09/49] Guard the recycle swap against an unrebuildable engine Fix round 1 from review: - maybeRecycleForDictReset() now refuses before any teardown when engineRebuildFactory is null (every public connect() overload leaves it unset -- only Sender.build() installs one) or the cursor engine isn't owned by this sender (setCursorEngine(engine, false)'s contract). Without this, a connect()-built sender with the default-on recycle feature armed (via resetSymbolDictionary() or a threshold crossing) would reach step 6, NPE against the null factory, and latch itself terminal for no reason. - Step 5 also resets lastCommitBoundaryFsn -- it held a raw old-epoch FSN that does not survive the roll. - checkRecycleFailure() now also guards sendRow(), closing the fluent-chain corner where a caller continues .symbol(...).atNow() against a currentTableBuffer selected before the latch, without an intervening table() call. - Step 6 rewires cursorEngine.setSlotLockReleaseListener(...) on the rebuilt engine, restoring the pool early-wakeup notification that bypassing setCursorEngine had dropped. - testPostRecycleSlotContents now asserts the post-recycle slot directory listing against the exact expected fresh-state file set, replacing a bare Files.exists(...) check that proved nothing (the outgoing engine had a same-named file too). - New testConnectBuiltSenderNeverRecyclesWithoutFactory covers both ways such a sender can arm (manual request, threshold crossing): neither may recycle, throw, or stop the sender from working. --- .../qwp/client/QwpWebSocketSender.java | 43 ++++-- .../qwp/client/SymbolDictRecycleTest.java | 128 +++++++++++++++++- 2 files changed, 158 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index bc4f2e45..bb6ca70c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -3655,11 +3655,14 @@ private void checkNotClosed() { * ({@link #recycleForDictReset()}). Everything was acked before the swap * tore the old engine down, so no data is at risk -- but the swap itself * left this sender without a cursor engine or I/O loop, so it refuses - * further use. Checked by {@link #table(CharSequence)} and the - * flush-family entry points ({@link #flush()}, {@link #flushAndGetSequence()}, - * {@link #drain(long)}, {@link #awaitAckedFsn(long, long)}) -- deliberately - * NOT by {@link #close()}, which must still be able to tear down a - * latched sender. + * further use. Checked by {@link #table(CharSequence)}, the flush-family + * entry points ({@link #flush()}, {@link #flushAndGetSequence()}, + * {@link #drain(long)}, {@link #awaitAckedFsn(long, long)}), and + * {@code sendRow()} (closing the fluent-chain corner where a caller + * continues {@code .symbol(...).atNow()} against a {@code currentTableBuffer} + * selected before the latch, without an intervening {@code table()} call) + * -- deliberately NOT by {@link #close()}, which must still be able to + * tear down a latched sender. */ private void checkRecycleFailure() { if (recycleFailure != null) { @@ -4573,8 +4576,18 @@ private void maybeBlockForStarvedReset() { * the symbol-dictionary recycle right now. Only ever called with * {@link #resetArmed} true. *

- * Refuses when there is producer-side state the swap cannot safely tear - * down: no connection yet (a V4 sender that has never sent is never + * Refuses before any teardown when the rebuild itself is impossible or + * unsafe: no {@link #engineRebuildFactory} (every public + * {@code QwpWebSocketSender.connect(...)} overload leaves it null -- + * only {@code Sender.build()} installs one -- and the recycle feature is + * default-on, so a connect()-built sender must simply stay unarmed rather + * than NPE at step 6 and latch terminal), or a cursor engine this sender + * does not own ({@code setCursorEngine(engine, false)}'s contract: the + * caller retains ownership, so closing it out from under them at step 3 + * would be a use-after-free from the caller's point of view). + *

+ * Also refuses when there is producer-side state the swap cannot safely + * tear down: no connection yet (a V4 sender that has never sent is never * pre-connected here), a flush in flight ({@code pendingRowCount != 0}), * or a row under construction. Otherwise proceeds to the ring-drained * check: if the backlog is empty, recycle immediately; if not, defer to @@ -4582,6 +4595,9 @@ private void maybeBlockForStarvedReset() { * indefinitely here. */ private void maybeRecycleForDictReset() { + if (engineRebuildFactory == null || !ownsCursorEngine) { + return; + } if (!connected || pendingRowCount != 0 || (currentTableBuffer != null && currentTableBuffer.hasInProgressRow())) { @@ -4615,13 +4631,15 @@ private void maybeRecycleForDictReset() { * precondition). *

  • Producer-side state swap: a fresh {@link GlobalSymbolDictionary} * (replaced, not cleared -- nothing else retains the old instance), - * both symbol-id watermarks reset, the epoch counter advanced, and - * the arming flags consumed.
  • + * both symbol-id watermarks reset, {@code lastCommitBoundaryFsn} + * reset (it held a raw old-epoch FSN that does not survive the + * roll), the epoch counter advanced, and the arming flags consumed. *
  • Rebuild the cursor engine on the now-empty slot via * {@link #engineRebuildFactory}, the identical construct path * {@code Sender.build()} uses. {@code deltaDictEnabled} is re-derived - * from the fresh engine, mirroring (not calling) {@link #setCursorEngine} - * -- that method's guards refuse a second engine.
  • + * from the fresh engine and its slot-lock-release listener rewired, + * mirroring (not calling) {@link #setCursorEngine} -- that method's + * guards refuse a second engine. *
  • Reconnect: {@link #ensureConnected()} builds a fresh I/O loop * against the rolled {@link #fsnEpochBase}.
  • * @@ -4654,6 +4672,7 @@ private void recycleForDictReset() { globalSymbolDictionary = new GlobalSymbolDictionary(); sentMaxSymbolId = -1; currentBatchMaxSymbolId = -1; + lastCommitBoundaryFsn = -1L; symbolDictEpoch++; resetArmed = false; manualResetRequested = false; @@ -4661,6 +4680,7 @@ private void recycleForDictReset() { cursorEngine = engineRebuildFactory.rebuild(); ownsCursorEngine = true; deltaDictEnabled = cursorEngine.isDeltaDictEnabled(); + cursorEngine.setSlotLockReleaseListener(this::onSlotLockReleased); // step 7: reconnect - rebuilds the loop with the rolled base connected = false; ensureConnected(); @@ -5324,6 +5344,7 @@ private void sealAndSwapBuffer() { * Rows buffer until flush (explicit or auto-flush). */ private void sendRow() { + checkRecycleFailure(); ensureConnected(); // Hard guard: a single row whose bytes exceed the server's wire cap diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index 9c08192b..e1c6f0ff 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -28,6 +28,9 @@ import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderConnectionDispatcher; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher; import io.questdb.client.std.Files; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; import org.junit.Assert; @@ -42,6 +45,7 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; @@ -121,6 +125,95 @@ public void testRecycleAtEmptyBacklog() throws Exception { }); } + /** + * {@code engineRebuildFactory} is only installed by {@code Sender.build()} + * ({@code Sender.java:1752}) -- every public {@code QwpWebSocketSender.connect(...)} + * overload leaves it null. Since the recycle feature is default-on and + * {@code resetSymbolDictionary()} is a public advisory API, a connect()-built + * sender can become "armed" with no way to ever act on it. + * {@code maybeRecycleForDictReset()} must refuse before any teardown in that + * case, not attempt step 6 and NPE into a latched terminal state -- covers + * both ways a sender can arm: the manual request and threshold crossing. + */ + @Test + public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + int port = server.getPort(); + + // Manual reset request on the simplest connect() overload. + try (QwpWebSocketSender sender = QwpWebSocketSender.connect("localhost", port)) { + sender.resetSymbolDictionary(); + Assert.assertTrue("a manual request arms immediately (no row/flush in flight)", + sender.isResetArmed()); + + // Drained instant (nothing published yet, no row in progress): with a + // real factory this table() call would recycle. With none installed it + // must simply do nothing and let the row through normally. + sender.table("t").longColumn("v", 1L).atNow(); + long fsn = sender.flushAndGetSequence(); + Assert.assertTrue("sender must keep working even though it can never recycle", + sender.awaitAckedFsn(fsn, 5_000)); + Assert.assertEquals("no factory -> the recycle can never actually run", + 0, sender.getSymbolDictEpochForTest()); + Assert.assertTrue("stays armed forever -- nothing ever consumes the request", + sender.isResetArmed()); + } + + // Threshold-based arming needs a custom low threshold, only reachable (without + // routing through Sender.build(), which WOULD install a factory) via the + // widest connect() overload -- mirrors SymbolDictRecycleArmingTest.testArmsInFullDictMode. + CursorSendEngine engine = new CursorSendEngine( + null, 4L * 1024 * 1024, 128L * 1024 * 1024, + CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS); + QwpWebSocketSender sender = QwpWebSocketSender.connect( + Collections.singletonList(new QwpWebSocketSender.Endpoint("localhost", port)), + null, // tlsConfig + 0, 0, 0L, // autoFlushRows, autoFlushBytes, autoFlushIntervalNanos + null, // authorizationHeader + false, // requestDurableAck + engine, + 5_000L, // closeFlushTimeoutMillis + CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS, + CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS, + CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_BACKOFF_MILLIS, + Sender.InitialConnectMode.OFF, + null, // errorHandler + SenderErrorDispatcher.DEFAULT_CAPACITY, + CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, + QwpWebSocketSender.DEFAULT_AUTH_TIMEOUT_MS, + 0, // connectTimeoutMs + null, // connectionListener + SenderConnectionDispatcher.DEFAULT_CAPACITY, + CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, + CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS, + CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS, + true, // symbolDictResetEnabled + 2, // symbolDictResetThresholdSymbols -- low, deliberately crossed below + QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS); + try { + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("threshold=2 crossed by a, b", sender.isResetArmed()); + + // Drained instant again: must not recycle, must not throw. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("sender must keep working with no factory installed", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals("no factory -> the recycle can never actually run", + 0, sender.getSymbolDictEpochForTest()); + Assert.assertTrue("stays armed -- nothing ever consumes the threshold arming", + sender.isResetArmed()); + } finally { + sender.close(); + } + } + }); + } + @Test public void testPostRecycleSlotContents() throws Exception { assertMemoryLeak(() -> { @@ -150,8 +243,17 @@ public void testPostRecycleSlotContents() throws Exception { CursorSendEngine after = ws.getCursorEngineForTesting(); Assert.assertNotSame("recycle must swap in a fresh engine instance", before, after); - Assert.assertTrue("the rebuilt engine must create its own initial segment", - Files.exists(slot + "/sf-initial.sfa")); + // A bare Files.exists(".../sf-initial.sfa") proves nothing on its own -- + // that name is fixed and the outgoing engine had one too. Prove the + // rebuilt slot's structure instead: exactly the well-known set of state + // files a brand-new (never-recovered) slot has, nothing left over from + // the outgoing epoch's segments. + List freshSlotFiles = Arrays.asList( + ".ack-watermark", ".lock", ".lock.pid", ".symbol-dict", + "sf-0000000000000000.sfa", "sf-initial.sfa", "sf-manifest.bin"); + Assert.assertEquals("post-recycle slot must contain exactly a fresh engine's " + + "own state files", + freshSlotFiles, listDir(slot)); Assert.assertEquals("post-recycle dictionary must start empty, not continue " + "the outgoing epoch's 2 entries", 0, after.getPersistedSymbolDict().size()); @@ -310,6 +412,28 @@ private static TestWebSocketServer ackingServer() throws Exception { return server; } + /** Sorted list of entry names directly inside {@code dir} (no recursion, no "."/".."). */ + private static List listDir(String dir) { + List names = new ArrayList<>(); + long find = Files.findFirst(dir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + names.add(name); + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Collections.sort(names); + return names; + } + private static void assertRethrowsWithCause(Throwable expectedCause, ThrowingRunnable action) throws Exception { try { From 91b2f66d8099e2235bf036fc64bd2d52eec6972b Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:27:31 +0100 Subject: [PATCH 10/49] Pin memory-mode recycle behavior Adds SymbolDictRecycleMemoryModeTest, the sf_dir-omitted counterpart of SymbolDictRecycleTest: threshold-triggered recycle at an empty backlog, a content oracle proving the epoch boundary loses (and duplicates) nothing acked, and the same recycle under initial_connect_retry=async. All three pass unmodified against the existing recycle swap -- zero production changes -- confirming the factory's slotPath == null arm, CursorSendEngine's file-less close, and the table() barrier are already mode-agnostic. --- .../SymbolDictRecycleMemoryModeTest.java | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java new file mode 100644 index 00000000..95981bb4 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java @@ -0,0 +1,337 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.std.Compat; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Memory-mode ({@code sf_dir} omitted) counterpart of {@link SymbolDictRecycleTest}. + *

    + * The recycle swap's eight steps ({@code QwpWebSocketSender.recycleForDictReset()}) + * were written against the store-and-forward slot lifecycle, but the factory's + * {@code slotPath == null} arm, {@code CursorSendEngine}'s file-less close, and the + * barrier itself are all mode-agnostic by construction -- nothing in + * {@code maybeRecycleForDictReset()} or the swap checks whether the sender is + * SF-backed. This suite pins that: every scenario {@code SymbolDictRecycleTest} + * proves for a disk-backed sender must hold identically for a {@code Sender.fromConfig} + * sender built with no {@code sf_dir} at all. No production change is expected to + * make these pass; a failure here means Task 5's swap accidentally gated something + * on store-and-forward being present. + */ +public class SymbolDictRecycleMemoryModeTest { + + @Test + public void testRecycleAtEmptyBacklog() throws Exception { + assertMemoryLeak(() -> { + RecycleHandler handler = new RecycleHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + // No sf_dir: memory mode. Everything else mirrors + // SymbolDictRecycleTest#testRecycleAtEmptyBacklog exactly. + String cfg = "ws::addr=localhost:" + port + ";symbol_dict_reset_threshold=2;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + Assert.assertEquals(1, handler.connectionsAccepted.get()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // Ring drained, no row in progress: this table() call must + // recycle synchronously, exactly as in SF mode. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle batch must still get acked", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN " + + "[fsn1=" + fsn1 + ", fsn2=" + fsn2 + ']', + fsn2 > fsn1); + } + + Assert.assertEquals("exactly 2 connections total", 2, handler.connectionsAccepted.get()); + Assert.assertEquals("connection 2's first data frame must carry deltaStart == 0 " + + "(a fresh, empty dictionary)", + 0, handler.conn2FirstFrameDeltaStart); + Assert.assertEquals("connection 2's dictionary must hold only the post-recycle " + + "symbols, not a, b", + Arrays.asList("c", "d"), handler.dictFor(2)); + } + }); + } + + /** + * Strengthens {@link #testRecycleAtEmptyBacklog} into a content oracle: every + * row before and after the recycle carries a distinct symbol value, and this + * asserts the server observed the FULL, exact, gap-free, duplicate-free + * sequence across both connections -- not just a spot check of the boundary + * frame. Proves the epoch swap loses (and doesn't duplicate) nothing that was + * ever acked, in memory mode exactly as {@code testPostRecycleSlotContents} + * proves the persisted-dictionary shape in SF mode. + */ + @Test + public void testRecycleLosesNothingAcked() throws Exception { + assertMemoryLeak(() -> { + RecycleHandler handler = new RecycleHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";symbol_dict_reset_threshold=2;"; + + List preRecycleSymbols = Arrays.asList("p0", "p1", "p2", "p3"); + List postRecycleSymbols = Arrays.asList("q0", "q1", "q2", "q3"); + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + for (String symbol : preRecycleSymbols) { + sender.table("t").symbol("s", symbol).longColumn("v", 1L).atNow(); + } + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: pre-recycle batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("threshold=2 crossed well before the 4th symbol", + ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // Ring drained: the FIRST post-recycle table() call recycles + // synchronously, then the row it is building lands on the + // fresh connection alongside the rest of postRecycleSymbols. + boolean first = true; + for (String symbol : postRecycleSymbols) { + sender.table("t").symbol("s", symbol).longColumn("v", 2L).atNow(); + if (first) { + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + first = false; + } + } + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle batch must still get acked", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue(fsn2 > fsn1); + } + + Assert.assertEquals("exactly 2 connections total", 2, handler.connectionsAccepted.get()); + Assert.assertEquals("connection 1 must have received exactly the pre-recycle symbols, " + + "in order, nothing lost or duplicated", + preRecycleSymbols, handler.dictFor(1)); + Assert.assertEquals("connection 2 must have received exactly the post-recycle symbols, " + + "in order, nothing lost or duplicated", + postRecycleSymbols, handler.dictFor(2)); + + List observedAcrossBothEpochs = new ArrayList<>(handler.dictFor(1)); + observedAcrossBothEpochs.addAll(handler.dictFor(2)); + List expectedAcrossBothEpochs = new ArrayList<>(preRecycleSymbols); + expectedAcrossBothEpochs.addAll(postRecycleSymbols); + Assert.assertEquals("the epoch boundary must lose (and not duplicate) nothing that " + + "was ever acked", + expectedAcrossBothEpochs, observedAcrossBothEpochs); + } + }); + } + + /** + * {@code initial_connect_retry=async} defers even the FIRST connect to the + * I/O thread ({@code ensureConnected()}'s {@code ASYNC} arm leaves + * {@code client == null} and lets {@code CursorWebSocketSendLoop} dial in the + * background) -- and {@code recycleForDictReset()}'s step 7 reconnect reuses + * the exact same {@code initialConnectMode} switch, so the post-recycle + * connection is ALSO dialled asynchronously rather than inline on the + * producer thread that called {@code table()}. Only the producer-side halves + * of the swap (steps 4-6: FSN epoch roll, dictionary swap, engine rebuild) + * are guaranteed synchronous by the time {@code table()} returns; the fresh + * handshake itself must be awaited separately here, unlike the SYNC-mode + * tests above where {@code server.handshakeCount()} is already correct the + * instant {@code table()} returns. + */ + @Test + public void testRecycleUnderAsyncInitialConnect() throws Exception { + assertMemoryLeak(() -> { + RecycleHandler handler = new RecycleHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + + ";initial_connect_retry=async;symbol_dict_reset_threshold=2;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + // Let the I/O thread complete the deferred initial connect + // before driving any traffic through it. Spins on the + // CLIENT-side sticky flag, not server.handshakeCount(): the + // server counts a handshake the moment IT finishes writing + // the upgrade response, which can observably precede the + // client processing that response and flipping + // wasEverConnected() -- polling the server-side counter as + // a proxy for client-side connectedness raced exactly that + // window when this test was first written. + awaitWasEverConnected(ws); + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + Assert.assertEquals(1, handler.connectionsAccepted.get()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // Ring drained: this table() call recycles synchronously on + // the producer thread for steps 1-6, but step 7's reconnect + // just re-arms the ASYNC path -- the actual handshake still + // happens on the I/O thread, so it must be awaited. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertFalse("recycle must disarm immediately (producer-side state, " + + "not gated on the wire)", + ws.isResetArmed()); + Assert.assertEquals("recycle must advance the epoch immediately (producer-side " + + "state, not gated on the wire)", + 1, ws.getSymbolDictEpochForTest()); + + // Don't gate on a connection counter here -- rows queue on + // the (memory-mode) cursor ring regardless of wire state in + // ASYNC mode, and awaitAckedFsn below is itself the + // deterministic wait for the fresh handshake to land. + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle batch must still get acked once the " + + "async I/O thread completes the fresh handshake", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue(fsn2 > fsn1); + // The ack above only arrives over a completed second + // handshake, so this is a safe post-condition, not a race. + Assert.assertEquals(2, handler.connectionsAccepted.get()); + } + + Assert.assertEquals("exactly 2 connections total", 2, handler.connectionsAccepted.get()); + Assert.assertEquals("connection 2's first data frame must carry deltaStart == 0 " + + "(a fresh, empty dictionary)", + 0, handler.conn2FirstFrameDeltaStart); + Assert.assertEquals("connection 2's dictionary must hold only the post-recycle " + + "symbols, not a, b", + Arrays.asList("c", "d"), handler.dictFor(2)); + } + }); + } + + /** + * Spins until the I/O thread has completed the deferred ASYNC initial + * connect. Needed only for the async test above: SYNC/OFF-mode recycle + * blocks {@code table()} until the fresh handshake completes, so those + * tests observe connectedness synchronously, but ASYNC mode hands the + * connect off to the I/O thread and returns control to the caller before + * it necessarily lands. + */ + private static void awaitWasEverConnected(QwpWebSocketSender ws) { + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!ws.wasEverConnected()) { + if (System.nanoTime() > deadlineNanos) { + throw new AssertionError("I/O thread did not complete the async initial " + + "connect within 5s"); + } + Compat.onSpinWait(); + } + } + + /** + * Reconstructs each connection's per-connection delta dictionary (mirrors + * {@code SymbolDictRecycleTest.RecycleHandler}) and records the delta-start + * id of connection 2's first non-empty data frame. + */ + private static class RecycleHandler implements TestWebSocketServer.WebSocketServerHandler { + final AtomicInteger connectionsAccepted = new AtomicInteger(); + volatile int conn2FirstFrameDeltaStart = -1; + private boolean conn2SeenFirstDataFrame; + private TestWebSocketServer.ClientHandler currentClient; + private final List> dictsByConn = new CopyOnWriteArrayList<>(); + private final AtomicLong nextSeq = new AtomicLong(0); + + synchronized List dictFor(int connNumber) { + return connNumber <= dictsByConn.size() + ? new ArrayList<>(dictsByConn.get(connNumber - 1)) + : new ArrayList<>(); + } + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + boolean newConnection = currentClient != client; + if (newConnection) { + currentClient = client; + connectionsAccepted.incrementAndGet(); + dictsByConn.add(new ArrayList<>()); + nextSeq.set(0); + conn2SeenFirstDataFrame = false; + } + int connNumber = dictsByConn.size(); + List dict = dictsByConn.get(connNumber - 1); + QwpWireTestUtils.accumulateDeltaDictionary(data, dict); + if (connNumber == 2 && !conn2SeenFirstDataFrame && QwpWireTestUtils.tableCount(data) > 0) { + conn2SeenFirstDataFrame = true; + if (QwpWireTestUtils.hasDelta(data)) { + int[] pos = {HEADER_SIZE}; + conn2FirstFrameDeltaStart = QwpWireTestUtils.readVarint(data, pos); + } + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} From 903f2f6d71423ba3542680a2954597f3cd21b354 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:57:04 +0100 Subject: [PATCH 11/49] Bounded blocking wait for a starved dictionary reset Fills the maybeBlockForStarvedReset() stub: when a symbol-dict recycle is armed but the ring is not yet drained, opportunistically waits (parked, awaitAckedFsn-shaped) up to symbol_dict_reset_max_wait_millis for the outstanding acks before giving up for this armed window. resetMaxWaitMillis<=0 disables the wait entirely; at most one blocking wait runs per armed window (starvationWaitDoneThisArm); an open deferred-commit group is never waited on, since the server withholds its acks by design until the closing commit lands and this producer thread is the only one that could ever send that commit -- blocking there would just run out the clock every time. A timeout increments the new symbolDictResetStarvationTimeouts counter and leaves the recycle armed so a later drained table() call can still fire it. Verified the deferred-commit guard is load-bearing by temporarily removing it and confirming the test fails (blocks the full deadline instead of returning immediately) before restoring it. --- .../qwp/client/QwpWebSocketSender.java | 65 ++- .../SymbolDictRecycleStarvationTest.java | 516 ++++++++++++++++++ 2 files changed, 575 insertions(+), 6 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index bb6ca70c..54dd828c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -431,6 +431,10 @@ public interface EngineRebuildFactory { // opportunistic-wait step sets it once it has waited out its window for // THIS arm cycle, so a subsequent forced-wait check does not re-wait. private boolean starvationWaitDoneThisArm; + // Incremented once per completed starvation wait that timed out without + // the backlog draining (maybeBlockForStarvedReset's deadline branch). 0 + // until the first such timeout. + private long symbolDictResetStarvationTimeouts; // Set (once) by recycleForDictReset's catch block when the recycle swap // itself fails -- everything was acked before the swap tore the old // engine down, so no data is at risk, but this sender can no longer make @@ -2132,6 +2136,15 @@ public long getSymbolDictEpochForTest() { return symbolDictEpoch; } + /** + * Number of times {@link #maybeBlockForStarvedReset()} has timed out + * without the backlog draining. 0 until the first such timeout. + */ + @TestOnly + public long getSymbolDictResetStarvationTimeoutsForTest() { + return symbolDictResetStarvationTimeouts; + } + /** * Test-only entry point for {@link #rollFsnEpochBase}, the same private * roll the symbol-dict recycle swap calls in production once the engine @@ -4561,14 +4574,54 @@ private boolean isRingDrained() { } /** - * Placeholder for the opportunistic/forced starvation-wait policy a later - * task fills in: when the ring is NOT drained at arming time, this - * decides whether to wait out an idle window before forcing the recycle - * regardless. No-op here -- an armed sender with a non-empty backlog - * simply stays armed and re-checks on the next {@link #table(CharSequence)} - * call. + * Starvation policy: when the ring is NOT drained at arming time, waits + * out an opportunistic window before giving up for this armed window. + * Refuses (returns immediately) in three cases: {@code resetMaxWaitMillis + * <= 0} (blocking disabled), a wait already ran for this arm cycle + * ({@link #starvationWaitDoneThisArm} -- at most one blocking wait per + * armed window), or a deferred-commit group is open + * ({@link #hasDeferredMessages}). That last guard is a data-safety + * requirement, not an optimisation: the server withholds acks for + * {@code FLAG_DEFER_COMMIT} frames by design until the closing commit + * lands, and this producer thread is the only one that could ever send + * that commit -- blocking here would just run out the clock every time, + * while starving the caller of the thread it needs to actually close the + * group. + *

    + * Otherwise waits (parked, {@code awaitAckedFsn}-shaped) until either the + * ring drains -- in which case the recycle runs synchronously before + * returning -- or {@code resetMaxWaitMillis} elapses from THIS call, in + * which case it gives up, counts the timeout, and leaves + * {@link #resetArmed} set so a later drained {@link #table(CharSequence)} + * call can still recycle opportunistically. */ private void maybeBlockForStarvedReset() { + if (resetMaxWaitMillis <= 0 || starvationWaitDoneThisArm) { + return; + } + if (hasDeferredMessages) { + return; + } + if (System.nanoTime() - armedSinceNanos < resetMaxWaitMillis * 1_000_000L) { + return; + } + starvationWaitDoneThisArm = true; + long deadlineNanos = System.nanoTime() + resetMaxWaitMillis * 1_000_000L; + while (!isRingDrained()) { + cursorEngine.checkDurability(); + if (cursorSendLoop != null) { + cursorSendLoop.checkError(); + } + checkConnectionError(); + if (System.nanoTime() >= deadlineNanos) { + symbolDictResetStarvationTimeouts++; + LOG.warn("symbol dictionary reset starved: backlog not drained within {} ms; " + + "re-arming opportunistically", resetMaxWaitMillis); + return; + } + java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + } + recycleForDictReset(); } /** diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java new file mode 100644 index 00000000..fd8b2113 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java @@ -0,0 +1,516 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.WebSocketResponse; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.FLAG_DEFER_COMMIT; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * The bounded blocking wait {@code maybeBlockForStarvedReset()} runs from + * {@code maybeRecycleForDictReset()} when a symbol-dictionary recycle is + * armed but the ring is not yet drained: it opportunistically waits (parked, + * {@code awaitAckedFsn}-shaped) for the outstanding acks to arrive, up to + * {@code symbol_dict_reset_max_wait_millis}, before giving up for this armed + * window. {@code resetMaxWaitMillis <= 0} disables the wait entirely (never + * block); a deferred-commit group open at the time of the check must never + * be waited on, since the server withholds its acks by design until the + * closing commit lands (the one data-loss-adjacent path in this feature -- + * forcing a wait there would just stall until the deadline, since only THIS + * thread can ever send the commit that unblocks it). + */ +public class SymbolDictRecycleStarvationTest { + + /** + * {@code symbol_dict_reset_max_wait_millis=0} must disable the wait + * unconditionally, regardless of how long the recycle has been armed or + * how large the backlog is. Every {@code table()} call must return in + * (near) constant time, the sender must never recycle, and it must stay + * armed forever -- nothing ever consumes the arming. + */ + @Test + public void testMaxWaitZeroNeverBlocks() throws Exception { + assertMemoryLeak(() -> { + GatedAckHandler handler = new GatedAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + + ";symbol_dict_reset_threshold=2" + + ";symbol_dict_reset_max_wait_millis=0;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + sender.flush(); // unacked forever -- handler never releases + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + Assert.assertEquals(0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + + // Repeated table() calls, spread over time, must every one of + // them return fast: resetMaxWaitMillis<=0 is checked BEFORE the + // armed-window elapsed check, so it never even looks at how long + // this arm has been outstanding. + for (int i = 0; i < 5; i++) { + long t0 = System.nanoTime(); + sender.table("t"); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + Assert.assertTrue("table() call #" + i + " took " + elapsedMs + + "ms -- resetMaxWaitMillis=0 must never block", + elapsedMs < 100); + Thread.sleep(20); + } + + Assert.assertTrue("armed forever -- nothing ever consumes it with no factory " + + "action taken", + ws.isResetArmed()); + Assert.assertEquals("must never recycle -- the ring never drained and " + + "blocking is disabled", + 0L, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + + // Release before the try-with-resources closes the sender below, + // or close()'s own drain would hang on this still-unacked batch -- + // irrelevant to what this test is proving. + handler.releaseAcks(); + } + } + }); + } + + /** + * With a non-zero max wait, a {@code table()} call made after the armed + * window has elapsed must block (parked) until the backlog drains, then + * recycle synchronously within that same call -- proving the wait + * actually parks rather than spinning or returning early, and that a + * drain arriving mid-wait is observed without needing a fresh + * {@code table()} call. + */ + @Test + public void testBlocksThenRecyclesWhenAcksArrive() throws Exception { + assertMemoryLeak(() -> { + GatedAckHandler handler = new GatedAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + long maxWaitMillis = 400; + String cfg = "ws::addr=localhost:" + port + + ";symbol_dict_reset_threshold=2" + + ";symbol_dict_reset_max_wait_millis=" + maxWaitMillis + ";"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + sender.flush(); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + + // Let the armed-window guard elapse (armedSinceNanos check uses + // the SAME resetMaxWaitMillis) so the triggering table() call + // actually enters the blocking wait -- with a FRESH maxWaitMillis + // deadline of its own -- instead of returning immediately. + Thread.sleep(maxWaitMillis + 50); + + // Release the acks from another thread, well before the fresh + // maxWaitMillis deadline elapses, while the main thread is + // parked in the wait loop. + long releaseDelayMs = 150; + Thread releaser = new Thread(() -> { + try { + Thread.sleep(releaseDelayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + handler.releaseAcks(); + }); + releaser.start(); + + long t0 = System.nanoTime(); + sender.table("t"); // blocks, then recycles once the ack lands + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + releaser.join(); + + Assert.assertTrue("must have actually blocked for roughly the release delay " + + "(" + releaseDelayMs + "ms), got " + elapsedMs + "ms", + elapsedMs >= releaseDelayMs - 50); + Assert.assertTrue("must not have run into the full max-wait deadline " + + "(" + maxWaitMillis + "ms), got " + elapsedMs + "ms", + elapsedMs < maxWaitMillis); + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals("recycle must have run exactly once", + 1L, ws.getSymbolDictEpochForTest()); + Assert.assertEquals("a successful drain-and-recycle is not a timeout", + 0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + } + } + }); + } + + /** + * Acks never arrive at all: the blocked {@code table()} call must give up + * at (roughly) {@code maxWaitMillis}, not hang indefinitely, and the + * sender must keep working afterwards -- ingest is not stuck. The + * starvation counter records the timeout, the recycle stays armed + * (nothing consumed it), and at most one blocking wait happens per armed + * window: an immediately-following table() call must NOT re-block. Once + * the backlog does drain later (acks release), the still-armed recycle + * fires on the next table() call -- opportunistically, without another + * blocking wait. + */ + @Test + public void testTimeoutLogsAndReArms() throws Exception { + assertMemoryLeak(() -> { + GatedAckHandler handler = new GatedAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + long maxWaitMillis = 300; + String cfg = "ws::addr=localhost:" + port + + ";symbol_dict_reset_threshold=2" + + ";symbol_dict_reset_max_wait_millis=" + maxWaitMillis + ";"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + + // Let the armed-window guard elapse so the call below actually + // enters the wait instead of short-circuiting on arm recency. + Thread.sleep(maxWaitMillis + 50); + + long t0 = System.nanoTime(); + sender.table("t"); // acks never come -- must give up at ~maxWaitMillis + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + + Assert.assertTrue("must have waited out roughly the full max-wait deadline " + + "(" + maxWaitMillis + "ms), got " + elapsedMs + "ms", + elapsedMs >= maxWaitMillis - 50); + Assert.assertTrue("must not hang far past its own deadline, got " + elapsedMs + "ms", + elapsedMs < maxWaitMillis + 5_000); + Assert.assertEquals("a timed-out wait must record exactly one starvation timeout", + 1L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + Assert.assertTrue("timing out must not consume the arming -- the recycle is " + + "still owed once the backlog eventually drains", + ws.isResetArmed()); + Assert.assertEquals("no recycle happened -- only the wait gave up", + 0L, ws.getSymbolDictEpochForTest()); + + // Ingest continues: more rows can still be appended and flushed + // without the sender getting stuck. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + + // At most one blocking wait per armed window: this table() call + // must NOT re-block even though the backlog is still undrained. + long t1 = System.nanoTime(); + sender.table("t"); + long secondElapsedMs = (System.nanoTime() - t1) / 1_000_000; + Assert.assertTrue("a second table() call in the same armed window must not " + + "re-block, took " + secondElapsedMs + "ms", + secondElapsedMs < 100); + Assert.assertEquals("still just the one timeout from before", + 1L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + + // Now let the backlog actually drain: the still-armed recycle + // must fire opportunistically on the next table() call, with no + // further blocking wait needed (isRingDrained() short-circuits + // straight to recycleForDictReset()). drain() uses watermark + // semantics -- it waits for publishedFsn(), covering BOTH the + // original arm batch and the ingest-continues row above it, not + // just fsn1's frame. + handler.releaseAcks(); + Assert.assertTrue("setup: every pending frame must get acked once released", + sender.drain(5_000)); + Assert.assertTrue("setup: the original arm batch must be covered too", + sender.awaitAckedFsn(fsn1, 0)); + + sender.table("t"); + Assert.assertFalse("the still-armed recycle must fire now that the backlog " + + "has drained", + ws.isResetArmed()); + Assert.assertEquals(1L, ws.getSymbolDictEpochForTest()); + Assert.assertEquals("draining later must not add another timeout", + 1L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + } + } + }); + } + + /** + * A terminal error latched WHILE the wait is parked must interrupt it + * immediately -- the wait polls {@code cursorSendLoop.checkError()} / + * {@code checkConnectionError()} every park interval, exactly like + * {@code awaitAckedFsn}. Forces the poison detector to escalate to a + * terminal on the very first NACK ({@code max_frame_rejections=1}, + * {@code poison_min_escalation_window_millis=0}) so the timing is + * deterministic instead of depending on the (much slower) defaults. + */ + @Test + public void testLatchedErrorDuringWaitThrows() throws Exception { + assertMemoryLeak(() -> { + GatedThenPoisonHandler handler = new GatedThenPoisonHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + long maxWaitMillis = 400; + String cfg = "ws::addr=localhost:" + port + + ";symbol_dict_reset_threshold=2" + + ";symbol_dict_reset_max_wait_millis=" + maxWaitMillis + + ";max_frame_rejections=1;poison_min_escalation_window_millis=0;"; + + Sender sender = Sender.fromConfig(cfg); + try { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + sender.flush(); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + + // Let the armed-window guard elapse (armedSinceNanos check uses + // the SAME resetMaxWaitMillis) so the triggering table() call + // below actually enters the blocking wait with a FRESH deadline. + Thread.sleep(maxWaitMillis + 50); + + long poisonDelayMs = 150; + Thread poisoner = new Thread(() -> { + try { + Thread.sleep(poisonDelayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + handler.poison(); + }); + poisoner.start(); + + long t0 = System.nanoTime(); + LineSenderException thrown = null; + try { + sender.table("t"); + } catch (LineSenderException e) { + thrown = e; + } + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + poisoner.join(); + + Assert.assertNotNull("a terminal error latched during the wait must propagate " + + "out of table(), not be swallowed into an indefinite hang", + thrown); + Assert.assertTrue("must have thrown promptly once poisoned, not waited out the " + + "full max-wait deadline (" + maxWaitMillis + "ms), got " + + elapsedMs + "ms", + elapsedMs < maxWaitMillis); + Assert.assertTrue("the recycle must never have run -- the swap requires a " + + "healthy connection", + ws.isResetArmed()); + Assert.assertEquals("a thrown wait is not a timeout", + 0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + } finally { + try { + sender.close(); + } catch (LineSenderException ignored) { + // close() may also observe the latched terminal -- irrelevant here + } + } + } + }); + } + + /** + * A deferred-commit group open past the armed window is the one + * data-safety-critical case: the server withholds acks for + * {@code FLAG_DEFER_COMMIT} frames by design, and this producer thread is + * the only one that can ever send the closing commit. Blocking here would + * therefore ALWAYS run out the clock -- worse, it would do so while + * holding up the very thread the caller needs free to actually close the + * group. {@code table()} must return immediately (the futility guard), + * and once the group is closed and its commit acked, the still-armed + * recycle must fire at the very next drained {@code table()} call. + */ + @Test + public void testDeferredCommitGroupSkipsWait() throws Exception { + assertMemoryLeak(() -> { + DeferAwareAckHandler handler = new DeferAwareAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + long maxWaitMillis = 300; + String cfg = "ws::addr=localhost:" + port + + ";symbol_dict_reset_threshold=2" + + ";symbol_dict_reset_max_wait_millis=" + maxWaitMillis + ";"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + ws.setDeferCommit(true); + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + sender.flush(); // deferred frame -- server withholds its ack + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + + // Let the armed window elapse -- without the futility guard this + // is exactly when a naive implementation would start blocking. + Thread.sleep(maxWaitMillis + 50); + + long t0 = System.nanoTime(); + sender.table("t"); // futility guard: must return immediately + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + + Assert.assertTrue("an open deferred-commit group must never be waited on " + + "(the server withholds its ack by design), took " + elapsedMs + "ms", + elapsedMs < 100); + Assert.assertTrue("must still be armed -- neither the wait nor the recycle ran", + ws.isResetArmed()); + Assert.assertEquals("the futility guard is not a timeout", + 0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + Assert.assertEquals(0L, ws.getSymbolDictEpochForTest()); + + // Close the deferred group: commit, and wait for its ack. + ws.setDeferCommit(false); + long commitFsn = sender.flushAndGetSequence(); + Assert.assertTrue("setup: the commit must get acked", + sender.awaitAckedFsn(commitFsn, 5_000)); + + // The ring is now drained -- the still-armed recycle must fire at + // the very next drained row start, with no wait needed at all. + sender.table("t"); + Assert.assertFalse("the still-armed recycle must fire once the group is " + + "committed and acked", + ws.isResetArmed()); + Assert.assertEquals(1L, ws.getSymbolDictEpochForTest()); + Assert.assertEquals("no wait ever ran in this test", + 0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + } + } + }); + } + + /** + * Receives frames but withholds every ack until {@link #releaseAcks()} is + * called, so a starvation wait provably has an unacknowledged target to + * wait on. Mirrors {@code CloseDrainTest.GatedAckHandler} / + * {@code SymbolDictRecycleFsnContinuityTest.GatedAckHandler}. + */ + private static class GatedAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + private final CountDownLatch released = new CountDownLatch(1); + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + if (!released.await(20, TimeUnit.SECONDS)) { + throw new AssertionError("starvation-wait witness never released the ack gate"); + } + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + void releaseAcks() { + released.countDown(); + } + } + + /** + * Withholds its response until {@link #poison()} is called, then replies + * with a terminal-worthy NACK (STATUS_PARSE_ERROR) instead of an ack -- + * models a connection killed while a starvation wait is parked. + */ + private static class GatedThenPoisonHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + private final CountDownLatch poisoned = new CountDownLatch(1); + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + if (!poisoned.await(20, TimeUnit.SECONDS)) { + throw new AssertionError("starvation-wait witness never poisoned the connection"); + } + client.sendBinary(QwpWireTestUtils.buildNack( + nextSeq.getAndIncrement(), WebSocketResponse.STATUS_PARSE_ERROR)); + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + void poison() { + poisoned.countDown(); + } + } + + /** + * Acks every non-deferred frame immediately (each connection's wire + * sequence restarts at 0), but withholds acks for any frame carrying + * {@code FLAG_DEFER_COMMIT} -- exactly the real server's ack contract for + * an open deferred-commit group (see + * {@code CloseDrainTest.AckFirstFrameOnlyHandler}, which models the same + * contract for a fixed one-committed-then-deferred shape). Acking the + * closing commit frame's wire sequence retroactively covers the whole + * group, since {@code ackedFsn} is a cumulative watermark. + */ + private static class DeferAwareAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + long seq = nextSeq.getAndIncrement(); + boolean deferred = data.length > 5 && (data[5] & FLAG_DEFER_COMMIT) != 0; + if (deferred) { + return; // withhold the ack -- the group is still open + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(seq)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} From 3e6867334728c8ba30aa48a439608e9187c3a1f7 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:12:25 +0100 Subject: [PATCH 12/49] Fix round 1: correct starvation-wait javadoc, tighten tests Javadoc for symbol_dict_reset_max_wait_millis (Sender.java builder method, and the DEFAULT_.../field comments in QwpWebSocketSender.java) said the knob controls how long the recycle waits "before forcing the rebuild" and that 0 means "never forces". That is backwards: nothing is ever forced through. On timeout the wait gives up, increments the starvation counter, logs a warning, and stays armed -- the dictionary threshold is the only actual backstop. Rewrite all three to state the real policy: once the armed window exceeds the knob, the NEXT table(...) call may block the calling thread for up to the knob's value waiting for the backlog to drain; 0 disables blocking entirely. WARN log wording "re-arming opportunistically" -> "staying armed": the arm is never consumed on a timeout, so nothing re-arms. Test fixes in SymbolDictRecycleStarvationTest: - testTimeoutLogsAndReArms: the "second table() must not re-block" probe was placed after a row had been queued (pendingRowCount==1), so it short-circuited on maybeRecycleForDictReset()'s precondition guard before ever reaching the wait -- vacuously true regardless of starvationWaitDoneThisArm. Moved the probe earlier, to a table() call with pendingRowCount==0, so it actually exercises the "at most one blocking wait per armed window" guard. - testBlocksThenRecyclesWhenAcksArrive: raised maxWaitMillis from 400 to 700ms (keeping the 150ms release delay) so the full recycle path (I/O loop join, engine close, rebuild, fresh handshake) has real headroom under the elapsedMs * Default {@code 30_000} (30 s). WebSocket transport only. */ diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 54dd828c..1f62fc3c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -141,9 +141,13 @@ public class QwpWebSocketSender implements Sender { // on by default so a long-lived sender's dictionary does not grow without // bound. public static final boolean DEFAULT_SYMBOL_DICT_RESET_ENABLED = true; - // Default for symbol_dict_reset_max_wait_millis: upper bound, in millis, the - // recycle waits for an opportunistic (idle) window before forcing the - // rebuild. 0 means opportunistic-only -- never forced. + // Default for symbol_dict_reset_max_wait_millis: once a recycle has been + // armed longer than this window without an opportunistic (idle) drain, + // the next row-start call (table()) blocks the calling thread for up to + // this many millis waiting for the backlog to drain, then recycles; on + // timeout that call gives up (still armed, retried opportunistically + // later) instead of blocking further. 0 disables blocking entirely -- + // opportunistic-only. public static final long DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS = 30_000L; // Default for symbol_dict_reset_threshold: distinct-symbol count that // triggers a recycle once symbol_dict_reset is on. @@ -406,9 +410,13 @@ public interface EngineRebuildFactory { // registered, bounding unbounded dictionary growth on a long-lived sender // (connect-string key symbol_dict_reset). private boolean resetEnabled = DEFAULT_SYMBOL_DICT_RESET_ENABLED; - // Upper bound, in millis, the recycle waits for an opportunistic (idle) - // window before forcing the rebuild; 0 means opportunistic-only, never - // forced (connect-string key symbol_dict_reset_max_wait_millis). + // Once a recycle has been armed longer than this window without an + // opportunistic (idle) drain, the next row-start call (table()) blocks + // the calling thread for up to this many millis waiting for the backlog + // to drain, then recycles; on timeout that call gives up (still armed, + // retried opportunistically later) instead of blocking further. 0 + // disables blocking entirely -- opportunistic-only (connect-string key + // symbol_dict_reset_max_wait_millis). private long resetMaxWaitMillis = DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS; // Distinct-symbol count that triggers a recycle once resetEnabled is on // (connect-string key symbol_dict_reset_threshold). @@ -4616,7 +4624,7 @@ private void maybeBlockForStarvedReset() { if (System.nanoTime() >= deadlineNanos) { symbolDictResetStarvationTimeouts++; LOG.warn("symbol dictionary reset starved: backlog not drained within {} ms; " - + "re-arming opportunistically", resetMaxWaitMillis); + + "staying armed", resetMaxWaitMillis); return; } java.util.concurrent.locks.LockSupport.parkNanos(50_000L); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java index fd8b2113..e187ac53 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java @@ -130,7 +130,11 @@ public void testBlocksThenRecyclesWhenAcksArrive() throws Exception { server.start(); Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); int port = server.getPort(); - long maxWaitMillis = 400; + // Generous relative to releaseDelayMs below: the recycle itself + // (I/O loop join, engine close, rebuild, fresh handshake) needs + // headroom on top of the release delay, or the elapsedMs Date: Mon, 17 Aug 2026 20:31:05 +0100 Subject: [PATCH 13/49] Pin recycle refusal conditions --- .../client/SymbolDictRecycleRefusalTest.java | 612 ++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java new file mode 100644 index 00000000..2b407e9a --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java @@ -0,0 +1,612 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.FLAG_DEFER_COMMIT; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * The negative space of the symbol-dictionary recycle: every guard in + * {@code QwpWebSocketSender.maybeRecycleForDictReset()} that must refuse the + * swap even though {@code resetArmed} is true, plus the deferral guard in + * {@code table(CharSequence)} that keeps a pre-connect manual request from + * ever touching the (not yet existing) cursor engine or I/O loop. + *

    + * {@link SymbolDictRecycleTest} and {@link SymbolDictRecycleMemoryModeTest} + * pin the swap itself; {@link SymbolDictRecycleArmingTest} pins how + * {@code resetArmed} flips true; {@link SymbolDictRecycleStarvationTest} pins + * the bounded blocking wait for an unacked backlog. This suite pins the + * OPPOSITE: every condition under which an armed sender must keep working + * normally and NOT recycle, until that condition clears -- at which point + * the still-armed request fires as a positive control in the same test. + * Every test asserts both halves: no recycle (connection count and + * {@code getSymbolDictEpochForTest()} unchanged) AND that ingestion keeps + * working (a row lands and gets acked) both before and after the eventual + * recycle. + */ +public class SymbolDictRecycleRefusalTest { + + /** + * The most basic refusal: a batch that itself crossed the arming + * threshold is still unacked when the very next {@code table()} call + * checks the barrier. {@code symbol_dict_reset_max_wait_millis=0} + * disables the (separately-pinned, {@link SymbolDictRecycleStarvationTest}) + * blocking wait, so every refusal here is instant and this test stays + * purely about the ring-drained guard. Repeated {@code table()} calls + * spread over a real span of wall-clock time (not one instantaneous + * check) prove the recycle does not fire late, either -- the whole + * mechanism is synchronous and producer-thread-driven, but a bounded + * settle window is the only way a test can actually witness that rather + * than assume it. + */ + @Test + public void testUnackedBacklogRefusesUntilAcked() throws Exception { + assertMemoryLeak(() -> { + GatedAckHandler handler = new GatedAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + + ";symbol_dict_reset_threshold=2" + + ";symbol_dict_reset_max_wait_millis=0;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); // ack withheld by the handler + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + Assert.assertEquals(1, server.handshakeCount()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + for (int i = 0; i < 5; i++) { + sender.table("t"); + Assert.assertTrue("recycle must not fire while the arming batch is unacked", + ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, server.handshakeCount()); + Thread.sleep(30); + } + + // Positive control: release the acks and prove the still-armed + // recycle fires on the very next drained table() call. + handler.releaseAcks(); + Assert.assertTrue("setup: the arming batch must get acked once released", + sender.awaitAckedFsn(fsn1, 5_000)); + + sender.table("t"); + Assert.assertFalse("recycle must fire once the backlog drains", ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); + + // Ingestion continues on the fresh epoch. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle batch must still get acked", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue(fsn2 > fsn1); + } + } + }); + } + + /** + * Isolates the {@code pendingRowCount != 0} guard from the ring-drained + * guard {@link #testUnackedBacklogRefusesUntilAcked} pins: the arming + * batch's ack is released and awaited WHILE a third row sits buffered + * (committed via {@code atNow()}, but never flushed -- {@code auto_flush_rows} + * is set well above 1 so it does not auto-flush). By the time the + * settle-window loop runs, the ring itself is fully drained, so any + * refusal it observes can only be this guard, not the earlier one. + */ + @Test + public void testPendingRowCountRefuses() throws Exception { + assertMemoryLeak(() -> { + GatedAckHandler handler = new GatedAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + + ";symbol_dict_reset_threshold=2" + + ";symbol_dict_reset_max_wait_millis=0" + + ";auto_flush_rows=10;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); // ack withheld by the handler + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + + // Ring not drained yet: refused by the OTHER guard, which just + // lets execution fall through so a new row can be buffered. + sender.table("t"); + Assert.assertTrue(ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // A third row, committed but never flushed: pendingRowCount=1, + // far under auto_flush_rows=10, so it stays buffered. + sender.symbol("s", "c").longColumn("v", 2L).atNow(); + + // Drain the arming batch -- from here on the ring itself is + // fully drained, isolating the pendingRowCount guard. + handler.releaseAcks(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + + for (int i = 0; i < 5; i++) { + sender.table("t"); + Assert.assertTrue("recycle must not fire while a row is buffered " + + "unflushed, even with the ring otherwise drained", + ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, server.handshakeCount()); + Thread.sleep(30); + } + + // Positive control: flush the buffered row, then the + // still-armed recycle fires on the next table() call. + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + + sender.table("t"); + Assert.assertFalse("recycle must fire once the buffered batch is flushed " + + "and acked", + ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(2, server.handshakeCount()); + + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn3 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn3, 5_000)); + Assert.assertTrue(fsn3 > fsn2); + } + } + }); + } + + /** + * A row under construction (columns set, {@code atNow()} not yet called) + * refuses the barrier two different ways depending on the next + * {@code table()} call's table name. The same-name case is the sharper + * proof: {@code table()}'s resetArmed check runs BEFORE the + * same-table-name fast path that would otherwise skip straight past + * everything, so this is the only way to prove the hook actually sits + * ahead of that shortcut. The different-name case falls through to the + * pre-existing "cannot switch tables while row is in progress" guard + * instead -- a thrown exception, not a recycle, and not a new failure + * mode this feature introduced. + */ + @Test + public void testInProgressRowRefuses() throws Exception { + assertMemoryLeak(() -> { + AckAllHandler handler = new AckAllHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + // Start a row but do not commit it: symbol() registers "a" + // into the dictionary immediately, yet the row itself stays + // in progress until atNow() runs. + sender.table("t").symbol("s", "a"); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // Arm WHILE the row is in progress: pendingRowCount is still + // 0 (an in-progress row is not counted as pending), so the + // manual request arms immediately even though a row is + // genuinely mid-flight. + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + + for (int i = 0; i < 3; i++) { + sender.table("t"); // same name -- fast path would skip past everything + Assert.assertTrue("recycle must not fire while a row is in progress", + ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, server.handshakeCount()); + Thread.sleep(20); + } + + LineSenderException thrown = null; + try { + sender.table("other"); + Assert.fail("expected 'cannot switch tables' while a row is in progress"); + } catch (LineSenderException e) { + thrown = e; + } + Assert.assertNotNull(thrown); + Assert.assertTrue("unexpected message: " + thrown.getMessage(), + thrown.getMessage().contains("cannot switch tables while row is in progress")); + Assert.assertTrue("the failed table-switch attempt must not have consumed " + + "the arming", + ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, server.handshakeCount()); + + // Complete the row: ingestion still works after both refusals. + sender.longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("nothing yet consumed the arming", ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // Positive control: with the row complete and the batch + // acked, the still-armed recycle fires on the next call. + sender.table("t"); + Assert.assertFalse("recycle must fire once the row completes and the ring " + + "drains", + ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(2, server.handshakeCount()); + + sender.table("t").symbol("s", "d").longColumn("v", 2L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue(fsn2 > fsn1); + } + } + }); + } + + /** + * The one data-safety-critical refusal, mirroring + * {@code SymbolDictRecycleStarvationTest#testDeferredCommitGroupSkipsWait} + * but for the barrier itself rather than the blocking-wait futility + * guard: the server withholds acks for {@code FLAG_DEFER_COMMIT} frames + * by design until the closing commit lands, so {@code isRingDrained()} + * stays false for as long as the group is open, however long that is. + * {@code symbol_dict_reset_max_wait_millis=0} keeps this test orthogonal + * to the (separately-pinned) starvation-wait timing. + */ + @Test + public void testDeferredCommitGroupRefusesUntilCommitAcked() throws Exception { + assertMemoryLeak(() -> { + DeferAwareAckHandler handler = new DeferAwareAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + + ";symbol_dict_reset_threshold=2" + + ";symbol_dict_reset_max_wait_millis=0;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + ws.setDeferCommit(true); + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + sender.flush(); // deferred frame -- server withholds its ack by design + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + + for (int i = 0; i < 5; i++) { + sender.table("t"); + Assert.assertTrue("an open deferred-commit group must never let the " + + "recycle fire -- the server withholds its ack until " + + "the closing commit", + ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, server.handshakeCount()); + Thread.sleep(30); + } + + // Positive control: close the group, wait for its + // (retroactive) ack, and prove the still-armed recycle + // fires next. + ws.setDeferCommit(false); + long commitFsn = sender.flushAndGetSequence(); + Assert.assertTrue("setup: the commit must get acked", + sender.awaitAckedFsn(commitFsn, 5_000)); + + sender.table("t"); + Assert.assertFalse("recycle must fire once the group is committed and acked", + ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(2, server.handshakeCount()); + + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue(fsn2 > commitFsn); + } + } + }); + } + + /** + * A manual {@code resetSymbolDictionary()} call arms {@code resetArmed} + * regardless of connection state ({@code armIfEligible()} touches only + * producer-side fields), so it can go through before the sender has ever + * connected -- modelled the same way + * {@code SymbolDictRecycleFsnContinuityTest} builds unconnected senders: + * {@link QwpWebSocketSender#createForTesting} plus a manually attached + * engine, with {@link QwpWebSocketSender#setEngineRebuildFactory} filled + * in (unlike {@code createForTesting}'s production counterparts, a + * connect()-built sender normally has none -- see + * {@code SymbolDictRecycleTest#testConnectBuiltSenderNeverRecyclesWithoutFactory}) + * so the deferred request can actually execute once connected. The very + * next {@code table()} call -- still pre-connect -- must defer rather + * than NPE: {@code !connected} refuses the barrier before it ever + * touches the cursor engine or I/O loop. + */ + @Test + public void testManualResetBeforeFirstConnectDeferred() throws Exception { + assertMemoryLeak(() -> { + AckAllHandler handler = new AckAllHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + + QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", port); + try { + CursorSendEngine engine = new CursorSendEngine( + null, 4L * 1024 * 1024, 128L * 1024 * 1024, + CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS); + sender.setCursorEngine(engine, true); + sender.setEngineRebuildFactory(() -> new CursorSendEngine( + null, 4L * 1024 * 1024, 128L * 1024 * 1024, + CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS)); + + // Manual request before the sender has ever connected. + sender.resetSymbolDictionary(); + Assert.assertTrue("a manual request arms immediately, independent of " + + "connection state", + sender.isResetArmed()); + Assert.assertEquals(0, sender.getSymbolDictEpochForTest()); + Assert.assertEquals(0, server.handshakeCount()); + + // table()'s barrier check runs here while still pre-connect + // (ensureConnected() only runs later, inside atNow()'s + // sendRow()) -- must defer quietly, not NPE. + sender.table("t").longColumn("v", 1L).atNow(); + Assert.assertTrue("still armed -- deferred, not consumed", + sender.isResetArmed()); + Assert.assertEquals(0, sender.getSymbolDictEpochForTest()); + Assert.assertEquals(1, server.handshakeCount()); + + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("flush alone does not consume the arming -- only table() " + + "does", + sender.isResetArmed()); + Assert.assertEquals(0, sender.getSymbolDictEpochForTest()); + + // Positive control: now connected and drained, the + // deferred request executes on the next table() call. + sender.table("t"); + Assert.assertFalse("the deferred request must execute once connected and " + + "drained", + sender.isResetArmed()); + Assert.assertEquals(1, sender.getSymbolDictEpochForTest()); + Assert.assertEquals(2, server.handshakeCount()); + + sender.table("t").longColumn("v", 2L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue(fsn2 > fsn1); + } finally { + sender.close(); + } + } + }); + } + + /** + * {@code reset()} discards a buffered-but-never-shipped row -- including + * reclaiming any symbol id it registered but never sent, via the same + * {@code truncateTo} mechanism the {@code BatchTooLargeForCapException} + * remedy documents. This proves that discard is compatible with an + * already-armed swap: after {@code reset()} clears the in-progress row + * that was the ONLY thing refusing the barrier, every guard is + * satisfied (connected, no pending row, no in-progress row, ring + * drained from the earlier shipped batch), so the next {@code table()} + * call recycles -- observed here to fire deterministically, not + * probabilistically, once those guards clear. It is compatible with + * {@code reset()}'s own reclaim: the swap replaces the whole dictionary + * object outright (step 5 of {@code recycleForDictReset()}), so + * whatever {@code truncateTo} did to the outgoing instance is moot -- + * the swap subsumes it. + */ + @Test + public void testResetDiscardsBufferedRowThenArmedSwapFires() throws Exception { + assertMemoryLeak(() -> { + AckAllHandler handler = new AckAllHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";symbol_dict_reset_threshold=3;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + // A real shipped batch: two distinct symbols, below the + // threshold of 3, so nothing arms yet. + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertFalse("dictionary has only 2 entries, below the threshold of 3", + ws.isResetArmed()); + + // Start (but never commit) a third row -- registers "c", + // crossing the threshold, but arming is only ever + // evaluated at a flush's tail or by resetSymbolDictionary(), + // neither of which has run yet. + sender.table("t").symbol("s", "c").longColumn("v", 2L); + Assert.assertFalse(ws.isResetArmed()); + + // Arm explicitly while the row is still in progress -- + // the in-progress-row guard refuses table(), exactly as + // testInProgressRowRefuses proves. + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + sender.table("t"); // refused: row "c" is in progress + Assert.assertTrue(ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, server.handshakeCount()); + + // Discard the buffered row -- reset() drops the + // in-progress row AND reclaims "c"'s never-shipped id. + sender.reset(); + + // Every barrier guard is now satisfied: connected, no + // pending row (reset cleared it), no in-progress row + // (reset cleared it), ring drained (a, b were acked + // before any of this). The armed swap fires here. + sender.table("t"); + Assert.assertFalse("the armed swap fires once reset() clears the blocking " + + "in-progress row", + ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(2, server.handshakeCount()); + + // Ingestion continues correctly post-swap: a fresh row + // lands and gets acked with no exception. + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue(fsn2 > fsn1); + } + } + }); + } + + /** + * ACKs every frame it receives; does not otherwise inspect the wire. + * Resets its wire sequence per new connection, mirroring + * {@code SymbolDictRecycleTest.RecycleHandler}, so post-recycle + * ingestion on the fresh connection acks correctly too. + */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private TestWebSocketServer.ClientHandler currentClient; + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (currentClient != client) { + currentClient = client; + nextSeq.set(0); + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Acks every non-deferred frame immediately, but withholds acks for any + * frame carrying {@code FLAG_DEFER_COMMIT} -- the real server's ack + * contract for an open deferred-commit group. Mirrors + * {@code SymbolDictRecycleStarvationTest.DeferAwareAckHandler}, plus a + * per-connection wire-sequence reset so ingestion on the post-recycle + * connection acks correctly too. + */ + private static class DeferAwareAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private TestWebSocketServer.ClientHandler currentClient; + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (currentClient != client) { + currentClient = client; + nextSeq.set(0); + } + long seq = nextSeq.getAndIncrement(); + boolean deferred = data.length > 5 && (data[5] & FLAG_DEFER_COMMIT) != 0; + if (deferred) { + return; // withhold the ack -- the group is still open + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(seq)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Receives frames but withholds every ack until {@link #releaseAcks()} + * is called, so a refusal-guard test provably has an unacknowledged + * target to refuse on. Mirrors + * {@code SymbolDictRecycleFsnContinuityTest.GatedAckHandler} / + * {@code SymbolDictRecycleStarvationTest.GatedAckHandler}, plus a + * per-connection wire-sequence reset so ingestion on the post-recycle + * connection acks correctly too. + */ + private static class GatedAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private final CountDownLatch released = new CountDownLatch(1); + private TestWebSocketServer.ClientHandler currentClient; + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + if (!released.await(20, TimeUnit.SECONDS)) { + throw new AssertionError("refusal-guard witness never released the ack gate"); + } + synchronized (this) { + if (currentClient != client) { + currentClient = client; + nextSeq.set(0); + } + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + void releaseAcks() { + released.countDown(); + } + } +} From 2841394beeda13705f6162d44ba28c227514809f Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:41:26 +0100 Subject: [PATCH 14/49] Pin recycle behavior under outage and orphan drain Adds SymbolDictRecycleOutageTest covering two interleavings between the symbol-dictionary recycle swap and events outside the producer's own control: a real connection outage on its own stream (recycle triggers while the pre-recycle I/O thread is mid-reconnect against a killed server; step 2's close() joins it, step 7's fresh connect recovers once the endpoint accepts again), and a sibling orphan drainer mid-drain (the recycle only tears down the foreground sender's own cursor engine/I/O loop, leaving a concurrently-gated BackgroundDrainer untouched and able to complete afterward). The third scenario from the task brief -- OrphanScanner.isCandidateOrphan rejecting an empty slot directory -- turned out to already be pinned by OrphanScannerTest#testIsCandidateOrphanDirect and #testEmptySlotDirIsNotAnOrphan, so no new test was added for it. Test-only change; no production code touched. --- .../client/SymbolDictRecycleOutageTest.java | 452 ++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java new file mode 100644 index 00000000..6c7b1aa5 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java @@ -0,0 +1,452 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Interleavings between the symbol-dictionary recycle swap + * ({@code QwpWebSocketSender.recycleForDictReset()}) and two things outside + * the producer's own control: a real connection outage on its own stream, and + * a sibling {@code BackgroundDrainer} running against a co-located orphan + * slot. + *

    + * (a) proves the recycle's step 2 ({@code cursorSendLoop.close()}) correctly + * joins an I/O thread that is itself mid-reconnect (not idle, not yet given + * up), and that step 7's fresh {@code ensureConnected()} recovers once the + * endpoint accepts connections again -- exercising + * {@code CursorWebSocketSendLoop.close()}'s "handles both states" contract + * under a real outage rather than a synthetic one. + *

    + * (b) proves the swap only ever tears down the producer's OWN cursor + * engine/I/O loop: an orphan drainer's engine and loop are entirely separate + * objects owned by {@code BackgroundDrainerPool}, so a recycle firing while a + * drain is in flight must leave the drain untouched and able to complete + * afterward. + */ +public class SymbolDictRecycleOutageTest { + + private static final String ORPHAN_MARKER_SYMBOL = "orphan-marker-1"; + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + /** + * Kills the server out from under an armed, fully-drained sender, waits + * for the pre-recycle I/O thread to actually enter its own reconnect + * loop (not just assumed via a fixed sleep), then triggers the recycle + * from a background thread -- because step 7's fresh + * {@code ensureConnected()} blocks the calling {@code table()} call (sync + * initial-connect mode) until the endpoint accepts again or + * {@code reconnect_max_duration_millis} elapses. The main thread revives + * a fresh server on the same port shortly after, well inside that + * budget, mirroring {@code ReconnectTest}'s down-then-up realism. + */ + @Test + public void testRecycleDuringOutageReconnectsAfresh() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("outage-recycle").toString(); + AckAllHandler firstHandler = new AckAllHandler(); + int port; + try (TestWebSocketServer server = new TestWebSocketServer(firstHandler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2" + + ";reconnect_initial_backoff_millis=20" + + ";reconnect_max_backoff_millis=80" + + ";reconnect_max_duration_millis=6000;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: the arming batch must be acked before the outage", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // Kill the connection AND the listener -- a real outage, not + // just a dropped socket the same server would re-accept + // instantly. + server.close(); + + // Confirm the pre-recycle I/O thread actually entered its + // own reconnect loop against the now-refused port before we + // trigger the recycle -- so step 2's close() below is + // provably joining a MID-reconnect thread, not one that + // simply hasn't noticed the drop yet. + long attemptDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (ws.getTotalReconnectAttempts() == 0 && System.nanoTime() < attemptDeadline) { + Thread.sleep(5); + } + Assert.assertTrue("pre-recycle I/O thread must have entered reconnect before " + + "the triggering table() call", + ws.getTotalReconnectAttempts() > 0); + + // Trigger the recycle off-thread: step 7's fresh + // ensureConnected() blocks the caller (sync initial-connect + // mode) until the endpoint accepts again. + AtomicReference triggerFailure = new AtomicReference<>(); + Thread trigger = new Thread(() -> { + try { + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + } catch (Throwable t) { + triggerFailure.set(t); + } + }, "recycle-trigger"); + trigger.start(); + + // Resolve the outage shortly after -- well inside + // reconnect_max_duration_millis=6000 -- on the SAME port. + Thread.sleep(150); + OutageRecycleHandler revivedHandler = new OutageRecycleHandler(); + try (TestWebSocketServer revived = + new TestWebSocketServer(revivedHandler, false, null, port)) { + revived.start(); + Assert.assertTrue(revived.awaitStart(5, TimeUnit.SECONDS)); + + trigger.join(10_000); + Assert.assertFalse("recycle-trigger thread must have finished once the " + + "endpoint accepts again", trigger.isAlive()); + Assert.assertNull("triggering table() must not throw once the outage " + + "resolves within budget: " + triggerFailure.get(), + triggerFailure.get()); + + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals("recycle must complete despite the outage", + 1, ws.getSymbolDictEpochForTest()); + Assert.assertTrue("revived server must observe a fresh handshake", + revived.handshakeCount() >= 1); + + // The "c" row was built (atNow()) inside the triggering + // call but not yet flushed -- flush now and prove it + // lands on the fresh connection. + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle row must land once reconnected", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN", + fsn2 > fsn1); + + Assert.assertEquals("the fresh connection's first frame must carry a " + + "fresh (empty) dictionary, not a, b", + 0, revivedHandler.firstFrameDeltaStart); + Assert.assertEquals("post-recycle dictionary must hold only the new " + + "epoch's symbol, nothing lost or duplicated from " + + "before the outage", + Collections.singletonList("c"), revivedHandler.dict()); + } + } + } + }); + } + + /** + * An orphan drainer's engine and I/O loop are objects entirely separate + * from the foreground sender's own {@code cursorEngine}/{@code + * cursorSendLoop} -- {@code BackgroundDrainerPool} owns them. Seeds a + * sibling orphan slot (mirrors {@code OrphanScanIntegrationTest}'s ghost + * recipe), lets the drainer adopt it and get its replay frame gated on + * the wire, then arms and fires a recycle on the foreground stream while + * the drain is provably still in flight. The recycle must leave the + * drain untouched: releasing the gate afterward still lets it complete, + * and every one of the three streams (pre-recycle foreground, + * post-recycle foreground, drained orphan) lands with the right symbol. + */ + @Test + public void testOrphanDrainerSurvivesRecycleMidDrain() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("outage-orphan-drain").toString(); + + // Phase 1: seed a sibling orphan slot. The ghost writes one row + // carrying a uniquely-marked symbol and dies without ever being + // acked -- same recipe as OrphanScanIntegrationTest. + SilentHandler ghostSilent = new SilentHandler(); + try (TestWebSocketServer ghostServer = new TestWebSocketServer(ghostSilent)) { + ghostServer.start(); + Assert.assertTrue(ghostServer.awaitStart(5, TimeUnit.SECONDS)); + String ghostCfg = "ws::addr=localhost:" + ghostServer.getPort() + + ";sf_dir=" + sfDir + ";sender_id=ghost;close_flush_timeout_millis=0;"; + try (Sender ghost = Sender.fromConfig(ghostCfg)) { + ghost.table("orphaned").symbol("s", ORPHAN_MARKER_SYMBOL).longColumn("v", 99L).atNow(); + ghost.flush(); + Assert.assertTrue("ghost frame must reach the wire before close", + ghostSilent.awaitFrame(5, TimeUnit.SECONDS)); + } + } + Assert.assertEquals("ghost slot must be a candidate orphan", + 1, OrphanScanner.scan(sfDir, "primary").size()); + + // Phase 2: one server serves both the primary sender and the + // orphan drainer it spawns. Gating is CONTENT-based (whichever + // connection ships the ghost's marker symbol), not + // connection-order-based -- the drainer's connect can race the + // primary's own first flush, and content-based gating stays + // correct regardless of which one wins that race. + PrimaryAndOrphanHandler handler = new PrimaryAndOrphanHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String primaryCfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";sender_id=primary;drain_orphans=on;symbol_dict_reset_threshold=2;"; + + try (Sender sender = Sender.fromConfig(primaryCfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + // Let the drainer discover + adopt the ghost slot and get + // its replay frame gated on the wire before touching the + // foreground stream at all -- proves the two run + // concurrently, not sequentially. + Assert.assertTrue("orphan drainer must ship its replay frame", + handler.awaitOrphanFrame(10, TimeUnit.SECONDS)); + + // Arm + fire the recycle on the foreground stream. These + // frames carry none of the orphan marker, so they get + // acked immediately regardless of the drain's state. + sender.table("t").symbol("s", "pre-a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "pre-b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // Recycle fires synchronously here, tearing down + rebuilding + // ONLY the foreground's own cursor engine/I/O loop. + sender.table("t").symbol("s", "post-c").longColumn("v", 2L).atNow(); + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle row must land on the fresh connection", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue(fsn2 > fsn1); + + // The drain must still be exactly where it was -- gated, + // not failed, not restarted -- proving the recycle never + // reached into the drainer's separate stack. + Assert.assertFalse("the drainer's connection must not have been touched by " + + "the foreground's recycle", handler.orphanAcked()); + + // Now release the drainer's gate: a drain that survived the + // recycle untouched must still be able to complete. + handler.releaseOrphan(); + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (OrphanScanner.scan(sfDir, "primary").size() > 0 + && System.nanoTime() < deadlineNanos) { + Thread.sleep(10); + } + Assert.assertEquals("orphan drainer must complete the drain after the recycle", + 0, OrphanScanner.scan(sfDir, "primary").size()); + } + + // Per-row symbol correctness for all three streams. + Assert.assertEquals("pre-recycle foreground stream", + Arrays.asList("pre-a", "pre-b"), handler.dictContaining("pre-a")); + Assert.assertEquals("post-recycle foreground stream", + Collections.singletonList("post-c"), handler.dictContaining("post-c")); + Assert.assertEquals("drained orphan stream", + Collections.singletonList(ORPHAN_MARKER_SYMBOL), + handler.dictContaining(ORPHAN_MARKER_SYMBOL)); + } + }); + } + + /** ACKs every frame it receives immediately; does not otherwise inspect the wire. */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Reconstructs the single connection it expects (the recycle's + * post-outage reconnect) and records the delta-start id of its first + * data frame. Tracks by connection identity like + * {@code SymbolDictRecycleTest.RecycleHandler} so a partially-established + * retry that never sends data cannot corrupt the state of the connection + * that actually does. + */ + private static class OutageRecycleHandler implements TestWebSocketServer.WebSocketServerHandler { + private final List dict = new ArrayList<>(); + private final AtomicLong nextSeq = new AtomicLong(0); + private TestWebSocketServer.ClientHandler currentClient; + private boolean seenFirstDataFrame; + volatile int firstFrameDeltaStart = -1; + + synchronized List dict() { + return new ArrayList<>(dict); + } + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (currentClient != client) { + currentClient = client; + dict.clear(); + nextSeq.set(0); + seenFirstDataFrame = false; + firstFrameDeltaStart = -1; + } + QwpWireTestUtils.accumulateDeltaDictionary(data, dict); + if (!seenFirstDataFrame && QwpWireTestUtils.tableCount(data) > 0) { + seenFirstDataFrame = true; + if (QwpWireTestUtils.hasDelta(data)) { + int[] pos = {HEADER_SIZE}; + firstFrameDeltaStart = QwpWireTestUtils.readVarint(data, pos); + } + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Receives binary frames but never acks. Causes the sender to leave + * unacked data on disk on close -- mirrors {@code + * OrphanScanIntegrationTest.SilentHandler}. + */ + private static class SilentHandler implements TestWebSocketServer.WebSocketServerHandler { + private final CountDownLatch frameReceived = new CountDownLatch(1); + + boolean awaitFrame(long timeout, TimeUnit unit) throws InterruptedException { + return frameReceived.await(timeout, unit); + } + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + frameReceived.countDown(); + } + } + + /** + * Serves both the primary sender's own stream and the orphan drainer it + * spawns from a single {@code TestWebSocketServer}. Acks every + * connection's frames immediately EXCEPT whichever one ships {@link + * #ORPHAN_MARKER_SYMBOL} -- that connection is identified by its wire + * content, not by arrival order (the drainer's connect can race the + * primary's own first flush), and is withheld until {@link + * #releaseOrphan()}. Per-connection wire sequence counters mirror {@code + * OrphanScanIntegrationTest.AckHandler}: each WebSocket connection numbers + * its own frames from 0. + */ + private static class PrimaryAndOrphanHandler implements TestWebSocketServer.WebSocketServerHandler { + private final ConcurrentHashMap byClient = + new ConcurrentHashMap<>(); + private final CountDownLatch orphanFrameSeen = new CountDownLatch(1); + private final CountDownLatch orphanGate = new CountDownLatch(1); + private volatile boolean orphanAcked; + + boolean awaitOrphanFrame(long timeout, TimeUnit unit) throws InterruptedException { + return orphanFrameSeen.await(timeout, unit); + } + + /** A copy of whichever connection's dictionary contains {@code marker}, or empty. */ + List dictContaining(String marker) { + for (ConnState state : byClient.values()) { + synchronized (state.dict) { + if (state.dict.contains(marker)) { + return new ArrayList<>(state.dict); + } + } + } + return Collections.emptyList(); + } + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + ConnState state = byClient.computeIfAbsent(client, c -> new ConnState()); + boolean isOrphanFrame; + synchronized (state.dict) { + QwpWireTestUtils.accumulateDeltaDictionary(data, state.dict); + isOrphanFrame = state.dict.contains(ORPHAN_MARKER_SYMBOL); + } + if (isOrphanFrame) { + orphanFrameSeen.countDown(); + try { + if (!orphanGate.await(20, TimeUnit.SECONDS)) { + throw new AssertionError("orphan ack gate never released"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + orphanAcked = true; + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(state.seq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** True once the gated orphan frame has actually been acked (gate released). */ + boolean orphanAcked() { + return orphanAcked; + } + + void releaseOrphan() { + orphanGate.countDown(); + } + + private static class ConnState { + final List dict = new ArrayList<>(); + final AtomicLong seq = new AtomicLong(0); + } + } +} From efd3e4f9127e2b04fb6bd57214f77154a172ce84 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:11:31 +0100 Subject: [PATCH 15/49] Pin zero-catch-up on the recycle epoch boundary SymbolDictRecycleCatchUpSkipTest pins that recycleForDictReset()'s step 7 reconnect never pays for a delta-dictionary catch-up frame: the rebuilt engine sits on a freshly-emptied slot, so the new loop's sentDictCount mirror seeds from PersistedSymbolDict.recoveredSize() == 0 and setWireBaselineWithCatchUp's gate stays false for the whole first post-recycle connection. The core scenario chains the negative and positive observations in one test so the zero count is provably a property, not a handler blind spot: after the recycle sends zero zero-table frames and tiles ids from 0, the handler force-drops the connection, and the resulting UNPLANNED reconnect does catch up -- bounded to exactly the new epoch's symbols, never replaying the retired epoch's. A follow-up symbol then ships with a delta start above 0, pinning that resetSymbolDictStateForNewConnection() on the plain-reconnect path preserves sentMaxSymbolId rather than folding the recycle's baseline reset into itself. A second test repeats the zero-catch-up observation under initial_connect_retry=async, where step 7's reconnect funnels through ensureConnected()'s ASYNC arm and the handshake completes on the I/O thread. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn --- .../SymbolDictRecycleCatchUpSkipTest.java | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java new file mode 100644 index 00000000..d0e56a29 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java @@ -0,0 +1,375 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.std.Compat; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Pins that the symbol-dictionary recycle's fresh connection ({@code + * QwpWebSocketSender.recycleForDictReset()}'s step 7 reconnect) never pays + * for a delta-dictionary catch-up frame, and that the state-reset it relies + * on to get there is not accidentally general-purpose. + *

    + * The catch-up mechanism itself is {@link DeltaDictCatchUpTest}'s territory + * ({@code CursorWebSocketSendLoop.setWireBaselineWithCatchUp}'s gate: + * {@code client != null && sentDictCount > 0 && hasReplayDictionaryDependency}). + * This suite does not re-implement or re-verify that mechanism -- it only + * observes the ONE fact specific to the recycle: {@code sentDictCount} on the + * fresh loop starts at 0 because {@code recycleForDictReset()}'s step 6 + * rebuilds the engine on a freshly-emptied slot, whose {@code + * PersistedSymbolDict.recoveredSize()} is 0 -- so the loop constructor's + * {@code pd.recoveredSize() > 0} seed never fires, and the gate stays false + * for the whole first post-recycle connection. A PLAIN (non-recycle) + * reconnect on that same connection, by contrast, reuses the SAME loop + * instance whose mirror has since grown from the frames it sent -- so it DOES + * trip the gate. Observing both back to back in one test is the only way to + * prove the zero count above is the recycle's fresh-mirror property and not a + * blind spot in how this suite's handler counts frames. + *

    + * No production change is expected to make these pass. A failure here means + * either the fresh-mirror seeding regressed (a post-recycle connection + * started paying for catch-up again) or {@code + * resetSymbolDictStateForNewConnection()} grew a second job (folding the + * recycle's own state reset into itself and clobbering {@code + * sentMaxSymbolId} on an ordinary reconnect). + */ +public class SymbolDictRecycleCatchUpSkipTest { + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + /** + * The core scenario, SF-disk mode. {@code symbol_dict_reset_threshold=3} + * is deliberately higher than the 2 symbols this test registers in the + * new epoch before forcing the unplanned drop: epoch 0 crosses the + * threshold on its own (a, b, x -- 3 distinct symbols), so the recycle + * fires exactly once, synchronously, on the "c" call. Epoch 1 then + * registers only c, d (2 symbols, below the threshold) before the drop, + * and only e (a 3rd) after it -- staying unarmed for the whole test so no + * SECOND recycle can sneak in and confound the "does a plain reconnect + * still catch up / preserve the baseline" assertions below. (A lower + * threshold that let epoch 1 re-arm on c, d would turn the later {@code + * table("e")} call into an unwanted second recycle, landing e on a 4th + * connection instead of a plain reconnect's 3rd -- exactly the + * confounder this threshold choice avoids.) + */ + @Test + public void testRecycleSkipsCatchUpThenUnplannedReconnectBoundsCatchUpToNewEpoch() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("catchup-skip").toString(); + SkipCatchUpHandler handler = new SkipCatchUpHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=3;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + // Epoch 0 (connection 1): 3 distinct symbols cross threshold=3 and arm. + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "x").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: the arming batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("must be armed after crossing threshold=3", ws.isResetArmed()); + Assert.assertEquals(1, handler.connectionsAccepted.get()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // Ring drained: this table() call recycles synchronously onto a fresh + // connection (2), a fresh (empty) engine/dictionary/epoch, and "c" is + // then the new epoch's own first symbol. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); + + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("epoch-1 batch must be acked before the unplanned drop", + sender.awaitAckedFsn(fsn2, 5_000)); + + // --- Pin 1 + 2: zero catch-up frames, dictionary tiles from 0. --- + // Connection 2 is the FIRST connection after the recycle: its loop's + // sentDictCount mirror was seeded from the fresh engine's + // PersistedSymbolDict.recoveredSize() == 0 (nothing survived the + // recycle's slot wipe), so setWireBaselineWithCatchUp's + // `sentDictCount > 0` gate stays false for this whole connection. + Assert.assertEquals("first post-recycle connection must send zero catch-up " + + "(zero-table) frames", + 0, handler.zeroTableFramesFor(2)); + Assert.assertEquals("connection 2's dictionary must tile ids from 0 with " + + "exactly the new epoch's symbols, none of epoch 0's a, b, x", + Arrays.asList("c", "d"), handler.dictFor(2)); + + // --- Positive control + pin 3: an UNPLANNED reconnect (server-side + // drop, no recycle involved) on this SAME connection DOES produce a + // catch-up frame, and that catch-up is bounded to exactly what this + // epoch has sent so far (c, d) -- proving both that the zero count + // above is a real property (not a handler blind spot) and that the + // recycle's fresh mirror does not somehow retain epoch 0's symbols. + handler.dropConnection(2); + waitFor(() -> handler.connectionsAccepted.get() >= 3, 5_000); + waitFor(() -> handler.dictFor(3).size() >= 2, 5_000); + + Assert.assertTrue("an unplanned reconnect mid-epoch must still produce a " + + "catch-up frame", + handler.zeroTableFramesFor(3) >= 1); + Assert.assertEquals("the catch-up must bound itself to exactly this epoch's " + + "own symbols (c, d), never replaying epoch 0's a, b, x", + Arrays.asList("c", "d"), handler.dictFor(3)); + + // --- Pin 4: the plain reconnect preserved sentMaxSymbolId. A NEW + // symbol registered after it must ship with a delta start ABOVE 0 -- + // resetSymbolDictStateForNewConnection only clears currentBatchMaxSymbolId, + // so the producer's baseline (c, d already at ids 0, 1) survives the + // wire boundary and e resumes at id 2. A regression that folded the + // recycle's sentMaxSymbolId reset into this general reconnect path + // would instead re-ship the whole dictionary from deltaStart 0. + sender.table("t").symbol("s", "e").longColumn("v", 4L).atNow(); + long fsn3 = sender.flushAndGetSequence(); + Assert.assertTrue("post-reconnect row must still get acked", + sender.awaitAckedFsn(fsn3, 5_000)); + Assert.assertTrue("connection 3's post-reconnect data frame carrying the new " + + "symbol e must ship a delta start ABOVE the surviving " + + "baseline (>= 1), not 0", + handler.sawDeltaAboveBaselineOn(3)); + } + + Assert.assertEquals("exactly 3 connections total (epoch 0, epoch 1's first " + + "connection, epoch 1's unplanned reconnect)", + 3, handler.connectionsAccepted.get()); + } + }); + } + + /** + * Pin 5: the recycle's step 7 reconnect funnels through {@code + * ensureConnected()}'s {@code ASYNC} arm exactly like any other initial + * connect, which ends up at the same {@code swapClient} catch-up gate as + * the SYNC-mode scenario above. Mirrors {@code + * SymbolDictRecycleMemoryModeTest#testRecycleUnderAsyncInitialConnect}, + * but in SF-disk mode (this suite's mode throughout) rather than memory + * mode, and asserts the zero-catch-up property instead of just the + * delta-start/dictionary-content pair that test already covers. + */ + @Test + public void testRecycleUnderAsyncInitialConnectSendsZeroCatchUpFrames() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("catchup-skip-async").toString(); + SkipCatchUpHandler handler = new SkipCatchUpHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";initial_connect_retry=async;symbol_dict_reset_threshold=2;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + // Let the I/O thread complete the deferred initial connect before + // driving any traffic through it (see + // SymbolDictRecycleMemoryModeTest.awaitWasEverConnected). + awaitWasEverConnected(ws); + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + Assert.assertEquals(1, handler.connectionsAccepted.get()); + Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + + // Recycles synchronously on the producer thread for steps 1-6; step + // 7's reconnect just re-arms the ASYNC path -- the actual handshake + // happens on the I/O thread and must be awaited via the ack below. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertFalse("recycle must disarm immediately (producer-side state)", + ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle batch must still get acked once the async " + + "I/O thread completes the fresh handshake", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals(2, handler.connectionsAccepted.get()); + } + + Assert.assertEquals("exactly 2 connections total", 2, handler.connectionsAccepted.get()); + Assert.assertEquals("the ASYNC path funnels through the same swapClient catch-up " + + "gate -- the first post-recycle connection must still send zero " + + "catch-up frames", + 0, handler.zeroTableFramesFor(2)); + Assert.assertEquals("connection 2's dictionary must hold only the post-recycle " + + "symbols, not a, b", + Arrays.asList("c", "d"), handler.dictFor(2)); + } + }); + } + + /** + * Spins until the I/O thread has completed the deferred ASYNC initial + * connect (mirrors {@code SymbolDictRecycleMemoryModeTest}'s helper of + * the same name). + */ + private static void awaitWasEverConnected(QwpWebSocketSender ws) { + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!ws.wasEverConnected()) { + if (System.nanoTime() > deadlineNanos) { + throw new AssertionError("I/O thread did not complete the async initial " + + "connect within 5s"); + } + Compat.onSpinWait(); + } + } + + private static void waitFor(BoolCondition cond, long timeoutMillis) { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + if (cond.test()) return; + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Assert.fail("interrupted"); + } + } + Assert.fail("waitFor timed out"); + } + + @FunctionalInterface + private interface BoolCondition { + boolean test(); + } + + /** + * Reconstructs each connection's per-connection delta dictionary (mirrors + * {@code DeltaDictCatchUpTest.CatchUpHandler} / {@code + * SymbolDictRecycleTest.RecycleHandler}), counts zero-table (catch-up) + * frames per connection, tracks whether any data frame on a connection + * carried a delta start above 0, and -- unlike the sibling handlers -- + * exposes {@link #dropConnection(int)} so the TEST THREAD can force an + * unplanned drop asynchronously, independent of the ack-driven close a + * handler normally does from inside {@code onBinaryMessage}. + */ + private static class SkipCatchUpHandler implements TestWebSocketServer.WebSocketServerHandler { + final AtomicInteger connectionsAccepted = new AtomicInteger(); + private final List> dictsByConn = new CopyOnWriteArrayList<>(); + private final List zeroTableFramesByConn = new CopyOnWriteArrayList<>(); + private final List deltaAboveBaselineByConn = new CopyOnWriteArrayList<>(); + private final List clientsByConn = new CopyOnWriteArrayList<>(); + private TestWebSocketServer.ClientHandler currentClient; + private final AtomicLong nextSeq = new AtomicLong(0); + + synchronized List dictFor(int connNumber) { + return connNumber <= dictsByConn.size() + // Copy under the lock: the caller iterates it unlocked while the + // server thread may still be appending to the live inner list. + ? new ArrayList<>(dictsByConn.get(connNumber - 1)) + : new ArrayList<>(); + } + + /** Closes connection N's socket from the caller's thread, forcing an unplanned reconnect. */ + void dropConnection(int connNumber) { + TestWebSocketServer.ClientHandler client; + synchronized (this) { + client = connNumber <= clientsByConn.size() ? clientsByConn.get(connNumber - 1) : null; + } + Assert.assertNotNull("no such connection to drop: " + connNumber, client); + client.close(); + } + + boolean sawDeltaAboveBaselineOn(int connNumber) { + return connNumber <= deltaAboveBaselineByConn.size() + && deltaAboveBaselineByConn.get(connNumber - 1).get(); + } + + int zeroTableFramesFor(int connNumber) { + return connNumber <= zeroTableFramesByConn.size() + ? zeroTableFramesByConn.get(connNumber - 1).get() + : 0; + } + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + boolean newConnection = currentClient != client; + if (newConnection) { + currentClient = client; + connectionsAccepted.incrementAndGet(); + dictsByConn.add(new ArrayList<>()); // fresh dictionary per connection + zeroTableFramesByConn.add(new AtomicInteger()); + deltaAboveBaselineByConn.add(new AtomicBoolean()); + clientsByConn.add(client); + nextSeq.set(0); + } + int connNumber = dictsByConn.size(); + List dict = dictsByConn.get(connNumber - 1); + QwpWireTestUtils.accumulateDeltaDictionary(data, dict); + if (QwpWireTestUtils.tableCount(data) == 0) { + zeroTableFramesByConn.get(connNumber - 1).incrementAndGet(); + } else if (QwpWireTestUtils.hasDelta(data)) { + // A DATA frame (tableCount > 0) carrying a delta section. A start id + // >= 1 means the producer resumed the delta ABOVE the surviving + // baseline; a reset baseline would instead re-ship from 0. + int[] pos = {HEADER_SIZE}; + if (QwpWireTestUtils.readVarint(data, pos) >= 1) { + deltaAboveBaselineByConn.get(connNumber - 1).set(true); + } + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} From c1a29bb3829bfd4abf9b0d1bb79991232af6f1ee Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:22:17 +0100 Subject: [PATCH 16/49] Fix round 1: correct Pin 4 mechanism attribution rev-t10 traced resetSymbolDictStateForNewConnection() to its single call site in ensureConnected(), which the unplanned I/O-thread reconnect (swapClient) never reaches -- so the old comment credited a function that does not even run on this path. sentMaxSymbolId survives the plain reconnect because nothing touches it there; only recycleForDictReset()'s step 5 ever zeroes the baseline. The class javadoc's failure-mode attribution carried the same imprecision and is reworded to match. The assertion itself was traced sound (symbolDeltaBaseline() -> encoder.beginMessage -> deltaStart) and is unchanged; comment-only diff. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn --- .../SymbolDictRecycleCatchUpSkipTest.java | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java index d0e56a29..a197617d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java @@ -70,10 +70,10 @@ *

    * No production change is expected to make these pass. A failure here means * either the fresh-mirror seeding regressed (a post-recycle connection - * started paying for catch-up again) or {@code - * resetSymbolDictStateForNewConnection()} grew a second job (folding the - * recycle's own state reset into itself and clobbering {@code - * sentMaxSymbolId} on an ordinary reconnect). + * started paying for catch-up again) or the recycle's {@code + * sentMaxSymbolId} reset ({@code recycleForDictReset()}'s step 5) leaked + * onto the ordinary reconnect path, which today never touches that + * baseline. */ public class SymbolDictRecycleCatchUpSkipTest { @@ -166,12 +166,15 @@ public void testRecycleSkipsCatchUpThenUnplannedReconnectBoundsCatchUpToNewEpoch Arrays.asList("c", "d"), handler.dictFor(3)); // --- Pin 4: the plain reconnect preserved sentMaxSymbolId. A NEW - // symbol registered after it must ship with a delta start ABOVE 0 -- - // resetSymbolDictStateForNewConnection only clears currentBatchMaxSymbolId, - // so the producer's baseline (c, d already at ids 0, 1) survives the - // wire boundary and e resumes at id 2. A regression that folded the - // recycle's sentMaxSymbolId reset into this general reconnect path - // would instead re-ship the whole dictionary from deltaStart 0. + // symbol registered after it must ship with a delta start ABOVE 0. + // Nothing on this I/O-thread reconnect path touches sentMaxSymbolId + // (resetSymbolDictStateForNewConnection runs only on the foreground + // initial-connect path, guarded by the connected flag, and never + // fires here), so the producer's baseline (c, d already at ids 0, 1) + // survives the wire boundary and e resumes at id 2. Only + // recycleForDictReset()'s step 5 ever zeroes that baseline; a + // regression that folded the reset into a path this reconnect DOES + // run would re-ship the whole dictionary from deltaStart 0. sender.table("t").symbol("s", "e").longColumn("v", 4L).atNow(); long fsn3 = sender.flushAndGetSequence(); Assert.assertTrue("post-reconnect row must still get acked", From debd45d6d8c8f831344299777fbe2fa8c222d93b Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:35:02 +0100 Subject: [PATCH 17/49] Pin recycle crash-window recovery Adds SymbolDictRecycleCrashWindowsTest, pinning what a restarted sender recovers if the process crashes at each of four points around QwpWebSocketSender.recycleForDictReset()'s 8-step symbol-dictionary recycle swap: - (a) before step 2 (the barrier that starts the swap): the pre-recycle epoch's slot holds a fully-acked batch on disk. Recovery must find that residue, recognize it as already acked (nothing to replay) and resume the SAME dictionary rather than starting fresh. Constructed by closing fast against a server that never acks (so the fully-drained unlink never fires) and then stamping the ack watermark directly to declare the batch acked retroactively, mirroring DeltaDictRecoveryTest#writeAckWatermark. - (b) between step 3 (fully-drained close of the old engine) and step 6 (rebuild): the slot is empty. Constructed by driving a real recycle to completion and then closing immediately, before any flush touches the freshly-rebuilt engine -- finishClose treats "nothing published yet" as fully drained too, so this unlinks everything step 6 just created, leaving the same empty state step 3 alone would have left. - (c) after step 7 (reconnect), before the new epoch's first flush: the slot holds a freshly-rebuilt engine's own state files but no data. Constructed by snapshotting the rebuilt slot's bytes before closing (there is no supported way to release just the slot's OS flock without running finishClose's unlink), closing for real so nothing leaks, then restoring the snapshot on top of the vacated directory. - (d) an ordinary mid-operation crash one epoch into the post-recycle steady state, to prove the epoch swap does not corrupt normal backlog recovery. Uses the same close-fast-against-a-non-acking-server idiom as RecoveryReplayTest, but only after the recycle's fresh connection is established. Arms (b) and (c) both replay nothing and both look "empty" at first glance, but they are not the same recoverable state and a restarted engine can tell them apart: (c)'s slot carries a manifest with collapsed boundaries alongside a same-based, zero-frame active segment, which SegmentRing.recover()'s chain-building accepts as a RECOVERED (if empty) chain, while (b)'s slot carries no engine state at all and recovers as EMPTY. wasRecoveredFromDisk() is the pinned, distinguishing observable between the two, asserted explicitly instead of writing two assertion-for-assertion duplicate tests. Every arm's oracle: the recovered sender keeps ingesting; the symbols it and its predecessor registered are exactly and correctly reconstructable from the wire (each fresh server handler rebuilds the per-connection delta dictionary); and no data frame is delivered more than the at-least-once contract allows (each handler counts data frames so a spurious re-send would show up as an unexpected count). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn --- .../SymbolDictRecycleCrashWindowsTest.java | 574 ++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java new file mode 100644 index 00000000..02d17999 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java @@ -0,0 +1,574 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client.sf.cursor; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.std.Files; +import io.questdb.client.test.cutlass.qwp.client.QwpWireTestUtils; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Crash-window recovery for {@code QwpWebSocketSender.recycleForDictReset()} + * (Task 5's 8-step symbol-dictionary recycle swap, quoted here for reference): + *

    + * 1. lastPublishedFsn = cursorEngine.publishedFsn()
    + * 2. close cursorSendLoop (I/O thread + client)
    + * 3. cursorEngine.close() -- FULLY DRAINED (the barrier only fires the swap once
    + *    isRingDrained() is true), so this unlinks every *.sfa, the ack watermark,
    + *    the persisted dictionary and the logical slot lock, leaving the slot empty.
    + * 4. rollFsnEpochBase(lastPublishedFsn)
    + * 5. producer state swap: fresh GlobalSymbolDictionary, sentMaxSymbolId=-1,
    + *    symbolDictEpoch++, resetArmed=false
    + * 6. cursorEngine = engineRebuildFactory.rebuild() -- a brand-new CursorSendEngine
    + *    on the now-empty slot (fresh .lock/.ack-watermark/.symbol-dict/segments)
    + * 7. reconnect (ensureConnected())
    + * 8. (catch) recycleFailure latch on any throw
    + * 
    + * This suite pins what a restarted sender recovers if the process dies at each + * of four points around that sequence, per the phase-11 brief: + *
      + *
    • (a) {@link #testCrashBeforeRecycleStartsRecoversAckedResidueOnly()} -- + * before step 2. The pre-recycle epoch's slot holds fully-acked residue.
    • + *
    • (b) {@link #testCrashBetweenEngineCloseAndRebuildRecoversAsFreshStart()} + * -- between steps 3 and 6. The slot is empty.
    • + *
    • (c) {@link #testCrashAfterRebuildBeforeFirstFlushRecoversAsRecoveredButEmpty()} + * -- after step 7, before the new epoch's first flush. The slot holds a + * freshly-rebuilt engine's own state files, but no data.
    • + *
    • (d) {@link #testCrashDuringSteadyStateEpochReplaysOnlyTheUnackedBacklog()} + * -- ordinary mid-operation crash recovery, but performed one epoch INTO the + * post-recycle steady state, to prove the epoch swap left nothing behind + * that could corrupt ordinary backlog replay.
    • + *
    + * + *

    Why these are simulated, not paused mid-sequence

    + * {@code recycleForDictReset()} runs synchronously inside one {@code table()} + * call with no external hook between its steps, so a test cannot literally + * suspend a live sender between step 3 and step 6. And unlike + * {@code CursorSendEngineCrashConsistencyTest}'s bare {@code CursorSendEngine} + + * fault-injecting {@code FilesFacade}, a {@code Sender} built through the public + * API (as production always does) has no seam for a custom {@code FilesFacade} + * -- {@code LineSenderBuilder.constructEngineOnSlot} always goes through the real + * filesystem. Each arm below instead constructs the exact on-disk image a crash + * at that point would leave, using only real production code paths plus + * filesystem-level fixtures already established elsewhere in this test suite + * ({@code DeltaDictRecoveryTest}'s {@code writeAckWatermark}, {@code + * RecoveryReplayTest}'s close-fast-with-a-silent-server idiom): + *
      + *
    • (a) closes fast against a server that never acks (so the fully-acked + * unlink branch never fires and the residue survives), then stamps the ack + * watermark directly to declare it acked retroactively -- simulating a real + * ack that landed on the wire a moment before the process died, before step + * 2 of the recycle ever started.
    • + *
    • (b) drives a real recycle through all 7 steps, then closes IMMEDIATELY, + * before any flush touches the freshly-rebuilt engine. {@code finishClose} + * treats {@code publishedFsn() < 0} as fully drained exactly like the + * everything-acked case, so this close unlinks every file step 6 just + * created -- the slot ends up in the same empty state that step 3 alone + * (on the OLD engine) would have left between tearing down and rebuilding. + * No rebuild-then-immediately-empty distinction survives on disk, since an + * empty directory carries no provenance.
    • + *
    • (c) also drives a real recycle to completion, but instead of closing + * it, snapshots the freshly-rebuilt slot's bytes to the side FIRST. The + * live sender is then closed normally -- for accounting purposes only, so + * nothing leaks -- and the snapshot is written back on top of the (now + * empty except for the reusable lock pair) directory. This is the only way + * to freeze that state: there is no supported way to release just the + * slot's OS flock without running {@code finishClose}'s unlink, and + * {@code finishClose} is exactly what this arm needs to NOT run.
    • + *
    • (d) drives a real recycle, appends more rows in the new epoch against + * a handler that stops acking after the first connection, then closes fast + * -- the established at-least-once backlog idiom, now exercised one epoch + * into the recycled sender's life.
    • + *
    + * + *

    (b) and (c) are NOT the same recoverable state

    + * Both look empty of data and both replay nothing, but they are not + * byte-identical on disk, and a restarted engine can tell them apart. Arm (b)'s + * directory holds nothing this engine ever created (no manifest, no segment). + * Arm (c)'s directory holds the fresh rebuild's own {@code sf-manifest.bin} + * (boundaries collapsed at 0) and its zero-frame {@code sf-initial.sfa} / + * {@code sf-...0000.sfa} pair. {@code SegmentRing.recover()}'s manifest branch + * (the {@code chain.size() == 0} check) accepts a manifest whose {@code + * headBase == activeBase} alongside a same-based, zero-frame active segment as + * a RECOVERED (if empty) chain -- it does not collapse that case to EMPTY the + * way a manifest with NO segment files at all does. So {@code + * wasRecoveredFromDisk()} comes back {@code false} for (b) and {@code true} for + * (c): the pinned, distinguishing observable between the two, asserted + * explicitly below instead of writing two assertion-for-assertion duplicate + * tests. + * + *

    Oracle

    + * Every arm asserts the same three things about the RECOVERED sender: it keeps + * ingesting after recovery; the symbols it and its predecessor registered are + * exactly and correctly reconstructable from the wire (each fresh server + * handler rebuilds the per-connection delta dictionary via {@link + * QwpWireTestUtils#accumulateDeltaDictionary}); and no data (table-carrying) + * frame is delivered more than the at-least-once contract allows (each handler + * also counts data frames, so a spurious re-send shows up as an unexpected + * count). + */ +public class SymbolDictRecycleCrashWindowsTest { + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + /** + * Arm (a): before step 2. The pre-recycle epoch has flushed a fully-acked + * batch but the barrier that starts {@code recycleForDictReset()} has not + * fired yet -- the crash lands with an intact, acked epoch-0 slot on disk. + * Recovery must find that residue, recognize it as already acked (nothing + * to replay) and resume the SAME (epoch-0) dictionary rather than starting + * fresh. + */ + @Test + public void testCrashBeforeRecycleStartsRecoversAckedResidueOnly() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("crash-a-pre-swap").toString(); + String slot = Paths.get(sfDir, "default").toString(); + long fsn; + SilentHandler crashedHandler = new SilentHandler(); + try (TestWebSocketServer crashed = startedServer(crashedHandler)) { + String cfg = "ws::addr=localhost:" + crashed.getPort() + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;close_flush_timeout_millis=0;"; + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + fsn = sender.flushAndGetSequence(); + Assert.assertTrue("threshold=2 crossed by a, b must arm the recycle " + + "immediately (arming does not wait for an ack)", + ws.isResetArmed()); + // close() below hits close_flush_timeout_millis=0 against a server + // that never acks: not fully drained, so finishClose does NOT unlink + // the segment/manifest/dictionary. It still releases the slot flock + // unconditionally (CursorSendEngine.finishClose's retryFlockReleaseIfReady + // runs in the outer finally regardless of drain state), so the + // successor below can acquire the slot cleanly. No recycle step ever + // ran -- the crash is strictly "before step 2". + } + } + // Retroactively declare the flush's only fsn acked: simulates the ack + // having actually landed moments before the process died, mirroring + // DeltaDictRecoveryTest#writeAckWatermark. + writeAckWatermark(slot, fsn); + + AckAllHandler freshHandler = new AckAllHandler(); + try (TestWebSocketServer fresh = startedServer(freshHandler)) { + String cfg2 = "ws::addr=localhost:" + fresh.getPort() + ";sf_dir=" + sfDir + ";"; + try (Sender successor = Sender.fromConfig(cfg2)) { + QwpWebSocketSender ws2 = (QwpWebSocketSender) successor; + CursorSendEngine recovered = ws2.getCursorEngineForTesting(); + Assert.assertTrue("acked residue on disk must be recognized as recovered", + recovered.wasRecoveredFromDisk()); + Assert.assertTrue("the watermark stamp must seed ackedFsn at least up to " + + "the only published fsn -- nothing left to replay", + recovered.ackedFsn() >= fsn); + Assert.assertTrue("the recovered producer must resume epoch 0's a, b " + + "dictionary, not restart at -1", + recovered.recoveredMaxSymbolId() >= 1); + + successor.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + long fsn2 = successor.flushAndGetSequence(); + Assert.assertTrue("recovered sender must keep ingesting", + successor.awaitAckedFsn(fsn2, 5_000)); + } + Assert.assertEquals("no replay of the already-acked a, b frame -- exactly " + + "the one new post-recovery frame reaches the server", + 1, freshHandler.dataFrameCount()); + Assert.assertEquals("the reconstructed dictionary is epoch 0's a, b plus " + + "the new c, correct and in order", + Arrays.asList("a", "b", "c"), freshHandler.dict()); + } + }); + } + + /** + * Arm (b): between steps 3 and 6. See the class javadoc for how this is + * constructed (drive a real recycle to completion, then close before any + * flush touches the rebuilt engine -- the "never published" fully-drained + * branch empties it exactly like step 3 alone would have). + */ + @Test + public void testCrashBetweenEngineCloseAndRebuildRecoversAsFreshStart() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("crash-b-mid-swap").toString(); + String slot = Paths.get(sfDir, "default").toString(); + AckAllHandler crashedHandler = new AckAllHandler(); + try (TestWebSocketServer crashed = startedServer(crashedHandler)) { + String cfg = "ws::addr=localhost:" + crashed.getPort() + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;"; + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue(ws.isResetArmed()); + + // A bare table() call fires the barrier hook before any row is + // constructed (mirrors SymbolDictRecycleTest#testFactoryRebuildsOnEmptySlot), + // so steps 1-7 run to completion with nothing left pending to flush. + sender.table("t"); + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + // close() below: the fresh engine has published nothing, so + // finishClose's "never published" branch is fully-drained too -- + // it unlinks everything step 6 just created, leaving the slot as + // empty as it was right after step 3 alone emptied the OLD engine. + } + } + + AckAllHandler freshHandler = new AckAllHandler(); + try (TestWebSocketServer fresh = startedServer(freshHandler)) { + String cfg2 = "ws::addr=localhost:" + fresh.getPort() + ";sf_dir=" + sfDir + ";"; + try (Sender successor = Sender.fromConfig(cfg2)) { + QwpWebSocketSender ws2 = (QwpWebSocketSender) successor; + CursorSendEngine recovered = ws2.getCursorEngineForTesting(); + Assert.assertFalse("an empty slot has nothing to recover -- see the " + + "class javadoc for why this differs from arm (c)", + recovered.wasRecoveredFromDisk()); + Assert.assertEquals(-1L, recovered.recoveredMaxSymbolId()); + Assert.assertEquals(-1L, recovered.publishedFsn()); + + successor.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn = successor.flushAndGetSequence(); + Assert.assertTrue(successor.awaitAckedFsn(fsn, 5_000)); + } + Assert.assertEquals("exactly the one post-crash frame reaches the server", + 1, freshHandler.dataFrameCount()); + Assert.assertEquals("the new epoch's dictionary tiles from id 0 -- none of " + + "the pre-crash a, b survive", + Arrays.asList("d"), freshHandler.dict()); + } + Assert.assertFalse("the slot dir must hold nothing from the emptied-and-abandoned " + + "rebuild once the successor's own recovery has cleaned up any " + + "stale (collapsed-boundary) manifest", + Files.exists(slot + "/sf-manifest.bin")); + }); + } + + /** + * Arm (c): after step 7, pre-first-flush. See the class javadoc for how + * this is constructed (snapshot the freshly-rebuilt slot before closing, + * close for real so nothing leaks, then restore the snapshot on top of the + * vacated directory) and for why this recovers differently from arm (b) + * despite carrying no data either. + */ + @Test + public void testCrashAfterRebuildBeforeFirstFlushRecoversAsRecoveredButEmpty() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("crash-c-post-swap").toString(); + String slot = Paths.get(sfDir, "default").toString(); + Map snapshot; + AckAllHandler crashedHandler = new AckAllHandler(); + try (TestWebSocketServer crashed = startedServer(crashedHandler)) { + String cfg = "ws::addr=localhost:" + crashed.getPort() + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;"; + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue(ws.isResetArmed()); + + sender.table("t"); // bare call: drives steps 1-7, nothing left pending + Assert.assertFalse(ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + + // The true pre-first-flush crash image, frozen before the + // upcoming close() would otherwise unlink it (arm (b)). + snapshot = snapshotDir(slot); + } + } + restoreDir(slot, snapshot); + + Assert.assertEquals("the restored image is exactly a freshly rebuilt (never " + + "flushed) engine's own state files", + Arrays.asList(".ack-watermark", ".lock", ".lock.pid", ".symbol-dict", + "sf-0000000000000000.sfa", "sf-initial.sfa", "sf-manifest.bin"), + listDir(slot)); + + AckAllHandler freshHandler = new AckAllHandler(); + try (TestWebSocketServer fresh = startedServer(freshHandler)) { + String cfg2 = "ws::addr=localhost:" + fresh.getPort() + ";sf_dir=" + sfDir + ";"; + try (Sender successor = Sender.fromConfig(cfg2)) { + QwpWebSocketSender ws2 = (QwpWebSocketSender) successor; + CursorSendEngine recovered = ws2.getCursorEngineForTesting(); + // The pinned discriminator vs arm (b): a manifest with collapsed + // (headBase == activeBase) boundaries alongside a same-based, + // zero-frame active segment recovers as RECOVERED, not EMPTY -- + // see SegmentRing.recover()'s chain.size()==0 branch and the class + // javadoc. + Assert.assertTrue("a manifest + zero-frame active segment recovers as " + + "RECOVERED even though it carries no data, unlike arm " + + "(b)'s genuinely empty directory", + recovered.wasRecoveredFromDisk()); + Assert.assertEquals(-1L, recovered.recoveredMaxSymbolId()); + Assert.assertEquals(-1L, recovered.publishedFsn()); + + successor.table("t").symbol("s", "e").longColumn("v", 4L).atNow(); + long fsn = successor.flushAndGetSequence(); + Assert.assertTrue(successor.awaitAckedFsn(fsn, 5_000)); + } + Assert.assertEquals("exactly the one post-crash frame reaches the server -- " + + "there was never anything else to replay", + 1, freshHandler.dataFrameCount()); + Assert.assertEquals("the new epoch's dictionary tiles from id 0 -- none of " + + "the pre-crash a, b survive", + Arrays.asList("e"), freshHandler.dict()); + } + }); + } + + /** + * Arm (d): an ordinary mid-operation crash, but one epoch into the + * post-recycle steady state (epoch g+1), to prove the recycle's epoch + * bookkeeping does not corrupt normal backlog recovery. Uses the same + * close-fast-with-an-unacking-server idiom as {@code RecoveryReplayTest}, + * except the handler only stops acking AFTER the recycle's fresh + * connection is established, so epoch 0's setup batch is genuinely acked + * (arming the recycle) and only epoch 1's backlog survives unacked. + */ + @Test + public void testCrashDuringSteadyStateEpochReplaysOnlyTheUnackedBacklog() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("crash-d-steady-state").toString(); + AckFirstConnectionSilentAfterHandler crashedHandler = + new AckFirstConnectionSilentAfterHandler(); + try (TestWebSocketServer crashed = startedServer(crashedHandler)) { + String cfg = "ws::addr=localhost:" + crashed.getPort() + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;close_flush_timeout_millis=0;"; + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup batch on connection 1 must be genuinely acked", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue(ws.isResetArmed()); + + // Triggers the recycle (opens connection 2, which the handler never + // acks) and immediately queues c, d into the fresh epoch. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + Assert.assertFalse(ws.isResetArmed()); + Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + sender.flushAndGetSequence(); + // Not drained (connection 2 never acks): close_flush_timeout_millis=0 + // returns immediately without unlinking, preserving c, d's segment + // and dictionary on disk while still releasing the slot flock. + } + } + + AckAllHandler freshHandler = new AckAllHandler(); + try (TestWebSocketServer fresh = startedServer(freshHandler)) { + String cfg2 = "ws::addr=localhost:" + fresh.getPort() + ";sf_dir=" + sfDir + ";"; + try (Sender successor = Sender.fromConfig(cfg2)) { + QwpWebSocketSender ws2 = (QwpWebSocketSender) successor; + CursorSendEngine recovered = ws2.getCursorEngineForTesting(); + Assert.assertTrue("epoch 1's unacked c, d segment must be recovered", + recovered.wasRecoveredFromDisk()); + Assert.assertTrue("epoch 1's own dictionary (c, d only) must be recovered, " + + "never epoch 0's a, b -- the recycle's slot wipe erased them", + recovered.recoveredMaxSymbolId() >= 1); + + Assert.assertTrue("the recovered sender must replay the backlog and " + + "get it acked", + successor.awaitAckedFsn(recovered.publishedFsn(), 5_000)); + + successor.table("t").symbol("s", "e").longColumn("v", 4L).atNow(); + long fsn = successor.flushAndGetSequence(); + Assert.assertTrue(successor.awaitAckedFsn(fsn, 5_000)); + } + Assert.assertEquals("the unacked c, d backlog frame replays exactly once, " + + "plus exactly one new frame for e -- no extra replay " + + "attempts beyond the at-least-once contract", + 2, freshHandler.dataFrameCount()); + Assert.assertEquals("the reconstructed dictionary is epoch 1's own c, d plus " + + "the new e -- none of epoch 0's a, b ever reappear", + Arrays.asList("c", "d", "e"), freshHandler.dict()); + } + }); + } + + /** Sorted list of entry names directly inside {@code dir} (no recursion, no "."/".."). */ + private static List listDir(String dir) { + List names = new ArrayList<>(); + long find = Files.findFirst(dir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + names.add(name); + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Collections.sort(names); + return names; + } + + /** Copies every file directly inside {@code dir} (by name -> bytes) for later {@link #restoreDir}. */ + private static Map snapshotDir(String dir) throws IOException { + Map snapshot = new LinkedHashMap<>(); + for (String name : listDir(dir)) { + snapshot.put(name, java.nio.file.Files.readAllBytes(Paths.get(dir, name))); + } + return snapshot; + } + + /** Writes back a {@link #snapshotDir} capture, creating or overwriting each file by name. */ + private static void restoreDir(String dir, Map snapshot) throws IOException { + for (Map.Entry entry : snapshot.entrySet()) { + java.nio.file.Files.write(Paths.get(dir, entry.getKey()), entry.getValue()); + } + } + + private static TestWebSocketServer startedServer(TestWebSocketServer.WebSocketServerHandler handler) + throws Exception { + TestWebSocketServer server = new TestWebSocketServer(handler); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + /** + * Mirrors {@code QwpWireTestUtils.tableCount} -- the wire header's table + * count, a little-endian uint16 at offset 6. That method is package-private + * and this suite lives one package below {@code QwpWireTestUtils} + * ({@code ...client.sf.cursor} vs {@code ...client}), so it cannot reach it. + */ + private static int tableCount(byte[] frame) { + return (frame[6] & 0xFF) | ((frame[7] & 0xFF) << 8); + } + + /** Directly stamps {@code /.ack-watermark}, mirroring DeltaDictRecoveryTest#writeAckWatermark. */ + private static void writeAckWatermark(String slotDir, long fsn) { + AckWatermark watermark = AckWatermark.open(slotDir); + Assert.assertNotNull("ack watermark must open for the test fixture", watermark); + try { + watermark.write(fsn); + watermark.sync(); + } finally { + watermark.close(); + } + } + + /** + * Acks connection 1 in full; every later connection (2, 3, ...) is silently + * dropped, as if the process died the instant that connection opened. + */ + private static class AckFirstConnectionSilentAfterHandler + implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicInteger connectionsAccepted = new AtomicInteger(); + private final AtomicLong nextSeq = new AtomicLong(0); + private boolean ackCurrentConnection; + private TestWebSocketServer.ClientHandler currentClient; + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (currentClient != client) { + currentClient = client; + ackCurrentConnection = connectionsAccepted.incrementAndGet() == 1; + nextSeq.set(0); + } + if (ackCurrentConnection) { + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + // else: intentionally dropped, simulating a crash on this connection. + } + } + + /** + * Acks every frame; reconstructs the connection's delta dictionary and + * counts data (table-carrying) frames so a test can catch an unwanted + * duplicate replay. + */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final List dict = new ArrayList<>(); + private int dataFrameCount; + private final AtomicLong nextSeq = new AtomicLong(0); + + synchronized int dataFrameCount() { + return dataFrameCount; + } + + synchronized List dict() { + return new ArrayList<>(dict); + } + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + QwpWireTestUtils.accumulateDeltaDictionary(data, dict); + if (tableCount(data) > 0) { + dataFrameCount++; + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** Never acks -- the crashed-process side of every close-fast fixture in this suite. */ + private static class SilentHandler implements TestWebSocketServer.WebSocketServerHandler { + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + // intentionally empty + } + } +} From b5ba2b2619b8bbbf96944bfe6c49d4d57062a8ed Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:10:37 +0100 Subject: [PATCH 18/49] Fix round 1: pin arm (b) disk image, tighten oracles Applies review findings 1, 2, 3, 4, 6, 7, 8 and 10 from the task-11 review (findings 5, 9, 11 are deferred to the whole-branch review): - Arm (b) never actually pinned the "empty slot" disk image it exists to test -- it inferred emptiness from wasRecoveredFromDisk()==false on the successor instead of asserting the crashed sender's own slot dir. Adds an explicit listDir(slot) == [".lock", ".lock.pid"] assertion right after the crashed sender closes (symmetric to arm (c)'s pre-existing file-set assertion), plus a second one after the successor's own fully-drained close, proving the empty-slot state is stable rather than a one-shot coincidence. This also replaces arm (b)'s closing assertion, whose message previously credited the successor's "recovery" for cleaning up a stale manifest that the crashed sender's own close had already removed. - Arm (c) snapshotted the freshly-rebuilt slot before waiting for the manager worker's asynchronous hot-spare provisioning to settle, so a mid-provision snapshot could race-capture a zero-magic spare that recovery would hard-fail on. Adds a bounded poll (awaitExactFileSet) before the snapshot, reusing the same expected file list (now a shared FRESH_REBUILD_FILES constant) as the post-restore assertion so the two can never drift apart. - Arms (a) and (d) loosely asserted recoveredMaxSymbolId() >= 1, which a leaked epoch-0-plus-epoch-1 dictionary would also satisfy. Tightens both to the deterministic exact value (1L), keeping their explanatory messages as-is now that the assertion actually proves what the message claims. - Widens QwpWireTestUtils.tableCount to public (its sibling frame helpers already are) and deletes this suite's local copy plus its now-unnecessary justification javadoc. - Inlines the AckAllHandler bindings in arms (b) and (c) that were never read (dict()/dataFrameCount() were only meaningful on the "fresh" server's handler, not the "crashed" one). - Class javadoc: notes that arm (c)'s snapshot/restore does not cover the logical slot lock (lives outside the slot dir; sender.close() reclaims it, acquireLogical recreates it -- benign), and corrects three mechanism imprecisions the review traced: the "never published" fully-drained check lives in close(boolean), not finishClose; a fully-drained close does not remove .lock/.lock.pid; and arm (b)'s SegmentRing.recover() counterpart is the no-manifest fall-through to Recovery.empty(), not the manifest-present collapse branch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn --- .../cutlass/qwp/client/QwpWireTestUtils.java | 2 +- .../SymbolDictRecycleCrashWindowsTest.java | 136 ++++++++++++------ 2 files changed, 91 insertions(+), 47 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWireTestUtils.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWireTestUtils.java index 1ed78752..8b42b337 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWireTestUtils.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWireTestUtils.java @@ -147,7 +147,7 @@ static int readVarint(byte[] buffer, int[] position) { throw new IllegalStateException("varint truncated"); } - static int tableCount(byte[] frame) { + public static int tableCount(byte[] frame) { return (frame[6] & 0xFF) | ((frame[7] & 0xFF) << 8); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java index 02d17999..d9258abf 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java @@ -103,13 +103,16 @@ * ack that landed on the wire a moment before the process died, before step * 2 of the recycle ever started. *
  • (b) drives a real recycle through all 7 steps, then closes IMMEDIATELY, - * before any flush touches the freshly-rebuilt engine. {@code finishClose} - * treats {@code publishedFsn() < 0} as fully drained exactly like the - * everything-acked case, so this close unlinks every file step 6 just - * created -- the slot ends up in the same empty state that step 3 alone - * (on the OLD engine) would have left between tearing down and rebuilding. - * No rebuild-then-immediately-empty distinction survives on disk, since an - * empty directory carries no provenance.
  • + * before any flush touches the freshly-rebuilt engine. {@code + * close(boolean)} classifies {@code publishedFsn() < 0} as fully drained + * exactly like the everything-acked case (that check lives there, not in + * {@code finishClose}, which only receives the resulting flag), so this + * close unlinks every SF state file step 6 just created -- everything but + * the reusable {@code .lock}/{@code .lock.pid} pair, which no close in this + * suite ever removes -- leaving the slot in the same empty state that step + * 3 alone (on the OLD engine) would have left between tearing down and + * rebuilding. No rebuild-then-immediately-empty distinction survives on + * disk, since an empty directory carries no provenance. *
  • (c) also drives a real recycle to completion, but instead of closing * it, snapshots the freshly-rebuilt slot's bytes to the side FIRST. The * live sender is then closed normally -- for accounting purposes only, so @@ -117,7 +120,13 @@ * empty except for the reusable lock pair) directory. This is the only way * to freeze that state: there is no supported way to release just the * slot's OS flock without running {@code finishClose}'s unlink, and - * {@code finishClose} is exactly what this arm needs to NOT run.
  • + * {@code finishClose} is exactly what this arm needs to NOT run. One + * immaterial divergence: {@code close()} also reclaims the LOGICAL slot + * lock, which lives outside the slot dir in the sibling {@code + * .slot-locks/} directory and so is untouched by the snapshot/restore -- a + * real post-step-7 crash would leave that file present with its flock + * kernel-released, but {@code acquireLogical} recreates a missing one, so + * nothing observable changes. *
  • (d) drives a real recycle, appends more rows in the new epoch against * a handler that stops acking after the first connection, then closes fast * -- the established at-least-once backlog idiom, now exercised one epoch @@ -127,14 +136,17 @@ *

    (b) and (c) are NOT the same recoverable state

    * Both look empty of data and both replay nothing, but they are not * byte-identical on disk, and a restarted engine can tell them apart. Arm (b)'s - * directory holds nothing this engine ever created (no manifest, no segment). - * Arm (c)'s directory holds the fresh rebuild's own {@code sf-manifest.bin} - * (boundaries collapsed at 0) and its zero-frame {@code sf-initial.sfa} / - * {@code sf-...0000.sfa} pair. {@code SegmentRing.recover()}'s manifest branch - * (the {@code chain.size() == 0} check) accepts a manifest whose {@code - * headBase == activeBase} alongside a same-based, zero-frame active segment as - * a RECOVERED (if empty) chain -- it does not collapse that case to EMPTY the - * way a manifest with NO segment files at all does. So {@code + * directory holds nothing this engine ever created -- no manifest, no segment. + * (The crashed sender's own fully-drained close already removed {@code + * sf-manifest.bin} along with the last segment, so recovery finds NO {@code + * .sfa} files and NO manifest, and falls straight through to {@code + * Recovery.empty()}.) Arm (c)'s directory holds the fresh rebuild's own + * {@code sf-manifest.bin} (boundaries collapsed at 0) and its zero-frame + * {@code sf-initial.sfa} / {@code sf-...0000.sfa} pair. {@code + * SegmentRing.recover()}'s manifest branch (the {@code chain.size() == 0} + * check) accepts a manifest whose {@code headBase == activeBase} alongside a + * same-based, zero-frame active segment as a RECOVERED (if empty) chain -- a + * different branch entirely from the one arm (b) falls through to. So {@code * wasRecoveredFromDisk()} comes back {@code false} for (b) and {@code true} for * (c): the pinned, distinguishing observable between the two, asserted * explicitly below instead of writing two assertion-for-assertion duplicate @@ -152,6 +164,16 @@ */ public class SymbolDictRecycleCrashWindowsTest { + /** + * The exact file set a freshly-rebuilt (never-flushed) engine's own slot + * holds -- matches {@code SymbolDictRecycleTest#testPostRecycleSlotContents}'s + * {@code freshSlotFiles}. Shared by arm (c)'s pre-snapshot wait and its + * post-restore assertion so the two can never drift apart. + */ + private static final List FRESH_REBUILD_FILES = Arrays.asList( + ".ack-watermark", ".lock", ".lock.pid", ".symbol-dict", + "sf-0000000000000000.sfa", "sf-initial.sfa", "sf-manifest.bin"); + @Rule public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); @@ -206,9 +228,9 @@ public void testCrashBeforeRecycleStartsRecoversAckedResidueOnly() throws Except Assert.assertTrue("the watermark stamp must seed ackedFsn at least up to " + "the only published fsn -- nothing left to replay", recovered.ackedFsn() >= fsn); - Assert.assertTrue("the recovered producer must resume epoch 0's a, b " - + "dictionary, not restart at -1", - recovered.recoveredMaxSymbolId() >= 1); + Assert.assertEquals("the recovered producer must resume epoch 0's a, b " + + "dictionary (ids 0, 1), not restart at -1", + 1L, recovered.recoveredMaxSymbolId()); successor.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); long fsn2 = successor.flushAndGetSequence(); @@ -236,8 +258,7 @@ public void testCrashBetweenEngineCloseAndRebuildRecoversAsFreshStart() throws E assertMemoryLeak(() -> { String sfDir = temporaryFolder.getRoot().toPath().resolve("crash-b-mid-swap").toString(); String slot = Paths.get(sfDir, "default").toString(); - AckAllHandler crashedHandler = new AckAllHandler(); - try (TestWebSocketServer crashed = startedServer(crashedHandler)) { + try (TestWebSocketServer crashed = startedServer(new AckAllHandler())) { String cfg = "ws::addr=localhost:" + crashed.getPort() + ";sf_dir=" + sfDir + ";symbol_dict_reset_threshold=2;"; try (Sender sender = Sender.fromConfig(cfg)) { @@ -255,11 +276,17 @@ public void testCrashBetweenEngineCloseAndRebuildRecoversAsFreshStart() throws E Assert.assertFalse("recycle must disarm", ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); // close() below: the fresh engine has published nothing, so - // finishClose's "never published" branch is fully-drained too -- - // it unlinks everything step 6 just created, leaving the slot as - // empty as it was right after step 3 alone emptied the OLD engine. + // close(boolean)'s "never published" check (CursorSendEngine's + // publishedFsn() < 0 branch) classifies it fully-drained too -- + // finishClose unlinks every SF state file step 6 just created + // (everything but .lock/.lock.pid), leaving the slot as empty + // as it was right after step 3 alone emptied the OLD engine. } } + Assert.assertEquals("a crash between steps 3 and 6 leaves the slot dir " + + "holding only the reusable lock pair -- this is the " + + "disk image this arm exists to pin", + Arrays.asList(".lock", ".lock.pid"), listDir(slot)); AckAllHandler freshHandler = new AckAllHandler(); try (TestWebSocketServer fresh = startedServer(freshHandler)) { @@ -283,10 +310,12 @@ public void testCrashBetweenEngineCloseAndRebuildRecoversAsFreshStart() throws E + "the pre-crash a, b survive", Arrays.asList("d"), freshHandler.dict()); } - Assert.assertFalse("the slot dir must hold nothing from the emptied-and-abandoned " - + "rebuild once the successor's own recovery has cleaned up any " - + "stale (collapsed-boundary) manifest", - Files.exists(slot + "/sf-manifest.bin")); + // The successor's own row is fully acked by now, so its own close is + // fully drained too and the slot settles back to the same lock-only + // image -- confirms the cycle is stable, not a one-shot coincidence. + Assert.assertEquals("the successor's fully-drained close leaves the slot " + + "dir back down to just the reusable lock pair", + Arrays.asList(".lock", ".lock.pid"), listDir(slot)); }); } @@ -303,8 +332,7 @@ public void testCrashAfterRebuildBeforeFirstFlushRecoversAsRecoveredButEmpty() t String sfDir = temporaryFolder.getRoot().toPath().resolve("crash-c-post-swap").toString(); String slot = Paths.get(sfDir, "default").toString(); Map snapshot; - AckAllHandler crashedHandler = new AckAllHandler(); - try (TestWebSocketServer crashed = startedServer(crashedHandler)) { + try (TestWebSocketServer crashed = startedServer(new AckAllHandler())) { String cfg = "ws::addr=localhost:" + crashed.getPort() + ";sf_dir=" + sfDir + ";symbol_dict_reset_threshold=2;"; try (Sender sender = Sender.fromConfig(cfg)) { @@ -319,6 +347,15 @@ public void testCrashAfterRebuildBeforeFirstFlushRecoversAsRecoveredButEmpty() t Assert.assertFalse(ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + // The manager worker provisions the fresh engine's hot-spare + // segment asynchronously (its own service pass, off the + // producer thread), so the slot is not guaranteed to have + // settled to its steady rebuilt-engine file set the instant + // table() returns. Wait for it before snapshotting -- a + // mid-provision snapshot could capture a zero-magic spare + // that recovery would then hard-fail on. + awaitExactFileSet(slot, FRESH_REBUILD_FILES); + // The true pre-first-flush crash image, frozen before the // upcoming close() would otherwise unlink it (arm (b)). snapshot = snapshotDir(slot); @@ -328,9 +365,7 @@ public void testCrashAfterRebuildBeforeFirstFlushRecoversAsRecoveredButEmpty() t Assert.assertEquals("the restored image is exactly a freshly rebuilt (never " + "flushed) engine's own state files", - Arrays.asList(".ack-watermark", ".lock", ".lock.pid", ".symbol-dict", - "sf-0000000000000000.sfa", "sf-initial.sfa", "sf-manifest.bin"), - listDir(slot)); + FRESH_REBUILD_FILES, listDir(slot)); AckAllHandler freshHandler = new AckAllHandler(); try (TestWebSocketServer fresh = startedServer(freshHandler)) { @@ -412,9 +447,9 @@ public void testCrashDuringSteadyStateEpochReplaysOnlyTheUnackedBacklog() throws CursorSendEngine recovered = ws2.getCursorEngineForTesting(); Assert.assertTrue("epoch 1's unacked c, d segment must be recovered", recovered.wasRecoveredFromDisk()); - Assert.assertTrue("epoch 1's own dictionary (c, d only) must be recovered, " + Assert.assertEquals("epoch 1's own dictionary (c, d only) must be recovered, " + "never epoch 0's a, b -- the recycle's slot wipe erased them", - recovered.recoveredMaxSymbolId() >= 1); + 1L, recovered.recoveredMaxSymbolId()); Assert.assertTrue("the recovered sender must replay the backlog and " + "get it acked", @@ -457,6 +492,25 @@ private static List listDir(String dir) { return names; } + /** + * Polls {@code listDir(dir)} until it equals {@code expected} or a 5s + * deadline elapses, then asserts the final state -- the manager worker + * provisions a fresh engine's hot-spare segment asynchronously (its own + * service pass, off the producer thread), so the slot dir is not + * guaranteed to hold its steady-state file set the instant a producer-side + * call returns. Same shape as the deadline loops in + * {@code DeltaDictRecoveryTest}. + */ + private static void awaitExactFileSet(String dir, List expected) throws InterruptedException { + long deadline = System.currentTimeMillis() + 5_000; + while (System.currentTimeMillis() < deadline && !expected.equals(listDir(dir))) { + Thread.sleep(20); + } + Assert.assertEquals("slot dir must settle to its steady-state file set " + + "before it can be snapshotted", + expected, listDir(dir)); + } + /** Copies every file directly inside {@code dir} (by name -> bytes) for later {@link #restoreDir}. */ private static Map snapshotDir(String dir) throws IOException { Map snapshot = new LinkedHashMap<>(); @@ -481,16 +535,6 @@ private static TestWebSocketServer startedServer(TestWebSocketServer.WebSocketSe return server; } - /** - * Mirrors {@code QwpWireTestUtils.tableCount} -- the wire header's table - * count, a little-endian uint16 at offset 6. That method is package-private - * and this suite lives one package below {@code QwpWireTestUtils} - * ({@code ...client.sf.cursor} vs {@code ...client}), so it cannot reach it. - */ - private static int tableCount(byte[] frame) { - return (frame[6] & 0xFF) | ((frame[7] & 0xFF) << 8); - } - /** Directly stamps {@code /.ack-watermark}, mirroring DeltaDictRecoveryTest#writeAckWatermark. */ private static void writeAckWatermark(String slotDir, long fsn) { AckWatermark watermark = AckWatermark.open(slotDir); @@ -553,7 +597,7 @@ synchronized List dict() { @Override public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { QwpWireTestUtils.accumulateDeltaDictionary(data, dict); - if (tableCount(data) > 0) { + if (QwpWireTestUtils.tableCount(data) > 0) { dataFrameCount++; } try { From 07ecb27623a461c91b1e815ef41b2c1873fe1818 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:36:06 +0100 Subject: [PATCH 19/49] Recycle heals full-dict degrade; expose recycle metrics A sender that degrades to full self-sufficient frames after a symbol-dict persistence fault (disableDeltaDict) used to stay degraded for the rest of its life -- deltaDictEnabled was set once at engine construction and never re-evaluated. The symbol-dictionary recycle already rebuilds the cursor engine from scratch on every swap; make that rebuild also re-derive deltaDictEnabled from the fresh engine instead of carrying the old one's verdict forward. If the underlying fault has cleared, the next recycle heals the sender back into delta mode; if it has not, the fresh engine degrades again on its own first append, the same ordinary catchable LineSenderException as any other persistence fault -- a degrade, never a latched recycleFailure terminal state. Promote the epoch and starvation-timeout counters from @TestOnly accessors to permanent public API: getSymbolDictEpochForTest() becomes getSymbolDictEpoch(), and getSymbolDictResetStarvationTimeoutsForTest() becomes getSymbolDictResetStarvationTimeouts(). Both counters already existed; this only changes their visibility and documents their thread-safety contract (producer-thread-written, so a read from another thread is an eventually-consistent snapshot). Add a third counter, getSymbolDictResetsPerformed(), incremented alongside the epoch inside recycleForDictReset() -- the two move together today but are defined and incremented independently, since a future change could roll the epoch by some path other than a completed recycle swap. Migrate every existing call site of the two renamed getters (7 test files, 48 + 15 occurrences) to the new public names. SymbolDictRecycleHealingTest covers all of this: a fault-then-heal recycle proves deltaDictEnabled and wire framing both return to proper delta encoding (the second post-recycle frame's delta starts where the first left off and carries only the newly-added symbol, the shape only delta mode produces); a fault-persists variant proves the fresh engine degrades again without escaping as a raw error or latching the sender terminal; and a two-recycle run asserts the three metrics getters track correctly in lockstep while the starvation counter stays untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn --- .../qwp/client/QwpWebSocketSender.java | 46 +- .../SymbolDictRecycleCatchUpSkipTest.java | 8 +- .../client/SymbolDictRecycleHealingTest.java | 428 ++++++++++++++++++ .../SymbolDictRecycleMemoryModeTest.java | 12 +- .../client/SymbolDictRecycleOutageTest.java | 8 +- .../client/SymbolDictRecycleRefusalTest.java | 40 +- .../SymbolDictRecycleStarvationTest.java | 30 +- .../qwp/client/SymbolDictRecycleTest.java | 10 +- .../SymbolDictRecycleCrashWindowsTest.java | 6 +- 9 files changed, 523 insertions(+), 65 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 1f62fc3c..c4494e0c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -454,6 +454,13 @@ public interface EngineRebuildFactory { // Incremented once per completed symbol-dictionary recycle. 0 until the // first recycle commits. private long symbolDictEpoch; + // Incremented once per completed symbol-dictionary recycle swap, beside + // symbolDictEpoch (recycleForDictReset step 5). The two move together + // today -- the only way symbolDictEpoch advances is through a committed + // recycle swap -- but they count different things (dictionary generation + // vs. completed swaps) and are incremented independently in case a + // future change ever rolls the epoch by some path other than a recycle. + private long symbolDictResetsPerformed; private long reconnectInitialBackoffMillis = CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS; private long reconnectMaxBackoffMillis = @@ -2136,20 +2143,41 @@ public long getFsnEpochBaseForTest() { } /** - * Number of symbol-dictionary recycles this sender has completed. - * Incremented by {@link #recycleForDictReset()} once the swap commits. + * Number of symbol-dictionary recycles this sender has completed. Advances + * by one at step 5 of {@link #recycleForDictReset()}, the instant the swap + * commits to the new epoch -- before the engine rebuild (step 6) or the + * reconnect (step 7), so a later rebuild/reconnect failure that latches + * {@link #recycleFailure} still leaves this incremented. Not synchronized: + * like the other symbol-dictionary-recycle fields, it is written only from + * the producer thread inside {@code recycleForDictReset()}, so a read from + * any other thread is an eventually-consistent snapshot, not a + * linearizable one. */ - @TestOnly - public long getSymbolDictEpochForTest() { + public long getSymbolDictEpoch() { return symbolDictEpoch; } + /** + * Number of symbol-dictionary recycle swaps this sender has completed. + * Incremented alongside {@link #getSymbolDictEpoch()} at step 5 of + * {@link #recycleForDictReset()}. The two counts move together today -- + * the only way the epoch advances is through a completed recycle swap -- + * but they are defined, and incremented, independently: this one counts + * completed swaps, {@code getSymbolDictEpoch()} counts the dictionary + * generation. They would diverge if a future change ever rolled the epoch + * by some path other than a recycle swap. Same thread-safety caveat as + * {@link #getSymbolDictEpoch()}. + */ + public long getSymbolDictResetsPerformed() { + return symbolDictResetsPerformed; + } + /** * Number of times {@link #maybeBlockForStarvedReset()} has timed out - * without the backlog draining. 0 until the first such timeout. + * without the backlog draining. 0 until the first such timeout. Same + * thread-safety caveat as {@link #getSymbolDictEpoch()}. */ - @TestOnly - public long getSymbolDictResetStarvationTimeoutsForTest() { + public long getSymbolDictResetStarvationTimeouts() { return symbolDictResetStarvationTimeouts; } @@ -4694,7 +4722,8 @@ private void maybeRecycleForDictReset() { * (replaced, not cleared -- nothing else retains the old instance), * both symbol-id watermarks reset, {@code lastCommitBoundaryFsn} * reset (it held a raw old-epoch FSN that does not survive the - * roll), the epoch counter advanced, and the arming flags consumed.
  • + * roll), the epoch counter and the completed-swap counter both + * advanced, and the arming flags consumed. *
  • Rebuild the cursor engine on the now-empty slot via * {@link #engineRebuildFactory}, the identical construct path * {@code Sender.build()} uses. {@code deltaDictEnabled} is re-derived @@ -4735,6 +4764,7 @@ private void recycleForDictReset() { currentBatchMaxSymbolId = -1; lastCommitBoundaryFsn = -1L; symbolDictEpoch++; + symbolDictResetsPerformed++; resetArmed = false; manualResetRequested = false; // step 6: rebuild the engine on the now-empty slot diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java index a197617d..ac9b7734 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java @@ -119,14 +119,14 @@ public void testRecycleSkipsCatchUpThenUnplannedReconnectBoundsCatchUpToNewEpoch sender.awaitAckedFsn(fsn1, 5_000)); Assert.assertTrue("must be armed after crossing threshold=3", ws.isResetArmed()); Assert.assertEquals(1, handler.connectionsAccepted.get()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Ring drained: this table() call recycles synchronously onto a fresh // connection (2), a fresh (empty) engine/dictionary/epoch, and "c" is // then the new epoch's own first symbol. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); Assert.assertFalse("recycle must disarm", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); Assert.assertEquals("recycle must open a fresh connection", 2, server.handshakeCount()); @@ -229,7 +229,7 @@ public void testRecycleUnderAsyncInitialConnectSendsZeroCatchUpFrames() throws E sender.awaitAckedFsn(fsn1, 5_000)); Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); Assert.assertEquals(1, handler.connectionsAccepted.get()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Recycles synchronously on the producer thread for steps 1-6; step // 7's reconnect just re-arms the ASYNC path -- the actual handshake @@ -237,7 +237,7 @@ public void testRecycleUnderAsyncInitialConnectSendsZeroCatchUpFrames() throws E sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); Assert.assertFalse("recycle must disarm immediately (producer-side state)", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java new file mode 100644 index 00000000..a87230ae --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -0,0 +1,428 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderConnectionDispatcher; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.DelegatingFilesFacade; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * The healing half of the symbol-dictionary recycle feature: a sender that degraded to + * full self-sufficient frames ({@code QwpWebSocketSender.disableDeltaDict}, e.g. after a + * recognised mmap access fault on the persisted dictionary -- see {@link MmapFaultDegradesTest}) + * is not stuck there forever. {@code recycleForDictReset()} rebuilds the cursor engine from + * scratch (step 6), and a fresh engine re-derives {@code deltaDictEnabled} independently of + * whatever the outgoing epoch's engine decided -- so once the underlying fault clears, the + * next recycle heals the sender back into delta mode. If the fault has not cleared, the fresh + * engine simply degrades again on its own first append: a normal, catchable + * {@link LineSenderException}, not a latched {@code recycleFailure} terminal state. + *

    + * Also covers the three permanent recycle-metrics getters ({@code getSymbolDictEpoch()}, + * {@code getSymbolDictResetsPerformed()}, {@code getSymbolDictResetStarvationTimeouts()}). + */ +public class SymbolDictRecycleHealingTest { + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + @Test + public void testMetricsAfterTwoRecycles() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("metrics-sf").toString(); + try (TestWebSocketServer server = ackingServer()) { + int port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + Assert.assertEquals(0, ws.getSymbolDictEpoch()); + Assert.assertEquals(0, ws.getSymbolDictResetsPerformed()); + Assert.assertEquals(0, ws.getSymbolDictResetStarvationTimeouts()); + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("armed: 2 distinct symbols crossed threshold=2", ws.isResetArmed()); + + // Ring drained -> this table() call recycles synchronously: epoch 1. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); + Assert.assertEquals(1, ws.getSymbolDictResetsPerformed()); + Assert.assertEquals("no starvation wait was deliberately triggered", + 0, ws.getSymbolDictResetStarvationTimeouts()); + + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue("armed again: c, d cross threshold=2 in the new epoch", + ws.isResetArmed()); + + // Ring drained again -> second recycle: epoch 2. + sender.table("t").symbol("s", "e").longColumn("v", 4L).atNow(); + Assert.assertEquals(2, ws.getSymbolDictEpoch()); + Assert.assertEquals(2, ws.getSymbolDictResetsPerformed()); + Assert.assertEquals("still no starvation wait was deliberately triggered", + 0, ws.getSymbolDictResetStarvationTimeouts()); + + long fsn3 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn3, 5_000)); + } + } + }); + } + + /** + * The recovery-side sibling of {@code MmapFaultDegradesTest.testMmapAccessFaultDegradesPersistInsteadOfPropagating}: + * once the sender has degraded to full self-sufficient frames, the underlying fault clears, + * and a recycle rebuilds the engine, the fresh engine must re-derive delta-dict mode from + * scratch rather than staying degraded forever. Wire evidence: the first post-recycle frame + * (a fresh, empty dictionary) starts a delta at 0; the SECOND post-recycle frame, which + * introduces exactly one more symbol, starts its delta where the first one left off and + * carries only that one new entry -- the shape only delta mode produces. In full-dict mode + * every frame re-ships the whole dictionary from id 0 (see + * {@code QwpWebSocketSender.symbolDeltaBaseline()}: confirmedMaxId is permanently -1), so + * this pair of frames could not look like this if healing had not taken effect. + */ + @Test + public void testRecycleHealsFullDictDegradeBackToDeltaMode() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("heal-sf").toString(); + String slot = Paths.get(sfDir, "default").toString(); + Assert.assertEquals(0, io.questdb.client.std.Files.mkdir(sfDir, + io.questdb.client.std.Files.DIR_MODE_DEFAULT)); + + CapturingAckHandler handler = new CapturingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + int port = server.getPort(); + + HealableMmapFaultFacade ff = new HealableMmapFaultFacade(); + CursorSendEngine engine = new CursorSendEngine( + slot, 4L * 1024 * 1024, 64L * 1024 * 1024, + CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff); + QwpWebSocketSender sender = buildSender(port, engine, 100_000); + // connect() never installs an engineRebuildFactory (only Sender.build() does), + // so the recycle would otherwise be a no-op. Install one that rebuilds on the + // SAME slot with the SAME (healable) facade -- mirroring the real factory + // Sender.build() installs, minus the FilesFacade seam Sender.fromConfig lacks. + sender.setEngineRebuildFactory(() -> new CursorSendEngine( + slot, 4L * 1024 * 1024, 64L * 1024 * 1024, + CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff)); + try { + Assert.assertTrue("must start in delta mode", sender.isDeltaDictEnabledForTest()); + + // Degrade mid-life: fault the dictionary's next mmap growth. + ff.armed = true; + sender.table("m").symbol("s", "a").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("expected the injected mmap fault to fail this flush"); + } catch (LineSenderException expected) { + // same guard MmapFaultDegradesTest pins + } + Assert.assertFalse("a recognised mmap access fault must degrade the sender", + sender.isDeltaDictEnabledForTest()); + + // Heal the facade. The retry below does not itself touch mmap -- + // persistNewSymbolsBeforePublish short-circuits once !deltaDictEnabled -- + // so healing here matters only for what the fresh post-recycle engine sees. + ff.armed = false; + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("the degraded retry must still ingest the row", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertEquals(0, sender.getSymbolDictEpoch()); + + // Drained: arm and trigger the recycle. + sender.resetSymbolDictionary(); + Assert.assertTrue(sender.isResetArmed()); + sender.table("m").symbol("s", "b").longColumn("v", 2L).atNow(); + Assert.assertFalse("recycle must disarm", sender.isResetArmed()); + Assert.assertEquals(1, sender.getSymbolDictEpoch()); + Assert.assertEquals(1, sender.getSymbolDictResetsPerformed()); + + // The fresh engine re-derives delta-dict mode against the healed facade. + Assert.assertTrue("a healed facade must let the fresh engine re-derive delta mode", + sender.isDeltaDictEnabledForTest()); + + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + + sender.table("m").symbol("s", "c").longColumn("v", 3L).atNow(); + long fsn3 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn3, 5_000)); + + List postRecycleFrames = handler.framesFor(2); + Assert.assertTrue("post-recycle connection must have sent at least 2 data frames", + postRecycleFrames.size() >= 2); + int[] first = deltaStartAndCount(postRecycleFrames.get(0)); + int[] second = deltaStartAndCount(postRecycleFrames.get(1)); + Assert.assertEquals("first post-recycle frame starts a fresh dictionary at 0", + 0, first[0]); + Assert.assertEquals("first post-recycle frame carries only the one new symbol (b)", + 1, first[1]); + Assert.assertEquals("delta mode: the second frame's delta starts where the first left off", + first[0] + first[1], second[0]); + Assert.assertEquals("delta mode: the second frame carries only the newly-added symbol (c), " + + "not the whole dictionary re-shipped from 0 as full-dict mode would", + 1, second[1]); + } finally { + sender.close(); + } + } + }); + } + + /** + * Persistent-fault sibling of {@link #testRecycleHealsFullDictDegradeBackToDeltaMode}: the + * facade is never healed, so the freshly rebuilt engine hits the SAME fault on its own first + * append and degrades again. Construction alone does not touch mmap (a brand-new, empty + * dictionary file needs only {@code openCleanRW}/{@code write} for its header -- see + * {@code PersistedSymbolDict.openFresh}), so the fresh engine transiently reports delta mode + * right after the recycle; the degrade only becomes observable once something actually + * appends to it. Either way this must stay a degrade, not a break: a plain, catchable + * {@link LineSenderException} on the one flush that hits the fault, no raw {@code Error} + * escaping, no latched {@code recycleFailure} terminal state, and the sender keeps ingesting + * rows (in full-dict mode) right after. + */ + @Test + public void testRecycleDegradesAgainWhenFaultPersists() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("persistent-fault-sf").toString(); + String slot = Paths.get(sfDir, "default").toString(); + Assert.assertEquals(0, io.questdb.client.std.Files.mkdir(sfDir, + io.questdb.client.std.Files.DIR_MODE_DEFAULT)); + + try (TestWebSocketServer server = ackingServer()) { + int port = server.getPort(); + + HealableMmapFaultFacade ff = new HealableMmapFaultFacade(); + CursorSendEngine engine = new CursorSendEngine( + slot, 4L * 1024 * 1024, 64L * 1024 * 1024, + CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff); + QwpWebSocketSender sender = buildSender(port, engine, 100_000); + sender.setEngineRebuildFactory(() -> new CursorSendEngine( + slot, 4L * 1024 * 1024, 64L * 1024 * 1024, + CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff)); + try { + // Degrade once before the recycle, same setup as the healing test. + ff.armed = true; + sender.table("m").symbol("s", "a").longColumn("v", 1L).atNow(); + try { + sender.flush(); + Assert.fail("expected the injected mmap fault to fail this flush"); + } catch (LineSenderException expected) { + // expected -- same guard as MmapFaultDegradesTest + } + Assert.assertFalse(sender.isDeltaDictEnabledForTest()); + long fsn1 = sender.flushAndGetSequence(); // retry succeeds in full-dict mode + Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); + + // Do NOT heal: the facade is still armed when the recycle rebuilds the + // engine, so the fresh engine's own first append hits it again. + sender.resetSymbolDictionary(); + sender.table("m").symbol("s", "b").longColumn("v", 2L).atNow(); + Assert.assertEquals(1, sender.getSymbolDictEpoch()); + Assert.assertEquals(1, sender.getSymbolDictResetsPerformed()); + + // Construction alone never touches mmap (see this test's javadoc), so the + // fresh engine transiently re-derives delta mode before its first append. + Assert.assertTrue("a fresh engine's construction never touches mmap, so it " + + "transiently re-derives delta mode before its first append", + sender.isDeltaDictEnabledForTest()); + + // ...until this flush's append hits the still-armed facade and degrades it + // again, exactly like the pre-recycle fault: a clean LineSenderException, + // never a raw Error, and the sender is not latched terminal. + try { + sender.flush(); + Assert.fail("expected the still-armed facade to fault the post-recycle append too"); + } catch (LineSenderException expected) { + // degrade, not break: a normal, catchable sender error + } + Assert.assertFalse("the fresh engine must degrade again, not stay in delta mode", + sender.isDeltaDictEnabledForTest()); + + // Degrade, not break: the sender keeps working (full-dict mode now), no + // latched recycleFailure and no exception escaping this retry. + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("the row must still get ingested after the second degrade", + sender.awaitAckedFsn(fsn2, 5_000)); + } finally { + sender.close(); + } + } + }); + } + + private static TestWebSocketServer ackingServer() throws Exception { + TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + /** + * Widest {@code QwpWebSocketSender.connect(...)} overload with everything but the + * fault-injecting engine and the reset threshold pinned to defaults -- mirrors + * {@code SymbolDictRecycleArmingTest.testArmsInFullDictMode}. {@code Sender.fromConfig} has + * no {@code FilesFacade} seam, so this is the only way to combine a custom facade with a + * custom low threshold; it leaves {@code engineRebuildFactory} null, same as every other + * {@code connect(...)} overload, so callers that need a working recycle must install one + * with {@code setEngineRebuildFactory} afterwards. + */ + private static QwpWebSocketSender buildSender(int port, CursorSendEngine engine, int thresholdSymbols) + throws Exception { + return QwpWebSocketSender.connect( + Collections.singletonList(new QwpWebSocketSender.Endpoint("localhost", port)), + null, // tlsConfig + 0, 0, 0L, // autoFlushRows, autoFlushBytes, autoFlushIntervalNanos + null, // authorizationHeader + false, // requestDurableAck + engine, + 5_000L, // closeFlushTimeoutMillis + CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS, + CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS, + CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_BACKOFF_MILLIS, + Sender.InitialConnectMode.OFF, + null, // errorHandler + SenderErrorDispatcher.DEFAULT_CAPACITY, + CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, + QwpWebSocketSender.DEFAULT_AUTH_TIMEOUT_MS, + 0, // connectTimeoutMs + null, // connectionListener + SenderConnectionDispatcher.DEFAULT_CAPACITY, + CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, + CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS, + CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS, + true, // symbolDictResetEnabled + thresholdSymbols, + QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS); + } + + /** {@code [deltaStart, deltaCount]} read from a frame that must carry a symbol-dict delta. */ + private static int[] deltaStartAndCount(byte[] frame) { + Assert.assertTrue("frame must carry a symbol-dict delta", QwpWireTestUtils.hasDelta(frame)); + int[] position = {HEADER_SIZE}; + int deltaStart = QwpWireTestUtils.readVarint(frame, position); + int deltaCount = QwpWireTestUtils.readVarint(frame, position); + return new int[]{deltaStart, deltaCount}; + } + + /** ACKs every frame it receives; does not otherwise inspect the wire. */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** ACKs every frame and records the raw bytes of every data frame, grouped by connection. */ + private static class CapturingAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private final List> framesByConn = new CopyOnWriteArrayList<>(); + private TestWebSocketServer.ClientHandler currentClient; + private final AtomicLong nextSeq = new AtomicLong(0); + + synchronized List framesFor(int connNumber) { + return connNumber <= framesByConn.size() + ? new CopyOnWriteArrayList<>(framesByConn.get(connNumber - 1)) + : Collections.emptyList(); + } + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (currentClient != client) { + currentClient = client; + framesByConn.add(new CopyOnWriteArrayList<>()); + nextSeq.set(0); + } + if (QwpWireTestUtils.tableCount(data) > 0) { + framesByConn.get(framesByConn.size() - 1).add(data); + } + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Raises a RECOGNISED mmap access fault out of the persisted dictionary's every mmap growth + * while {@link #armed}, and stops (returns to normal behaviour) as soon as a test flips + * {@link #armed} back to {@code false}. Unlike the self-disarming + * {@code MmapFaultDegradesTest.MmapFaultDictFacade} / {@code SymbolDictRecycleArmingTest.MmapFaultDictFacade}, + * this one stays armed across as many mmap calls as the test wants -- so a test can + * explicitly "heal" the underlying storage by flipping the flag, or deliberately leave it + * faulting across a recycle to prove the fresh engine degrades again instead of masking the + * still-broken medium. + */ + private static final class HealableMmapFaultFacade extends DelegatingFilesFacade { + volatile boolean armed; + + @Override + public boolean isMmapAllowed() { + return true; + } + + @Override + public long mmap(int fd, long len, long offset, int flags, int memoryTag) { + if (armed) { + throw new InternalError( + "a fault occurred in a recent unsafe memory access operation in compiled Java code"); + } + return INSTANCE.mmap(fd, len, offset, flags, memoryTag); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java index 95981bb4..b8784486 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java @@ -81,7 +81,7 @@ public void testRecycleAtEmptyBacklog() throws Exception { sender.awaitAckedFsn(fsn1, 5_000)); Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); Assert.assertEquals(1, handler.connectionsAccepted.get()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Ring drained, no row in progress: this table() call must // recycle synchronously, exactly as in SF mode. @@ -89,7 +89,7 @@ public void testRecycleAtEmptyBacklog() throws Exception { Assert.assertFalse("recycle must disarm", ws.isResetArmed()); Assert.assertEquals("recycle must open a fresh connection", 2, server.handshakeCount()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); @@ -144,7 +144,7 @@ public void testRecycleLosesNothingAcked() throws Exception { sender.awaitAckedFsn(fsn1, 5_000)); Assert.assertTrue("threshold=2 crossed well before the 4th symbol", ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Ring drained: the FIRST post-recycle table() call recycles // synchronously, then the row it is building lands on the @@ -154,7 +154,7 @@ public void testRecycleLosesNothingAcked() throws Exception { sender.table("t").symbol("s", symbol).longColumn("v", 2L).atNow(); if (first) { Assert.assertFalse("recycle must disarm", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); first = false; } } @@ -229,7 +229,7 @@ public void testRecycleUnderAsyncInitialConnect() throws Exception { sender.awaitAckedFsn(fsn1, 5_000)); Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); Assert.assertEquals(1, handler.connectionsAccepted.get()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Ring drained: this table() call recycles synchronously on // the producer thread for steps 1-6, but step 7's reconnect @@ -241,7 +241,7 @@ public void testRecycleUnderAsyncInitialConnect() throws Exception { ws.isResetArmed()); Assert.assertEquals("recycle must advance the epoch immediately (producer-side " + "state, not gated on the wire)", - 1, ws.getSymbolDictEpochForTest()); + 1, ws.getSymbolDictEpoch()); // Don't gate on a connection counter here -- rows queue on // the (memory-mode) cursor ring regardless of wire state in diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java index 6c7b1aa5..458be65a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java @@ -110,7 +110,7 @@ public void testRecycleDuringOutageReconnectsAfresh() throws Exception { Assert.assertTrue("setup: the arming batch must be acked before the outage", sender.awaitAckedFsn(fsn1, 5_000)); Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Kill the connection AND the listener -- a real outage, not // just a dropped socket the same server would re-accept @@ -161,7 +161,7 @@ public void testRecycleDuringOutageReconnectsAfresh() throws Exception { Assert.assertFalse("recycle must disarm", ws.isResetArmed()); Assert.assertEquals("recycle must complete despite the outage", - 1, ws.getSymbolDictEpochForTest()); + 1, ws.getSymbolDictEpoch()); Assert.assertTrue("revived server must observe a fresh handshake", revived.handshakeCount() >= 1); @@ -255,13 +255,13 @@ public void testOrphanDrainerSurvivesRecycleMidDrain() throws Exception { long fsn1 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Recycle fires synchronously here, tearing down + rebuilding // ONLY the foreground's own cursor engine/I/O loop. sender.table("t").symbol("s", "post-c").longColumn("v", 2L).atNow(); Assert.assertFalse("recycle must disarm", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue("post-recycle row must land on the fresh connection", diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java index 2b407e9a..e3206775 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java @@ -55,7 +55,7 @@ * normally and NOT recycle, until that condition clears -- at which point * the still-armed request fires as a positive control in the same test. * Every test asserts both halves: no recycle (connection count and - * {@code getSymbolDictEpochForTest()} unchanged) AND that ingestion keeps + * {@code getSymbolDictEpoch()} unchanged) AND that ingestion keeps * working (a row lands and gets acked) both before and after the eventual * recycle. */ @@ -94,13 +94,13 @@ public void testUnackedBacklogRefusesUntilAcked() throws Exception { long fsn1 = sender.flushAndGetSequence(); // ack withheld by the handler Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); Assert.assertEquals(1, server.handshakeCount()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); for (int i = 0; i < 5; i++) { sender.table("t"); Assert.assertTrue("recycle must not fire while the arming batch is unacked", ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); Assert.assertEquals(1, server.handshakeCount()); Thread.sleep(30); } @@ -113,7 +113,7 @@ public void testUnackedBacklogRefusesUntilAcked() throws Exception { sender.table("t"); Assert.assertFalse("recycle must fire once the backlog drains", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); Assert.assertEquals("recycle must open a fresh connection", 2, server.handshakeCount()); @@ -162,7 +162,7 @@ public void testPendingRowCountRefuses() throws Exception { // lets execution fall through so a new row can be buffered. sender.table("t"); Assert.assertTrue(ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // A third row, committed but never flushed: pendingRowCount=1, // far under auto_flush_rows=10, so it stays buffered. @@ -178,7 +178,7 @@ public void testPendingRowCountRefuses() throws Exception { Assert.assertTrue("recycle must not fire while a row is buffered " + "unflushed, even with the ring otherwise drained", ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); Assert.assertEquals(1, server.handshakeCount()); Thread.sleep(30); } @@ -192,7 +192,7 @@ public void testPendingRowCountRefuses() throws Exception { Assert.assertFalse("recycle must fire once the buffered batch is flushed " + "and acked", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); Assert.assertEquals(2, server.handshakeCount()); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); @@ -233,7 +233,7 @@ public void testInProgressRowRefuses() throws Exception { // into the dictionary immediately, yet the row itself stays // in progress until atNow() runs. sender.table("t").symbol("s", "a"); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Arm WHILE the row is in progress: pendingRowCount is still // 0 (an in-progress row is not counted as pending), so the @@ -246,7 +246,7 @@ public void testInProgressRowRefuses() throws Exception { sender.table("t"); // same name -- fast path would skip past everything Assert.assertTrue("recycle must not fire while a row is in progress", ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); Assert.assertEquals(1, server.handshakeCount()); Thread.sleep(20); } @@ -264,7 +264,7 @@ public void testInProgressRowRefuses() throws Exception { Assert.assertTrue("the failed table-switch attempt must not have consumed " + "the arming", ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); Assert.assertEquals(1, server.handshakeCount()); // Complete the row: ingestion still works after both refusals. @@ -272,7 +272,7 @@ public void testInProgressRowRefuses() throws Exception { long fsn1 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); Assert.assertTrue("nothing yet consumed the arming", ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Positive control: with the row complete and the batch // acked, the still-armed recycle fires on the next call. @@ -280,7 +280,7 @@ public void testInProgressRowRefuses() throws Exception { Assert.assertFalse("recycle must fire once the row completes and the ring " + "drains", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); Assert.assertEquals(2, server.handshakeCount()); sender.table("t").symbol("s", "d").longColumn("v", 2L).atNow(); @@ -329,7 +329,7 @@ public void testDeferredCommitGroupRefusesUntilCommitAcked() throws Exception { + "recycle fire -- the server withholds its ack until " + "the closing commit", ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); Assert.assertEquals(1, server.handshakeCount()); Thread.sleep(30); } @@ -345,7 +345,7 @@ public void testDeferredCommitGroupRefusesUntilCommitAcked() throws Exception { sender.table("t"); Assert.assertFalse("recycle must fire once the group is committed and acked", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); Assert.assertEquals(2, server.handshakeCount()); sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); @@ -397,7 +397,7 @@ public void testManualResetBeforeFirstConnectDeferred() throws Exception { Assert.assertTrue("a manual request arms immediately, independent of " + "connection state", sender.isResetArmed()); - Assert.assertEquals(0, sender.getSymbolDictEpochForTest()); + Assert.assertEquals(0, sender.getSymbolDictEpoch()); Assert.assertEquals(0, server.handshakeCount()); // table()'s barrier check runs here while still pre-connect @@ -406,7 +406,7 @@ public void testManualResetBeforeFirstConnectDeferred() throws Exception { sender.table("t").longColumn("v", 1L).atNow(); Assert.assertTrue("still armed -- deferred, not consumed", sender.isResetArmed()); - Assert.assertEquals(0, sender.getSymbolDictEpochForTest()); + Assert.assertEquals(0, sender.getSymbolDictEpoch()); Assert.assertEquals(1, server.handshakeCount()); long fsn1 = sender.flushAndGetSequence(); @@ -414,7 +414,7 @@ public void testManualResetBeforeFirstConnectDeferred() throws Exception { Assert.assertTrue("flush alone does not consume the arming -- only table() " + "does", sender.isResetArmed()); - Assert.assertEquals(0, sender.getSymbolDictEpochForTest()); + Assert.assertEquals(0, sender.getSymbolDictEpoch()); // Positive control: now connected and drained, the // deferred request executes on the next table() call. @@ -422,7 +422,7 @@ public void testManualResetBeforeFirstConnectDeferred() throws Exception { Assert.assertFalse("the deferred request must execute once connected and " + "drained", sender.isResetArmed()); - Assert.assertEquals(1, sender.getSymbolDictEpochForTest()); + Assert.assertEquals(1, sender.getSymbolDictEpoch()); Assert.assertEquals(2, server.handshakeCount()); sender.table("t").longColumn("v", 2L).atNow(); @@ -488,7 +488,7 @@ public void testResetDiscardsBufferedRowThenArmedSwapFires() throws Exception { Assert.assertTrue(ws.isResetArmed()); sender.table("t"); // refused: row "c" is in progress Assert.assertTrue(ws.isResetArmed()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); Assert.assertEquals(1, server.handshakeCount()); // Discard the buffered row -- reset() drops the @@ -503,7 +503,7 @@ public void testResetDiscardsBufferedRowThenArmedSwapFires() throws Exception { Assert.assertFalse("the armed swap fires once reset() clears the blocking " + "in-progress row", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); Assert.assertEquals(2, server.handshakeCount()); // Ingestion continues correctly post-swap: a fresh row diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java index e187ac53..cd8d691e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java @@ -81,7 +81,7 @@ public void testMaxWaitZeroNeverBlocks() throws Exception { sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); sender.flush(); // unacked forever -- handler never releases Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); - Assert.assertEquals(0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + Assert.assertEquals(0L, ws.getSymbolDictResetStarvationTimeouts()); // Repeated table() calls, spread over time, must every one of // them return fast: resetMaxWaitMillis<=0 is checked BEFORE the @@ -102,8 +102,8 @@ public void testMaxWaitZeroNeverBlocks() throws Exception { ws.isResetArmed()); Assert.assertEquals("must never recycle -- the ring never drained and " + "blocking is disabled", - 0L, ws.getSymbolDictEpochForTest()); - Assert.assertEquals(0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + 0L, ws.getSymbolDictEpoch()); + Assert.assertEquals(0L, ws.getSymbolDictResetStarvationTimeouts()); // Release before the try-with-resources closes the sender below, // or close()'s own drain would hang on this still-unacked batch -- @@ -187,9 +187,9 @@ public void testBlocksThenRecyclesWhenAcksArrive() throws Exception { elapsedMs < maxWaitMillis); Assert.assertFalse("recycle must disarm", ws.isResetArmed()); Assert.assertEquals("recycle must have run exactly once", - 1L, ws.getSymbolDictEpochForTest()); + 1L, ws.getSymbolDictEpoch()); Assert.assertEquals("a successful drain-and-recycle is not a timeout", - 0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + 0L, ws.getSymbolDictResetStarvationTimeouts()); } } }); @@ -241,12 +241,12 @@ public void testTimeoutLogsAndReArms() throws Exception { Assert.assertTrue("must not hang far past its own deadline, got " + elapsedMs + "ms", elapsedMs < maxWaitMillis + 5_000); Assert.assertEquals("a timed-out wait must record exactly one starvation timeout", - 1L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + 1L, ws.getSymbolDictResetStarvationTimeouts()); Assert.assertTrue("timing out must not consume the arming -- the recycle is " + "still owed once the backlog eventually drains", ws.isResetArmed()); Assert.assertEquals("no recycle happened -- only the wait gave up", - 0L, ws.getSymbolDictEpochForTest()); + 0L, ws.getSymbolDictEpoch()); // At most one blocking wait per armed window: pendingRowCount is // still 0 here (nothing added since the flush above) and the ring @@ -264,7 +264,7 @@ public void testTimeoutLogsAndReArms() throws Exception { + "re-block, took " + secondElapsedMs + "ms", secondElapsedMs < 100); Assert.assertEquals("still just the one timeout from before", - 1L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + 1L, ws.getSymbolDictResetStarvationTimeouts()); // Ingest continues: more rows can still be appended and flushed // without the sender getting stuck. @@ -287,9 +287,9 @@ public void testTimeoutLogsAndReArms() throws Exception { Assert.assertFalse("the still-armed recycle must fire now that the backlog " + "has drained", ws.isResetArmed()); - Assert.assertEquals(1L, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1L, ws.getSymbolDictEpoch()); Assert.assertEquals("draining later must not add another timeout", - 1L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + 1L, ws.getSymbolDictResetStarvationTimeouts()); } } }); @@ -371,7 +371,7 @@ public void testLatchedErrorDuringWaitThrows() throws Exception { + "healthy connection", ws.isResetArmed()); Assert.assertEquals("a thrown wait is not a timeout", - 0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + 0L, ws.getSymbolDictResetStarvationTimeouts()); } finally { try { sender.close(); @@ -430,8 +430,8 @@ public void testDeferredCommitGroupSkipsWait() throws Exception { Assert.assertTrue("must still be armed -- neither the wait nor the recycle ran", ws.isResetArmed()); Assert.assertEquals("the futility guard is not a timeout", - 0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); - Assert.assertEquals(0L, ws.getSymbolDictEpochForTest()); + 0L, ws.getSymbolDictResetStarvationTimeouts()); + Assert.assertEquals(0L, ws.getSymbolDictEpoch()); // Close the deferred group: commit, and wait for its ack. ws.setDeferCommit(false); @@ -445,9 +445,9 @@ public void testDeferredCommitGroupSkipsWait() throws Exception { Assert.assertFalse("the still-armed recycle must fire once the group is " + "committed and acked", ws.isResetArmed()); - Assert.assertEquals(1L, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1L, ws.getSymbolDictEpoch()); Assert.assertEquals("no wait ever ran in this test", - 0L, ws.getSymbolDictResetStarvationTimeoutsForTest()); + 0L, ws.getSymbolDictResetStarvationTimeouts()); } } }); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index e1c6f0ff..69c83554 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -90,7 +90,7 @@ public void testRecycleAtEmptyBacklog() throws Exception { sender.awaitAckedFsn(fsn1, 5_000)); Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); Assert.assertEquals(1, handler.connectionsAccepted.get()); - Assert.assertEquals(0, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); // The ring is drained (everything acked) and no row is in // progress, so this table() call must recycle synchronously. @@ -103,7 +103,7 @@ public void testRecycleAtEmptyBacklog() throws Exception { Assert.assertFalse("recycle must disarm", ws.isResetArmed()); Assert.assertEquals("recycle must open a fresh connection", 2, server.handshakeCount()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); @@ -155,7 +155,7 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception Assert.assertTrue("sender must keep working even though it can never recycle", sender.awaitAckedFsn(fsn, 5_000)); Assert.assertEquals("no factory -> the recycle can never actually run", - 0, sender.getSymbolDictEpochForTest()); + 0, sender.getSymbolDictEpoch()); Assert.assertTrue("stays armed forever -- nothing ever consumes the request", sender.isResetArmed()); } @@ -204,7 +204,7 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception Assert.assertTrue("sender must keep working with no factory installed", sender.awaitAckedFsn(fsn2, 5_000)); Assert.assertEquals("no factory -> the recycle can never actually run", - 0, sender.getSymbolDictEpochForTest()); + 0, sender.getSymbolDictEpoch()); Assert.assertTrue("stays armed -- nothing ever consumes the threshold arming", sender.isResetArmed()); } finally { @@ -298,7 +298,7 @@ public void testRecycleUnderDurableAck() throws Exception { sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); Assert.assertFalse("recycle must disarm", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); Assert.assertEquals("recycle must open a fresh connection", 2, server.handshakeCount()); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java index d9258abf..acd98450 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java @@ -274,7 +274,7 @@ public void testCrashBetweenEngineCloseAndRebuildRecoversAsFreshStart() throws E // so steps 1-7 run to completion with nothing left pending to flush. sender.table("t"); Assert.assertFalse("recycle must disarm", ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); // close() below: the fresh engine has published nothing, so // close(boolean)'s "never published" check (CursorSendEngine's // publishedFsn() < 0 branch) classifies it fully-drained too -- @@ -345,7 +345,7 @@ public void testCrashAfterRebuildBeforeFirstFlushRecoversAsRecoveredButEmpty() t sender.table("t"); // bare call: drives steps 1-7, nothing left pending Assert.assertFalse(ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); // The manager worker provisions the fresh engine's hot-spare // segment asynchronously (its own service pass, off the @@ -431,7 +431,7 @@ public void testCrashDuringSteadyStateEpochReplaysOnlyTheUnackedBacklog() throws sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); Assert.assertFalse(ws.isResetArmed()); - Assert.assertEquals(1, ws.getSymbolDictEpochForTest()); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); sender.flushAndGetSequence(); // Not drained (connection 2 never acks): close_flush_timeout_millis=0 // returns immediately without unlinking, preserving c, d's segment From 631ce886c3ed21ba21ef2dc3f4c4923f4b9eb445 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:54:19 +0100 Subject: [PATCH 20/49] Fix round 1: volatile metrics, tighten healing oracles Applies task-12-review.md findings Q1-Q6. Q1: the three symbol-dictionary-recycle metrics fields (symbolDictEpoch, symbolDictResetsPerformed, symbolDictResetStarvationTimeouts) become volatile -- they are permanent public API now, and their obvious reader is a monitoring thread on some other thread, unlike every other symbol-dictionary-recycle field they were modelled on. A plain, non-volatile long gives a cross-thread reader no visibility guarantee at all under the JMM (a polling loop can legally observe 0 forever), whereas the javadoc claimed an "eventually-consistent snapshot". Reworded the three javadocs to state the guarantee volatile actually gives: each read sees the latest write the producer thread completed, with no atomicity across the three counters -- a concurrent reader can see the epoch already advanced while resets-performed still reflects the prior value, even though the producer thread writes them on adjacent lines. Q3: getSymbolDictResetStarvationTimeouts()'s javadoc named recycleForDictReset() as the writer by importing getSymbolDictEpoch()'s caveat sentence verbatim; the starvation counter is actually written in maybeBlockForStarvedReset(). Named that method directly instead. Q4: getSymbolDictResetsPerformed() said swaps this sender has "completed" without stating that the counter advances at step 5, before the engine rebuild (step 6) and reconnect (step 7) -- so a recycle that later latches recycleFailure at step 6/7 still counts. Made that explicit, mirroring the precision getSymbolDictEpoch() already had. Q2: SymbolDictRecycleHealingTest's healing test asserted isDeltaDictEnabledForTest() right after the recycle and attributed the result to the healed facade, but the same assertion holds unconditionally -- a fresh engine's construction never touches mmap, so it reports true whether or not the facade was healed (the persistent-fault sibling test proves this directly). Reworded the message to state what that assertion actually pins, and added the discriminating check: re-assert after the first post-recycle flush, the fresh engine's first real append -- a still-armed facade would degrade it there, so staying true is real evidence of healing. Q5: AckAllHandler never reset its ack sequence per connection, unlike CapturingAckHandler right below it in the same file. A rebuilt engine restarts its raw FSNs at 0 after a recycle, so the stale, unreset sequence could satisfy awaitAckedFsn with an ack for a frame that was never published on the new connection -- a weaker gate than it looks, even though the specific sequences in these tests happened not to collide. Reset nextSeq on every new connection, matching the sibling handler and existing repo precedent for connection-aware ack sequencing. Q6: both fault-injection tests caught LineSenderException with a comment claiming parity with MmapFaultDegradesTest's guard, but never actually asserted the message MmapFaultDegradesTest checks ("failed to persist symbol dictionary before publish") -- so the catch could equally have swallowed an unrelated connection-level exception. Added the same message assertion to all three catch sites (two in the persistent-fault test, one in the healing test) so the comment's claim now holds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn --- .../qwp/client/QwpWebSocketSender.java | 48 ++++++++++++------- .../client/SymbolDictRecycleHealingTest.java | 40 ++++++++++++++-- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index c4494e0c..088b505d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -441,8 +441,10 @@ public interface EngineRebuildFactory { private boolean starvationWaitDoneThisArm; // Incremented once per completed starvation wait that timed out without // the backlog draining (maybeBlockForStarvedReset's deadline branch). 0 - // until the first such timeout. - private long symbolDictResetStarvationTimeouts; + // until the first such timeout. volatile: this is public API (see + // getSymbolDictResetStarvationTimeouts()), and a monitoring thread is + // its obvious reader. + private volatile long symbolDictResetStarvationTimeouts; // Set (once) by recycleForDictReset's catch block when the recycle swap // itself fails -- everything was acked before the swap tore the old // engine down, so no data is at risk, but this sender can no longer make @@ -452,15 +454,17 @@ public interface EngineRebuildFactory { // works normally. private Throwable recycleFailure; // Incremented once per completed symbol-dictionary recycle. 0 until the - // first recycle commits. - private long symbolDictEpoch; + // first recycle commits. volatile: this is public API (see + // getSymbolDictEpoch()), and a monitoring thread is its obvious reader. + private volatile long symbolDictEpoch; // Incremented once per completed symbol-dictionary recycle swap, beside // symbolDictEpoch (recycleForDictReset step 5). The two move together // today -- the only way symbolDictEpoch advances is through a committed // recycle swap -- but they count different things (dictionary generation // vs. completed swaps) and are incremented independently in case a // future change ever rolls the epoch by some path other than a recycle. - private long symbolDictResetsPerformed; + // volatile for the same reason as symbolDictEpoch. + private volatile long symbolDictResetsPerformed; private long reconnectInitialBackoffMillis = CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS; private long reconnectMaxBackoffMillis = @@ -2147,11 +2151,13 @@ public long getFsnEpochBaseForTest() { * by one at step 5 of {@link #recycleForDictReset()}, the instant the swap * commits to the new epoch -- before the engine rebuild (step 6) or the * reconnect (step 7), so a later rebuild/reconnect failure that latches - * {@link #recycleFailure} still leaves this incremented. Not synchronized: - * like the other symbol-dictionary-recycle fields, it is written only from - * the producer thread inside {@code recycleForDictReset()}, so a read from - * any other thread is an eventually-consistent snapshot, not a - * linearizable one. + * {@link #recycleFailure} still leaves this incremented. volatile: a + * concurrent read sees the latest write the producer thread completed, + * but there is no atomicity across the three symbol-dictionary-recycle + * counters -- a reader on another thread can observe this one already + * advanced while {@link #getSymbolDictResetsPerformed()} still reflects + * the prior value, even though the producer thread writes them on + * adjacent lines. */ public long getSymbolDictEpoch() { return symbolDictEpoch; @@ -2160,13 +2166,16 @@ public long getSymbolDictEpoch() { /** * Number of symbol-dictionary recycle swaps this sender has completed. * Incremented alongside {@link #getSymbolDictEpoch()} at step 5 of - * {@link #recycleForDictReset()}. The two counts move together today -- - * the only way the epoch advances is through a completed recycle swap -- - * but they are defined, and incremented, independently: this one counts + * {@link #recycleForDictReset()} -- before the engine rebuild (step 6) or + * the reconnect (step 7), so, like the epoch counter, a later + * rebuild/reconnect failure that latches {@link #recycleFailure} still + * leaves this incremented. The two counts move together today -- the + * only way the epoch advances is through a completed recycle swap -- but + * they are defined, and incremented, independently: this one counts * completed swaps, {@code getSymbolDictEpoch()} counts the dictionary - * generation. They would diverge if a future change ever rolled the epoch - * by some path other than a recycle swap. Same thread-safety caveat as - * {@link #getSymbolDictEpoch()}. + * generation. They would diverge if a future change ever rolled the + * epoch by some path other than a recycle swap. Same thread-safety + * caveat as {@link #getSymbolDictEpoch()}. */ public long getSymbolDictResetsPerformed() { return symbolDictResetsPerformed; @@ -2174,8 +2183,11 @@ public long getSymbolDictResetsPerformed() { /** * Number of times {@link #maybeBlockForStarvedReset()} has timed out - * without the backlog draining. 0 until the first such timeout. Same - * thread-safety caveat as {@link #getSymbolDictEpoch()}. + * without the backlog draining. 0 until the first such timeout. volatile, + * written only from the producer thread inside + * {@link #maybeBlockForStarvedReset()}: same thread-safety caveat as + * {@link #getSymbolDictEpoch()} -- a concurrent read sees the latest + * completed write, with no atomicity across the three counters. */ public long getSymbolDictResetStarvationTimeouts() { return symbolDictResetStarvationTimeouts; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java index a87230ae..d4a469c0 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -165,6 +165,10 @@ public void testRecycleHealsFullDictDegradeBackToDeltaMode() throws Exception { Assert.fail("expected the injected mmap fault to fail this flush"); } catch (LineSenderException expected) { // same guard MmapFaultDegradesTest pins + Assert.assertTrue("the fault must be reported as a sender error, not a " + + "raw InternalError: " + expected.getMessage(), + expected.getMessage().contains( + "failed to persist symbol dictionary before publish")); } Assert.assertFalse("a recognised mmap access fault must degrade the sender", sender.isDeltaDictEnabledForTest()); @@ -186,13 +190,25 @@ public void testRecycleHealsFullDictDegradeBackToDeltaMode() throws Exception { Assert.assertEquals(1, sender.getSymbolDictEpoch()); Assert.assertEquals(1, sender.getSymbolDictResetsPerformed()); - // The fresh engine re-derives delta-dict mode against the healed facade. - Assert.assertTrue("a healed facade must let the fresh engine re-derive delta mode", - sender.isDeltaDictEnabledForTest()); + // The rebuilt engine re-derives delta-dict mode from scratch (a fresh, + // empty dictionary always opens cleanly at construction -- see this + // test's persistent-fault sibling for why this alone does not prove the + // facade was healed). The discriminating check is below, after the first + // post-recycle append. + Assert.assertTrue(sender.isDeltaDictEnabledForTest()); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + // Discriminating check: fsn2's flush was the fresh engine's first + // append. A still-armed facade would have degraded it there (as the + // persistent-fault sibling proves against the identical setup) -- staying + // true here is real evidence the heal took effect, not just an artifact + // of fresh-engine construction never touching mmap. + Assert.assertTrue("a healed facade must let the fresh engine's first " + + "post-recycle append succeed and keep delta mode enabled", + sender.isDeltaDictEnabledForTest()); + sender.table("m").symbol("s", "c").longColumn("v", 3L).atNow(); long fsn3 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn3, 5_000)); @@ -258,6 +274,10 @@ public void testRecycleDegradesAgainWhenFaultPersists() throws Exception { Assert.fail("expected the injected mmap fault to fail this flush"); } catch (LineSenderException expected) { // expected -- same guard as MmapFaultDegradesTest + Assert.assertTrue("the fault must be reported as a sender error, not a " + + "raw InternalError: " + expected.getMessage(), + expected.getMessage().contains( + "failed to persist symbol dictionary before publish")); } Assert.assertFalse(sender.isDeltaDictEnabledForTest()); long fsn1 = sender.flushAndGetSequence(); // retry succeeds in full-dict mode @@ -284,6 +304,10 @@ public void testRecycleDegradesAgainWhenFaultPersists() throws Exception { Assert.fail("expected the still-armed facade to fault the post-recycle append too"); } catch (LineSenderException expected) { // degrade, not break: a normal, catchable sender error + Assert.assertTrue("the fault must be reported as a sender error, not a " + + "raw InternalError: " + expected.getMessage(), + expected.getMessage().contains( + "failed to persist symbol dictionary before publish")); } Assert.assertFalse("the fresh engine must degrade again, not stay in delta mode", sender.isDeltaDictEnabledForTest()); @@ -356,10 +380,20 @@ private static int[] deltaStartAndCount(byte[] frame) { /** ACKs every frame it receives; does not otherwise inspect the wire. */ private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private TestWebSocketServer.ClientHandler currentClient; private final AtomicLong nextSeq = new AtomicLong(0); @Override public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (currentClient != client) { + // A rebuilt engine restarts its raw FSNs at 0 (externalFsnBase absorbs the + // offset), and the ack sequence below is applied as a raw engine FSN -- so + // acking a recycle's fresh connection against the outgoing connection's + // sequence would ack frames that were never published. Reset per connection, + // matching CapturingAckHandler below. + currentClient = client; + nextSeq.set(0); + } try { client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); } catch (IOException e) { From 555fd487900d134b5b590ef920c731b97613cad0 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:10:59 +0100 Subject: [PATCH 21/49] Point cap error at the reset valve; raise client cap to 2M The symbol-dictionary cap error told callers to close the sender and build a new one, but gave no way to avoid hitting the cap in the first place. Append a sentence pointing at the automatic dictionary reset knobs (symbol_dict_reset, symbol_dict_reset_threshold) and the manual Sender.resetSymbolDictionary() escape hatch, so the error message matches the recycle feature this client now ships. Raise QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE from 1_000_000 to 2_000_000 to mirror the server-side constant of the same name, which moved to 2_000_000 in questdb OSS commit 306062e243 (#7468). That commit is contained in release tag 10.0.0, the support floor for this client, so client <= server holds across the whole supported fleet. Update DeltaDictCeilingTest and GlobalSymbolDictionaryTest, which pinned the old 1,000,000 value and message text, to the new cap and add a case that drives the dictionary to the cap with automatic reset disabled (symbol_dict_reset=off) and a threshold configured at the cap, confirming the refusal still fires and still names the reset valve even on a sender that has it switched off. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn --- .../qwp/client/GlobalSymbolDictionary.java | 3 +- .../cutlass/qwp/protocol/QwpConstants.java | 2 +- .../qwp/client/DeltaDictCeilingTest.java | 45 ++++++++++++++++++- .../client/GlobalSymbolDictionaryTest.java | 17 ++++--- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java index 3e9fa1a3..fd6f4c26 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java @@ -155,7 +155,8 @@ public int getOrAddSymbol(CharSequence symbol) { + ". Rows using already-registered symbol values continue to work. To start a fresh " + "dictionary, close this sender and build a new one (with store-and-forward the " + "buffered backlog drains first). For unbounded-cardinality data use varchar " - + "columns instead of symbol"); + + "columns instead of symbol. Alternatively enable the automatic dictionary reset " + + "(symbol_dict_reset, symbol_dict_reset_threshold) or call Sender.resetSymbolDictionary()."); } // Assign new ID — toString() only for new symbols that must be stored diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java index 074cf0e3..5d1b2f5c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java @@ -93,7 +93,7 @@ public final class QwpConstants { * NOT the result-direction cap: {@code QwpResultBatchDecoder.MAX_CONN_DICT_SIZE} * (8,388,608) governs server-to-client result batches and is unrelated. */ - public static final int MAX_SYMBOL_DICTIONARY_SIZE = 1_000_000; + public static final int MAX_SYMBOL_DICTIONARY_SIZE = 2_000_000; /** * Maximum table name length in bytes. Mirrors the server's same-named * constant; used by the decoder to reject malformed wire bytes. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCeilingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCeilingTest.java index d9ab6026..85cc347a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCeilingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCeilingTest.java @@ -45,7 +45,7 @@ /** * The producer-side dictionary cap ({@code MAX_SYMBOL_DICTIONARY_SIZE}) as the * application sees it: {@code symbol()} with a value that would create the - * 1,000,001st distinct entry throws BEFORE the row is buffered, the row is + * 2,000,001st distinct entry throws BEFORE the row is buffered, the row is * cancellable, and the sender keeps working with already-registered values -- * the wire never carries the refused symbol. */ @@ -76,7 +76,7 @@ public void testSymbolPastCapThrowsAndSenderStaysUsable() throws Exception { sender.table("t").symbol("s", "one-too-many"); Assert.fail("expected LineSenderException past the dictionary cap"); } catch (LineSenderException expected) { - Assert.assertTrue(expected.getMessage().contains("1000000")); + Assert.assertTrue(expected.getMessage().contains(String.valueOf(MAX_SYMBOL_DICTIONARY_SIZE))); } Assert.assertEquals("the refusal must not have grown the dictionary", MAX_SYMBOL_DICTIONARY_SIZE, dict.size()); @@ -98,6 +98,47 @@ public void testSymbolPastCapThrowsAndSenderStaysUsable() throws Exception { }); } + /** + * A threshold configured AT the cap, with automatic reset DISABLED, must + * behave exactly like the undecorated cap: the refusal still fires, and + * its message still names the reset valve even though this particular + * sender has it switched off -- the valve is documented for senders that + * want it, not conditioned on this sender having chosen it. + */ + @Test + public void testCapReachedWithResetDisabledStillThrowsAndNamesTheResetValve() throws Exception { + assertMemoryLeak(() -> { + AckAllHandler handler = new AckAllHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + port + + ";symbol_dict_reset=off;symbol_dict_reset_threshold=" + MAX_SYMBOL_DICTIONARY_SIZE + ";")) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + GlobalSymbolDictionary dict = ws.getGlobalSymbolDictionaryForTest(); + for (int i = 0; i < MAX_SYMBOL_DICTIONARY_SIZE; i++) { + dict.getOrAddSymbol("f" + i); + } + Assert.assertFalse("reset disabled must never arm", ws.isResetArmed()); + + try { + sender.table("t").symbol("s", "one-too-many"); + Assert.fail("expected LineSenderException past the dictionary cap"); + } catch (LineSenderException expected) { + String message = expected.getMessage(); + Assert.assertTrue("message names the limit: " + message, + message.contains(String.valueOf(MAX_SYMBOL_DICTIONARY_SIZE))); + Assert.assertTrue("message points at the reset valve: " + message, + message.contains("symbol_dict_reset") && message.contains("resetSymbolDictionary()")); + } + Assert.assertFalse("still not armed after the refusal", ws.isResetArmed()); + } + } + }); + } + private static void waitFor(Condition condition, long timeoutMillis) throws Exception { long deadline = System.currentTimeMillis() + timeoutMillis; while (!condition.holds()) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java index 65d61c26..9b418a85 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java @@ -298,14 +298,14 @@ public void testSpecialCharactersInSymbols() { @Test public void testGetOrAddSymbol_refusesGrowthPastProtocolCap() { - // Pre-sized so the 1M fill does not rehash its way through the test budget. - GlobalSymbolDictionary dict = new GlobalSymbolDictionary(1 << 21); + // Pre-sized so the 2M fill does not rehash its way through the test budget. + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(1 << 22); for (int i = 0; i < QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE; i++) { assertEquals(i, dict.getOrAddSymbol("f" + i)); } - // Boundary: the 1,000,000th distinct symbol (id 999_999) was ACCEPTED above -- + // Boundary: the 2,000,000th distinct symbol (id 1_999_999) was ACCEPTED above -- // the guard must refuse growth PAST the cap, not growth TO it, because the - // server accepts a catch-up of exactly deltaStart + deltaCount == 1_000_000. + // server accepts a catch-up of exactly deltaStart + deltaCount == 2_000_000. assertEquals(QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE, dict.size()); try { @@ -313,9 +313,12 @@ public void testGetOrAddSymbol_refusesGrowthPastProtocolCap() { fail("expected LineSenderException past the dictionary cap"); } catch (LineSenderException expected) { assertTrue("message names the limit: " + expected.getMessage(), - expected.getMessage().contains("1000000")); + expected.getMessage().contains("2000000")); assertTrue("message names the recovery: " + expected.getMessage(), expected.getMessage().contains("close this sender")); + assertTrue("message points at the reset valve: " + expected.getMessage(), + expected.getMessage().contains("symbol_dict_reset") + && expected.getMessage().contains("resetSymbolDictionary()")); } // The refusal mutated nothing: size unchanged, the refused symbol absent, @@ -333,9 +336,9 @@ public void testGetOrAddSymbol_refusesGrowthPastProtocolCap() { @Test public void testProtocolCapConstantPinnedToServerValue() { // The server-side QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE (questdb OSS) is - // 1_000_000 and the ingress decoder rejects any delta or catch-up whose + // 2_000_000 and the ingress decoder rejects any delta or catch-up whose // deltaStartId + deltaCount exceeds it. If this pin fails, the server // constant moved and both sides must move together. - assertEquals(1_000_000, QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE); + assertEquals(2_000_000, QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE); } } From e22376e4b12e1cd6c4c74e1eec73e16b0c470cca Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:29:44 +0100 Subject: [PATCH 22/49] Fix round 1: drop tautological arm-state assertions The new cap-reached-while-armed test never calls sender.flush(): the 2M fill goes through the raw GlobalSymbolDictionary test accessor, and the one Sender-routed call throws inside symbol() before a row completes. armIfEligible() only runs from the tail of a completed flush(), so it never had a code path available to flip isResetArmed() to true regardless of whether symbol_dict_reset was on or off -- the two assertFalse(ws.isResetArmed()) checks passed identically either way and proved nothing about the knob under test. Drop both assertions and note in the test's javadoc that arming semantics are out of scope here and are pinned instead by SymbolDictRecycleArmingTest.testArmsAtThreshold, which drives real rows through flush() and is the idiom that actually exercises armIfEligible(). The test's brief-mandated assertion -- the cap refusal still fires, with the new reset-valve message, when reset is disabled -- is untouched and remains the load-bearing check here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn --- .../test/cutlass/qwp/client/DeltaDictCeilingTest.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCeilingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCeilingTest.java index 85cc347a..846b2e8a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCeilingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCeilingTest.java @@ -104,6 +104,15 @@ public void testSymbolPastCapThrowsAndSenderStaysUsable() throws Exception { * its message still names the reset valve even though this particular * sender has it switched off -- the valve is documented for senders that * want it, not conditioned on this sender having chosen it. + *

    + * Out of scope here: whether {@code symbol_dict_reset=off} actually keeps + * {@code armIfEligible()} from arming. That only runs from the tail of a + * completed {@code flush()}, which this test never performs (the fill + * goes through the raw dictionary test accessor, and the one + * {@code Sender}-routed call throws inside {@code symbol()} before a row + * completes) -- an {@code isResetArmed()} assertion here would pass + * regardless of the knob, proving nothing. That arming-vs-flush property + * is pinned in {@code SymbolDictRecycleArmingTest.testArmsAtThreshold}. */ @Test public void testCapReachedWithResetDisabledStillThrowsAndNamesTheResetValve() throws Exception { @@ -121,7 +130,6 @@ public void testCapReachedWithResetDisabledStillThrowsAndNamesTheResetValve() th for (int i = 0; i < MAX_SYMBOL_DICTIONARY_SIZE; i++) { dict.getOrAddSymbol("f" + i); } - Assert.assertFalse("reset disabled must never arm", ws.isResetArmed()); try { sender.table("t").symbol("s", "one-too-many"); @@ -133,7 +141,6 @@ public void testCapReachedWithResetDisabledStillThrowsAndNamesTheResetValve() th Assert.assertTrue("message points at the reset valve: " + message, message.contains("symbol_dict_reset") && message.contains("resetSymbolDictionary()")); } - Assert.assertFalse("still not armed after the refusal", ws.isResetArmed()); } } }); From 6dc84e550b873cc8b46ba6bdc57bb1b55881f954 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:55:21 +0100 Subject: [PATCH 23/49] Final wave: survive a step-7 connect failure without latching recycleForDictReset() no longer latches the sender terminal when the recycle's reconnect fails. Step 7 moves out of the latching try block: steps 1-6 still latch recycleFailure (a half-swapped sender genuinely cannot make progress), but a failed ensureConnected() logs a warning and rethrows to the triggering caller without latching. By step 7 the swap has committed, so the sender is coherent - connected == false, loop and client already closed and nulled by ensureConnected's own catch, the fresh engine attached, the step-5 epoch and swap counters correctly left incremented - and the ordinary sendRow() -> ensureConnected() path retries the connect, and only the connect, on the next send. Nothing re-runs a teardown step, and nothing can fire a second swap meanwhile: the fresh dictionary sits below the threshold, manualResetRequested was consumed at step 5, and maybeRecycleForDictReset requires connected. This removes a default-configuration brick. A sender built with no reconnect_* knob resolves initialConnectMode to OFF, so step 7 is a single-shot connect; a server restart or an LB blip across a drained, armed sender therefore latched every later table()/flush() call forever, including seconds later once the endpoint was back. Deferring the connect to the next send exposed a second defect, which this commit also fixes. ensureConnected() calls resetSymbolDictStateForNewConnection(), which cleared currentBatchMaxSymbolId unconditionally. That watermark is batch-scoped, not connection-scoped - a flush ships exactly [sentMaxSymbolId+1 .. currentBatchMaxSymbolId] - and clearing it was harmless only because build() connects before the application can register a symbol. On the deferred path symbol() runs first, so the clear made the next flush ship an empty delta while its rows referenced symbol id 0: rows on the wire pointing at ids the server never received. The reset now runs only from the drained state the old code assumed (no pending rows, no row in progress). testDefaultConfigRecycleSurvivesFailedReconnect pins both. It drives a default-config sender (no reconnect knobs), kills the listener at a drained instant, asserts the recycle's throw reaches the caller, asserts recycleFailure is not latched by ingesting successfully once the endpoint returns on the same port, and asserts the recovered stream defines every symbol its rows reference. Against the pre-fix code it fails on the latch; with the latch fixed but the watermark clear restored it fails on the empty dictionary. Documentation and hygiene alongside: - getTotalFramesReplayed/getTotalFramesSent/getTotalReconnectAttempts/ getTotalReconnectsSucceeded/getTotalServerErrors now state that they read the live send loop and therefore restart at 0 on every recycle ("since the last recycle"), and point at the lifetime-scoped getSymbolDictEpoch/getSymbolDictResetsPerformed for correlation. - Sender.resetSymbolDictionary(), its QwpWebSocketSender override and LineSenderBuilder.symbolDictReset() now say that the manual valve is a permanent no-op while symbol_dict_reset is off, because armIfEligible gates on that knob. - armIfEligible's javadoc names both call sites; resetSymbolDictionary() calls it too, and the stale single-call-site claim invited a wrong inlining refactor. - The builder's symbolDictReset default references DEFAULT_SYMBOL_DICT_RESET_ENABLED instead of a hardcoded true. - SymbolDictRecycleOutageTest joins its trigger thread in a finally so an assert failure cannot leave a thread inside the sender, and records why the revived server's handshake count stays >= 1 rather than == 1. - SymbolDictRecycleCrashWindowsTest inlines an unread handler binding, and SymbolDictRecycleHealingTest's AckAllHandler carries a warning that its per-connection ack reset assumes every connection change is a recycle and must not be copied into a plain-reconnect test. Client suite: 3045 run, 0 failures, 0 errors, 2 skipped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn --- .../main/java/io/questdb/client/Sender.java | 11 +- .../qwp/client/QwpWebSocketSender.java | 188 ++++++++++++++---- .../client/SymbolDictRecycleHealingTest.java | 12 +- .../client/SymbolDictRecycleOutageTest.java | 171 +++++++++++++--- .../SymbolDictRecycleCrashWindowsTest.java | 3 +- 5 files changed, 312 insertions(+), 73 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index f1cb3739..ac0297b9 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -694,6 +694,11 @@ default Sender long256Column(CharSequence name, long l0, long l1, long l2, long * happens at the next safe point (all published data acknowledged, no row * in progress); it may be deferred indefinitely under sustained load. No-op * on transports without a symbol dictionary. + *

    + * Also a permanent no-op on a sender configured with + * {@code symbol_dict_reset=off} ({@link LineSenderBuilder#symbolDictReset(boolean)}): + * that knob gates the arming path this request feeds, so the request is + * accepted and never acted on. */ default void resetSymbolDictionary() { } @@ -1097,7 +1102,7 @@ final class LineSenderBuilder { private int maxFrameRejections = PARAMETER_NOT_SET_EXPLICITLY; private long poisonMinEscalationWindowMillis = PARAMETER_NOT_SET_EXPLICITLY; private long catchUpCapGapMinEscalationWindowMillis = PARAMETER_NOT_SET_EXPLICITLY; - private boolean symbolDictReset = true; + private boolean symbolDictReset = QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_ENABLED; private int symbolDictResetThreshold = PARAMETER_NOT_SET_EXPLICITLY; private long symbolDictResetMaxWaitMillis = PARAMETER_NOT_SET_EXPLICITLY; private String httpPath; @@ -1894,6 +1899,10 @@ public LineSenderBuilder catchUpCapGapMinEscalationWindowMillis(long millis) { * once it reaches {@link #symbolDictResetThreshold(int)} distinct symbols, * so a long-lived sender's dictionary does not grow without bound. *

    + * Switching it off also disables the manual valve: + * {@link Sender#resetSymbolDictionary()} becomes a permanent no-op, + * because arming gates on this knob. + *

    * Default {@code true} (on). WebSocket transport only. */ public LineSenderBuilder symbolDictReset(boolean enabled) { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 088b505d..0d67191c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -446,12 +446,14 @@ public interface EngineRebuildFactory { // its obvious reader. private volatile long symbolDictResetStarvationTimeouts; // Set (once) by recycleForDictReset's catch block when the recycle swap - // itself fails -- everything was acked before the swap tore the old - // engine down, so no data is at risk, but this sender can no longer make - // progress (no cursor engine, no I/O loop) and refuses further use. - // checkRecycleFailure() rethrows a fresh LineSenderException wrapping + // itself fails in steps 2-6 -- everything was acked before the swap tore + // the old engine down, so no data is at risk, but this sender can no + // longer make progress (no cursor engine, no I/O loop) and refuses further + // use. checkRecycleFailure() rethrows a fresh LineSenderException wrapping // this cause on every later table()/flush-family call; close() still - // works normally. + // works normally. A step-7 (reconnect) failure deliberately does NOT set + // this: the swap has already committed there, so the sender is coherent + // and merely disconnected, and the next send retries the connect. private Throwable recycleFailure; // Incremented once per completed symbol-dictionary recycle. 0 until the // first recycle commits. volatile: this is public API (see @@ -2150,8 +2152,11 @@ public long getFsnEpochBaseForTest() { * Number of symbol-dictionary recycles this sender has completed. Advances * by one at step 5 of {@link #recycleForDictReset()}, the instant the swap * commits to the new epoch -- before the engine rebuild (step 6) or the - * reconnect (step 7), so a later rebuild/reconnect failure that latches - * {@link #recycleFailure} still leaves this incremented. volatile: a + * reconnect (step 7), so a later step-6 rebuild failure that latches + * {@link #recycleFailure}, and a step-7 reconnect failure that does not, + * both still leave this incremented. Unlike the per-send-loop + * {@code getTotal*} counters, it is scoped to the sender's whole lifetime + * and never resets. volatile: a * concurrent read sees the latest write the producer thread completed, * but there is no atomicity across the three symbol-dictionary-recycle * counters -- a reader on another thread can observe this one already @@ -2168,8 +2173,9 @@ public long getSymbolDictEpoch() { * Incremented alongside {@link #getSymbolDictEpoch()} at step 5 of * {@link #recycleForDictReset()} -- before the engine rebuild (step 6) or * the reconnect (step 7), so, like the epoch counter, a later - * rebuild/reconnect failure that latches {@link #recycleFailure} still - * leaves this incremented. The two counts move together today -- the + * rebuild/reconnect failure still leaves this incremented, latched or not. + * Also like the epoch counter, it is scoped to the sender's whole lifetime + * and never resets. The two counts move together today -- the * only way the epoch advances is through a completed recycle swap -- but * they are defined, and incremented, independently: this one counts * completed swaps, {@code getSymbolDictEpoch()} counts the dictionary @@ -2295,9 +2301,15 @@ public long getTotalErrorNotificationsDelivered() { } /** - * Cumulative count of frames re-sent during post-reconnect catch-up - * windows. Zero in steady state; a sustained nonzero rate signals - * flapping where every reconnect replays meaningful work. + * Count of frames re-sent during post-reconnect catch-up windows since the + * last symbol-dictionary recycle. Zero in steady state; a sustained nonzero + * rate signals flapping where every reconnect replays meaningful work. + *

    + * Reads the live cursor I/O loop, which a symbol-dictionary recycle + * rebuilds, so the count restarts at 0 on every recycle: a monitor + * differencing it across one sees a negative delta. Correlate with the + * lifetime-scoped {@link #getSymbolDictEpoch()} / + * {@link #getSymbolDictResetsPerformed()}, which never reset. */ public long getTotalFramesReplayed() { CursorWebSocketSendLoop l = cursorSendLoop; @@ -2305,7 +2317,14 @@ public long getTotalFramesReplayed() { } /** - * Total binary frames the cursor I/O loop has issued to the wire. + * Binary frames the cursor I/O loop has issued to the wire since the last + * symbol-dictionary recycle. + *

    + * Reads the live cursor I/O loop, which a symbol-dictionary recycle + * rebuilds, so the count restarts at 0 on every recycle: a monitor + * differencing it across one sees a negative delta. Correlate with the + * lifetime-scoped {@link #getSymbolDictEpoch()} / + * {@link #getSymbolDictResetsPerformed()}, which never reset. */ public long getTotalFramesSent() { CursorWebSocketSendLoop l = cursorSendLoop; @@ -2313,9 +2332,16 @@ public long getTotalFramesSent() { } /** - * Number of reconnect attempts the cursor I/O loop has issued — - * succeeded plus failed. Diverges from {@link #getTotalReconnectsSucceeded} - * when the server is flapping. Returns 0 if no I/O loop is running. + * Number of reconnect attempts the cursor I/O loop has issued since the + * last symbol-dictionary recycle -- succeeded plus failed. Diverges from + * {@link #getTotalReconnectsSucceeded} when the server is flapping. + * Returns 0 if no I/O loop is running. + *

    + * Reads the live cursor I/O loop, which a symbol-dictionary recycle + * rebuilds, so the count restarts at 0 on every recycle: a monitor + * differencing it across one sees a negative delta. Correlate with the + * lifetime-scoped {@link #getSymbolDictEpoch()} / + * {@link #getSymbolDictResetsPerformed()}, which never reset. */ public long getTotalReconnectAttempts() { CursorWebSocketSendLoop l = cursorSendLoop; @@ -2323,7 +2349,14 @@ public long getTotalReconnectAttempts() { } /** - * Number of successful reconnects. Returns 0 if no I/O loop is running. + * Number of successful reconnects since the last symbol-dictionary + * recycle. Returns 0 if no I/O loop is running. + *

    + * Reads the live cursor I/O loop, which a symbol-dictionary recycle + * rebuilds, so the count restarts at 0 on every recycle: a monitor + * differencing it across one sees a negative delta. Correlate with the + * lifetime-scoped {@link #getSymbolDictEpoch()} / + * {@link #getSymbolDictResetsPerformed()}, which never reset. */ public long getTotalReconnectsSucceeded() { CursorWebSocketSendLoop l = cursorSendLoop; @@ -2331,7 +2364,14 @@ public long getTotalReconnectsSucceeded() { } /** - * Total errors observed by the I/O loop (retriable and terminal combined). + * Errors the I/O loop has observed since the last symbol-dictionary + * recycle (retriable and terminal combined). + *

    + * Reads the live cursor I/O loop, which a symbol-dictionary recycle + * rebuilds, so the count restarts at 0 on every recycle: a monitor + * differencing it across one sees a negative delta. Correlate with the + * lifetime-scoped {@link #getSymbolDictEpoch()} / + * {@link #getSymbolDictResetsPerformed()}, which never reset. */ public long getTotalServerErrors() { CursorWebSocketSendLoop l = cursorSendLoop; @@ -2652,6 +2692,10 @@ public void reset() { * have to wait for a later flush to observe {@code isResetArmed()}. A * request made mid-batch is picked up by the next * {@code resetTableBuffersAfterFlush} instead. + *

    + * A permanent no-op while {@code symbol_dict_reset} is off: arming gates + * on that knob, so a sender configured with the recycle disabled never + * acts on the request, however many times it is made. */ @Override public void resetSymbolDictionary() { @@ -3712,11 +3756,14 @@ private void checkNotClosed() { } /** - * Terminal latch for a failed symbol-dictionary recycle swap - * ({@link #recycleForDictReset()}). Everything was acked before the swap - * tore the old engine down, so no data is at risk -- but the swap itself - * left this sender without a cursor engine or I/O loop, so it refuses - * further use. Checked by {@link #table(CharSequence)}, the flush-family + * Terminal latch for a symbol-dictionary recycle swap that failed in steps + * 2-6 ({@link #recycleForDictReset()}). Everything was acked before the + * swap tore the old engine down, so no data is at risk -- but the swap + * itself left this sender without a cursor engine or I/O loop, so it + * refuses further use. A step-7 reconnect failure is NOT latched (the swap + * has committed by then and the sender is coherent, just disconnected -- + * see {@link #recycleForDictReset()}). Checked by + * {@link #table(CharSequence)}, the flush-family * entry points ({@link #flush()}, {@link #flushAndGetSequence()}, * {@link #drain(long)}, {@link #awaitAckedFsn(long, long)}), and * {@code sendRow()} (closing the fluent-chain corner where a caller @@ -4180,10 +4227,11 @@ private void ensureConnected() { ep.host, ep.port, endpoints.size()); } // Server starts fresh on each connection, so reset the per-batch - // symbol-dict watermark. Every frame still carries its full inline schema, - // and the fresh server's dictionary is re-established either by a full-dict - // frame (full-dict mode) or by an I/O-thread catch-up frame before replay - // (delta mode), so post-reconnect replay needs no producer-side reset signal. + // symbol-dict watermark when nothing is staged against it. Every frame + // still carries its full inline schema, and the fresh server's dictionary + // is re-established either by a full-dict frame (full-dict mode) or by an + // I/O-thread catch-up frame before replay (delta mode), so post-reconnect + // replay needs no producer-side reset signal. resetSymbolDictStateForNewConnection(); connectionError.set(null); @@ -4588,11 +4636,13 @@ private void resetTableBuffersAfterFlush() { * frames still benefits from bounding its dictionary size, and a manual * request is honoured regardless of mode. *

    - * Called only from the tail of {@link #resetTableBuffersAfterFlush()} (a - * safe point: no row in progress, this flush's data already handed to the - * engine), never from the per-symbol registration path - * ({@link #getOrAddGlobalSymbol}) -- arming mid-row or mid-encode would - * observe a dictionary size that has not yet settled for this batch. + * Called from two safe points only: the tail of + * {@link #resetTableBuffersAfterFlush()} (no row in progress, this flush's + * data already handed to the engine) and {@link #resetSymbolDictionary()} + * when nothing is in flight ({@code pendingRowCount == 0}). Never from the + * per-symbol registration path ({@link #getOrAddGlobalSymbol}) -- arming + * mid-row or mid-encode would observe a dictionary size that has not yet + * settled for this batch. */ private void armIfEligible() { boolean shouldArm = resetEnabled @@ -4743,14 +4793,29 @@ private void maybeRecycleForDictReset() { * mirroring (not calling) {@link #setCursorEngine} -- that method's * guards refuse a second engine.

  • *
  • Reconnect: {@link #ensureConnected()} builds a fresh I/O loop - * against the rolled {@link #fsnEpochBase}.
  • + * against the rolled {@link #fsnEpochBase}. Runs OUTSIDE the latching + * try -- see below. * - * A throw at any step is caught, latches {@link #recycleFailure} (step 8), + * A throw in steps 1-6 is caught, latches {@link #recycleFailure} (step 8), * and rethrows: every frame that existed before this call was already * proven acked, so no data is at risk, but the sender that made the throw - * observe a torn-down engine/loop refuses further use from here on -- + * observe a half-swapped engine/loop refuses further use from here on -- * {@link #checkRecycleFailure()} enforces that at every later * {@link #table(CharSequence)} and flush-family call. + *

    + * Step 7 is deliberately exempt from that latch. By then the swap has + * committed, so a failed connect leaves a fully coherent sender that is + * merely disconnected: {@code connected == false}, loop and client already + * closed and nulled by {@link #ensureConnected()}'s own catch, the fresh + * engine attached, and the step-5 counters ({@link #symbolDictEpoch}, + * {@link #symbolDictResetsPerformed}) correctly left incremented because + * the swap really did happen. It rethrows loudly to the triggering caller + * but the sender stays usable, and the ordinary + * {@code sendRow() -> ensureConnected()} path retries the connect -- and + * only the connect -- on the next send. Nothing can fire a second swap + * meanwhile: the fresh dictionary is below threshold, + * {@code manualResetRequested} was consumed at step 5, and + * {@link #maybeRecycleForDictReset()} requires {@code connected}. */ private void recycleForDictReset() { final long lastPublishedFsn = cursorEngine.publishedFsn(); // step 1 @@ -4784,11 +4849,6 @@ private void recycleForDictReset() { ownsCursorEngine = true; deltaDictEnabled = cursorEngine.isDeltaDictEnabled(); cursorEngine.setSlotLockReleaseListener(this::onSlotLockReleased); - // step 7: reconnect - rebuilds the loop with the rolled base - connected = false; - ensureConnected(); - LOG.info("symbol dictionary recycled [epoch={}, dictSizeAtSwap={}, pauseMicros={}]", - symbolDictEpoch, dictSizeAtSwap, (System.nanoTime() - startNanos) / 1000L); } catch (Throwable t) { // step 8: terminal latch - everything was acked before step 2, // so no data is at risk; the sender refuses further use. @@ -4800,6 +4860,32 @@ private void recycleForDictReset() { } throw new LineSenderException(t).put("symbol dictionary recycle failed"); } + // step 7: reconnect - rebuilds the loop with the rolled base. OUTSIDE + // the latching try on purpose: the swap has already committed, so a + // connect failure here leaves a coherent fresh-epoch sender that is + // merely disconnected, not a half-swapped one. It throws loudly to the + // caller but does NOT latch -- ensureConnected's own catch has already + // closed and nulled the loop and the client, the epoch and swap + // counters incremented at step 5 stay incremented (the swap really did + // happen), the sender stays disarmed (a fresh dictionary is below + // threshold and manualResetRequested was consumed, so nothing can fire + // a second swap while disconnected), and the next sendRow() retries the + // connect - and ONLY the connect - through ensureConnected. + connected = false; + try { + ensureConnected(); + } catch (Throwable t) { + LOG.warn("symbol dictionary swap committed but its reconnect failed; sender stays " + + "disconnected on the fresh epoch and retries the connect on the " + + "next send [epoch={}, dictSizeAtSwap={}]", + symbolDictEpoch, dictSizeAtSwap, t); + if (t instanceof LineSenderException) { + throw (LineSenderException) t; + } + throw new LineSenderException(t).put("symbol dictionary recycle reconnect failed"); + } + LOG.info("symbol dictionary recycled [epoch={}, dictSizeAtSwap={}, pauseMicros={}]", + symbolDictEpoch, dictSizeAtSwap, (System.nanoTime() - startNanos) / 1000L); } /** @@ -5225,14 +5311,28 @@ private void reclaimUnsentSymbolIds() { } private void resetSymbolDictStateForNewConnection() { - // Runs on the foreground (initial) connect only -- NOT on the I/O thread's - // reconnect/failover path. The per-batch watermark is drained state, so - // clearing it here is harmless. sentMaxSymbolId is deliberately left + // Runs on the foreground connect only -- NOT on the I/O thread's + // reconnect/failover path. sentMaxSymbolId is deliberately left // untouched: in delta mode the I/O thread re-registers the whole // dictionary with a catch-up frame on reconnect, so the producer's // monotonic baseline must survive the wire boundary; resetting it would // desync the producer from the I/O thread's sent-dictionary count. - currentBatchMaxSymbolId = -1; + // + // currentBatchMaxSymbolId is batch-scoped, not connection-scoped: a + // flush ships exactly [sentMaxSymbolId+1 .. currentBatchMaxSymbolId], + // so clearing it while a batch already references those ids ships a + // delta that OMITS them and puts rows on the wire pointing at symbol + // ids the server never received. Clearing it used to be unconditional + // and harmless because build() connects before the application can + // register anything. That no longer holds: a symbol-dictionary recycle + // whose step-7 connect failed defers the connect to the next + // sendRow(), which runs after symbol() has registered the ids of the + // row being built. Reset only from the drained state the old code + // assumed. + if (pendingRowCount == 0 + && (currentTableBuffer == null || !currentTableBuffer.hasInProgressRow())) { + currentBatchMaxSymbolId = -1; + } } /** diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java index d4a469c0..43a0b493 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -378,7 +378,17 @@ private static int[] deltaStartAndCount(byte[] frame) { return new int[]{deltaStart, deltaCount}; } - /** ACKs every frame it receives; does not otherwise inspect the wire. */ + /** + * ACKs every frame it receives; does not otherwise inspect the wire. + *

    + * WARNING -- recycle-only handler, do not copy into a plain-reconnect test. + * The per-connection sequence reset below assumes every connection change + * is a recycle, i.e. that a fresh engine is behind the new connection and + * its raw FSNs really do restart at 0. On an ordinary reconnect the SAME + * engine survives and keeps counting, so resetting here would ack frames + * the sender never published and silently advance its watermark past + * unsent data. + */ private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { private TestWebSocketServer.ClientHandler currentClient; private final AtomicLong nextSeq = new AtomicLong(0); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java index 458be65a..2f0036b5 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client; import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; @@ -143,44 +144,164 @@ public void testRecycleDuringOutageReconnectsAfresh() throws Exception { }, "recycle-trigger"); trigger.start(); - // Resolve the outage shortly after -- well inside - // reconnect_max_duration_millis=6000 -- on the SAME port. - Thread.sleep(150); + try { + // Resolve the outage shortly after -- well inside + // reconnect_max_duration_millis=6000 -- on the SAME port. + Thread.sleep(150); + OutageRecycleHandler revivedHandler = new OutageRecycleHandler(); + try (TestWebSocketServer revived = + new TestWebSocketServer(revivedHandler, false, null, port)) { + revived.start(); + Assert.assertTrue(revived.awaitStart(5, TimeUnit.SECONDS)); + + trigger.join(10_000); + Assert.assertFalse("recycle-trigger thread must have finished once the " + + "endpoint accepts again", trigger.isAlive()); + Assert.assertNull("triggering table() must not throw once the outage " + + "resolves within budget: " + triggerFailure.get(), + triggerFailure.get()); + + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals("recycle must complete despite the outage", + 1, ws.getSymbolDictEpoch()); + // Deliberately >= 1, not == 1: the pre-recycle I/O thread is + // banging on the refused port when the revive binds, and + // nothing orders step 2's close+join against that bind, so a + // stray handshake from the outgoing loop is legal here. + Assert.assertTrue("revived server must observe a fresh handshake", + revived.handshakeCount() >= 1); + + // The "c" row was built (atNow()) inside the triggering + // call but not yet flushed -- flush now and prove it + // lands on the fresh connection. + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle row must land once reconnected", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN", + fsn2 > fsn1); + + Assert.assertEquals("the fresh connection's first frame must carry a " + + "fresh (empty) dictionary, not a, b", + 0, revivedHandler.firstFrameDeltaStart); + Assert.assertEquals("post-recycle dictionary must hold only the new " + + "epoch's symbol, nothing lost or duplicated from " + + "before the outage", + Collections.singletonList("c"), revivedHandler.dict()); + } + } finally { + // Never leave the trigger thread running past this test: + // a thread still inside the sender on an assert-failure + // path muddies assertMemoryLeak's diagnostics. + trigger.join(10_000); + } + } + } + }); + } + + /** + * Default configuration: no {@code reconnect_*} knob and no + * {@code initial_connect_retry}, so the builder resolves + * {@code initialConnectMode} to OFF and step 7's {@code ensureConnected()} + * is a single-shot connect that fails outright while the endpoint refuses. + * Steps 1-6 have already committed by then, so latching {@code + * recycleFailure} here would brick the sender permanently over an ordinary + * transient outage -- the shipped default for every sender that crosses + * the threshold. + *

    + * Proves the step-7 failure reaches the caller WITHOUT latching, that the + * swap committed exactly one epoch, and that the very next send recovers + * through the existing {@code sendRow() -> ensureConnected()} path once + * the endpoint is back -- reconnecting only, never re-running a teardown + * step and never swapping a second time. + */ + @Test + public void testDefaultConfigRecycleSurvivesFailedReconnect() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("default-config-outage").toString(); + AckAllHandler firstHandler = new AckAllHandler(); + int port; + try (TestWebSocketServer server = new TestWebSocketServer(firstHandler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + port = server.getPort(); + String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2;"; + + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + Assert.assertTrue("the recycle must be on under a default configuration", + ws.isSymbolDictResetEnabled()); + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: the arming batch must be acked before the outage", + sender.awaitAckedFsn(fsn1, 5_000)); + Assert.assertTrue("must be armed after crossing threshold=2", ws.isResetArmed()); + Assert.assertEquals(0, ws.getSymbolDictEpoch()); + + // Kill the listener AND the live connection. The ring is + // drained, so the sender-level connected flag is still true + // and the next table() call fires the recycle into a wire + // that is already down. + server.close(); + + LineSenderException triggering = null; + try { + sender.table("t"); + Assert.fail("step 7's single-shot connect must throw while the endpoint " + + "refuses connections"); + } catch (LineSenderException e) { + triggering = e; + } + Assert.assertNotNull(triggering); + + Assert.assertEquals("the swap committed exactly one epoch before the connect " + + "failed", 1, ws.getSymbolDictEpoch()); + Assert.assertEquals(1, ws.getSymbolDictResetsPerformed()); + Assert.assertFalse("a committed swap disarms even when its reconnect fails", + ws.isResetArmed()); + + // Endpoint back on the SAME port. The sender must not be + // terminal: the next send reconnects on its own. OutageRecycleHandler revivedHandler = new OutageRecycleHandler(); try (TestWebSocketServer revived = new TestWebSocketServer(revivedHandler, false, null, port)) { revived.start(); Assert.assertTrue(revived.awaitStart(5, TimeUnit.SECONDS)); - trigger.join(10_000); - Assert.assertFalse("recycle-trigger thread must have finished once the " - + "endpoint accepts again", trigger.isAlive()); - Assert.assertNull("triggering table() must not throw once the outage " - + "resolves within budget: " + triggerFailure.get(), - triggerFailure.get()); - - Assert.assertFalse("recycle must disarm", ws.isResetArmed()); - Assert.assertEquals("recycle must complete despite the outage", - 1, ws.getSymbolDictEpoch()); - Assert.assertTrue("revived server must observe a fresh handshake", - revived.handshakeCount() >= 1); - - // The "c" row was built (atNow()) inside the triggering - // call but not yet flushed -- flush now and prove it - // lands on the fresh connection. + // "d" registers BEFORE the deferred connect -- symbol() + // runs ahead of sendRow(), which is what finally + // performs it -- so this first batch is the one that + // proves the connect left the batch's symbol watermark + // alone on its way through. + sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); - Assert.assertTrue("post-recycle row must land once reconnected", + Assert.assertTrue("a sender whose step-7 connect failed must still ingest " + + "once the endpoint returns", sender.awaitAckedFsn(fsn2, 5_000)); Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN", fsn2 > fsn1); + Assert.assertEquals("the recovery reconnects only -- it must not run a " + + "second swap", 1, ws.getSymbolDictEpoch()); + Assert.assertEquals(1, ws.getSymbolDictResetsPerformed()); - Assert.assertEquals("the fresh connection's first frame must carry a " + Assert.assertEquals("the recovered connection's first frame must carry a " + "fresh (empty) dictionary, not a, b", 0, revivedHandler.firstFrameDeltaStart); - Assert.assertEquals("post-recycle dictionary must hold only the new " - + "epoch's symbol, nothing lost or duplicated from " - + "before the outage", - Collections.singletonList("c"), revivedHandler.dict()); + Assert.assertEquals("the recovered stream must define every symbol its " + + "rows reference: a deferred connect that cleared the " + + "batch watermark would ship a row pointing at an id " + + "the server never received", + Collections.singletonList("d"), revivedHandler.dict()); + + // And the epoch keeps extending normally from there. + sender.table("t").symbol("s", "e").longColumn("v", 4L).atNow(); + long fsn3 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(fsn3, 5_000)); + Assert.assertEquals("later batches must extend the same fresh dictionary", + Arrays.asList("d", "e"), revivedHandler.dict()); } } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java index acd98450..0f4a5e97 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java @@ -191,8 +191,7 @@ public void testCrashBeforeRecycleStartsRecoversAckedResidueOnly() throws Except String sfDir = temporaryFolder.getRoot().toPath().resolve("crash-a-pre-swap").toString(); String slot = Paths.get(sfDir, "default").toString(); long fsn; - SilentHandler crashedHandler = new SilentHandler(); - try (TestWebSocketServer crashed = startedServer(crashedHandler)) { + try (TestWebSocketServer crashed = startedServer(new SilentHandler())) { String cfg = "ws::addr=localhost:" + crashed.getPort() + ";sf_dir=" + sfDir + ";symbol_dict_reset_threshold=2;close_flush_timeout_millis=0;"; try (Sender sender = Sender.fromConfig(cfg)) { From 06dcb6dbcd59888ae73e27e1226155496274d16e Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:46:03 +0100 Subject: [PATCH 24/49] Fix round 2: recycle awaits a deferred engine close Review C1: recycleForDictReset step 3 re-acquired the slot flock without waiting for a deferred close to release it. When the SF worker is wedged in a syscall past SegmentManager's bounded join, CursorSendEngine.close() returns with the flock retained and isCloseCompleted() false, releasing both from the worker's exit path; the step-6 rebuild then threw SlotLockContentionException on the retained flock and latched the sender permanently terminal -- converting exactly the transient disk stall the deferred-close machinery exists to survive into a hard sender death on the default (SF + recycle-on) path. recycleForDictReset now mirrors close()'s deferred-close discipline: awaitDeferredEngineClose parks (awaitAckedFsn-shaped) until the deferred cleanup confirms the flock release, re-arming the shared flock-release retry driver each pass like isSlotLockReleased() does. Only exhausting the 30 s budget -- a genuinely dead worker -- latches terminal, and that path hands the still-locked engine to retainedEngine so a pool re-probe recovers the slot's capacity if the worker ever exits; close() no longer clobbers slotLockReleased to true while such an engine is pending. SymbolDictRecycleDeferredCloseTest pins both branches with a wedged- worker harness (red-proofed: without the await, the survival test dies with the exact SlotLockContentionException the review traced). Review Mo1: getAckedFsn and awaitAckedFsn snapshot cursorEngine into a local (the recycle transitions it non-null -> null -> non-null), and while it is null they report the durable watermark the recycle barrier proved (new lastRecycleDurableFsn) instead of collapsing to -1. Review Mo2: PooledSender forwards resetSymbolDictionary() to the live delegate instead of inheriting the interface's no-op default; pinned by a pooled-path test. Review Mo5 (verified: released 9.4.x servers cap the dictionary at 1M): document the pre-10.0.0 compatibility constraint on the 2M client cap at QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE and symbolDictReset(boolean). Review mi7/mi8: step 6 refuses a rebuilt engine that recovered from disk (the empties-the-slot contract was breached) and resets slotLockReleased after a successful swap. Review mi3: recycle javadoc now says seven steps / steps 2-6, the resetArmed and resetSymbolDictionary docs match the code, and EngineRebuildFactory moves out of the field block. Review Mo4: SymbolDictRecycleFsnContinuityTest wraps every test in assertMemoryLeak, and LineSenderBuilderWebSocketTest pins the config boundaries: threshold == 2M accepted on both config paths, the symbol_dict_reset=on parse branch, the invalid-value message, and the three fluent-setter transport guards. Co-Authored-By: Claude Fable 5 --- .../main/java/io/questdb/client/Sender.java | 7 + .../qwp/client/QwpWebSocketSender.java | 184 +++++-- .../cutlass/qwp/protocol/QwpConstants.java | 8 + .../io/questdb/client/impl/PooledSender.java | 5 + .../LineSenderBuilderWebSocketTest.java | 58 +++ .../SymbolDictRecycleDeferredCloseTest.java | 296 ++++++++++++ .../SymbolDictRecycleFsnContinuityTest.java | 452 +++++++++--------- .../client/test/impl/SenderPoolSfTest.java | 31 ++ 8 files changed, 793 insertions(+), 248 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index ac0297b9..689553f4 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1903,6 +1903,13 @@ public LineSenderBuilder catchUpCapGapMinEscalationWindowMillis(long millis) { * {@link Sender#resetSymbolDictionary()} becomes a permanent no-op, * because arming gates on this knob. *

    + * Switching it off removes the only bound on dictionary growth below + * the hard cap ({@link io.questdb.client.cutlass.qwp.protocol.QwpConstants#MAX_SYMBOL_DICTIONARY_SIZE}, + * 2,000,000). Servers released before QuestDB 10.0.0 cap the + * dictionary at 1,000,000 and reject anything beyond it as a terminal + * parse error, so with the recycle off against a pre-10.0.0 server + * keep symbol cardinality below 1M. + *

    * Default {@code true} (on). WebSocket transport only. */ public LineSenderBuilder symbolDictReset(boolean enabled) { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 0d67191c..2f2bf08e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -159,6 +159,13 @@ public class QwpWebSocketSender implements Sender { // sf-client.md section 4.4 floor: drop-oldest under bursts needs a wide // enough window to preserve the trailing category distribution. private static final int MIN_ERROR_INBOX_CAPACITY = 16; + // Upper bound on how long recycleForDictReset step 3 waits for a DEFERRED + // engine close (SF worker wedged in a syscall past SegmentManager's + // bounded join) to release the slot flock before giving up and latching + // the sender terminal. Sized well past any transient disk/NFS stall the + // deferred-close machinery exists to survive; a worker still wedged after + // this long is treated as a genuinely dead disk. + private static final long RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS = 30_000L; private static final String WRITE_PATH = "/write/v4"; private final String authorizationHeader; private final int autoFlushBytes; @@ -301,15 +308,6 @@ public class QwpWebSocketSender implements Sender { // while the producer thread reads it from sendRow without // holding the sender monitor. private volatile int effectiveAutoFlushBytes; - /** - * Rebuilds a fresh {@link CursorSendEngine} on this sender's own slot, going - * through the identical construct/quarantine code path - * {@link Sender.LineSenderBuilder#build} uses. - */ - public interface EngineRebuildFactory { - CursorSendEngine rebuild(); - } - // Installed by build() once connect() succeeds; null for a sender that // has never connected. See setEngineRebuildFactory. private EngineRebuildFactory engineRebuildFactory; @@ -432,8 +430,10 @@ public interface EngineRebuildFactory { // runs and consumes the request. private boolean manualResetRequested; // True once armIfEligible has determined a recycle should happen. Consumed - // by the (later-task) recycle trigger; set only at the tail of - // resetTableBuffersAfterFlush, never on the per-symbol registration path. + // by the recycle trigger; set only from armIfEligible's two safe call + // points (the tail of resetTableBuffersAfterFlush, and + // resetSymbolDictionary() when no flush is in flight), never on the + // per-symbol registration path. private boolean resetArmed; // Cleared on the false -> true armed transition; a later task's // opportunistic-wait step sets it once it has waited out its window for @@ -445,6 +445,19 @@ public interface EngineRebuildFactory { // getSymbolDictResetStarvationTimeouts()), and a monitoring thread is // its obvious reader. private volatile long symbolDictResetStarvationTimeouts; + // External-scale FSN of the last frame proven durably acked by a recycle's + // barrier, recorded at recycleForDictReset step 1 before any teardown. + // -1 until the first recycle that had published anything. Lets the + // monitoring accessors (getAckedFsn, awaitAckedFsn's null-engine branch) + // keep reporting the durable watermark instead of collapsing to -1 while + // cursorEngine is transiently null mid-swap or permanently null after a + // failed recycle -- all pre-swap data really is acked, so the watermark + // stays truthful. + private long lastRecycleDurableFsn = -1L; + // Budget for recycleForDictReset's deferred-close await (see + // RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS); non-final only so tests can + // shrink it to drive the timeout branch. + private long recycleDeferredCloseMaxWaitMillis = RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS; // Set (once) by recycleForDictReset's catch block when the recycle swap // itself fails in steps 2-6 -- everything was acked before the swap tore // the old engine down, so no data is at risk, but this sender can no @@ -1050,10 +1063,16 @@ public void atNow() { public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { checkNotClosed(); checkRecycleFailure(); - if (cursorEngine == null) { - return targetFsn < 0L; + // Snapshot: the recycle transitions cursorEngine non-null -> null -> + // non-null on the producer thread; reading the field once keeps this + // method from dereferencing a half-swapped null. While it is null, + // anything at or below the watermark the recycle barrier proved + // durable is truthfully "acked". + CursorSendEngine engine = cursorEngine; + if (engine == null) { + return targetFsn < 0L || targetFsn <= lastRecycleDurableFsn; } - cursorEngine.checkDurability(); + engine.checkDurability(); // Surface latched errors before any early-return path, so a caller // polling with timeoutMillis <= 0 to drive their own loop sees the // throw instead of an indefinite "not yet". The durability latch @@ -1071,15 +1090,15 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { } targetFsn = internalTarget; } - if (cursorEngine.ackedFsn() >= targetFsn) { + if (engine.ackedFsn() >= targetFsn) { return true; } if (timeoutMillis <= 0L) { return false; } long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; - while (cursorEngine.ackedFsn() < targetFsn) { - cursorEngine.checkDurability(); + while (engine.ackedFsn() < targetFsn) { + engine.checkDurability(); if (cursorSendLoop != null) { cursorSendLoop.checkError(); } @@ -1899,7 +1918,13 @@ public QwpWebSocketSender geoHashColumn(CharSequence columnName, CharSequence va */ @Override public long getAckedFsn() { - return cursorEngine != null ? fsnEpochBase + cursorEngine.ackedFsn() : -1L; + // Snapshot: the recycle transitions cursorEngine non-null -> null -> + // non-null on the producer thread, so read the field once. While it + // is null (mid-swap, or for good after a failed swap) report the last + // watermark a recycle barrier proved durable instead of collapsing + // to -1 -- all pre-swap data really is acked. + CursorSendEngine engine = cursorEngine; + return engine != null ? fsnEpochBase + engine.ackedFsn() : lastRecycleDurableFsn; } /** @@ -2686,11 +2711,13 @@ public void reset() { /** * Advisory request to start a fresh symbol-dictionary epoch. Sets - * {@link #manualResetRequested}; if no row is currently in progress and no - * flush is in flight ({@code pendingRowCount == 0}), re-evaluates arming - * immediately so a caller that requests a reset between batches does not - * have to wait for a later flush to observe {@code isResetArmed()}. A - * request made mid-batch is picked up by the next + * {@link #manualResetRequested}; if no flush is in flight + * ({@code pendingRowCount == 0} -- a first row may still be under + * construction; arming is harmless there because the recycle trigger + * itself refuses to run mid-row), re-evaluates arming immediately so a + * caller that requests a reset between batches does not have to wait for + * a later flush to observe {@code isResetArmed()}. A request made + * mid-batch is picked up by the next * {@code resetTableBuffersAfterFlush} instead. *

    * A permanent no-op while {@code symbol_dict_reset} is off: arming gates @@ -2870,6 +2897,11 @@ public void setErrorInboxCapacity(int capacity) { this.errorInboxCapacity = capacity; } + @TestOnly + public void setRecycleDeferredCloseMaxWaitMillisForTesting(long millis) { + this.recycleDeferredCloseMaxWaitMillis = millis; + } + public void setTransactional(boolean transactional) { this.transactional = transactional; } @@ -3832,7 +3864,11 @@ private synchronized Throwable closeRemainingResources(Throwable terminalError) slotLockReleased = false; retainedEngine = engine; } - } else { + } else if (retainedEngine == null) { + // No engine and nothing retained: no flock left to report. A + // non-null retainedEngine (a recycle's deferred-close await + // timed out) still holds the slot flock, so leave the flag + // false and let isSlotLockReleased() re-probe it. slotLockReleased = true; } if (errorDispatcher != null) { @@ -4654,6 +4690,48 @@ private void armIfEligible() { resetArmed = shouldArm; } + /** + * Recycle step 3's deferred-close await. A fully-drained engine close + * normally completes inline ({@code isCloseCompleted()} true on return), + * making this a single volatile read. When the SF worker was wedged in a + * syscall past {@code SegmentManager}'s bounded join, the close instead + * returned with the slot flock retained and its release deferred to the + * worker's exit path -- exactly the transient disk stall the + * deferred-close machinery exists to survive. Park (the same + * {@code awaitAckedFsn}-shaped wait the starvation policy uses) until the + * deferred cleanup confirms the release; each pass also re-arms the + * shared flock-release retry driver for the close-ran-but-release-failed + * case, mirroring {@link #isSlotLockReleased()}'s re-probe. + *

    + * Exhausting {@link #recycleDeferredCloseMaxWaitMillis} means the worker + * is genuinely dead (not stalled), so throw -- the recycle's catch then + * latches the sender terminal. Before throwing, hand the still-locked + * engine to {@link #retainedEngine} so a pool re-probe + * ({@link #isSlotLockReleased()}) can still recover the slot's capacity + * if the worker ever exits. + */ + private void awaitDeferredEngineClose(CursorSendEngine outgoing) { + if (outgoing.isCloseCompleted()) { + return; + } + LOG.warn("symbol dictionary recycle waiting for a deferred engine close: the SF worker " + + "did not quiesce, so the slot lock is still held [maxWaitMillis={}]", + recycleDeferredCloseMaxWaitMillis); + long deadlineNanos = System.nanoTime() + recycleDeferredCloseMaxWaitMillis * 1_000_000L; + while (!outgoing.isCloseCompleted()) { + if (System.nanoTime() >= deadlineNanos) { + retainedEngine = outgoing; + slotLockReleased = false; + throw new LineSenderException("symbol dictionary recycle could not reclaim its " + + "slot: the outgoing engine's deferred close did not release the slot " + + "lock within " + recycleDeferredCloseMaxWaitMillis + + " ms (SF worker wedged)"); + } + outgoing.ensureFlockReleaseRetryScheduled(); + java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + } + } + /** * True once every FSN this engine has published has also been * server-acknowledged (or nothing has been published yet). The barrier @@ -4765,7 +4843,7 @@ private void maybeRecycleForDictReset() { * The symbol-dictionary recycle swap. Runs synchronously on the producer * thread from the {@link #table(CharSequence)} barrier, once * {@link #maybeRecycleForDictReset()} has proven the ring is drained. - * Eight steps, strictly ordered: + * Seven steps, strictly ordered: *

      *
    1. Snapshot the outgoing epoch's last published (raw) FSN.
    2. *
    3. Close and null the cursor I/O loop -- joins the I/O thread and @@ -4796,12 +4874,24 @@ private void maybeRecycleForDictReset() { * against the rolled {@link #fsnEpochBase}. Runs OUTSIDE the latching * try -- see below.
    4. *
    - * A throw in steps 1-6 is caught, latches {@link #recycleFailure} (step 8), + * A throw in steps 2-6 is caught, latches {@link #recycleFailure}, * and rethrows: every frame that existed before this call was already * proven acked, so no data is at risk, but the sender that made the throw * observe a half-swapped engine/loop refuses further use from here on -- * {@link #checkRecycleFailure()} enforces that at every later - * {@link #table(CharSequence)} and flush-family call. + * {@link #table(CharSequence)} and flush-family call. (Step 1 is a pair + * of plain reads and runs before the latching try.) + *

    + * Step 3 mirrors {@code close()}'s deferred-close discipline: when the + * outgoing engine's close could not confirm SF-worker quiescence it + * returns with the slot flock retained and {@code isCloseCompleted()} + * false, releasing both from the worker's exit path. Step 3 then awaits + * that deferred release (bounded by + * {@link #RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS}) before step 6 rebuilds + * on the slot -- rebuilding against the retained flock would throw + * {@code SlotLockContentionException} and needlessly latch the sender + * terminal for what is usually a transient disk stall. Only exhausting + * the await budget (a genuinely dead worker) latches terminal. *

    * Step 7 is deliberately exempt from that latch. By then the swap has * committed, so a failed connect leaves a fully coherent sender that is @@ -4821,6 +4911,12 @@ private void recycleForDictReset() { final long lastPublishedFsn = cursorEngine.publishedFsn(); // step 1 final int dictSizeAtSwap = globalSymbolDictionary.size(); final long startNanos = System.nanoTime(); + if (lastPublishedFsn >= 0) { + // The barrier proved every published frame acked, so this is the + // durable watermark the monitoring accessors keep reporting while + // cursorEngine is null (mid-swap, or for good after a failed swap). + lastRecycleDurableFsn = fsnEpochBase + lastPublishedFsn; + } try { // step 2: close the loop - joins the I/O thread, closes the client if (cursorSendLoop != null) { @@ -4830,9 +4926,14 @@ private void recycleForDictReset() { client = null; // step 3: fully-drained close of the engine - empties the slot. // Holds NO logical slot lock here: close(true) unlinks the logical - // lock file (CursorSendEngine close javadoc). - cursorEngine.close(); + // lock file (CursorSendEngine close javadoc). When the SF worker + // is wedged in a syscall the close defers flock release to the + // worker's exit path -- await it (bounded) rather than let step 6 + // throw SlotLockContentionException on the retained flock. + CursorSendEngine outgoing = cursorEngine; + outgoing.close(); cursorEngine = null; + awaitDeferredEngineClose(outgoing); // step 4: roll the external FSN base (-1 no-publish case adds 0) rollFsnEpochBase(lastPublishedFsn); // step 5: producer state swap - replace, don't clear() @@ -4847,10 +4948,24 @@ private void recycleForDictReset() { // step 6: rebuild the engine on the now-empty slot cursorEngine = engineRebuildFactory.rebuild(); ownsCursorEngine = true; + if (cursorEngine.wasRecoveredFromDisk()) { + // close(true) above emptied the slot, so a rebuild that found + // a persisted dictionary to recover from means the outgoing + // close's empties-the-slot contract was breached -- the fresh + // producer dictionary and the slot's on-disk state have + // diverged, so refuse to run on it. + throw new LineSenderException( + "symbol dictionary recycle rebuilt on a non-empty slot: " + + "the outgoing engine's fully-drained close did not empty it"); + } deltaDictEnabled = cursorEngine.isDeltaDictEnabled(); cursorEngine.setSlotLockReleaseListener(this::onSlotLockReleased); + // The fresh engine holds the slot flock again; step 3's release + // flipped slotLockReleased true via the outgoing engine's + // listener, which no longer describes this sender's state. + slotLockReleased = false; } catch (Throwable t) { - // step 8: terminal latch - everything was acked before step 2, + // terminal latch - everything was acked before step 2, // so no data is at risk; the sender refuses further use. recycleFailure = t; LOG.error("symbol dictionary recycle failed; sender is now terminal " @@ -5674,6 +5789,15 @@ public Endpoint(String host, int port) { } } + /** + * Rebuilds a fresh {@link CursorSendEngine} on this sender's own slot, going + * through the identical construct/quarantine code path + * {@link Sender.LineSenderBuilder#build} uses. + */ + public interface EngineRebuildFactory { + CursorSendEngine rebuild(); + } + private final class ReconnectSupplier implements CursorWebSocketSendLoop.ReconnectFactory { /** * Optional caller-owned liveness gate. {@code null} means this factory diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java index 5d1b2f5c..ec51437c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java @@ -92,6 +92,14 @@ public final class QwpConstants { *

    * NOT the result-direction cap: {@code QwpResultBatchDecoder.MAX_CONN_DICT_SIZE} * (8,388,608) governs server-to-client result batches and is unrelated. + *

    + * Compatibility: servers released before QuestDB 10.0.0 cap their + * dictionary at 1,000,000, and QWP has no wire-level negotiation of the + * limit -- a dictionary this client lets grow past 1M is rejected by + * those servers as a terminal parse error. Unreachable on defaults + * ({@code symbol_dict_reset} recycles at 100k), but a sender configured + * with the recycle off (or a threshold above 1M) against a pre-10.0.0 + * server must keep its symbol cardinality below the old 1M cap. */ public static final int MAX_SYMBOL_DICTIONARY_SIZE = 2_000_000; /** diff --git a/core/src/main/java/io/questdb/client/impl/PooledSender.java b/core/src/main/java/io/questdb/client/impl/PooledSender.java index 7b4e5f80..095f64ab 100644 --- a/core/src/main/java/io/questdb/client/impl/PooledSender.java +++ b/core/src/main/java/io/questdb/client/impl/PooledSender.java @@ -335,6 +335,11 @@ public void reset() { slot.live(generation).reset(); } + @Override + public void resetSymbolDictionary() { + slot.live(generation).resetSymbolDictionary(); + } + @Override public Sender shortColumn(CharSequence name, short value) { slot.live(generation).shortColumn(name, value); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java index 0bb792e3..75540eef 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java @@ -334,6 +334,64 @@ public void testSymbolDictResetRejectedForNonWebSocketTransport() { () -> Sender.builder("http::addr=" + LOCALHOST + ":9000;symbol_dict_reset_max_wait_millis=0;")); } + /** + * The three fluent setters carry the same transport guard as the + * connect-string keys, but sit on a separate code path -- pin them + * directly so a guard dropped from the setters alone cannot ship green. + */ + @Test + public void testSymbolDictResetFluentSettersRejectNonWebSocketTransport() { + assertThrows("symbol_dict_reset is only supported for WebSocket transport", + () -> Sender.builder(Sender.Transport.HTTP).symbolDictReset(true)); + assertThrows("symbol_dict_reset_threshold is only supported for WebSocket transport", + () -> Sender.builder(Sender.Transport.HTTP).symbolDictResetThreshold(500)); + assertThrows("symbol_dict_reset_max_wait_millis is only supported for WebSocket transport", + () -> Sender.builder(Sender.Transport.HTTP).symbolDictResetMaxWaitMillis(0)); + } + + /** + * {@code symbol_dict_reset=on} must survive the parse as {@code true} -- + * distinct from the default-true path, which passes with the parse branch + * deleted. Contrast against {@code off} on an otherwise identical builder. + */ + @Test + public void testSymbolDictResetOnParsesTrue() { + Assert.assertEquals(Boolean.TRUE, + Sender.builder("ws::addr=" + LOCALHOST + ";symbol_dict_reset=on;") + .wsConfigSnapshotForTest() + .get("symbol_dict_reset")); + Assert.assertEquals(Boolean.FALSE, + Sender.builder("ws::addr=" + LOCALHOST + ";symbol_dict_reset=off;") + .wsConfigSnapshotForTest() + .get("symbol_dict_reset")); + } + + @Test + public void testSymbolDictResetRejectsInvalidValue() { + assertThrows("invalid symbol_dict_reset [value=banana, allowed-values=[on, off]]", + () -> Sender.builder("ws::addr=" + LOCALHOST + ";symbol_dict_reset=banana;")); + } + + /** + * The accepted upper edge: exactly {@code MAX_SYMBOL_DICTIONARY_SIZE} + * (2M) must pass validation -- a {@code >} -> {@code >=} regression at + * the bound would reject it. Both the connect-string and the fluent + * setter paths. + */ + @Test + public void testSymbolDictResetThresholdAcceptsHardCapBoundary() { + Assert.assertEquals(QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE, + Sender.builder("ws::addr=" + LOCALHOST + ";symbol_dict_reset_threshold=" + + QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE + ";") + .wsConfigSnapshotForTest() + .get("symbol_dict_reset_threshold")); + Assert.assertEquals(QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE, + Sender.builder(Sender.Transport.WEBSOCKET) + .symbolDictResetThreshold(QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE) + .wsConfigSnapshotForTest() + .get("symbol_dict_reset_threshold")); + } + @Test public void testConnectionRefused() throws Exception { assertMemoryLeak(() -> { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java new file mode 100644 index 00000000..4b3d2962 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java @@ -0,0 +1,296 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * The recycle's deferred-close discipline ({@code recycleForDictReset} step 3, + * {@code awaitDeferredEngineClose}): when the SF worker is wedged in a syscall + * past {@code SegmentManager}'s bounded join, the outgoing engine's close + * returns with the slot flock retained and its release deferred to the + * worker's exit path. The recycle must await that release before rebuilding on + * the slot -- rebuilding against the retained flock throws + * {@code SlotLockContentionException} and would latch the sender permanently + * terminal for what is usually a transient disk stall. Only exhausting the + * await budget (a genuinely dead worker) may latch terminal. + *

    + * The wedge: {@code SegmentManager}'s trim-sync hook runs unconditionally once + * per service pass, so parking the worker there leaves it un-joinable exactly + * the way a stalled disk/NFS syscall does, and a shrunken + * {@code workerJoinTimeoutMillis} lets the close's bounded join give up + * promptly (the same recipe as {@code SlotLockReleasedContractTest}). + */ +public class SymbolDictRecycleDeferredCloseTest { + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + /** + * A transient wedge: the worker un-wedges while the recycle is parked in + * its deferred-close await. The recycle must ride the stall out and + * complete -- fresh epoch, rebuilt engine, sender fully usable -- instead + * of latching terminal on the retained flock. + */ + @Test(timeout = 60_000L) + public void testRecycleSurvivesDeferredEngineClose() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("recycle-deferred-survive").toString(); + try (TestWebSocketServer server = ackingServer()) { + String cfg = "ws::addr=localhost:" + server.getPort() + ";sf_dir=" + sfDir + ";"; + CountDownLatch workerBlocked = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + AtomicBoolean wedgeFired = new AtomicBoolean(); + AtomicReference auxErr = new AtomicReference<>(); + Thread releaser = null; + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + + CursorSendEngine outgoing = ws.getCursorEngineForTesting(); + SegmentManager manager = outgoing.getManagerForTesting(); + try { + manager.setBeforeTrimSyncHook(() -> { + if (!wedgeFired.compareAndSet(false, true)) { + return; + } + workerBlocked.countDown(); + try { + if (!releaseWorker.await(30, TimeUnit.SECONDS)) { + auxErr.compareAndSet(null, new AssertionError( + "timed out waiting for the test to release the worker")); + } + } catch (Throwable t) { + auxErr.compareAndSet(null, t); + } + }); + manager.wakeWorker(); + Assert.assertTrue("worker never reached the wedge hook", + workerBlocked.await(5, TimeUnit.SECONDS)); + manager.setWorkerJoinTimeoutMillis(50L); + + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + + // Un-wedges the worker while the recycle is parked in its + // deferred-close await. The pre-release assert can never + // race: the flock release needs the worker's exit, which + // needs this very countDown. + releaser = new Thread(() -> { + try { + Thread.sleep(1_500L); + Assert.assertFalse( + "deferred close cannot complete while the worker is wedged", + outgoing.isCloseCompleted()); + } catch (Throwable t) { + auxErr.compareAndSet(null, t); + } finally { + releaseWorker.countDown(); + } + }, "deferred-close-releaser"); + releaser.start(); + + // Triggers the recycle. Step 3's close cannot reap the + // wedged worker, so it returns with the slot flock + // retained; the bounded await must ride the wedge out + // instead of letting step 6 throw + // SlotLockContentionException and latch terminal. + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + + Assert.assertEquals("the recycle must have committed", + 1, ws.getSymbolDictEpoch()); + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertTrue("the outgoing engine's deferred close must have " + + "completed before the rebuild", + outgoing.isCloseCompleted()); + Assert.assertNotSame("the recycle must run on a rebuilt engine", + outgoing, ws.getCursorEngineForTesting()); + + sender.table("t").symbol("s", "c").longColumn("v", 3L).atNow(); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle batch must still get acked", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN " + + "[fsn1=" + fsn1 + ", fsn2=" + fsn2 + ']', fsn2 > fsn1); + } finally { + manager.setBeforeTrimSyncHook(null); + releaseWorker.countDown(); + } + } finally { + releaseWorker.countDown(); + if (releaser != null) { + releaser.join(10_000L); + } + } + if (auxErr.get() != null) { + throw new AssertionError("auxiliary thread failed", auxErr.get()); + } + } + }); + } + + /** + * A permanent wedge: the await budget (shrunk via the test seam) runs out + * with the flock still held -- a genuinely dead worker. The recycle must + * latch terminal BEFORE committing any of the swap (epoch stays 0), and + * the still-locked engine must stay reachable through + * {@code isSlotLockReleased()}'s re-probe so the slot's capacity is + * recoverable if the worker ever exits. + */ + @Test(timeout = 60_000L) + public void testRecycleLatchesTerminalWhenDeferredCloseNeverReleases() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("recycle-deferred-timeout").toString(); + try (TestWebSocketServer server = ackingServer()) { + String cfg = "ws::addr=localhost:" + server.getPort() + ";sf_dir=" + sfDir + ";"; + CountDownLatch workerBlocked = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + AtomicBoolean wedgeFired = new AtomicBoolean(); + AtomicReference auxErr = new AtomicReference<>(); + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + + CursorSendEngine outgoing = ws.getCursorEngineForTesting(); + SegmentManager manager = outgoing.getManagerForTesting(); + try { + manager.setBeforeTrimSyncHook(() -> { + if (!wedgeFired.compareAndSet(false, true)) { + return; + } + workerBlocked.countDown(); + try { + if (!releaseWorker.await(30, TimeUnit.SECONDS)) { + auxErr.compareAndSet(null, new AssertionError( + "timed out waiting for the test to release the worker")); + } + } catch (Throwable t) { + auxErr.compareAndSet(null, t); + } + }); + manager.wakeWorker(); + Assert.assertTrue("worker never reached the wedge hook", + workerBlocked.await(5, TimeUnit.SECONDS)); + manager.setWorkerJoinTimeoutMillis(50L); + ws.setRecycleDeferredCloseMaxWaitMillisForTesting(150L); + + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + + try { + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + Assert.fail("expected the recycle to latch terminal once the " + + "deferred-close await budget ran out"); + } catch (LineSenderException e) { + TestUtils.assertContains(e.getMessage(), + "deferred close did not release the slot lock"); + } + + // The await runs at step 3, before the step-5 swap: no + // epoch may have committed, and every later entry point + // must rethrow the latched failure. + Assert.assertEquals("the swap must not have committed", + 0, ws.getSymbolDictEpoch()); + try { + sender.table("t"); + Assert.fail("expected a latched terminal sender to rethrow"); + } catch (LineSenderException expected) { + } + Assert.assertFalse("the slot flock is still held by the wedged engine", + ws.isSlotLockReleased()); + + // The worker finally exits: the deferred cleanup must + // complete and the sender must expose the late release + // (the retained-engine re-probe), so a pool can recover + // the slot's capacity. + releaseWorker.countDown(); + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (!outgoing.isCloseCompleted() && System.nanoTime() < deadlineNanos) { + Thread.sleep(10L); + } + Assert.assertTrue("deferred cleanup did not complete after the release", + outgoing.isCloseCompleted()); + Assert.assertTrue("sender must expose the late flock release", + ws.isSlotLockReleased()); + } finally { + manager.setBeforeTrimSyncHook(null); + releaseWorker.countDown(); + } + } + if (auxErr.get() != null) { + throw new AssertionError("auxiliary thread failed", auxErr.get()); + } + } + }); + } + + private static TestWebSocketServer ackingServer() throws Exception { + TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + /** ACKs every frame it receives; does not otherwise inspect the wire. */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java index afe5d21e..3266fc73 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java @@ -45,6 +45,8 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + /** * FSN epoch-base continuity across a symbol-dict recycle. Task 5's engine * rebuild restarts the internal cursor engine's raw FSNs at 0; every @@ -80,103 +82,109 @@ public class SymbolDictRecycleFsnContinuityTest { */ @Test public void testPreRollTargetAnswersTrueAfterRoll() throws Exception { - try (TestWebSocketServer server = ackingServer()) { - long fsn1; - try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { - sender1.table("t").longColumn("v", 1L).atNow(); - fsn1 = sender1.flushAndGetSequence(); - Assert.assertTrue("setup: the batch must actually be acked before the roll", - sender1.drain(5_000)); - } + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + long fsn1; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.table("t").longColumn("v", 1L).atNow(); + fsn1 = sender1.flushAndGetSequence(); + Assert.assertTrue("setup: the batch must actually be acked before the roll", + sender1.drain(5_000)); + } - QwpWebSocketSender sender2 = createRolledSender(server, fsn1); - try { - long t0 = System.nanoTime(); - Assert.assertTrue("a target FSN from a pre-recycle epoch must be reported acked " - + "immediately -- it was proven acked before the swap", - sender2.awaitAckedFsn(fsn1, 0)); - long elapsedMs = (System.nanoTime() - t0) / 1_000_000; - Assert.assertTrue("must short-circuit, not poll: took " + elapsedMs + "ms", - elapsedMs < 200); - } finally { - sender2.close(); + QwpWebSocketSender sender2 = createRolledSender(server, fsn1); + try { + long t0 = System.nanoTime(); + Assert.assertTrue("a target FSN from a pre-recycle epoch must be reported acked " + + "immediately -- it was proven acked before the swap", + sender2.awaitAckedFsn(fsn1, 0)); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + Assert.assertTrue("must short-circuit, not poll: took " + elapsedMs + "ms", + elapsedMs < 200); + } finally { + sender2.close(); + } } - } + }); } @Test public void testPostRollSequencesExceedAllPreRoll() throws Exception { - try (TestWebSocketServer server = ackingServer()) { - long fsn1; - try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { - sender1.table("t").longColumn("v", 1L).atNow(); - fsn1 = sender1.flushAndGetSequence(); - Assert.assertTrue(sender1.drain(5_000)); - } + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + long fsn1; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.table("t").longColumn("v", 1L).atNow(); + fsn1 = sender1.flushAndGetSequence(); + Assert.assertTrue(sender1.drain(5_000)); + } - QwpWebSocketSender sender2 = createRolledSender(server, fsn1); - try { - long newBase = sender2.getFsnEpochBaseForTest(); - Assert.assertEquals(fsn1 + 1, newBase); - - sender2.table("t").longColumn("v", 2L).atNow(); - long fsn2 = sender2.flushAndGetSequence(); - Assert.assertTrue(sender2.drain(5_000)); - - Assert.assertTrue("post-roll FSN must exceed every pre-roll FSN: fsn2=" + fsn2 - + " fsn1=" + fsn1, - fsn2 > fsn1); - // sender2's engine is genuinely fresh (raw publishedFsn() starts at -1), so - // its first-ever flush publishes raw 0. The exact-equality check is strictly - // stronger than ">" alone: it also catches an off-by-one in the roll formula - // (e.g. fsnEpochBase += lastPublishedFsn instead of + 1L), which the ">" - // check above would not. - Assert.assertEquals(newBase, fsn2); - } finally { - sender2.close(); + QwpWebSocketSender sender2 = createRolledSender(server, fsn1); + try { + long newBase = sender2.getFsnEpochBaseForTest(); + Assert.assertEquals(fsn1 + 1, newBase); + + sender2.table("t").longColumn("v", 2L).atNow(); + long fsn2 = sender2.flushAndGetSequence(); + Assert.assertTrue(sender2.drain(5_000)); + + Assert.assertTrue("post-roll FSN must exceed every pre-roll FSN: fsn2=" + fsn2 + + " fsn1=" + fsn1, + fsn2 > fsn1); + // sender2's engine is genuinely fresh (raw publishedFsn() starts at -1), so + // its first-ever flush publishes raw 0. The exact-equality check is strictly + // stronger than ">" alone: it also catches an off-by-one in the roll formula + // (e.g. fsnEpochBase += lastPublishedFsn instead of + 1L), which the ">" + // check above would not. + Assert.assertEquals(newBase, fsn2); + } finally { + sender2.close(); + } } - } + }); } @Test public void testGetAckedFsnMonotoneAcrossRoll() throws Exception { - try (TestWebSocketServer server = ackingServer()) { - long w; - long lastPublishedFsn; - try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { - sender1.table("t").longColumn("v", 1L).atNow(); - long fsn1 = sender1.flushAndGetSequence(); - Assert.assertTrue(sender1.drain(5_000)); - w = sender1.getAckedFsn(); - lastPublishedFsn = fsn1; - Assert.assertEquals("sanity: single-batch acked watermark must match its own FSN", - fsn1, w); - } + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + long w; + long lastPublishedFsn; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.table("t").longColumn("v", 1L).atNow(); + long fsn1 = sender1.flushAndGetSequence(); + Assert.assertTrue(sender1.drain(5_000)); + w = sender1.getAckedFsn(); + lastPublishedFsn = fsn1; + Assert.assertEquals("sanity: single-batch acked watermark must match its own FSN", + fsn1, w); + } - // A fresh sender/engine models the post-recycle engine that restarts its - // internal FSNs at 0; rolling its epoch base by the outgoing epoch's last - // published FSN is exactly what the recycle swap does in production. - QwpWebSocketSender sender2 = createRolledSender(server, lastPublishedFsn); - try { - long newBase = sender2.getFsnEpochBaseForTest(); - Assert.assertEquals(lastPublishedFsn + 1, newBase); - - Assert.assertEquals("before any new ack, getAckedFsn must read the synthetic " - + "watermark: one past the last external FSN the outgoing epoch " - + "ever reported", - newBase - 1, sender2.getAckedFsn()); - Assert.assertTrue(sender2.getAckedFsn() >= w); - - sender2.table("t").longColumn("v", 2L).atNow(); - sender2.flush(); - Assert.assertTrue(sender2.drain(5_000)); - Assert.assertTrue("a new ack must advance the watermark past the synthetic " - + "post-roll value", - sender2.getAckedFsn() > newBase - 1); - } finally { - sender2.close(); + // A fresh sender/engine models the post-recycle engine that restarts its + // internal FSNs at 0; rolling its epoch base by the outgoing epoch's last + // published FSN is exactly what the recycle swap does in production. + QwpWebSocketSender sender2 = createRolledSender(server, lastPublishedFsn); + try { + long newBase = sender2.getFsnEpochBaseForTest(); + Assert.assertEquals(lastPublishedFsn + 1, newBase); + + Assert.assertEquals("before any new ack, getAckedFsn must read the synthetic " + + "watermark: one past the last external FSN the outgoing epoch " + + "ever reported", + newBase - 1, sender2.getAckedFsn()); + Assert.assertTrue(sender2.getAckedFsn() >= w); + + sender2.table("t").longColumn("v", 2L).atNow(); + sender2.flush(); + Assert.assertTrue(sender2.drain(5_000)); + Assert.assertTrue("a new ack must advance the watermark past the synthetic " + + "post-roll value", + sender2.getAckedFsn() > newBase - 1); + } finally { + sender2.close(); + } } - } + }); } /** @@ -189,31 +197,33 @@ public void testGetAckedFsnMonotoneAcrossRoll() throws Exception { */ @Test public void testDrainAfterRollWaitsForNewFrames() throws Exception { - GatedAckHandler handler = new GatedAckHandler(); - try (TestWebSocketServer server = new TestWebSocketServer(handler)) { - server.start(); - Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); - - // Roll well past the raw FSNs this fresh engine will ever publish, so a - // missing translation in drain() would make its raw target look pre-roll. - // Must roll before the sender's first connect (see rollFsnEpochBase's - // precondition: cursorSendLoop must be null). - QwpWebSocketSender sender = createRolledSender(server, 999L); - try { - sender.table("foo").longColumn("v", 1L).atNow(); - boolean drainedEarly = sender.drain(200); - Assert.assertFalse("drain() must not spuriously report the new frame acked just " - + "because its raw FSN is smaller than the rolled epoch base", - drainedEarly); - - handler.releaseAcks(); - Assert.assertTrue("drain() must return true once the real ack arrives", - sender.drain(5_000)); - } finally { - handler.releaseAcks(); - sender.close(); + assertMemoryLeak(() -> { + GatedAckHandler handler = new GatedAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + // Roll well past the raw FSNs this fresh engine will ever publish, so a + // missing translation in drain() would make its raw target look pre-roll. + // Must roll before the sender's first connect (see rollFsnEpochBase's + // precondition: cursorSendLoop must be null). + QwpWebSocketSender sender = createRolledSender(server, 999L); + try { + sender.table("foo").longColumn("v", 1L).atNow(); + boolean drainedEarly = sender.drain(200); + Assert.assertFalse("drain() must not spuriously report the new frame acked just " + + "because its raw FSN is smaller than the rolled epoch base", + drainedEarly); + + handler.releaseAcks(); + Assert.assertTrue("drain() must return true once the real ack arrives", + sender.drain(5_000)); + } finally { + handler.releaseAcks(); + sender.close(); + } } - } + }); } /** @@ -225,136 +235,142 @@ public void testDrainAfterRollWaitsForNewFrames() throws Exception { */ @Test public void testSenderErrorSpansCarryExternalFsns() throws Exception { - TerminalNackHandler handler = new TerminalNackHandler(); - try (TestWebSocketServer server = new TestWebSocketServer(handler)) { - server.start(); - Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); - - AtomicReference asyncError = new AtomicReference<>(); - QwpWebSocketSender sender = createRolledSender(server, 41L); - long base = sender.getFsnEpochBaseForTest(); - sender.setErrorHandler(e -> asyncError.compareAndSet(null, e)); - try { - sender.table("foo").longColumn("v", 1L).atNow(); - sender.flush(); - - waitFor(() -> handler.totalBinaryReceived.get() >= 1, 5_000); - waitFor(() -> sender.getLastTerminalError() != null, 5_000); - - LineSenderServerException thrown = null; + assertMemoryLeak(() -> { + TerminalNackHandler handler = new TerminalNackHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + AtomicReference asyncError = new AtomicReference<>(); + QwpWebSocketSender sender = createRolledSender(server, 41L); + long base = sender.getFsnEpochBaseForTest(); + sender.setErrorHandler(e -> asyncError.compareAndSet(null, e)); try { - sender.table("foo").longColumn("v", 2L).atNow(); + sender.table("foo").longColumn("v", 1L).atNow(); sender.flush(); - } catch (LineSenderServerException e) { - thrown = e; - } - Assert.assertNotNull("expected the latched terminal to surface synchronously", - thrown); - SenderError err = thrown.getServerError(); - Assert.assertEquals("fromFsn must be the external (epoch-rebased) FSN, not raw", - base, err.getFromFsn()); - Assert.assertEquals("toFsn must be the external (epoch-rebased) FSN, not raw", - base, err.getToFsn()); - - waitFor(() -> asyncError.get() != null, 5_000); - SenderError asyncErr = asyncError.get(); - Assert.assertEquals("async error handler must observe the same fromFsn as the " - + "synchronous throw", - err.getFromFsn(), asyncErr.getFromFsn()); - Assert.assertEquals("async error handler must observe the same toFsn as the " - + "synchronous throw", - err.getToFsn(), asyncErr.getToFsn()); - } finally { - try { - sender.close(); - } catch (LineSenderException ignored) { + + waitFor(() -> handler.totalBinaryReceived.get() >= 1, 5_000); + waitFor(() -> sender.getLastTerminalError() != null, 5_000); + + LineSenderServerException thrown = null; + try { + sender.table("foo").longColumn("v", 2L).atNow(); + sender.flush(); + } catch (LineSenderServerException e) { + thrown = e; + } + Assert.assertNotNull("expected the latched terminal to surface synchronously", + thrown); + SenderError err = thrown.getServerError(); + Assert.assertEquals("fromFsn must be the external (epoch-rebased) FSN, not raw", + base, err.getFromFsn()); + Assert.assertEquals("toFsn must be the external (epoch-rebased) FSN, not raw", + base, err.getToFsn()); + + waitFor(() -> asyncError.get() != null, 5_000); + SenderError asyncErr = asyncError.get(); + Assert.assertEquals("async error handler must observe the same fromFsn as the " + + "synchronous throw", + err.getFromFsn(), asyncErr.getFromFsn()); + Assert.assertEquals("async error handler must observe the same toFsn as the " + + "synchronous throw", + err.getToFsn(), asyncErr.getToFsn()); + } finally { + try { + sender.close(); + } catch (LineSenderException ignored) { + } } } - } + }); } @Test public void testProgressStreamMonotoneAcrossRoll() throws Exception { - try (TestWebSocketServer server = ackingServer()) { - List observed = Collections.synchronizedList(new ArrayList()); - SenderProgressHandler collector = observed::add; - - long lastPublishedFsn; - try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { - sender1.setProgressHandler(collector); - sender1.table("t").longColumn("v", 1L).atNow(); - lastPublishedFsn = sender1.flushAndGetSequence(); - Assert.assertTrue(sender1.drain(5_000)); - } - waitFor(() -> !observed.isEmpty(), 5_000); - int preRollCount = observed.size(); + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + List observed = Collections.synchronizedList(new ArrayList()); + SenderProgressHandler collector = observed::add; + + long lastPublishedFsn; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.setProgressHandler(collector); + sender1.table("t").longColumn("v", 1L).atNow(); + lastPublishedFsn = sender1.flushAndGetSequence(); + Assert.assertTrue(sender1.drain(5_000)); + } + waitFor(() -> !observed.isEmpty(), 5_000); + int preRollCount = observed.size(); - // Roll BEFORE this sender's first connect so its loop's frozen - // externalFsnBase actually carries the roll (see class javadoc). - QwpWebSocketSender sender2 = createRolledSender(server, lastPublishedFsn); - sender2.setProgressHandler(collector); - try { - sender2.table("t").longColumn("v", 2L).atNow(); - sender2.flush(); - Assert.assertTrue(sender2.drain(5_000)); - waitFor(() -> observed.size() > preRollCount, 5_000); - - List snapshot = new ArrayList<>(observed); - for (int i = 1; i < snapshot.size(); i++) { - Assert.assertTrue("progress stream must be non-decreasing across the roll, " - + "got " + snapshot, - snapshot.get(i) >= snapshot.get(i - 1)); + // Roll BEFORE this sender's first connect so its loop's frozen + // externalFsnBase actually carries the roll (see class javadoc). + QwpWebSocketSender sender2 = createRolledSender(server, lastPublishedFsn); + sender2.setProgressHandler(collector); + try { + sender2.table("t").longColumn("v", 2L).atNow(); + sender2.flush(); + Assert.assertTrue(sender2.drain(5_000)); + waitFor(() -> observed.size() > preRollCount, 5_000); + + List snapshot = new ArrayList<>(observed); + for (int i = 1; i < snapshot.size(); i++) { + Assert.assertTrue("progress stream must be non-decreasing across the roll, " + + "got " + snapshot, + snapshot.get(i) >= snapshot.get(i - 1)); + } + Assert.assertTrue("post-roll progress must exceed the pre-roll watermark", + snapshot.get(snapshot.size() - 1) > lastPublishedFsn); + } finally { + sender2.close(); } - Assert.assertTrue("post-roll progress must exceed the pre-roll watermark", - snapshot.get(snapshot.size() - 1) > lastPublishedFsn); - } finally { - sender2.close(); } - } + }); } @Test public void testLatchedErrorStillThrowsForOldEpochTarget() throws Exception { - // Acks the first frame it ever receives (from sender1, producing fsn1), then - // terminal-NACKs everything after (sender2's frame, on its own fresh connection). - AckFirstThenTerminalNackHandler handler = new AckFirstThenTerminalNackHandler(); - try (TestWebSocketServer server = new TestWebSocketServer(handler)) { - server.start(); - Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); - - long fsn1; - try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { - sender1.table("foo").longColumn("v", 1L).atNow(); - fsn1 = sender1.flushAndGetSequence(); - Assert.assertTrue(sender1.drain(5_000)); - } - - // Roll before sender2's first connect (rollFsnEpochBase's precondition), then - // force sender2's own loop to latch a terminal via the handler's NACK. - QwpWebSocketSender sender2 = createRolledSender(server, fsn1); - try { - sender2.table("foo").longColumn("v", 2L).atNow(); - sender2.flush(); - waitFor(() -> sender2.getLastTerminalError() != null, 5_000); - - LineSenderException thrown = null; - try { - boolean acked = sender2.awaitAckedFsn(fsn1, 0); - Assert.fail("awaitAckedFsn must throw on a latched terminal error, but " - + "returned " + acked); - } catch (LineSenderException e) { - thrown = e; + assertMemoryLeak(() -> { + // Acks the first frame it ever receives (from sender1, producing fsn1), then + // terminal-NACKs everything after (sender2's frame, on its own fresh connection). + AckFirstThenTerminalNackHandler handler = new AckFirstThenTerminalNackHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + long fsn1; + try (QwpWebSocketSender sender1 = (QwpWebSocketSender) Sender.fromConfig(cfg(server))) { + sender1.table("foo").longColumn("v", 1L).atNow(); + fsn1 = sender1.flushAndGetSequence(); + Assert.assertTrue(sender1.drain(5_000)); } - Assert.assertNotNull("a latched terminal error must surface even for a " - + "pre-recycle-epoch target -- the error check must run before the " - + "pre-roll short-circuit", thrown); - } finally { + + // Roll before sender2's first connect (rollFsnEpochBase's precondition), then + // force sender2's own loop to latch a terminal via the handler's NACK. + QwpWebSocketSender sender2 = createRolledSender(server, fsn1); try { - sender2.close(); - } catch (LineSenderException ignored) { + sender2.table("foo").longColumn("v", 2L).atNow(); + sender2.flush(); + waitFor(() -> sender2.getLastTerminalError() != null, 5_000); + + LineSenderException thrown = null; + try { + boolean acked = sender2.awaitAckedFsn(fsn1, 0); + Assert.fail("awaitAckedFsn must throw on a latched terminal error, but " + + "returned " + acked); + } catch (LineSenderException e) { + thrown = e; + } + Assert.assertNotNull("a latched terminal error must surface even for a " + + "pre-recycle-epoch target -- the error check must run before the " + + "pre-roll short-circuit", thrown); + } finally { + try { + sender2.close(); + } catch (LineSenderException ignored) { + } } } - } + }); } private static TestWebSocketServer ackingServer() throws Exception { diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java index 81d564fd..037989ed 100644 --- a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java @@ -138,6 +138,37 @@ public void testTwoConcurrentSfSendersGetDistinctSlots() throws Exception { }); } + @Test + public void testResetSymbolDictionaryForwardsToPooledDelegate() throws Exception { + TestUtils.assertMemoryLeak(() -> { + CountingAckHandler handler = new CountingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + int port = server.getPort(); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";"; + try (SenderPool pool = new SenderPool(config, 1, 1, 5_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + PooledSender pooled = pool.borrow(); + try { + QwpWebSocketSender delegate = + (QwpWebSocketSender) pooled.getDelegateForTesting(); + Assert.assertFalse("setup: nothing may be armed before the manual request", + delegate.isResetArmed()); + // The pooled wrapper must forward the manual valve to the + // live delegate; inheriting Sender's default no-op would + // silently drop the request. + pooled.resetSymbolDictionary(); + Assert.assertTrue("resetSymbolDictionary() must reach the pooled delegate", + delegate.isResetArmed()); + } finally { + pooled.close(); + } + } + } + }); + } + @Test public void testGrowToMaxAllSfSendersCoexist() throws Exception { TestUtils.assertMemoryLeak(() -> { From 65029ced6c93fe8976cdf2f1103a99462ed11b71 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:04:28 +0100 Subject: [PATCH 25/49] Make recycle-read monitoring fields volatile getAckedFsn()/awaitAckedFsn() read cursorEngine, fsnEpochBase and lastRecycleDurableFsn from monitoring threads while the recycle reassigns all three on the producer thread. The sibling observability counters went volatile in an earlier round for exactly this reader; these three carry the same contract, so a monitor could observe a fresh engine with a stale epoch base and report an FSN dip to -1. Also document resetSymbolDictionary() as producer-thread-only: it mutates unsynchronized producer state, and the fire-and-forget javadoc framing invited cross-thread calls (notably via PooledSender). --- .../main/java/io/questdb/client/Sender.java | 3 +++ .../qwp/client/QwpWebSocketSender.java | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 689553f4..04f8753a 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -699,6 +699,9 @@ default Sender long256Column(CharSequence name, long l0, long l1, long l2, long * {@code symbol_dict_reset=off} ({@link LineSenderBuilder#symbolDictReset(boolean)}): * that knob gates the arming path this request feeds, so the request is * accepted and never acted on. + *

    + * Call on the producing thread only: like every other {@code Sender} + * method, this mutates producer-side state and is not thread-safe. */ default void resetSymbolDictionary() { } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 2f2bf08e..cb4d8a7b 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -269,8 +269,11 @@ public class QwpWebSocketSender implements Sender { private String currentTableName; // Cursor SF engine: the producer (user thread) writes encoded QWP frames // into the engine's mmap'd ring; the cursorSendLoop is the I/O thread - // that walks the ring and sends frames. - private CursorSendEngine cursorEngine; + // that walks the ring and sends frames. Volatile since the recycle + // started reassigning it (non-null -> null -> non-null on the producer + // thread): the monitoring accessors (getAckedFsn, awaitAckedFsn) read it + // from a monitor thread, same reasoning as symbolDictEpoch. + private volatile CursorSendEngine cursorEngine; private CursorWebSocketSendLoop cursorSendLoop; private boolean deferCommit; // True when the sender emits incremental (delta) symbol dictionaries: each @@ -327,8 +330,11 @@ public class QwpWebSocketSender implements Sender { // recycle path) advance it past every FSN already handed out, so the // external sequence stays strictly monotone across the internal reset. // Rule everywhere it is applied: external = fsnEpochBase + raw: raw - // -1 (no-data) sentinels are never translated. - private long fsnEpochBase = 0; + // -1 (no-data) sentinels are never translated. Volatile because the + // recycle rolls it while a monitor thread may be inside getAckedFsn / + // awaitAckedFsn: a stale base paired with a fresh engine would report + // an FSN dip to -1 (same reasoning as symbolDictEpoch). + private volatile long fsnEpochBase = 0; private boolean hasDeferredMessages; // FSN of the last commit-bearing (non-FLAG_DEFER_COMMIT) frame this session // published, or -1 when none. Frames above it are deferred and uncommitted: @@ -452,8 +458,9 @@ public class QwpWebSocketSender implements Sender { // keep reporting the durable watermark instead of collapsing to -1 while // cursorEngine is transiently null mid-swap or permanently null after a // failed recycle -- all pre-swap data really is acked, so the watermark - // stays truthful. - private long lastRecycleDurableFsn = -1L; + // stays truthful. Volatile: those accessors are exactly the surface a + // monitoring thread reads mid-swap, same reasoning as symbolDictEpoch. + private volatile long lastRecycleDurableFsn = -1L; // Budget for recycleForDictReset's deferred-close await (see // RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS); non-final only so tests can // shrink it to drive the timeout branch. From 6793928a840e950f9b671b19a0ce1d8dddcd8834 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:14:11 +0100 Subject: [PATCH 26/49] Pin buffer-through-outage contract for the recycle Red first: the outage tests now assert the store-and-forward contract (producer never sees a transport error nor a reconnect budget after the initial connect) instead of pinning the step-7 foreground connect they used to. The production change lands in the next commit. --- .../client/SymbolDictRecycleOutageTest.java | 204 +++++++----------- 1 file changed, 80 insertions(+), 124 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java index 2f0036b5..624804f4 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java @@ -25,7 +25,6 @@ package io.questdb.client.test.cutlass.qwp.client; import io.questdb.client.Sender; -import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; @@ -43,7 +42,6 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE; import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; @@ -57,8 +55,9 @@ *

    * (a) proves the recycle's step 2 ({@code cursorSendLoop.close()}) correctly * joins an I/O thread that is itself mid-reconnect (not idle, not yet given - * up), and that step 7's fresh {@code ensureConnected()} recovers once the - * endpoint accepts connections again -- exercising + * up), and that step 7 no longer recovers the connection on the calling + * thread -- it defers to the I/O loop, so the swap returns promptly and the + * producer never observes the outage -- exercising * {@code CursorWebSocketSendLoop.close()}'s "handles both states" contract * under a real outage rather than a synthetic one. *

    @@ -78,16 +77,19 @@ public class SymbolDictRecycleOutageTest { /** * Kills the server out from under an armed, fully-drained sender, waits * for the pre-recycle I/O thread to actually enter its own reconnect - * loop (not just assumed via a fixed sleep), then triggers the recycle - * from a background thread -- because step 7's fresh - * {@code ensureConnected()} blocks the calling {@code table()} call (sync - * initial-connect mode) until the endpoint accepts again or - * {@code reconnect_max_duration_millis} elapses. The main thread revives - * a fresh server on the same port shortly after, well inside that - * budget, mirroring {@code ReconnectTest}'s down-then-up realism. + * loop (not just assumed via a fixed sleep) -- so the recycle's step 2 + * ({@code cursorSendLoop.close()}) provably joins a MID-reconnect + * thread -- then triggers the recycle inline, on the calling thread. + * {@code reconnect_max_duration_millis} bounds only the sender's initial + * connect; under the store-and-forward contract step 7 no longer + * re-enters {@code connectWithRetry} on the producer thread, so the + * triggering {@code table()} call must return well within that budget + * even though the endpoint is still down when it fires. The main thread + * revives a fresh server on the same port after asserting the bound, + * mirroring {@code ReconnectTest}'s down-then-up realism. */ @Test - public void testRecycleDuringOutageReconnectsAfresh() throws Exception { + public void testSyncModeRecycleDoesNotBlockProducerDuringOutage() throws Exception { assertMemoryLeak(() -> { String sfDir = temporaryFolder.getRoot().toPath().resolve("outage-recycle").toString(); AckAllHandler firstHandler = new AckAllHandler(); @@ -131,68 +133,31 @@ public void testRecycleDuringOutageReconnectsAfresh() throws Exception { + "the triggering table() call", ws.getTotalReconnectAttempts() > 0); - // Trigger the recycle off-thread: step 7's fresh - // ensureConnected() blocks the caller (sync initial-connect - // mode) until the endpoint accepts again. - AtomicReference triggerFailure = new AtomicReference<>(); - Thread trigger = new Thread(() -> { - try { - sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); - } catch (Throwable t) { - triggerFailure.set(t); - } - }, "recycle-trigger"); - trigger.start(); - - try { - // Resolve the outage shortly after -- well inside - // reconnect_max_duration_millis=6000 -- on the SAME port. - Thread.sleep(150); - OutageRecycleHandler revivedHandler = new OutageRecycleHandler(); - try (TestWebSocketServer revived = - new TestWebSocketServer(revivedHandler, false, null, port)) { - revived.start(); - Assert.assertTrue(revived.awaitStart(5, TimeUnit.SECONDS)); - - trigger.join(10_000); - Assert.assertFalse("recycle-trigger thread must have finished once the " - + "endpoint accepts again", trigger.isAlive()); - Assert.assertNull("triggering table() must not throw once the outage " - + "resolves within budget: " + triggerFailure.get(), - triggerFailure.get()); - - Assert.assertFalse("recycle must disarm", ws.isResetArmed()); - Assert.assertEquals("recycle must complete despite the outage", - 1, ws.getSymbolDictEpoch()); - // Deliberately >= 1, not == 1: the pre-recycle I/O thread is - // banging on the refused port when the revive binds, and - // nothing orders step 2's close+join against that bind, so a - // stray handshake from the outgoing loop is legal here. - Assert.assertTrue("revived server must observe a fresh handshake", - revived.handshakeCount() >= 1); - - // The "c" row was built (atNow()) inside the triggering - // call but not yet flushed -- flush now and prove it - // lands on the fresh connection. - long fsn2 = sender.flushAndGetSequence(); - Assert.assertTrue("post-recycle row must land once reconnected", - sender.awaitAckedFsn(fsn2, 5_000)); - Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN", - fsn2 > fsn1); - - Assert.assertEquals("the fresh connection's first frame must carry a " - + "fresh (empty) dictionary, not a, b", - 0, revivedHandler.firstFrameDeltaStart); - Assert.assertEquals("post-recycle dictionary must hold only the new " - + "epoch's symbol, nothing lost or duplicated from " - + "before the outage", - Collections.singletonList("c"), revivedHandler.dict()); - } - } finally { - // Never leave the trigger thread running past this test: - // a thread still inside the sender on an assert-failure - // path muddies assertMemoryLeak's diagnostics. - trigger.join(10_000); + // The recycle must return promptly: reconnect_max_duration_millis + // governs only the initial connect, and step 7 defers to the + // I/O loop instead of re-entering connectWithRetry on the + // producer thread. + long startNanos = System.nanoTime(); + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L; + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + Assert.assertEquals("recycle must complete despite the outage", + 1, ws.getSymbolDictEpoch()); + Assert.assertTrue("the swap must not block the producer on the reconnect " + + "budget [elapsedMillis=" + elapsedMillis + ']', + elapsedMillis < 3_000); + + long fsn2 = sender.flushAndGetSequence(); + OutageRecycleHandler revivedHandler = new OutageRecycleHandler(); + try (TestWebSocketServer revived = + new TestWebSocketServer(revivedHandler, false, null, port)) { + revived.start(); + Assert.assertTrue(revived.awaitStart(5, TimeUnit.SECONDS)); + Assert.assertTrue("the outage-window row must land once reconnected", + sender.awaitAckedFsn(fsn2, 10_000)); + Assert.assertTrue(fsn2 > fsn1); + Assert.assertEquals(0, revivedHandler.firstFrameDeltaStart); + Assert.assertEquals(Collections.singletonList("c"), revivedHandler.dict()); } } } @@ -202,21 +167,21 @@ public void testRecycleDuringOutageReconnectsAfresh() throws Exception { /** * Default configuration: no {@code reconnect_*} knob and no * {@code initial_connect_retry}, so the builder resolves - * {@code initialConnectMode} to OFF and step 7's {@code ensureConnected()} - * is a single-shot connect that fails outright while the endpoint refuses. - * Steps 1-6 have already committed by then, so latching {@code - * recycleFailure} here would brick the sender permanently over an ordinary - * transient outage -- the shipped default for every sender that crosses - * the threshold. + * {@code initialConnectMode} to OFF. Under the store-and-forward + * contract, step 7 no longer opens a connection on the calling thread + * at all -- it defers to the I/O loop, so the triggering {@code table()} + * call must return normally even while the endpoint refuses + * connections. *

    - * Proves the step-7 failure reaches the caller WITHOUT latching, that the - * swap committed exactly one epoch, and that the very next send recovers - * through the existing {@code sendRow() -> ensureConnected()} path once - * the endpoint is back -- reconnecting only, never re-running a teardown - * step and never swapping a second time. + * Proves the swap commits exactly one epoch and disarms without the + * caller ever observing a transport failure, that the flush right after + * publishes into the fresh epoch's SF slot, and that once the endpoint + * returns on the same port the I/O loop's own reconnect replays every + * row sent during the outage with zero loss -- reconnecting only, never + * re-running a teardown step and never swapping a second time. */ @Test - public void testDefaultConfigRecycleSurvivesFailedReconnect() throws Exception { + public void testDefaultConfigRecycleBuffersThroughOutage() throws Exception { assertMemoryLeak(() -> { String sfDir = temporaryFolder.getRoot().toPath().resolve("default-config-outage").toString(); AckAllHandler firstHandler = new AckAllHandler(); @@ -247,61 +212,52 @@ public void testDefaultConfigRecycleSurvivesFailedReconnect() throws Exception { // that is already down. server.close(); - LineSenderException triggering = null; - try { - sender.table("t"); - Assert.fail("step 7's single-shot connect must throw while the endpoint " - + "refuses connections"); - } catch (LineSenderException e) { - triggering = e; - } - Assert.assertNotNull(triggering); - - Assert.assertEquals("the swap committed exactly one epoch before the connect " - + "failed", 1, ws.getSymbolDictEpoch()); + // The ring is drained, so the next table() fires the recycle + // into a wire that is already down. The swap must complete AND + // return normally -- the reconnect is the I/O loop's job, so + // no transport failure may reach the producer. "c" registers + // into the fresh dictionary after the swap's + // resetSymbolDictStateForNewConnection but before the wire is + // up, which keeps pinning the drained-guard: a deferred + // connect that cleared the batch watermark would ship a row + // pointing at an id the server never received. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertEquals("the swap must commit exactly one epoch", + 1, ws.getSymbolDictEpoch()); Assert.assertEquals(1, ws.getSymbolDictResetsPerformed()); - Assert.assertFalse("a committed swap disarms even when its reconnect fails", - ws.isResetArmed()); + Assert.assertFalse("a committed swap disarms", ws.isResetArmed()); - // Endpoint back on the SAME port. The sender must not be - // terminal: the next send reconnects on its own. + // Producer keeps working against the dead endpoint: the + // flush publishes into the fresh epoch's SF slot. + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN", + fsn2 > fsn1); + + // Endpoint back on the SAME port: the I/O loop's own + // reconnect must land the buffered rows -- zero loss. OutageRecycleHandler revivedHandler = new OutageRecycleHandler(); try (TestWebSocketServer revived = new TestWebSocketServer(revivedHandler, false, null, port)) { revived.start(); Assert.assertTrue(revived.awaitStart(5, TimeUnit.SECONDS)); - // "d" registers BEFORE the deferred connect -- symbol() - // runs ahead of sendRow(), which is what finally - // performs it -- so this first batch is the one that - // proves the connect left the batch's symbol watermark - // alone on its way through. - sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); - long fsn2 = sender.flushAndGetSequence(); - Assert.assertTrue("a sender whose step-7 connect failed must still ingest " - + "once the endpoint returns", - sender.awaitAckedFsn(fsn2, 5_000)); - Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN", - fsn2 > fsn1); - Assert.assertEquals("the recovery reconnects only -- it must not run a " - + "second swap", 1, ws.getSymbolDictEpoch()); + Assert.assertTrue("rows sent during the outage must replay once " + + "the endpoint returns", + sender.awaitAckedFsn(fsn2, 10_000)); + Assert.assertEquals("the recovery reconnects only -- no second swap", + 1, ws.getSymbolDictEpoch()); Assert.assertEquals(1, ws.getSymbolDictResetsPerformed()); - - Assert.assertEquals("the recovered connection's first frame must carry a " + Assert.assertEquals("the fresh connection's first frame must carry a " + "fresh (empty) dictionary, not a, b", 0, revivedHandler.firstFrameDeltaStart); - Assert.assertEquals("the recovered stream must define every symbol its " - + "rows reference: a deferred connect that cleared the " - + "batch watermark would ship a row pointing at an id " - + "the server never received", - Collections.singletonList("d"), revivedHandler.dict()); + Assert.assertEquals(Collections.singletonList("c"), revivedHandler.dict()); // And the epoch keeps extending normally from there. sender.table("t").symbol("s", "e").longColumn("v", 4L).atNow(); long fsn3 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn3, 5_000)); Assert.assertEquals("later batches must extend the same fresh dictionary", - Arrays.asList("d", "e"), revivedHandler.dict()); + Arrays.asList("c", "e"), revivedHandler.dict()); } } } From abed2a8d3bf633dfc7f8d7136948ee0bfeb0b9ba Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:22:26 +0100 Subject: [PATCH 27/49] Defer the recycle reconnect to the I/O loop Step 7 of the symbol-dict recycle re-ran the initial-connect policy on the producer thread: OFF senders got a single-shot connect whose failure made every subsequent send throw before buffering until the endpoint returned, and SYNC senders blocked the producer up to reconnect_max_duration_millis. Both violate the store-and-forward contract (post-init, the client never exposes transport problems and never imposes a reconnect budget on the producer). ensureConnected() now latches hasConnectedOnce on its first completion and routes every later entry through the existing ASYNC (deferred) branch: the loop is built and started synchronously, the socket connect happens on the I/O thread with indefinite retry, and the producer keeps buffering into the fresh epoch's slot. This is the same path ASYNC-mode senders already took at step 7. The server clears its per-connection dictionary on disconnect, so the buffered fresh-epoch frames (deltaStart=0) replay correctly on reconnect. --- .../qwp/client/QwpWebSocketSender.java | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index cb4d8a7b..dae800e3 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -335,6 +335,14 @@ public class QwpWebSocketSender implements Sender { // awaitAckedFsn: a stale base paired with a fresh engine would report // an FSN dip to -1 (same reasoning as symbolDictEpoch). private volatile long fsnEpochBase = 0; + // Latched true the first time ensureConnected() completes. Once set, + // every later ensureConnected() -- today only the recycle's step 7 and + // its retry-on-next-send path -- takes the deferred (ASYNC-style) + // branch regardless of initialConnectMode: the store-and-forward + // contract scopes foreground connectivity errors to initialization + // only, so post-initial (re)connects belong to the I/O loop's + // indefinite retry (Invariant B), never to the producer thread. + private boolean hasConnectedOnce; private boolean hasDeferredMessages; // FSN of the last commit-bearing (non-FLAG_DEFER_COMMIT) frame this session // published, or -1 when none. Frames above it are deferred and uncommitted: @@ -4150,7 +4158,14 @@ private void ensureConnected() { connectionListener, connectionListenerInboxCapacity); } CursorWebSocketSendLoop.ReconnectFactory reconnectFactory = newReconnectFactory(); - switch (initialConnectMode) { + // initialConnectMode is an *initialization* policy. After the first + // successful connect the SF contract forbids foreground connects on + // the producer thread, so re-entries (the recycle's step 7, or its + // retry after a failed loop start) always defer to the I/O thread. + Sender.InitialConnectMode effectiveMode = hasConnectedOnce + ? Sender.InitialConnectMode.ASYNC + : initialConnectMode; + switch (effectiveMode) { case SYNC: client = CursorWebSocketSendLoop.connectWithRetry( reconnectFactory, @@ -4168,9 +4183,10 @@ private void ensureConnected() { // a future version bump must account for that. Transport // failures retry indefinitely on the I/O thread (Invariant B). // But a terminal auth, upgrade or capability rejection on this - // initial connect -- before the wire is ever up -- is surfaced - // to the async SenderErrorHandler and latched for a close() - // rethrow, not retried. + // deferred connect (the initial one, or a later re-entry such + // as the recycle's step 7) -- before the wire is ever up -- is + // surfaced to the async SenderErrorHandler and latched for a + // close() rethrow, not retried. client = null; break; case OFF: @@ -4279,6 +4295,7 @@ private void ensureConnected() { connectionError.set(null); connected = true; + hasConnectedOnce = true; } private void ensureNoInProgressRow() { @@ -4878,7 +4895,8 @@ private void maybeRecycleForDictReset() { * mirroring (not calling) {@link #setCursorEngine} -- that method's * guards refuse a second engine. *

  • Reconnect: {@link #ensureConnected()} builds a fresh I/O loop - * against the rolled {@link #fsnEpochBase}. Runs OUTSIDE the latching + * against the rolled {@link #fsnEpochBase} and defers the socket + * connect itself to the I/O thread. Runs OUTSIDE the latching * try -- see below.
  • * * A throw in steps 2-6 is caught, latches {@link #recycleFailure}, @@ -4982,17 +5000,18 @@ private void recycleForDictReset() { } throw new LineSenderException(t).put("symbol dictionary recycle failed"); } - // step 7: reconnect - rebuilds the loop with the rolled base. OUTSIDE - // the latching try on purpose: the swap has already committed, so a - // connect failure here leaves a coherent fresh-epoch sender that is - // merely disconnected, not a half-swapped one. It throws loudly to the - // caller but does NOT latch -- ensureConnected's own catch has already - // closed and nulled the loop and the client, the epoch and swap - // counters incremented at step 5 stay incremented (the swap really did - // happen), the sender stays disarmed (a fresh dictionary is below - // threshold and manualResetRequested was consumed, so nothing can fire - // a second swap while disconnected), and the next sendRow() retries the - // connect - and ONLY the connect - through ensureConnected. + // step 7: reconnect - rebuilds the loop with the rolled base, deferring + // the socket connect to the I/O thread (hasConnectedOnce forces + // ensureConnected's ASYNC branch): the loop retries indefinitely with + // backoff while the producer keeps buffering into the fresh slot, so a + // transient outage in this window never surfaces to the producer and + // never imposes a reconnect budget on it (the store-and-forward + // contract). OUTSIDE the latching try on purpose: the swap has already + // committed, so the residual failures here (dispatcher construction, + // loop build/start -- environmental, not transport) leave a coherent + // fresh-epoch sender that is merely disconnected. They throw loudly + // but do NOT latch; the next sendRow() retries the deferred setup -- + // and ONLY that -- through ensureConnected. connected = false; try { ensureConnected(); From dbbabf4a14aabb76c37bd62f5bbfe8a6e6458b31 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:40:45 +0100 Subject: [PATCH 28/49] Seed the rebuilt loop's ever-connected flag A symbol-dict recycle's step 7 hands the rebuilt CursorWebSocketSendLoop a null client (ensureConnected's deferred branch), and the loop's constructor seeds hasEverConnected = (client != null) = false. The fresh loop then believes it has never connected, even when a prior loop instance of the same sender already reached the server. That misclassification breaks two contracts. First, endpointPolicyFailureIsTerminal() treats "never connected" as a startup condition and takes the terminal branch on an auth, upgrade, or durable-ack rejection instead of Invariant B's retry-and-ride-it- out contract for a FOREGROUND sender past initialization. Second, the public wasEverConnected() -- documented "sticky, once true stays true" -- delegates to the loop's flag, so it reports false for the whole post-recycle outage window. QwpWebSocketSender now tracks hasLoopEverConnected, a sender-lifetime sticky OR across every loop instance it has owned: latched on a successful foreground connect, and OR'd in from the outgoing loop's own hasEverConnected() at recycle step 2 (covers an ASYNC-initial sender, whose only connect ever happened on the I/O thread). ensureConnected() seeds it into a freshly built loop via the new CursorWebSocketSendLoop.markEverConnected(), called before start(), restoring Invariant B's classification and wasEverConnected()'s stickiness across the rebuild. Also: renamed hasConnectedOnce to hasInitialConnectRun -- it collided in name, but not meaning, with CursorWebSocketSendLoop's own hasEverConnected field; fixed the deferred-connect log message and comment, which said "initial connect" even on a post-recycle re-entry; and pinned wasEverConnected()'s stickiness across the outage window in SymbolDictRecycleOutageTest. --- .../qwp/client/QwpWebSocketSender.java | 70 +++++++++++++++---- .../sf/cursor/CursorWebSocketSendLoop.java | 19 +++++ .../client/SymbolDictRecycleOutageTest.java | 5 ++ 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index dae800e3..fecdda84 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -335,6 +335,7 @@ public class QwpWebSocketSender implements Sender { // awaitAckedFsn: a stale base paired with a fresh engine would report // an FSN dip to -1 (same reasoning as symbolDictEpoch). private volatile long fsnEpochBase = 0; + private boolean hasDeferredMessages; // Latched true the first time ensureConnected() completes. Once set, // every later ensureConnected() -- today only the recycle's step 7 and // its retry-on-next-send path -- takes the deferred (ASYNC-style) @@ -342,8 +343,22 @@ public class QwpWebSocketSender implements Sender { // contract scopes foreground connectivity errors to initialization // only, so post-initial (re)connects belong to the I/O loop's // indefinite retry (Invariant B), never to the producer thread. - private boolean hasConnectedOnce; - private boolean hasDeferredMessages; + private boolean hasInitialConnectRun; + // Sender-lifetime sticky OR of every rebuilt loop's own hasEverConnected: + // once ANY loop instance owned by this sender has reached the server, + // this stays true even after a symbol-dict recycle rebuilds the loop. + // Latched in two places -- ensureConnected()'s tail on a successful + // foreground (client != null) connect, and recycleForDictReset()'s step + // 2, which OR's in the outgoing loop's own hasEverConnected() before + // closing it (covers an ASYNC-initial sender whose only connect ever + // happened on the I/O thread, so this method never observed client != + // null). ensureConnected() seeds it into the freshly built loop via + // markEverConnected() before start(), so a post-recycle loop rebuild + // does not reset CursorWebSocketSendLoop's own hasEverConnected back to + // false -- which would wrongly re-arm its startup-terminal + // classification (endpointPolicyFailureIsTerminal()) and misclassify + // wasEverConnected() for the whole post-recycle outage window. + private boolean hasLoopEverConnected; // FSN of the last commit-bearing (non-FLAG_DEFER_COMMIT) frame this session // published, or -1 when none. Frames above it are deferred and uncommitted: // the server withholds their acks by design (their rows are rolled back on @@ -4162,7 +4177,7 @@ private void ensureConnected() { // successful connect the SF contract forbids foreground connects on // the producer thread, so re-entries (the recycle's step 7, or its // retry after a failed loop start) always defer to the I/O thread. - Sender.InitialConnectMode effectiveMode = hasConnectedOnce + Sender.InitialConnectMode effectiveMode = hasInitialConnectRun ? Sender.InitialConnectMode.ASYNC : initialConnectMode; switch (effectiveMode) { @@ -4238,6 +4253,16 @@ private void ensureConnected() { // the loop no longer fires a terminal budget-exhaustion event -- it // retries indefinitely.) cursorSendLoop.setConnectionDispatcher(connectionDispatcher); + // Seed the fresh loop's own hasEverConnected before it can observe + // any endpoint-policy failure: without this, a symbol-dict + // recycle's rebuilt loop starts believing it has never connected + // (ASYNC startup always hands the constructor a null client), + // which would wrongly re-arm endpointPolicyFailureIsTerminal()'s + // startup-terminal branch for a FOREGROUND sender that already + // reached the server in a prior loop instance. + if (hasLoopEverConnected) { + cursorSendLoop.markEverConnected(); + } cursorSendLoop.start(); } catch (Throwable t) { // start() (or dispatcher construction) failed after cursorSendLoop was @@ -4276,14 +4301,24 @@ private void ensureConnected() { // client; same path runs on every reconnect. LOG.info("Connected to WebSocket [host={}, port={}, qwpVersion={}, serverMaxBatchSize={}, effectiveAutoFlushBytes={}]", host, port, client.getServerQwpVersion(), serverMaxBatchSize, effectiveAutoFlushBytes); + hasLoopEverConnected = true; } else { - // Async mode: I/O thread will drive the connect. Encoder uses - // its default version (V1). The per-batch symbol-dict watermark still - // gets reset for consistency with the sync path; the post-connect - // replay path needs no producer-side reset signal (see below). + // Deferred connect: the I/O thread will drive it, on the sender's + // true initial connect (hasInitialConnectRun still false here) or + // on a post-initial re-entry such as the recycle's step 7. Either + // way the encoder keeps whatever version was already negotiated + // (V1 -- the only supported wire version today); a re-entry never + // resets it. The per-batch symbol-dict watermark still gets reset + // for consistency with the sync path; the post-connect replay + // path needs no producer-side reset signal (see below). Endpoint ep = endpoints.get(0); - LOG.info("Async initial connect deferred to I/O thread [firstHost={}, firstPort={}, endpointCount={}]", - ep.host, ep.port, endpoints.size()); + if (hasInitialConnectRun) { + LOG.info("Reconnect deferred to I/O thread [firstHost={}, firstPort={}, endpointCount={}]", + ep.host, ep.port, endpoints.size()); + } else { + LOG.info("Initial connect deferred to I/O thread [firstHost={}, firstPort={}, endpointCount={}]", + ep.host, ep.port, endpoints.size()); + } } // Server starts fresh on each connection, so reset the per-batch // symbol-dict watermark when nothing is staged against it. Every frame @@ -4295,7 +4330,7 @@ private void ensureConnected() { connectionError.set(null); connected = true; - hasConnectedOnce = true; + hasInitialConnectRun = true; } private void ensureNoInProgressRow() { @@ -4943,8 +4978,14 @@ private void recycleForDictReset() { lastRecycleDurableFsn = fsnEpochBase + lastPublishedFsn; } try { - // step 2: close the loop - joins the I/O thread, closes the client + // step 2: close the loop - joins the I/O thread, closes the client. + // Capture the outgoing loop's own ever-connected state into the + // sender-lifetime sticky OR BEFORE closing it: this is the only + // place an ASYNC-initial sender's connect (which happens only on + // the I/O thread, never observed by ensureConnected's client != + // null branch) reaches hasLoopEverConnected. if (cursorSendLoop != null) { + hasLoopEverConnected |= cursorSendLoop.hasEverConnected(); cursorSendLoop.close(); cursorSendLoop = null; } @@ -5001,8 +5042,11 @@ private void recycleForDictReset() { throw new LineSenderException(t).put("symbol dictionary recycle failed"); } // step 7: reconnect - rebuilds the loop with the rolled base, deferring - // the socket connect to the I/O thread (hasConnectedOnce forces - // ensureConnected's ASYNC branch): the loop retries indefinitely with + // the socket connect to the I/O thread (hasInitialConnectRun forces + // ensureConnected's ASYNC branch, and hasLoopEverConnected -- if this + // sender ever reached the server -- seeds the fresh loop's own + // hasEverConnected so Invariant B's classification survives the + // rebuild): the loop retries indefinitely with // backoff while the producer keeps buffering into the fresh slot, so a // transient outage in this window never surfaces to the producer and // never imposes a reconnect budget on it (the store-and-forward diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java index 8b7c9955..4a100143 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java @@ -1513,6 +1513,25 @@ public boolean isRunning() { return running; } + /** + * Called by the sender before {@link #start()} when a prior loop of the + * same sender already reached the server: restores Invariant B's + * past-initialization classification (see {@link + * #endpointPolicyFailureIsTerminal()}) across a symbol-dict recycle's + * loop rebuild, where the constructor would otherwise seed a fresh + * {@code hasEverConnected = false} for the new loop instance (ASYNC + * startup always hands the constructor a null client). Public rather + * than package-private only because the owning sender lives in a + * different package; it is not part of the public {@code Sender} API. + * {@code hasEverConnected} is volatile, so this write needs no extra + * synchronization to be visible to the I/O thread -- callers still call + * it before {@code start()} so the invariant is established before the + * loop can observe any endpoint-policy failure. + */ + public void markEverConnected() { + hasEverConnected = true; + } + /** * Plug an async-delivery sink for {@link SenderConnectionEvent} * notifications. Connection events fire from diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java index 624804f4..2f8b43d6 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java @@ -222,6 +222,11 @@ public void testDefaultConfigRecycleBuffersThroughOutage() throws Exception { // connect that cleared the batch watermark would ship a row // pointing at an id the server never received. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertTrue("wasEverConnected() must stay sticky across the recycle's " + + "rebuilt loop while the endpoint is still down -- the fresh " + + "loop must not report 'never connected' just because it is a " + + "new loop instance", + ws.wasEverConnected()); Assert.assertEquals("the swap must commit exactly one epoch", 1, ws.getSymbolDictEpoch()); Assert.assertEquals(1, ws.getSymbolDictResetsPerformed()); From aa6a98f362712420f248d42affc9530f9c9b57c8 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:56:10 +0100 Subject: [PATCH 29/49] Await the async post-recycle handshake in tests The recycle's reconnect is now deferred to the I/O thread, so handshake/connection-count assertions that ran synchronously after the triggering table() call became racy. Each moves after the next awaitAckedFsn(): an acked post-recycle frame proves the fresh connection is up, making the count deterministic again. Refusal-path assertions (no recycle, no reconnect) are unchanged. --- .../SymbolDictRecycleCatchUpSkipTest.java | 12 +++--- .../SymbolDictRecycleMemoryModeTest.java | 37 ++++++++++--------- .../client/SymbolDictRecycleRefusalTest.java | 14 +++---- .../qwp/client/SymbolDictRecycleTest.java | 26 ++++++------- 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java index ac9b7734..2d8fab8d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java @@ -121,19 +121,21 @@ public void testRecycleSkipsCatchUpThenUnplannedReconnectBoundsCatchUpToNewEpoch Assert.assertEquals(1, handler.connectionsAccepted.get()); Assert.assertEquals(0, ws.getSymbolDictEpoch()); - // Ring drained: this table() call recycles synchronously onto a fresh - // connection (2), a fresh (empty) engine/dictionary/epoch, and "c" is - // then the new epoch's own first symbol. + // Ring drained: this table() call recycles synchronously (steps 1-6: + // fresh empty engine/dictionary/epoch), and "c" is then the new + // epoch's own first symbol. The fresh connection (2) itself is the + // I/O thread's job and completes asynchronously -- confirmed below, + // after an acked post-recycle frame proves it is up. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); Assert.assertFalse("recycle must disarm", ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); - Assert.assertEquals("recycle must open a fresh connection", - 2, server.handshakeCount()); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue("epoch-1 batch must be acked before the unplanned drop", sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); // --- Pin 1 + 2: zero catch-up frames, dictionary tiles from 0. --- // Connection 2 is the FIRST connection after the recycle: its loop's diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java index b8784486..b148df44 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java @@ -84,17 +84,20 @@ public void testRecycleAtEmptyBacklog() throws Exception { Assert.assertEquals(0, ws.getSymbolDictEpoch()); // Ring drained, no row in progress: this table() call must - // recycle synchronously, exactly as in SF mode. + // recycle synchronously, exactly as in SF mode. The fresh + // WebSocket handshake is the I/O thread's job and completes + // asynchronously -- it is asserted below, after an acked + // post-recycle frame proves the connection is up. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); Assert.assertFalse("recycle must disarm", ws.isResetArmed()); - Assert.assertEquals("recycle must open a fresh connection", - 2, server.handshakeCount()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue("post-recycle batch must still get acked", sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN " + "[fsn1=" + fsn1 + ", fsn2=" + fsn2 + ']', fsn2 > fsn1); @@ -187,15 +190,14 @@ public void testRecycleLosesNothingAcked() throws Exception { * {@code initial_connect_retry=async} defers even the FIRST connect to the * I/O thread ({@code ensureConnected()}'s {@code ASYNC} arm leaves * {@code client == null} and lets {@code CursorWebSocketSendLoop} dial in the - * background) -- and {@code recycleForDictReset()}'s step 7 reconnect reuses - * the exact same {@code initialConnectMode} switch, so the post-recycle - * connection is ALSO dialled asynchronously rather than inline on the - * producer thread that called {@code table()}. Only the producer-side halves - * of the swap (steps 4-6: FSN epoch roll, dictionary swap, engine rebuild) - * are guaranteed synchronous by the time {@code table()} returns; the fresh - * handshake itself must be awaited separately here, unlike the SYNC-mode - * tests above where {@code server.handshakeCount()} is already correct the - * instant {@code table()} returns. + * background). {@code recycleForDictReset()}'s step 7 reconnect defers to + * the I/O thread on every sender regardless of {@code initialConnectMode} + * (a re-entry past the sender's first {@code ensureConnected()} completion + * always takes the {@code ASYNC} branch) -- this test just happens to also + * start out that way. Only the producer-side halves of the swap (steps + * 4-6: FSN epoch roll, dictionary swap, engine rebuild) are guaranteed + * synchronous by the time {@code table()} returns; the fresh handshake + * itself must always be awaited separately, exactly like the tests above. */ @Test public void testRecycleUnderAsyncInitialConnect() throws Exception { @@ -271,11 +273,12 @@ public void testRecycleUnderAsyncInitialConnect() throws Exception { /** * Spins until the I/O thread has completed the deferred ASYNC initial - * connect. Needed only for the async test above: SYNC/OFF-mode recycle - * blocks {@code table()} until the fresh handshake completes, so those - * tests observe connectedness synchronously, but ASYNC mode hands the - * connect off to the I/O thread and returns control to the caller before - * it necessarily lands. + * connect. Needed only for the async test above, to let its first connect + * land before driving any traffic through it -- {@code + * recycleForDictReset()}'s step 7 reconnect always defers to the I/O + * thread regardless of {@code initialConnectMode}, so waiting for THAT + * handshake is instead done via the post-recycle {@code awaitAckedFsn} + * throughout this file, not this helper. */ private static void awaitWasEverConnected(QwpWebSocketSender ws) { long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java index e3206775..00b47421 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java @@ -114,14 +114,14 @@ public void testUnackedBacklogRefusesUntilAcked() throws Exception { sender.table("t"); Assert.assertFalse("recycle must fire once the backlog drains", ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); - Assert.assertEquals("recycle must open a fresh connection", - 2, server.handshakeCount()); // Ingestion continues on the fresh epoch. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue("post-recycle batch must still get acked", sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); Assert.assertTrue(fsn2 > fsn1); } } @@ -193,11 +193,11 @@ public void testPendingRowCountRefuses() throws Exception { + "and acked", ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); - Assert.assertEquals(2, server.handshakeCount()); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn3 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn3, 5_000)); + Assert.assertEquals(2, server.handshakeCount()); Assert.assertTrue(fsn3 > fsn2); } } @@ -281,11 +281,11 @@ public void testInProgressRowRefuses() throws Exception { + "drains", ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); - Assert.assertEquals(2, server.handshakeCount()); sender.table("t").symbol("s", "d").longColumn("v", 2L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals(2, server.handshakeCount()); Assert.assertTrue(fsn2 > fsn1); } } @@ -346,11 +346,11 @@ public void testDeferredCommitGroupRefusesUntilCommitAcked() throws Exception { Assert.assertFalse("recycle must fire once the group is committed and acked", ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); - Assert.assertEquals(2, server.handshakeCount()); sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals(2, server.handshakeCount()); Assert.assertTrue(fsn2 > commitFsn); } } @@ -423,11 +423,11 @@ public void testManualResetBeforeFirstConnectDeferred() throws Exception { + "drained", sender.isResetArmed()); Assert.assertEquals(1, sender.getSymbolDictEpoch()); - Assert.assertEquals(2, server.handshakeCount()); sender.table("t").longColumn("v", 2L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals(2, server.handshakeCount()); Assert.assertTrue(fsn2 > fsn1); } finally { sender.close(); @@ -504,13 +504,13 @@ public void testResetDiscardsBufferedRowThenArmedSwapFires() throws Exception { + "in-progress row", ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); - Assert.assertEquals(2, server.handshakeCount()); // Ingestion continues correctly post-swap: a fresh row // lands and gets acked with no exception. sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals(2, server.handshakeCount()); Assert.assertTrue(fsn2 > fsn1); } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index 69c83554..48f3d9b5 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -60,8 +60,9 @@ * barrier hook + {@code recycleForDictReset()}): tears the cursor engine and * I/O loop down once the ring is proven drained, replaces the producer's * global symbol dictionary, and rebuilds the engine on the same (now-empty) - * slot before reconnecting -- all synchronously inside a single - * {@code table()} call. + * slot -- all synchronously inside a single {@code table()} call. The fresh + * WebSocket handshake itself (the reconnect) is deferred to the I/O thread + * and completes asynchronously. */ public class SymbolDictRecycleTest { @@ -94,21 +95,19 @@ public void testRecycleAtEmptyBacklog() throws Exception { // The ring is drained (everything acked) and no row is in // progress, so this table() call must recycle synchronously. - // ensureConnected() performs the fresh WebSocket handshake - // synchronously as part of the swap, before any data is sent -- - // handshakeCount (not the handler's own onBinaryMessage-driven - // counter, which only advances once a frame actually arrives) - // observes that handshake immediately. + // The fresh WebSocket handshake is the I/O thread's job and + // completes asynchronously -- it is asserted below, after an + // acked post-recycle frame proves the connection is up. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); Assert.assertFalse("recycle must disarm", ws.isResetArmed()); - Assert.assertEquals("recycle must open a fresh connection", - 2, server.handshakeCount()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue("post-recycle batch must still get acked", sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN " + "[fsn1=" + fsn1 + ", fsn2=" + fsn2 + ']', fsn2 > fsn1); @@ -236,8 +235,9 @@ public void testPostRecycleSlotContents() throws Exception { CursorSendEngine before = ws.getCursorEngineForTesting(); // Synchronous swap: by the time table() returns, the old engine - // is gone and a fresh one is rebuilt and reconnected. Asserting - // right here needs no polling -- there is no window to race. + // is gone and a fresh one is rebuilt (the reconnect itself defers + // to the I/O thread). Asserting engine identity right here needs + // no polling -- there is no window to race for that. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); CursorSendEngine after = ws.getCursorEngineForTesting(); @@ -299,14 +299,14 @@ public void testRecycleUnderDurableAck() throws Exception { sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); Assert.assertFalse("recycle must disarm", ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); - Assert.assertEquals("recycle must open a fresh connection", - 2, server.handshakeCount()); sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue("post-recycle batch must still get durably acked on the " + "fresh connection", sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertEquals("recycle must open a fresh connection", + 2, server.handshakeCount()); Assert.assertTrue(fsn2 > fsn1); } } From fb2871835590d7baee36890964ee704abbc1de52 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:14:04 +0100 Subject: [PATCH 30/49] Make awaitAckedFsn recycle-safe; fix step-7 prose awaitAckedFsn now snapshots cursorSendLoop into a local before each null-check, the same way it already snapshots cursorEngine. The field is non-volatile and the recycle nulls it on the producer thread, so a monitor thread calling awaitAckedFsn could previously read non-null, then have the recycle null the field before the second read, NPE'ing inside checkError(). The snapshot closes that window at both call sites in the method. recycleForDictReset's javadoc and its step-7 catch's LOG.warn still described a foreground connect retry, but ensureConnected() now defers the socket connect to the I/O thread, so step-7 failures are only dispatcher construction, loop build, or start() -- environmental, not transport. Reworded both to describe the deferred reconnect that actually runs. --- .../qwp/client/QwpWebSocketSender.java | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index fecdda84..086ca657 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -1108,8 +1108,12 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { // throw instead of an indefinite "not yet". The durability latch // above is transient: it throws while latched, and clears once a // later periodic sync pass fully succeeds so producers can resume. - if (cursorSendLoop != null) { - cursorSendLoop.checkError(); + // Snapshot for the same reason as engine above: the recycle nulls + // cursorSendLoop on the producer thread, so a double read here could + // NPE between the check and the call. + CursorWebSocketSendLoop loop = cursorSendLoop; + if (loop != null) { + loop.checkError(); } checkConnectionError(); if (targetFsn >= 0) { @@ -1129,8 +1133,9 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; while (engine.ackedFsn() < targetFsn) { engine.checkDurability(); - if (cursorSendLoop != null) { - cursorSendLoop.checkError(); + loop = cursorSendLoop; + if (loop != null) { + loop.checkError(); } checkConnectionError(); if (System.nanoTime() >= deadlineNanos) { @@ -4954,15 +4959,17 @@ private void maybeRecycleForDictReset() { * the await budget (a genuinely dead worker) latches terminal. *

    * Step 7 is deliberately exempt from that latch. By then the swap has - * committed, so a failed connect leaves a fully coherent sender that is + * committed, so a failed step-7 setup (dispatcher construction, loop + * build/start -- environmental, since the socket connect itself is + * deferred to the I/O thread) leaves a fully coherent sender that is * merely disconnected: {@code connected == false}, loop and client already * closed and nulled by {@link #ensureConnected()}'s own catch, the fresh * engine attached, and the step-5 counters ({@link #symbolDictEpoch}, * {@link #symbolDictResetsPerformed}) correctly left incremented because * the swap really did happen. It rethrows loudly to the triggering caller * but the sender stays usable, and the ordinary - * {@code sendRow() -> ensureConnected()} path retries the connect -- and - * only the connect -- on the next send. Nothing can fire a second swap + * {@code sendRow() -> ensureConnected()} path retries the deferred setup + * -- and only that -- on the next send. Nothing can fire a second swap * meanwhile: the fresh dictionary is below threshold, * {@code manualResetRequested} was consumed at step 5, and * {@link #maybeRecycleForDictReset()} requires {@code connected}. @@ -5060,9 +5067,9 @@ private void recycleForDictReset() { try { ensureConnected(); } catch (Throwable t) { - LOG.warn("symbol dictionary swap committed but its reconnect failed; sender stays " - + "disconnected on the fresh epoch and retries the connect on the " - + "next send [epoch={}, dictSizeAtSwap={}]", + LOG.warn("symbol dictionary swap committed but starting its deferred reconnect " + + "failed; sender stays disconnected on the fresh epoch and retries " + + "the setup on the next send [epoch={}, dictSizeAtSwap={}]", symbolDictEpoch, dictSizeAtSwap, t); if (t instanceof LineSenderException) { throw (LineSenderException) t; From d00acf9c0cdda94bf82d3343afc4d8e5e37a8261 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:32:15 +0100 Subject: [PATCH 31/49] Pin step-7 non-latching with a fault-injection test Adds a @TestOnly loop-start fault hook and a regression test proving a failed recycle reconnect leaves the sender usable (review r3, C5). The test the PR body credited for this was deleted by 6793928a; this one is red-proofed against re-latching step 7. --- .../qwp/client/QwpWebSocketSender.java | 14 ++ .../SymbolDictRecycleStep7FaultTest.java | 131 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 086ca657..dabe7a35 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -498,6 +498,12 @@ public class QwpWebSocketSender implements Sender { // this: the swap has already committed there, so the sender is coherent // and merely disconnected, and the next send retries the connect. private Throwable recycleFailure; + // Test seam: recycle step-7 fault injection. When set, runs (and is + // expected to throw) inside ensureConnected()'s loop-construction try, + // after cursorSendLoop is assigned but before start() -- exercising the + // catch that closes and nulls the fresh loop, i.e. the failed-reconnect + // state the C4/C5 regression tests pin. + private Runnable loopStartFault; // Incremented once per completed symbol-dictionary recycle. 0 until the // first recycle commits. volatile: this is public API (see // getSymbolDictEpoch()), and a monitoring thread is its obvious reader. @@ -2932,6 +2938,11 @@ public void setErrorInboxCapacity(int capacity) { this.errorInboxCapacity = capacity; } + @TestOnly + public void setLoopStartFaultForTesting(Runnable fault) { + this.loopStartFault = fault; + } + @TestOnly public void setRecycleDeferredCloseMaxWaitMillisForTesting(long millis) { this.recycleDeferredCloseMaxWaitMillis = millis; @@ -4268,6 +4279,9 @@ private void ensureConnected() { if (hasLoopEverConnected) { cursorSendLoop.markEverConnected(); } + if (loopStartFault != null) { + loopStartFault.run(); + } cursorSendLoop.start(); } catch (Throwable t) { // start() (or dispatcher construction) failed after cursorSendLoop was diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java new file mode 100644 index 00000000..92db63ac --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java @@ -0,0 +1,131 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Fault-injects a failure into recycle step 7 (the deferred reconnect) via + * {@link QwpWebSocketSender#setLoopStartFaultForTesting(Runnable)} and pins + * that the failure does NOT latch the sender terminal. + */ +public class SymbolDictRecycleStep7FaultTest { + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + /** + * Pins that a step-7 (reconnect) failure does NOT latch the sender + * terminal: the swap has committed, the sender is coherent and merely + * disconnected, and the next send retries the deferred setup. Review + * round 3, finding C5 (the test the PR body credited was deleted by + * commit 6793928a). + */ + @Test + public void testStep7FailureDoesNotLatchAndRecovers() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.newFolder("step7-c5").getAbsolutePath(); + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(cfg(server, sfDir))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long f1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(f1, 5_000)); + Assert.assertTrue(ws.isResetArmed()); + + RuntimeException fault = new RuntimeException("injected step-7 fault"); + ws.setLoopStartFaultForTesting(() -> { + throw fault; + }); + try { + sender.table("t"); + Assert.fail("the triggering table() must surface the step-7 failure"); + } catch (LineSenderException e) { + Assert.assertSame(fault, findRootCause(e)); + } + // The swap committed; the sender must NOT be terminal. + Assert.assertEquals(1, ws.getSymbolDictEpoch()); + ws.setLoopStartFaultForTesting(null); + // Next send retries the deferred setup and data flows again. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + long f2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recovery batch must be acked", + sender.awaitAckedFsn(f2, 5_000)); + } + } + }); + } + + private static TestWebSocketServer ackingServer() throws Exception { + TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + private static String cfg(TestWebSocketServer server, String sfDir) { + return "ws::addr=localhost:" + server.getPort() + ";sf_dir=" + sfDir + + ";symbol_dict_reset_threshold=2" + + ";reconnect_initial_backoff_millis=20" + + ";reconnect_max_backoff_millis=80;"; + } + + /** Follows {@code getCause()} to the deepest non-null cause. */ + private static Throwable findRootCause(Throwable t) { + Throwable cause = t; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return cause; + } + + /** ACKs every frame it receives; does not otherwise inspect the wire. */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} From 895cc22a8db102b53b231bb521a4a36cd21fcf0e Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:44:05 +0100 Subject: [PATCH 32/49] Add regression test for the batch-watermark reset guard Drives a real failed step-7 reconnect and asserts the next flushed delta still carries ids registered while the row was in progress (review r3, C4). Red-proofed: reverting the guard to the unconditional clear fails the test. --- .../SymbolDictRecycleStep7FaultTest.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java index 92db63ac..66921574 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java @@ -34,6 +34,9 @@ import org.junit.rules.TemporaryFolder; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; @@ -92,6 +95,64 @@ public void testStep7FailureDoesNotLatchAndRecovers() throws Exception { }); } + /** + * Pins the conditional in resetSymbolDictStateForNewConnection(): ids a + * row registered before the deferred reconnect completes must still ship + * in the next delta. Review round 3, finding C4 (empirically untested: + * suite was green with the guard reverted). + */ + @Test + public void testPostFailedReconnectDeltaCoversStagedSymbolIds() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.newFolder("step7-c4").getAbsolutePath(); + CapturingAckHandler handler = new CapturingAckHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + try (Sender sender = Sender.fromConfig(cfg(server, sfDir))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + long f1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(f1, 5_000)); + + ws.setLoopStartFaultForTesting(() -> { + throw new RuntimeException("injected step-7 fault"); + }); + try { + sender.table("t"); + Assert.fail("expected the step-7 failure to surface"); + } catch (LineSenderException ignore) { + } + // Everything before this point belongs to the old epoch's + // dictionary; the swap already committed (steps 1-6), only + // the reconnect (step 7) failed, so no new frame reached + // the wire during the failed table() call above. + int firstPostRecycle = handler.framesSnapshot().size(); + ws.setLoopStartFaultForTesting(null); + + // This row registers "c" in the FRESH dictionary during + // symbol(); its sendRow() then completes the deferred + // reconnect, which runs resetSymbolDictStateForNewConnection + // with the row in progress -- the C4 window. + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + long f2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(f2, 5_000)); + + // Replay every captured frame's delta sections; a delta that + // omits "c" while rows reference it throws DictionaryGapException. + List dict = new ArrayList<>(); + List frames = handler.framesSnapshot(); + for (int i = firstPostRecycle; i < frames.size(); i++) { + QwpWireTestUtils.accumulateDeltaDictionary(frames.get(i), dict); + } + Assert.assertTrue("the post-recycle delta must carry the staged id for 'c'", + dict.contains("c")); + } + } + }); + } + private static TestWebSocketServer ackingServer() throws Exception { TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); server.start(); @@ -128,4 +189,37 @@ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler clien } } } + + /** + * ACKs every frame it receives and records each binary payload before + * acking, so the test can replay the wire's delta-dictionary sections + * after the fact. Tracks the current client like {@code + * OutageRecycleHandler} (SymbolDictRecycleOutageTest) and resets its + * sequence counter on a new connection -- otherwise a post-recycle + * reconnect's fresh {@code nextSeq=0} clamps against the prior + * connection's already-higher acked sequence and spams a benign WARN. + */ + private static class CapturingAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private final List frames = Collections.synchronizedList(new ArrayList()); + private final AtomicLong nextSeq = new AtomicLong(0); + private TestWebSocketServer.ClientHandler currentClient; + + List framesSnapshot() { + return new ArrayList<>(frames); + } + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (currentClient != client) { + currentClient = client; + nextSeq.set(0); + } + frames.add(data); + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } } From 598660982879d1a80c0bf6c31072ed79078eebb4 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:51:55 +0100 Subject: [PATCH 33/49] Split close()'s drain guard by true dependency The flush/commit/seal trio needs only the cursor engine; only checkUnsurfacedError and drainOnClose need the I/O loop. Review r3, C3. The review's staged-rows-lost trigger is unreachable (sendRow calls ensureConnected before a row commits, and failures roll the row back), so this is hardening: close() no longer skips persisting staged rows in any future state where the loop is absent. --- .../cutlass/qwp/client/QwpWebSocketSender.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index dabe7a35..74106ffd 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -1360,10 +1360,14 @@ public void close() { ? cursorSendLoop.getSynchronouslySurfacedError() : null; try { - // Only drain when both the engine and the I/O loop are wired - // up — close() is also called from createForTesting() teardown - // and from connect() rollback paths where one or both may be null. - if (connectionError.get() == null && cursorEngine != null && cursorSendLoop != null) { + // The flush/commit/seal trio needs only the engine: rows are + // encoded into the SF ring on the user thread. The loop-only + // members below (checkUnsurfacedError, drainOnClose) keep + // their own gate -- with no I/O loop nothing can advance + // acks, so draining would only stall for the full timeout. + // Also covers createForTesting() teardown and connect() + // rollback paths where the loop (or both) may be null. + if (connectionError.get() == null && cursorEngine != null) { // 1) Flush user-thread state into the engine (encoded // rows -> mmap'd / malloc'd ring). After this, the // cursor engine's publishedFsn reflects the final @@ -1429,7 +1433,7 @@ public void close() { // both still get the loud rethrow on shutdown. boolean terminalOwnedByCustomHandler = errorDispatcher != null && errorDispatcher.hasDeliveredTerminalToCustomHandler(); - if (!terminalOwnedByCustomHandler) { + if (cursorSendLoop != null && !terminalOwnedByCustomHandler) { cursorSendLoop.checkUnsurfacedError(); } // 3) Bounded drain: block until the server has ACK'd @@ -1442,7 +1446,7 @@ public void close() { // without re-throwing (re-throwing would double-signal // an error the user already handled). Otherwise the // drain keeps the loud safety net and surfaces it. - if (closeFlushTimeoutMillis > 0L) { + if (cursorSendLoop != null && closeFlushTimeoutMillis > 0L) { drainOnClose(terminalOwnedByCustomHandler); } } From 77d4ad7a02b9d541984e1b55ac82662df97269d3 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:03:15 +0100 Subject: [PATCH 34/49] Add re-arm floor to stop recycle thrash A live symbol set above the threshold previously re-armed the reset on every refill, recycling (teardown + reconnect + dictionary re-ship) every ~L*ln(L/(L-T)) rows forever (review r3, C1). Each swap now raises the re-arm bar to twice the size at swap, capped at half the protocol cap so unbounded-cardinality producers keep recycling. No new config knob; the default threshold is unchanged. --- .../qwp/client/QwpWebSocketSender.java | 20 ++++++++- .../qwp/client/SymbolDictRecycleTest.java | 45 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 74106ffd..1792e895 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -448,6 +448,15 @@ public class QwpWebSocketSender implements Sender { // Distinct-symbol count that triggers a recycle once resetEnabled is on // (connect-string key symbol_dict_reset_threshold). private int resetThresholdSymbols = DEFAULT_SYMBOL_DICT_RESET_THRESHOLD_SYMBOLS; + // Anti-thrash floor for the automatic reset (review r3, C1). 0 until the + // first swap; the effective re-arm bar is max(resetThresholdSymbols, + // resetFloorSymbols). Each swap raises it to twice the dictionary size + // at that swap, so a live symbol set larger than the threshold stops + // re-arming after at most ~log2(liveSet/threshold) swaps, while a + // genuinely unbounded-cardinality producer keeps recycling: the floor is + // capped at half the protocol cap so it can never double into the hard + // stop. Never lowered -- a shrunken working set simply stops arming. + private int resetFloorSymbols; // Wall-clock time (System.nanoTime()) at which resetArmed last flipped // false -> true. Recorded by armIfEligible so a later task's opportunistic // wait can measure how long the recycle has been armed against @@ -2172,6 +2181,11 @@ public int getSymbolDictResetThreshold() { return resetThresholdSymbols; } + @TestOnly + public int getResetFloorSymbolsForTesting() { + return resetFloorSymbols; + } + @TestOnly public QwpTableBuffer getTableBuffer(String tableName) { QwpTableBuffer buffer = tableBuffers.get(tableName); @@ -4764,7 +4778,8 @@ private void resetTableBuffersAfterFlush() { */ private void armIfEligible() { boolean shouldArm = resetEnabled - && (globalSymbolDictionary.size() >= resetThresholdSymbols || manualResetRequested); + && (globalSymbolDictionary.size() >= Math.max(resetThresholdSymbols, resetFloorSymbols) + || manualResetRequested); if (shouldArm && !resetArmed) { armedSinceNanos = System.nanoTime(); starvationWaitDoneThisArm = false; @@ -5034,6 +5049,9 @@ private void recycleForDictReset() { lastCommitBoundaryFsn = -1L; symbolDictEpoch++; symbolDictResetsPerformed++; + // C1 anti-thrash floor: see resetFloorSymbols. + resetFloorSymbols = Math.min(dictSizeAtSwap * 2, + QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE / 2); resetArmed = false; manualResetRequested = false; // step 6: rebuild the engine on the now-empty slot diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index 48f3d9b5..ce833ce6 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -405,6 +405,51 @@ public void testFailedRebuildLatchesTerminal() throws Exception { }); } + /** + * A live symbol set larger than the threshold must not thrash the + * recycle: after a swap, re-arming requires the dictionary to reach + * max(threshold, 2 * size-at-swap). Review round 3, finding C1. + */ + @Test + public void testLiveSetAboveThresholdDoesNotThrash() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + // threshold=4; the live set has 6 distinct symbols + try (Sender sender = Sender.fromConfig(cfg(server) + "symbol_dict_reset_threshold=4;")) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + String[] live = {"s0", "s1", "s2", "s3", "s4", "s5"}; + sendLiveSet(sender, live); // registers 6 distinct -> arms + sender.table("t"); // barrier -> recycle #1 + Assert.assertEquals(1, ws.getSymbolDictResetsPerformed()); + // Refill from the SAME live pool three times over: 6 is above + // the threshold but below the doubled floor (12) -> no re-arm. + for (int pass = 0; pass < 3; pass++) { + sendLiveSet(sender, live); + sender.table("t"); + } + Assert.assertEquals("a bounded live set must not re-trigger the recycle", + 1, ws.getSymbolDictResetsPerformed()); + // Genuine growth past the floor DOES re-arm: 12 fresh symbols. + String[] grown = new String[12]; + for (int i = 0; i < 12; i++) { + grown[i] = "g" + i; + } + sendLiveSet(sender, grown); + sender.table("t"); + Assert.assertEquals(2, ws.getSymbolDictResetsPerformed()); + } + } + }); + } + + private void sendLiveSet(Sender sender, String[] symbols) throws Exception { + for (String s : symbols) { + sender.table("t").symbol("s", s).longColumn("v", 1L).atNow(); + } + long f = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(f, 5_000)); + } + private static TestWebSocketServer ackingServer() throws Exception { TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); server.start(); From 16ac394f7f1bcfeb29a9b86fac249dcdc3b81711 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:22:31 +0100 Subject: [PATCH 35/49] Commit the recycle swap only after a successful rebuild Reorders recycleForDictReset() so the producer-visible swap (fresh dictionary, epoch counters, engine wiring) happens only once a fresh engine stands on the emptied slot. A failed rebuild no longer leaves half-swapped counters. Rides two review-r3 one-liners that live in the moved lines: M5 (read hasEverConnected after close() joins the I/O thread) and M11 (Error passes through unwrapped and unlatched). The review's M10 is deliberately NOT applied: delta-dict re-derivation is the tested healing contract (SymbolDictRecycleHealingTest); the stale one-way-latch doc wording is fixed instead. Also updates testMetricsAfterTwoRecycles for the C1 re-arm floor. --- .../qwp/client/QwpWebSocketSender.java | 176 ++++++++++-------- .../client/SymbolDictRecycleHealingTest.java | 7 +- 2 files changed, 102 insertions(+), 81 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 1792e895..61fed5bc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -458,9 +458,9 @@ public class QwpWebSocketSender implements Sender { // stop. Never lowered -- a shrunken working set simply stops arming. private int resetFloorSymbols; // Wall-clock time (System.nanoTime()) at which resetArmed last flipped - // false -> true. Recorded by armIfEligible so a later task's opportunistic - // wait can measure how long the recycle has been armed against - // resetMaxWaitMillis. + // false -> true. Recorded by armIfEligible so maybeBlockForStarvedReset's + // opportunistic wait can measure how long the recycle has been armed + // against resetMaxWaitMillis. private long armedSinceNanos; // Set by resetSymbolDictionary() (the public advisory API) and never // cleared by armIfEligible itself -- once a caller asks for a fresh epoch, @@ -473,7 +473,7 @@ public class QwpWebSocketSender implements Sender { // resetSymbolDictionary() when no flush is in flight), never on the // per-symbol registration path. private boolean resetArmed; - // Cleared on the false -> true armed transition; a later task's + // Cleared on the false -> true armed transition; maybeBlockForStarvedReset's // opportunistic-wait step sets it once it has waited out its window for // THIS arm cycle, so a subsequent forced-wait check does not re-wait. private boolean starvationWaitDoneThisArm; @@ -4917,8 +4917,8 @@ private void maybeBlockForStarvedReset() { * pre-connected here), a flush in flight ({@code pendingRowCount != 0}), * or a row under construction. Otherwise proceeds to the ring-drained * check: if the backlog is empty, recycle immediately; if not, defer to - * the (later-task) starvation-wait policy instead of blocking the caller - * indefinitely here. + * {@link #maybeBlockForStarvedReset()}'s starvation-wait policy instead + * of blocking the caller indefinitely here. */ private void maybeRecycleForDictReset() { if (engineRebuildFactory == null || !ownsCursorEngine) { @@ -4944,67 +4944,85 @@ private void maybeRecycleForDictReset() { *

      *
    1. Snapshot the outgoing epoch's last published (raw) FSN.
    2. *
    3. Close and null the cursor I/O loop -- joins the I/O thread and - * closes the WebSocket client.
    4. - *
    5. Fully-drained close of the outgoing cursor engine. Everything was - * proven acked by the barrier, so {@code close()} (== {@code close(true)}) - * takes the reclaim branch: it empties the slot AND unlinks the + * closes the WebSocket client. {@code hasLoopEverConnected} is read + * only AFTER {@code close()} returns: the join makes even an + * ASYNC-initial sender's connect (observed only by the I/O thread, + * never by {@code ensureConnected}'s {@code client != null} branch) + * final by then.
    6. + *
    7. Clear {@code connected} -- the sender must not claim connectivity + * once its engine is coming down -- then fully-drained close of the + * outgoing cursor engine. Everything was proven acked by the + * barrier, so {@code close()} (== {@code close(true)}) takes the + * reclaim branch: it empties the slot AND unlinks the * parent-anchored logical slot lock (see {@code CursorSendEngine.close}'s * javadoc) -- this sender holds no other lock on the slot at this - * point, so releasing it here is safe.
    8. + * point, so releasing it here is safe. Awaits the (possibly + * deferred) release -- see below. *
    9. Roll {@link #fsnEpochBase} past every FSN the outgoing epoch ever * handed out. Must run with {@code cursorSendLoop == null} (step 2 * already guarantees this -- see {@link #rollFsnEpochBase}'s * precondition).
    10. - *
    11. Producer-side state swap: a fresh {@link GlobalSymbolDictionary} + *
    12. Rebuild the cursor engine on the now-empty slot via + * {@link #engineRebuildFactory}, the identical construct path + * {@code Sender.build()} uses. A rebuild that recovers a persisted + * dictionary from disk means the outgoing close's empties-the-slot + * contract was breached, so the recycle refuses to run on it.
    13. + *
    14. Producer-side swap COMMIT -- only now that a fresh engine stands + * on the emptied slot: a fresh {@link GlobalSymbolDictionary} * (replaced, not cleared -- nothing else retains the old instance), * both symbol-id watermarks reset, {@code lastCommitBoundaryFsn} * reset (it held a raw old-epoch FSN that does not survive the * roll), the epoch counter and the completed-swap counter both - * advanced, and the arming flags consumed.
    15. - *
    16. Rebuild the cursor engine on the now-empty slot via - * {@link #engineRebuildFactory}, the identical construct path - * {@code Sender.build()} uses. {@code deltaDictEnabled} is re-derived - * from the fresh engine and its slot-lock-release listener rewired, - * mirroring (not calling) {@link #setCursorEngine} -- that method's - * guards refuse a second engine.
    17. + * advanced, the arming flags consumed, the C1 anti-thrash floor + * raised, {@code deltaDictEnabled} re-derived from the fresh + * engine (the healing half of the recycle contract -- see + * {@link #disableDeltaDict}), and the fresh engine's + * slot-lock-release listener rewired, mirroring (not calling) + * {@link #setCursorEngine} -- that method's guards refuse a second + * engine. *
    18. Reconnect: {@link #ensureConnected()} builds a fresh I/O loop * against the rolled {@link #fsnEpochBase} and defers the socket * connect itself to the I/O thread. Runs OUTSIDE the latching * try -- see below.
    19. *
    - * A throw in steps 2-6 is caught, latches {@link #recycleFailure}, - * and rethrows: every frame that existed before this call was already - * proven acked, so no data is at risk, but the sender that made the throw - * observe a half-swapped engine/loop refuses further use from here on -- - * {@link #checkRecycleFailure()} enforces that at every later - * {@link #table(CharSequence)} and flush-family call. (Step 1 is a pair - * of plain reads and runs before the latching try.) + * The producer-visible swap (dictionary, counters, epoch) commits only + * once a fresh engine stands on the emptied slot: a throw in steps 2-6 + * is caught before that point leaves the counters un-bumped, latches + * {@link #recycleFailure}, and rethrows -- every frame that existed + * before this call was already proven acked, so no data is at risk, but + * the sender that made the throw observe a half-swapped engine/loop + * refuses further use from here on -- {@link #checkRecycleFailure()} + * enforces that at every later {@link #table(CharSequence)} and + * flush-family call. An {@link Error} (OOM/SOE/linkage) passes through + * this catch untouched, neither latched nor wrapped -- it is not a + * recycle verdict. (Step 1 is a pair of plain reads and runs before the + * latching try.) *

    * Step 3 mirrors {@code close()}'s deferred-close discipline: when the * outgoing engine's close could not confirm SF-worker quiescence it * returns with the slot flock retained and {@code isCloseCompleted()} * false, releasing both from the worker's exit path. Step 3 then awaits * that deferred release (bounded by - * {@link #RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS}) before step 6 rebuilds + * {@link #RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS}) before step 5 rebuilds * on the slot -- rebuilding against the retained flock would throw * {@code SlotLockContentionException} and needlessly latch the sender * terminal for what is usually a transient disk stall. Only exhausting * the await budget (a genuinely dead worker) latches terminal. *

    * Step 7 is deliberately exempt from that latch. By then the swap has - * committed, so a failed step-7 setup (dispatcher construction, loop - * build/start -- environmental, since the socket connect itself is + * committed (step 6), so a failed step-7 setup (dispatcher construction, + * loop build/start -- environmental, since the socket connect itself is * deferred to the I/O thread) leaves a fully coherent sender that is * merely disconnected: {@code connected == false}, loop and client already * closed and nulled by {@link #ensureConnected()}'s own catch, the fresh - * engine attached, and the step-5 counters ({@link #symbolDictEpoch}, + * engine attached, and the step-6 counters ({@link #symbolDictEpoch}, * {@link #symbolDictResetsPerformed}) correctly left incremented because * the swap really did happen. It rethrows loudly to the triggering caller * but the sender stays usable, and the ordinary * {@code sendRow() -> ensureConnected()} path retries the deferred setup * -- and only that -- on the next send. Nothing can fire a second swap * meanwhile: the fresh dictionary is below threshold, - * {@code manualResetRequested} was consumed at step 5, and + * {@code manualResetRequested} was consumed at step 6, and * {@link #maybeRecycleForDictReset()} requires {@code connected}. */ private void recycleForDictReset() { @@ -5012,49 +5030,32 @@ private void recycleForDictReset() { final int dictSizeAtSwap = globalSymbolDictionary.size(); final long startNanos = System.nanoTime(); if (lastPublishedFsn >= 0) { - // The barrier proved every published frame acked, so this is the - // durable watermark the monitoring accessors keep reporting while - // cursorEngine is null (mid-swap, or for good after a failed swap). lastRecycleDurableFsn = fsnEpochBase + lastPublishedFsn; } try { // step 2: close the loop - joins the I/O thread, closes the client. - // Capture the outgoing loop's own ever-connected state into the - // sender-lifetime sticky OR BEFORE closing it: this is the only - // place an ASYNC-initial sender's connect (which happens only on - // the I/O thread, never observed by ensureConnected's client != - // null branch) reaches hasLoopEverConnected. if (cursorSendLoop != null) { - hasLoopEverConnected |= cursorSendLoop.hasEverConnected(); cursorSendLoop.close(); + // Read the sticky AFTER close(): close joins the I/O thread, + // so a connect that landed mid-window is final here. This is + // the only place an ASYNC-initial sender's connect (observed + // only by the I/O thread) reaches hasLoopEverConnected. + hasLoopEverConnected |= cursorSendLoop.hasEverConnected(); cursorSendLoop = null; } client = null; - // step 3: fully-drained close of the engine - empties the slot. - // Holds NO logical slot lock here: close(true) unlinks the logical - // lock file (CursorSendEngine close javadoc). When the SF worker - // is wedged in a syscall the close defers flock release to the - // worker's exit path -- await it (bounded) rather than let step 6 - // throw SlotLockContentionException on the retained flock. + // step 3: fully-drained close of the engine - empties the slot + // and unlinks the parent-anchored logical slot lock. When the SF + // worker is wedged the close defers flock release to the worker's + // exit path; await it (bounded) before rebuilding on the slot. CursorSendEngine outgoing = cursorEngine; - outgoing.close(); cursorEngine = null; + connected = false; + outgoing.close(); awaitDeferredEngineClose(outgoing); // step 4: roll the external FSN base (-1 no-publish case adds 0) rollFsnEpochBase(lastPublishedFsn); - // step 5: producer state swap - replace, don't clear() - globalSymbolDictionary = new GlobalSymbolDictionary(); - sentMaxSymbolId = -1; - currentBatchMaxSymbolId = -1; - lastCommitBoundaryFsn = -1L; - symbolDictEpoch++; - symbolDictResetsPerformed++; - // C1 anti-thrash floor: see resetFloorSymbols. - resetFloorSymbols = Math.min(dictSizeAtSwap * 2, - QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE / 2); - resetArmed = false; - manualResetRequested = false; - // step 6: rebuild the engine on the now-empty slot + // step 5: rebuild the engine on the now-empty slot. cursorEngine = engineRebuildFactory.rebuild(); ownsCursorEngine = true; if (cursorEngine.wasRecoveredFromDisk()) { @@ -5067,12 +5068,34 @@ private void recycleForDictReset() { "symbol dictionary recycle rebuilt on a non-empty slot: " + "the outgoing engine's fully-drained close did not empty it"); } + // step 6: producer-side swap COMMIT - replace, don't clear(). + globalSymbolDictionary = new GlobalSymbolDictionary(); + sentMaxSymbolId = -1; + currentBatchMaxSymbolId = -1; + lastCommitBoundaryFsn = -1L; + symbolDictEpoch++; + symbolDictResetsPerformed++; + resetArmed = false; + manualResetRequested = false; + // C1 anti-thrash floor: see resetFloorSymbols. + resetFloorSymbols = Math.min(dictSizeAtSwap * 2, + QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE / 2); + // Deliberately re-derived (not carried over): the healing half + // of the recycle contract -- a sender that degraded to full + // frames heals back into delta mode once the underlying fault + // clears; a persistent fault just degrades the fresh engine + // again on its first append (SymbolDictRecycleHealingTest). deltaDictEnabled = cursorEngine.isDeltaDictEnabled(); cursorEngine.setSlotLockReleaseListener(this::onSlotLockReleased); // The fresh engine holds the slot flock again; step 3's release // flipped slotLockReleased true via the outgoing engine's // listener, which no longer describes this sender's state. slotLockReleased = false; + } catch (Error e) { + // OOM/SOE/linkage must reach the caller untouched (same + // convention as rethrowTerminal) and must not masquerade as a + // recycle verdict. + throw e; } catch (Throwable t) { // terminal latch - everything was acked before step 2, // so no data is at risk; the sender refuses further use. @@ -5084,24 +5107,14 @@ private void recycleForDictReset() { } throw new LineSenderException(t).put("symbol dictionary recycle failed"); } - // step 7: reconnect - rebuilds the loop with the rolled base, deferring - // the socket connect to the I/O thread (hasInitialConnectRun forces - // ensureConnected's ASYNC branch, and hasLoopEverConnected -- if this - // sender ever reached the server -- seeds the fresh loop's own - // hasEverConnected so Invariant B's classification survives the - // rebuild): the loop retries indefinitely with - // backoff while the producer keeps buffering into the fresh slot, so a - // transient outage in this window never surfaces to the producer and - // never imposes a reconnect budget on it (the store-and-forward - // contract). OUTSIDE the latching try on purpose: the swap has already - // committed, so the residual failures here (dispatcher construction, - // loop build/start -- environmental, not transport) leave a coherent - // fresh-epoch sender that is merely disconnected. They throw loudly - // but do NOT latch; the next sendRow() retries the deferred setup -- - // and ONLY that -- through ensureConnected. - connected = false; + // step 7: reconnect -- unchanged from head (outside the latch; the + // loop retries indefinitely on the I/O thread; a failed setup here + // leaves a coherent, merely-disconnected sender and is retried by + // the next sendRow()'s ensureConnected()). try { ensureConnected(); + } catch (Error e) { + throw e; } catch (Throwable t) { LOG.warn("symbol dictionary swap committed but starting its deferred reconnect " + "failed; sender stays disconnected on the fresh epoch and retries " @@ -5176,8 +5189,10 @@ private void advanceSentMaxSymbolId() { } /** - * Stops emitting delta dictionaries for the rest of this sender's life, after the - * per-slot {@code .symbol-dict} has proved unwritable. + * Stops emitting delta dictionaries for the rest of this epoch, after the + * per-slot {@code .symbol-dict} has proved unwritable -- a symbol-dictionary + * recycle re-derives {@code deltaDictEnabled} from the fresh engine (the + * healing contract; see {@link #recycleForDictReset()}). *

    * The side-file can stop accepting appends mid-run -- a full disk or an exhausted * quota, where SF's own segments stay writable because they are pre-allocated mmap @@ -5200,8 +5215,9 @@ private void disableDeltaDict(Throwable cause) { } deltaDictEnabled = false; LOG.warn("symbol dictionary persistence failed; this sender has switched to full " - + "self-sufficient frames for the rest of its life (bandwidth cost only -- " - + "no data is at risk, and recovery replays such frames without a side file)", + + "self-sufficient frames for the rest of this epoch (bandwidth cost only -- " + + "no data is at risk, and recovery replays such frames without a side file; " + + "a symbol dictionary recycle re-derives delta mode from the fresh engine)", cause); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java index 43a0b493..779c2443 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -99,7 +99,12 @@ public void testMetricsAfterTwoRecycles() throws Exception { sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); - Assert.assertTrue("armed again: c, d cross threshold=2 in the new epoch", + // The C1 anti-thrash floor (resetFloorSymbols = 2x the first swap's + // dictSizeAtSwap = 4) keeps c,d (2 symbols, == threshold but < floor) + // from re-arming on their own; a manual request bypasses the floor by + // design, so drive the second recycle through resetSymbolDictionary(). + sender.resetSymbolDictionary(); + Assert.assertTrue("manual reset request bypasses the C1 floor", ws.isResetArmed()); // Ring drained again -> second recycle: epoch 2. From b0a01269bf5a39570d7de93c3084cc45c180c5c1 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:33:38 +0100 Subject: [PATCH 36/49] Fix stale step-number docs left by the recycle reorder Task 5's reorder made the engine rebuild step 5 and the producer-side swap commit step 6 (after a successful rebuild), but several comments and javadocs still described the old order where the swap committed before the rebuild. Fixes getSymbolDictEpoch()/getSymbolDictResetsPerformed() javadocs (a rebuild failure now leaves the counters un-bumped, not incremented), a field comment, maybeRecycleForDictReset()'s NPE note, and SymbolDictRecycleHealingTest's class javadoc. Also restores two load-bearing notes the reorder's shorter step comments had dropped (lastRecycleDurableFsn is written before teardown for the monitoring accessors; hasInitialConnectRun forces the step-7 reconnect's ASYNC branch) and fixes a missing comma. --- .../qwp/client/QwpWebSocketSender.java | 38 +++++++++++-------- .../client/SymbolDictRecycleHealingTest.java | 2 +- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 61fed5bc..2150e204 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -518,7 +518,7 @@ public class QwpWebSocketSender implements Sender { // getSymbolDictEpoch()), and a monitoring thread is its obvious reader. private volatile long symbolDictEpoch; // Incremented once per completed symbol-dictionary recycle swap, beside - // symbolDictEpoch (recycleForDictReset step 5). The two move together + // symbolDictEpoch (recycleForDictReset step 6). The two move together // today -- the only way symbolDictEpoch advances is through a committed // recycle swap -- but they count different things (dictionary generation // vs. completed swaps) and are incremented independently in case a @@ -2234,11 +2234,12 @@ public long getFsnEpochBaseForTest() { /** * Number of symbol-dictionary recycles this sender has completed. Advances - * by one at step 5 of {@link #recycleForDictReset()}, the instant the swap - * commits to the new epoch -- before the engine rebuild (step 6) or the - * reconnect (step 7), so a later step-6 rebuild failure that latches - * {@link #recycleFailure}, and a step-7 reconnect failure that does not, - * both still leave this incremented. Unlike the per-send-loop + * by one at step 6 of {@link #recycleForDictReset()}, the instant the swap + * commits to the new epoch -- after the engine rebuild (step 5) has + * already succeeded, so a step-5 rebuild failure that latches + * {@link #recycleFailure} leaves this counter un-bumped, while a later + * step-7 reconnect failure (which cannot latch: the swap already + * committed by then) still leaves this incremented. Unlike the per-send-loop * {@code getTotal*} counters, it is scoped to the sender's whole lifetime * and never resets. volatile: a * concurrent read sees the latest write the producer thread completed, @@ -2254,10 +2255,11 @@ public long getSymbolDictEpoch() { /** * Number of symbol-dictionary recycle swaps this sender has completed. - * Incremented alongside {@link #getSymbolDictEpoch()} at step 5 of - * {@link #recycleForDictReset()} -- before the engine rebuild (step 6) or - * the reconnect (step 7), so, like the epoch counter, a later - * rebuild/reconnect failure still leaves this incremented, latched or not. + * Incremented alongside {@link #getSymbolDictEpoch()} at step 6 of + * {@link #recycleForDictReset()} -- after the engine rebuild (step 5) has + * already succeeded, so, like the epoch counter, a step-5 rebuild failure + * leaves this un-bumped while a step-7 reconnect failure still leaves it + * incremented (the swap has already committed by then). * Also like the epoch counter, it is scoped to the sender's whole lifetime * and never resets. The two counts move together today -- the * only way the epoch advances is through a completed recycle swap -- but @@ -4907,7 +4909,7 @@ private void maybeBlockForStarvedReset() { * {@code QwpWebSocketSender.connect(...)} overload leaves it null -- * only {@code Sender.build()} installs one -- and the recycle feature is * default-on, so a connect()-built sender must simply stay unarmed rather - * than NPE at step 6 and latch terminal), or a cursor engine this sender + * than NPE at step 5 and latch terminal), or a cursor engine this sender * does not own ({@code setCursorEngine(engine, false)}'s contract: the * caller retains ownership, so closing it out from under them at step 3 * would be a use-after-free from the caller's point of view). @@ -4987,7 +4989,7 @@ private void maybeRecycleForDictReset() { * * The producer-visible swap (dictionary, counters, epoch) commits only * once a fresh engine stands on the emptied slot: a throw in steps 2-6 - * is caught before that point leaves the counters un-bumped, latches + * is caught before that point, leaves the counters un-bumped, latches * {@link #recycleFailure}, and rethrows -- every frame that existed * before this call was already proven acked, so no data is at risk, but * the sender that made the throw observe a half-swapped engine/loop @@ -5030,6 +5032,8 @@ private void recycleForDictReset() { final int dictSizeAtSwap = globalSymbolDictionary.size(); final long startNanos = System.nanoTime(); if (lastPublishedFsn >= 0) { + // Written before teardown: the monitoring accessors keep + // reporting this durable watermark while cursorEngine is null. lastRecycleDurableFsn = fsnEpochBase + lastPublishedFsn; } try { @@ -5107,10 +5111,12 @@ private void recycleForDictReset() { } throw new LineSenderException(t).put("symbol dictionary recycle failed"); } - // step 7: reconnect -- unchanged from head (outside the latch; the - // loop retries indefinitely on the I/O thread; a failed setup here - // leaves a coherent, merely-disconnected sender and is retried by - // the next sendRow()'s ensureConnected()). + // step 7: reconnect (outside the latch; the swap has already + // committed). hasInitialConnectRun forces ensureConnected's ASYNC + // branch here, so the deferred socket connect never parks the + // producer thread; the loop retries indefinitely on the I/O thread, + // and a failed setup here leaves a coherent, merely-disconnected + // sender that is retried by the next sendRow()'s ensureConnected(). try { ensureConnected(); } catch (Error e) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java index 779c2443..0c264ec2 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -54,7 +54,7 @@ * full self-sufficient frames ({@code QwpWebSocketSender.disableDeltaDict}, e.g. after a * recognised mmap access fault on the persisted dictionary -- see {@link MmapFaultDegradesTest}) * is not stuck there forever. {@code recycleForDictReset()} rebuilds the cursor engine from - * scratch (step 6), and a fresh engine re-derives {@code deltaDictEnabled} independently of + * scratch (step 5), and a fresh engine re-derives {@code deltaDictEnabled} independently of * whatever the outgoing epoch's engine decided -- so once the underlying fault clears, the * next recycle heals the sender back into delta mode. If the fault has not cleared, the fresh * engine simply degrades again on its own first append: a normal, catchable From dc990429523c316dfd0720279d1d5380c407b349 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:06:16 +0100 Subject: [PATCH 37/49] Make the recycle resumable instead of latching terminal Review r3, C2: transient failures in the recycle (a wedged SF worker, an interrupted producer thread, a momentary EMFILE at rebuild, a post-cleanup fsync warning) latched a healthy sender terminal although zero data was at risk. The recycle now records a two-state resume point (CLOSE_LOOP / REBUILD) and the next send finishes the swap; the commit refuses while a row is in progress so old-dictionary symbol ids can never cross the swap. The terminal latch survives for exactly one case: a rebuilt engine that recovered UNACKED frames, which proves the outgoing close's fully-drained contract was breached. Benign recovered leftovers (a transiently failed segment unlink) heal by closing the recovered engine (which retries the unlink by design) and rebuilding. --- .../qwp/client/QwpWebSocketSender.java | 436 +++++++++++++----- .../SymbolDictRecycleDeferredCloseTest.java | 87 ++++ .../qwp/client/SymbolDictRecycleTest.java | 121 +++-- 3 files changed, 475 insertions(+), 169 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 2150e204..a4d90542 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -497,16 +497,29 @@ public class QwpWebSocketSender implements Sender { // RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS); non-final only so tests can // shrink it to drive the timeout branch. private long recycleDeferredCloseMaxWaitMillis = RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS; - // Set (once) by recycleForDictReset's catch block when the recycle swap - // itself fails in steps 2-6 -- everything was acked before the swap tore - // the old engine down, so no data is at risk, but this sender can no - // longer make progress (no cursor engine, no I/O loop) and refuses further - // use. checkRecycleFailure() rethrows a fresh LineSenderException wrapping - // this cause on every later table()/flush-family call; close() still - // works normally. A step-7 (reconnect) failure deliberately does NOT set - // this: the swap has already committed there, so the sender is coherent - // and merely disconnected, and the next send retries the connect. + // Set (once) by completeRecycleRebuild when a rebuilt engine recovered + // UNACKED frames from the slot the outgoing close was supposed to have + // emptied -- the one failure that proves the fully-drained close contract + // was breached, so the producer's fresh dictionary and the slot's on-disk + // state have provably diverged and this sender refuses further use. + // checkRecycleFailure() rethrows a fresh LineSenderException wrapping this + // cause on every later table()/flush-family call; close() still works + // normally. Every other recycle failure is transient and resumable (see + // recycleResume), never latched here. private Throwable recycleFailure; + // Resumable recycle (review r3, C2): a transient failure mid-recycle no + // longer latches the sender terminal. CLOSE_LOOP = step 2 failed, the + // old loop is still dying and the old engine/dictionary are intact. + // REBUILD = the old engine is closed (possibly still releasing its slot + // flock); await/rebuild/commit are pending. resumeRecycleIfPending() + // advances the state from the table() barrier and ensureConnected(). + private RecycleResume recycleResume = RecycleResume.NONE; + // The closed-but-not-yet-released outgoing engine a REBUILD resume still + // awaits; null once its deferred close completes. + private CursorSendEngine recyclePendingOutgoing; + // Raw last-published FSN of the outgoing epoch (step-1 snapshot), + // consumed by the commit when a REBUILD resume completes. + private long recyclePendingLastPublishedFsn = -1L; // Test seam: recycle step-7 fault injection. When set, runs (and is // expected to throw) inside ensureConnected()'s loop-construction try, // after cursorSendLoop is assigned but before start() -- exercising the @@ -547,7 +560,10 @@ public class QwpWebSocketSender implements Sender { // Lifetime-monotonic in delta mode -- it is NOT reset on reconnect, because // the I/O thread re-registers the full dictionary via a catch-up frame before // replaying, so the producer's delta baseline stays valid across the wire - // boundary. Used only when deltaDictEnabled; ignored in full-dict mode. + // boundary. It drops back to -1 only where the LOOP ITSELF is replaced and + // its catch-up mirror dies with it: the recycle's swap commit, and the + // CLOSE_LOOP resume (see resumeRecycleIfPending). Used only when + // deltaDictEnabled; ignored in full-dict mode. private int sentMaxSymbolId = -1; // When true, auto-flush sends messages with FLAG_DEFER_COMMIT and only // explicit flush() triggers the server-side commit. Enables accumulating @@ -2081,6 +2097,16 @@ public int getEffectiveAutoFlushBytes() { return effectiveAutoFlushBytes; } + /** + * The installed engine-rebuild factory, so a test can wrap the real one + * (e.g. fault-inject the first rebuild and delegate afterwards) instead of + * replacing it outright. {@code null} for a {@code connect()}-built sender. + */ + @TestOnly + public EngineRebuildFactory getEngineRebuildFactoryForTesting() { + return engineRebuildFactory; + } + /** * Snapshot of the typed payload for the latched terminal server-rejection error, * or {@code null} if the I/O loop has not latched a server-rejection terminal @@ -3148,7 +3174,9 @@ public QwpWebSocketSender symbol(CharSequence columnName, CharSequence value) { public QwpWebSocketSender table(CharSequence tableName) { checkNotClosed(); checkRecycleFailure(); - if (resetArmed) { + if (recycleResume != RecycleResume.NONE) { + resumeRecycleIfPending(); + } else if (resetArmed) { maybeRecycleForDictReset(); } // Fast path: if table name matches current, skip hashmap lookup @@ -3854,13 +3882,18 @@ private void checkNotClosed() { } /** - * Terminal latch for a symbol-dictionary recycle swap that failed in steps - * 2-6 ({@link #recycleForDictReset()}). Everything was acked before the - * swap tore the old engine down, so no data is at risk -- but the swap - * itself left this sender without a cursor engine or I/O loop, so it - * refuses further use. A step-7 reconnect failure is NOT latched (the swap - * has committed by then and the sender is coherent, just disconnected -- - * see {@link #recycleForDictReset()}). Checked by + * Terminal latch for the one symbol-dictionary recycle failure that is not + * resumable: a rebuilt engine that recovered UNACKED frames from the slot + * the outgoing engine's fully-drained close was supposed to have emptied + * (see {@link #completeRecycleRebuild}). That proves the everything-acked + * barrier the swap rests on was breached, so the producer's fresh + * dictionary and the slot's on-disk state have diverged and this sender + * refuses further use. Every OTHER recycle failure -- a wedged SF worker, + * an interrupted producer thread, a momentary rebuild fault, a + * post-cleanup fsync warning, a failed step-7 reconnect -- is transient: + * it throws to the triggering caller, leaves the counters un-bumped and + * the recycle pending ({@link #recycleResume}), and the next send + * finishes the swap. Checked by * {@link #table(CharSequence)}, the flush-family * entry points ({@link #flush()}, {@link #flushAndGetSequence()}, * {@link #drain(long)}, {@link #awaitAckedFsn(long, long)}), and @@ -4193,6 +4226,7 @@ private void ensureActiveBufferReady() { private void ensureConnected() { checkNotClosed(); + resumeRecycleIfPending(); if (connected) { return; } @@ -4802,10 +4836,10 @@ private void armIfEligible() { * shared flock-release retry driver for the close-ran-but-release-failed * case, mirroring {@link #isSlotLockReleased()}'s re-probe. *

    - * Exhausting {@link #recycleDeferredCloseMaxWaitMillis} means the worker - * is genuinely dead (not stalled), so throw -- the recycle's catch then - * latches the sender terminal. Before throwing, hand the still-locked - * engine to {@link #retainedEngine} so a pool re-probe + * Exhausting {@link #recycleDeferredCloseMaxWaitMillis} throws; the + * recycle stays pending ({@link RecycleResume#REBUILD}) and the next send + * retries the await. Before throwing, hand the still-locked engine to + * {@link #retainedEngine} so a pool re-probe * ({@link #isSlotLockReleased()}) can still recover the slot's capacity * if the worker ever exits. */ @@ -4821,16 +4855,133 @@ private void awaitDeferredEngineClose(CursorSendEngine outgoing) { if (System.nanoTime() >= deadlineNanos) { retainedEngine = outgoing; slotLockReleased = false; - throw new LineSenderException("symbol dictionary recycle could not reclaim its " - + "slot: the outgoing engine's deferred close did not release the slot " - + "lock within " + recycleDeferredCloseMaxWaitMillis - + " ms (SF worker wedged)"); + throw new LineSenderException("symbol dictionary recycle could not yet reclaim " + + "its slot: the outgoing engine's deferred close did not release the " + + "slot lock within " + recycleDeferredCloseMaxWaitMillis + + " ms (SF worker stalled); the recycle stays pending and is retried " + + "on the next send"); } outgoing.ensureFlockReleaseRetryScheduled(); java.util.concurrent.locks.LockSupport.parkNanos(50_000L); } } + private static void closeQuietly(CursorSendEngine engine) { + try { + engine.close(); + } catch (Throwable ignored) { + // best-effort; the retained files are the next rebuild's problem + } + } + + /** + * The recycle's tail: await the outgoing engine's (possibly deferred) + * close, rebuild a fresh engine on the emptied slot, and only then + * commit the swap -- roll the FSN base, install the fresh dictionary, + * advance the counters, wire the engine, reconnect. Every phase before + * the commit is idempotent, so both {@link #recycleForDictReset()} and a + * REBUILD resume run this; a transient throw leaves + * {@code recycleResume == REBUILD} for the next attempt. Only a rebuild + * that recovered UNACKED frames -- a genuine breach of the barrier's + * everything-acked proof -- latches {@link #recycleFailure}. + */ + private void completeRecycleRebuild(int dictSizeAtSwap, long startNanos) { + CursorSendEngine outgoing = recyclePendingOutgoing; + if (outgoing != null) { + awaitDeferredEngineClose(outgoing); // throws transient while wedged + recyclePendingOutgoing = null; + retainedEngine = null; + } + // step 5: rebuild the engine on the now-empty slot. + CursorSendEngine rebuilt = rebuildEngineOrAbandon( + "symbol dictionary recycle could not rebuild its engine; retried on the next send"); + if (rebuilt.wasRecoveredFromDisk()) { + // The outgoing close's empties-the-slot contract can miss + // benignly: a transiently failed segment unlink retains the ack + // watermark, and the SF design is that the NEXT engine on the + // slot recovers those segments as fully acked and retries the + // unlink on its own close. Heal by doing exactly that. Only a + // recovery holding UNACKED frames is a genuine breach: latch. + if (rebuilt.publishedFsn() > rebuilt.ackedFsn()) { + LineSenderException breach = new LineSenderException( + "symbol dictionary recycle rebuilt on a slot holding unacknowledged " + + "frames: the outgoing engine's fully-drained close contract " + + "was breached"); + recycleFailure = breach; + recycleResume = RecycleResume.NONE; + closeQuietly(rebuilt); + LOG.error("symbol dictionary recycle failed; sender is now terminal " + + "[epoch={}, dictSizeAtSwap={}]", symbolDictEpoch, dictSizeAtSwap, breach); + throw breach; + } + closeQuietly(rebuilt); // fully drained: retries the segment unlink + rebuilt = rebuildEngineOrAbandon( + "symbol dictionary recycle could not rebuild its engine after healing " + + "leftover acked segments; retried on the next send"); + if (rebuilt.wasRecoveredFromDisk()) { + closeQuietly(rebuilt); + throw new LineSenderException( + "symbol dictionary recycle keeps recovering leftover acked segments " + + "(slot cleanup not durable yet); retried on the next send"); + } + } + // COMMIT (steps 4 + 6): pure producer-side state, and nothing below + // can throw. Step 4 rolls the external FSN base past every FSN the + // outgoing epoch handed out (the -1 no-publish case adds 0); it must + // run with cursorSendLoop == null, which step 2 guarantees and the + // step-7 reconnect below only undoes afterwards. + rollFsnEpochBase(recyclePendingLastPublishedFsn); + // Replace the dictionary, don't clear(). + globalSymbolDictionary = new GlobalSymbolDictionary(); + sentMaxSymbolId = -1; + currentBatchMaxSymbolId = -1; + lastCommitBoundaryFsn = -1L; + symbolDictEpoch++; + symbolDictResetsPerformed++; + resetArmed = false; + manualResetRequested = false; + // C1 anti-thrash floor: see resetFloorSymbols. + resetFloorSymbols = Math.min(dictSizeAtSwap * 2, + QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE / 2); + // Deliberately re-derived (not carried over): the healing half + // of the recycle contract -- a sender that degraded to full + // frames heals back into delta mode once the underlying fault + // clears; a persistent fault just degrades the fresh engine + // again on its first append (SymbolDictRecycleHealingTest). + deltaDictEnabled = rebuilt.isDeltaDictEnabled(); + cursorEngine = rebuilt; + ownsCursorEngine = true; + cursorEngine.setSlotLockReleaseListener(this::onSlotLockReleased); + // The fresh engine holds the slot flock again; step 3's release + // flipped slotLockReleased true via the outgoing engine's + // listener, which no longer describes this sender's state. + slotLockReleased = false; + recycleResume = RecycleResume.NONE; + recyclePendingLastPublishedFsn = -1L; + // step 7: reconnect (the swap has already committed). + // hasInitialConnectRun forces ensureConnected's ASYNC branch here, so + // the deferred socket connect never parks the producer thread; the + // loop retries indefinitely on the I/O thread, and a failed setup + // here leaves a coherent, merely-disconnected sender that is retried + // by the next sendRow()'s ensureConnected(). + try { + ensureConnected(); + } catch (Error e) { + throw e; + } catch (Throwable t) { + LOG.warn("symbol dictionary swap committed but starting its deferred reconnect " + + "failed; sender stays disconnected on the fresh epoch and retries " + + "the setup on the next send [epoch={}, dictSizeAtSwap={}]", + symbolDictEpoch, dictSizeAtSwap, t); + if (t instanceof LineSenderException) { + throw (LineSenderException) t; + } + throw new LineSenderException(t).put("symbol dictionary recycle reconnect failed"); + } + LOG.info("symbol dictionary recycled [epoch={}, dictSizeAtSwap={}, pauseMicros={}]", + symbolDictEpoch, dictSizeAtSwap, (System.nanoTime() - startNanos) / 1000L); + } + /** * True once every FSN this engine has published has also been * server-acknowledged (or nothing has been published yet). The barrier @@ -4938,6 +5089,16 @@ private void maybeRecycleForDictReset() { } } + private CursorSendEngine rebuildEngineOrAbandon(String message) { + try { + return engineRebuildFactory.rebuild(); + } catch (Error e) { + throw e; + } catch (Throwable t) { + throw new LineSenderException(t).put(message); + } + } + /** * The symbol-dictionary recycle swap. Runs synchronously on the producer * thread from the {@link #table(CharSequence)} barrier, once @@ -4984,36 +5145,33 @@ private void maybeRecycleForDictReset() { * engine. *

  • Reconnect: {@link #ensureConnected()} builds a fresh I/O loop * against the rolled {@link #fsnEpochBase} and defers the socket - * connect itself to the I/O thread. Runs OUTSIDE the latching - * try -- see below.
  • + * connect itself to the I/O thread. * - * The producer-visible swap (dictionary, counters, epoch) commits only - * once a fresh engine stands on the emptied slot: a throw in steps 2-6 - * is caught before that point, leaves the counters un-bumped, latches - * {@link #recycleFailure}, and rethrows -- every frame that existed - * before this call was already proven acked, so no data is at risk, but - * the sender that made the throw observe a half-swapped engine/loop - * refuses further use from here on -- {@link #checkRecycleFailure()} - * enforces that at every later {@link #table(CharSequence)} and - * flush-family call. An {@link Error} (OOM/SOE/linkage) passes through - * this catch untouched, neither latched nor wrapped -- it is not a - * recycle verdict. (Step 1 is a pair of plain reads and runs before the - * latching try.) + * This method runs steps 1-3 and hands steps 4-7 to + * {@link #completeRecycleRebuild(int, long)}. The producer-visible swap + * (dictionary, counters, epoch) commits only once a fresh engine stands on + * the emptied slot, and a throw before that point no longer kills the + * sender: every frame that existed before this call was already proven + * acked, so nothing is at risk, and the recycle simply records how far it + * got ({@link #recycleResume}) and resumes from the next + * {@link #table(CharSequence)} or {@link #ensureConnected()} -- see + * {@link #resumeRecycleIfPending()}. An {@link Error} (OOM/SOE/linkage) + * passes through untouched, neither recorded nor wrapped -- it is not a + * recycle verdict. *

    * Step 3 mirrors {@code close()}'s deferred-close discipline: when the * outgoing engine's close could not confirm SF-worker quiescence it * returns with the slot flock retained and {@code isCloseCompleted()} - * false, releasing both from the worker's exit path. Step 3 then awaits + * false, releasing both from the worker's exit path. The tail then awaits * that deferred release (bounded by * {@link #RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS}) before step 5 rebuilds * on the slot -- rebuilding against the retained flock would throw - * {@code SlotLockContentionException} and needlessly latch the sender - * terminal for what is usually a transient disk stall. Only exhausting - * the await budget (a genuinely dead worker) latches terminal. + * {@code SlotLockContentionException} for what is usually a transient disk + * stall. *

    - * Step 7 is deliberately exempt from that latch. By then the swap has - * committed (step 6), so a failed step-7 setup (dispatcher construction, - * loop build/start -- environmental, since the socket connect itself is + * Step 7's failure mode is unchanged: by then the swap has committed + * (step 6), so a failed step-7 setup (dispatcher construction, loop + * build/start -- environmental, since the socket connect itself is * deferred to the I/O thread) leaves a fully coherent sender that is * merely disconnected: {@code connected == false}, loop and client already * closed and nulled by {@link #ensureConnected()}'s own catch, the fresh @@ -5036,8 +5194,8 @@ private void recycleForDictReset() { // reporting this durable watermark while cursorEngine is null. lastRecycleDurableFsn = fsnEpochBase + lastPublishedFsn; } + // step 2: close the loop - joins the I/O thread, closes the client. try { - // step 2: close the loop - joins the I/O thread, closes the client. if (cursorSendLoop != null) { cursorSendLoop.close(); // Read the sticky AFTER close(): close joins the I/O thread, @@ -5048,91 +5206,112 @@ private void recycleForDictReset() { cursorSendLoop = null; } client = null; - // step 3: fully-drained close of the engine - empties the slot - // and unlinks the parent-anchored logical slot lock. When the SF - // worker is wedged the close defers flock release to the worker's - // exit path; await it (bounded) before rebuilding on the slot. - CursorSendEngine outgoing = cursorEngine; - cursorEngine = null; - connected = false; - outgoing.close(); - awaitDeferredEngineClose(outgoing); - // step 4: roll the external FSN base (-1 no-publish case adds 0) - rollFsnEpochBase(lastPublishedFsn); - // step 5: rebuild the engine on the now-empty slot. - cursorEngine = engineRebuildFactory.rebuild(); - ownsCursorEngine = true; - if (cursorEngine.wasRecoveredFromDisk()) { - // close(true) above emptied the slot, so a rebuild that found - // a persisted dictionary to recover from means the outgoing - // close's empties-the-slot contract was breached -- the fresh - // producer dictionary and the slot's on-disk state have - // diverged, so refuse to run on it. - throw new LineSenderException( - "symbol dictionary recycle rebuilt on a non-empty slot: " - + "the outgoing engine's fully-drained close did not empty it"); - } - // step 6: producer-side swap COMMIT - replace, don't clear(). - globalSymbolDictionary = new GlobalSymbolDictionary(); - sentMaxSymbolId = -1; - currentBatchMaxSymbolId = -1; - lastCommitBoundaryFsn = -1L; - symbolDictEpoch++; - symbolDictResetsPerformed++; - resetArmed = false; - manualResetRequested = false; - // C1 anti-thrash floor: see resetFloorSymbols. - resetFloorSymbols = Math.min(dictSizeAtSwap * 2, - QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE / 2); - // Deliberately re-derived (not carried over): the healing half - // of the recycle contract -- a sender that degraded to full - // frames heals back into delta mode once the underlying fault - // clears; a persistent fault just degrades the fresh engine - // again on its first append (SymbolDictRecycleHealingTest). - deltaDictEnabled = cursorEngine.isDeltaDictEnabled(); - cursorEngine.setSlotLockReleaseListener(this::onSlotLockReleased); - // The fresh engine holds the slot flock again; step 3's release - // flipped slotLockReleased true via the outgoing engine's - // listener, which no longer describes this sender's state. - slotLockReleased = false; } catch (Error e) { - // OOM/SOE/linkage must reach the caller untouched (same - // convention as rethrowTerminal) and must not masquerade as a - // recycle verdict. throw e; } catch (Throwable t) { - // terminal latch - everything was acked before step 2, - // so no data is at risk; the sender refuses further use. - recycleFailure = t; - LOG.error("symbol dictionary recycle failed; sender is now terminal " - + "[epoch={}, dictSizeAtSwap={}]", symbolDictEpoch, dictSizeAtSwap, t); - if (t instanceof LineSenderException) { - throw (LineSenderException) t; - } - throw new LineSenderException(t).put("symbol dictionary recycle failed"); - } - // step 7: reconnect (outside the latch; the swap has already - // committed). hasInitialConnectRun forces ensureConnected's ASYNC - // branch here, so the deferred socket connect never parks the - // producer thread; the loop retries indefinitely on the I/O thread, - // and a failed setup here leaves a coherent, merely-disconnected - // sender that is retried by the next sendRow()'s ensureConnected(). + // close() set the loop's stop flag before throwing, so the loop + // is irreversibly dying but its I/O thread may still own the + // engine. Neither proceed with the swap nor claim connectivity; + // abandon, and let resumeRecycleIfPending() finish the close + // (a repeated close() converges once the I/O thread exits). + connected = false; + recycleResume = RecycleResume.CLOSE_LOOP; + LOG.warn("symbol dictionary recycle abandoned: closing the outgoing I/O loop " + + "failed; the close is finished on the next send [epoch={}]", + symbolDictEpoch, t); + rethrowRecycleAbandoned(t, "symbol dictionary recycle abandoned while closing " + + "the outgoing I/O loop; retried on the next send"); + } + // step 3: fully-drained close of the engine - empties the slot and + // unlinks the parent-anchored logical slot lock. From here the old + // engine cannot come back, so record the REBUILD resume point BEFORE + // anything that can throw. + CursorSendEngine outgoing = cursorEngine; + cursorEngine = null; + connected = false; + recycleResume = RecycleResume.REBUILD; + recyclePendingOutgoing = outgoing; + recyclePendingLastPublishedFsn = lastPublishedFsn; try { - ensureConnected(); + outgoing.close(); } catch (Error e) { throw e; } catch (Throwable t) { - LOG.warn("symbol dictionary swap committed but starting its deferred reconnect " - + "failed; sender stays disconnected on the fresh epoch and retries " - + "the setup on the next send [epoch={}, dictSizeAtSwap={}]", - symbolDictEpoch, dictSizeAtSwap, t); - if (t instanceof LineSenderException) { - throw (LineSenderException) t; + // A throw with the terminal cleanup nevertheless completed (the + // post-cleanup fsyncDir durability warning, C2(b)) is not a swap + // failure -- finishClose's finally released the slot regardless. + // awaitDeferredEngineClose() below tells the two apart: it + // returns immediately when isCloseCompleted(), parks while the + // deferred close is in flight, and throws only on a genuinely + // dead worker. + LOG.warn("outgoing engine close reported a failure during the symbol dictionary " + + "recycle; deferring to the close-completion probe", t); + } + completeRecycleRebuild(dictSizeAtSwap, startNanos); + } + + /** + * Advances an abandoned recycle. CLOSE_LOOP finishes killing the old + * loop (no swap -- the old engine and dictionary are intact and the + * armed recycle re-fires from a later barrier, once the reconnect the + * next send drives has restored {@code connected}); it drops the + * producer's delta baseline with the dead loop's catch-up mirror, see + * below. REBUILD completes the await/rebuild/commit tail; because the + * commit swaps the dictionary, it refuses while producer state could + * carry old-dictionary symbol ids (staged rows or a row in progress) -- + * the caller's row fails, rolls back, and the next table() resumes + * cleanly. + */ + private void resumeRecycleIfPending() { + if (recycleResume == RecycleResume.NONE) { + return; + } + if (recycleResume == RecycleResume.CLOSE_LOOP) { + try { + cursorSendLoop.close(); // re-signals; converges once the I/O thread exits + } catch (Error e) { + throw e; + } catch (Throwable t) { + rethrowRecycleAbandoned(t, "the outgoing I/O loop is still stopping; " + + "retried on the next send"); } - throw new LineSenderException(t).put("symbol dictionary recycle reconnect failed"); + hasLoopEverConnected |= cursorSendLoop.hasEverConnected(); + cursorSendLoop = null; + client = null; + // The dead loop took its catch-up mirror with it, and the fresh + // loop ensureConnected() builds next seeds that mirror only from a + // RECOVERED persisted dictionary -- this engine is live, so the + // fresh loop starts at sentDictCount == 0. sentMaxSymbolId is the + // producer's model of the same number (it normally survives a + // reconnect precisely because the SAME loop re-registers from its + // mirror), so it has to drop with the mirror; otherwise the next + // frame's delta starts above the new loop's coverage and trips its + // torn-dictionary guard. Nothing is invalidated by the drop: the + // barrier proved the ring drained before step 2, and every publish + // path runs ensureConnected() -- hence this resume -- first, so no + // frame referencing those ids can be waiting to replay. + sentMaxSymbolId = -1; + recycleResume = RecycleResume.NONE; + return; } - LOG.info("symbol dictionary recycled [epoch={}, dictSizeAtSwap={}, pauseMicros={}]", - symbolDictEpoch, dictSizeAtSwap, (System.nanoTime() - startNanos) / 1000L); + // REBUILD + if (pendingRowCount != 0 + || (currentTableBuffer != null && currentTableBuffer.hasInProgressRow())) { + throw new LineSenderException( + "a symbol dictionary recycle is completing; finish or cancel the " + + "in-progress row and retry"); + } + completeRecycleRebuild(globalSymbolDictionary.size(), System.nanoTime()); + } + + private void rethrowRecycleAbandoned(Throwable t, String message) { + if (t instanceof Error) { + throw (Error) t; + } + if (t instanceof LineSenderException) { + throw (LineSenderException) t; + } + throw new LineSenderException(t).put(message); } /** @@ -5933,6 +6112,15 @@ public interface EngineRebuildFactory { CursorSendEngine rebuild(); } + /** + * How far an abandoned symbol-dictionary recycle got, and therefore what + * {@link #resumeRecycleIfPending()} must still do. See + * {@link #recycleResume}. + */ + private enum RecycleResume { + NONE, CLOSE_LOOP, REBUILD + } + private final class ReconnectSupplier implements CursorWebSocketSendLoop.ReconnectFactory { /** * Optional caller-owned liveness gate. {@code null} means this factory diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java index 4b3d2962..34736ba5 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java @@ -273,6 +273,93 @@ public void testRecycleLatchesTerminalWhenDeferredCloseNeverReleases() throws Ex }); } + /** + * The await budget runs out while the worker is still wedged, but the + * wedge is transient after all. Exhausting the budget must NOT latch the + * sender terminal (review r3, C2): the recycle stays pending in its + * REBUILD resume state, and once the worker exits and the deferred close + * releases the flock, the next send finishes the await, rebuilds and + * commits the swap. + */ + @Test(timeout = 60_000L) + public void testExhaustedDeferredCloseAwaitResumesOnNextSend() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("recycle-deferred-resume").toString(); + try (TestWebSocketServer server = ackingServer()) { + String cfg = "ws::addr=localhost:" + server.getPort() + ";sf_dir=" + sfDir + ";"; + CountDownLatch workerBlocked = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + AtomicBoolean wedgeFired = new AtomicBoolean(); + AtomicReference auxErr = new AtomicReference<>(); + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + + CursorSendEngine outgoing = ws.getCursorEngineForTesting(); + SegmentManager manager = outgoing.getManagerForTesting(); + try { + manager.setBeforeTrimSyncHook(() -> { + if (!wedgeFired.compareAndSet(false, true)) { + return; + } + workerBlocked.countDown(); + try { + if (!releaseWorker.await(30, TimeUnit.SECONDS)) { + auxErr.compareAndSet(null, new AssertionError( + "timed out waiting for the test to release the worker")); + } + } catch (Throwable t) { + auxErr.compareAndSet(null, t); + } + }); + manager.wakeWorker(); + Assert.assertTrue("worker never reached the wedge hook", + workerBlocked.await(5, TimeUnit.SECONDS)); + manager.setWorkerJoinTimeoutMillis(50L); + ws.setRecycleDeferredCloseMaxWaitMillisForTesting(100L); + + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + + // With the worker wedged past the tiny await budget, the + // triggering call must abandon -- not latch. + try { + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + Assert.fail("expected the exhausted deferred-close await to surface"); + } catch (LineSenderException expected) { + } + Assert.assertEquals("swap must not have committed", 0, ws.getSymbolDictEpoch()); + releaseWorker.countDown(); + // Wait for the worker to exit and release the flock, then the + // next call resumes and completes the recycle. + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (!outgoing.isCloseCompleted() && System.nanoTime() < deadlineNanos) { + Thread.sleep(10L); + } + Assert.assertTrue("deferred cleanup did not complete after the release", + outgoing.isCloseCompleted()); + sender.table("t").symbol("s", "c").longColumn("v", 3L).atNow(); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); + + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-resume batch must still get acked", + sender.awaitAckedFsn(fsn2, 5_000)); + } finally { + manager.setBeforeTrimSyncHook(null); + releaseWorker.countDown(); + } + } + if (auxErr.get() != null) { + throw new AssertionError("auxiliary thread failed", auxErr.get()); + } + } + }); + } + private static TestWebSocketServer ackingServer() throws Exception { TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); server.start(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index ce833ce6..ad6d8924 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -355,56 +355,103 @@ public void testFactoryRebuildsOnEmptySlot() throws Exception { } /** - * A step-6 rebuild failure (the {@link QwpWebSocketSender.EngineRebuildFactory} - * itself throwing) must latch the sender terminal rather than leaving it in - * the torn-down state the swap's earlier steps produced. Uses - * {@code setEngineRebuildFactory} directly to inject the fault -- the real - * factory has no seam for a custom {@code FilesFacade} (it always goes - * through {@code LineSenderBuilder.constructEngineOnSlot} against the real - * filesystem), and this isolates the exception-path behaviour under test - * from any particular failure cause. + * A transient engine-rebuild failure must NOT latch the sender terminal: + * the recycle is abandoned before the swap commits and resumes on the + * next send. Replaces testFailedRebuildLatchesTerminal (review r3, C2(d): + * build() has a retry-and-quarantine loop for exactly these operational + * failures; killing a healthy sender on a provably empty slot mid-life + * was strictly worse than the build()-time behavior). */ @Test - public void testFailedRebuildLatchesTerminal() throws Exception { + public void testFailedRebuildAbandonsAndRecovers() throws Exception { assertMemoryLeak(() -> { try (TestWebSocketServer server = ackingServer()) { try (Sender sender = Sender.fromConfig(cfg(server))) { QwpWebSocketSender ws = (QwpWebSocketSender) sender; - RuntimeException fault = new RuntimeException("injected engine rebuild fault"); + QwpWebSocketSender.EngineRebuildFactory real = + ws.getEngineRebuildFactoryForTesting(); + AtomicInteger remainingFaults = new AtomicInteger(1); ws.setEngineRebuildFactory(() -> { - throw fault; + if (remainingFaults.getAndDecrement() > 0) { + throw new RuntimeException("injected engine rebuild fault"); + } + return real.rebuild(); }); - // pendingRowCount == 0 -> resetSymbolDictionary() arms immediately. sender.resetSymbolDictionary(); Assert.assertTrue(ws.isResetArmed()); - - LineSenderException triggering = null; try { sender.table("t"); Assert.fail("expected the triggering table() call to throw"); - } catch (LineSenderException e) { - triggering = e; + } catch (LineSenderException expected) { } - Assert.assertNotNull(triggering); - Assert.assertSame("the latched failure must be the exact rebuild fault", - fault, triggering.getCause()); - - // Every subsequent table()/flush-family call must rethrow -- - // never touch the torn-down (null cursorEngine/cursorSendLoop) state. - assertRethrowsWithCause(fault, () -> sender.table("t")); - assertRethrowsWithCause(fault, sender::flush); - assertRethrowsWithCause(fault, sender::flushAndGetSequence); - assertRethrowsWithCause(fault, () -> sender.drain(0)); - assertRethrowsWithCause(fault, () -> sender.awaitAckedFsn(0, 0)); - - // close() must still work despite the latched terminal failure. + // NOT latched, and the swap did NOT commit. + Assert.assertEquals(0, ws.getSymbolDictEpoch()); + Assert.assertEquals(0, ws.getSymbolDictResetsPerformed()); + // The next call resumes the pending recycle with the real + // factory and completes it. + sender.table("t").symbol("s", "post").longColumn("v", 1L).atNow(); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); + long f = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(f, 5_000)); sender.close(); } } }); } + /** + * A producer thread whose interrupt flag is already set makes step 2's + * loop close throw deterministically (CountDownLatch.await throws on + * entry). That must abandon the recycle non-terminally; once the flag is + * cleared the next call finishes the loop close and the sender recovers. + * Review r3, C2(c). + */ + @Test + public void testInterruptedRecycleAbandonsAndRecovers() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(cfg(server))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + long f1 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(f1, 5_000)); + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + + Thread.currentThread().interrupt(); + boolean threw = false; + try { + sender.table("t"); + } catch (LineSenderException expected) { + threw = true; + } + // close() re-asserts the flag on the abandon path; clear it + // for the recovery half of the test. + boolean flagWasPreserved = Thread.interrupted(); + if (threw) { + Assert.assertTrue("the failed-stop protocol re-asserts the flag", + flagWasPreserved); + } + // Whether the close raced past the interrupt or abandoned, + // the sender must never be terminal and must finish the + // recycle on subsequent sends. A CLOSE_LOOP abandon leaves + // the recycle armed but NOT yet run, and the barrier only + // recycles at a drained instant with nothing staged -- so + // flush the recovery row before the barrier that must swap. + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + long f2 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(f2, 5_000)); + sender.table("t"); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); + sender.table("t").symbol("s", "c").longColumn("v", 3L).atNow(); + long f3 = sender.flushAndGetSequence(); + Assert.assertTrue(sender.awaitAckedFsn(f3, 5_000)); + } + } + }); + } + /** * A live symbol set larger than the threshold must not thrash the * recycle: after a swap, re-arming requires the dictionary to reach @@ -479,17 +526,6 @@ private static List listDir(String dir) { return names; } - private static void assertRethrowsWithCause(Throwable expectedCause, ThrowingRunnable action) - throws Exception { - try { - action.run(); - Assert.fail("expected a latched LineSenderException to rethrow"); - } catch (LineSenderException e) { - Assert.assertSame("a latched terminal sender must keep rethrowing the same cause", - expectedCause, e.getCause()); - } - } - private static String cfg(TestWebSocketServer server) { return "ws::addr=localhost:" + server.getPort() + ";"; } @@ -517,11 +553,6 @@ private static byte[] buildOkFrame(String tableName, long wireSeq, long seqTxn) return bb.array(); } - @FunctionalInterface - private interface ThrowingRunnable { - void run() throws Exception; - } - /** ACKs every frame it receives; does not otherwise inspect the wire. */ private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { private final AtomicLong nextSeq = new AtomicLong(0); From 2ae927d4fdcf5414fdbc58cc5a597d17e3bccebe Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:44:32 +0100 Subject: [PATCH 38/49] Pin the recycle's slot-heal and breach verdicts Adds engine-level doctored-slot tests for the two recovered-rebuild verdicts (review r3, C2(a)): fully-acked leftovers heal via a second rebuild; unacked leftovers latch terminal. Also gives the deferred-close test a positive parked witness instead of a 1.5s sleep (M15), so it can no longer silently skip the code under test. Carries four riders from the previous round's review: rethrowRecycleAbandoned and the extracted latchRecycleBreach declare a RuntimeException return so callers prefix them with throw and a fall-through is unrepresentable; the heal-once second rebuild re-checks the breach before its resumable throw; the deferred-close suite's stale "latches terminal" javadoc and misnamed test now describe the resume semantics and assert the sender never latched; and Sender.constructEngineOnSlot's javadoc no longer claims a recycle rebuild latches terminal. --- .../main/java/io/questdb/client/Sender.java | 10 +- .../qwp/client/QwpWebSocketSender.java | 69 +++- .../SymbolDictRecycleDeferredCloseTest.java | 60 ++- .../client/SymbolDictRecycleSlotHealTest.java | 354 ++++++++++++++++++ 4 files changed, 461 insertions(+), 32 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 04f8753a..7252ce03 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3310,9 +3310,13 @@ static ConstructedEngine constructEngineOnSlotLocked( * {@code slotPath}'s logical lock. {@link #build} itself does not call this -- * its own lock spans the connect loop too, see the comment at its call site -- * this entry point is for callers that only need a freshly (re)built engine on - * an already-owned slot, such as a symbol-dictionary epoch rebuild. Discards the - * quarantined verdict: a recycle rebuild latches terminal on connect failure - * rather than quarantining, so it has no connect-loop retry guard to seed. + * an already-owned slot, such as a symbol-dictionary epoch rebuild. Recovery + * verdicts still quarantine here exactly as they do under {@link #build} -- + * that happens inside {@link #constructEngineOnSlotLocked}. Only the + * quarantined FLAG is discarded: it exists to seed {@code build}'s connect-loop + * retry guard, and a recycle rebuild has no such loop. A rebuild that fails + * outright does not latch the sender terminal either; the recycle abandons and + * retries on the next send. */ static CursorSendEngine constructEngineOnSlot( String sfDir, String senderId, String slotPath, diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index a4d90542..8a993337 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -276,6 +276,10 @@ public class QwpWebSocketSender implements Sender { private volatile CursorSendEngine cursorEngine; private CursorWebSocketSendLoop cursorSendLoop; private boolean deferCommit; + // Test seam: runs once when awaitDeferredEngineClose() actually begins + // parking (positive witness that the await engaged rather than + // completing inline -- see SymbolDictRecycleDeferredCloseTest). + private Runnable deferredCloseParkWitness; // True when the sender emits incremental (delta) symbol dictionaries: each // message carries only symbol ids not yet sent on the wire, rather than the // full dictionary from id 0. Enabled in memory-mode (a reconnect replays from @@ -2951,6 +2955,17 @@ public synchronized void setDrainerListener(BackgroundDrainerListener listener) } } + /** + * Installs the positive witness {@link #awaitDeferredEngineClose} runs + * once it actually begins parking, so a test can prove the await engaged + * instead of completing inline -- see + * {@code SymbolDictRecycleDeferredCloseTest}. + */ + @TestOnly + public void setDeferredCloseParkWitnessForTesting(Runnable witness) { + this.deferredCloseParkWitness = witness; + } + public void setEngineRebuildFactory(EngineRebuildFactory factory) { this.engineRebuildFactory = factory; } @@ -4850,6 +4865,10 @@ private void awaitDeferredEngineClose(CursorSendEngine outgoing) { LOG.warn("symbol dictionary recycle waiting for a deferred engine close: the SF worker " + "did not quiesce, so the slot lock is still held [maxWaitMillis={}]", recycleDeferredCloseMaxWaitMillis); + Runnable witness = deferredCloseParkWitness; + if (witness != null) { + witness.run(); + } long deadlineNanos = System.nanoTime() + recycleDeferredCloseMaxWaitMillis * 1_000_000L; while (!outgoing.isCloseCompleted()) { if (System.nanoTime() >= deadlineNanos) { @@ -4903,22 +4922,19 @@ private void completeRecycleRebuild(int dictSizeAtSwap, long startNanos) { // unlink on its own close. Heal by doing exactly that. Only a // recovery holding UNACKED frames is a genuine breach: latch. if (rebuilt.publishedFsn() > rebuilt.ackedFsn()) { - LineSenderException breach = new LineSenderException( - "symbol dictionary recycle rebuilt on a slot holding unacknowledged " - + "frames: the outgoing engine's fully-drained close contract " - + "was breached"); - recycleFailure = breach; - recycleResume = RecycleResume.NONE; - closeQuietly(rebuilt); - LOG.error("symbol dictionary recycle failed; sender is now terminal " - + "[epoch={}, dictSizeAtSwap={}]", symbolDictEpoch, dictSizeAtSwap, breach); - throw breach; + throw latchRecycleBreach(rebuilt, dictSizeAtSwap); } closeQuietly(rebuilt); // fully drained: retries the segment unlink rebuilt = rebuildEngineOrAbandon( "symbol dictionary recycle could not rebuild its engine after healing " + "leftover acked segments; retried on the next send"); if (rebuilt.wasRecoveredFromDisk()) { + // Re-check: a breach the first pass could not see (the heal's + // close reshaped what recovery finds) must latch here too, + // otherwise it loops forever behind a resumable "acked" message. + if (rebuilt.publishedFsn() > rebuilt.ackedFsn()) { + throw latchRecycleBreach(rebuilt, dictSizeAtSwap); + } closeQuietly(rebuilt); throw new LineSenderException( "symbol dictionary recycle keeps recovering leftover acked segments " @@ -4999,6 +5015,28 @@ private boolean isRingDrained() { return published < 0 || cursorEngine.ackedFsn() >= published; } + /** + * The recycle's one non-resumable verdict: a rebuild recovered UNACKED + * frames from the slot the outgoing engine's fully-drained close was + * supposed to have emptied, so the producer's fresh dictionary and the + * slot's on-disk state have genuinely diverged. Latches + * {@link #recycleFailure}, disposes the rebuilt engine, and always throws + * -- the declared return type only lets callers write + * {@code throw latchRecycleBreach(...)}. + */ + private RuntimeException latchRecycleBreach(CursorSendEngine rebuilt, int dictSizeAtSwap) { + LineSenderException breach = new LineSenderException( + "symbol dictionary recycle rebuilt on a slot holding unacknowledged " + + "frames: the outgoing engine's fully-drained close contract " + + "was breached"); + recycleFailure = breach; + recycleResume = RecycleResume.NONE; + closeQuietly(rebuilt); + LOG.error("symbol dictionary recycle failed; sender is now terminal " + + "[epoch={}, dictSizeAtSwap={}]", symbolDictEpoch, dictSizeAtSwap, breach); + throw breach; + } + /** * Starvation policy: when the ring is NOT drained at arming time, waits * out an opportunistic window before giving up for this armed window. @@ -5219,7 +5257,7 @@ private void recycleForDictReset() { LOG.warn("symbol dictionary recycle abandoned: closing the outgoing I/O loop " + "failed; the close is finished on the next send [epoch={}]", symbolDictEpoch, t); - rethrowRecycleAbandoned(t, "symbol dictionary recycle abandoned while closing " + throw rethrowRecycleAbandoned(t, "symbol dictionary recycle abandoned while closing " + "the outgoing I/O loop; retried on the next send"); } // step 3: fully-drained close of the engine - empties the slot and @@ -5272,7 +5310,7 @@ private void resumeRecycleIfPending() { } catch (Error e) { throw e; } catch (Throwable t) { - rethrowRecycleAbandoned(t, "the outgoing I/O loop is still stopping; " + throw rethrowRecycleAbandoned(t, "the outgoing I/O loop is still stopping; " + "retried on the next send"); } hasLoopEverConnected |= cursorSendLoop.hasEverConnected(); @@ -5304,7 +5342,12 @@ private void resumeRecycleIfPending() { completeRecycleRebuild(globalSymbolDictionary.size(), System.nanoTime()); } - private void rethrowRecycleAbandoned(Throwable t, String message) { + /** + * Always throws; the declared return type exists purely so every caller + * can write {@code throw rethrowRecycleAbandoned(...)} and make an + * accidental fall-through past an abandoned recycle unrepresentable. + */ + private RuntimeException rethrowRecycleAbandoned(Throwable t, String message) { if (t instanceof Error) { throw (Error) t; } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java index 34736ba5..169f5927 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java @@ -52,9 +52,12 @@ * returns with the slot flock retained and its release deferred to the * worker's exit path. The recycle must await that release before rebuilding on * the slot -- rebuilding against the retained flock throws - * {@code SlotLockContentionException} and would latch the sender permanently - * terminal for what is usually a transient disk stall. Only exhausting the - * await budget (a genuinely dead worker) may latch terminal. + * {@code SlotLockContentionException} and would fail the recycle for what is + * usually a transient disk stall. Exhausting the await budget does not latch + * the sender terminal either (review r3, C2): it throws to the triggering + * caller and leaves the recycle pending in its {@code RecycleResume.REBUILD} + * state, so each later send retries the await, and one of them finishes the + * swap once the worker finally exits. Nothing on this path is terminal. *

    * The wedge: {@code SegmentManager}'s trim-sync hook runs unconditionally once * per service pass, so parking the worker there leaves it un-joinable exactly @@ -117,13 +120,22 @@ public void testRecycleSurvivesDeferredEngineClose() throws Exception { sender.resetSymbolDictionary(); Assert.assertTrue(ws.isResetArmed()); + // Positive witness that the await really parked (M15). + // A sleep could not tell "the await is parked" from + // "the close completed inline and the test skipped the + // code under test"; this fires from inside the await's + // own deferred-close branch. + CountDownLatch parked = new CountDownLatch(1); + ws.setDeferredCloseParkWitnessForTesting(parked::countDown); + // Un-wedges the worker while the recycle is parked in its // deferred-close await. The pre-release assert can never // race: the flock release needs the worker's exit, which // needs this very countDown. releaser = new Thread(() -> { try { - Thread.sleep(1_500L); + Assert.assertTrue("the await must actually park", + parked.await(10, TimeUnit.SECONDS)); Assert.assertFalse( "deferred close cannot complete while the worker is wedged", outgoing.isCloseCompleted()); @@ -175,15 +187,17 @@ public void testRecycleSurvivesDeferredEngineClose() throws Exception { } /** - * A permanent wedge: the await budget (shrunk via the test seam) runs out - * with the flock still held -- a genuinely dead worker. The recycle must - * latch terminal BEFORE committing any of the swap (epoch stays 0), and - * the still-locked engine must stay reachable through - * {@code isSlotLockReleased()}'s re-probe so the slot's capacity is - * recoverable if the worker ever exits. + * A wedge that outlives the await budget (shrunk via the test seam): every + * send while the worker is stuck runs the await afresh and throws again, + * and none of them may commit any part of the swap (epoch stays 0). The + * repeated throw is NOT a latch, which the tail proves: once the worker + * exits, the still-locked engine's late release becomes visible through + * {@code isSlotLockReleased()}'s retained-engine re-probe -- so the slot's + * capacity is recoverable -- and the very next send resumes the pending + * recycle and completes it. A latched sender could do neither. */ @Test(timeout = 60_000L) - public void testRecycleLatchesTerminalWhenDeferredCloseNeverReleases() throws Exception { + public void testExhaustedDeferredCloseAwaitKeepsThrowingWhileWedged() throws Exception { assertMemoryLeak(() -> { String sfDir = temporaryFolder.getRoot().toPath().resolve("recycle-deferred-timeout").toString(); try (TestWebSocketServer server = ackingServer()) { @@ -228,8 +242,8 @@ public void testRecycleLatchesTerminalWhenDeferredCloseNeverReleases() throws Ex try { sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); - Assert.fail("expected the recycle to latch terminal once the " - + "deferred-close await budget ran out"); + Assert.fail("expected the exhausted deferred-close await to throw " + + "while the worker stays wedged"); } catch (LineSenderException e) { TestUtils.assertContains(e.getMessage(), "deferred close did not release the slot lock"); @@ -237,13 +251,16 @@ public void testRecycleLatchesTerminalWhenDeferredCloseNeverReleases() throws Ex // The await runs at step 3, before the step-5 swap: no // epoch may have committed, and every later entry point - // must rethrow the latched failure. + // re-runs the await and throws the same transient + // verdict for as long as the worker stays wedged. Assert.assertEquals("the swap must not have committed", 0, ws.getSymbolDictEpoch()); try { sender.table("t"); - Assert.fail("expected a latched terminal sender to rethrow"); - } catch (LineSenderException expected) { + Assert.fail("expected the retried await to throw again"); + } catch (LineSenderException e) { + TestUtils.assertContains(e.getMessage(), + "deferred close did not release the slot lock"); } Assert.assertFalse("the slot flock is still held by the wedged engine", ws.isSlotLockReleased()); @@ -261,6 +278,17 @@ public void testRecycleLatchesTerminalWhenDeferredCloseNeverReleases() throws Ex outgoing.isCloseCompleted()); Assert.assertTrue("sender must expose the late flock release", ws.isSlotLockReleased()); + + // The proof that none of the throws above latched: the + // next send resumes the pending recycle and commits it. + sender.table("t").symbol("s", "c").longColumn("v", 3L).atNow(); + Assert.assertEquals("the pending recycle must complete once the wedge " + + "clears", 1, ws.getSymbolDictEpoch()); + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-resume batch must still get acked", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN " + + "[fsn1=" + fsn1 + ", fsn2=" + fsn2 + ']', fsn2 > fsn1); } finally { manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java new file mode 100644 index 00000000..c459340b --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java @@ -0,0 +1,354 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager; +import io.questdb.client.std.Files; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * The two verdicts {@code QwpWebSocketSender.completeRecycleRebuild} reaches + * when the recycle's step-5 rebuild comes back + * {@code wasRecoveredFromDisk()} -- i.e. when the outgoing engine's + * fully-drained close did NOT leave the slot empty. + *

      + *
    • Benign: the leftovers are fully acked. A close-time segment unlink + * failed transiently, so the watermark stayed behind to cover the + * residue by design (see {@code CursorSendEngineCloseUnlinkFailureTest}) + * and the SF contract is that the NEXT engine on the slot recovers them + * as acked and retries the unlink on its own close. The recycle must + * HEAL -- close the recovered engine, rebuild once more -- not brick.
    • + *
    • Breach: the leftovers hold UNACKED frames. The everything-acked + * barrier the swap rests on was violated, so the fresh producer + * dictionary and the slot's on-disk state have genuinely diverged. This + * is the recycle's one surviving terminal latch.
    • + *
    + * Both tests doctor a slot directly at the engine level and then point a live + * sender's rebuild factory at it, so the verdict is driven by real on-disk + * state rather than by a mocked engine. + *

    + * The acked-leftover recipe injects the unlink failure the way + * {@code CursorSendEngineCloseUnlinkFailureTest} does: it drops write + * permission on the slot directory (POSIX unlink needs a writable parent), so + * that test skips on Windows and wherever permissions are not enforced (root). + */ +public class SymbolDictRecycleSlotHealTest { + + /** + * Big enough for the real QWP frames the sender appends to the rebuilt + * engine after the heal, and identical in the prep helpers so recovery + * reads the doctored segments back at the size they were written with. + */ + private static final long SEGMENT_BYTES = 1024L * 1024L; + private static final int PAYLOAD_BYTES = 32; + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + /** + * Review r3, C2(a): a benign fully-drained close verdict (segment unlink + * transiently failed; watermark retained by design) must not brick the + * recycle. The rebuild recovers fully-acked leftovers; the sender heals by + * closing the recovered engine (which retries the unlink) and rebuilding + * once more. + */ + @Test(timeout = 60_000L) + public void testRecoveredFullyAckedLeftoversHealAndRecycleCompletes() throws Exception { + assertMemoryLeak(() -> { + // Phase 1: doctor a slot -- fully-acked frames whose close-time + // unlink failed (CursorSendEngineCloseUnlinkFailureTest's recipe). + String doctoredSlot = temporaryFolder.getRoot().toPath() + .resolve("doctored-slot").toString(); + prepareFullyAckedLeftoverSlot(doctoredSlot); + + // Phase 2: a live sender whose rebuild factory lands on that slot. + String sfDir = temporaryFolder.getRoot().toPath().resolve("heal-sf").toString(); + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(config(server, sfDir))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + + AtomicInteger rebuilds = new AtomicInteger(); + ws.setEngineRebuildFactory(() -> { + rebuilds.incrementAndGet(); + return new CursorSendEngine(doctoredSlot, SEGMENT_BYTES); + }); + + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + // Recycle: rebuild #1 recovers the acked leftovers -> heal + // -> rebuild #2 stands on a genuinely empty slot. + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + + Assert.assertEquals("heal must close the recovered engine and rebuild again", + 2, rebuilds.get()); + Assert.assertEquals("the recycle must have committed", + 1, ws.getSymbolDictEpoch()); + Assert.assertFalse("recycle must disarm", ws.isResetArmed()); + // The heal's close retried the unlink the outgoing close + // could not do, so the engine the swap committed on stands + // on a genuinely emptied slot -- not on the leftovers. + Assert.assertFalse("the recycle must commit on a non-recovered engine", + ws.getCursorEngineForTesting().wasRecoveredFromDisk()); + + long fsn2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-heal batch must still get acked", + sender.awaitAckedFsn(fsn2, 5_000)); + Assert.assertTrue("post-recycle FSN must exceed pre-recycle FSN " + + "[fsn1=" + fsn1 + ", fsn2=" + fsn2 + ']', fsn2 > fsn1); + } + } + }); + } + + /** + * Review r3: the terminal latch's one surviving case. A rebuild that + * recovers UNACKED frames proves the fully-drained-close contract was + * breached -- the fresh producer dictionary and the slot's state have + * genuinely diverged, so the sender must refuse further use ({@code close()} + * still works). + */ + @Test(timeout = 60_000L) + public void testRecoveredUnackedFramesLatchTerminal() throws Exception { + assertMemoryLeak(() -> { + String doctoredSlot = temporaryFolder.getRoot().toPath() + .resolve("breach-slot").toString(); + prepareUnackedLeftoverSlot(doctoredSlot); + + String sfDir = temporaryFolder.getRoot().toPath().resolve("breach-sf").toString(); + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(config(server, sfDir))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + long fsn1 = sender.flushAndGetSequence(); + Assert.assertTrue("setup: batch must be acked before the recycle", + sender.awaitAckedFsn(fsn1, 5_000)); + + ws.setEngineRebuildFactory(() -> new CursorSendEngine(doctoredSlot, SEGMENT_BYTES)); + sender.resetSymbolDictionary(); + try { + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + Assert.fail("a breached slot must latch terminal"); + } catch (LineSenderException expected) { + TestUtils.assertContains(expected.getMessage(), "unacknowledged"); + } + Assert.assertEquals("the swap must not have committed", + 0, ws.getSymbolDictEpoch()); + try { + sender.flush(); + Assert.fail("the latch must gate every later call"); + } catch (LineSenderException expected) { + TestUtils.assertContains(expected.getMessage(), + "sender is terminal: symbol dictionary recycle failed"); + } + try { + sender.table("t"); + Assert.fail("the latch must gate every later call"); + } catch (LineSenderException expected) { + TestUtils.assertContains(expected.getMessage(), + "sender is terminal: symbol dictionary recycle failed"); + } + sender.close(); // must still work + } + } + }); + } + + private static TestWebSocketServer ackingServer() throws Exception { + TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + private static String config(TestWebSocketServer server, String sfDir) { + return "ws::addr=localhost:" + server.getPort() + ";sf_dir=" + sfDir + ";"; + } + + private static void fill(long address, int len, byte value) { + for (int i = 0; i < len; i++) { + Unsafe.getUnsafe().putByte(address + i, value); + } + } + + /** + * Leaves {@code slot} holding one segment whose only frame the server + * already acknowledged, plus the ack watermark that covers it -- the exact + * residue a fully-drained close produces when its segment unlink fails + * transiently. Follows {@code CursorSendEngineCloseUnlinkFailureTest}: a + * shared {@link SegmentManager} that is deliberately never started (no + * worker thread can persist the watermark or trim behind the test's back, + * and the close-path quiescence barrier is trivially satisfied), and the + * unlink failure injected by dropping write permission on the slot dir. + * Restores the permissions before returning, so the successor engine the + * sender's rebuild factory constructs can heal the slot. + */ + private static void prepareFullyAckedLeftoverSlot(String slot) throws Exception { + Path slotPath = Paths.get(slot); + long payload = Unsafe.malloc(PAYLOAD_BYTES, MemoryTag.NATIVE_DEFAULT); + SegmentManager manager = new SegmentManager(SEGMENT_BYTES, TimeUnit.SECONDS.toNanos(60)); + CursorSendEngine pred = null; + boolean slotDirReadOnly = false; + try { + fill(payload, PAYLOAD_BYTES, (byte) 0x33); + pred = new CursorSendEngine(slot, SEGMENT_BYTES, manager); + Assert.assertEquals(0L, pred.appendBlocking(payload, PAYLOAD_BYTES)); + Assert.assertEquals(0L, pred.publishedFsn()); + // The server durably acknowledged FSN 0 in this session. + Assert.assertTrue(pred.acknowledge(0L)); + Assert.assertEquals(0L, pred.ackedFsn()); + + // Inject the close-time unlink failure, and prove the injection + // works with a probe file -- root (and some filesystems) ignore + // directory permissions. + String probePath = slot + "/probe"; + Assert.assertTrue(java.nio.file.Files.exists( + java.nio.file.Files.createFile(Paths.get(probePath)))); + try { + setPermissions(slotPath, "r-xr-xr-x"); + } catch (UnsupportedOperationException e) { + Assume.assumeNoException("POSIX permissions unavailable on this platform", e); + } + slotDirReadOnly = true; + boolean probeRemoved = Files.remove(probePath); + if (probeRemoved) { + setPermissions(slotPath, "rwxr-xr-x"); + slotDirReadOnly = false; + } + Assume.assumeFalse("directory permissions not enforced (running as root?)", + probeRemoved); + + // Fully-drained close: tries to unlink the acknowledged segment + // file and fails, so the watermark stays behind to cover it. + pred.close(); + Assert.assertTrue("flock release needs no dir write; close must complete", + pred.isCloseCompleted()); + pred = null; + + // The transient failure clears before the sender's rebuild arrives. + setPermissions(slotPath, "rwxr-xr-x"); + slotDirReadOnly = false; + Files.remove(probePath); + + Assert.assertTrue("prep: the injected unlink failure must leave the acked segment", + Files.exists(slot + "/sf-initial.sfa")); + Assert.assertTrue("prep: the watermark must be retained to cover the residue", + Files.exists(slot + "/" + AckWatermark.FILE_NAME)); + } finally { + if (slotDirReadOnly) { + try { + setPermissions(slotPath, "rwxr-xr-x"); + } catch (Throwable ignored) { + } + } + if (pred != null) { + pred.close(); + } + manager.close(); + Unsafe.free(payload, PAYLOAD_BYTES, MemoryTag.NATIVE_DEFAULT); + } + } + + /** + * Leaves {@code slot} holding one published-but-never-acknowledged frame. + * A close that is not fully drained retains the segment files by design + * (they still have to reach the server), so the successor recovers them + * with {@code publishedFsn > ackedFsn} -- the breach signature. Same + * never-started shared {@link SegmentManager} as the acked variant. + */ + private static void prepareUnackedLeftoverSlot(String slot) { + long payload = Unsafe.malloc(PAYLOAD_BYTES, MemoryTag.NATIVE_DEFAULT); + SegmentManager manager = new SegmentManager(SEGMENT_BYTES, TimeUnit.SECONDS.toNanos(60)); + CursorSendEngine pred = null; + try { + fill(payload, PAYLOAD_BYTES, (byte) 0x44); + pred = new CursorSendEngine(slot, SEGMENT_BYTES, manager); + Assert.assertEquals(0L, pred.appendBlocking(payload, PAYLOAD_BYTES)); + Assert.assertEquals(0L, pred.publishedFsn()); + Assert.assertTrue("prep: the frame must stay unacknowledged", + pred.ackedFsn() < pred.publishedFsn()); + pred.close(); + Assert.assertTrue(pred.isCloseCompleted()); + pred = null; + Assert.assertTrue("prep: an unacknowledged frame must survive the close", + Files.exists(slot + "/sf-initial.sfa")); + } finally { + if (pred != null) { + pred.close(); + } + manager.close(); + Unsafe.free(payload, PAYLOAD_BYTES, MemoryTag.NATIVE_DEFAULT); + } + } + + private static void setPermissions(Path path, String posix) throws Exception { + Set perms = PosixFilePermissions.fromString(posix); + java.nio.file.Files.setPosixFilePermissions(path, perms); + } + + /** ACKs every frame it receives; does not otherwise inspect the wire. */ + private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler { + private final AtomicLong nextSeq = new AtomicLong(0); + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(QwpWireTestUtils.buildAck(nextSeq.getAndIncrement())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} From 8c416358d794d3dc4a9b787c8a6850e3d5590986 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:02:16 +0100 Subject: [PATCH 39/49] Never arm the reset on senders that cannot recycle connect()-built senders (no rebuild factory) armed forever while the recycle silently refused, so isResetArmed() read true with a permanently-0 resets counter (review r3, M3). The capability checks move into armIfEligible, which also removes the per-barrier refusal branch. The dictionary-cap message no longer advises levers that are no-ops for the populations that can reach it. --- .../qwp/client/GlobalSymbolDictionary.java | 6 ++- .../qwp/client/QwpWebSocketSender.java | 49 ++++++++++--------- .../client/SymbolDictRecycleArmingTest.java | 19 +++++-- .../qwp/client/SymbolDictRecycleTest.java | 25 ++++++---- 4 files changed, 62 insertions(+), 37 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java index fd6f4c26..861f2156 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java @@ -155,8 +155,10 @@ public int getOrAddSymbol(CharSequence symbol) { + ". Rows using already-registered symbol values continue to work. To start a fresh " + "dictionary, close this sender and build a new one (with store-and-forward the " + "buffered backlog drains first). For unbounded-cardinality data use varchar " - + "columns instead of symbol. Alternatively enable the automatic dictionary reset " - + "(symbol_dict_reset, symbol_dict_reset_threshold) or call Sender.resetSymbolDictionary()."); + + "columns instead of symbol. The automatic dictionary reset " + + "(symbol_dict_reset, symbol_dict_reset_threshold) and " + + "Sender.resetSymbolDictionary() avoid this cap, but both act only " + + "on senders created via Sender.build()/fromConfig()."); } // Assign new ID — toString() only for new symbols that must be stored diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 8a993337..1b0aca84 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -4812,12 +4812,26 @@ private void resetTableBuffersAfterFlush() { /** * Re-evaluates whether the symbol-dictionary recycle should be armed: - * {@code resetEnabled} is on AND either the global dictionary has reached - * {@code resetThresholdSymbols} distinct entries or a caller requested a - * reset via {@link #resetSymbolDictionary()}. Deliberately ignores - * {@code deltaDictEnabled} -- a producer degraded to full self-sufficient - * frames still benefits from bounding its dictionary size, and a manual - * request is honoured regardless of mode. + * {@code resetEnabled} is on, the sender can actually rebuild ({@link + * #engineRebuildFactory} is set and {@link #ownsCursorEngine}), AND + * either the global dictionary has reached the effective bar + * {@code max(resetThresholdSymbols, resetFloorSymbols)} distinct entries + * or a caller requested a reset via {@link #resetSymbolDictionary()}. + * Deliberately ignores {@code deltaDictEnabled} -- a producer degraded to + * full self-sufficient frames still benefits from bounding its dictionary + * size, and a manual request is honoured regardless of mode. + *

    + * A sender that cannot rebuild -- no {@link #engineRebuildFactory} (every + * public {@code QwpWebSocketSender.connect(...)} overload leaves it null + * -- only {@code Sender.build()} installs one), or a cursor engine this + * sender does not own ({@code setCursorEngine(engine, false)}'s contract: + * the caller retains ownership, so closing it out from under them would + * be a use-after-free from the caller's point of view) -- must never arm. + * Since the recycle feature is default-on and {@code + * resetSymbolDictionary()} is a public advisory API, arming a sender with + * no way to ever act on the request would leave {@code isResetArmed()} + * reading true forever alongside a permanently-0 resets counter, + * misleading monitoring. *

    * Called from two safe points only: the tail of * {@link #resetTableBuffersAfterFlush()} (no row in progress, this flush's @@ -4829,6 +4843,8 @@ private void resetTableBuffersAfterFlush() { */ private void armIfEligible() { boolean shouldArm = resetEnabled + && engineRebuildFactory != null + && ownsCursorEngine && (globalSymbolDictionary.size() >= Math.max(resetThresholdSymbols, resetFloorSymbols) || manualResetRequested); if (shouldArm && !resetArmed) { @@ -5091,20 +5107,12 @@ private void maybeBlockForStarvedReset() { /** * Evaluates whether the barrier in {@link #table(CharSequence)} may run * the symbol-dictionary recycle right now. Only ever called with - * {@link #resetArmed} true. - *

    - * Refuses before any teardown when the rebuild itself is impossible or - * unsafe: no {@link #engineRebuildFactory} (every public - * {@code QwpWebSocketSender.connect(...)} overload leaves it null -- - * only {@code Sender.build()} installs one -- and the recycle feature is - * default-on, so a connect()-built sender must simply stay unarmed rather - * than NPE at step 5 and latch terminal), or a cursor engine this sender - * does not own ({@code setCursorEngine(engine, false)}'s contract: the - * caller retains ownership, so closing it out from under them at step 3 - * would be a use-after-free from the caller's point of view). + * {@link #resetArmed} true -- {@link #armIfEligible()} already refused to + * arm a sender that cannot rebuild, so this method only has to weigh + * producer-side state. *

    - * Also refuses when there is producer-side state the swap cannot safely - * tear down: no connection yet (a V4 sender that has never sent is never + * Refuses when there is producer-side state the swap cannot safely tear + * down: no connection yet (a V4 sender that has never sent is never * pre-connected here), a flush in flight ({@code pendingRowCount != 0}), * or a row under construction. Otherwise proceeds to the ring-drained * check: if the backlog is empty, recycle immediately; if not, defer to @@ -5112,9 +5120,6 @@ private void maybeBlockForStarvedReset() { * of blocking the caller indefinitely here. */ private void maybeRecycleForDictReset() { - if (engineRebuildFactory == null || !ownsCursorEngine) { - return; - } if (!connected || pendingRowCount != 0 || (currentTableBuffer != null && currentTableBuffer.hasInProgressRow())) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java index e83dfa45..b3de45e2 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java @@ -80,7 +80,7 @@ public void testArmsAtThreshold() throws Exception { /** * Decision 5: arming ignores {@code deltaDictEnabled} -- threshold-based - * arming must still fire once the sender has degraded to full self-sufficient + * evaluation must still run once the sender has degraded to full self-sufficient * frames. Reaching a custom low {@code symbol_dict_reset_threshold} on a * sender that also carries the fault-injecting {@code FilesFacade} needs * {@code QwpWebSocketSender}'s widest {@code connect(List, ...)} @@ -90,6 +90,14 @@ public void testArmsAtThreshold() throws Exception { * {@code sender.resetThresholdSymbols} directly and also accepts the * hand-built {@code CursorSendEngine}, so both requirements are reachable * together. + *

    + * This {@code connect(...)} overload installs no {@link + * io.questdb.client.cutlass.qwp.client.QwpWebSocketSender.EngineRebuildFactory + * EngineRebuildFactory} (only {@code Sender.build()} does), so per review + * r3 M3 crossing the threshold must never actually arm -- {@code + * armIfEligible()} folds the capability check in ahead of the threshold + * comparison. Decision 5 is instead pinned negatively here: full-dict + * degradation does not change that verdict either way. */ @Test public void testArmsInFullDictMode() throws Exception { @@ -160,11 +168,14 @@ public void testArmsInFullDictMode() throws Exception { sender.isResetArmed()); // No manual resetSymbolDictionary() call anywhere in this test: crossing - // the threshold alone must arm the recycle, even while degraded. + // the threshold, even while degraded, still must not arm -- this + // connect(...) overload installs no engineRebuildFactory (review r3, + // M3), and that capability check now runs ahead of the threshold + // comparison in armIfEligible(). sender.table("m").symbol("s", "c").longColumn("v", 3L).atNow(); sender.flush(); - Assert.assertTrue("threshold-based arming must fire even in full-dict mode " - + "(Decision 5: arming ignores deltaDictEnabled)", + Assert.assertFalse("a sender with no rebuild factory must never arm, even once " + + "the threshold is crossed in full-dict mode (review r3, M3)", sender.isResetArmed()); } finally { sender.close(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index ad6d8924..6360654a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -126,13 +126,16 @@ public void testRecycleAtEmptyBacklog() throws Exception { /** * {@code engineRebuildFactory} is only installed by {@code Sender.build()} - * ({@code Sender.java:1752}) -- every public {@code QwpWebSocketSender.connect(...)} + * ({@code Sender.java:1760}) -- every public {@code QwpWebSocketSender.connect(...)} * overload leaves it null. Since the recycle feature is default-on and * {@code resetSymbolDictionary()} is a public advisory API, a connect()-built - * sender can become "armed" with no way to ever act on it. - * {@code maybeRecycleForDictReset()} must refuse before any teardown in that - * case, not attempt step 6 and NPE into a latched terminal state -- covers - * both ways a sender can arm: the manual request and threshold crossing. + * sender could previously become "armed" with no way to ever act on it -- + * {@code isResetArmed()} reading true forever alongside a permanently-0 + * resets counter misled monitoring (review r3, M3). {@code armIfEligible()} + * now folds the same capability check ({@code engineRebuildFactory != null + * && ownsCursorEngine}) into the arming decision itself, so a sender that + * cannot rebuild never arms in the first place -- covers both ways a + * sender can otherwise arm: the manual request and threshold crossing. */ @Test public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception { @@ -143,7 +146,8 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception // Manual reset request on the simplest connect() overload. try (QwpWebSocketSender sender = QwpWebSocketSender.connect("localhost", port)) { sender.resetSymbolDictionary(); - Assert.assertTrue("a manual request arms immediately (no row/flush in flight)", + Assert.assertFalse("a sender with no rebuild factory must never arm, not even " + + "for a manual request (review r3, M3)", sender.isResetArmed()); // Drained instant (nothing published yet, no row in progress): with a @@ -155,7 +159,7 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception sender.awaitAckedFsn(fsn, 5_000)); Assert.assertEquals("no factory -> the recycle can never actually run", 0, sender.getSymbolDictEpoch()); - Assert.assertTrue("stays armed forever -- nothing ever consumes the request", + Assert.assertFalse("still never armed -- nothing changed that would flip it", sender.isResetArmed()); } @@ -195,7 +199,9 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); long fsn1 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn1, 5_000)); - Assert.assertTrue("threshold=2 crossed by a, b", sender.isResetArmed()); + Assert.assertFalse("a sender with no rebuild factory must never arm: " + + "isResetArmed()==true with a permanently-0 resets counter " + + "misleads monitoring", sender.isResetArmed()); // Drained instant again: must not recycle, must not throw. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); @@ -204,7 +210,8 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception sender.awaitAckedFsn(fsn2, 5_000)); Assert.assertEquals("no factory -> the recycle can never actually run", 0, sender.getSymbolDictEpoch()); - Assert.assertTrue("stays armed -- nothing ever consumes the threshold arming", + Assert.assertFalse("still never armed -- crossing the threshold again changes " + + "nothing", sender.isResetArmed()); } finally { sender.close(); From 4bb08fb7a28551d0f1261fa117fcb26905cbd173 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:13:12 +0100 Subject: [PATCH 40/49] Fix accessor truthfulness across the recycle window Review r3 M1/M4/M9: getAckedFsn reads fsnEpochBase before the engine so a torn pair can never fabricate an FSN above anything published; wasEverConnected honors its documented stickiness through the loop-null window via the sender-lifetime flag; the getAckedFsn javadocs now state the real post-recycle sentinel behavior (the value was already truthful). --- .../main/java/io/questdb/client/Sender.java | 6 ++- .../qwp/client/QwpWebSocketSender.java | 37 ++++++++++++------- .../SymbolDictRecycleStep7FaultTest.java | 2 + 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 7252ce03..6ce3c684 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -611,8 +611,10 @@ default Sender geoHashColumn(CharSequence name, CharSequence value) { /** * Highest frame sequence number (FSN) the server has acknowledged. - * Returns {@code -1} when no batch has been published yet, and on transports that - * do not track FSNs (HTTP, TCP, UDP). + * Returns {@code -1} only while nothing has ever been published in this + * sender's lifetime. After a symbol-dictionary recycle the accessor keeps + * reporting the last pre-swap durable watermark until the fresh epoch + * publishes -- it never collapses back to {@code -1}. *
    * Snapshot accessor: for a bounded blocking wait, use * {@link #awaitAckedFsn(long, long)}. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 1b0aca84..67fee551 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -1984,21 +1984,26 @@ public QwpWebSocketSender geoHashColumn(CharSequence columnName, CharSequence va /** * Highest FSN that has been server-acknowledged. Rejections never advance - * the watermark. {@code -1} if - * the I/O loop has not yet started or no batch has been published. + * the watermark. Returns {@code -1} only while nothing has ever been + * published in this sender's lifetime. After a symbol-dictionary recycle + * the accessor keeps reporting the last pre-swap durable watermark until + * the fresh epoch publishes -- it never collapses back to {@code -1}. *

    * Snapshot accessor — for a bounded wait, use * {@link #awaitAckedFsn(long, long)}. */ @Override public long getAckedFsn() { - // Snapshot: the recycle transitions cursorEngine non-null -> null -> - // non-null on the producer thread, so read the field once. While it - // is null (mid-swap, or for good after a failed swap) report the last - // watermark a recycle barrier proved durable instead of collapsing - // to -1 -- all pre-swap data really is acked. + // Read fsnEpochBase FIRST, then cursorEngine: the recycle writes + // engine=null -> base+=L+1 -> engine=fresh, so a reader that saw the + // NEW base is ordered after the null write and can only observe null + // or the fresh engine -- never (new base, stale engine), which would + // fabricate an FSN above anything ever published. Sender is + // documented single-threaded; this ordering just keeps best-effort + // monitor reads truthful rather than promising thread safety. + long base = fsnEpochBase; CursorSendEngine engine = cursorEngine; - return engine != null ? fsnEpochBase + engine.ackedFsn() : lastRecycleDurableFsn; + return engine != null ? base + engine.ackedFsn() : lastRecycleDurableFsn; } /** @@ -3291,15 +3296,19 @@ public QwpWebSocketSender uuidColumn(CharSequence columnName, long lo, long hi) /** * True iff this sender has at least once installed a live (connected * + upgraded) WebSocket. Sticky — once true, stays true even after a - * subsequent disconnect. Lets a {@link SenderErrorHandler} - * disambiguate a "never reached the server" terminal failure (likely - * a config typo or firewall block) from a "lost connection after we - * were up" failure (likely transient). Returns {@code false} if no - * I/O loop is running. + * subsequent disconnect, including through a symbol-dict recycle's + * loop-null window (mid-swap, or after a failed reconnect setup). + * Lets a {@link SenderErrorHandler} disambiguate a "never reached the + * server" terminal failure (likely a config typo or firewall block) + * from a "lost connection after we were up" failure (likely + * transient). Returns {@code false} only if no loop has ever + * connected in this sender's lifetime. */ public boolean wasEverConnected() { + // Sticky by contract: fall back to the sender-lifetime flag while no + // loop is installed (mid-recycle, or after a failed reconnect setup). CursorWebSocketSendLoop l = cursorSendLoop; - return l != null && l.hasEverConnected(); + return hasLoopEverConnected || (l != null && l.hasEverConnected()); } private static Throwable captureCloseError(Throwable terminalError, Throwable t) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java index 66921574..9a40130c 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java @@ -84,6 +84,8 @@ public void testStep7FailureDoesNotLatchAndRecovers() throws Exception { } // The swap committed; the sender must NOT be terminal. Assert.assertEquals(1, ws.getSymbolDictEpoch()); + Assert.assertTrue("wasEverConnected() is documented sticky and must survive " + + "the recycle's loop-null window", ws.wasEverConnected()); ws.setLoopStartFaultForTesting(null); // Next send retries the deferred setup and data flows again. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); From dc6526bccaf0281871d9521a9d4bce0c3368e7fe Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:23:46 +0100 Subject: [PATCH 41/49] Fix up M9 javadoc overclaim and make hasLoopEverConnected volatile Review of task 9: scope the getAckedFsn 'never collapses to -1' claim to a live sender (close() nulls cursorEngine and lastRecycleDurableFsn is only ever set by a recycle, so a published+acked, never-recycled, closed sender does return -1 again -- the prior wording overclaimed). hasLoopEverConnected is now load-bearing for wasEverConnected(), which is documented for cross-thread use from a SenderErrorHandler callback; mark it volatile like every other monitoring-accessor field in this class. --- core/src/main/java/io/questdb/client/Sender.java | 5 +++-- .../cutlass/qwp/client/QwpWebSocketSender.java | 13 ++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 6ce3c684..4e1834f8 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -612,9 +612,10 @@ default Sender geoHashColumn(CharSequence name, CharSequence value) { /** * Highest frame sequence number (FSN) the server has acknowledged. * Returns {@code -1} only while nothing has ever been published in this - * sender's lifetime. After a symbol-dictionary recycle the accessor keeps + * sender's lifetime. On a live sender the value never collapses back to + * {@code -1}: after a symbol-dictionary recycle the accessor keeps * reporting the last pre-swap durable watermark until the fresh epoch - * publishes -- it never collapses back to {@code -1}. + * publishes. (After {@code close()} the reading is unspecified.) *
    * Snapshot accessor: for a bounded blocking wait, use * {@link #awaitAckedFsn(long, long)}. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 67fee551..e7d66473 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -361,8 +361,9 @@ public class QwpWebSocketSender implements Sender { // does not reset CursorWebSocketSendLoop's own hasEverConnected back to // false -- which would wrongly re-arm its startup-terminal // classification (endpointPolicyFailureIsTerminal()) and misclassify - // wasEverConnected() for the whole post-recycle outage window. - private boolean hasLoopEverConnected; + // wasEverConnected() for the whole post-recycle outage window. Volatile: + // wasEverConnected() is consulted from the error-dispatcher daemon. + private volatile boolean hasLoopEverConnected; // FSN of the last commit-bearing (non-FLAG_DEFER_COMMIT) frame this session // published, or -1 when none. Frames above it are deferred and uncommitted: // the server withholds their acks by design (their rows are rolled back on @@ -1985,9 +1986,11 @@ public QwpWebSocketSender geoHashColumn(CharSequence columnName, CharSequence va /** * Highest FSN that has been server-acknowledged. Rejections never advance * the watermark. Returns {@code -1} only while nothing has ever been - * published in this sender's lifetime. After a symbol-dictionary recycle - * the accessor keeps reporting the last pre-swap durable watermark until - * the fresh epoch publishes -- it never collapses back to {@code -1}. + * published in this sender's lifetime. On a live sender the value never + * collapses back to {@code -1}: after a symbol-dictionary recycle the + * accessor keeps reporting the last pre-swap durable watermark until the + * fresh epoch publishes. (After {@code close()} the reading is + * unspecified.) *

    * Snapshot accessor — for a bounded wait, use * {@link #awaitAckedFsn(long, long)}. From 4b3894d5a248756efcebce632aca20e0fa7eccd1 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:05:02 +0100 Subject: [PATCH 42/49] Fix stale doc claims left by the review wave - Sender.getAckedFsn: restore the transport caveat -- HTTP/TCP/UDP senders inherit the -1 default and never track FSNs. - QwpWebSocketSender.getSymbolDictEpoch: a step-5 rebuild failure no longer latches recycleFailure; the resume machinery abandons the recycle instead, to be resumed by a later send. - QwpWebSocketSender.resetSymbolDictionary: note that the request is also a permanent no-op on senders that cannot recycle (no engine rebuild factory, or an engine they do not own). - QwpWebSocketSender.isDeltaDictEnabledForTest: reword the one-way "permanently" claim to epoch-scoped -- a symbol-dictionary recycle re-derives delta mode from the fresh engine. - QwpWebSocketSender.sealAndSwapBuffer: guard the append-failure catch's cursorSendLoop.checkError() call against a null loop so it cannot NPE and mask the real append failure. --- .../main/java/io/questdb/client/Sender.java | 5 ++-- .../qwp/client/QwpWebSocketSender.java | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 4e1834f8..f97ae399 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -611,8 +611,9 @@ default Sender geoHashColumn(CharSequence name, CharSequence value) { /** * Highest frame sequence number (FSN) the server has acknowledged. - * Returns {@code -1} only while nothing has ever been published in this - * sender's lifetime. On a live sender the value never collapses back to + * Returns {@code -1} while nothing has ever been published in this + * sender's lifetime, and always on transports that do not track FSNs + * (HTTP, TCP, UDP). On a live sender the value never collapses back to * {@code -1}: after a symbol-dictionary recycle the accessor keeps * reporting the last pre-swap durable watermark until the fresh epoch * publishes. (After {@code close()} the reading is unspecified.) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index e7d66473..60a37e90 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -2239,9 +2239,11 @@ public QwpTableBuffer getTableBuffer(String tableName) { /** * Whether this sender is still in delta-encoded mode. Flips to {@code false} - * permanently once {@link #disableDeltaDict} fires (a persisted-dictionary - * write failure, including a recognised mmap access fault) -- every later - * flush then ships full self-sufficient frames instead. + * for the rest of this epoch once {@link #disableDeltaDict} fires (a + * persisted-dictionary write failure, including a recognised mmap access + * fault) -- every later flush this epoch then ships full self-sufficient + * frames instead. A symbol-dictionary recycle re-derives this from the + * fresh engine. */ @TestOnly public boolean isDeltaDictEnabledForTest() { @@ -2274,10 +2276,11 @@ public long getFsnEpochBaseForTest() { * Number of symbol-dictionary recycles this sender has completed. Advances * by one at step 6 of {@link #recycleForDictReset()}, the instant the swap * commits to the new epoch -- after the engine rebuild (step 5) has - * already succeeded, so a step-5 rebuild failure that latches - * {@link #recycleFailure} leaves this counter un-bumped, while a later - * step-7 reconnect failure (which cannot latch: the swap already - * committed by then) still leaves this incremented. Unlike the per-send-loop + * already succeeded, so a step-5 rebuild failure -- which abandons the + * recycle to be resumed by a later send -- leaves this counter + * un-bumped, while a later step-7 reconnect failure (which cannot + * latch: the swap already committed by then) still leaves this + * incremented. Unlike the per-send-loop * {@code getTotal*} counters, it is scoped to the sender's whole lifetime * and never resets. volatile: a * concurrent read sees the latest write the producer thread completed, @@ -2821,7 +2824,10 @@ public void reset() { *

    * A permanent no-op while {@code symbol_dict_reset} is off: arming gates * on that knob, so a sender configured with the recycle disabled never - * acts on the request, however many times it is made. + * acts on the request, however many times it is made. The request is + * likewise a permanent no-op on senders that cannot recycle -- ones + * without an engine rebuild factory (every {@code connect()}-built + * sender) or running on an engine they do not own -- which never arm. */ @Override public void resetSymbolDictionary() { @@ -6026,7 +6032,9 @@ private void sealAndSwapBuffer() { // Surface any I/O thread error first — appendBlocking itself only // throws on PAYLOAD_TOO_LARGE / backpressure deadline, but the // I/O loop can have failed independently. - cursorSendLoop.checkError(); + if (cursorSendLoop != null) { + cursorSendLoop.checkError(); + } throw new LineSenderException("cursor SF append failed", t); } } From 1d869cc579e3e9a720303599525c29a5028d0a6e Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:51:31 +0100 Subject: [PATCH 43/49] Pin the recycle re-arm floor arithmetic The floor (2x the dictionary size at the last swap, capped at half the protocol cap) had no test reading it, which is how two integration suites with bounded symbol sets went red when the floor landed: they still expected organic re-arms that the floor makes impossible. Pin the doubling, the organic-re-arm refusal below the floor, and the advisory-request bypass. Rename the arming test whose every assertion is assertFalse(isResetArmed()) to say what it proves. --- .../qwp/client/QwpWebSocketSender.java | 4 ++ .../client/SymbolDictRecycleArmingTest.java | 49 ++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 60a37e90..d3d273c9 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -2219,6 +2219,10 @@ public int getSymbolDictResetThreshold() { return resetThresholdSymbols; } + /** + * The current re-arm floor: 0 before the first swap, then + * {@code min(2 x dictSizeAtSwap, MAX_SYMBOL_DICTIONARY_SIZE / 2)}. + */ @TestOnly public int getResetFloorSymbolsForTesting() { return resetFloorSymbols; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java index b3de45e2..33d659f9 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java @@ -100,7 +100,7 @@ public void testArmsAtThreshold() throws Exception { * degradation does not change that verdict either way. */ @Test - public void testArmsInFullDictMode() throws Exception { + public void testDoesNotArmWithoutRebuildFactory() throws Exception { assertMemoryLeak(() -> { String sfDir = temporaryFolder.getRoot().toPath().resolve("arm-full-dict-sf").toString(); String slot = Paths.get(sfDir, "default").toString(); @@ -231,6 +231,53 @@ public void testManualResetRequestArms() throws Exception { }); } + @Test + public void testReArmFloorDoublesPerSwapAndBlocksOrganicReArm() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("floor-sf").toString(); + try (TestWebSocketServer server = ackingServer()) { + String config = cfg(server) + "sf_dir=" + sfDir + ";symbol_dict_reset_threshold=2;"; + try (Sender sender = Sender.fromConfig(config)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + Assert.assertEquals("no swap yet: floor is 0", 0, ws.getResetFloorSymbolsForTesting()); + + // epoch 0: two symbols == threshold -> arms + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); + Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); + Assert.assertTrue(ws.isResetArmed()); + + // swap #1 runs inside this table() with dictSizeAtSwap == 2 + sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); + Assert.assertEquals("floor = 2 x size-at-swap", 4, ws.getResetFloorSymbolsForTesting()); + + // epoch 1: c,d,e,f == floor -> arms again + sender.table("t").symbol("s", "d").longColumn("v", 2L).atNow(); + sender.table("t").symbol("s", "e").longColumn("v", 2L).atNow(); + sender.table("t").symbol("s", "f").longColumn("v", 2L).atNow(); + Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); + Assert.assertTrue("size 4 >= max(threshold 2, floor 4) must arm", ws.isResetArmed()); + + // swap #2 with dictSizeAtSwap == 4 + sender.table("t").symbol("s", "g").longColumn("v", 3L).atNow(); + Assert.assertEquals(2, ws.getSymbolDictEpoch()); + Assert.assertEquals("floor doubles again", 8, ws.getResetFloorSymbolsForTesting()); + + // epoch 2: four symbols is above the threshold but below the floor + sender.table("t").symbol("s", "h").longColumn("v", 4L).atNow(); + sender.table("t").symbol("s", "i").longColumn("v", 4L).atNow(); + sender.table("t").symbol("s", "j").longColumn("v", 4L).atNow(); + Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); + Assert.assertFalse("size 4 < floor 8 must not re-arm organically", ws.isResetArmed()); + + sender.resetSymbolDictionary(); + Assert.assertTrue("the advisory request bypasses the floor", ws.isResetArmed()); + } + } + }); + } + @Test public void testResetSymbolDictionaryOnNonWsSenderIsNoOp() throws Exception { assertMemoryLeak(() -> { From 269aa8a5b2e617eac37890e17f57ac65e6f4857d Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:54:07 +0100 Subject: [PATCH 44/49] Point comments at the renamed arming test SymbolDictRecycleArmingTest.testArmsInFullDictMode was renamed to testDoesNotArmWithoutRebuildFactory; update the two comment references that still named the old method. --- .../test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java | 2 +- .../client/test/cutlass/qwp/client/SymbolDictRecycleTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java index 0c264ec2..9749d7ac 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -339,7 +339,7 @@ private static TestWebSocketServer ackingServer() throws Exception { /** * Widest {@code QwpWebSocketSender.connect(...)} overload with everything but the * fault-injecting engine and the reset threshold pinned to defaults -- mirrors - * {@code SymbolDictRecycleArmingTest.testArmsInFullDictMode}. {@code Sender.fromConfig} has + * {@code SymbolDictRecycleArmingTest.testDoesNotArmWithoutRebuildFactory}. {@code Sender.fromConfig} has * no {@code FilesFacade} seam, so this is the only way to combine a custom facade with a * custom low threshold; it leaves {@code engineRebuildFactory} null, same as every other * {@code connect(...)} overload, so callers that need a working recycle must install one diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index 6360654a..413242b0 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -165,7 +165,7 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception // Threshold-based arming needs a custom low threshold, only reachable (without // routing through Sender.build(), which WOULD install a factory) via the - // widest connect() overload -- mirrors SymbolDictRecycleArmingTest.testArmsInFullDictMode. + // widest connect() overload -- mirrors SymbolDictRecycleArmingTest.testDoesNotArmWithoutRebuildFactory. CursorSendEngine engine = new CursorSendEngine( null, 4L * 1024 * 1024, 128L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS); From 5591741edcbc3781a4f8179dbbb2e24590a59898 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:05:30 +0100 Subject: [PATCH 45/49] Hand the live error handler to the rebuild factory The builder's rebuild factory read the builder's own errorHandler field at rebuild time, so a slot quarantined during a recycle rebuild never reached a handler installed on the sender after build() via setErrorHandler(). The factory gains a rebuild(SenderErrorHandler) overload the sender calls with its current user handler (null when only the default is installed, matching build-time behaviour); the builder's factory forwards it and snapshots sfDir/senderId instead of reading builder fields live. --- .../main/java/io/questdb/client/Sender.java | 23 +++++++--- .../qwp/client/QwpWebSocketSender.java | 16 ++++++- .../qwp/client/SymbolDictRecycleTest.java | 44 +++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index f97ae399..317ae232 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1761,11 +1761,24 @@ public Sender build() { // dispatcher daemon, drainer pool, microbatch buffers and // WebSocketClient inside the abandoned `connected`. connected.setTransactional(transactional); - connected.setEngineRebuildFactory(() -> LineSenderBuilder.constructEngineOnSlot( - sfDir, senderId, slotPath, - actualSfMaxSegmentBytes, actualSfMaxTotalBytes, - actualSfAppendDeadlineNanos, actualSfSyncIntervalNanos, - errorHandler)); + final String rebuildSfDir = sfDir; + final String rebuildSenderId = senderId; + final SenderErrorHandler buildTimeHandler = errorHandler; + connected.setEngineRebuildFactory(new QwpWebSocketSender.EngineRebuildFactory() { + @Override + public CursorSendEngine rebuild() { + return rebuild(buildTimeHandler); + } + + @Override + public CursorSendEngine rebuild(SenderErrorHandler liveHandler) { + return LineSenderBuilder.constructEngineOnSlot( + rebuildSfDir, rebuildSenderId, slotPath, + actualSfMaxSegmentBytes, actualSfMaxTotalBytes, + actualSfAppendDeadlineNanos, actualSfSyncIntervalNanos, + liveHandler); + } + }); try { // Install the drainer listener BEFORE startOrphanDrainers // below: drainers must see the listener at submit time so diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index d3d273c9..99dce59c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -5156,7 +5156,7 @@ private void maybeRecycleForDictReset() { private CursorSendEngine rebuildEngineOrAbandon(String message) { try { - return engineRebuildFactory.rebuild(); + return engineRebuildFactory.rebuild(userErrorHandler()); } catch (Error e) { throw e; } catch (Throwable t) { @@ -5431,6 +5431,11 @@ private void sendCommitMessage() { lastCommitBoundaryFsn = cursorEngine.publishedFsn(); } + private SenderErrorHandler userErrorHandler() { + SenderErrorHandler h = errorHandler; + return h == DefaultSenderErrorHandler.INSTANCE ? null : h; + } + /** * Advances the delta baseline once a frame carrying the current batch's * symbols has been queued onto the ring. No-op in full-dict mode. Only ever @@ -6182,6 +6187,15 @@ public Endpoint(String host, int port) { */ public interface EngineRebuildFactory { CursorSendEngine rebuild(); + + /** + * Rebuild with the sender's current user-supplied error handler ({@code null} + * when only the default handler is installed), so a quarantine during the + * rebuild reaches a handler installed after {@code build()}. + */ + default CursorSendEngine rebuild(SenderErrorHandler liveHandler) { + return rebuild(); + } } /** diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index 413242b0..6dca288b 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client; import io.questdb.client.Sender; +import io.questdb.client.SenderErrorHandler; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; @@ -49,8 +50,10 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE; import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; @@ -407,6 +410,47 @@ public void testFailedRebuildAbandonsAndRecovers() throws Exception { }); } + @Test + public void testRebuildFactoryReceivesTheLiveErrorHandler() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(cfg(server))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + SenderErrorHandler installedAfterBuild = error -> { }; + ws.setErrorHandler(installedAfterBuild); + + QwpWebSocketSender.EngineRebuildFactory real = ws.getEngineRebuildFactoryForTesting(); + AtomicReference handlerSeen = new AtomicReference<>(); + AtomicBoolean handlerlessOverloadCalled = new AtomicBoolean(); + ws.setEngineRebuildFactory(new QwpWebSocketSender.EngineRebuildFactory() { + @Override + public CursorSendEngine rebuild() { + handlerlessOverloadCalled.set(true); + return real.rebuild(); + } + + @Override + public CursorSendEngine rebuild(SenderErrorHandler liveHandler) { + handlerSeen.set(liveHandler); + return real.rebuild(liveHandler); + } + }); + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); + sender.resetSymbolDictionary(); + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + Assert.assertEquals("the recycle must have committed", 1, ws.getSymbolDictEpoch()); + + Assert.assertSame("a rebuild-time quarantine must reach the handler installed after build()", + installedAfterBuild, handlerSeen.get()); + Assert.assertFalse("the sender must call the handler-aware overload", + handlerlessOverloadCalled.get()); + } + } + }); + } + /** * A producer thread whose interrupt flag is already set makes step 2's * loop close throw deterministically (CountDownLatch.await throws on From e02a52cdfcbd3ed70137c8fd18de5cc239bc88f3 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:21:29 +0100 Subject: [PATCH 46/49] Track a recovered engine's deferred close in the recycle The heal and second-pass paths closed a rebuilt engine that recovered leftovers with a fire-and-forget close. When that engine's SF worker is wedged the close returns with the slot flock retained, the next rebuild collides with it, and a later sender close() reports the flock released because neither cursorEngine nor retainedEngine points at the engine that still holds it. Close a recovered engine with the same deferred-close protocol step 3 uses for the outgoing engine, so the existing await, retain-on-timeout and resume-on-next-send cover it; a breach latch records the engine as retained when its close deferred. Step 3 also detaches the outgoing engine's slot-lock listener before closing it: the listener is the sender's own onSlotLockReleased, and a release completing after step 6 would have flagged the rebuilt engine's flock as released. A recycle-retained engine is surfaced to pools through the isSlotLockReleased() re-probe instead. --- .../qwp/client/QwpWebSocketSender.java | 46 +++-- .../client/sf/cursor/CursorSendEngine.java | 5 + .../SymbolDictRecycleDeferredCloseTest.java | 35 ++++ .../client/SymbolDictRecycleSlotHealTest.java | 160 ++++++++++++++++++ 4 files changed, 233 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 99dce59c..e29bc09c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -417,7 +417,8 @@ public class QwpWebSocketSender implements Sender { // Engine whose close() could not complete during sender close() — its // cleanup is pending on a worker/I/O-thread exit path. isSlotLockReleased() // re-probes it so a late flock release becomes visible to the owning pool. - // Only ever set inside close(); null for a sender that closed cleanly. + // Set by close() and by a recycle whose deferred-close await ran out; null + // while no engine is retained. private volatile CursorSendEngine retainedEngine; private int pendingRowCount; private SenderProgressDispatcher progressDispatcher; @@ -1575,8 +1576,9 @@ public boolean isCloseCleanupComplete() { * Not a one-shot snapshot: when close() left engine cleanup pending on a * manager-worker quiescence or I/O-thread exit path, this re-probes the * retained engine and latches true the moment that cleanup completes — pools re-probe retired - * slots through this getter to recover their capacity. Monotonic: - * false→true only, never back. Cheap (volatile reads on every common + * slots through this getter to recover their capacity. Reset to false by a + * recycle that rebuilds the engine (the fresh engine holds the flock + * again); otherwise latches false→true. Cheap (volatile reads on every common * path) so pools may call it under their capacity lock; only the rare * orphaned-retry state below does more. *

    @@ -4923,12 +4925,19 @@ private void awaitDeferredEngineClose(CursorSendEngine outgoing) { } } - private static void closeQuietly(CursorSendEngine engine) { + private void closeRecoveredEngine(CursorSendEngine recovered) { + recyclePendingOutgoing = recovered; try { - engine.close(); - } catch (Throwable ignored) { - // best-effort; the retained files are the next rebuild's problem + recovered.close(); + } catch (Error e) { + throw e; + } catch (Throwable t) { + LOG.warn("recovered engine close reported a failure during the symbol dictionary " + + "recycle; deferring to the close-completion probe", t); } + awaitDeferredEngineClose(recovered); + recyclePendingOutgoing = null; + retainedEngine = null; } /** @@ -4962,7 +4971,7 @@ private void completeRecycleRebuild(int dictSizeAtSwap, long startNanos) { if (rebuilt.publishedFsn() > rebuilt.ackedFsn()) { throw latchRecycleBreach(rebuilt, dictSizeAtSwap); } - closeQuietly(rebuilt); // fully drained: retries the segment unlink + closeRecoveredEngine(rebuilt); // fully drained: retries the segment unlink rebuilt = rebuildEngineOrAbandon( "symbol dictionary recycle could not rebuild its engine after healing " + "leftover acked segments; retried on the next send"); @@ -4973,7 +4982,7 @@ private void completeRecycleRebuild(int dictSizeAtSwap, long startNanos) { if (rebuilt.publishedFsn() > rebuilt.ackedFsn()) { throw latchRecycleBreach(rebuilt, dictSizeAtSwap); } - closeQuietly(rebuilt); + closeRecoveredEngine(rebuilt); throw new LineSenderException( "symbol dictionary recycle keeps recovering leftover acked segments " + "(slot cleanup not durable yet); retried on the next send"); @@ -5006,9 +5015,9 @@ private void completeRecycleRebuild(int dictSizeAtSwap, long startNanos) { cursorEngine = rebuilt; ownsCursorEngine = true; cursorEngine.setSlotLockReleaseListener(this::onSlotLockReleased); - // The fresh engine holds the slot flock again; step 3's release - // flipped slotLockReleased true via the outgoing engine's - // listener, which no longer describes this sender's state. + // The fresh engine holds the slot flock again; a stale true (an + // isSlotLockReleased() re-probe of the outgoing engine while the + // recycle stayed pending) no longer describes this sender's state. slotLockReleased = false; recycleResume = RecycleResume.NONE; recyclePendingLastPublishedFsn = -1L; @@ -5069,7 +5078,17 @@ private RuntimeException latchRecycleBreach(CursorSendEngine rebuilt, int dictSi + "was breached"); recycleFailure = breach; recycleResume = RecycleResume.NONE; - closeQuietly(rebuilt); + try { + rebuilt.close(); + } catch (Error e) { + throw e; + } catch (Throwable ignored) { + // terminal either way; the retained-engine probe below covers a deferred close + } + if (!rebuilt.isCloseCompleted()) { + retainedEngine = rebuilt; + slotLockReleased = false; + } LOG.error("symbol dictionary recycle failed; sender is now terminal " + "[epoch={}, dictSizeAtSwap={}]", symbolDictEpoch, dictSizeAtSwap, breach); throw breach; @@ -5298,6 +5317,7 @@ private void recycleForDictReset() { recyclePendingOutgoing = outgoing; recyclePendingLastPublishedFsn = lastPublishedFsn; try { + outgoing.setSlotLockReleaseListener(null); outgoing.close(); } catch (Error e) { throw e; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index 66a0635e..b06d3f39 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -1349,6 +1349,11 @@ public SlotLock getSlotLockForTesting() { return slotLock; } + @TestOnly + public Runnable getSlotLockReleaseListenerForTesting() { + return slotLockReleaseListener; + } + @TestOnly public long getSyncIntervalNanosForTesting() { return syncIntervalNanos; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java index 169f5927..687ddb7b 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java @@ -301,6 +301,41 @@ public void testExhaustedDeferredCloseAwaitKeepsThrowingWhileWedged() throws Exc }); } + /** + * The outgoing engine's slot-lock listener is the sender's own + * onSlotLockReleased. Left attached, a release that completes after step 6 + * (a preempted retry thread between closeCompleted = true and listener.run()) + * would mark the REBUILT engine's flock released. Step 3 detaches it. + */ + @Test(timeout = 60_000L) + public void testRecycleDetachesTheOutgoingEngineListener() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("recycle-detach-listener").toString(); + try (TestWebSocketServer server = ackingServer()) { + String cfg = "ws::addr=localhost:" + server.getPort() + ";sf_dir=" + sfDir + ";"; + try (Sender sender = Sender.fromConfig(cfg)) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); + + CursorSendEngine outgoing = ws.getCursorEngineForTesting(); + Assert.assertNotNull("the live engine carries the sender's listener", + outgoing.getSlotLockReleaseListenerForTesting()); + + sender.resetSymbolDictionary(); + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + Assert.assertEquals(1, ws.getSymbolDictEpoch()); + + Assert.assertNull("step 3 must detach the outgoing engine's listener", + outgoing.getSlotLockReleaseListenerForTesting()); + Assert.assertNotNull("the rebuilt engine carries the listener instead", + ws.getCursorEngineForTesting().getSlotLockReleaseListenerForTesting()); + Assert.assertFalse("the rebuilt engine holds the flock", ws.isSlotLockReleased()); + } + } + }); + } + /** * The await budget runs out while the worker is still wedged, but the * wedge is transient after all. Exhausting the budget must NOT latch the diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java index c459340b..de2629cf 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java @@ -47,9 +47,11 @@ import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; import java.util.Set; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; @@ -152,6 +154,164 @@ public void testRecoveredFullyAckedLeftoversHealAndRecycleCompletes() throws Exc }); } + /** + * The heal closes the engine that recovered the leftovers. When that engine's + * SF worker is wedged, its close returns with the slot flock retained, exactly + * like the outgoing engine's close in step 3 -- and must be awaited the same + * way, or rebuild #2 collides with the retained flock. + */ + @Test(timeout = 60_000L) + public void testHealRidesOutADeferredCloseOfTheRecoveredEngine() throws Exception { + assertMemoryLeak(() -> { + String doctoredSlot = temporaryFolder.getRoot().toPath().resolve("doctored-deferred").toString(); + prepareFullyAckedLeftoverSlot(doctoredSlot); + String sfDir = temporaryFolder.getRoot().toPath().resolve("heal-deferred-sf").toString(); + CountDownLatch workerBlocked = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + AtomicReference auxErr = new AtomicReference<>(); + Thread releaser = null; + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(config(server, sfDir))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); + + AtomicInteger rebuilds = new AtomicInteger(); + ws.setEngineRebuildFactory(() -> { + CursorSendEngine engine = new CursorSendEngine(doctoredSlot, SEGMENT_BYTES); + if (rebuilds.incrementAndGet() == 1) { + // Wedge the RECOVERED engine's worker so the heal's close defers. + SegmentManager manager = engine.getManagerForTesting(); + manager.setBeforeTrimSyncHook(() -> { + workerBlocked.countDown(); + try { + if (!releaseWorker.await(30, TimeUnit.SECONDS)) { + auxErr.compareAndSet(null, new AssertionError("worker never released")); + } + } catch (Throwable t) { + auxErr.compareAndSet(null, t); + } + }); + manager.wakeWorker(); + try { + Assert.assertTrue("worker never reached the wedge hook", + workerBlocked.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + manager.setWorkerJoinTimeoutMillis(50L); + } + return engine; + }); + + CountDownLatch parked = new CountDownLatch(1); + ws.setDeferredCloseParkWitnessForTesting(parked::countDown); + releaser = new Thread(() -> { + try { + Assert.assertTrue("the heal's close must park in the deferred-close await", + parked.await(10, TimeUnit.SECONDS)); + } catch (Throwable t) { + auxErr.compareAndSet(null, t); + } finally { + releaseWorker.countDown(); + } + }, "heal-deferred-close-releaser"); + releaser.start(); + + sender.resetSymbolDictionary(); + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + + Assert.assertEquals("heal must close the recovered engine and rebuild again", 2, rebuilds.get()); + Assert.assertEquals("the recycle must have committed", 1, ws.getSymbolDictEpoch()); + Assert.assertFalse(ws.getCursorEngineForTesting().wasRecoveredFromDisk()); + Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); + } finally { + releaseWorker.countDown(); + if (releaser != null) { + releaser.join(10_000); + } + } + } + if (auxErr.get() != null) { + throw new AssertionError(auxErr.get()); + } + }); + } + + /** + * When the recovered engine's deferred close outlives the await budget, the + * recycle must retain that engine exactly like an outgoing engine: close() + * must not report the slot flock released while the wedged worker still + * holds it, and the re-probe must latch once the worker exits. + */ + @Test(timeout = 60_000L) + public void testHealDeferredCloseExhaustionRetainsTheRecoveredEngine() throws Exception { + assertMemoryLeak(() -> { + String doctoredSlot = temporaryFolder.getRoot().toPath().resolve("doctored-retained").toString(); + prepareFullyAckedLeftoverSlot(doctoredSlot); + String sfDir = temporaryFolder.getRoot().toPath().resolve("heal-retained-sf").toString(); + CountDownLatch workerBlocked = new CountDownLatch(1); + CountDownLatch releaseWorker = new CountDownLatch(1); + AtomicReference auxErr = new AtomicReference<>(); + try (TestWebSocketServer server = ackingServer()) { + QwpWebSocketSender ws = (QwpWebSocketSender) Sender.fromConfig(config(server, sfDir)); + try { + ws.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + Assert.assertTrue(ws.awaitAckedFsn(ws.flushAndGetSequence(), 5_000)); + + ws.setEngineRebuildFactory(() -> { + CursorSendEngine engine = new CursorSendEngine(doctoredSlot, SEGMENT_BYTES); + SegmentManager manager = engine.getManagerForTesting(); + manager.setBeforeTrimSyncHook(() -> { + workerBlocked.countDown(); + try { + if (!releaseWorker.await(30, TimeUnit.SECONDS)) { + auxErr.compareAndSet(null, new AssertionError("worker never released")); + } + } catch (Throwable t) { + auxErr.compareAndSet(null, t); + } + }); + manager.wakeWorker(); + try { + Assert.assertTrue("worker never reached the wedge hook", + workerBlocked.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + manager.setWorkerJoinTimeoutMillis(50L); + return engine; + }); + ws.setRecycleDeferredCloseMaxWaitMillisForTesting(150L); + + ws.resetSymbolDictionary(); + try { + ws.table("t"); + Assert.fail("the exhausted await must throw a resumable failure"); + } catch (LineSenderException expected) { + Assert.assertTrue(expected.getMessage(), + expected.getMessage().contains("could not yet reclaim its slot")); + } + Assert.assertEquals("the swap must not have committed", 0, ws.getSymbolDictEpoch()); + } finally { + ws.close(); + } + Assert.assertFalse("close() must not report the flock released while the recovered " + + "engine's wedged worker still holds it", ws.isSlotLockReleased()); + + releaseWorker.countDown(); + long deadline = System.currentTimeMillis() + 10_000; + while (!ws.isSlotLockReleased() && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + Assert.assertTrue("the re-probe must latch once the worker exits", ws.isSlotLockReleased()); + } + if (auxErr.get() != null) { + throw new AssertionError(auxErr.get()); + } + }); + } + /** * Review r3: the terminal latch's one surviving case. A rebuild that * recovers UNACKED frames proves the fully-drained-close contract was From 9d83a38d666dd043b3bb6f6181b926f52c1116aa Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:39:47 +0100 Subject: [PATCH 47/49] Pre-size the fresh dictionary, clamp getAckedFsn The recycle allocated the fresh dictionary at the default capacity of 64, so every epoch re-climbed the rehash ladder inside one symbol() call; size it to the outgoing dictionary like the recovery path does. getAckedFsn() now clamps against the durable watermark the barrier proved, so a torn (old base, fresh engine) read cannot dip below a value it already returned. Correct the docs the code had outgrown: the recycle rebuilds before it rolls the epoch base and heals recovered acked leftovers rather than refusing them; a step-7 auth rejection is retried, not latched; the 1M bound of pre-10.0.0 servers is reachable on defaults because the re-arm bar climbs to half the cap; the threshold javadoc names the bar. Keep one copy of the recovery-verdict rationale. Drop review-round finding IDs from comments. --- .../main/java/io/questdb/client/Sender.java | 32 +++------ .../qwp/client/QwpWebSocketSender.java | 68 ++++++++++--------- .../cutlass/qwp/protocol/QwpConstants.java | 9 +-- 3 files changed, 51 insertions(+), 58 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 317ae232..5852ad67 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1630,25 +1630,7 @@ public Sender build() { try (SlotLock logicalSlotLock = slotPath == null ? null : SlotLock.acquireLogical(slotPath)) { - // The constructor's own recovery seed can also fail terminally, and - // not only as UnreplayableSlotException: when SegmentRing.openExisting - // had to skip an unreadable segment it throws SfRecoveryException (it - // constructs UnreplayableSlotException nowhere), and where it cannot - // even prove the chain's identity -- no manifest -- it quarantines the - // corrupt files and returns an EMPTY recovery rather than refusing. - // Either way the frame range cannot be shown already-acked, so recovery - // sets the slot aside rather than risk seeding the ack cursor past - // frames that were never delivered. All three types below are load - // bearing; narrowing this catch to UnreplayableSlotException would - // restore the permanent build() brick for the segment-skip case. That verdict gets - // the exact same quarantine-and-continue treatment as the connect()-time - // verdict below -- constructing cursorEngine is not inside the loop below, - // so a throw here would otherwise escape build() entirely, uncaught. - // quarantineTornSlot(null, ...) renames the WHOLE slot directory aside - // (not just the unreadable segment file) before building the replacement - // at the original slotPath, so the replacement starts on a genuinely empty - // directory with nothing left to skip -- it cannot throw the same way - // twice, which is what makes looping unnecessary here. + // Recovery-verdict handling lives in constructEngineOnSlotLocked. ConstructedEngine constructed = constructEngineOnSlotLocked( sfDir, senderId, slotPath, actualSfMaxSegmentBytes, actualSfMaxTotalBytes, @@ -1942,7 +1924,11 @@ public LineSenderBuilder symbolDictReset(boolean enabled) { /** * Number of distinct symbols the sender's dictionary may accumulate before - * {@link #symbolDictReset(boolean)} triggers a recycle. Must be greater than + * {@link #symbolDictReset(boolean)} triggers a recycle. Each recycle raises + * the effective bar to {@code max(threshold, 2 x dictionary size at the swap)}, + * capped at half of {@link QwpConstants#MAX_SYMBOL_DICTIONARY_SIZE}, so a + * bounded live set larger than the threshold recycles once and settles + * instead of recycling on every refill. Must be greater than * {@code 0} and no larger than {@link QwpConstants#MAX_SYMBOL_DICTIONARY_SIZE}. *

    * Default {@code 100_000}. WebSocket transport only. @@ -3259,9 +3245,9 @@ static ConstructedEngine constructEngineOnSlotLocked( // frames that were never delivered. All three types below are load // bearing; narrowing this catch to UnreplayableSlotException would // restore the permanent build() brick for the segment-skip case. That verdict gets - // the exact same quarantine-and-continue treatment as the connect()-time - // verdict below -- constructing cursorEngine is not inside the loop below, - // so a throw here would otherwise escape build() entirely, uncaught. + // the same quarantine-and-continue treatment as build()'s connect()-time + // verdict; for build() the construction runs outside its retry loop, and + // for a recycle rebuild there is no loop at all. // quarantineTornSlot(null, ...) renames the WHOLE slot directory aside // (not just the unreadable segment file) before building the replacement // at the original slotPath, so the replacement starts on a genuinely empty diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index e29bc09c..7cc3ac98 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -454,7 +454,7 @@ public class QwpWebSocketSender implements Sender { // Distinct-symbol count that triggers a recycle once resetEnabled is on // (connect-string key symbol_dict_reset_threshold). private int resetThresholdSymbols = DEFAULT_SYMBOL_DICT_RESET_THRESHOLD_SYMBOLS; - // Anti-thrash floor for the automatic reset (review r3, C1). 0 until the + // Anti-thrash floor for the automatic reset. 0 until the // first swap; the effective re-arm bar is max(resetThresholdSymbols, // resetFloorSymbols). Each swap raises it to twice the dictionary size // at that swap, so a live symbol set larger than the threshold stops @@ -513,15 +513,15 @@ public class QwpWebSocketSender implements Sender { // normally. Every other recycle failure is transient and resumable (see // recycleResume), never latched here. private Throwable recycleFailure; - // Resumable recycle (review r3, C2): a transient failure mid-recycle no + // Resumable recycle: a transient failure mid-recycle no // longer latches the sender terminal. CLOSE_LOOP = step 2 failed, the // old loop is still dying and the old engine/dictionary are intact. // REBUILD = the old engine is closed (possibly still releasing its slot // flock); await/rebuild/commit are pending. resumeRecycleIfPending() // advances the state from the table() barrier and ensureConnected(). private RecycleResume recycleResume = RecycleResume.NONE; - // The closed-but-not-yet-released outgoing engine a REBUILD resume still - // awaits; null once its deferred close completes. + // The closed-but-not-yet-released outgoing or recovered engine a REBUILD + // resume still awaits; null once its deferred close completes. private CursorSendEngine recyclePendingOutgoing; // Raw last-published FSN of the outgoing epoch (step-1 snapshot), // consumed by the commit when a REBUILD resume completes. @@ -530,7 +530,7 @@ public class QwpWebSocketSender implements Sender { // expected to throw) inside ensureConnected()'s loop-construction try, // after cursorSendLoop is assigned but before start() -- exercising the // catch that closes and nulls the fresh loop, i.e. the failed-reconnect - // state the C4/C5 regression tests pin. + // state SymbolDictRecycleStep7FaultTest pins. private Runnable loopStartFault; // Incremented once per completed symbol-dictionary recycle. 0 until the // first recycle commits. volatile: this is public API (see @@ -2003,12 +2003,14 @@ public long getAckedFsn() { // engine=null -> base+=L+1 -> engine=fresh, so a reader that saw the // NEW base is ordered after the null write and can only observe null // or the fresh engine -- never (new base, stale engine), which would - // fabricate an FSN above anything ever published. Sender is - // documented single-threaded; this ordering just keeps best-effort - // monitor reads truthful rather than promising thread safety. + // fabricate an FSN above anything ever published. The clamp against + // lastRecycleDurableFsn keeps the other torn pair (old base, fresh + // engine) from reading below a value already returned. Sender is + // documented single-threaded; this keeps best-effort monitor reads + // truthful rather than promising thread safety. long base = fsnEpochBase; CursorSendEngine engine = cursorEngine; - return engine != null ? base + engine.ackedFsn() : lastRecycleDurableFsn; + return engine != null ? Math.max(lastRecycleDurableFsn, base + engine.ackedFsn()) : lastRecycleDurableFsn; } /** @@ -2281,8 +2283,8 @@ public long getFsnEpochBaseForTest() { /** * Number of symbol-dictionary recycles this sender has completed. Advances * by one at step 6 of {@link #recycleForDictReset()}, the instant the swap - * commits to the new epoch -- after the engine rebuild (step 5) has - * already succeeded, so a step-5 rebuild failure -- which abandons the + * commits to the new epoch -- after the engine rebuild (step 4) has + * already succeeded, so a step-4 rebuild failure -- which abandons the * recycle to be resumed by a later send -- leaves this counter * un-bumped, while a later step-7 reconnect failure (which cannot * latch: the swap already committed by then) still leaves this @@ -2303,8 +2305,8 @@ public long getSymbolDictEpoch() { /** * Number of symbol-dictionary recycle swaps this sender has completed. * Incremented alongside {@link #getSymbolDictEpoch()} at step 6 of - * {@link #recycleForDictReset()} -- after the engine rebuild (step 5) has - * already succeeded, so, like the epoch counter, a step-5 rebuild failure + * {@link #recycleForDictReset()} -- after the engine rebuild (step 4) has + * already succeeded, so, like the epoch counter, a step-4 rebuild failure * leaves this un-bumped while a step-7 reconnect failure still leaves it * incremented (the swap has already committed by then). * Also like the epoch counter, it is scoped to the sender's whole lifetime @@ -4306,11 +4308,13 @@ private void ensureConnected() { // connect commit to V1 because cursor segments are immutable; // a future version bump must account for that. Transport // failures retry indefinitely on the I/O thread (Invariant B). - // But a terminal auth, upgrade or capability rejection on this - // deferred connect (the initial one, or a later re-entry such - // as the recycle's step 7) -- before the wire is ever up -- is + // But a terminal auth, upgrade or capability rejection on the + // INITIAL deferred connect -- before the wire is ever up -- is // surfaced to the async SenderErrorHandler and latched for a - // close() rethrow, not retried. + // close() rethrow, not retried. A re-entry after a prior + // connect (the recycle's step 7) seeds the fresh loop with + // markEverConnected(), so the same rejection there is retried + // like any post-connect failure. client = null; break; case OFF: @@ -4915,7 +4919,7 @@ private void awaitDeferredEngineClose(CursorSendEngine outgoing) { retainedEngine = outgoing; slotLockReleased = false; throw new LineSenderException("symbol dictionary recycle could not yet reclaim " - + "its slot: the outgoing engine's deferred close did not release the " + + "its slot: the engine's deferred close did not release the " + "slot lock within " + recycleDeferredCloseMaxWaitMillis + " ms (SF worker stalled); the recycle stays pending and is retried " + "on the next send"); @@ -4958,7 +4962,7 @@ private void completeRecycleRebuild(int dictSizeAtSwap, long startNanos) { recyclePendingOutgoing = null; retainedEngine = null; } - // step 5: rebuild the engine on the now-empty slot. + // step 4: rebuild the engine on the now-empty slot. CursorSendEngine rebuilt = rebuildEngineOrAbandon( "symbol dictionary recycle could not rebuild its engine; retried on the next send"); if (rebuilt.wasRecoveredFromDisk()) { @@ -4988,14 +4992,14 @@ private void completeRecycleRebuild(int dictSizeAtSwap, long startNanos) { + "(slot cleanup not durable yet); retried on the next send"); } } - // COMMIT (steps 4 + 6): pure producer-side state, and nothing below - // can throw. Step 4 rolls the external FSN base past every FSN the + // COMMIT (steps 5 + 6): pure producer-side state, and nothing below + // can throw. Step 5 rolls the external FSN base past every FSN the // outgoing epoch handed out (the -1 no-publish case adds 0); it must // run with cursorSendLoop == null, which step 2 guarantees and the // step-7 reconnect below only undoes afterwards. rollFsnEpochBase(recyclePendingLastPublishedFsn); // Replace the dictionary, don't clear(). - globalSymbolDictionary = new GlobalSymbolDictionary(); + globalSymbolDictionary = new GlobalSymbolDictionary(Math.max(dictSizeAtSwap, 64)); sentMaxSymbolId = -1; currentBatchMaxSymbolId = -1; lastCommitBoundaryFsn = -1L; @@ -5003,7 +5007,7 @@ private void completeRecycleRebuild(int dictSizeAtSwap, long startNanos) { symbolDictResetsPerformed++; resetArmed = false; manualResetRequested = false; - // C1 anti-thrash floor: see resetFloorSymbols. + // Anti-thrash floor: see resetFloorSymbols. resetFloorSymbols = Math.min(dictSizeAtSwap * 2, QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE / 2); // Deliberately re-derived (not carried over): the healing half @@ -5205,22 +5209,24 @@ private CursorSendEngine rebuildEngineOrAbandon(String message) { * javadoc) -- this sender holds no other lock on the slot at this * point, so releasing it here is safe. Awaits the (possibly * deferred) release -- see below. + *

  • Rebuild the cursor engine on the now-empty slot via + * {@link #engineRebuildFactory}, the identical construct path + * {@code Sender.build()} uses. A rebuild that recovers fully-acked + * leftovers heals the slot (closes that engine, which retries the + * unlink, and rebuilds); one that recovers unacknowledged frames + * proves the outgoing close's empties-the-slot contract was + * breached and latches the sender terminal.
  • *
  • Roll {@link #fsnEpochBase} past every FSN the outgoing epoch ever * handed out. Must run with {@code cursorSendLoop == null} (step 2 * already guarantees this -- see {@link #rollFsnEpochBase}'s * precondition).
  • - *
  • Rebuild the cursor engine on the now-empty slot via - * {@link #engineRebuildFactory}, the identical construct path - * {@code Sender.build()} uses. A rebuild that recovers a persisted - * dictionary from disk means the outgoing close's empties-the-slot - * contract was breached, so the recycle refuses to run on it.
  • *
  • Producer-side swap COMMIT -- only now that a fresh engine stands * on the emptied slot: a fresh {@link GlobalSymbolDictionary} * (replaced, not cleared -- nothing else retains the old instance), * both symbol-id watermarks reset, {@code lastCommitBoundaryFsn} * reset (it held a raw old-epoch FSN that does not survive the * roll), the epoch counter and the completed-swap counter both - * advanced, the arming flags consumed, the C1 anti-thrash floor + * advanced, the arming flags consumed, the anti-thrash floor * raised, {@code deltaDictEnabled} re-derived from the fresh * engine (the healing half of the recycle contract -- see * {@link #disableDeltaDict}), and the fresh engine's @@ -5248,7 +5254,7 @@ private CursorSendEngine rebuildEngineOrAbandon(String message) { * returns with the slot flock retained and {@code isCloseCompleted()} * false, releasing both from the worker's exit path. The tail then awaits * that deferred release (bounded by - * {@link #RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS}) before step 5 rebuilds + * {@link #RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS}) before step 4 rebuilds * on the slot -- rebuilding against the retained flock would throw * {@code SlotLockContentionException} for what is usually a transient disk * stall. @@ -5323,7 +5329,7 @@ private void recycleForDictReset() { throw e; } catch (Throwable t) { // A throw with the terminal cleanup nevertheless completed (the - // post-cleanup fsyncDir durability warning, C2(b)) is not a swap + // post-cleanup fsyncDir durability warning) is not a swap // failure -- finishClose's finally released the slot regardless. // awaitDeferredEngineClose() below tells the two apart: it // returns immediately when isCloseCompleted(), parks while the diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java index ec51437c..0fa7a645 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java @@ -96,10 +96,11 @@ public final class QwpConstants { * Compatibility: servers released before QuestDB 10.0.0 cap their * dictionary at 1,000,000, and QWP has no wire-level negotiation of the * limit -- a dictionary this client lets grow past 1M is rejected by - * those servers as a terminal parse error. Unreachable on defaults - * ({@code symbol_dict_reset} recycles at 100k), but a sender configured - * with the recycle off (or a threshold above 1M) against a pre-10.0.0 - * server must keep its symbol cardinality below the old 1M cap. + * those servers as a terminal parse error. Reachable on defaults: each + * recycle raises the re-arm bar to twice the dictionary size at the + * swap, capped at half of this constant, so an unbounded-cardinality + * producer's dictionary grows to 1M entries per epoch. Only 10.0.0+ + * servers are supported. */ public static final int MAX_SYMBOL_DICTIONARY_SIZE = 2_000_000; /** From 233897619c32bca94c4bf40f833d44c21f138ec5 Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:56:34 +0100 Subject: [PATCH 48/49] Drop review finding IDs from the recycle tests Review-round and finding identifiers resolve to nothing once the PR is squashed; describe the behaviour instead. Also drop a close() that the try-with-resources already performs. --- .../qwp/client/SymbolDictRecycleArmingTest.java | 10 +++++----- .../client/SymbolDictRecycleDeferredCloseTest.java | 6 +++--- .../qwp/client/SymbolDictRecycleHealingTest.java | 4 ++-- .../qwp/client/SymbolDictRecycleSlotHealTest.java | 4 ++-- .../qwp/client/SymbolDictRecycleStep7FaultTest.java | 11 +++++------ .../cutlass/qwp/client/SymbolDictRecycleTest.java | 12 +++++------- 6 files changed, 22 insertions(+), 25 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java index 33d659f9..d54433a8 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java @@ -93,8 +93,8 @@ public void testArmsAtThreshold() throws Exception { *

    * This {@code connect(...)} overload installs no {@link * io.questdb.client.cutlass.qwp.client.QwpWebSocketSender.EngineRebuildFactory - * EngineRebuildFactory} (only {@code Sender.build()} does), so per review - * r3 M3 crossing the threshold must never actually arm -- {@code + * EngineRebuildFactory} (only {@code Sender.build()} does), so + * crossing the threshold must never actually arm -- {@code * armIfEligible()} folds the capability check in ahead of the threshold * comparison. Decision 5 is instead pinned negatively here: full-dict * degradation does not change that verdict either way. @@ -169,13 +169,13 @@ public void testDoesNotArmWithoutRebuildFactory() throws Exception { // No manual resetSymbolDictionary() call anywhere in this test: crossing // the threshold, even while degraded, still must not arm -- this - // connect(...) overload installs no engineRebuildFactory (review r3, - // M3), and that capability check now runs ahead of the threshold + // connect(...) overload installs no engineRebuildFactory, + // and that capability check now runs ahead of the threshold // comparison in armIfEligible(). sender.table("m").symbol("s", "c").longColumn("v", 3L).atNow(); sender.flush(); Assert.assertFalse("a sender with no rebuild factory must never arm, even once " - + "the threshold is crossed in full-dict mode (review r3, M3)", + + "the threshold is crossed in full-dict mode", sender.isResetArmed()); } finally { sender.close(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java index 687ddb7b..0908e0ff 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java @@ -54,7 +54,7 @@ * the slot -- rebuilding against the retained flock throws * {@code SlotLockContentionException} and would fail the recycle for what is * usually a transient disk stall. Exhausting the await budget does not latch - * the sender terminal either (review r3, C2): it throws to the triggering + * the sender terminal either: it throws to the triggering * caller and leaves the recycle pending in its {@code RecycleResume.REBUILD} * state, so each later send retries the await, and one of them finishes the * swap once the worker finally exits. Nothing on this path is terminal. @@ -120,7 +120,7 @@ public void testRecycleSurvivesDeferredEngineClose() throws Exception { sender.resetSymbolDictionary(); Assert.assertTrue(ws.isResetArmed()); - // Positive witness that the await really parked (M15). + // Positive witness that the await really parked. // A sleep could not tell "the await is parked" from // "the close completed inline and the test skipped the // code under test"; this fires from inside the await's @@ -339,7 +339,7 @@ public void testRecycleDetachesTheOutgoingEngineListener() throws Exception { /** * The await budget runs out while the worker is still wedged, but the * wedge is transient after all. Exhausting the budget must NOT latch the - * sender terminal (review r3, C2): the recycle stays pending in its + * sender terminal: the recycle stays pending in its * REBUILD resume state, and once the worker exits and the deferred close * releases the flock, the next send finishes the await, rebuilds and * commits the swap. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java index 9749d7ac..01816540 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -99,12 +99,12 @@ public void testMetricsAfterTwoRecycles() throws Exception { sender.table("t").symbol("s", "d").longColumn("v", 3L).atNow(); long fsn2 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(fsn2, 5_000)); - // The C1 anti-thrash floor (resetFloorSymbols = 2x the first swap's + // The anti-thrash floor (resetFloorSymbols = 2x the first swap's // dictSizeAtSwap = 4) keeps c,d (2 symbols, == threshold but < floor) // from re-arming on their own; a manual request bypasses the floor by // design, so drive the second recycle through resetSymbolDictionary(). sender.resetSymbolDictionary(); - Assert.assertTrue("manual reset request bypasses the C1 floor", + Assert.assertTrue("manual reset request bypasses the re-arm floor", ws.isResetArmed()); // Ring drained again -> second recycle: epoch 2. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java index de2629cf..af0b243e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java @@ -95,7 +95,7 @@ public class SymbolDictRecycleSlotHealTest { public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); /** - * Review r3, C2(a): a benign fully-drained close verdict (segment unlink + * A benign fully-drained close verdict (segment unlink * transiently failed; watermark retained by design) must not brick the * recycle. The rebuild recovers fully-acked leftovers; the sender heals by * closing the recovered engine (which retries the unlink) and rebuilding @@ -313,7 +313,7 @@ public void testHealDeferredCloseExhaustionRetainsTheRecoveredEngine() throws Ex } /** - * Review r3: the terminal latch's one surviving case. A rebuild that + * The terminal latch's one surviving case. A rebuild that * recovers UNACKED frames proves the fully-drained-close contract was * breached -- the fresh producer dictionary and the slot's state have * genuinely diverged, so the sender must refuse further use ({@code close()} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java index 9a40130c..76919f48 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java @@ -55,9 +55,8 @@ public class SymbolDictRecycleStep7FaultTest { /** * Pins that a step-7 (reconnect) failure does NOT latch the sender * terminal: the swap has committed, the sender is coherent and merely - * disconnected, and the next send retries the deferred setup. Review - * round 3, finding C5 (the test the PR body credited was deleted by - * commit 6793928a). + * disconnected, and the next send retries the deferred setup. The test + * the PR body credited was deleted by commit 6793928a. */ @Test public void testStep7FailureDoesNotLatchAndRecovers() throws Exception { @@ -100,8 +99,8 @@ public void testStep7FailureDoesNotLatchAndRecovers() throws Exception { /** * Pins the conditional in resetSymbolDictStateForNewConnection(): ids a * row registered before the deferred reconnect completes must still ship - * in the next delta. Review round 3, finding C4 (empirically untested: - * suite was green with the guard reverted). + * in the next delta. Empirically untested: suite was green with the + * guard reverted. */ @Test public void testPostFailedReconnectDeltaCoversStagedSymbolIds() throws Exception { @@ -136,7 +135,7 @@ public void testPostFailedReconnectDeltaCoversStagedSymbolIds() throws Exception // This row registers "c" in the FRESH dictionary during // symbol(); its sendRow() then completes the deferred // reconnect, which runs resetSymbolDictStateForNewConnection - // with the row in progress -- the C4 window. + // with the row in progress -- this race window. sender.table("t").symbol("s", "c").longColumn("v", 2L).atNow(); long f2 = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(f2, 5_000)); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index 6dca288b..630a778d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -134,7 +134,7 @@ public void testRecycleAtEmptyBacklog() throws Exception { * {@code resetSymbolDictionary()} is a public advisory API, a connect()-built * sender could previously become "armed" with no way to ever act on it -- * {@code isResetArmed()} reading true forever alongside a permanently-0 - * resets counter misled monitoring (review r3, M3). {@code armIfEligible()} + * resets counter misled monitoring. {@code armIfEligible()} * now folds the same capability check ({@code engineRebuildFactory != null * && ownsCursorEngine}) into the arming decision itself, so a sender that * cannot rebuild never arms in the first place -- covers both ways a @@ -150,7 +150,7 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception try (QwpWebSocketSender sender = QwpWebSocketSender.connect("localhost", port)) { sender.resetSymbolDictionary(); Assert.assertFalse("a sender with no rebuild factory must never arm, not even " - + "for a manual request (review r3, M3)", + + "for a manual request", sender.isResetArmed()); // Drained instant (nothing published yet, no row in progress): with a @@ -367,8 +367,8 @@ public void testFactoryRebuildsOnEmptySlot() throws Exception { /** * A transient engine-rebuild failure must NOT latch the sender terminal: * the recycle is abandoned before the swap commits and resumes on the - * next send. Replaces testFailedRebuildLatchesTerminal (review r3, C2(d): - * build() has a retry-and-quarantine loop for exactly these operational + * next send. Replaces testFailedRebuildLatchesTerminal (build() has a + * retry-and-quarantine loop for exactly these operational * failures; killing a healthy sender on a provably empty slot mid-life * was strictly worse than the build()-time behavior). */ @@ -404,7 +404,6 @@ public void testFailedRebuildAbandonsAndRecovers() throws Exception { Assert.assertEquals(1, ws.getSymbolDictEpoch()); long f = sender.flushAndGetSequence(); Assert.assertTrue(sender.awaitAckedFsn(f, 5_000)); - sender.close(); } } }); @@ -456,7 +455,6 @@ public CursorSendEngine rebuild(SenderErrorHandler liveHandler) { * loop close throw deterministically (CountDownLatch.await throws on * entry). That must abandon the recycle non-terminally; once the flag is * cleared the next call finishes the loop close and the sender recovers. - * Review r3, C2(c). */ @Test public void testInterruptedRecycleAbandonsAndRecovers() throws Exception { @@ -506,7 +504,7 @@ public void testInterruptedRecycleAbandonsAndRecovers() throws Exception { /** * A live symbol set larger than the threshold must not thrash the * recycle: after a swap, re-arming requires the dictionary to reach - * max(threshold, 2 * size-at-swap). Review round 3, finding C1. + * max(threshold, 2 * size-at-swap). */ @Test public void testLiveSetAboveThresholdDoesNotThrash() throws Exception { From 3ba9fbd7aaaefd43cb55644cdef5eefb235adf5f Mon Sep 17 00:00:00 2001 From: Sergei Minaev <5072859+jovfer@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:27:07 +0100 Subject: [PATCH 49/49] Pin the rebuild-time quarantine notification The builder's rebuild factory is the half that forwards the live error handler into constructEngineOnSlot -> quarantineTornSlot, and that hand-off is only observable when a rebuild really has a slot to set aside. The new test plants SegmentSkipQuarantineTest's tainted slot (several never-acked segments, the oldest one's magic overwritten) into the empty slot the recycle's step-3 close just left behind, then lets the real factory run: the recycle still commits, and the handler installed after build() receives the DATA_LOSS SenderError naming where the bytes were set aside. The sender is built without an error handler at all, so a regression that forwarded the build-time handler instead of the live one leaves the notification nowhere -- verified by mutating the delegation to the handlerless overload, which fails the new assertion. Also align the test javadocs with the recycle step numbering and drop the last review-era names. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ru8dSnpZsgkg3uPkfwyGvp --- .../SymbolDictRecycleCatchUpSkipTest.java | 4 +- .../SymbolDictRecycleDeferredCloseTest.java | 2 +- .../client/SymbolDictRecycleHealingTest.java | 2 +- .../client/SymbolDictRecycleRefusalTest.java | 2 +- .../client/SymbolDictRecycleSlotHealTest.java | 2 +- .../SymbolDictRecycleStep7FaultTest.java | 7 +- .../qwp/client/SymbolDictRecycleTest.java | 169 ++++++++++++++++++ 7 files changed, 178 insertions(+), 10 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java index 2d8fab8d..bb15a55d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java @@ -71,7 +71,7 @@ * No production change is expected to make these pass. A failure here means * either the fresh-mirror seeding regressed (a post-recycle connection * started paying for catch-up again) or the recycle's {@code - * sentMaxSymbolId} reset ({@code recycleForDictReset()}'s step 5) leaked + * sentMaxSymbolId} reset ({@code recycleForDictReset()}'s step 6) leaked * onto the ordinary reconnect path, which today never touches that * baseline. */ @@ -174,7 +174,7 @@ public void testRecycleSkipsCatchUpThenUnplannedReconnectBoundsCatchUpToNewEpoch // initial-connect path, guarded by the connected flag, and never // fires here), so the producer's baseline (c, d already at ids 0, 1) // survives the wire boundary and e resumes at id 2. Only - // recycleForDictReset()'s step 5 ever zeroes that baseline; a + // recycleForDictReset()'s step 6 ever zeroes that baseline; a // regression that folded the reset into a path this reconnect DOES // run would re-ship the whole dictionary from deltaStart 0. sender.table("t").symbol("s", "e").longColumn("v", 4L).atNow(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java index 0908e0ff..11421f7a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java @@ -249,7 +249,7 @@ public void testExhaustedDeferredCloseAwaitKeepsThrowingWhileWedged() throws Exc "deferred close did not release the slot lock"); } - // The await runs at step 3, before the step-5 swap: no + // The await runs at step 3, before the step-6 swap: no // epoch may have committed, and every later entry point // re-runs the await and throws the same transient // verdict for as long as the worker stays wedged. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java index 01816540..494c6522 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -54,7 +54,7 @@ * full self-sufficient frames ({@code QwpWebSocketSender.disableDeltaDict}, e.g. after a * recognised mmap access fault on the persisted dictionary -- see {@link MmapFaultDegradesTest}) * is not stuck there forever. {@code recycleForDictReset()} rebuilds the cursor engine from - * scratch (step 5), and a fresh engine re-derives {@code deltaDictEnabled} independently of + * scratch (step 4), and a fresh engine re-derives {@code deltaDictEnabled} independently of * whatever the outgoing epoch's engine decided -- so once the underlying fault clears, the * next recycle heals the sender back into delta mode. If the fault has not cleared, the fresh * engine simply degrades again on its own first append: a normal, catchable diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java index 00b47421..0b461733 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleRefusalTest.java @@ -448,7 +448,7 @@ public void testManualResetBeforeFirstConnectDeferred() throws Exception { * call recycles -- observed here to fire deterministically, not * probabilistically, once those guards clear. It is compatible with * {@code reset()}'s own reclaim: the swap replaces the whole dictionary - * object outright (step 5 of {@code recycleForDictReset()}), so + * object outright (step 6 of {@code recycleForDictReset()}), so * whatever {@code truncateTo} did to the outgoing instance is moot -- * the swap subsumes it. */ diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java index af0b243e..6b89e274 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java @@ -57,7 +57,7 @@ /** * The two verdicts {@code QwpWebSocketSender.completeRecycleRebuild} reaches - * when the recycle's step-5 rebuild comes back + * when the recycle's step-4 rebuild comes back * {@code wasRecoveredFromDisk()} -- i.e. when the outgoing engine's * fully-drained close did NOT leave the slot empty. *

      diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java index 76919f48..d95e0872 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java @@ -55,13 +55,12 @@ public class SymbolDictRecycleStep7FaultTest { /** * Pins that a step-7 (reconnect) failure does NOT latch the sender * terminal: the swap has committed, the sender is coherent and merely - * disconnected, and the next send retries the deferred setup. The test - * the PR body credited was deleted by commit 6793928a. + * disconnected, and the next send retries the deferred setup. */ @Test public void testStep7FailureDoesNotLatchAndRecovers() throws Exception { assertMemoryLeak(() -> { - String sfDir = temporaryFolder.newFolder("step7-c5").getAbsolutePath(); + String sfDir = temporaryFolder.newFolder("step7-reconnect").getAbsolutePath(); try (TestWebSocketServer server = ackingServer()) { try (Sender sender = Sender.fromConfig(cfg(server, sfDir))) { QwpWebSocketSender ws = (QwpWebSocketSender) sender; @@ -105,7 +104,7 @@ public void testStep7FailureDoesNotLatchAndRecovers() throws Exception { @Test public void testPostFailedReconnectDeltaCoversStagedSymbolIds() throws Exception { assertMemoryLeak(() -> { - String sfDir = temporaryFolder.newFolder("step7-c4").getAbsolutePath(); + String sfDir = temporaryFolder.newFolder("step7-staged-ids").getAbsolutePath(); CapturingAckHandler handler = new CapturingAckHandler(); try (TestWebSocketServer server = new TestWebSocketServer(handler)) { server.start(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index 630a778d..e7aeea5d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client; import io.questdb.client.Sender; +import io.questdb.client.SenderError; import io.questdb.client.SenderErrorHandler; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; @@ -33,7 +34,10 @@ import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderConnectionDispatcher; import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher; import io.questdb.client.std.Files; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; @@ -450,6 +454,83 @@ public CursorSendEngine rebuild(SenderErrorHandler liveHandler) { }); } + /** + * The builder's rebuild factory ({@code Sender.build()}) is the half that + * actually forwards the live handler into {@code constructEngineOnSlot} -> + * {@code quarantineTornSlot}, and that hand-off is only observable when a + * rebuild really has a slot to set aside: the notification is dispatched + * synchronously from inside the rebuild, to whatever handler it was handed. + * Step 3's fully-drained close leaves the slot empty, so the fixture plants + * {@code SegmentSkipQuarantineTest}'s tainted slot -- several never-acked + * segments with the oldest one's magic overwritten, so recovery must skip + * it -- into that empty slot just before the real factory runs. The sender + * is built with no error handler at all, so a regression that forwarded the + * BUILD-time handler instead of the live one would leave the recycle green + * and the data-loss notification nowhere. + */ + @Test + public void testRebuildTimeQuarantineReachesTheHandlerInstalledAfterBuild() throws Exception { + assertMemoryLeak(() -> { + String taintedSfDir = temporaryFolder.newFolder("rebuild-quarantine-tainted").getAbsolutePath(); + writeSlotWithCorruptedOldestSegment(taintedSfDir); + final String taintedSlot = taintedSfDir + "/default"; + + String sfDir = temporaryFolder.newFolder("rebuild-quarantine-live").getAbsolutePath(); + final String slotPath = sfDir + "/default"; + final List errors = new CopyOnWriteArrayList<>(); + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + server.getPort() + + ";sf_dir=" + sfDir + ";")) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + ws.setErrorHandler(errors::add); + + sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); + Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); + + final QwpWebSocketSender.EngineRebuildFactory real = ws.getEngineRebuildFactoryForTesting(); + ws.setEngineRebuildFactory(new QwpWebSocketSender.EngineRebuildFactory() { + @Override + public CursorSendEngine rebuild() { + plantSlotContents(taintedSlot, slotPath); + return real.rebuild(); + } + + @Override + public CursorSendEngine rebuild(SenderErrorHandler liveHandler) { + plantSlotContents(taintedSlot, slotPath); + return real.rebuild(liveHandler); + } + }); + + sender.resetSymbolDictionary(); + sender.table("t").symbol("s", "b").longColumn("v", 2L).atNow(); + Assert.assertEquals("the recycle must have committed", 1, ws.getSymbolDictEpoch()); + Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); + } + } + + SenderError quarantine = null; + for (int i = 0, n = errors.size(); i < n; i++) { + if (errors.get(i).getCategory() == SenderError.Category.DATA_LOSS) { + quarantine = errors.get(i); + break; + } + } + Assert.assertNotNull("the rebuild's quarantine must reach the handler installed AFTER " + + "build(); it is the only programmatic channel telling the application that " + + "the set-aside bytes need resending [errors=" + errors + ']', quarantine); + Assert.assertTrue("the notification must name where the bytes went [msg=" + + quarantine.getServerMessage() + ']', + quarantine.getServerMessage() != null + && quarantine.getServerMessage().contains("slot set aside at")); + Assert.assertNotNull("getQuarantinedPath() is the programmatic answer to \"where are " + + "my bytes\"", quarantine.getQuarantinedPath()); + Assert.assertTrue("the quarantined path must name the set-aside dir [path=" + + quarantine.getQuarantinedPath() + ']', + quarantine.getQuarantinedPath().contains("unreplayable-")); + }); + } + /** * A producer thread whose interrupt flag is already set makes step 2's * loop close throw deterministically (CountDownLatch.await throws on @@ -575,6 +656,94 @@ private static List listDir(String dir) { return names; } + /** + * Overwrites the 4-byte {@code FILE_MAGIC} field at offset 0 so + * {@code MmapSegment.openExisting} throws at the magic check, landing in + * {@code SegmentRing}'s per-file skip arm without disturbing any other byte. + * {@code SegmentSkipQuarantineTest}'s technique, unchanged. + */ + private static void corruptMagic(String path) { + int fd = Files.openRW(path); + Assert.assertTrue("openRW failed", fd >= 0); + long buf = Unsafe.malloc(4, MemoryTag.NATIVE_DEFAULT); + try { + Unsafe.getUnsafe().putInt(buf, 0xBADBAD00); + Files.write(fd, buf, 4, 0); + } finally { + Unsafe.free(buf, 4, MemoryTag.NATIVE_DEFAULT); + Files.close(fd); + } + } + + /** + * Copies a slot's recoverable content into {@code slotPath}, creating it if + * the outgoing close removed it. Both lock files are left behind: the + * directory-local {@code .lock} belongs to the engine about to be built, + * and the logical lock lives outside the slot directory entirely. + */ + private static void plantSlotContents(String sourceSlot, String slotPath) { + try { + java.nio.file.Path target = Paths.get(slotPath); + java.nio.file.Files.createDirectories(target); + java.nio.file.DirectoryStream entries = + java.nio.file.Files.newDirectoryStream(Paths.get(sourceSlot)); + try { + for (java.nio.file.Path entry : entries) { + String name = entry.getFileName().toString(); + if (".lock".equals(name) || ".lock.pid".equals(name)) { + continue; + } + java.nio.file.Files.copy(entry, target.resolve(name), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } finally { + entries.close(); + } + } catch (IOException e) { + throw new RuntimeException("could not plant the slot contents", e); + } + } + + /** + * {@code SegmentSkipQuarantineTest}'s fixture, written under {@code sfDir}: + * a real slot produced against a never-acking server so several segments + * survive on disk unacked, with the oldest one's magic bytes then + * overwritten. {@code sf_max_segment_bytes} forces a genuine rotation, so + * the corruption is a skip among data-bearing survivors rather than the + * only file present. + */ + private static void writeSlotWithCorruptedOldestSegment(String sfDir) throws Exception { + try (TestWebSocketServer silent = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() { + })) { + silent.start(); + Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS)); + String pad = TestUtils.repeat("x", 64); + String cfg = "ws::addr=localhost:" + silent.getPort() + + ";sf_dir=" + sfDir + + ";sf_max_segment_bytes=512" + + ";close_flush_timeout_millis=0;"; + try (Sender s = Sender.fromConfig(cfg)) { + for (int i = 0; i < 20; i++) { + s.table("foo").stringColumn("p", pad).longColumn("v", i).atNow(); + s.flush(); + } + } + } + String slot = sfDir + "/default"; + String oldest = slot + "/sf-initial.sfa"; + Assert.assertTrue("setup: nothing acked, so sf-initial.sfa must survive", Files.exists(oldest)); + int segments = 0; + List names = listDir(slot); + for (int i = 0, n = names.size(); i < n; i++) { + if (names.get(i).endsWith(".sfa")) { + segments++; + } + } + Assert.assertTrue("setup: the slot must hold more than one segment so the corruption is a " + + "skip among survivors [names=" + names + ']', segments > 1); + corruptMagic(oldest); + } + private static String cfg(TestWebSocketServer server) { return "ws::addr=localhost:" + server.getPort() + ";"; }