diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index bfef5189..5852ad67 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; @@ -610,8 +611,12 @@ 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} 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.) *
* Snapshot accessor: for a bounded blocking wait, use * {@link #awaitAckedFsn(long, long)}. @@ -688,6 +693,23 @@ 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. + *

+ * 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. + *

+ * 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() { + } + /** * Clear the internal buffers, discarding any unsent data. *
@@ -1087,6 +1109,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 = QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_ENABLED; + 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 +1570,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 @@ -1599,77 +1630,21 @@ 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. - 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); - } + // Recovery-verdict handling lives in 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; @@ -1712,7 +1687,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 @@ -1765,6 +1743,24 @@ public Sender build() { // dispatcher daemon, drainer pool, microbatch buffers and // WebSocketClient inside the abandoned `connected`. connected.setTransactional(transactional); + 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 @@ -1900,6 +1896,83 @@ 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. + *

+ * Switching it off also disables the manual valve: + * {@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) { + 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. 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. + */ + 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, on how long a triggered symbol-dictionary + * recycle stays armed before it may block the calling thread to force + * progress. Once a recycle has been armed for longer than this window + * without an opportunistic (idle) drain, the NEXT row-start call + * ({@code table(...)}) blocks the calling thread for up to this many + * millis waiting for the outstanding backlog to drain, then recycles + * before returning. If the backlog still has not drained by the + * deadline, that call gives up (logging a warning) and returns without + * blocking further -- the recycle stays armed and is retried + * opportunistically on a later {@code table(...)} call that happens to + * find the backlog already drained. {@code 0} disables blocking + * entirely (opportunistic-only): the recycle then only ever runs when a + * {@code table(...)} call finds the backlog already drained on its own. + *

+ * 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 @@ -3132,6 +3205,135 @@ 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 ConstructedEngine 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 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 + // 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 new ConstructedEngine(cursorEngine, quarantined); + } + + /** + * {@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. 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, + 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).engine; + } + } + /** * Sets a slot aside that either connect() (a symbol dictionary that cannot cover its * surviving frames, {@code UnreplayableSlotException}) or the @@ -3778,6 +3980,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"); @@ -4053,6 +4279,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")); } @@ -4122,6 +4354,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()); @@ -4237,6 +4479,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/GlobalSymbolDictionary.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java index 3e9fa1a3..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,7 +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"); + + "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 7aed1419..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 @@ -137,6 +137,21 @@ 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: 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. + 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); @@ -144,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; @@ -247,10 +269,17 @@ 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; + // 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 @@ -286,6 +315,9 @@ public class QwpWebSocketSender implements Sender { // while the producer thread reads it from sendRow without // holding the sender monitor. private volatile int effectiveAutoFlushBytes; + // 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 @@ -293,7 +325,45 @@ public class QwpWebSocketSender implements Sender { 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. 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; + // 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 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. 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 @@ -347,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; @@ -367,6 +438,112 @@ public class QwpWebSocketSender implements Sender { // 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; + // 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). + private int resetThresholdSymbols = DEFAULT_SYMBOL_DICT_RESET_THRESHOLD_SYMBOLS; + // 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 + // 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 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, + // 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 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; 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; + // Incremented once per completed starvation wait that timed out without + // the backlog draining (maybeBlockForStarvedReset's deadline branch). 0 + // until the first such timeout. volatile: this is public API (see + // 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. 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. + private long recycleDeferredCloseMaxWaitMillis = RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS; + // 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: 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 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. + 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 + // catch that closes and nulls the fresh loop, i.e. the failed-reconnect + // 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 + // 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 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 + // future change ever rolls the epoch by some path other than a recycle. + // volatile for the same reason as symbolDictEpoch. + private volatile long symbolDictResetsPerformed; private long reconnectInitialBackoffMillis = CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS; private long reconnectMaxBackoffMillis = @@ -389,7 +566,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 @@ -746,12 +926,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. */ @@ -778,7 +963,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, @@ -797,6 +985,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; @@ -938,30 +1129,50 @@ public void atNow() { @Override public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { checkNotClosed(); - if (cursorEngine == null) { - return targetFsn < 0L; + checkRecycleFailure(); + // 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 // 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 (cursorEngine.ackedFsn() >= targetFsn) { + 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 (engine.ackedFsn() >= targetFsn) { return true; } if (timeoutMillis <= 0L) { return false; } long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; - while (cursorEngine.ackedFsn() < targetFsn) { - cursorEngine.checkDurability(); - if (cursorSendLoop != null) { - cursorSendLoop.checkError(); + while (engine.ackedFsn() < targetFsn) { + engine.checkDurability(); + loop = cursorSendLoop; + if (loop != null) { + loop.checkError(); } checkConnectionError(); if (System.nanoTime() >= deadlineNanos) { @@ -1180,10 +1391,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 @@ -1249,7 +1464,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 @@ -1262,7 +1477,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); } } @@ -1361,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. *

@@ -1609,6 +1825,7 @@ public QwpWebSocketSender floatColumn(CharSequence columnName, float value) { */ @Override public void flush() { + checkRecycleFailure(); flushAndGetSequence(); } @@ -1627,6 +1844,7 @@ public void flush() { @Override public long flushAndGetSequence() { checkNotClosed(); + checkRecycleFailure(); if (cursorEngine != null) { cursorEngine.checkDurability(); } @@ -1658,7 +1876,7 @@ public long flushAndGetSequence() { checkConnectionError(); long afterFsn = cursorEngine != null ? cursorEngine.publishedFsn() : -1L; - return afterFsn > beforeFsn ? afterFsn : -1L; + return afterFsn > beforeFsn ? fsnEpochBase + afterFsn : -1L; } /** @@ -1687,8 +1905,10 @@ public long flushAndGetSequence() { */ @Override public boolean drain(long timeoutMillis) { + checkRecycleFailure(); 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); } @@ -1767,15 +1987,30 @@ 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. 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)}. */ @Override public long getAckedFsn() { - return cursorEngine != null ? cursorEngine.ackedFsn() : -1L; + // 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. 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 ? Math.max(lastRecycleDurableFsn, base + engine.ackedFsn()) : lastRecycleDurableFsn; } /** @@ -1878,6 +2113,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 @@ -1966,6 +2211,27 @@ 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; + } + + /** + * 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; + } + @TestOnly public QwpTableBuffer getTableBuffer(String tableName) { QwpTableBuffer buffer = tableBuffers.get(tableName); @@ -1981,15 +2247,132 @@ 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() { 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() { + return resetEnabled; + } + + /** Current value of {@link #fsnEpochBase}. */ + @TestOnly + public long getFsnEpochBaseForTest() { + return fsnEpochBase; + } + + /** + * 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 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 + * 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 + * advanced while {@link #getSymbolDictResetsPerformed()} still reflects + * the prior value, even though the producer thread writes them on + * adjacent lines. + */ + public long getSymbolDictEpoch() { + return symbolDictEpoch; + } + + /** + * 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 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 + * 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 + * 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. 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; + } + + /** + * 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. 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) { + 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. + *

+ * 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; + } + /** * Total binary frames whose ACKs have been received and applied. */ @@ -2053,9 +2436,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; @@ -2063,7 +2452,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; @@ -2071,9 +2467,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; @@ -2081,7 +2484,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; @@ -2089,7 +2499,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; @@ -2402,6 +2819,33 @@ public void reset() { cachedTimestampNanosColumn = null; } + /** + * Advisory request to start a fresh symbol-dictionary epoch. Sets + * {@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 + * on that knob, so a sender configured with the recycle disabled never + * 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() { + 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 @@ -2533,6 +2977,21 @@ 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; + } + /** * Configure the user-supplied error handler. May be called either before * or after {@code connect()} — when called after, the change propagates @@ -2562,6 +3021,16 @@ 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; + } + public void setTransactional(boolean transactional) { this.transactional = transactional; } @@ -2741,6 +3210,12 @@ public QwpWebSocketSender symbol(CharSequence columnName, CharSequence value) { @Override public QwpWebSocketSender table(CharSequence tableName) { checkNotClosed(); + checkRecycleFailure(); + if (recycleResume != RecycleResume.NONE) { + resumeRecycleIfPending(); + } else if (resetArmed) { + maybeRecycleForDictReset(); + } // Fast path: if table name matches current, skip hashmap lookup if (currentTableName != null && currentTableBuffer != null && Chars.equals(tableName, currentTableName)) { return this; @@ -2838,15 +3313,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) { @@ -3443,6 +3922,35 @@ private void checkNotClosed() { checkConnectionError(); } + /** + * 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 + * {@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) { + 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"); @@ -3496,7 +4004,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) { @@ -3755,6 +4267,7 @@ private void ensureActiveBufferReady() { private void ensureConnected() { checkNotClosed(); + resumeRecycleIfPending(); if (connected) { return; } @@ -3771,7 +4284,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 = hasInitialConnectRun + ? Sender.InitialConnectMode.ASYNC + : initialConnectMode; + switch (effectiveMode) { case SYNC: client = CursorWebSocketSendLoop.connectWithRetry( reconnectFactory, @@ -3788,10 +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 - // initial connect -- before the wire is ever up -- is surfaced - // to the async SenderErrorHandler and latched for a close() - // rethrow, not retried. + // 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. 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: @@ -3818,7 +4341,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 @@ -3842,6 +4366,19 @@ 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(); + } + if (loopStartFault != null) { + loopStartFault.run(); + } cursorSendLoop.start(); } catch (Throwable t) { // start() (or dispatcher construction) failed after cursorSendLoop was @@ -3880,24 +4417,36 @@ 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. 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); connected = true; + hasInitialConnectRun = true; } private void ensureNoInProgressRow() { @@ -4286,6 +4835,579 @@ private void resetTableBuffersAfterFlush() { currentTableBufferSnapshotBytes = 0; pendingRowCount = 0; firstPendingRowTimeNanos = 0; + armIfEligible(); + } + + /** + * Re-evaluates whether the symbol-dictionary recycle should be armed: + * {@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 + * 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 + && engineRebuildFactory != null + && ownsCursorEngine + && (globalSymbolDictionary.size() >= Math.max(resetThresholdSymbols, resetFloorSymbols) + || manualResetRequested); + if (shouldArm && !resetArmed) { + armedSinceNanos = System.nanoTime(); + starvationWaitDoneThisArm = false; + } + 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} 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. + */ + 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); + Runnable witness = deferredCloseParkWitness; + if (witness != null) { + witness.run(); + } + 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 yet reclaim " + + "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"); + } + outgoing.ensureFlockReleaseRetryScheduled(); + java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + } + } + + private void closeRecoveredEngine(CursorSendEngine recovered) { + recyclePendingOutgoing = recovered; + try { + 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; + } + + /** + * 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 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()) { + // 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()) { + throw latchRecycleBreach(rebuilt, dictSizeAtSwap); + } + 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"); + 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); + } + closeRecoveredEngine(rebuilt); + throw new LineSenderException( + "symbol dictionary recycle keeps recovering leftover acked segments " + + "(slot cleanup not durable yet); retried on the next send"); + } + } + // 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(Math.max(dictSizeAtSwap, 64)); + sentMaxSymbolId = -1; + currentBatchMaxSymbolId = -1; + lastCommitBoundaryFsn = -1L; + symbolDictEpoch++; + symbolDictResetsPerformed++; + resetArmed = false; + manualResetRequested = false; + // 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; 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; + // 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 + * {@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; + } + + /** + * 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; + 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; + } + + /** + * 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; " + + "staying armed", resetMaxWaitMillis); + return; + } + java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + } + recycleForDictReset(); + } + + /** + * Evaluates whether the barrier in {@link #table(CharSequence)} may run + * the symbol-dictionary recycle right now. Only ever called with + * {@link #resetArmed} true -- {@link #armIfEligible()} already refused to + * arm a sender that cannot rebuild, so this method only has to weigh + * producer-side state. + *

+ * 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 + * {@link #maybeBlockForStarvedReset()}'s 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(); + } + } + + private CursorSendEngine rebuildEngineOrAbandon(String message) { + try { + return engineRebuildFactory.rebuild(userErrorHandler()); + } 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 + * {@link #maybeRecycleForDictReset()} has proven the ring is drained. + * 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 + * 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.
  4. + *
  5. 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. Awaits the (possibly + * deferred) release -- see below.
  6. + *
  7. 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.
  8. + *
  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 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 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.
  12. + *
  13. 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.
  14. + *
+ * 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. The tail then awaits + * that deferred release (bounded by + * {@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. + *

+ * 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 + * 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 6, and + * {@link #maybeRecycleForDictReset()} requires {@code connected}. + */ + private void recycleForDictReset() { + final long lastPublishedFsn = cursorEngine.publishedFsn(); // step 1 + 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; + } + // step 2: close the loop - joins the I/O thread, closes the client. + try { + if (cursorSendLoop != null) { + 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; + } catch (Error e) { + throw e; + } catch (Throwable t) { + // 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); + 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 + // 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 { + outgoing.setSlotLockReleaseListener(null); + outgoing.close(); + } catch (Error e) { + throw e; + } catch (Throwable t) { + // A throw with the terminal cleanup nevertheless completed (the + // 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 + // 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) { + throw rethrowRecycleAbandoned(t, "the outgoing I/O loop is still stopping; " + + "retried on the next send"); + } + 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; + } + // 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()); + } + + /** + * 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; + } + if (t instanceof LineSenderException) { + throw (LineSenderException) t; + } + throw new LineSenderException(t).put(message); } /** @@ -4335,6 +5457,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 @@ -4348,8 +5475,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 @@ -4372,8 +5501,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); } @@ -4711,14 +5841,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; + } } /** @@ -4923,7 +6067,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); } } @@ -4933,6 +6079,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 @@ -5059,6 +6206,33 @@ 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(); + + /** + * 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(); + } + } + + /** + * 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/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/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/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..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 @@ -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) { @@ -1497,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 @@ -1786,8 +1821,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 +1861,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 +1993,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 +2001,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 +2107,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 +2135,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 +3881,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 +4005,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/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..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 @@ -92,8 +92,17 @@ 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; + *

+ * 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. 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; /** * 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/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/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/DeltaDictCeilingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCeilingTest.java index d9ab6026..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 @@ -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,54 @@ 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. + *

+ * 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 { + 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); + } + + 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()")); + } + } + } + }); + } + 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); } } 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..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 @@ -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,125 @@ 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;")); + } + + /** + * 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/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/SymbolDictRecycleArmingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java new file mode 100644 index 00000000..d54433a8 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java @@ -0,0 +1,366 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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.Collections; +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} -- threshold-based + * 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, ...)} + * 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. + *

+ * This {@code connect(...)} overload installs no {@link + * io.questdb.client.cutlass.qwp.client.QwpWebSocketSender.EngineRebuildFactory + * 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. + */ + @Test + public void testDoesNotArmWithoutRebuildFactory() 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( + 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", "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: 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()); + 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; "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("dictionary has 2 entries, still below the threshold of 3", + sender.isResetArmed()); + + // No manual resetSymbolDictionary() call anywhere in this test: crossing + // the threshold, even while degraded, still must not arm -- this + // 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", + 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 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(() -> { + // 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); + } + } +} 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..bb15a55d --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleCatchUpSkipTest.java @@ -0,0 +1,380 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 the recycle's {@code + * sentMaxSymbolId} reset ({@code recycleForDictReset()}'s step 6) leaked + * onto the ordinary reconnect path, which today never touches that + * baseline. + */ +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.getSymbolDictEpoch()); + + // 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()); + + 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 + // 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. + // 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 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(); + 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.getSymbolDictEpoch()); + + // 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.getSymbolDictEpoch()); + + 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); + } + } + } +} 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..11421f7a --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleDeferredCloseTest.java @@ -0,0 +1,446 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 fail the recycle for what is + * usually a transient disk stall. Exhausting the await budget does not latch + * 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. + *

+ * 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()); + + // 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 + // 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 { + 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()); + } 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 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 testExhaustedDeferredCloseAwaitKeepsThrowingWhileWedged() 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 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"); + } + + // 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. + Assert.assertEquals("the swap must not have committed", + 0, ws.getSymbolDictEpoch()); + try { + sender.table("t"); + 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()); + + // 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()); + + // 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(); + } + } + if (auxErr.get() != null) { + throw new AssertionError("auxiliary thread failed", auxErr.get()); + } + } + }); + } + + /** + * 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 + * 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. + */ + @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(); + 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 new file mode 100644 index 00000000..3266fc73 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java @@ -0,0 +1,498 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +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 + * 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. + *

+ * 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 { + 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(); + } + } + }); + } + + @Test + public void testPostRollSequencesExceedAllPreRoll() throws Exception { + 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(); + } + } + }); + } + + @Test + public void testGetAckedFsnMonotoneAcrossRoll() throws Exception { + 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(); + } + } + }); + } + + /** + * 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 { + 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(); + } + } + }); + } + + /** + * {@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 { + 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", 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 { + 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)); + } + 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 { + 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)); + } + + // 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; + } + 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 { + 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/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java new file mode 100644 index 00000000..494c6522 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -0,0 +1,477 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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 + * {@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)); + // 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 re-arm floor", + 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.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()); + + // 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 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)); + + 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.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 + 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.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()); + + // 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.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 + * 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. + *

+ * 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); + + @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) { + 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 new file mode 100644 index 00000000..b148df44 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleMemoryModeTest.java @@ -0,0 +1,340 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.getSymbolDictEpoch()); + + // Ring drained, no row in progress: this table() call must + // 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(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); + } + + 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.getSymbolDictEpoch()); + + // 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.getSymbolDictEpoch()); + 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). {@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 { + 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.getSymbolDictEpoch()); + + // 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.getSymbolDictEpoch()); + + // 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, 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); + 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); + } + } + } +} 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..2f8b43d6 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleOutageTest.java @@ -0,0 +1,534 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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 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. + *

+ * (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) -- 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 testSyncModeRecycleDoesNotBlockProducerDuringOutage() 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.getSymbolDictEpoch()); + + // 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); + + // 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()); + } + } + } + }); + } + + /** + * Default configuration: no {@code reconnect_*} knob and no + * {@code initial_connect_retry}, so the builder resolves + * {@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 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 testDefaultConfigRecycleBuffersThroughOutage() 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(); + + // 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.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()); + Assert.assertFalse("a committed swap disarms", ws.isResetArmed()); + + // 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)); + + 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 fresh connection's first frame must carry a " + + "fresh (empty) dictionary, not a, b", + 0, revivedHandler.firstFrameDeltaStart); + 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("c", "e"), 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.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.getSymbolDictEpoch()); + + 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); + } + } +} 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..0b461733 --- /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 getSymbolDictEpoch()} 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.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.getSymbolDictEpoch()); + 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.getSymbolDictEpoch()); + + // 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); + } + } + }); + } + + /** + * 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.getSymbolDictEpoch()); + + // 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.getSymbolDictEpoch()); + 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.getSymbolDictEpoch()); + + 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); + } + } + }); + } + + /** + * 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.getSymbolDictEpoch()); + + // 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.getSymbolDictEpoch()); + 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.getSymbolDictEpoch()); + 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.getSymbolDictEpoch()); + + // 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.getSymbolDictEpoch()); + + 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); + } + } + }); + } + + /** + * 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.getSymbolDictEpoch()); + 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.getSymbolDictEpoch()); + + 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); + } + } + }); + } + + /** + * 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.getSymbolDictEpoch()); + 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.getSymbolDictEpoch()); + 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.getSymbolDictEpoch()); + + // 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.getSymbolDictEpoch()); + + 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(); + } + } + }); + } + + /** + * {@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 6 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.getSymbolDictEpoch()); + 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.getSymbolDictEpoch()); + + // 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); + } + } + }); + } + + /** + * 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(); + } + } +} 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..6b89e274 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java @@ -0,0 +1,514 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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; + +/** + * The two verdicts {@code QwpWebSocketSender.completeRecycleRebuild} reaches + * 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. + *

+ * 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(); + + /** + * 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); + } + } + }); + } + + /** + * 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()); + } + }); + } + + /** + * 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); + } + } + } +} 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..cd8d691e --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStarvationTest.java @@ -0,0 +1,539 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.getSymbolDictResetStarvationTimeouts()); + + // 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.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 -- + // 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(); + // 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 { + try { + Thread.sleep(releaseDelayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + handler.releaseAcks(); + }); + releaser.start(); + + long elapsedMs; + try { + long t0 = System.nanoTime(); + sender.table("t"); // blocks, then recycles once the ack lands + elapsedMs = (System.nanoTime() - t0) / 1_000_000; + } finally { + // Join even if table() throws unexpectedly, so an assertion + // failure below never leaks a non-daemon thread. + 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.getSymbolDictEpoch()); + Assert.assertEquals("a successful drain-and-recycle is not a timeout", + 0L, ws.getSymbolDictResetStarvationTimeouts()); + } + } + }); + } + + /** + * 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.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.getSymbolDictEpoch()); + + // At most one blocking wait per armed window: pendingRowCount is + // still 0 here (nothing added since the flush above) and the ring + // is still undrained, so this table() call reaches + // maybeBlockForStarvedReset() again -- but starvationWaitDoneThisArm + // is already set from the call above, so it must NOT re-block. (A + // probe placed after a pending row would short-circuit on the + // pendingRowCount!=0 guard in maybeRecycleForDictReset() before ever + // reaching the wait, making the "must not re-block" assertion true + // for the wrong reason.) + 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.getSymbolDictResetStarvationTimeouts()); + + // 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(); + + // 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.getSymbolDictEpoch()); + Assert.assertEquals("draining later must not add another timeout", + 1L, ws.getSymbolDictResetStarvationTimeouts()); + } + } + }); + } + + /** + * 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(); + + LineSenderException thrown = null; + long elapsedMs; + try { + long t0 = System.nanoTime(); + try { + sender.table("t"); + } catch (LineSenderException e) { + thrown = e; + } + elapsedMs = (System.nanoTime() - t0) / 1_000_000; + } finally { + // Join even if something unexpected escapes above, so an + // assertion failure never leaks a non-daemon thread. + 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.getSymbolDictResetStarvationTimeouts()); + } 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.getSymbolDictResetStarvationTimeouts()); + Assert.assertEquals(0L, ws.getSymbolDictEpoch()); + + // 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.getSymbolDictEpoch()); + Assert.assertEquals("no wait ever ran in this test", + 0L, ws.getSymbolDictResetStarvationTimeouts()); + } + } + }); + } + + /** + * 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); + } + } + } +} 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..d95e0872 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleStep7FaultTest.java @@ -0,0 +1,225 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.ArrayList; +import java.util.Collections; +import java.util.List; +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. + */ + @Test + public void testStep7FailureDoesNotLatchAndRecovers() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.newFolder("step7-reconnect").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()); + 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(); + long f2 = sender.flushAndGetSequence(); + Assert.assertTrue("post-recovery batch must be acked", + sender.awaitAckedFsn(f2, 5_000)); + } + } + }); + } + + /** + * Pins the conditional in resetSymbolDictStateForNewConnection(): ids a + * row registered before the deferred reconnect completes must still ship + * in the next delta. Empirically untested: suite was green with the + * guard reverted. + */ + @Test + public void testPostFailedReconnectDeltaCoversStagedSymbolIds() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.newFolder("step7-staged-ids").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 -- this race 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(); + 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); + } + } + } + + /** + * 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); + } + } + } +} 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..e7aeea5d --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -0,0 +1,866 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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; +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.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; +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.Collections; +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; + +/** + * 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 -- 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 { + + @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.getSymbolDictEpoch()); + + // The ring is drained (everything acked) and no row is in + // progress, so this table() call must recycle synchronously. + // 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(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); + } + + 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)); + } + }); + } + + /** + * {@code engineRebuildFactory} is only installed by {@code Sender.build()} + * ({@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 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. {@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 { + 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.assertFalse("a sender with no rebuild factory must never arm, not even " + + "for a manual request", + 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.getSymbolDictEpoch()); + Assert.assertFalse("still never armed -- nothing changed that would flip it", + 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.testDoesNotArmWithoutRebuildFactory. + 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.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(); + 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.getSymbolDictEpoch()); + Assert.assertFalse("still never armed -- crossing the threshold again changes " + + "nothing", + sender.isResetArmed()); + } finally { + sender.close(); + } + } + }); + } + + @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 (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(); + Assert.assertNotSame("recycle must swap in a fresh engine instance", + before, after); + // 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()); + + 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.getSymbolDictEpoch()); + + 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); + } + } + }); + } + + /** + * 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 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 (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 testFailedRebuildAbandonsAndRecovers() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + try (Sender sender = Sender.fromConfig(cfg(server))) { + QwpWebSocketSender ws = (QwpWebSocketSender) sender; + QwpWebSocketSender.EngineRebuildFactory real = + ws.getEngineRebuildFactoryForTesting(); + AtomicInteger remainingFaults = new AtomicInteger(1); + ws.setEngineRebuildFactory(() -> { + if (remainingFaults.getAndDecrement() > 0) { + throw new RuntimeException("injected engine rebuild fault"); + } + return real.rebuild(); + }); + + sender.resetSymbolDictionary(); + Assert.assertTrue(ws.isResetArmed()); + try { + sender.table("t"); + Assert.fail("expected the triggering table() call to throw"); + } catch (LineSenderException expected) { + } + // 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)); + } + } + }); + } + + @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()); + } + } + }); + } + + /** + * 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 + * entry). That must abandon the recycle non-terminally; once the flag is + * cleared the next call finishes the loop close and the sender recovers. + */ + @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 + * max(threshold, 2 * size-at-swap). + */ + @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(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + 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; + } + + /** + * 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() + ";"; + } + + 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(); + } + + /** 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); + } + } + } +} 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) { 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..0f4a5e97 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java @@ -0,0 +1,617 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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: + * + * + *

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): + * + * + *

(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. + * (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 + * 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 { + + /** + * 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(); + + /** + * 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; + 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)) { + 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.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(); + 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(); + 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)) { + 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.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 -- + // 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)) { + 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()); + } + // 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)); + }); + } + + /** + * 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; + 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)) { + 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.getSymbolDictEpoch()); + + // 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); + } + } + restoreDir(slot, snapshot); + + Assert.assertEquals("the restored image is exactly a freshly rebuilt (never " + + "flushed) engine's own state files", + FRESH_REBUILD_FILES, 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.getSymbolDictEpoch()); + 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.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", + 1L, recovered.recoveredMaxSymbolId()); + + 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; + } + + /** + * 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<>(); + 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; + } + + /** 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 (QwpWireTestUtils.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 + } + } +} 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(() -> { 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");