diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 5106da96..bfef5189 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -38,6 +38,13 @@ 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.OrphanScanner; +import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock; +import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentCorruptionException; +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.impl.ConfStringParser; import io.questdb.client.impl.ConfigString; import io.questdb.client.impl.ConfigView; @@ -48,6 +55,7 @@ import io.questdb.client.std.Decimal256; import io.questdb.client.std.Decimal64; import io.questdb.client.std.Files; +import io.questdb.client.std.FilesFacade; import io.questdb.client.std.IntList; import io.questdb.client.std.Numbers; import io.questdb.client.std.NumericException; @@ -790,12 +798,16 @@ default Sender uuidColumn(CharSequence name, long lo, long hi) { * unconnected sender; the I/O thread runs the same retry loop in * the background. The user thread can call {@code at()} / * {@code flush()} immediately; rows accumulate in the cursor SF - * engine until the wire is up. Connect failures are retried - * indefinitely in the background; a terminal upgrade failure - * (auth reject, capability mismatch) is delivered to the async - * error inbox as a {@link io.questdb.client.SenderError} (no - * synchronous throw on the user call site). Wire - * {@code error_handler=...} to observe these. + * engine until the wire is up. Transport failures (unreachable or + * dropped server) retry indefinitely and are never surfaced -- the + * buffered rows are safe in SF and the server may still appear. A + * terminal auth, upgrade or capability rejection on the initial + * connect (before the wire has ever come up) has no caller left to + * throw at, so it is delivered to the async error inbox as a + * {@link io.questdb.client.SenderError}; wire {@code error_handler=...} + * to observe it. Once the wire has come up even once, SF owns the + * buffered data and the same rejections (e.g. a credential rotation) + * become transients retried indefinitely. * *
* Default resolution when the caller does not pick a value:
@@ -995,6 +1007,12 @@ final class LineSenderBuilder {
// build() time. 0 or negative is a documented "disable" value, so
// a Long.MIN_VALUE sentinel keeps it distinguishable from "unset".
private static final long DURABLE_ACK_KEEPALIVE_NOT_SET = Long.MIN_VALUE;
+ private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(LineSenderBuilder.class);
+ // How many quarantined copies of one slot may pile up under sf_dir before build()
+ // refuses to set aside another. Each is an unreplayable slot a human still has to
+ // look at; accumulating them without bound would turn a disk-space problem into a
+ // second incident.
+ private static final int MAX_QUARANTINE_SLOT_ATTEMPTS = 64;
private static final int MIN_BUFFER_SIZE = AuthUtils.CHALLENGE_LEN + 1; // challenge size + 1;
// sf-client.md section 4.4: the inbox capacity must accommodate the
// distinct error categories in a bursty error stream so that drop-oldest
@@ -1010,6 +1028,16 @@ final class LineSenderBuilder {
private static final int PROTOCOL_TCP = 0;
private static final int PROTOCOL_UDP = 3;
private static final int PROTOCOL_WEBSOCKET = 2;
+ @TestOnly
+ private static volatile Runnable quarantineAfterCloseHook;
+ @TestOnly
+ private static volatile FilesFacade quarantineFilesFacade = FilesFacade.INSTANCE;
+ // Suffix for a slot set aside by quarantineTornSlot. Deliberately NOT the
+ // sender's own slot name, so a restarted sender does not re-adopt it as its own;
+ // quarantineTornSlot then marks it .failed, so the orphan drainer skips it too and
+ // the bytes stay put for a human to inspect and resend.
+ private static final String QUARANTINE_SLOT_SUFFIX =
+ OrphanScanner.QUARANTINE_SLOT_INFIX;
private final ObjList
+ * A cap gap means a symbol already accepted by one node is too large to
+ * re-register on the node the sender just failed over to, because that node
+ * advertises a smaller maximum batch size. On a homogeneous cluster this cannot
+ * happen; it takes a heterogeneous or mid-roll cluster, or an operator lowering
+ * the cap below existing data.
+ *
+ * A foreground sender retries such a gap indefinitely because the larger-cap node
+ * may simply be away. An orphan drainer may quarantine only once the gap has BOTH
+ * recurred many times AND persisted for this long. Raise it for a cluster whose
+ * rolling restarts take longer than the 5-minute default; set it to {@code 0} to
+ * quarantine an orphan slot as soon as the retry count is exhausted.
+ *
+ * WebSocket transport only.
+ */
+ public LineSenderBuilder catchUpCapGapMinEscalationWindowMillis(long millis) {
+ if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
+ throw new LineSenderException("catch_up_cap_gap_min_escalation_window_millis is only supported for WebSocket transport");
+ }
+ if (millis < 0) {
+ throw new LineSenderException("catch_up_cap_gap_min_escalation_window_millis must be >= 0: ").put(millis);
+ }
+ this.catchUpCapGapMinEscalationWindowMillis = millis;
+ 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
@@ -2167,11 +2346,10 @@ public LineSenderBuilder initialConnectMode(InitialConnectMode mode) {
}
/**
- * Opt in to retrying the initial connect with the same backoff /
- * cap / auth-terminal policy as in-flight reconnect. Set true if
- * your deployment expects the server to come up shortly after the
- * sender. Auth failures (HTTP 401/403/non-101) stay terminal in
- * either mode.
+ * Opt in to retrying the initial connect with backoff on the calling
+ * thread, bounded by the configured reconnect cap. Set true if your
+ * deployment expects the server to come up shortly after the sender.
+ * Auth failures (HTTP 401/403/non-101) stay terminal in this SYNC mode.
*
* When this method is not called, the resolution rule documented
* on {@link InitialConnectMode} applies: SYNC implicitly when any
@@ -2476,7 +2654,7 @@ public LineSenderBuilder maxFrameRejections(int rejections) {
* alone can turn a transient outage into a producer-fatal terminal.
* This window guarantees a brief outage a chance to clear (an OK
* at/beyond the suspect resets the detector) first. {@code 0} disables
- * the dwell (legacy immediate escalation at the strike threshold).
+ * the dwell (immediate escalation at the strike threshold).
* Default {@code 5_000} (5 s). WebSocket only.
*/
public LineSenderBuilder poisonMinEscalationWindowMillis(long millis) {
@@ -2512,8 +2690,8 @@ public LineSenderBuilder reconnectMaxBackoffMillis(long millis) {
* with exponential backoff until connect succeeds or this many
* millis elapse, then throws. The background reconnect loop
* (mid-stream outages and async initial connect) does NOT consult
- * this value: it retries indefinitely and halts only on a terminal
- * auth/upgrade error or {@code close()}.
+ * this value: endpoint and transport failures are retried indefinitely
+ * until {@code close()}.
*
* Default {@code 300_000} (5 minutes). Lower for fail-fast startup;
* higher for tolerating a slow server boot. Must be positive: a zero
@@ -2954,6 +3132,143 @@ private static long parseSizeValue(@NotNull StringSink value, @NotNull String na
}
}
+ /**
+ * Sets a slot aside that either connect() (a symbol dictionary that cannot cover its
+ * surviving frames, {@code UnreplayableSlotException}) or the
+ * {@code CursorSendEngine} constructor itself (a corrupt or incomplete durable
+ * chain, {@code SfRecoveryException} / {@code MmapSegmentCorruptionException})
+ * declared terminal, and returns a fresh engine on an empty slot so the producer
+ * can keep producing.
+ *
+ * {@code torn} is the live engine to release, or {@code null} when the verdict came
+ * from the constructor and no engine was ever built -- there is nothing to
+ * {@link CursorSendEngine#close(boolean)} in that case, only the directory to rename.
+ *
+ * Such a slot is unreplayable BY THIS PRODUCER: either its frames reference symbol ids
+ * the recovered dictionary lost (a host/power crash tore the unsynced side-file), so a
+ * producer seeded from the short dictionary would hand those ids to different symbols
+ * and silently misattribute values -- or recovery could not show a skipped segment's
+ * frames were already acked, so replaying would risk seeding the ack cursor past data
+ * that was never delivered. Detecting either is correct and load-bearing -- but simply
+ * THROWING is not a safe response. {@code senderId} defaults to a stable name, so a
+ * restarted process re-adopts the same slot; the engine's close retains a slot that is
+ * not fully drained; and so every subsequent {@code build()} would re-recover the same
+ * slot and throw again -- forever, until an operator deleted the directory by hand. The
+ * application could not construct a Sender at all, and so could not even BUFFER new
+ * rows. That trades a bounded, already-lost batch for an unbounded outage of everything
+ * after it, which inverts the one guarantee store-and-forward exists to give.
+ *
+ * So: rename the slot aside instead, and mark it {@code .failed}. The verdict is
+ * authoritative -- the recovery seed already tried every source of truth (the
+ * persisted prefix AND the surviving frames' own deltas, or the per-segment scan for
+ * a skipped segment), and the orphan drainer's own replay guard uses that same walk, so there
+ * is nothing a drainer could rebuild that the seed did not. {@code markFailed} (below)
+ * therefore quarantines the copy for a human rather than leaving a drainer to retry an
+ * unreplayable slot forever; a full-dictionary-fallback slot never reaches here, because
+ * its dictionary is discarded at recovery and it never throws. The bytes are preserved
+ * on disk for forensics and a manual resend, and the new name -- NOT the sender's own
+ * slot name -- keeps a restarted sender from re-adopting it (and, for the segment-skip case, also
+ * keeps a later recovery from ever re-scanning the individually-renamed {@code .corrupt}
+ * segment inside it). The producer, meanwhile, starts on a clean empty slot and never
+ * notices.
+ *
+ * If the rename fails (a Windows share lock, a read-only mount) there is no way to
+ * free the slot name without destroying data, so fall back to the old behaviour and
+ * throw -- loudly, and never silently dropping bytes.
+ */
+ private static CursorSendEngine quarantineTornSlot(
+ CursorSendEngine torn, RuntimeException cause, String sfDir,
+ String senderId, String slotPath,
+ long sfMaxSegmentBytes, long sfMaxTotalBytes, long sfAppendDeadlineNanos,
+ long sfSyncIntervalNanos,
+ io.questdb.client.SenderErrorHandler errorHandler
+ ) {
+ // The verdict, and the reason, come from the recovery seed -- the only code that
+ // has tried every source of truth. Recomputing them here would mean a second,
+ // independently-drifting notion of "unreplayable".
+ final String detail = cause.getMessage();
+ // Release the slot lock and the dictionary fd before renaming. connect()'s failure
+ // path already closed the engine; close() is idempotent, so make it explicit rather
+ // than depend on that. close(false): build() holds the logical slot lock across this
+ // whole transition -- that is precisely what serialises the rename against a queued
+ // orphan drainer -- so the engine must not unlink it. torn is null when the verdict
+ // came from the constructor itself: no engine was ever built, so there is nothing to
+ // close.
+ if (torn != null) {
+ try {
+ torn.close(false);
+ } catch (Throwable ignored) {
+ // Best-effort, like build()'s other rollback closes. A failed close
+ // here (fsync of a not-fully-drained slot, a torn dictionary fd)
+ // must not abort the quarantine: skipping the rename and markFailed
+ // below would restore the permanent build() brick this method
+ // exists to remove, and replace the recovery verdict in `cause`
+ // with a secondary close failure.
+ }
+ }
+ Runnable hook = quarantineAfterCloseHook;
+ if (hook != null) {
+ hook.run();
+ }
+
+ FilesFacade ff = quarantineFilesFacade;
+ String quarantinePath = null;
+ for (int i = 0; i < MAX_QUARANTINE_SLOT_ATTEMPTS; i++) {
+ String candidate = sfDir + "/" + senderId + QUARANTINE_SLOT_SUFFIX + i;
+ if (!ff.exists(candidate)) {
+ quarantinePath = candidate;
+ break;
+ }
+ }
+ if (quarantinePath == null || ff.rename(slotPath, quarantinePath) != 0) {
+ throw new LineSenderException(
+ detail + "; the affected data must be resent. The slot could not be set aside "
+ + "automatically (" + (quarantinePath == null
+ ? "too many quarantined slots already under " + sfDir
+ : "rename to " + quarantinePath + " failed")
+ + "), so this sender cannot start until "
+ + slotPath + " is moved or removed by hand");
+ }
+ // Mark the quarantined copy so the orphan drainer treats it as a
+ // human-in-the-loop slot rather than silently retrying it forever.
+ OrphanScanner.markFailed(quarantinePath, detail);
+ LOG.error("{} -- the slot has been set aside at {} and the affected data must be resent; "
+ + "this sender continues on a fresh, empty slot at {}",
+ detail, quarantinePath, slotPath);
+ // build() no longer throws for this -- it starts the producer on a fresh slot so
+ // the outage stays bounded. But abandoning buffered rows is precisely the event a
+ // caller must be able to act on, and LOG.error alone cannot carry it: this client
+ // ships slf4j-api with no binding, so an embedding app with no provider gets a NOP
+ // logger and the loss is announced nowhere. Deliver it programmatically too, so an
+ // errorHandler can alert / page / record it. Dispatched synchronously here because
+ // the async SenderErrorDispatcher belongs to the connected sender, which does not
+ // exist yet at build time. A throwing handler must not turn a contained outage back
+ // into a failed build, so swallow anything it raises.
+ if (errorHandler != null) {
+ try {
+ errorHandler.onError(SenderError.dataLoss(
+ detail + " [slot set aside at " + quarantinePath
+ + "; sender continues on a fresh slot at " + slotPath + ']',
+ quarantinePath));
+ } catch (Throwable handlerFailure) {
+ LOG.error("sender error handler threw while reporting a quarantined slot: {}",
+ String.valueOf(handlerFailure));
+ }
+ }
+ return new CursorSendEngine(slotPath, sfMaxSegmentBytes, sfMaxTotalBytes,
+ sfAppendDeadlineNanos, sfSyncIntervalNanos);
+ }
+
+ @TestOnly
+ public static void setQuarantineAfterCloseHookForTest(Runnable hook) {
+ quarantineAfterCloseHook = hook;
+ }
+
+ @TestOnly
+ public static void setQuarantineFilesFacadeForTest(FilesFacade ff) {
+ quarantineFilesFacade = ff == null ? FilesFacade.INSTANCE : ff;
+ }
+
private static int resolveIPv4(String host) {
try {
byte[] addr = InetAddress.getByName(host).getAddress();
@@ -3457,6 +3772,12 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
}
pos = getValue(configurationString, pos, sink, "poison_min_escalation_window_millis");
poisonMinEscalationWindowMillis(parseLongValue(sink, "poison_min_escalation_window_millis"));
+ } else if (Chars.equals("catch_up_cap_gap_min_escalation_window_millis", sink)) {
+ if (protocol != PROTOCOL_WEBSOCKET) {
+ throw new LineSenderException("catch_up_cap_gap_min_escalation_window_millis is only supported for WebSocket transport");
+ }
+ 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("initial_connect_retry", sink)) {
if (protocol != PROTOCOL_WEBSOCKET) {
throw new LineSenderException("initial_connect_retry is only supported for WebSocket transport");
@@ -3729,6 +4050,9 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString)
if (view.has("poison_min_escalation_window_millis")) {
poisonMinEscalationWindowMillis(wsLong(view, v, "poison_min_escalation_window_millis"));
}
+ 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("sf_append_deadline_millis")) {
sfAppendDeadlineMillis(wsLong(view, v, "sf_append_deadline_millis"));
}
@@ -3912,6 +4236,7 @@ public java.util.Map Delivered to user code through two paths:
* Always paired with {@link Policy#ABANDONED}; the two are never
+ * issued apart, and only {@link SenderError#dataLoss} constructs them.
+ * The bytes are preserved on disk — {@link #getQuarantinedPath()}
+ * names where — so the data can be inspected and re-ingested from its
+ * source. This is the event to page on. (The Rust client models the
+ * same verdict as {@code ErrorCode::StoreResendRequired}.)
+ */
+ DATA_LOSS,
/**
* Status byte the client does not recognize — forward compatibility for new server codes.
*/
@@ -221,14 +303,19 @@ public enum Category {
* connect-string per-category {@code on_*_error} → connect-string global {@code on_server_error}
* → spec defaults.
*
- * There is no drop policy by design: the client never silently discards data. A rejected
- * batch is either replayed ({@link #RETRIABLE} / {@link #RETRIABLE_OTHER}) or halts the
- * sender loudly with the bytes preserved on disk ({@link #TERMINAL}).
+ * There is no silent-drop policy by design: the client never discards
+ * data without telling anyone. A rejected batch is replayed
+ * ({@link #RETRIABLE} / {@link #RETRIABLE_OTHER}), halts the sender loudly
+ * with the bytes preserved on disk ({@link #TERMINAL}), or — the one case
+ * where the bytes can never be sent — is abandoned in place and announced
+ * as {@link #ABANDONED}, which is precisely what keeps the abandonment
+ * non-silent.
*
- * {@link Category#PROTOCOL_VIOLATION} is forced {@link #TERMINAL} and
- * {@link Category#UNKNOWN} is forced {@link #RETRIABLE} (fail open: a status byte from a
- * newer server must degrade to retry, not to a dead sender); user overrides for those
- * categories are ignored.
+ * {@link Category#PROTOCOL_VIOLATION} is forced {@link #TERMINAL},
+ * {@link Category#UNKNOWN} is forced {@link #RETRIABLE} (fail open: a
+ * status byte from a newer server must degrade to retry, not to a dead
+ * sender), and {@link Category#DATA_LOSS} is forced {@link #ABANDONED};
+ * user overrides for these categories are ignored.
*/
public enum Policy {
/**
@@ -252,6 +339,21 @@ public enum Policy {
* caller closes and rebuilds it. The rejected bytes remain in the store-and-forward log
* on disk — nothing is silently discarded.
*/
- TERMINAL
+ TERMINAL,
+ /**
+ * The rows are gone. Nothing replays them, and — unlike
+ * {@link #TERMINAL} — nothing throws: no {@link LineSenderServerException}
+ * is latched, and the sender that reported this keeps running (a
+ * quarantining {@code build()} returns a working sender on a fresh,
+ * empty slot). The bytes stay on disk under the path named by
+ * {@link SenderError#getQuarantinedPath()}, for forensics and a manual
+ * resend.
+ *
+ * Issued only with {@link Category#DATA_LOSS} and never resolvable
+ * from user configuration: no resolver or {@code on_*_error} key can
+ * select it or override it away. It reports a fact about bytes already
+ * abandoned, not a choice about how to react.
+ */
+ ABANDONED
}
}
diff --git a/core/src/main/java/io/questdb/client/SenderErrorHandler.java b/core/src/main/java/io/questdb/client/SenderErrorHandler.java
index e8ba7573..f95c187b 100644
--- a/core/src/main/java/io/questdb/client/SenderErrorHandler.java
+++ b/core/src/main/java/io/questdb/client/SenderErrorHandler.java
@@ -32,8 +32,13 @@
* {@code LineSenderBuilder.errorHandler(SenderErrorHandler)}.
*
*
+ * Recovery ({@code QwpWebSocketSender.seedGlobalDictionaryFromPersisted})
+ * replays the persisted entries in id order to rebuild this dictionary. It must
+ * NOT collapse two source strings that decode to the same characters, because
+ * the persisted {@code .symbol-dict}, the on-wire delta and the I/O-thread
+ * catch-up mirror all key on the entry POSITION (id), not on the string. The
+ * only strings that collide this way are malformed lone UTF-16 surrogates,
+ * which the UTF-8 encoder maps to {@code '?'}: {@link #getOrAddSymbol} would
+ * de-dup them and leave this dictionary SHORTER than the persisted entry count,
+ * desyncing the producer's delta baseline from the catch-up mirror (which uses
+ * {@code pd.size()}) and silently misattributing later symbols. Appending
+ * unconditionally keeps {@link #size()} equal to that count. The reverse lookup
+ * keeps the highest id for a colliding string, which is harmless: both ids
+ * encode to the same bytes, so resolving either is equivalent.
+ *
+ * Deliberately NOT capped at {@link QwpConstants#MAX_SYMBOL_DICTIONARY_SIZE}:
+ * recovery replays entries {@link #getOrAddSymbol} already admitted under the
+ * cap, so an over-cap replay is unreachable from data this client wrote.
+ *
+ * @param symbol the recovered symbol string (must not be null)
+ * @return the id assigned (the previous {@link #size()})
+ */
+ public int addRecoveredSymbol(String symbol) {
+ if (symbol == null) {
+ throw new IllegalArgumentException("symbol cannot be null");
+ }
+ int newId = idToSymbol.size();
+ symbolToId.put(symbol, newId);
+ idToSymbol.add(symbol);
+ return newId;
+ }
+
/**
* Clears all symbols from the dictionary.
*
@@ -93,6 +130,7 @@ public int getId(String symbol) {
* @param symbol the symbol string (must not be null)
* @return the global ID for this symbol (>= 0)
* @throws IllegalArgumentException if symbol is null
+ * @throws LineSenderException if the symbol is new and the dictionary already holds {@link QwpConstants#MAX_SYMBOL_DICTIONARY_SIZE} entries
*/
public int getOrAddSymbol(CharSequence symbol) {
if (symbol == null) {
@@ -104,6 +142,22 @@ public int getOrAddSymbol(CharSequence symbol) {
return existingId;
}
+ // The server rejects any delta or catch-up whose deltaStartId + deltaCount
+ // exceeds the protocol cap, and that rejection is terminal for the sender --
+ // in store-and-forward mode it would strand an already-buffered backlog with
+ // no drainer able to deliver it. Refusing the symbol HERE, before the row is
+ // buffered, keeps everything buffered deliverable: the check is > on the
+ // server, so a dictionary of exactly the cap still catches up cleanly.
+ if (idToSymbol.size() >= QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE) {
+ throw new LineSenderException(
+ "global symbol dictionary is full: the QWP protocol caps a sender's distinct symbol values at "
+ + QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE
+ + ". 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");
+ }
+
// Assign new ID — toString() only for new symbols that must be stored
String symbolStr = symbol.toString();
int newId = idToSymbol.size();
@@ -143,4 +197,46 @@ public boolean isEmpty() {
public int size() {
return idToSymbol.size();
}
+
+ /**
+ * Drops every entry at id {@code >= newSize}, returning those ids to the
+ * unassigned space so the next {@link #getOrAddSymbol} reuses them. A no-op
+ * when {@code newSize} is at or above the current {@link #size()}.
+ *
+ * Reusing an id is only safe while nothing else has recorded it. The
+ * persisted {@code .symbol-dict}, the on-wire delta and the send loop's
+ * catch-up mirror all key on the entry POSITION, so an id that any of them
+ * already binds to one string must never be handed to another -- that is the
+ * silent symbol misattribution the dense id space exists to prevent. The
+ * caller owns that proof; see {@code QwpWebSocketSender.reset()}, which
+ * reclaims only above both the sent watermark and the persisted size, and
+ * only in delta mode.
+ *
+ * @param newSize the number of entries to keep, from id 0
+ * @throws IllegalArgumentException if {@code newSize} is negative
+ */
+ public void truncateTo(int newSize) {
+ if (newSize < 0) {
+ throw new IllegalArgumentException("newSize cannot be negative: " + newSize);
+ }
+ int size = idToSymbol.size();
+ if (newSize >= size) {
+ return;
+ }
+ // Release the discarded strings: setPos only moves the cursor, so the
+ // backing array would otherwise keep every reclaimed entry alive.
+ for (int id = newSize; id < size; id++) {
+ idToSymbol.setQuick(id, null);
+ }
+ idToSymbol.setPos(newSize);
+ // CharSequenceIntHashMap has no remove(), so rebuild the reverse index
+ // from the survivors. O(newSize), on a path a caller only reaches when it
+ // is genuinely reclaiming ids -- the steady state never gets here. The
+ // rebuild also preserves addRecoveredSymbol's "highest id wins" rule for
+ // two source strings that decode to the same characters.
+ symbolToId.clear();
+ for (int id = 0; id < newSize; id++) {
+ symbolToId.put(idToSymbol.getQuick(id), id);
+ }
+ }
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java
index aad95f3c..86343772 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java
@@ -76,6 +76,27 @@ public static int varintSize(long value) {
return (64 - Long.numberOfLeadingZeros(value) + 6) / 7;
}
+ /**
+ * Writes {@code value} as an unsigned LEB128 varint directly at native address
+ * {@code addr} and returns the address just past the last byte. The canonical
+ * raw-address varint writer shared by the SF cursor's persisted dictionary and
+ * catch-up frame builder.
+ *
+ * {@code value} must be non-negative: the signed {@code value > 0x7F} loop emits
+ * a SINGLE truncated byte for a negative long, whereas {@link #varintSize}
+ * returns 10 for it -- a size/write mismatch that would corrupt the stream. All
+ * callers pass ids/lengths/counts (non-negative); the assert pins that contract.
+ */
+ public static long writeVarint(long addr, long value) {
+ assert value >= 0 : "unsigned LEB128 varint requires a non-negative value: " + value;
+ while (value > 0x7F) {
+ Unsafe.getUnsafe().putByte(addr++, (byte) ((value & 0x7F) | 0x80));
+ value >>>= 7;
+ }
+ Unsafe.getUnsafe().putByte(addr++, (byte) value);
+ return addr;
+ }
+
@Override
public void close() {
if (bufferPtr != 0) {
@@ -305,6 +326,7 @@ public void putUtf8(CharSequence value) {
*/
@Override
public void putVarint(long value) {
+ assert value >= 0 : "unsigned LEB128 varint requires a non-negative value: " + value;
ensureCapacity(10); // max varint bytes
long addr = bufferPtr + position;
while (value > 0x7F) {
@@ -336,11 +358,7 @@ public void skip(int bytes) {
}
private static void writeVarintDirect(long addr, long value) {
- while (value > 0x7F) {
- Unsafe.getUnsafe().putByte(addr++, (byte) ((value & 0x7F) | 0x80));
- value >>>= 7;
- }
- Unsafe.getUnsafe().putByte(addr, (byte) value);
+ writeVarint(addr, value);
}
private void encodeUtf8(CharSequence value, int utf8Len) {
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java
index ced1a1b5..f38d9e93 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java
@@ -26,6 +26,8 @@
import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer;
import io.questdb.client.std.QuietCloseable;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.std.Vect;
import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.*;
@@ -39,6 +41,16 @@ public class QwpWebSocketEncoder implements QuietCloseable {
private final QwpColumnWriter columnWriter = new QwpColumnWriter();
private NativeBufferWriter buffer;
+ // Byte offsets, within the buffer, of the symbol-dict delta ENTRY region
+ // ([len][utf8]... only, without the two section varints) that beginMessage
+ // last wrote. Let the producer persist those bytes straight to the slot's
+ // .symbol-dict instead of re-encoding the same symbols (see
+ // QwpWebSocketSender.persistNewSymbolsBeforePublish). Valid until the next
+ // beginMessage; stored as offsets so they survive a buffer realloc.
+ private int deltaCount;
+ private int deltaEntriesEnd;
+ private int deltaEntriesStart;
+ private int deltaStart;
// QWP ingress always advertises Gorilla timestamp encoding. The column
// writer still emits a per-column encoding byte and falls back to raw
// values when delta-of-delta overflows int32.
@@ -65,8 +77,8 @@ public void beginMessage(
int batchMaxId
) {
buffer.reset();
- int deltaStart = confirmedMaxId + 1;
- int deltaCount = Math.max(0, batchMaxId - confirmedMaxId);
+ deltaStart = confirmedMaxId + 1;
+ deltaCount = Math.max(0, batchMaxId - confirmedMaxId);
byte headerFlags = (byte) (flags | FLAG_DELTA_SYMBOL_DICT);
byte origFlags = flags;
flags = headerFlags;
@@ -75,10 +87,12 @@ public void beginMessage(
payloadStart = buffer.getPosition();
buffer.putVarint(deltaStart);
buffer.putVarint(deltaCount);
+ deltaEntriesStart = buffer.getPosition();
for (int id = deltaStart; id < deltaStart + deltaCount; id++) {
String symbol = globalDict.getSymbol(id);
buffer.putString(symbol);
}
+ deltaEntriesEnd = buffer.getPosition();
columnWriter.setBuffer(buffer);
}
@@ -90,6 +104,64 @@ public void close() {
}
}
+ /**
+ * Copies one single-table split message from the combined message currently
+ * staged in this encoder. The table body is copied byte-for-byte from its
+ * recorded offset; columns and rows are not encoded again.
+ */
+ public int copySplitMessage(
+ MicrobatchBuffer target,
+ int tableBodyOffset,
+ int tableBodyLength,
+ boolean deferCommit,
+ int confirmedMaxId,
+ int batchMaxId
+ ) {
+ if (target.getBufferPos() != 0) {
+ throw new IllegalStateException("split message target is not empty");
+ }
+ if (tableBodyOffset < deltaEntriesEnd
+ || tableBodyLength < 0
+ || (long) tableBodyOffset + tableBodyLength > buffer.getPosition()) {
+ throw new IllegalArgumentException("table body slice is outside the staged message");
+ }
+
+ int splitDeltaStart = confirmedMaxId + 1;
+ int splitDeltaCount = Math.max(0, batchMaxId - confirmedMaxId);
+ int deltaEntriesLength = splitDeltaEntriesLength(splitDeltaStart, splitDeltaCount);
+ int messageSize = splitMessageSize(
+ tableBodyLength, splitDeltaStart, splitDeltaCount, deltaEntriesLength);
+ target.ensureCapacity(messageSize);
+
+ long source = buffer.getBufferPtr();
+ long destination = target.getBufferPtr();
+ Vect.memcpy(destination, source, HEADER_SIZE);
+
+ byte splitFlags = Unsafe.getUnsafe().getByte(source + HEADER_OFFSET_FLAGS);
+ if (deferCommit) {
+ splitFlags |= FLAG_DEFER_COMMIT;
+ } else {
+ splitFlags &= ~FLAG_DEFER_COMMIT;
+ }
+ Unsafe.getUnsafe().putByte(destination + HEADER_OFFSET_FLAGS, splitFlags);
+ Unsafe.getUnsafe().putShort(destination + 6, (short) 1);
+ Unsafe.getUnsafe().putInt(destination + 8, messageSize - HEADER_SIZE);
+
+ long writeAddress = destination + HEADER_SIZE;
+ writeAddress = NativeBufferWriter.writeVarint(writeAddress, splitDeltaStart);
+ writeAddress = NativeBufferWriter.writeVarint(writeAddress, splitDeltaCount);
+ if (deltaEntriesLength > 0) {
+ Vect.memcpy(writeAddress, source + deltaEntriesStart, deltaEntriesLength);
+ writeAddress += deltaEntriesLength;
+ }
+ Vect.memcpy(writeAddress, source + tableBodyOffset, tableBodyLength);
+ writeAddress += tableBodyLength;
+ assert writeAddress == destination + messageSize;
+
+ target.setBufferPos(messageSize);
+ return messageSize;
+ }
+
public int encode(QwpTableBuffer tableBuffer) {
buffer.reset();
writeHeader(1, 0);
@@ -122,6 +194,33 @@ public QwpBufferWriter getBuffer() {
return buffer;
}
+ /**
+ * Byte length of the symbol-dict delta ENTRY region ({@code [len][utf8]...},
+ * excluding the two section varints) that {@link #beginMessage} last wrote.
+ */
+ public int getDeltaEntriesLen() {
+ return deltaEntriesEnd - deltaEntriesStart;
+ }
+
+ /**
+ * Byte offset, within {@link #getBuffer()}, of the symbol-dict delta ENTRY
+ * region {@link #beginMessage} last wrote.
+ */
+ public int getDeltaEntriesStart() {
+ return deltaEntriesStart;
+ }
+
+ public int getSplitMessageSize(int tableBodyLength, int confirmedMaxId, int batchMaxId) {
+ if (tableBodyLength < 0) {
+ throw new IllegalArgumentException("tableBodyLength must be non-negative");
+ }
+ int splitDeltaStart = confirmedMaxId + 1;
+ int splitDeltaCount = Math.max(0, batchMaxId - confirmedMaxId);
+ int deltaEntriesLength = splitDeltaEntriesLength(splitDeltaStart, splitDeltaCount);
+ return splitMessageSize(
+ tableBodyLength, splitDeltaStart, splitDeltaCount, deltaEntriesLength);
+ }
+
public void setDeferCommit(boolean defer) {
if (defer) {
flags |= FLAG_DEFER_COMMIT;
@@ -144,4 +243,35 @@ public void writeHeader(int tableCount, int payloadLength) {
buffer.putShort((short) tableCount);
buffer.putInt(payloadLength);
}
+
+ private int splitDeltaEntriesLength(int splitDeltaStart, int splitDeltaCount) {
+ if (splitDeltaCount == 0) {
+ return 0;
+ }
+ if (splitDeltaStart != deltaStart || splitDeltaCount != deltaCount) {
+ throw new IllegalStateException("split delta does not match the staged message"
+ + " [stagedStart=" + deltaStart
+ + ", stagedCount=" + deltaCount
+ + ", splitStart=" + splitDeltaStart
+ + ", splitCount=" + splitDeltaCount + ']');
+ }
+ return deltaEntriesEnd - deltaEntriesStart;
+ }
+
+ private int splitMessageSize(
+ int tableBodyLength,
+ int splitDeltaStart,
+ int splitDeltaCount,
+ int deltaEntriesLength
+ ) {
+ long messageSize = (long) HEADER_SIZE
+ + NativeBufferWriter.varintSize(splitDeltaStart)
+ + NativeBufferWriter.varintSize(splitDeltaCount)
+ + deltaEntriesLength
+ + tableBodyLength;
+ if (messageSize > Integer.MAX_VALUE) {
+ throw new OutOfMemoryError("split QWP message size overflow: " + messageSize);
+ }
+ return (int) messageSize;
+ }
}
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 92de0565..7aed1419 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
@@ -47,9 +47,12 @@
import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderConnectionListener;
import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderErrorHandler;
import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderProgressHandler;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderConnectionDispatcher;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderProgressDispatcher;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.UnreplayableSlotException;
import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer;
import io.questdb.client.std.CharSequenceObjHashMap;
@@ -57,6 +60,7 @@
import io.questdb.client.std.Decimal128;
import io.questdb.client.std.Decimal256;
import io.questdb.client.std.Decimal64;
+import io.questdb.client.std.IntList;
import io.questdb.client.std.Misc;
import io.questdb.client.std.Numbers;
import io.questdb.client.std.NumericException;
@@ -153,7 +157,11 @@ public class QwpWebSocketSender implements Sender {
private final QwpWebSocketEncoder encoder;
private final List
+ * Every dictionary chunk carries {@code FLAG_DEFER_COMMIT}, so the server withholds
+ * its ack and CLAMPS the connection's cumulative-ack watermark until the group
+ * commits. Published frames cannot be rolled back, and the batch is deliberately
+ * retained on the failure paths that reach here -- so without this, {@code ackedFsn}
+ * freezes for the connection's whole life: trim stops for every frame, the ring
+ * fills, and each retry appends another full copy of the dictionary (full-dict mode
+ * re-derives {@code deltaBaseline == -1}, so chunking restarts from id 0). The
+ * process stops ingesting until it is restarted, even though the on-disk state
+ * self-heals -- recovery retires a deferred-only tail as an aborted transaction.
+ *
+ * Committing is the same recovery {@code close()} already performs for an orphaned
+ * group, just early enough to keep the producer alive. It is safe for the chunks
+ * themselves: they carry no rows, and re-registering the same ids with the same
+ * strings is not a redefinition. When the failure came from the SPLIT path the
+ * group may also contain some of the batch's table frames, so the commit can apply
+ * a partial batch -- accepted deliberately, because the retained batch's retry
+ * re-sends every table and dedup collapses the overlap, whereas leaving the group
+ * open kills ingestion outright.
+ *
+ * Best-effort: the commit publishes through the same seal path that just failed, so
+ * it can fail too. Its failure is attached to the original as a suppressed cause
+ * rather than replacing it.
+ */
+ private void commitOrphanedDictionaryChunks(Throwable primary) {
+ try {
+ sendCommitMessage();
+ } catch (Throwable t) {
+ if (t != primary) {
+ primary.addSuppressed(t);
}
}
- return tableCount;
}
private Endpoint currentEndpoint() {
@@ -3454,14 +3698,23 @@ private void drainOnClose(boolean errorOwnedByCustomHandler) {
}
if (System.nanoTime() >= deadlineNanos) {
long acked = cursorEngine.ackedFsn();
- LOG.warn("close() drain timed out after {}ms [target={} acked={}], pending data may be lost",
- closeFlushTimeoutMillis, target, acked);
+ // Name the outage the I/O thread is riding out, when there is one. A
+ // foreground sender now retries endpoint-policy rejections indefinitely,
+ // so a revoked token reaches the operator HERE, and blaming timeout
+ // tuning for what is actually an auth failure would misdirect them.
+ CursorWebSocketSendLoop loop = cursorSendLoop;
+ Throwable outage = loop == null ? null : loop.lastReconnectError();
+ LOG.warn("close() drain timed out after {}ms [target={} acked={}], pending data may be lost{}",
+ closeFlushTimeoutMillis, target, acked,
+ outage == null ? "" : "; wire is not draining: " + outage.getMessage());
throw new LineSenderException("close() drain timed out after ")
.put(closeFlushTimeoutMillis).put(" ms [targetFsn=")
.put(target).put(", ackedFsn=").put(acked)
.put("] - server did not acknowledge ")
.put(target - acked)
- .put(" pending batches; data may be lost (use larger closeFlushTimeoutMillis or smaller batches)");
+ .put(outage == null
+ ? " pending batches; data may be lost (use larger closeFlushTimeoutMillis or smaller batches)"
+ : " pending batches; the wire is not draining: " + outage.getMessage());
}
java.util.concurrent.locks.LockSupport.parkNanos(50_000L);
}
@@ -3533,10 +3786,12 @@ private void ensureConnected() {
// Encoder stays at its default (V1 -- the only supported wire
// version today). Frames written before the first successful
// connect commit to V1 because cursor segments are immutable;
- // a future version bump must account for that. Auth/upgrade
- // rejects are surfaced via the error inbox by the I/O
- // thread, not thrown here; plain connect failures retry
- // indefinitely (Invariant B).
+ // 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.
client = null;
break;
case OFF:
@@ -3556,13 +3811,14 @@ private void ensureConnected() {
client, cursorEngine,
0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
reconnectFactory,
- reconnectMaxDurationMillis,
reconnectInitialBackoffMillis,
reconnectMaxBackoffMillis,
requestDurableAck,
durableAckKeepaliveIntervalMillis,
maxFrameRejections,
- poisonMinEscalationWindowMillis);
+ poisonMinEscalationWindowMillis,
+ catchUpCapGapMinEscalationWindowMillis,
+ CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND);
// 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
@@ -3588,6 +3844,17 @@ private void ensureConnected() {
cursorSendLoop.setConnectionDispatcher(connectionDispatcher);
cursorSendLoop.start();
} catch (Throwable t) {
+ // start() (or dispatcher construction) failed after cursorSendLoop was
+ // assigned. Close it so a caller that retries -- re-entering
+ // ensureConnected and reassigning cursorSendLoop above -- cannot orphan
+ // a recovered slot's ctor-seeded native mirror (freed only by close()
+ // or the I/O loop, neither of which has run). close() is idempotent and
+ // frees the mirror via its loopNeverRan path; it also closes the shared
+ // client, so the client.close() below is a safe idempotent no-op.
+ if (cursorSendLoop != null) {
+ cursorSendLoop.close();
+ cursorSendLoop = null;
+ }
if (client != null) {
client.close();
client = null;
@@ -3615,18 +3882,18 @@ private void ensureConnected() {
host, port, client.getServerQwpVersion(), serverMaxBatchSize, effectiveAutoFlushBytes);
} else {
// Async mode: I/O thread will drive the connect. Encoder uses
- // its default version (V1). The symbol-dict watermark still gets
- // reset for consistency with the sync path; the post-connect replay
- // path does not need a producer-side reset signal because every
- // cursor frame is self-sufficient.
+ // 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).
Endpoint ep = endpoints.get(0);
LOG.info("Async 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 symbol-dict
- // watermark. Cursor frames are self-sufficient (every frame carries its
- // full inline schema + a symbol-dict delta from id 0), so post-reconnect
- // replay needs no producer-side reset signal.
+ // 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.
resetSymbolDictStateForNewConnection();
connectionError.set(null);
@@ -3664,7 +3931,7 @@ private void flushPendingRows(boolean deferCommit) {
cachedTimestampNanosColumn = null;
ObjList
+ * Not atomic across frames. The frames publish one at a time, so a
+ * publish failure partway through -- {@link #sealAndSwapBuffer()} throwing on
+ * frame k>1, e.g. a backpressure deadline or the buffer-recycle timeout --
+ * leaves frames 1..k-1 already on the ring as deferred (appended, not yet
+ * committed). The throw propagates past the {@code resetTableBuffersAfterFlush}
+ * at the end of the loop, so the source rows survive in their table buffers
+ * and the NEXT flush re-emits the whole batch; the eventual commit then
+ * commits the already-published prefix alongside the re-sent copies,
+ * delivering those rows at-least-once (duplicated), not exactly-once. This is
+ * within store-and-forward's at-least-once contract -- a DEDUP table or a
+ * durable-ack await absorbs the duplicate, and the symbol-dict state stays
+ * consistent on the retry (the re-sent frames carry empty deltas and the
+ * write-ahead persist is a {@code pd.size()} no-op). Making the split atomic
+ * (rolling back the published prefix, or skipping it on retry) would be a
+ * larger change.
*
* @param deferCommit when true, ALL messages (including the last)
* carry FLAG_DEFER_COMMIT. When false, only the
* last message omits the flag.
*/
- private void flushPendingRowsSplit(ObjList
+ * 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
+ * files while the dictionary is the one thing still growing. Without a way back,
+ * {@code deltaDictEnabled} is written once at {@code setCursorEngine} and every
+ * later {@code flush()} re-throws forever: a condition store-and-forward is built
+ * to survive becomes total, permanent ingestion loss.
+ *
+ * Full self-sufficient frames need no side file at all -- each carries the whole
+ * dictionary from id 0, which is exactly what recovery and orphan-drain replay
+ * against a fresh server. So degrade instead of dying. The producer's monotonic
+ * baseline stops being consulted ({@link #symbolDeltaBaseline()} returns -1), and
+ * the write-ahead persist becomes a no-op.
+ *
+ * Producer-thread only, like every other reader of {@code deltaDictEnabled}.
+ */
+ private void disableDeltaDict(Throwable cause) {
+ if (!deltaDictEnabled) {
+ return;
+ }
+ 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)",
+ cause);
+ }
+
+ /**
+ * Writes the ids the surviving frames contributed above the persisted prefix back
+ * into {@code .symbol-dict}, immediately, before any new frame can be published.
+ *
+ * {@link #seedGlobalDictionaryFromPersisted} can rebuild the producer dictionary from
+ * TWO sources -- the side-file's intact prefix and the surviving frames' own delta
+ * sections -- and then resumes {@code sentMaxSymbolId} at the combined tip. When the
+ * frames contributed anything, that tip is ABOVE {@code pd.size()}, so every frame
+ * published from here on carries a {@code deltaStart} the side-file cannot describe.
+ * That breaks the write-ahead invariant the whole design rests on: the persisted
+ * dictionary must be a superset of every recoverable frame's references.
+ *
+ * The steady-state write-ahead does NOT close that gap on its own. It persists
+ * {@code [pd.size() .. currentBatchMaxSymbolId]} and returns early when the batch's
+ * highest id is below {@code pd.size()}, so it heals only if -- and only as far as --
+ * a later batch happens to reference the recovered high ids. Meanwhile the frames
+ * that carry those ids are the oldest unacked, so they are the FIRST to be acked and
+ * trimmed. Once they are gone, an ordinary process crash (which store-and-forward
+ * promises to survive; only the original tear needs a host crash) leaves a slot whose
+ * frames reference ids nothing holds: recovery marks a gap and {@code build()}
+ * quarantines it with "resend the affected data".
+ *
+ * Healing here, eagerly and in full, restores the invariant before the window opens.
+ */
+ /**
+ * On-wire byte cost of one symbol-dictionary entry, exactly as
+ * {@code NativeBufferWriter.putString} writes it: {@code [varint utf8Len][utf8]}.
+ * Both of that method's branches (the ASCII fast path, which reserves
+ * {@code varintSize(charLen) == varintSize(utf8Len)}, and the two-pass fallback)
+ * produce this size, so the chunker below sizes frames against the same
+ * arithmetic the encoder will use rather than an independent estimate.
+ */
+ private int dictionaryEntryWireBytes(int id) {
+ int utf8Len = NativeBufferWriter.utf8Length(globalSymbolDictionary.getSymbol(id));
+ return NativeBufferWriter.varintSize(utf8Len) + utf8Len;
+ }
+
+ private void healPersistedDictionary(PersistedSymbolDict pd) {
+ if (pd == null || !deltaDictEnabled) {
+ return;
+ }
+ int from = pd.size();
+ int to = globalSymbolDictionary.size() - 1;
+ if (to < from) {
+ return; // the side-file already covers everything the frames defined
+ }
+ try {
+ pd.appendSymbols(globalSymbolDictionary, from, to);
+ } catch (Throwable t) {
+ // A recognised mmap access fault is NOT a JVM failure to propagate: it is how
+ // an unbacked or sparse append page surfaces, and commitMappedChunk uses
+ // Crc32c.updateUnsafe precisely so it arrives catchable here. Rethrowing it
+ // raw skips disableDeltaDict, and from healPersistedDictionary it escapes
+ // Sender.build() as neither UnreplayableSlotException nor LineSenderException,
+ // so the slot is neither quarantined nor reported and every restart re-faults
+ // the same page.
+ if (t instanceof Error && !MmapSegment.isMmapAccessFault(t)) {
+ throw (Error) t;
+ }
+ // Do NOT fail recovery: the surviving frames still carry these ids in their
+ // own deltas, so THIS session replays correctly either way. Only a future
+ // recovery, after those frames are trimmed, would be affected -- and the
+ // degrade below removes even that exposure by dropping back to frames that
+ // need no side file.
+ disableDeltaDict(t);
+ }
+ }
+
+ /**
+ * Appends the symbols this frame introduces ({@code [sentMaxSymbolId+1 ..
+ * currentBatchMaxSymbolId]}) to the slot's persisted dictionary BEFORE the
+ * frame is published to the ring. This write-ahead ordering keeps the
+ * persisted dictionary a superset of every process-crash-recoverable frame's
+ * references, so recovery and orphan-drain can re-register it on a fresh
+ * server. Not fsync'd (see PersistedSymbolDict) -- a host crash that tears it
+ * is caught by the send loop's replay guard. No-op in memory mode (no
+ * persisted dictionary) and when the frame introduces no new symbols.
+ */
+ private void persistNewSymbolsBeforePublish() {
+ if (!deltaDictEnabled || cursorEngine == null) {
+ return;
+ }
+ PersistedSymbolDict pd = cursorEngine.getPersistedSymbolDict();
+ if (pd == null) {
+ return;
+ }
+ // Persist [pd.size() .. currentBatchMaxSymbolId] as ONE write, BEFORE the
+ // frame is published.
+ //
+ // Resume from the dictionary's own durable size, NOT sentMaxSymbolId+1:
+ // the persist advances pd.size() only after a full write, whereas
+ // sentMaxSymbolId only advances after the WHOLE frame is published (via
+ // advanceSentMaxSymbolId, after activeBuffer.write). If a prior persist
+ // threw (short write -- disk full/quota) or the publish threw, the frame
+ // was not published and sentMaxSymbolId stayed put, while the symbols
+ // before the failure are already on disk. Keying the resume point off
+ // sentMaxSymbolId+1 would re-append that persisted prefix on the retry,
+ // duplicating entries and corrupting the dense id->symbol mapping recovery
+ // relies on (position i must be symbol id i). pd.size() resumes exactly
+ // past what is already durable, so the write-ahead is idempotent.
+ int from = pd.size();
+ if (currentBatchMaxSymbolId < from) {
+ return; // nothing new to persist (warm batch, or an idempotent retry)
+ }
+ // Fast path: the frame the encoder just built already holds these symbols
+ // in its delta section as [len][utf8]... -- byte-identical to what
+ // PersistedSymbolDict stores. In the common case pd.size() equals the
+ // frame's delta start id (sentMaxSymbolId+1), so persist those bytes
+ // straight from the frame instead of re-encoding the symbols. After a
+ // failed publish the durable size has run ahead of the wire baseline, so
+ // the frame's delta covers MORE than remains to persist; then re-encode
+ // just the [from .. currentBatchMaxSymbolId] suffix.
+ try {
+ if (from == sentMaxSymbolId + 1) {
+ QwpBufferWriter buffer = encoder.getBuffer();
+ pd.appendRawEntries(
+ buffer.getBufferPtr() + encoder.getDeltaEntriesStart(),
+ encoder.getDeltaEntriesLen(),
+ currentBatchMaxSymbolId - from + 1);
+ } else {
+ pd.appendSymbols(globalSymbolDictionary, from, currentBatchMaxSymbolId);
+ }
+ } catch (Throwable t) {
+ // A failed write to the persisted dictionary throws a low-level
+ // IllegalStateException: in production that is ff.allocate refusing to grow
+ // the mmap append window (how a full disk / exhausted quota surfaces there),
+ // and behind an injected facade a short positioned write. Surface it as a
+ // LineSenderException -- like every other flush-path failure, e.g. the cursor
+ // append in sealAndSwapBuffer -- so a caller catching LineSenderException
+ // around flush() also catches a disk-full during the write-ahead persist. The
+ // persist ran before publish and pd.size() did not advance on the failure, so
+ // the still-buffered rows re-persist the same range idempotently on retry.
+ // A JVM Error is never a persist failure; let it propagate -- except a
+ // recognised mmap access fault, which is NOT a JVM failure to propagate: it
+ // is how an unbacked or sparse append page surfaces, and commitMappedChunk
+ // uses Crc32c.updateUnsafe precisely so it arrives catchable here. Rethrowing
+ // it raw skips disableDeltaDict, and from healPersistedDictionary it escapes
+ // Sender.build() as neither UnreplayableSlotException nor LineSenderException,
+ // so the slot is neither quarantined nor reported and every restart re-faults
+ // the same page.
+ if (t instanceof Error && !MmapSegment.isMmapAccessFault(t)) {
+ throw (Error) t;
+ }
+ // Degrade before throwing, so this failure is survivable rather than terminal:
+ // every LATER flush emits full self-sufficient frames, which need no side file
+ // (see disableDeltaDict). This one flush still has to fail -- beginMessage has
+ // already baked a delta deltaStart into the staged frame, and publishing it
+ // would put ids on the ring that the side-file cannot describe. The throw
+ // precedes every publish, so the caller's rows stay buffered and the next
+ // flush() re-encodes them from id 0.
+ disableDeltaDict(t);
+ throw new LineSenderException("failed to persist symbol dictionary before publish; "
+ + "this sender has switched to full self-sufficient frames -- retry the flush", t);
+ }
+ }
+
+ /**
+ * Publishes symbol ids {@code [from, batchMaxId]} as DEFERRED, table-less
+ * frames, each chunked under {@code cap}, so the batch's data frames can then
+ * encode against an empty delta.
+ *
+ * Why this exists. A full-dictionary frame carries the whole dictionary from
+ * id 0, so its fixed overhead grows with lifetime symbol cardinality. Once that
+ * overhead reaches the server's batch cap -- alone, or beside a table body --
+ * EVERY frame is oversized however the batch is split,
+ * {@code flushPendingRowsSplit}'s pre-flight rejects it, and the sender can never
+ * flush again: {@code reset()} discards rows, not the dictionary, so the next
+ * batch fails identically. Chunking the dictionary into its own frames removes
+ * the wall -- each chunk carries a contiguous id range sized under the cap,
+ * exactly as {@code CursorWebSocketSendLoop.sendDictCatchUp} chunks the reconnect
+ * catch-up.
+ *
+ * Why it is safe to make the data frames non-self-sufficient. Full-dict mode
+ * exists so a recovered or orphan-drained slot never replays a frame whose symbol
+ * ids nothing registered. The chunks preserve that, one level up: they are emitted
+ * with {@code FLAG_DEFER_COMMIT}, and the server refuses to advance its cumulative
+ * ack watermark over uncommitted deferred rows (it marks them and, as a last
+ * resort, clamps the watermark and logs {@code critical}). A deferred group is
+ * therefore atomic with respect to the client's trim watermark: the chunks cannot
+ * be trimmed away ahead of the data frames that depend on them, so the GROUP is
+ * self-sufficient even though its individual frames are not. Recovery replays it
+ * whole, in order, and {@code RecoveredFrameAnalysis} folds the chunks' deltas
+ * before it reaches the data frames.
+ *
+ * The baseline is deliberately NOT persisted into {@code sentMaxSymbolId}: full-dict
+ * mode carries no cross-batch dictionary state, so every batch re-registers. That
+ * keeps the bandwidth cost full-dict mode already accepts, and keeps each batch
+ * independently replayable.
+ *
+ * All-or-nothing in both directions. Every entry is validated against the cap
+ * BEFORE any chunk is published, so a symbol too large to ship at all throws with
+ * nothing on the ring; and the sole caller only reaches here once it has proven
+ * the batch's bodies fit an empty delta, so a batch that will be rejected never
+ * publishes a chunk either.
+ */
+ private void publishDictionaryChunks(int cap, int from, int batchMaxId) {
+ assert !deltaDictEnabled;
+ // Pass one: prove every entry is shippable on its own, before anything
+ // reaches the ring. A symbol wider than the cap cannot be split across
+ // frames, so it can never be registered and the batch is unshippable --
+ // say that plainly rather than let it surface as an unexplained oversized
+ // frame from the chunk loop below.
+ for (int id = from; id <= batchMaxId; id++) {
+ long soloFrameBytes = (long) QwpConstants.HEADER_SIZE
+ + NativeBufferWriter.varintSize(id)
+ + NativeBufferWriter.varintSize(1)
+ + dictionaryEntryWireBytes(id);
+ if (soloFrameBytes > cap) {
+ throw new BatchTooLargeForCapException("a single symbol value is too large for the server batch cap")
+ .put(" [symbolId=").put(id)
+ .put(", frameBytes=").put(soloFrameBytes)
+ .put(", serverMaxBatchSize=").put(cap).put(']')
+ .put("; a symbol value cannot be split across frames -- shorten it, "
+ + "raise the server's maximum batch size, or use a varchar "
+ + "column instead of symbol for this data");
+ }
+ }
+ // Pass two publishes. Every chunk is a DEFERRED frame, so from the first
+ // successful publish onward this method owns a commit debt: if a later chunk
+ // throws (sealAndSwapBuffer's buffer-recycle timeout, or appendBlocking's
+ // backpressure deadline when the ring is at sf_max_total_bytes), the chunks
+ // already on the ring have no rollback and nothing downstream will close their
+ // group. Close it here instead -- see commitOrphanedDictionaryChunks.
+ int chunkStart = from;
+ long chunkBytes = 0;
+ boolean anyChunkPublished = false;
+ try {
+ for (int id = from; id <= batchMaxId; id++) {
+ int entryBytes = dictionaryEntryWireBytes(id);
+ // Size the frame this entry WOULD produce, with the count varint the
+ // grown chunk actually needs -- a reserve-based estimate can be one byte
+ // short exactly when the count crosses a varint boundary.
+ long withEntry = (long) QwpConstants.HEADER_SIZE
+ + NativeBufferWriter.varintSize(chunkStart)
+ + NativeBufferWriter.varintSize(id - chunkStart + 1)
+ + chunkBytes + entryBytes;
+ if (id > chunkStart && withEntry > cap) {
+ publishDictionaryChunk(chunkStart, id - 1);
+ anyChunkPublished = true;
+ chunkStart = id;
+ chunkBytes = 0;
+ }
+ chunkBytes += entryBytes;
+ }
+ publishDictionaryChunk(chunkStart, batchMaxId);
+ } catch (Throwable t) {
+ if (anyChunkPublished) {
+ commitOrphanedDictionaryChunks(t);
+ }
+ throw t;
+ }
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Registered symbol dictionary in chunks [from={}, to={}, cap={}]",
+ from, batchMaxId, cap);
+ }
+ }
+
+ /**
+ * Publishes one deferred, table-less frame registering symbol ids
+ * {@code [startId, endId]}. Mirrors {@link #sendCommitMessage()}'s publish
+ * sequence; the caller restores the encoder's defer flag before encoding the
+ * batch's data frames.
+ */
+ private void publishDictionaryChunk(int startId, int endId) {
+ encoder.setDeferCommit(true);
+ // confirmedMaxId = startId - 1 makes beginMessage emit deltaStart = startId,
+ // deltaCount = endId - startId + 1.
+ encoder.beginMessage(0, globalSymbolDictionary, startId - 1, endId);
+ int messageSize = encoder.finishMessage();
+ QwpBufferWriter buffer = encoder.getBuffer();
+ ensureActiveBufferReady();
+ activeBuffer.ensureCapacity(messageSize);
+ activeBuffer.write(buffer.getBufferPtr(), messageSize);
+ activeBuffer.incrementRowCount();
+ sealAndSwapBuffer();
+ // The chunk is on the ring carrying FLAG_DEFER_COMMIT, so the server withholds
+ // its ack and clamps the connection's cumulative-ack watermark until the group
+ // commits. Record that debt here rather than leaving it to the caller: when the
+ // batch meant to close the group throws instead -- an oversized table body
+ // reaching the split pre-flight -- flushPendingRows never reaches its own
+ // hasDeferredMessages assignment, close() skips sendCommitMessage, and the group
+ // never commits. ackedFsn then freezes for the connection's whole life, so trim
+ // stops for every frame and the ring fills. A later successful flush reassigns
+ // this from its own deferCommit, which is correct: its data frame closes the group.
+ hasDeferredMessages = true;
+ }
+
+ /**
+ * Returns the ids a discarded batch registered but never shipped, so a later
+ * batch does not have to re-encode them.
+ *
+ * Delta mode anchors every section at {@code sentMaxSymbolId + 1}, and that
+ * watermark only advances when a frame is published. Symbols an abandoned
+ * batch registered therefore sit permanently between the watermark and the
+ * dictionary tip: the next batch that touches ANY newer id spans them again,
+ * so the section never shrinks and the sender stays wedged on the very cap
+ * rejection {@code reset()} is documented to clear. Whether it recovers
+ * depended entirely on whether the caller's next row happened to reuse an old
+ * symbol -- which is not a contract.
+ *
+ * The floor is what makes reuse safe. An id may be reclaimed only while
+ * nothing has bound it to a string yet:
+ *
+ * Full-dict mode is excluded. Its sections always start at id 0, so there is
+ * no lifetime anchor to shrink and nothing to gain -- while reclaiming would
+ * let a later frame define a different string at an id a frame already on the
+ * ring defines, which is precisely the hazard above.
+ */
+ private void reclaimUnsentSymbolIds() {
+ if (!deltaDictEnabled) {
+ return;
+ }
+ int floor = sentMaxSymbolId + 1;
+ PersistedSymbolDict pd = cursorEngine == null ? null : cursorEngine.getPersistedSymbolDict();
+ if (pd != null) {
+ int durable = pd.size();
+ if (durable > floor) {
+ floor = durable;
+ }
+ }
+ globalSymbolDictionary.truncateTo(floor);
+ }
+
private void resetSymbolDictStateForNewConnection() {
- // The new server has an empty symbol dictionary, so the next batch
- // must ship a delta starting at id 0. beginMessage() always passes
- // confirmedMaxId = -1; resetting the batch watermark here keeps a
- // stale value from suppressing re-emission of symbol ids the new
- // server has never seen.
+ // 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
+ // 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;
}
+ /**
+ * On recovery, repopulates the producer's {@link GlobalSymbolDictionary} so that newly
+ * ingested symbols continue ABOVE every id the surviving frames already define, and
+ * resumes the delta baseline at that tip.
+ *
+ * Seeds from TWO sources, in this order:
+ *
+ * Uses {@link GlobalSymbolDictionary#addRecoveredSymbol} (append, NOT de-dup): the
+ * persisted dictionary, the on-wire delta and the mirror all key on the entry POSITION
+ * (id), so the producer's id space must match the recovered entry count exactly.
+ * {@code getOrAddSymbol} would collapse two source strings that decode to the same
+ * characters -- only malformed lone UTF-16 surrogates, which UTF-8-encode to {@code '?'}
+ * -- leaving this dictionary SHORTER than the count and silently misattributing later
+ * symbols.
+ *
+ * Why seeding from the frames matters. The dictionary is not fsync'd (see
+ * {@code PersistedSymbolDict}), so a host/power crash can tear off its newest entries
+ * while the segment frames that introduced those ids survive -- and those newest frames,
+ * being the least likely to be acked, are exactly the ones that replay. Seeded from the
+ * short dictionary alone, this producer would hand its next new symbol an id those frames
+ * already define, putting two symbols on one id and silently misattributing values. The
+ * old code detected that and threw, which was safe but far too blunt: it bricked
+ * {@code build()} for slots the background drainer replays PERFECTLY, because the frames
+ * carry the torn-off symbols in their own deltas and {@code accumulateSentDict} rebuilds
+ * the dictionary from them. This method now rebuilds the producer from the same bytes,
+ * so a torn -- or entirely lost -- dictionary is recoverable whenever the surviving
+ * frames define the ids themselves. The next flush's write-ahead persist then re-writes
+ * those ids (it resumes from {@code pd.size()}), healing the side-file on disk.
+ *
+ * What still fails clean. A genuine GAP: the ids below a surviving frame's delta
+ * start were introduced by frames that were acked and TRIMMED away, so they lived only in
+ * the lost dictionary and nothing can rebuild them.
+ * {@code addRecoveredSymbolsTo} returns -1 for that and we throw. It is the same
+ * condition the send loop's replay guard ({@code deltaStart > sentDictCount}) trips on, so
+ * producer and drainer now agree on exactly which slots are recoverable, instead of the
+ * producer rejecting slots the drainer drains.
+ */
+ private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
+ if (cursorEngine == null) {
+ return;
+ }
+ // 1. The dictionary's intact prefix. addRecoveredSymbol appends without de-dup, so
+ // the producer's size tracks pd.size() exactly -- which is what the send loop's
+ // mirror also seeds sentDictCount from.
+ // Pre-size before pouring the recovered symbols in. The default capacity is 64,
+ // so rebuilding a large dictionary rehashed the map ~log2(n/64) times, each pass
+ // O(current size) -- roughly doubling the rebuild and touching a growing table
+ // the whole way. recoveredMaxSymbolId + 1 is the upper bound the seed can reach.
+ long expected = Math.max(pd == null ? 0L : pd.recoveredSize(),
+ cursorEngine.recoveredMaxSymbolId() + 1L);
+ if (expected > globalSymbolDictionary.size() && expected <= Integer.MAX_VALUE) {
+ globalSymbolDictionary = new GlobalSymbolDictionary((int) expected);
+ }
+ int baseline = 0;
+ if (pd != null && pd.size() > 0) {
+ pd.addLoadedSymbolsTo(globalSymbolDictionary);
+ baseline = globalSymbolDictionary.size();
+ }
+ // 2. Everything the surviving frames define above that prefix, straight out of their
+ // own delta sections -- the same bytes, in the same order, accumulateSentDict will
+ // feed the mirror as those frames go back on the wire.
+ long coverage = cursorEngine.addRecoveredSymbolsTo(baseline, globalSymbolDictionary);
+ if (coverage < 0) {
+ // A gap: the surviving frames reference ids below their own delta start,
+ // introduced by frames since acked and trimmed away, and the persisted
+ // dictionary no longer holds them (a host crash tore its unsynced tail, or it
+ // could not be opened). That gap only matters for frames that will REPLAY.
+ // When every recovered committed frame is already acked
+ // (ackedFsn >= recoveredCommitBoundaryFsn), NOTHING replays: the gap is in
+ // data the server already has, and the retired orphan-deferred tail above the
+ // commit boundary is never transmitted. Throwing here would raise a false
+ // "resend required" for delivered data AND -- because such a slot is fully
+ // drained -- let build()'s connect-path close unlink the (already-delivered)
+ // bytes the quarantine claims to preserve. So DON'T throw: seed the intact
+ // prefix only; addRecoveredSymbolsTo adds nothing on a -1 exactly as the
+ // send loop's mirror does, so the producer baseline and the mirror's
+ // sentDictCount still agree by construction. The producer resumes above the
+ // prefix and the fully-drained slot is cleaned up on close.
+ long ackedFsn = cursorEngine.ackedFsn();
+ long commitBoundaryFsn = cursorEngine.recoveredCommitBoundaryFsn();
+ if (ackedFsn >= commitBoundaryFsn) {
+ sentMaxSymbolId = globalSymbolDictionary.size() - 1;
+ LOG.info("recovered store-and-forward slot has a torn/incomplete symbol dictionary, "
+ + "but every committed frame was already acked so nothing needs replaying; "
+ + "resuming on the intact prefix without quarantine and without data loss "
+ + "[recoveredPrefixSize={}, ackedFsn={}, commitBoundaryFsn={}]",
+ baseline, ackedFsn, commitBoundaryFsn);
+ return;
+ }
+ // Genuine loss: unacked committed frames reference ids nothing still holds.
+ // Typed, because Sender.build() sets such a slot aside instead of failing: this
+ // is the point at which every source of truth has been tried and none of them
+ // holds the missing ids. See UnreplayableSlotException.
+ throw new UnreplayableSlotException(
+ "recovered store-and-forward symbol dictionary is incomplete and cannot be "
+ + "rebuilt from the surviving frames (likely a host crash tore its unsynced "
+ + "tail): the frames reference symbol ids below their own delta start, which "
+ + "were introduced by frames since acked and trimmed away, so nothing still "
+ + "holds them; the recovered dictionary holds only "
+ + (pd == null ? 0 : pd.size()) + " id(s) -- resend the affected data");
+ }
+ // Producer baseline == the coverage the replay will establish == the mirror's
+ // sentDictCount once those frames have gone out. The first new frame therefore
+ // starts its delta exactly at the tip, and the replay guard passes.
+ sentMaxSymbolId = globalSymbolDictionary.size() - 1;
+ // ...but the baseline now sits ABOVE pd.size() whenever the frames contributed
+ // ids, so restore the write-ahead invariant right now rather than hoping a later
+ // batch reaches high enough to do it. See healPersistedDictionary.
+ healPersistedDictionary(pd);
+ }
+
+ /**
+ * The symbol id below which the server already holds every dictionary entry,
+ * used as {@code confirmedMaxId} when encoding a frame. In delta mode this is
+ * the producer's monotonic sent watermark; in full-dict mode it is -1 so every
+ * frame re-ships the dictionary from id 0.
+ */
+ private int symbolDeltaBaseline() {
+ return deltaDictEnabled ? sentMaxSymbolId : -1;
+ }
+
private void rollbackRow() {
if (currentTableBuffer != null) {
currentTableBuffer.cancelCurrentRow();
@@ -3920,7 +4914,7 @@ private void sealAndSwapBuffer() {
// back to it; flushPendingRows aborts its post-enqueue state
// updates after this throw, so the source rows stay intact and the
// next batch re-emits the same rows along with the full inline
- // schema and symbol-dict delta from id 0.
+ // schema and the symbol-dict delta the batch requires.
if (toSend.isSending()) {
toSend.markRecycled();
} else if (toSend.isSealed()) {
@@ -3941,24 +4935,21 @@ private void sealAndSwapBuffer() {
private void sendRow() {
ensureConnected();
- // Hard guard: if THIS row's bytes already exceed the server's wire
- // cap, the flush would produce an oversize WS frame the server
- // closes with ws-close[1009]. Check the per-row delta before
- // nextRow() commits the row, so the at()/atNow() error path can
- // roll back via rollbackRow() and prior committed rows in the
- // batch stay intact. (The check ignores the null-padding bytes
- // nextRow() will add; that's bounded by numColumns * elemSize and
- // far below any realistic cap.)
- if (serverMaxBatchSize > 0) {
- long rowBytes = currentTableBuffer.getBufferedBytes() - currentTableBufferSnapshotBytes;
- if (rowBytes > serverMaxBatchSize) {
- throw new LineSenderException("row too large for server batch cap")
- .put(" [rowBytes=").put(rowBytes)
- .put(", serverMaxBatchSize=").put(serverMaxBatchSize).put(']');
- }
- }
-
- currentTableBuffer.nextRow();
+ // Hard guard: a single row whose bytes exceed the server's wire cap
+ // would flush as an oversize WS frame the server closes with
+ // ws-close[1009]. nextRow() measures the row -- padding included,
+ // since padding goes to the wire too -- inside its existing walk and
+ // throws BEFORE committing, so the at()/atNow() error path can roll
+ // back via rollbackRow() and prior committed rows in the batch stay
+ // intact.
+ // Snapshot the volatile cap ONCE, as flushPendingRows does. The I/O thread
+ // lowers serverMaxBatchSize -- or clears it to 0 on a failover to a node that
+ // advertises no cap -- mid-stream via applyServerBatchSizeLimit. Re-reading the
+ // field across the guard and the throw could observe it drop to 0 between reads
+ // and reject the row against a "cap" of 0, which actually means "no cap".
+ int cap = serverMaxBatchSize;
+ long bufferedNow = currentTableBuffer.nextRow(
+ currentTableBufferSnapshotBytes, cap > 0 ? cap : Long.MAX_VALUE);
if (pendingRowCount == 0) {
firstPendingRowTimeNanos = System.nanoTime();
@@ -3966,12 +4957,11 @@ private void sendRow() {
pendingRowCount++;
// Advance pendingBytes by the bytes the just-committed row added to
- // the current table, then re-snap. Column setters and nextRow()
- // only ever touch currentTableBuffer between consistency points, so
- // the per-row work stays O(numColumns of the current table) -- no
+ // the current table. nextRow() accumulated the total during its
+ // null-padding walk, so there is no second O(columns) walk needed here.
+ // The per-row work stays O(numColumns of the current table) -- no
// map walk, no scaling with the number of tables this sender has
// seen across its lifetime.
- long bufferedNow = currentTableBuffer.getBufferedBytes();
pendingBytes += bufferedNow - currentTableBufferSnapshotBytes;
currentTableBufferSnapshotBytes = bufferedNow;
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java
index 015b25ef..81d59d28 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java
@@ -67,6 +67,12 @@ public class WebSocketResponse {
public static final int MIN_DURABLE_ACK_SIZE = 3; // status + tableCount
public static final int MIN_ERROR_RESPONSE_SIZE = 11; // status + sequence + error length
public static final int MIN_OK_RESPONSE_SIZE = 11; // status + sequence + tableCount
+ /**
+ * A delta symbol dictionary began above the server's connection dictionary. Wire
+ * {@code 0x0D}. Server state decides this, not the frame bytes, so it is retriable:
+ * recycling the wire re-runs the catch-up from id 0 and the same frames then land.
+ */
+ public static final byte STATUS_DICTIONARY_GAP = 0x0D;
/**
* Per-table durable-upload acknowledgment. Emitted by servers where
* primary replication is enabled and the connection opted in via
@@ -224,6 +230,8 @@ public String getStatusName() {
return "INTERNAL_ERROR";
case STATUS_NOT_WRITABLE:
return "NOT_WRITABLE";
+ case STATUS_DICTIONARY_GAP:
+ return "DICTIONARY_GAP";
default:
return "UNKNOWN(" + (status & 0xFF) + ")";
}
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 6f6d7eff..a9b5545e 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
@@ -24,6 +24,8 @@
package io.questdb.client.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.SenderError;
+import io.questdb.client.SenderErrorHandler;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketUpgradeException;
import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
@@ -36,6 +38,7 @@
import org.slf4j.LoggerFactory;
import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
/**
@@ -44,7 +47,9 @@
*
* Lifecycle:
*
- * For the foreground sender that condition is loud-fail: the producer
- * is actively pushing data. The drainer is asymmetric: source data is
+ * Blocking foreground startup keeps its fail-fast policy, while asynchronous
+ * foreground startup and reconnect keep buffering and retrying through a
+ * rolling capability change. The orphan drainer is asymmetric: source data is
* pinned (durable-ack-mode trims only on STATUS_DURABLE_ACK frames,
* which the offending endpoints by definition do not send), so we
* give the cluster a budget to settle before quarantining the slot.
@@ -258,21 +279,17 @@ public BackgroundDrainer() {
* {@link QwpDurableAckMismatchException} sweeps only. Transient
* conditions -- an all-replica failover window (role reject) or a
* transport error -- are retried indefinitely (Invariant B) and never
- * consume the budget: the wall-clock half accumulates only across
- * uninterrupted gap-to-gap intervals, so a mid-episode transport window
- * pauses the clock (without touching the attempt count), and a role
- * reject additionally restarts the episode, because it proves the
- * topology changed under the rolling upgrade.
+ * consume the budget. Either transient restarts the attempt count and wall
+ * clock so only uninterrupted capability-gap sweeps can escalate.
* Genuine terminals (auth failure, non-421 upgrade reject) preserve
* the original behavior: mark failed, exit.
*
* @return a fresh durable-ack-capable client, or {@code null} if
* {@link #outcome} has been set to FAILED or STOPPED
*/
- @TestOnly
public WebSocketClient connectWithDurableAckRetry() {
// run() already set runnerThread; setting it again here is a no-op
- // on that path but wires up direct @TestOnly calls so requestStop()
+ // on that path but wires up direct callers so requestStop()
// can unpark them too.
runnerThread = Thread.currentThread();
long backoffMillis = reconnectInitialBackoffMillis;
@@ -280,25 +297,29 @@ public WebSocketClient connectWithDurableAckRetry() {
// QwpDurableAckMismatchException sweeps; the wall-clock half
// accumulates ONLY across uninterrupted gap-to-gap intervals, so
// transient churn (role reject, transport) can never burn the budget
- // -- neither before the first gap is observed nor mid-episode (a
- // cluster unreachable for longer than the whole budget that comes
- // back still gapped has consumed none of it). An intervening role
- // reject resets the episode (topology churn: the offending node is
- // gone); a transport error neither increments nor resets the attempt
- // count -- a dropped socket does not prove promotion churn, and
- // resetting on it would let a flaky-but-misconfigured cluster evade
- // the cap forever -- it only pauses the wall clock: the gap-to-gap
- // interval spanning the transport window is not charged.
+ // -- neither before the first gap is observed nor mid-episode. Any
+ // intervening role or transport state resets the episode: after the
+ // cluster leaves the capability-gap state, later gaps must establish a
+ // fresh consecutive run before quarantine is permitted.
int capabilityGapAttempts = 0;
// Wall-clock time accumulated across uninterrupted gap-to-gap
// intervals of the current episode; escalates once it reaches
// capabilityGapBudgetNanos (or the attempt cap fires first).
long capabilityGapElapsedNanos = 0L;
// Timestamp of the previous capability-gap sweep; 0 = the next gap
- // charges nothing (episode start, post-role-reject restart, or the
- // interval was interrupted by a transport window).
+ // charges nothing because a fresh episode is starting.
long lastCapabilityGapNanos = 0L;
- final long capabilityGapBudgetNanos = reconnectMaxDurationMillis * 1_000_000L;
+ // Saturate rather than multiply: reconnect_max_duration_millis is validated only
+ // as > 0, so a large value (Long.MAX_VALUE is the natural way to ask for "never
+ // give up") wraps a raw multiply NEGATIVE. capabilityGapElapsedNanos then clears
+ // the budget on the FIRST capability-gap sweep -- 0 >= a negative -- and, because
+ // this gate is an OR with the attempt cap, nothing else holds it back: the slot
+ // quarantines immediately, skipping the whole 16-sweep settle budget. Asking for
+ // more tolerance would buy exactly none. TimeUnit clamps at Long.MAX_VALUE, which
+ // is the intended "effectively unbounded". CursorWebSocketSendLoop's dwell
+ // conversion guards the same way for the same reason.
+ final long capabilityGapBudgetNanos =
+ TimeUnit.MILLISECONDS.toNanos(reconnectMaxDurationMillis);
// Observability-only counter for the transient all-replica window;
// never consulted for escalation (Invariant B).
int roleRejectAttempts = 0;
@@ -319,12 +340,13 @@ public WebSocketClient connectWithDurableAckRetry() {
} catch (QwpAuthFailedException | WebSocketUpgradeException e) {
// Genuinely non-retriable across the cluster (auth 401/403, or a
// non-421 upgrade reject): waiting will not fix it, so quarantine
- // immediately -- exactly as the live sender's background loop
- // (CursorWebSocketSendLoop.connectLoop) halts on these errors.
+ // immediately under the orphan reconnect policy.
String msg = e.getMessage();
LOG.error("drainer terminal upgrade/auth error for slot {}: {}", slotPath, msg);
lastErrorMessage = msg;
- OrphanScanner.markFailed(slotPath, "auth/upgrade: " + msg);
+ String reason = "auth/upgrade: " + msg;
+ OrphanScanner.markFailed(slotPath, reason);
+ dispatchDataLoss(reason);
outcome = DrainOutcome.FAILED;
return null;
} catch (QwpRoleMismatchException | QwpIngressRoleRejectedException e) {
@@ -391,9 +413,10 @@ public WebSocketClient connectWithDurableAckRetry() {
}
}
lastErrorMessage = e.getMessage();
- OrphanScanner.markFailed(slotPath,
- "durable-ack persistently unavailable after "
- + capabilityGapAttempts + " attempts: " + e.getMessage());
+ String reason = "durable-ack persistently unavailable after "
+ + capabilityGapAttempts + " attempts: " + e.getMessage();
+ OrphanScanner.markFailed(slotPath, reason);
+ dispatchDataLoss(reason);
outcome = DrainOutcome.FAILED;
return null;
}
@@ -433,10 +456,11 @@ public WebSocketClient connectWithDurableAckRetry() {
// Invariant B -- but it is NOT a transport outage, so log it
// truthfully below rather than mislabelling it "cluster unreachable".
lastErrorMessage = t.getMessage();
- // Pause the episode wall clock: the gap-to-gap interval this
- // window interrupts is never charged. Attempts and elapsed
- // already accumulated are preserved (anti-evasion: see the
- // budget comment above).
+ // This unrelated state breaks the consecutive capability-gap
+ // run. Restart both halves of the settle budget so a later gap
+ // must establish a fresh episode before quarantine.
+ capabilityGapAttempts = 0;
+ capabilityGapElapsedNanos = 0L;
lastCapabilityGapNanos = 0L;
long nowWarn = System.nanoTime();
if (nowWarn - lastTransportWarnNanos >= 5_000_000_000L) {
@@ -573,17 +597,74 @@ private boolean stopRequestedOrInterrupted() {
return stopRequested;
}
+ // Every markFailed site pairs with one of these: the .failed sentinel is
+ // permanent (nothing in production clears it), so each one is a
+ // permanent-abandonment verdict on the slot's unacked data. A throwing
+ // sink must not disturb the drainer's own teardown arc.
+ private void dispatchDataLoss(String reason) {
+ if (slotPath == null) {
+ // Only the @TestOnly zero-segment drainer carries a null slot path; a
+ // DATA_LOSS without a quarantined path would violate the factory's contract.
+ return;
+ }
+ SenderErrorHandler sink = errorSink;
+ if (sink != null) {
+ try {
+ sink.onError(SenderError.dataLoss(reason, slotPath));
+ } catch (Throwable t) {
+ LOG.warn("drainer error sink threw while reporting abandoned slot {}: {}",
+ slotPath, String.valueOf(t));
+ }
+ }
+ }
+
@Override
public void run() {
runnerThread = Thread.currentThread();
+ SlotLock logicalSlotLock = null;
CursorSendEngine engine = null;
WebSocketClient client = null;
CursorWebSocketSendLoop loop = null;
try {
- // The engine acquires the slot's .lock itself — we don't need
- // (and must not) double-lock it. If another sender or drainer
- // holds it, the engine constructor throws and we exit silently
- // (no .failed sentinel — contention is expected, not an error).
+ // Scanner results are only snapshots. Serialize adoption against
+ // a producer's close -> quarantine rename -> fresh-slot recreate
+ // transition, then revalidate while that stable parent-anchored
+ // lock is held. The slot's own .lock inode moves with a rename and
+ // cannot provide this guarantee by itself.
+ if (slotPath != null) {
+ try {
+ logicalSlotLock = SlotLock.acquireLogical(slotPath);
+ } catch (SlotLockContentionException t) {
+ LOG.info("orphan logical slot already locked, skipping: {} ({})",
+ slotPath, t.getMessage());
+ outcome = DrainOutcome.LOCKED_BY_OTHER;
+ return;
+ } catch (Exception t) {
+ // Everything else here is LOCAL and pre-adoption: a permission
+ // problem on the shared .slot-locks directory, momentary fd
+ // exhaustion, an unwritable parent. It must NOT reach the outer
+ // catch, which writes the .failed sentinel unconditionally --
+ // OrphanScanner.isCandidateOrphan treats that sentinel as
+ // disqualifying and nothing ever removes it, so a healthy slot
+ // whose real owner later dies would be stranded until an
+ // operator intervened. Report FAILED and let the next scan retry.
+ String msg = t.toString();
+ LOG.warn("drainer could not take the logical slot lock for {}: {}",
+ slotPath, msg, t);
+ lastErrorMessage = msg;
+ outcome = DrainOutcome.FAILED;
+ return;
+ }
+ if (!OrphanScanner.isCandidateOrphan(slotPath)) {
+ LOG.info("orphan candidate changed before adoption, skipping: {}", slotPath);
+ outcome = DrainOutcome.SUCCESS;
+ return;
+ }
+ }
+
+ // The engine acquires the directory-local .lock itself. Keep the
+ // lock order logical -> local, and release the short-lived logical
+ // lock only after the engine has secured stable ownership.
try {
try {
engine = new CursorSendEngine(slotPath, segmentSizeBytes,
@@ -618,21 +699,45 @@ public void run() {
// Repeated scans cannot repair it, so preserve the terminal
// quarantine path in the outer catch below.
throw t;
+ } catch (UnreplayableSlotException t) {
+ // The constructor assigns this.slotLock (CursorSendEngine's own
+ // .lock, taken under SlotLock.acquire) before the try that opens
+ // the ring, so this throw proves the ring was opened under that
+ // lock -- adoption happened, even though the local `engine`
+ // reference here is still null. Quarantine explicitly instead of
+ // falling into the retryable catch below: the verdict this type
+ // carries is that the slot's symbol dictionary cannot be rebuilt
+ // from ANY source, which no number of retries changes. Placed
+ // after the SfSanitizedResidueException retry and before the
+ // catch-all so an operational failure -- which must NOT
+ // permanently quarantine an orphan slot -- still lands on the
+ // retryable arm.
+ String msg = t.getMessage();
+ LOG.error("drainer slot {} is unreplayable, quarantining: {}", slotPath, msg);
+ lastErrorMessage = msg;
+ String reason = "unreplayable: " + msg;
+ OrphanScanner.markFailed(slotPath, reason);
+ dispatchDataLoss(reason);
+ outcome = DrainOutcome.FAILED;
+ return;
} catch (Exception t) {
// Every other pre-publication construction exception is
// retryable: setup I/O, resource pressure, unexpected setup
// faults, and future operational failures do not prove durable
- // data corruption. The constructor has closed partial
- // resources; SlotLock retains and retries any unconfirmed
- // unlock. Leave no .failed sentinel for the next orphan scan.
- // Error deliberately escapes to the outer Error path: it is
- // observable after teardown but cannot quarantine intact data.
- // This path retries on every orphan scan, so the log line is
- // the ONLY diagnostic a deterministic bug (e.g. an unexpected
- // NPE, whose getMessage() is null) ever produces: attach the
- // throwable for the stack trace and carry class+message into
- // the telemetry surface, mirroring the outer setup-failure
- // catch below.
+ // data corruption. In particular SfOperationalException lands
+ // here (it extends IllegalStateException), which is exactly
+ // where it must land -- an EMFILE/ENOMEM during setup must
+ // never permanently quarantine an orphan slot. The constructor
+ // has closed partial resources; SlotLock retains and retries any
+ // unconfirmed unlock. Leave no .failed sentinel for the next
+ // orphan scan. Error deliberately escapes to the outer Error
+ // path: it is observable after teardown but cannot quarantine
+ // intact data. This path retries on every orphan scan, so the
+ // log line is the ONLY diagnostic a deterministic bug (e.g. an
+ // unexpected NPE, whose getMessage() is null) ever produces:
+ // attach the throwable for the stack trace and carry
+ // class+message into the telemetry surface, mirroring the outer
+ // setup-failure catch below.
String msg = t.toString();
LOG.warn("drainer setup temporarily unavailable for slot {}: {}",
slotPath, msg, t);
@@ -641,6 +746,15 @@ public void run() {
return;
}
engineForTesting = engine;
+ if (logicalSlotLock != null) {
+ logicalSlotLock.close();
+ logicalSlotLock = null;
+ }
+ // A recovered deferred-only tail is an aborted transaction and can
+ // be retired locally once everything below it is already ACKed.
+ // Do this before opening a socket: auth/upgrade failures must not
+ // quarantine a slot that has no wire-visible work left.
+ engine.retireRecoveredOrphanTailIfReady();
long target = engine.publishedFsn();
if (engine.ackedFsn() >= target) {
LOG.info("orphan slot already drained: {} (acked={} target={})",
@@ -668,13 +782,14 @@ public void run() {
client, engine,
0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
clientFactory,
- reconnectMaxDurationMillis,
reconnectInitialBackoffMillis,
reconnectMaxBackoffMillis,
requestDurableAck,
durableAckKeepaliveIntervalMillis,
maxHeadFrameRejections,
- poisonMinEscalationWindowMillis);
+ poisonMinEscalationWindowMillis,
+ catchUpCapGapMinEscalationWindowMillis,
+ CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN);
loop.start();
while (!stopRequestedOrInterrupted()) {
@@ -744,7 +859,9 @@ public void run() {
String msg = t.getMessage();
LOG.error("drainer wire error for slot {}: {}", slotPath, msg);
lastErrorMessage = msg;
- OrphanScanner.markFailed(slotPath, "wire: " + msg);
+ String reason = "wire: " + msg;
+ OrphanScanner.markFailed(slotPath, reason);
+ dispatchDataLoss(reason);
outcome = DrainOutcome.FAILED;
return;
}
@@ -781,11 +898,28 @@ public void run() {
LOG.debug("drainer setup failed for slot {}: {}", slotPath, msg, t);
}
lastErrorMessage = msg;
+ // Everything reaching here is terminal by construction, so the
+ // sentinel is unconditional -- notably SfRecoveryException and
+ // MmapSegmentCorruptionException, which the construction catch
+ // deliberately rethrows for exactly this treatment. A null engine
+ // does NOT mean "pre-adoption" for those: CursorSendEngine assigns
+ // its slotLock field before the try that opens the ring, so the
+ // throw proves the ring was opened under the slot lock.
+ //
+ // The retryable, pre-adoption failures never get this far: lock
+ // contention, operational setup errors and the logical-lock
+ // acquisition above all return on their own typed arms without a
+ // sentinel. That matters because OrphanScanner.isCandidateOrphan
+ // treats .failed as disqualifying and nothing ever removes it, so a
+ // sentinel on a transient failure would strand a healthy slot's
+ // unacked data once its real owner died.
+ String reason = "setup: " + msg;
try {
- OrphanScanner.markFailed(slotPath, "setup: " + msg);
+ OrphanScanner.markFailed(slotPath, reason);
} catch (Throwable ignored) {
// best-effort
}
+ dispatchDataLoss(reason);
outcome = DrainOutcome.FAILED;
} finally {
boolean ioThreadStopped = true;
@@ -835,12 +969,23 @@ public void run() {
+ "slot lock releases when it exits", slotPath);
}
}
+ if (logicalSlotLock != null) {
+ logicalSlotLock.close();
+ }
// Don't let a later requestStop() unpark an unrelated task that
// the pool's executor may have scheduled onto this same thread.
runnerThread = null;
}
}
+ public SenderErrorHandler getErrorSink() {
+ return errorSink;
+ }
+
+ public void setErrorSink(SenderErrorHandler errorSink) {
+ this.errorSink = errorSink;
+ }
+
/**
* Plug an observer for durable-ack-related events. {@code null} clears
* any previously installed listener. See {@link BackgroundDrainerListener}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerPool.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerPool.java
index 64283e81..a35bf934 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerPool.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerPool.java
@@ -24,6 +24,7 @@
package io.questdb.client.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.SenderErrorHandler;
import io.questdb.client.std.Compat;
import io.questdb.client.std.ObjList;
import io.questdb.client.std.QuietCloseable;
@@ -101,6 +102,7 @@ public final class BackgroundDrainerPool implements QuietCloseable {
* drainer in the finally block of {@link #submit}.
*/
private final AtomicLong totalSucceeded = new AtomicLong();
+ private volatile SenderErrorHandler errorSink;
/**
* Pool-level listener applied to drainers at submit time when the
* drainer doesn't already carry one. Volatile so callers can install
@@ -226,6 +228,10 @@ public long getTotalSucceeded() {
return totalSucceeded.get();
}
+ public void setErrorSink(SenderErrorHandler errorSink) {
+ this.errorSink = errorSink;
+ }
+
/**
* Plug a default {@link BackgroundDrainerListener} for drainers
* submitted through this pool. {@code null} clears the default.
@@ -278,6 +284,10 @@ public void submit(BackgroundDrainer drainer) {
if (poolListener != null && drainer.getListener() == null) {
drainer.setListener(poolListener);
}
+ SenderErrorHandler poolErrorSink = errorSink;
+ if (poolErrorSink != null && drainer.getErrorSink() == null) {
+ drainer.setErrorSink(poolErrorSink);
+ }
boolean accepted = false;
try {
active.add(drainer);
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 80f54486..66a0635e 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
@@ -24,6 +24,7 @@
package io.questdb.client.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary;
import io.questdb.client.std.Compat;
import io.questdb.client.std.Files;
import io.questdb.client.std.FilesFacade;
@@ -93,6 +94,11 @@ public final class CursorSendEngine implements QuietCloseable {
// callback allocation failure cannot orphan manager resources. A timed-out
// close can then hand it to either manager path without allocating.
private final Runnable deferredClose;
+ // Facade for the persisted symbol dictionary only. Separate from
+ // filesFacade (which the manager supplies for segments, watermark and
+ // manifest) so a test can fault-inject a dictionary write without also
+ // breaking the segment chain underneath it.
+ private final FilesFacade dictFf;
private final FilesFacade filesFacade;
private final SegmentManager manager;
// We own the manager iff the user constructed us with no manager — in that
@@ -109,9 +115,9 @@ public final class CursorSendEngine implements QuietCloseable {
private final SlotLock slotLock;
// True when the constructor recovered an existing on-disk slot rather
// than starting fresh. Diagnostic accessor for tests and observability;
- // cursor frames are self-sufficient (every frame carries full schema +
- // full symbol-dict delta), so producer-side schema reset on recovery
- // is not required.
+ // every frame carries its full inline schema, so producer-side schema reset
+ // on recovery is not required (the symbol dictionary, which delta frames do
+ // NOT carry in full, is re-registered by an I/O-thread catch-up instead).
private final boolean wasRecoveredFromDisk;
// FSN of the last commit-bearing (non-FLAG_DEFER_COMMIT) frame found in a
// ring recovered from disk, or -1 for fresh/memory rings and recovered
@@ -121,6 +127,27 @@ public final class CursorSendEngine implements QuietCloseable {
// covers them. Read by the sender's close-time drain to avoid waiting on
// acks that cannot arrive.
private long recoveredCommitBoundaryFsn = -1L;
+ // Highest symbol id any recovered delta frame references, or -1 for
+ // fresh/memory rings (and recovered rings with no symbol-bearing frame). A
+ // resuming producer seeds its dictionary baseline from the persisted
+ // .symbol-dict; if that dictionary was torn below this id by a host crash
+ // (the side-file is not fsync'd), the producer would re-use ids the surviving
+ // frames already define. seedGlobalDictionaryFromPersisted compares this
+ // against the recovered dictionary size to fail clean instead. Computed once
+ // in the constructor's recovery branch; -1 elsewhere.
+ private long recoveredMaxSymbolId = -1L;
+ // How many times the constructor folded the recovered ring. Observable because
+ // recoveryFramesVisited() cannot be: the full-dict-fallback re-fold REPLACES the
+ // analysis instance, resetting that counter, so a second fold is invisible through it.
+ private int recoveryFoldCount;
+ // Highest deltaStart across the recovered COMMITTED frames; 0 when none carries a symbol
+ // dictionary. ZERO means every surviving frame is SELF-SUFFICIENT -- it re-registers its
+ // dictionary from id 0 -- so the slot replays with no dictionary at all and the send loop
+ // needs no catch-up. ABOVE zero means at least one frame is a true delta whose ids depend
+ // on registrations it does not itself carry, so the loop must seed its mirror (and ship a
+ // catch-up) before replaying. Both the full-dict-fallback discard below and the send
+ // loop's mirror seeding key off this.
+ private long recoveredMaxSymbolDeltaStart;
// FSN of the last frame of a recovered orphaned deferred tail, or -1 when
// the recovered ring has no such tail. When >= 0, frames
// [recoveredCommitBoundaryFsn + 1 .. recoveredOrphanTipFsn] all carry
@@ -136,6 +163,17 @@ public final class CursorSendEngine implements QuietCloseable {
// constructor, closed by {@link #close()}. The segment manager writes
// through this on every tick where ackedFsn has advanced.
private final AckWatermark watermark;
+ // Engine-owned per-slot symbol dictionary file (disk mode only; {@code null}
+ // in memory mode and if open() failed). Enables delta-encoded SF frames:
+ // recovery / orphan-drain load it to re-register the dictionary on the fresh
+ // server before replaying non-self-sufficient frames. Opened in the
+ // constructor, closed by {@link #close()}. When null in disk mode the engine
+ // reports delta encoding as unavailable and the sender keeps full-dict frames.
+ private final PersistedSymbolDict persistedSymbolDict;
+ // Engine-owned output of the single ordered recovery walk. It is retained
+ // because both producer seeding and every recycled send loop need the same
+ // frame-rebuilt symbol suffix. Null for fresh and memory-only engines.
+ private final RecoveredFrameAnalysis recoveredFrameAnalysis;
// close() is publicly callable from any thread (Sender.close from a user
// thread, JVM shutdown hooks, test cleanup). volatile + synchronized
// close() makes the check-and-set atomic and gives readers a fence.
@@ -168,6 +206,14 @@ public final class CursorSendEngine implements QuietCloseable {
// Ensures this engine has at most one entry in the shared flock-release
// retry driver. The error path only: ordinary closes never enqueue work.
private final AtomicBoolean flockReleaseRetryStarted = new AtomicBoolean();
+ // False only when a caller ABOVE this engine holds the parent-anchored
+ // logical slot lock (Sender.build() across construct -> connect ->
+ // quarantine). finishClose then skips the fully-drained unlink of that
+ // lock file, which would otherwise free the pathname while the caller
+ // still holds the flock on it. Latched by close(boolean) before any
+ // cleanup runs; volatile because finishClose can run later on the manager
+ // worker's exit thread or the shared flock-release retry driver.
+ private volatile boolean reclaimLogicalSlotLock = true;
// Published before deferredClose is registered. The manager lock provides
// the callback handoff fence; volatile also covers a direct test/retry read.
private volatile boolean fullyDrainedForDeferredClose;
@@ -216,6 +262,29 @@ public CursorSendEngine(String sfDir, long segmentSizeBytes,
this(sfDir, segmentSizeBytes, maxTotalBytes, appendDeadlineNanos, 0L);
}
+ /**
+ * As {@link #CursorSendEngine(String, long, long, long)}, but with an explicit
+ * {@link FilesFacade} for the persisted symbol dictionary.
+ *
+ * The seam exists so a test can drive a dictionary I/O fault -- a short write from
+ * a full disk or an exhausted quota -- through the REAL producer path
+ * ({@code flush()} -> the write-ahead persist), and assert it surfaces as a
+ * {@code LineSenderException} like every other flush-path failure rather than as a
+ * raw {@code IllegalStateException} that would sail past every caller's
+ * {@code catch (LineSenderException)}. Nothing else could reach that translation:
+ * {@code PersistedSymbolDict} has facade-aware overloads, but the engine used to
+ * call only the {@code FilesFacade.INSTANCE} ones, so no fault could be injected
+ * from outside. Deliberately narrower than the manager's facade: the segment
+ * chain stays on the production one, so a dictionary fault cannot be confused
+ * with a recovery failure.
+ */
+ @TestOnly
+ public CursorSendEngine(String sfDir, long segmentSizeBytes,
+ long maxTotalBytes, long appendDeadlineNanos, FilesFacade dictFf) {
+ this(sfDir, segmentSizeBytes, null, true, maxTotalBytes,
+ appendDeadlineNanos, 0L, dictFf);
+ }
+
/**
* Creates an engine with an optional periodic data-checkpoint interval.
* A positive interval requires disk-backed store-and-forward mode.
@@ -257,6 +326,14 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager manager,
boolean ownsManager, long maxTotalBytes, long appendDeadlineNanos,
long syncIntervalNanos) {
+ this(sfDir, segmentSizeBytes, manager, ownsManager, maxTotalBytes,
+ appendDeadlineNanos, syncIntervalNanos, FilesFacade.INSTANCE);
+ }
+
+ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager manager,
+ boolean ownsManager, long maxTotalBytes, long appendDeadlineNanos,
+ long syncIntervalNanos, FilesFacade dictFf) {
+ this.dictFf = dictFf;
// Allocate the bound callback before constructing an owned manager.
// Field initializers have completed, but no engine-owned native/disk
// resource exists yet. If callback allocation throws, construction
@@ -320,11 +397,21 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
// reference instead of orphaning the mmap'd segments + fds.
SegmentRing ringInProgress = null;
AckWatermark watermarkInProgress = null;
+ PersistedSymbolDict persistedDictInProgress = null;
+ RecoveredFrameAnalysis recoveredFrameAnalysisInProgress = null;
try {
// Disk mode: try to recover any *.sfa files left behind by a prior
// session before deciding to start fresh. Without this the engine
// would create a new sf-initial.sfa at baseSeq=0, overlapping FSNs
// already on disk and corrupting ACK translation, trim, and replay.
+ // May throw UnreplayableSlotException or SfRecoveryException /
+ // MmapSegmentCorruptionException (the durable chain is proven corrupt
+ // or incomplete) -- the catch (Throwable) below cleans up and lets
+ // them propagate so the constructor's two callers, Sender.build() and
+ // BackgroundDrainer, can quarantine the slot instead of seeding the
+ // ack past frames that were never sent. A plain MmapSegmentException
+ // is operational: it aborts startup without quarantining, so a
+ // transient EMFILE/ENOMEM can never silently drop durable frames.
SegmentRing.Recovery recovery = memoryMode ? null
: SegmentRing.recover(filesFacade, sfDir, segmentSizeBytes);
SegmentRing recovered = recovery == null ? null : recovery.ring();
@@ -386,6 +473,40 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
throw new SfOperationalException(
"could not open required ack watermark for SF slot " + sfDir);
}
+ // Load the persisted symbol dictionary so delta-encoded frames
+ // in this recovered slot can be re-registered on the fresh
+ // server before replay. Returns null only when the dictionary
+ // is provably absent or corrupt -- the sender then degrades to
+ // full self-sufficient frames for this session, a disposition
+ // that is sticky across restarts.
+ //
+ // An I/O failure throws SfOperationalException, which is CAPTURED
+ // rather than propagated here. Whether an unreadable dictionary is
+ // fatal depends on something only the fold below can answer: if every
+ // surviving frame is self-sufficient (maxDeltaStart() == 0, i.e. each
+ // re-registers its whole dictionary from id 0) the side-file is not
+ // needed to replay this slot at all, and aborting on it would be a
+ // permanent outage over a file nothing reads. That mattered because
+ // SfOperationalException extends IllegalStateException, so it is NOT in
+ // Sender.build()'s quarantine catch list and escapes build() entirely:
+ // with a stable senderId and a retained slot, a NON-clearing operational
+ // error (a hard EIO on this one file, a read-only mount failing the tail
+ // truncate, an ownership change) re-threw on every restart. The
+ // application could then not construct a Sender at all, so it could not
+ // even BUFFER new rows -- and in a pool it is worse, because
+ // allocateSlotIndex() hands out the LOWEST free index, so the same bad
+ // slot is re-selected and every borrow() fails rather than the pool
+ // losing one slot of capacity.
+ //
+ // Deferring the verdict keeps the abort for the case that genuinely
+ // needs it (a delta frame whose ids cannot be rebuilt) and drops it for
+ // the case that does not.
+ SfOperationalException dictOpenFailure = null;
+ try {
+ persistedDictInProgress = PersistedSymbolDict.open(dictFf, sfDir);
+ } catch (SfOperationalException e) {
+ dictOpenFailure = e;
+ }
long baseSeed = lowestBase - 1;
long watermarkFsn = watermarkInProgress.read();
// Reject watermarks past publishedFsn: a correctly
@@ -403,6 +524,54 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
if (seed >= 0) {
recovered.acknowledge(seed);
}
+ // Fold the whole recovered ring once. The result checkpoints all
+ // running metadata and raw symbol bytes at each commit-bearing
+ // frame, so its final snapshot excludes an orphan deferred tail
+ // without requiring a second bounded scan.
+ recoveredFrameAnalysisInProgress = recovered.analyzeRecovery(
+ persistedDictInProgress == null ? 0 : persistedDictInProgress.size());
+ recoveryFoldCount++;
+ if (dictOpenFailure != null) {
+ // The deferred verdict from the dictionary open above, now that the
+ // fold can say whether the file was load-bearing.
+ if (recoveredFrameAnalysisInProgress.maxDeltaStart() > 0L) {
+ // A surviving frame's delta starts above id 0, so the ids it
+ // references exist ONLY in the side-file this process could not
+ // read. Replaying it would hand the server ids it was never
+ // given. Abort -- retriably, which is why the type is unchanged:
+ // the file is left intact, Sender.build() does not quarantine it
+ // (SfOperationalException is deliberately outside that catch
+ // list), and BackgroundDrainer treats it as retryable and leaves
+ // no .failed sentinel, so a retry once the transient clears
+ // recovers the slot in full.
+ throw dictOpenFailure;
+ }
+ // Every surviving frame carries its whole dictionary inline, so the
+ // unreadable side-file is not needed to replay this slot, and no
+ // frame this session goes on to write will need it either
+ // (persistedSymbolDict stays null, so isDeltaDictEnabled() is false
+ // and the producer runs full-dict). Clear it for the same reason the
+ // discard branch below clears its own: a session that writes
+ // full-dict frames next to a side-file it never verified hands the
+ // NEXT recovery a survivor describing a previous generation's id
+ // space, which seeds the producer with the wrong strings.
+ if (PersistedSymbolDict.removeOrphan(dictFf, sfDir)) {
+ LOG.warn("sf slot {}: symbol dictionary unreadable ({}), but every recovered "
+ + "frame is self-sufficient -- removed the unusable side-file and "
+ + "recovering in full-dictionary mode",
+ sfDir, dictOpenFailure.getMessage());
+ } else {
+ // Deliberately NOT fatal: refusing here would restore the
+ // permanent build() outage this deferral exists to remove, over a
+ // file nothing in this slot reads. Say plainly what is left
+ // behind so the residue is actionable.
+ LOG.error("sf slot {}: symbol dictionary is neither readable ({}) nor removable; "
+ + "recovering in full-dictionary mode, but the stale side-file "
+ + "SURVIVES -- remove it by hand, or a later recovery whose frames "
+ + "reference fewer ids than it holds will seed symbols from it",
+ sfDir, dictOpenFailure.getMessage());
+ }
+ }
// Locate the last commit-bearing frame below a potentially
// orphaned FLAG_DEFER_COMMIT tail. A producer that crashed (or
// closed) mid-transaction leaves deferred frames with no
@@ -412,12 +581,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
// drainOnClose), and (b) replaying them into a NEW session's
// commit would resurrect half a transaction -- see the WARN
// below. Computed before the I/O loop or producer append.
- this.recoveredCommitBoundaryFsn = recovered.findLastFsnWithoutPayloadFlag(
- io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_OFFSET_FLAGS,
- io.questdb.client.cutlass.qwp.protocol.QwpConstants.FLAG_DEFER_COMMIT,
- io.questdb.client.cutlass.qwp.protocol.QwpConstants.MAGIC_MESSAGE,
- io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE
- );
+ this.recoveredCommitBoundaryFsn = recoveredFrameAnalysisInProgress.commitBoundaryFsn();
if (publishedFsn >= 0 && recoveredCommitBoundaryFsn < publishedFsn) {
this.recoveredOrphanTipFsn = publishedFsn;
LOG.warn("recovered SF log ends with {} deferred frame(s) whose transaction was never "
@@ -427,6 +591,121 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
publishedFsn - Math.max(recoveredCommitBoundaryFsn, -1L),
recoveredCommitBoundaryFsn, publishedFsn);
}
+ // Highest symbol id the surviving COMMITTED frames reference. A
+ // resuming producer compares this against its recovered dictionary
+ // size (seedGlobalDictionaryFromPersisted) to detect a host-crash
+ // tear: if a committed frame references an id the (unsynced, torn)
+ // .symbol-dict no longer holds, resuming would re-use it. The walk is
+ // bounded to recoveredCommitBoundaryFsn so the aborted orphan-deferred
+ // tail -- retired without ever being transmitted -- does not inflate
+ // this and over-reject an otherwise-recoverable slot. maxDeltaEnd()
+ // returns 0 when no such frame carries a symbol, yielding -1 here.
+ // Computed before the I/O loop or producer append; single-threaded.
+ this.recoveredMaxSymbolId = recoveredFrameAnalysisInProgress.maxDeltaEnd() - 1L;
+ // Full-dict-fallback recovery. When the persisted .symbol-dict is a
+ // SUBSET of the ids the surviving frames reference
+ // (recoveredMaxSymbolId >= its size) YET every such frame is
+ // self-sufficient (maxDeltaStart() == 0 -- a full-dict frame that
+ // re-registers its dictionary from id 0), the slot was written in
+ // full-dict fallback: the dictionary never opened when writing, so no
+ // side-file exists and this recovery opened a FRESH EMPTY one. Those
+ // frames replay with no dictionary, so discard the empty side-file and
+ // recover in full-dict mode -- isDeltaDictEnabled() then reports false
+ // and the producer + send loop both run full-dict, exactly as the slot
+ // was written. Without this the sender's seed-time guard would treat the
+ // empty dictionary as a host-crash tear and brick build(), even though
+ // the orphan drainer drains the same frames fine. A genuine torn DELTA
+ // dictionary keeps a frame with deltaStart > 0 (maxDeltaStart() > 0)
+ // and is NOT discarded here: it still fails clean at seed time, since
+ // the ids its delta frames reference cannot be rebuilt without the lost
+ // dictionary. The recoveredMaxSymbolId >= size guard means this never
+ // fires for a slot whose dictionary is intact, nor for an empty slot
+ // (recoveredMaxSymbolId == -1). Single-threaded; before the I/O loop.
+ this.recoveredMaxSymbolDeltaStart = recoveredFrameAnalysisInProgress.maxDeltaStart();
+ if (persistedDictInProgress != null
+ && recoveredMaxSymbolId >= persistedDictInProgress.size()
+ && recoveredMaxSymbolDeltaStart == 0L) {
+ // Only a NON-EMPTY discarded dictionary can desynchronise the fold. The
+ // first fold was keyed to this very size, so when it is 0 the re-fold
+ // below recomputes the identical baseline-0 result -- a no-op. The guard
+ // exists for the case below where discardedSize is genuinely > 0: the
+ // re-fold there is a second O(payload bytes) walk -- with a
+ // byte-at-a-time varint decode over full-dict frames, which carry the
+ // whole dictionary -- so it is worth paying only when the mismatch it
+ // resolves is real.
+ int discardedSize = persistedDictInProgress.size();
+ persistedDictInProgress.close();
+ persistedDictInProgress = null;
+ // Unlink it, do not merely stop using it. Closing alone left the
+ // file on disk, and full-dict mode never creates or appends it
+ // (persistNewSymbolsBeforePublish returns early on
+ // !deltaDictEnabled), so the survivor was STICKY -- it outlived
+ // this session unchanged and every later restart re-read it.
+ //
+ // That is a cross-generation trap. The discard's own guard
+ // (recoveredMaxSymbolId >= size) is what protects a healthy slot,
+ // and it stops firing as soon as an intervening session ingests
+ // FEWER distinct symbols than the survivor holds. The next recovery
+ // then keeps the stale dictionary, re-enables delta mode, and
+ // seedGlobalDictionaryFromPersisted anchors the producer on the
+ // PREVIOUS generation's strings for ids [0, size). Nothing detects
+ // it: the fold's `deltaEnd <= runningCoverage` fast path declares
+ // those frames already covered and never compares a string, so
+ // coverage stays >= 0 and no gap is raised. On the wire the catch-up
+ // registers the stale strings, the replayed full-dict frames then
+ // redefine the same ids with the real ones, and every subsequent row
+ // the producer writes against those ids lands under the wrong
+ // symbol -- silently, with row counts intact.
+ //
+ // Safe to delete precisely here: this branch has already established
+ // maxDeltaStart() == 0, so every surviving frame re-registers its
+ // whole dictionary from id 0 and needs nothing from the file, and
+ // this session runs full-dict so it will not write one that does.
+ // Mirrors the AckWatermark.removeOrphan in the fresh-start branch
+ // below, which clears its own stale artifact for the same reason.
+ if (!PersistedSymbolDict.removeOrphan(dictFf, sfDir)) {
+ LOG.error("sf slot {}: discarded the recovered symbol dictionary but could NOT "
+ + "remove the side-file; recovery continues in full-dictionary "
+ + "mode, but remove it by hand -- a later recovery whose frames "
+ + "reference fewer ids than it holds will seed symbols from it",
+ sfDir);
+ }
+ // Re-fold at baseline 0. The analysis above was keyed to the
+ // dictionary's size, and every consumer of it presents the baseline it
+ // derived the same way -- seedGlobalDictionaryFromPersisted computes
+ // baseline 0 once pd is gone, and checkedRecoveryAnalysis rejects a
+ // baseline that disagrees with the fold. Discarding a dictionary that
+ // held entries (size > 0) therefore desynchronised the two and threw
+ // "recovery symbol baseline mismatch" out of build(). checkedRecoveryAnalysis
+ // now raises that as an UnreplayableSlotException, so build() would at least
+ // set the slot aside rather than rethrow forever -- but quarantining is
+ // still the wrong outcome HERE, because this slot is fully recoverable
+ // (its frames carry their whole dictionary inline). Re-folding avoids the
+ // mismatch entirely instead of trading a permanent brick for a needless
+ // quarantine plus a "resend the affected data" the operator does not owe.
+ //
+ // Reachable when self-sufficient frames sit next to a POPULATED
+ // side-file. open() no longer degrades on a transient (it throws
+ // SfOperationalException), so the organic route is a host-crash
+ // tear that shortened the dictionary's CRC-valid prefix below the
+ // ids the surviving full-dict frames reference; an operator
+ // restoring a side-file by hand lands here too. Either way the
+ // frames carry their whole dictionary inline, so discarding the
+ // survivor and re-folding at baseline 0 recovers the slot exactly
+ // as written.
+ //
+ // Re-folding rather than keeping the dictionary preserves the discard's
+ // whole point -- the slot recovers in full-dict mode, exactly as it was
+ // written, with producer, mirror and replay guard all anchored at 0.
+ if (discardedSize > 0) {
+ recoveredFrameAnalysisInProgress.close();
+ recoveredFrameAnalysisInProgress = recovered.analyzeRecovery(0);
+ recoveryFoldCount++;
+ this.recoveredCommitBoundaryFsn = recoveredFrameAnalysisInProgress.commitBoundaryFsn();
+ this.recoveredMaxSymbolId = recoveredFrameAnalysisInProgress.maxDeltaEnd() - 1L;
+ this.recoveredMaxSymbolDeltaStart = recoveredFrameAnalysisInProgress.maxDeltaStart();
+ }
+ }
} else {
// Fresh start with no recovered segments. Any stale
// watermark from a prior fully-drained session refers
@@ -440,6 +719,28 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
throw new SfOperationalException(
"could not open required ack watermark for SF slot " + sfDir);
}
+ // A fresh slot MUST start with an EMPTY symbol dictionary.
+ // Unlike the ack watermark above -- a discardable optimization a
+ // max() clamp protects -- the dictionary is load-bearing: a
+ // delta frame referencing an id missing from it is unrecoverable,
+ // and a STALE dictionary inherited here (the segments are gone, so
+ // the producer is NOT seeded from it) shifts the dense id->symbol
+ // mapping and silently misattributes symbols on the next
+ // reconnect. openClean() truncates any survivor to empty rather
+ // than trusting a best-effort delete that may have failed (e.g. a
+ // Windows share lock). If the file is simply ABSENT and creating it
+ // fails, persistedSymbolDict stays null and the sender falls back to
+ // full self-sufficient frames -- safe for this session, since there
+ // is no prior generation's id space to lose. But if a dictionary
+ // DOES survive here and cannot be truncated, that fallback is only
+ // safe for THIS session: the next recovery would seed its catch-up
+ // from the stale file and replay this generation's frames on top,
+ // misattributing symbols with no detectable gap. So openClean()
+ // refuses that case outright (throws UnreplayableSlotException)
+ // instead of degrading to null; the catch (Throwable) below cleans
+ // up and lets it propagate so Sender.build() can quarantine this
+ // fresh, dataless slot instead of bricking every restart.
+ persistedDictInProgress = PersistedSymbolDict.openClean(dictFf, sfDir);
}
MmapSegment initial;
String initialPath = null;
@@ -502,11 +803,23 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
if (ownsManager) {
manager.start();
}
- manager.register(ringInProgress, sfDir, watermarkInProgress, syncIntervalNanos);
- // All construction succeeded — commit the ring and
- // watermark references.
+ // occupiedDiskBytes(), not appendedBytes(): the cap is a DISK budget,
+ // and ensureAppendMap reserves real blocks past the committed offset
+ // (rounded up to APPEND_MAP_CAPACITY) for as long as the slot is
+ // live. Gauging the logical offset under-counted every live slot by
+ // up to one window.
+ // The gauge outlives close() safely: it is a pair of wait-free
+ // volatile reads that take no lock and never touch the fd, so a
+ // manager tick racing engine shutdown observes the final values.
+ // It must not become synchronized -- see sideFileBytesLocked().
+ manager.register(ringInProgress, sfDir, watermarkInProgress, syncIntervalNanos,
+ persistedDictInProgress == null ? null : persistedDictInProgress::occupiedDiskBytes);
+ // All construction succeeded -- commit the ring, watermark, and
+ // symbol-dictionary references.
this.ring = ringInProgress;
this.watermark = watermarkInProgress;
+ this.persistedSymbolDict = persistedDictInProgress;
+ this.recoveredFrameAnalysis = recoveredFrameAnalysisInProgress;
} catch (Throwable t) {
// Stop an owned manager before freeing the ring and watermark it may
// touch, then release the slot lock. Each cleanup is in its own
@@ -532,6 +845,18 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
} catch (Throwable ignored) {
}
}
+ if (persistedDictInProgress != null) {
+ try {
+ persistedDictInProgress.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ if (recoveredFrameAnalysisInProgress != null) {
+ try {
+ recoveredFrameAnalysisInProgress.close();
+ } catch (Throwable ignored) {
+ }
+ }
if (acquiredLock != null) {
try {
acquiredLock.close();
@@ -659,7 +984,27 @@ public void checkDurability() {
}
@Override
- public synchronized void close() {
+ public void close() {
+ close(true);
+ }
+
+ /**
+ * As {@link #close()}, but {@code reclaimLogicalSlotLock == false} skips the
+ * parent-anchored logical slot-lock unlink at fully-drained retirement.
+ *
+ * A caller that HOLDS that lock must pass {@code false}. {@code Sender.build()}
+ * keeps it across engine construction and connect, and closes the engine from
+ * inside that scope when connect fails -- and a fresh slot is "fully drained" by
+ * definition ({@code publishedFsn() < 0}), so the default path would unlink the
+ * lock file while build() still holds the flock on it. On POSIX that frees the
+ * pathname without releasing the lock, so the next {@code acquireLogical} creates
+ * a SECOND inode and locks it successfully: two parties owning a lock whose only
+ * job is serialising the quarantine close->rename->recreate window.
+ */
+ public synchronized void close(boolean reclaimLogicalSlotLock) {
+ // Latch before the early return: a retried close() must not widen a
+ // previous caller's narrower reclaim decision.
+ this.reclaimLogicalSlotLock &= reclaimLogicalSlotLock;
if (closed && closeCompleted) return;
closed = true;
// Capture drain state BEFORE closing the ring — once the ring is
@@ -905,6 +1250,18 @@ private void finishClose(boolean fullyDrained) {
} catch (Throwable ignored) {
}
}
+ if (persistedSymbolDict != null) {
+ try {
+ persistedSymbolDict.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ if (recoveredFrameAnalysis != null) {
+ try {
+ recoveredFrameAnalysis.close();
+ } catch (Throwable ignored) {
+ }
+ }
if (fullyDrained && watermark != null) {
boolean segmentsRemoved = false;
try {
@@ -928,6 +1285,26 @@ private void finishClose(boolean fullyDrained) {
+ "engine on this slot recovers them as fully acked and retries the "
+ "unlink on its own close", sfDir);
}
+ try {
+ // Slot fully drained: the dictionary has no frames behind it.
+ PersistedSymbolDict.removeOrphan(sfDir);
+ } catch (Throwable ignored) {
+ }
+ if (reclaimLogicalSlotLock) {
+ try {
+ // The logical slot lock lives OUTSIDE the slot dir (in the
+ // shared .slot-locks dir) so it survives a slot rename; the
+ // fully-drained retirement that removes this slot's other
+ // side-files must remove it too, or .slot-locks accumulates a
+ // dead lock+pid pair per distinct slot name for the lifetime of
+ // sf_dir. Holding the directory-local lock is NOT sufficient to
+ // make this safe -- it says nothing about the LOGICAL lock, which
+ // a caller higher in the same stack may hold. Only a caller that
+ // knows it does not hold it may reclaim, hence the flag.
+ SlotLock.removeOrphanLogical(sfDir);
+ } catch (Throwable ignored) {
+ }
+ }
}
if (durabilityFailure != null) {
throw durabilityFailure;
@@ -1265,6 +1642,79 @@ public void ensureFlockReleaseRetryScheduled() {
}
}
+ /**
+ * Decodes the cached recovery suffix directly into the producer's global
+ * dictionary. Recovery always builds the analysis with the persisted
+ * prefix size as its baseline, so no intermediate cardinality-sized list is
+ * needed on the production path.
+ */
+ public long addRecoveredSymbolsTo(int baseline, GlobalSymbolDictionary target) {
+ if (recoveredFrameAnalysis == null) {
+ return baseline;
+ }
+ RecoveredFrameAnalysis analysis = checkedRecoveryAnalysis(baseline);
+ long coverage = analysis.coverage();
+ if (coverage >= 0L) {
+ analysis.addDecodedSymbolsTo(target);
+ }
+ return coverage;
+ }
+
+ long recoveredSymbolCoverage(int baseline) {
+ return checkedRecoveryAnalysis(baseline).coverage();
+ }
+
+ int recoveredSymbolSuffixCount(int baseline) {
+ return checkedRecoveryAnalysis(baseline).rawCount();
+ }
+
+ int recoveredSymbolSuffixLen(int baseline) {
+ return checkedRecoveryAnalysis(baseline).rawLen();
+ }
+
+ void copyRecoveredSymbolSuffix(int baseline, long target) {
+ RecoveredFrameAnalysis analysis = checkedRecoveryAnalysis(baseline);
+ int len = analysis.rawLen();
+ if (len > 0) {
+ io.questdb.client.std.Unsafe.getUnsafe().copyMemory(analysis.rawAddr(), target, len);
+ }
+ }
+
+ @TestOnly
+ public int recoveryFoldCount() {
+ return recoveryFoldCount;
+ }
+
+ @TestOnly
+ public long recoveryFramesVisited() {
+ return recoveredFrameAnalysis == null ? 0L : recoveredFrameAnalysis.framesVisited();
+ }
+
+ @TestOnly
+ public long recoverySymbolEntriesVisited() {
+ return recoveredFrameAnalysis == null ? 0L : recoveredFrameAnalysis.symbolEntriesVisited();
+ }
+
+ @TestOnly
+ public int recoverySymbolNativeCapacity() {
+ return recoveredFrameAnalysis == null ? 0 : recoveredFrameAnalysis.rawCapacity();
+ }
+
+ private RecoveredFrameAnalysis checkedRecoveryAnalysis(int baseline) {
+ if (recoveredFrameAnalysis == null || recoveredFrameAnalysis.baseline() != baseline) {
+ // UnreplayableSlotException, NOT IllegalStateException: Sender.build() routes
+ // on the type, and only this type reaches its quarantine handler. A raw
+ // IllegalStateException escapes build() instead, and because senderId is
+ // stable and a not-fully-drained slot is retained on close, every restart
+ // re-recovers the same slot and rethrows -- the application can never
+ // construct a Sender, so it cannot even BUFFER new rows.
+ throw new UnreplayableSlotException("recovery symbol baseline mismatch [expected="
+ + (recoveredFrameAnalysis == null ? "none" : recoveredFrameAnalysis.baseline())
+ + ", actual=" + baseline + ']');
+ }
+ return recoveredFrameAnalysis;
+ }
+
/**
* Pass-through to {@link SegmentRing#findSegmentContaining(long)}.
*/
@@ -1283,6 +1733,18 @@ public MmapSegment firstSealed() {
return ring.firstSealed();
}
+ /**
+ * The engine's persisted symbol dictionary, or {@code null} in memory mode
+ * (and in disk mode when recovery proved the side-file absent or corrupt,
+ * or a fresh slot could not create one -- a TRANSIENT open failure never
+ * lands here: it throws {@link SfOperationalException} out of the
+ * constructor, so no engine exists). The producer appends new symbols
+ * to it; recovery / orphan-drain read its loaded entries to seed catch-up.
+ */
+ public PersistedSymbolDict getPersistedSymbolDict() {
+ return persistedSymbolDict;
+ }
+
/**
* Number of times {@link #appendBlocking} hit
* {@link SegmentRing#BACKPRESSURE_NO_SPARE} on its first attempt and
@@ -1293,6 +1755,18 @@ public long getTotalBackpressureStalls() {
return backpressureStallCount.get();
}
+ /**
+ * Whether the sender may delta-encode symbol dictionaries on this engine.
+ * Always true in memory mode (the send loop keeps an in-process catch-up
+ * mirror). In disk mode it requires the persisted dictionary to have opened,
+ * since delta frames are not self-sufficient and recovery / orphan-drain must
+ * be able to rebuild the dictionary from disk. When false in disk mode the
+ * sender falls back to full self-sufficient frames.
+ */
+ public boolean isDeltaDictEnabled() {
+ return sfDir == null || persistedSymbolDict != null;
+ }
+
/**
* Pass-through to {@link SegmentRing#nextSealedAfter(MmapSegment)}.
*/
@@ -1365,6 +1839,31 @@ public long recoveredCommitBoundaryFsn() {
return recoveredCommitBoundaryFsn;
}
+ /**
+ * Highest symbol id any recovered delta frame references, or {@code -1} for
+ * fresh/memory rings (and recovered rings with no symbol-bearing frame). A
+ * resuming producer compares this against its recovered dictionary size to
+ * detect a host-crash tear of the persisted {@code .symbol-dict}.
+ */
+ public long recoveredMaxSymbolId() {
+ return recoveredMaxSymbolId;
+ }
+
+ /**
+ * Highest {@code deltaStart} across the recovered committed frames; {@code 0} when every
+ * surviving frame is self-sufficient (or none carries a dictionary at all).
+ *
+ * The send loop uses this to decide whether it needs a catch-up: at zero, every frame
+ * re-registers its dictionary from id 0 as it replays, so seeding the mirror -- and
+ * shipping a catch-up frame off it -- would be pure redundancy. Above zero, at least one
+ * frame's delta starts above ids it does not itself carry, so the mirror must hold those
+ * ids before the replay begins, or the server rejects the frame with
+ * STATUS_DICTIONARY_GAP.
+ */
+ public long recoveredMaxSymbolDeltaStart() {
+ return recoveredMaxSymbolDeltaStart;
+ }
+
/**
* FSN of the last frame of a recovered orphaned deferred tail, or
* {@code -1} when none. See {@link #recoveredCommitBoundaryFsn()}: the
@@ -1374,6 +1873,31 @@ public long recoveredOrphanTipFsn() {
return recoveredOrphanTipFsn;
}
+ /**
+ * Retires a recovered deferred tail once every frame below it is ACKed.
+ * The operation is local and idempotent: no wire sequence ever referred
+ * to these aborted-transaction frames.
+ *
+ * @return true if no orphan tail remains, false if lower frames still need
+ * server ACKs
+ */
+ public boolean retireRecoveredOrphanTailIfReady() {
+ long orphanTip = recoveredOrphanTipFsn;
+ if (orphanTip < 0L) {
+ return true;
+ }
+ long orphanStart = recoveredCommitBoundaryFsn + 1L;
+ if (ackedFsn() < orphanStart - 1L) {
+ return false;
+ }
+ LOG.warn("retiring orphaned deferred tail: {} frame(s) [fsn {}..{}] belong to a transaction "
+ + "whose commit was never published; aborting them (never transmitted, slots trimmed)",
+ orphanTip - orphanStart + 1L, orphanStart, orphanTip);
+ acknowledge(orphanTip);
+ recoveredOrphanTipFsn = -1L;
+ return true;
+ }
+
/**
* Ascending removal rank of a segment file name for
* {@link #unlinkAllSegmentFiles(String)}. {@code sf-initial.sfa} is
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 3f58fa35..5a66c302 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
@@ -31,14 +31,17 @@
import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
import io.questdb.client.cutlass.http.client.WebSocketUpgradeException;
import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.client.NativeBufferWriter;
import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException;
import io.questdb.client.cutlass.qwp.client.QwpRoleMismatchException;
import io.questdb.client.cutlass.qwp.client.QwpVersionMismatchException;
import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
import io.questdb.client.cutlass.qwp.websocket.WebSocketCloseCode;
import io.questdb.client.std.CharSequenceLongHashMap;
+import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.QuietCloseable;
import io.questdb.client.std.Unsafe;
import org.jetbrains.annotations.TestOnly;
@@ -65,9 +68,13 @@
* can trim fully-acked segments.
+ * It matters here because the cap gap's trigger IS a failover. With the reconnect
+ * backoff capped at {@link #DEFAULT_RECONNECT_MAX_BACKOFF_MILLIS} (5 s), 16 cap
+ * gaps accrue in roughly two minutes -- comfortably less than an ordinary rolling
+ * restart of the larger-cap node. A count-only budget could therefore quarantine an
+ * otherwise drainable orphan slot during a routine cluster operation. Requiring the
+ * dwell as well means the orphan terminal needs BOTH "we have looked many times" AND
+ * "it has stayed true for a long time". Foreground senders never apply this terminal
+ * policy: they retry indefinitely until a larger-cap node returns.
+ *
+ * 5 minutes -- the same figure as {@link #DEFAULT_RECONNECT_MAX_DURATION_MILLIS},
+ * this codebase's existing notion of how long a transient outage may plausibly
+ * last. Configurable per sender via the
+ * {@code catch_up_cap_gap_min_escalation_window_millis} connect-string key or
+ * {@code LineSenderBuilder.catchUpCapGapMinEscalationWindowMillis(long)}; {@code 0}
+ * restores count-only escalation for orphan drainers.
+ */
+ public static final long DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS = 300_000L;
+ /**
+ * Packing ceiling for a dictionary catch-up when the server advertises no batch cap
+ * (an older build that omits X-QWP-Max-Batch-Size, or one whose derived cap collapsed
+ * to zero). "Not advertised" is not "unbounded": the transport still closes anything
+ * larger than the server's receive buffer with 1009, and a catch-up-only close is
+ * deliberately non-terminal, so an unchunked catch-up reconnects into the identical
+ * frame forever. Deliberately well below the 131072 default receive buffer, since the
+ * real value cannot be known when it is not advertised.
+ *
+ * This bounds MULTI-ENTRY packing only. The cap-gap terminal is measured against a
+ * separate, generous limit -- see sendDictCatchUp -- so that a single entry which
+ * already shipped inside a data frame is never reclassified as unsendable.
+ */
+ static final int UNCAPPED_CATCHUP_PACKING_LIMIT = 64 * 1024;
private static final Logger LOG = LoggerFactory.getLogger(CursorWebSocketSendLoop.class);
+ // Settle budget for the symbol-dict catch-up cap gap: how many cap-gap attempts
+ // -- catch-ups that reached a fresh server and found a single dictionary entry
+ // too large for its advertised batch cap -- may occur consecutively before an
+ // orphan drainer latches a terminal. This is a
+ // SANCTIONED orphan-only terminal (a genuine cluster batch-size capability gap),
+ // the connect-time analog of the orphan drainer's durable-ack capability gap
+ // (DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS). A homogeneous cluster never trips
+ // it -- an entry that fit its data frame under a cap always fits its bare catch-up
+ // frame under that same cap -- so it only affects a heterogeneous / rolling-cap
+ // cluster, where a failover to a smaller-cap node can hit it for an entry an
+ // earlier node accepted. A foreground sender retries forever. An orphan drainer
+ // rides out the transient window until a larger-cap node returns; only a persistent
+ // gap (this many consecutive cap gaps with no successful catch-up or unrelated
+ // reconnect state in between) latches.
+ //
+ // Budget accounting (satisfies "a transient must never burn the terminal
+ // budget"): catchUpCapGapAttempts increments ONLY inside sendDictCatchUp when a
+ // node is reached and an entry is oversized. A successful catch-up ends the
+ // episode, as does any unrelated reconnect state (connect refusal, catch-up send
+ // failure, upgrade/role rejection): otherwise its downtime would count toward the
+ // wall-clock dwell even though no cap gap was observed. The cap-gap exception
+ // itself does NOT reset the episode, so consecutive small-cap nodes still prove a
+ // persistent cluster capability gap and can exhaust the orphan policy.
+ private static final int MAX_CATCHUP_CAP_GAP_ATTEMPTS = 16;
+ // Hard ceiling for the lifetime-monotonic sent-dictionary mirror. The mirror
+ // fields are int, so it cannot exceed Integer.MAX_VALUE bytes; reaching even
+ // this needs ~200M+ distinct symbols on a single connection, far past any real
+ // workload. The guard exists so that pathological growth fails loudly instead
+ // of overflowing the int capacity math into a heap-corrupting copyMemory.
+ private static final int MAX_SENT_DICT_BYTES = Integer.MAX_VALUE - 8;
/**
* Throttle "reconnect attempt N failed" WARN logs to one per 5 s.
*/
private static final long RECONNECT_LOG_THROTTLE_NANOS = 5_000_000_000L;
+ // Test seam: when true, recovery mirror seeding throws immediately AFTER
+ // ensureSentDictCapacity has grown (and therefore taken ownership of) the mirror,
+ // standing in for the copyRecoveredSymbolSuffix-adjacent failure that leaves a
+ // freshly malloc'd mirror with no owner. It sits after the grow deliberately: the
+ // constructor's cleanup only frees an OWNED mirror, so a seam before the grow
+ // would leave nothing to free and the leak guard would pass with the cleanup
+ // deleted. Production never sets it; volatile only so a test thread's write is
+ // visible to the loop under test.
+ @TestOnly
+ public static volatile boolean forceMirrorSeedFailureForTest;
// Pre-converted to nanos for the comparison in sendDurableAckKeepaliveIfDue.
// Zero or negative disables the keepalive entirely.
private final long durableAckKeepaliveIntervalNanos;
@@ -175,8 +262,14 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// by the server -- holding stale watermarks across the wire boundary
// would falsely advance trim before re-confirmation.
private final CharSequenceLongHashMap durableTableWatermarks = new CharSequenceLongHashMap();
+ // Pre-converted to nanos. Consulted only by the orphan terminal policy. Zero disables
+ // the dwell entirely (count-only escalation at MAX_CATCHUP_CAP_GAP_ATTEMPTS); the
+ // user-facing 5-minute default is applied at the config layer.
+ private final long catchUpCapGapMinEscalationWindowNanos;
+ private final CatchUpCapGapPolicy catchUpCapGapPolicy;
private final CursorSendEngine engine;
private final long parkNanos;
+ private final ReconnectPolicy reconnectPolicy;
// FIFO of OK-acked batches awaiting durable-upload confirmation. Used only
// when durableAckMode is true. Each entry binds a wireSeq to the per-table
// (name, seqTxn) pairs the server reported on the OK frame. The queue is
@@ -198,12 +291,6 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
private final ReconnectFactory reconnectFactory;
private final long reconnectInitialBackoffMillis;
private final long reconnectMaxBackoffMillis;
- // Retained for constructor symmetry and passed in by callers, but NOT
- // consulted by the background loop: Invariant B removed the wall-clock
- // give-up from connectLoop. The budget still bounds the blocking (non-lazy)
- // initial connect via QwpWebSocketSender -> connectWithRetry, which takes it
- // as an explicit argument rather than reading this field.
- private final long reconnectMaxDurationMillis;
private final WebSocketResponse response = new WebSocketResponse();
private final ResponseHandler responseHandler = new ResponseHandler();
private final CountDownLatch shutdownLatch = new CountDownLatch(1);
@@ -229,6 +316,80 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// by category. Includes both retriable and terminal outcomes — i.e. every
// server-side rejection observed regardless of how the loop reacted.
private final AtomicLong totalServerErrors = new AtomicLong();
+ // Delta symbol dictionary catch-up state (see swapClient).
+ // ALWAYS active -- in memory mode, in disk mode, and (critically) even when the
+ // per-slot persisted dictionary failed to open. sentDictCount is this loop's model
+ // of how many ids the CURRENT server has been told about, and the torn-dict guard
+ // in trySendOne reads it, so it must track the wire in every mode; gating it on
+ // engine.isDeltaDictEnabled() is what once froze it at 0 and terminal'd a slot
+ // whose frames replay perfectly from id 0 (see the constructor). On a recovered /
+ // orphan-drained slot the constructor SEEDS sentDict* from the persisted
+ // dictionary when there is one; otherwise the mirror starts empty and grows from
+ // the frames themselves. The loop mirrors, in sentDict*, every symbol it has ever
+ // sent -- the concatenated [len varint][utf8] bytes in global-id order
+ // (sentDictBytes*) plus the count (sentDictCount) -- so that on reconnect it can
+ // re-register the whole dictionary on the fresh server (which discards its
+ // dictionary on every disconnect) before replaying frames whose deltas start above
+ // id 0. All of this is touched only by the I/O thread.
+ // Footprint note: this mirror is a SECOND copy of the dictionary -- the same
+ // symbols the producer's GlobalSymbolDictionary already holds as Java Strings --
+ // kept as native UTF-8 bytes for the reconnect-catch-up capability. So a
+ // memory-mode connection's steady-state dictionary footprint is ~2x the symbol
+ // set. It is bounded by distinct-symbol count (not per-row) and never trimmed for
+ // the connection's lifetime (a reconnect may need the whole dictionary at any
+ // moment), so it cannot be dropped; it is an intentional cost of the feature.
+ private long sentDictBytesAddr;
+ private int sentDictBytesCapacity;
+ private int sentDictBytesLen;
+ // False while the mirror is unallocated, and while a loop of EITHER policy borrows
+ // the engine's persisted prefix -- both borrow now; neither consumes it. Any growth
+ // copy-on-writes into loop-owned memory and sets this true; only owned buffers are
+ // freed.
+ private boolean sentDictBytesOwned;
+ private int sentDictCount;
+ // True when replay frames can start above dictionary id zero and therefore
+ // depend on a catch-up on a fresh connection. Delta-enabled live engines
+ // always have this dependency. A recovered delta slot whose dictionary
+ // failed to open is identified by its non-zero recovered delta start. The
+ // remaining false case is full-dictionary fallback: every frame re-registers
+ // from id zero, so sending the mirror first would duplicate the dictionary.
+ private final boolean hasReplayDictionaryDependency;
+ // Reusable staging buffer for the small QWP catch-up prefix (fixed header plus
+ // delta range varints). Symbol bytes stay in sentDictBytesAddr and are passed as a
+ // second WebSocket payload slice, avoiding a full-dictionary staging copy.
+ private long catchUpFrameAddr;
+ private int catchUpFrameCapacity;
+ private int catchUpFrameGrowthCount;
+ // Orphan-policy cap-gap attempts -- catch-ups that reached a node and found an entry
+ // too large for its batch cap -- with no intervening successful catch-up or unrelated
+ // reconnect state (see MAX_CATCHUP_CAP_GAP_ATTEMPTS for the full budget accounting).
+ // Foreground retries never increment it. I/O-thread-only.
+ private int catchUpCapGapAttempts;
+ // System.nanoTime() of the FIRST cap gap of the current orphan-policy episode.
+ // Anchors the escalation dwell. Reset together with catchUpCapGapAttempts on a
+ // successful catch-up or an unrelated reconnect state, so only an uninterrupted run
+ // of cap-gap observations contributes wall-clock dwell. Anchoring at the FIRST gap
+ // (not refreshed per attempt) lets consecutive small-cap nodes satisfy the dwell;
+ // resetting after transport/role states prevents their downtime from doing so.
+ // I/O-thread-only, like catchUpCapGapAttempts.
+ //
+ // -1 marks "no episode open" for a debugger or a heap dump, but it is NOT the test
+ // for one -- catchUpCapGapAttempts == 0 is (see sendDictCatchUp). A nanoTime instant
+ // is only meaningful as a difference: its origin is arbitrary and the spec permits
+ // negative values, so no state may ride on this field's sign.
+ private long catchUpCapGapFirstNanos = -1L;
+ // True once a real ring frame (data or commit) has been sent on the CURRENT
+ // connection, as opposed to only the dictionary catch-up. The catch-up consumes
+ // wire sequences (nextWireSeq), so nextWireSeq > 0 no longer implies "the head
+ // frame was sent": onClose's poison-strike gate and handleServerRejection's
+ // pre-send gate key off THIS instead. Without it, a transient outage AFTER the
+ // catch-up but BEFORE the first data frame (a flapping LB/middlebox that accepts
+ // the upgrade + catch-up then closes) would be mistaken for a deterministic
+ // head-frame rejection and escalate to a PROTOCOL_VIOLATION terminal -- breaking
+ // the store-and-forward "retry a transient outage forever" contract. Reset per
+ // connection in setWireBaselineWithCatchUp; set in trySendOne after a successful
+ // send.
+ private boolean dataFrameSentThisConnection;
private volatile WebSocketClient client;
// Optional: when non-null, every server-rejection error (retriable and
// terminal alike) is offered to the dispatcher for async delivery to the user's
@@ -262,11 +423,19 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
private long shutdownAwaitTimeoutMillis = DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS;
// Sticky flag: false until the very first time a live client is installed
// (either via the constructor in SYNC/OFF mode or via swapClient on a
- // successful connect attempt in any mode). Once true, stays true. Used to
- // distinguish a "never reached the server" terminal failure (looks like a
- // config typo or firewall block) from "lost connection after we were
- // up" (looks transient).
+ // successful connect attempt in any mode). Once true, stays true.
+ // LOAD-BEARING, not observability: it is half of endpointPolicyFailureIsTerminal(),
+ // which latches an auth / upgrade / durable-ack rejection only for an ORPHAN drainer
+ // or a sender that has never connected. Relocate or lazily initialise the write and a
+ // foreground sender's transient auth blip becomes a producer-fatal terminal -- the
+ // exact failure this policy exists to prevent.
private volatile boolean hasEverConnected;
+ // Cause of the outage the reconnect loop is currently riding out, or null once a
+ // connect succeeds. Written by the I/O thread, read by the producer thread so a
+ // backpressure or drain-timeout failure can NAME the reason the wire is not draining
+ // instead of blaming disk sizing. Was a connectLoop local that never escaped, which
+ // is why a revoked token surfaced to operators as "sf_max_total_bytes too small".
+ private volatile Throwable lastReconnectError;
private volatile Thread ioThread;
// Typed marker for a durable-ack CAPABILITY-GAP terminal: set (before the
// terminalError latch, so a checkError() caller that observes the latch is
@@ -274,8 +443,8 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// QwpDurableAckMismatchException. The orphan drainer consults it to route
// a mid-drain capability gap into its budgeted settle-retry
// (BackgroundDrainer.connectWithDurableAckRetry) instead of quarantining
- // the slot on the first sweep; the foreground sender ignores it and keeps
- // its spec'd loud-fail (sf-client.md section 8.1). Write-once alongside
+ // the slot on the first sweep. Foreground reconnects never set this marker;
+ // they keep retrying after a successful initial connection. Write-once alongside
// terminalError: the only writer runs on the I/O thread under the same
// first-writer-wins latch.
private volatile QwpDurableAckMismatchException capabilityGapTerminal;
@@ -301,6 +470,10 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// configured interval has elapsed since the most recent outbound event.
// Zero until the first send; reset to zero on reconnect.
private long lastFrameOrPingNanos;
+ // Throttles the catch-up oversize WARN. Only consulted when the server
+ // advertises NO batch cap, where the loop has no terminal to fall back on
+ // and would otherwise recycle in silence. See sendDictCatchUp.
+ private long lastUncappedOversizeWarnNanos;
private long nextWireSeq;
private volatile SenderProgressDispatcher progressDispatcher;
// Frames sent during the post-reconnect catch-up window — i.e. frames
@@ -379,7 +552,7 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
private final int maxHeadFrameRejections;
// Minimum wall-clock dwell (nanos) a suspect frame must stay poisoned before
// escalation, even after the strike threshold is hit (connect-string key
- // poison_min_escalation_window_millis). 0 = legacy immediate escalation.
+ // poison_min_escalation_window_millis). 0 = immediate.
private final long poisonMinEscalationWindowNanos;
private volatile boolean running;
// sendOffset: byte offset inside sendingSegment of the first not-yet-sent
@@ -406,19 +579,18 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
* {@code client} may be {@code null} only if {@code reconnectFactory}
* is non-null — this is the async-initial-connect path: the I/O thread
* runs the same retry loop on its first iteration to obtain a live
- * client, and a terminal failure (auth/upgrade reject) is delivered
- * through the dispatcher rather than thrown to the constructor's
- * caller; plain connect failures are retried indefinitely
- * (Invariant B: no wall-clock budget give-up).
+ * client. Every endpoint-policy and transport failure stays inside that
+ * background retry loop; it is never delivered to the foreground producer
+ * (Invariant B: no wall-clock budget give-up). Blocking OFF/SYNC startup
+ * performs its fail-fast policy before constructing this loop.
*/
public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
long fsnAtZero, long parkNanos,
ReconnectFactory reconnectFactory,
- long reconnectMaxDurationMillis,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
- reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
+ reconnectInitialBackoffMillis,
reconnectMaxBackoffMillis, false);
}
@@ -434,12 +606,11 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
long fsnAtZero, long parkNanos,
ReconnectFactory reconnectFactory,
- long reconnectMaxDurationMillis,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
boolean durableAckMode) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
- reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
+ reconnectInitialBackoffMillis,
reconnectMaxBackoffMillis, durableAckMode,
DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS);
}
@@ -454,19 +625,18 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
long fsnAtZero, long parkNanos,
ReconnectFactory reconnectFactory,
- long reconnectMaxDurationMillis,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
boolean durableAckMode,
long durableAckKeepaliveIntervalMillis) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
- reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
+ reconnectInitialBackoffMillis,
reconnectMaxBackoffMillis, durableAckMode,
durableAckKeepaliveIntervalMillis, DEFAULT_MAX_HEAD_FRAME_REJECTIONS);
}
/**
- * Eleven-arg overload — omits the poison-escalation dwell window, which
+ * Ten-arg overload — omits the poison-escalation dwell window, which
* defaults to {@code 0} (legacy: escalate as soon as maxHeadFrameRejections
* strikes accrue, with no minimum wall-clock). The user-facing 5s default
* ({@link #DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS}) is applied at the
@@ -477,37 +647,90 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
long fsnAtZero, long parkNanos,
ReconnectFactory reconnectFactory,
- long reconnectMaxDurationMillis,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
boolean durableAckMode,
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
- reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
+ reconnectInitialBackoffMillis,
reconnectMaxBackoffMillis, durableAckMode,
durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, 0L);
}
/**
- * Master constructor — also accepts the poison-frame detector threshold
+ * Eleven-arg overload — omits the symbol-dict cap-gap escalation dwell, which
+ * defaults to {@code 0}. This and the twelve-arg overload use the foreground-safe
+ * retry-forever policy; orphan drainers opt into count-and-dwell escalation through
+ * the policy-aware master constructor.
+ */
+ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+ long fsnAtZero, long parkNanos,
+ ReconnectFactory reconnectFactory,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ boolean durableAckMode,
+ long durableAckKeepaliveIntervalMillis,
+ int maxHeadFrameRejections,
+ long poisonMinEscalationWindowMillis) {
+ this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis, durableAckMode,
+ durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
+ poisonMinEscalationWindowMillis, 0L);
+ }
+
+ /**
+ * Twelve-arg overload. Catch-up cap gaps are retried indefinitely, which is the
+ * required policy for a foreground store-and-forward sender. Orphan drainers use the
+ * policy-aware overload below to opt into bounded terminal escalation.
+ *
+ * Also accepts the poison-frame detector threshold
* ({@code max_frame_rejections}): 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. Must be {@code >= 1}. The final
- * argument is the minimum wall-clock dwell (millis) the suspect must stay
- * poisoned before escalation ({@code poison_min_escalation_window_millis};
- * {@code >= 0}, where 0 = legacy immediate escalation at the threshold).
+ * loop escalates to a typed terminal. Must be {@code >= 1}. Then the minimum
+ * wall-clock dwell (millis) the suspect must stay poisoned before escalation
+ * ({@code poison_min_escalation_window_millis}; {@code >= 0}, where 0 =
+ * immediate escalation at the threshold).
+ *
+ * The final argument is the analogous dwell for the symbol-dict catch-up cap gap; it
+ * is consulted only when the policy-aware overload selects
+ * {@link CatchUpCapGapPolicy#TERMINAL_AFTER_SETTLE_BUDGET}.
*/
public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
long fsnAtZero, long parkNanos,
ReconnectFactory reconnectFactory,
- long reconnectMaxDurationMillis,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
boolean durableAckMode,
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections,
- long poisonMinEscalationWindowMillis) {
+ long poisonMinEscalationWindowMillis,
+ long catchUpCapGapMinEscalationWindowMillis) {
+ this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis, durableAckMode,
+ durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
+ poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis,
+ CatchUpCapGapPolicy.RETRY_FOREVER);
+ }
+
+ /**
+ * Implementation constructor. The {@link ReconnectPolicy} overload is the public
+ * entry point; it names the foreground versus orphan ownership distinction directly
+ * and maps it onto the {@link CatchUpCapGapPolicy} this constructor takes.
+ */
+ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+ long fsnAtZero, long parkNanos,
+ ReconnectFactory reconnectFactory,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ boolean durableAckMode,
+ long durableAckKeepaliveIntervalMillis,
+ int maxHeadFrameRejections,
+ long poisonMinEscalationWindowMillis,
+ long catchUpCapGapMinEscalationWindowMillis,
+ CatchUpCapGapPolicy catchUpCapGapPolicy) {
if (maxHeadFrameRejections < 1) {
throw new IllegalArgumentException(
"maxHeadFrameRejections must be >= 1: " + maxHeadFrameRejections);
@@ -516,6 +739,24 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
throw new IllegalArgumentException(
"poisonMinEscalationWindowMillis must be >= 0: " + poisonMinEscalationWindowMillis);
}
+ if (catchUpCapGapMinEscalationWindowMillis < 0) {
+ throw new IllegalArgumentException(
+ "catchUpCapGapMinEscalationWindowMillis must be >= 0: "
+ + catchUpCapGapMinEscalationWindowMillis);
+ }
+ // TimeUnit conversion saturates at Long.MAX_VALUE. A raw multiply
+ // wraps large valid millisecond values negative, which makes the
+ // elapsed-time gate appear satisfied as soon as the strike threshold
+ // is reached and can quarantine a recoverable orphan slot prematurely.
+ this.catchUpCapGapMinEscalationWindowNanos =
+ TimeUnit.MILLISECONDS.toNanos(catchUpCapGapMinEscalationWindowMillis);
+ if (catchUpCapGapPolicy == null) {
+ throw new IllegalArgumentException("catchUpCapGapPolicy must be non-null");
+ }
+ this.catchUpCapGapPolicy = catchUpCapGapPolicy;
+ this.reconnectPolicy = catchUpCapGapPolicy == CatchUpCapGapPolicy.RETRY_FOREVER
+ ? ReconnectPolicy.FOREGROUND
+ : ReconnectPolicy.ORPHAN;
if (engine == null) {
throw new IllegalArgumentException("engine must be non-null");
}
@@ -525,19 +766,141 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
}
this.client = client;
this.engine = engine;
+ this.hasReplayDictionaryDependency = engine.isDeltaDictEnabled()
+ || engine.recoveredMaxSymbolDeltaStart() > 0L;
+ // Recovery / orphan-drain: the loop starts with a fresh in-memory mirror,
+ // so seed it from the slot's persisted dictionary. That way the very first
+ // connection re-registers the whole dictionary (via a catch-up frame)
+ // before replaying the recovered delta frames.
+ //
+ // Deliberately NOT gated on engine.isDeltaDictEnabled(). The mirror, the
+ // catch-up and the replay guard together are this loop's model of what the
+ // SERVER's dictionary holds, and that model must track every mode. Gating
+ // them on that flag is what once bricked a recoverable slot: an unopenable
+ // .symbol-dict reports isDeltaDictEnabled()=false, yet the frames already on
+ // disk are still DELTA frames (deltaStart 0,1,2,...). With the mirror gated
+ // off, sentDictCount froze at 0 while the ungated guard kept comparing
+ // against it, so the frame at deltaStart=1 tripped a terminal -- even though
+ // replaying the whole sequence from id 0 rebuilds the dictionary on the
+ // server contiguously and drains perfectly. isDeltaDictEnabled() decides
+ // what the PRODUCER emits and persists; it must never decide what this loop
+ // tracks. When the dictionary could not be opened, pd is null, the mirror
+ // simply starts empty and grows from the frames themselves.
+ PersistedSymbolDict pd = engine.getPersistedSymbolDict();
+ if (pd != null && pd.recoveredSize() > 0) {
+ int len = pd.loadedEntriesLen();
+ if (len > 0) {
+ // Seed by reference. The foreground loop takes ownership after
+ // construction succeeds; orphan-drainer loops keep borrowing because
+ // one engine can create several sessions during capability-gap
+ // recycling. A borrowed mirror performs copy-on-write if a recovered
+ // frame suffix must extend it.
+ sentDictBytesAddr = pd.loadedEntriesAddr();
+ sentDictBytesCapacity = len;
+ sentDictBytesLen = len;
+ // Set the count only alongside the bytes so sentDictCount can
+ // never claim symbols the mirror does not hold -- and take it from
+ // recoveredSize(), NOT the live size(). They differ: the recovery
+ // heal (QwpWebSocketSender.healPersistedDictionary) appends the
+ // frame-contributed suffix to the file before this loop is built, so
+ // size() would already have run past the loaded byte region.
+ sentDictCount = pd.recoveredSize();
+ }
+ }
+ // ...and then from the surviving frames' own delta sections, exactly as the producer
+ // seeds its dictionary. The mirror is the loop's model of what the SERVER holds and it
+ // is what the catch-up frame ships, so when it stops at the persisted prefix while the
+ // producer's baseline runs past it, the loop condemns (deltaStart > sentDictCount) a
+ // slot the producer just seeded successfully. Same two sources, same order, so the two
+ // land on the same number by construction.
+ // ...but ONLY when a surviving frame actually depends on ids it does not carry
+ // (recoveredMaxSymbolDeltaStart > 0). At zero every frame is self-sufficient and
+ // re-registers its dictionary from id 0 as it replays, so seeding the mirror would buy
+ // nothing and cost a catch-up frame on every connection -- full-dict slots must stay
+ // catch-up-free.
+ // The prefix seed above may still be borrowed from PersistedSymbolDict.
+ // Extending it inside this cleanup boundary is what makes that safe:
+ // ensureSentDictCapacity copy-on-writes a borrowed prefix into loop-OWNED
+ // native memory, so from that point a failure would leak it -- and an
+ // allocation failure here propagates out of the constructor before any
+ // caller can own the half-built loop, leaving nothing else able to
+ // release it. releaseSentDictBytes() in the catch frees exactly that
+ // mirror, and only when this loop owns it.
+ try {
+ if (engine.recoveredMaxSymbolDeltaStart() > 0L) {
+ int baseline = sentDictCount;
+ if (engine.recoveredSymbolCoverage(baseline) >= 0L) {
+ int suffixLen = engine.recoveredSymbolSuffixLen(baseline);
+ int suffixCount = engine.recoveredSymbolSuffixCount(baseline);
+ if (suffixLen > 0) {
+ ensureSentDictCapacity((long) sentDictBytesLen + suffixLen);
+ if (forceMirrorSeedFailureForTest) {
+ // Throw AFTER the grow, never before it. ensureSentDictCapacity
+ // is what copy-on-writes a borrowed prefix into a loop-OWNED
+ // allocation (sentDictBytesOwned = true), and the catch's
+ // releaseSentDictBytes() is gated on exactly that flag. A seam
+ // in front of the grow fires while the mirror is still borrowed
+ // from PersistedSymbolDict, so the cleanup frees nothing and the
+ // guard below passes even with the cleanup deleted -- it would
+ // stop pinning the leak it exists for.
+ throw new LineSenderException(
+ "simulated mirror seed allocation failure (test only)");
+ }
+ engine.copyRecoveredSymbolSuffix(
+ baseline, sentDictBytesAddr + sentDictBytesLen);
+ sentDictBytesLen += suffixLen;
+ sentDictCount += suffixCount;
+ }
+ }
+ }
+ // No per-entry offset index is derived here, and none is kept anywhere:
+ // the mirror carries its own framing ([len varint][utf8] per entry), so
+ // sendDictCatchUp -- its only reader -- recovers each entry's span with a
+ // running pointer as it walks. A recovered slot that drains without ever
+ // reconnecting, the normal case, therefore never pays that O(n) walk and
+ // carries no index memory for it.
+ } catch (Throwable t) {
+ releaseSentDictBytes();
+ throw t;
+ }
+ // BOTH policies borrow the engine-owned recovery sources; neither consumes them.
+ //
+ // The foreground path used to take ownership here -- pd.takeLoadedEntries() plus
+ // engine.releaseRecoveredSymbolStorage() -- which made the hand-off ONE-SHOT while
+ // leaving a second construction reachable: QwpWebSocketSender.ensureConnected's
+ // catch closes and nulls cursorSendLoop precisely so a caller can retry, and
+ // `connected` is only set at the very end. takeLoadedEntries zeroes addr/len but
+ // NOT size, and releaseRecoveredSymbolStorage zeroes the raw buffer but NOT
+ // baseline, so the retry saw pd.recoveredSize() > 0 with loadedEntriesLen() == 0,
+ // left sentDictCount at 0, and then either tripped checkedRecoveryAnalysis'
+ // baseline mismatch or -- when recoveredMaxSymbolDeltaStart == 0 -- latched the
+ // torn-dictionary terminal ("resend required") on a perfectly healthy slot.
+ //
+ // Borrowing removes the state machine instead of trying to make it idempotent.
+ // Lifetime is safe in both directions: QwpWebSocketSender.close() closes the loop
+ // before the engine, and BackgroundDrainer's finally does the same, so the buffers
+ // 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.parkNanos = parkNanos;
this.reconnectFactory = reconnectFactory;
- this.reconnectMaxDurationMillis = reconnectMaxDurationMillis;
this.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
this.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
this.durableAckMode = durableAckMode;
+ // Saturate, never multiply raw -- the same hazard the cap-gap dwell above
+ // guards. A raw multiply wraps a large millisecond value NEGATIVE, and both of
+ // these read as "elapsed >= window", so a negative makes the gate trivially
+ // true: the keepalive would ping on every loop pass, and the poison detector
+ // would escalate on the strike count alone -- exactly the transient-to-terminal
+ // false positive the dwell exists to stop (Invariant B). Both keys are
+ // user-supplied and validated only as >= 0, so asking for a very long window is
+ // legal, and asking for a longer one must never buy a shorter one.
this.durableAckKeepaliveIntervalNanos = durableAckKeepaliveIntervalMillis > 0
- ? durableAckKeepaliveIntervalMillis * 1_000_000L
+ ? TimeUnit.MILLISECONDS.toNanos(durableAckKeepaliveIntervalMillis)
: 0L;
this.maxHeadFrameRejections = maxHeadFrameRejections;
this.poisonMinEscalationWindowNanos = poisonMinEscalationWindowMillis > 0
- ? poisonMinEscalationWindowMillis * 1_000_000L
+ ? TimeUnit.MILLISECONDS.toNanos(poisonMinEscalationWindowMillis)
: 0L;
// SYNC/OFF startup hands a live client to the constructor, so we
// already know we reached the server at least once. ASYNC startup
@@ -555,6 +918,40 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
}
}
+ /**
+ * Policy-aware master constructor. A foreground sender fails fast while
+ * 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.
+ */
+ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+ long fsnAtZero, long parkNanos,
+ ReconnectFactory reconnectFactory,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ boolean durableAckMode,
+ long durableAckKeepaliveIntervalMillis,
+ int maxHeadFrameRejections,
+ long poisonMinEscalationWindowMillis,
+ long catchUpCapGapMinEscalationWindowMillis,
+ ReconnectPolicy reconnectPolicy) {
+ this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis, durableAckMode,
+ durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
+ poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis,
+ catchUpPolicyFor(reconnectPolicy));
+ }
+
+ private static CatchUpCapGapPolicy catchUpPolicyFor(ReconnectPolicy reconnectPolicy) {
+ if (reconnectPolicy == null) {
+ throw new IllegalArgumentException("reconnectPolicy must be non-null");
+ }
+ return reconnectPolicy == ReconnectPolicy.FOREGROUND
+ ? CatchUpCapGapPolicy.RETRY_FOREVER
+ : CatchUpCapGapPolicy.TERMINAL_AFTER_SETTLE_BUDGET;
+ }
+
/**
* Maps a server status byte to a {@link SenderError.Category}. Exposed for unit tests.
*/
@@ -573,6 +970,8 @@ public static SenderError.Category classify(byte status) {
return SenderError.Category.WRITE_ERROR;
case WebSocketResponse.STATUS_NOT_WRITABLE:
return SenderError.Category.NOT_WRITABLE;
+ case WebSocketResponse.STATUS_DICTIONARY_GAP:
+ return SenderError.Category.DICTIONARY_GAP;
default:
return SenderError.Category.UNKNOWN;
}
@@ -601,12 +1000,21 @@ public static WebSocketClient connectWithRetry(
String contextLabel
) {
long startNanos = System.nanoTime();
- long deadlineNanos = startNanos + maxDurationMillis * 1_000_000L;
+ // Saturating budget, compared against ELAPSED rather than an absolute
+ // startNanos+budget deadline. A large reconnect_max_duration_millis (e.g.
+ // Long.MAX_VALUE, the natural "retry until the server boots") must not collapse
+ // the budget to zero: a raw maxDurationMillis*1_000_000L wraps NEGATIVE -- the
+ // same overflow the drainer's capabilityGapBudgetNanos and the loop dwells were
+ // hardened against -- so the pre-condition loop below would make ZERO attempts.
+ // Even TimeUnit.toNanos saturated to Long.MAX_VALUE would overflow an absolute
+ // startNanos+budget sum; comparing the bounded nanoTime difference against the
+ // budget stays correct at saturation.
+ long budgetNanos = TimeUnit.MILLISECONDS.toNanos(maxDurationMillis);
long backoffMillis = initialBackoffMillis;
int attempts = 0;
long lastLogNanos = 0L;
Throwable lastError = null;
- while (System.nanoTime() < deadlineNanos) {
+ while (System.nanoTime() - startNanos < budgetNanos) {
attempts++;
try {
WebSocketClient c = factory.reconnect();
@@ -661,7 +1069,7 @@ public static WebSocketClient connectWithRetry(
// this > 0. Mirrors BackgroundDrainer's sweep-loop jitter guard.
long jitter = ThreadLocalRandom.current().nextLong(Math.max(1L, backoffMillis));
long sleepMillis = backoffMillis + jitter;
- long remainingMillis = (deadlineNanos - System.nanoTime()) / 1_000_000L;
+ long remainingMillis = (budgetNanos - (System.nanoTime() - startNanos)) / 1_000_000L;
if (remainingMillis <= 0) {
break;
}
@@ -693,12 +1101,18 @@ public static WebSocketClient connectWithRetry(
@TestOnly
public static SenderError.Policy defaultPolicyFor(SenderError.Category category) {
switch (category) {
- case WRITE_ERROR: // transient server state (disk pressure, suspended table)
- case INTERNAL_ERROR: // transient by definition; deterministic repeats poison-escalate
- case UNKNOWN: // fail open: status byte from a newer server
+ case WRITE_ERROR: // transient server state (disk pressure, suspended table)
+ case INTERNAL_ERROR: // transient by definition; deterministic repeats poison-escalate
+ case DICTIONARY_GAP: // server state, not frame bytes: re-register and replay
+ case UNKNOWN: // fail open: status byte from a newer server
return SenderError.Policy.RETRIABLE;
case NOT_WRITABLE: // read-only replica / demoting primary: rotate endpoints
return SenderError.Policy.RETRIABLE_OTHER;
+ case DATA_LOSS:
+ // Client-originated; classify() never produces it (no wire byte
+ // maps to it). Explicit so the default arm's TERMINAL cannot
+ // silently re-adopt the category.
+ return SenderError.Policy.ABANDONED;
case SCHEMA_MISMATCH: // deterministic: same bytes, same mismatch
case PARSE_ERROR: // deterministic: malformed bytes never parse
case SECURITY_ERROR: // ACL denial on a writable node (read-only refusals arrive as role-change closes)
@@ -732,6 +1146,21 @@ public static boolean isTerminalCloseCode(int code) {
}
}
+ @TestOnly
+ public static int maxCatchUpCapGapAttempts() {
+ return MAX_CATCHUP_CAP_GAP_ATTEMPTS;
+ }
+
+ @TestOnly
+ public static int maxSentDictBytes() {
+ return MAX_SENT_DICT_BYTES;
+ }
+
+ @TestOnly
+ public static int uncappedCatchUpPackingLimit() {
+ return UNCAPPED_CATCHUP_PACKING_LIMIT;
+ }
+
/**
* Surfaces any error the I/O thread recorded. Called by the producer
* thread (typically from inside its append wrapper) so failures don't
@@ -790,6 +1219,12 @@ public synchronized void close() {
// after — the latch await is only skipped when the loop never ran.
running = false;
Thread t = ioThread;
+ // The symbol-dict mirror (sentDictBytesAddr) is I/O-thread-owned and gets
+ // freed on ioLoop's exit path. When t == null the loop never ran (start()
+ // was never called, or t.start() failed before committing ioThread), so
+ // that free never happens and a seeded (recovery / orphan-drain) mirror
+ // would leak. Capture that here; free it below, after client teardown.
+ boolean loopNeverRan = t == null;
if (t != null) {
LockSupport.unpark(t);
// Only await the shutdown latch if the I/O thread actually ran.
@@ -917,6 +1352,17 @@ public synchronized void close() {
}
client = null;
}
+ // Free the I/O-thread-owned symbol-dict mirror ONLY when the loop never
+ // ran (see loopNeverRan). If it ran, ioLoop's exit already freed it -- and
+ // on the failed-stop path (interrupted latch await, ioThread left set) the
+ // thread may still be mid-send, so touching the mirror here would race.
+ // A duplicate close observes sentDictBytesAddr == 0 and skips.
+ if (loopNeverRan && sentDictBytesAddr != 0) {
+ releaseSentDictBytes();
+ }
+ if (loopNeverRan) {
+ freeCatchUpFrameBuffer();
+ }
}
/**
@@ -1107,6 +1553,25 @@ public synchronized void start() {
// straight to the active and orphan everything in sealed.
try {
positionCursorForStart();
+ } catch (CatchUpSendException e) {
+ // A recovered sender re-registers its dictionary with a catch-up on
+ // the very first connect. Here that runs on the CALLER thread (sync
+ // start), so we must NOT let it drive connectLoop -- that would block
+ // Sender construction forever on a transient outage. Drop the dead
+ // client instead: the I/O thread then reconnects via
+ // attemptInitialConnect -> swapClient and re-sends the catch-up off
+ // this thread. If the failure was already terminal (recordFatal set
+ // running=false, e.g. an entry too large for the batch cap), the I/O
+ // thread simply winds down and checkError() surfaces it.
+ WebSocketClient dead = client;
+ client = null;
+ if (dead != null) {
+ try {
+ dead.close();
+ } catch (Throwable ignored) {
+ // best-effort
+ }
+ }
} catch (Throwable t) {
running = false;
releaseSendingSegment();
@@ -1177,10 +1642,12 @@ private void applyDurableAck() {
/**
* Drives the very first connect attempt on the I/O thread, used in the
* async-initial-connect mode (constructed with {@code client == null}).
- * Reuses the same retry+backoff machinery as {@link #fail(Throwable)} —
- * connect failures are retried indefinitely (Invariant B), and a
- * terminal upgrade reject is delivered through the dispatcher, not
- * thrown to the producer.
+ * Reuses the same retry+backoff machinery as {@link #fail(Throwable)}.
+ * Transport failures retry indefinitely here (Invariant B). But this path
+ * runs with {@code !hasEverConnected}, so an auth, upgrade or capability
+ * rejection is terminal (see {@link #endpointPolicyFailureIsTerminal()}):
+ * it is dispatched to the async {@code SenderErrorHandler} and latched for
+ * a {@code close()} rethrow, rather than retried.
*/
private void attemptInitialConnect() {
connectLoop(new LineSenderException(
@@ -1202,6 +1669,15 @@ private void clearDurableAckTracking() {
lastFrameOrPingNanos = 0L;
}
+ private static boolean isCatchUpCapGap(Throwable t) {
+ return t instanceof CatchUpSendException && ((CatchUpSendException) t).isCapGap;
+ }
+
+ private void resetCatchUpCapGapEpisode() {
+ catchUpCapGapAttempts = 0;
+ catchUpCapGapFirstNanos = -1L;
+ }
+
/**
* Shared per-outage retry loop. Used by {@link #fail(Throwable)} for
* mid-flight wire failures (phase="reconnect") and by
@@ -1219,21 +1695,32 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
recordFatal(initial);
return;
}
+ // A wire/role state that interrupted an orphan cap-gap run is not evidence that
+ // the cluster's batch cap stayed incompatible during the outage. Start a fresh
+ // episode; the cap-gap exception itself is the one reconnect cause that preserves
+ // the existing attempt count and dwell anchor.
+ if (!isCatchUpCapGap(initial)) {
+ resetCatchUpCapGapEpisode();
+ }
snapshotReplayTarget();
LOG.warn("cursor I/O loop entering {} loop: {}",
phase, initial.getMessage());
long outageStartNanos = System.nanoTime();
- // INVARIANT B: a store-and-forward drainer must NEVER terminate on a
+ // INVARIANT B: a store-and-forward loop must NEVER terminate on a
// wall-clock reconnect budget. A replica-only / all-endpoints-replica
// window is TRANSIENT -- a replica gets promoted, a primary reappears --
// so this background loop retries for as long as it is running, backing
- // off between attempts. The ONLY terminal conditions are a genuinely
- // non-retriable upgrade (auth / non-421 upgrade / durable-ack capability
- // gap), which return directly below, or the sender being stopped. SF
+ // off between attempts. Endpoint-policy failures (auth / non-421
+ // upgrade / durable-ack capability gap) are terminal only for orphan
+ // drainers. Foreground senders retry them from asynchronous startup onward
+ // so a credential or cluster capability rotation cannot stop the producer. SF
// exhaustion is surfaced to the PRODUCER as append backpressure, never
- // here. reconnect_max_duration_millis is intentionally NOT consulted: it
- // bounds only the blocking (non-lazy) initial connect in
- // QwpWebSocketSender.buildAndConnect, never this background loop.
+ // here. reconnect_max_duration_millis is intentionally NOT consulted by
+ // THIS loop. Its holders pass it explicitly where it does apply: the
+ // blocking (non-lazy) initial connect hands it to connectWithRetry (see
+ // QwpWebSocketSender.ensureConnected, the SYNC branch), and
+ // BackgroundDrainer converts it into the durable-ack capability-gap
+ // budget. Neither bounds this loop's steady-state reconnect.
long backoffMillis = reconnectInitialBackoffMillis;
if (paceFirstAttemptMillis > 0 && running) {
// NACK-initiated recycle against a reachable server: pace the
@@ -1253,7 +1740,7 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
}
int attempts = 0;
long lastLogNanos = 0L;
- Throwable lastReconnectError = initial;
+ lastReconnectError = initial;
while (running) {
attempts++;
totalReconnectAttempts.incrementAndGet();
@@ -1289,66 +1776,86 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
phase, elapsedMs, attempts, fsnAtZero);
return;
}
+ // A null factory result is an unsuccessful connect state, not a cap-gap
+ // observation. Do not let the time spent retrying it satisfy orphan dwell.
+ resetCatchUpCapGapEpisode();
} catch (QwpAuthFailedException | WebSocketUpgradeException e) {
- // Terminal across all configured endpoints per spec sf-client.md
- // section 13.3: auth (401/403) bypasses reconnect and surfaces as
- // SECURITY_ERROR. WebSocketUpgradeException reaching here is always
- // non-421: QwpUpgradeFailures.classify upstream converts a
- // 421-with-X-QuestDB-Role to QwpIngressRoleRejectedException, and a
- // 421 without that header walks the transport-error path in
- // buildAndConnect and lands as a LineSenderException, falling into
- // the Throwable 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());
- SenderError err = new SenderError(
- SenderError.Category.SECURITY_ERROR,
- SenderError.Policy.TERMINAL,
- SenderError.NO_STATUS_BYTE,
- "ws-upgrade-failed: " + e.getMessage(),
- SenderError.NO_MESSAGE_SEQUENCE,
- fromFsn,
- toFsn,
- null,
- System.nanoTime()
- );
- totalServerErrors.incrementAndGet();
- recordFatal(new LineSenderServerException(err));
- dispatchError(err);
- return;
+ if (endpointPolicyFailureIsTerminal()) {
+ // Orphans return control to their quarantine owner.
+ // WebSocketUpgradeException reaching here is always non-421:
+ // 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());
+ SenderError err = new SenderError(
+ SenderError.Category.SECURITY_ERROR,
+ SenderError.Policy.TERMINAL,
+ SenderError.NO_STATUS_BYTE,
+ "ws-upgrade-failed: " + e.getMessage(),
+ SenderError.NO_MESSAGE_SEQUENCE,
+ fromFsn,
+ toFsn,
+ null,
+ System.nanoTime()
+ );
+ totalServerErrors.incrementAndGet();
+ recordFatal(new LineSenderServerException(err));
+ dispatchError(err);
+ return;
+ }
+ resetCatchUpCapGapEpisode();
+ lastReconnectError = e;
+ dispatchRetriedEndpointPolicyFailure(
+ SenderError.Category.SECURITY_ERROR, "ws-upgrade-failed: " + e.getMessage());
+ long now = System.nanoTime();
+ if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) {
+ LOG.warn("{} attempt {}: foreground auth/upgrade policy rejected the connection; "
+ + "retrying while store-and-forward owns the buffered data -- {}",
+ phase, attempts, e.getMessage());
+ lastLogNanos = now;
+ }
} catch (QwpDurableAckMismatchException e) {
- // Per spec sf-client.md section 8.1: the client opted into durable
- // ack but the cluster cannot honour it. Loud fail at connect rather
- // than silently waiting for ack frames that will never arrive.
- // Classified as PROTOCOL_VIOLATION (config/capability mismatch),
- // not SECURITY_ERROR -- this is not an auth failure.
- LOG.error("durable-ack mismatch during {} -- won't retry: {}",
- phase, e.getMessage());
- if (terminalError == null) {
- // Mirror recordFatal's first-writer-wins latch: only the
- // sweep that owns the terminal may mark the gap, and the
- // marker must be visible before the terminalError volatile
- // write that checkError() keys on.
- capabilityGapTerminal = e;
+ if (endpointPolicyFailureIsTerminal()) {
+ // Orphans hand a capability gap back to BackgroundDrainer's
+ // settle budget.
+ LOG.error("durable-ack mismatch during {} -- won't retry: {}",
+ phase, e.getMessage());
+ if (terminalError == null) {
+ // Publish the marker before terminalError, which is the
+ // volatile first-writer-wins latch observed by the owner.
+ capabilityGapTerminal = e;
+ }
+ long fromFsn = engine.ackedFsn() + 1L;
+ long toFsn = Math.max(fromFsn, engine.publishedFsn());
+ SenderError err = new SenderError(
+ SenderError.Category.PROTOCOL_VIOLATION,
+ SenderError.Policy.TERMINAL,
+ SenderError.NO_STATUS_BYTE,
+ "durable-ack-mismatch: " + e.getMessage(),
+ SenderError.NO_MESSAGE_SEQUENCE,
+ fromFsn,
+ toFsn,
+ null,
+ System.nanoTime()
+ );
+ totalServerErrors.incrementAndGet();
+ recordFatal(new LineSenderServerException(err));
+ dispatchError(err);
+ return;
}
- long fromFsn = engine.ackedFsn() + 1L;
- long toFsn = Math.max(fromFsn, engine.publishedFsn());
- SenderError err = new SenderError(
+ resetCatchUpCapGapEpisode();
+ lastReconnectError = e;
+ dispatchRetriedEndpointPolicyFailure(
SenderError.Category.PROTOCOL_VIOLATION,
- SenderError.Policy.TERMINAL,
- SenderError.NO_STATUS_BYTE,
- "durable-ack-mismatch: " + e.getMessage(),
- SenderError.NO_MESSAGE_SEQUENCE,
- fromFsn,
- toFsn,
- null,
- System.nanoTime()
- );
- totalServerErrors.incrementAndGet();
- recordFatal(new LineSenderServerException(err));
- dispatchError(err);
- return;
+ "durable-ack-mismatch: " + e.getMessage());
+ long now = System.nanoTime();
+ if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) {
+ LOG.warn("{} attempt {}: foreground durable-ack capability is temporarily "
+ + "unavailable; retrying while store-and-forward owns the buffered data -- {}",
+ phase, attempts, e.getMessage());
+ lastLogNanos = now;
+ }
} catch (QwpRoleMismatchException | QwpIngressRoleRejectedException e) {
// Role mismatch: every reachable endpoint role-rejected the
// upgrade -- right now they are all replicas / primary-catchup.
@@ -1365,6 +1872,7 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
// endpoint, forever. Growing to reconnectMaxBackoffMillis
// mirrors the orphan drainer's role-reject path and honours the
// documented capped-exponential-backoff contract.
+ resetCatchUpCapGapEpisode();
lastReconnectError = e;
long now = System.nanoTime();
if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) {
@@ -1388,6 +1896,9 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
recordFatal(e);
throw (Error) e;
}
+ if (!isCatchUpCapGap(e)) {
+ resetCatchUpCapGapEpisode();
+ }
lastReconnectError = e;
long now = System.nanoTime();
if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) {
@@ -1433,6 +1944,70 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
phase, elapsedMs, attempts, lastMsg);
}
+ /**
+ * Reports an endpoint-policy rejection a FOREGROUND sender is riding out.
+ *
+ * Retrying is what the store-and-forward contract demands, but the retry must not be
+ * programmatically invisible. Without this dispatch a revoked token produces only a
+ * throttled slf4j WARN -- and this library ships embedded, frequently with no binding
+ * configured -- while flush() keeps returning success until SF fills, at which point
+ * the failure surfaces as "cursor ring backpressured ... sf_max_total_bytes too
+ * small" and points the operator at disk sizing instead of at their credentials.
+ *
+ * RETRIABLE, not TERMINAL: the handler learns the wire is being rejected while the
+ * producer stays alive and no data is at risk.
+ */
+ private void dispatchRetriedEndpointPolicyFailure(SenderError.Category category, String message) {
+ long fromFsn = engine.ackedFsn() + 1L;
+ dispatchError(new SenderError(
+ category,
+ SenderError.Policy.RETRIABLE,
+ SenderError.NO_STATUS_BYTE,
+ message,
+ SenderError.NO_MESSAGE_SEQUENCE,
+ fromFsn,
+ Math.max(fromFsn, engine.publishedFsn()),
+ null,
+ System.nanoTime()
+ ));
+ }
+
+ /**
+ * Cause of the outage the reconnect loop is riding out, or {@code null} when the wire
+ * is up. Lets a producer-side failure name the real reason rather than blaming disk
+ * sizing -- see the field.
+ */
+ public Throwable lastReconnectError() {
+ return lastReconnectError;
+ }
+
+ /**
+ * Whether an endpoint-policy failure (auth, non-421 upgrade, durable-ack
+ * capability gap) latches a terminal instead of retrying under Invariant B.
+ *
+ * Two callers own a terminal, for different reasons:
+ *
@@ -1689,11 +2264,10 @@ private void ioLoop() {
// Async-initial-connect path: ctor accepted a null client because
// a reconnect factory is wired. Drive the very first connect on
// this thread so the producer thread never blocks on it.
- // attemptInitialConnect either sets `client` (success) or records
- // a terminal failure and clears `running` (auth/upgrade reject;
- // plain connect failures retry indefinitely under Invariant B).
- // Either way, the main loop below sees the outcome via the
- // `running` and `client` fields.
+ // attemptInitialConnect retries every endpoint/transport failure
+ // indefinitely under Invariant B and returns only after it installs
+ // a client or close() clears `running`. The main loop below sees the
+ // outcome via the `running` and `client` fields.
if (client == null && running) {
attemptInitialConnect();
}
@@ -1774,6 +2348,12 @@ private void ioLoop() {
// best-effort
}
}
+ // The symbol-dict mirror is I/O-thread-owned; free it here, on the
+ // owning thread's exit path, after the last send that could touch it.
+ if (sentDictBytesAddr != 0) {
+ releaseSentDictBytes();
+ }
+ freeCatchUpFrameBuffer();
shutdownLatch.countDown();
Runnable closeCallback = delegatedClose;
if (closeCallback != null) {
@@ -1795,8 +2375,11 @@ private void ioLoop() {
/**
* Walk the engine's segments to find the one containing {@code targetFsn},
* and set {@code sendOffset} to the byte offset of that frame within it.
- * This is called at startup and after every reconnect, after fsnAtZero has
- * already been reset to {@code targetFsn} and nextWireSeq to 0.
+ * This is called at startup and after every reconnect, once
+ * {@link #setWireBaselineWithCatchUp} has anchored the wire baseline
+ * ({@code fsnAtZero} / {@code nextWireSeq}) -- which may leave {@code nextWireSeq}
+ * past the catch-up frames it emitted. This method only positions the byte
+ * cursor at {@code targetFsn}; it does not touch the wire mapping.
*
* If {@code targetFsn} is already published, the method positions the byte
* cursor exactly at that frame. If {@code targetFsn} is not published yet,
@@ -1961,10 +2544,7 @@ private void snapshotReplayTarget() {
private void swapClient(WebSocketClient newClient) {
WebSocketClient old = this.client;
this.client = newClient;
- // Sticky: once the wire is up, we've reached the server at least
- // once for this sender's lifetime. Used downstream to classify a
- // subsequent terminal failure as transient vs config-likely.
- this.hasEverConnected = true;
+ this.lastReconnectError = null;
if (old != null) {
try {
old.close();
@@ -1992,7 +2572,706 @@ private void swapClient(WebSocketClient newClient) {
// carrying stale state across the wire boundary would either
// double-trim or starve the queue.
clearDurableAckTracking();
+ setWireBaselineWithCatchUp(replayStart);
positionCursorAt(replayStart);
+ // Sticky: once the wire is up, we've reached the server at least once
+ // for this sender's lifetime. Exposed to the owning sender for
+ // connection-state observability, and it ends initialization: from here
+ // on endpointPolicyFailureIsTerminal() stops latching endpoint-policy
+ // failures on a foreground sender and rides them out instead, because
+ // store-and-forward now owns the buffered data (Invariant B).
+ // Deliberately LAST: a first connection that completes the upgrade but
+ // dies inside the dictionary catch-up above was never fully
+ // established, so the sender stays INITIALIZING and a subsequent
+ // endpoint-policy rejection still latches the startup terminal.
+ this.hasEverConnected = true;
+ }
+
+ /**
+ * Sets the wire-sequence baseline for a fresh connection and, when the symbol
+ * dictionary mirror is non-empty, emits a full-dictionary catch-up frame first
+ * so the fresh server (whose dictionary starts empty) can resolve the
+ * non-self-sufficient delta frames that replay next.
+ *
+ * The catch-up occupies wire seq 0, which maps to the already-acked FSN just
+ * below {@code replayStart} (a harmless re-ack); real replay frames then follow
+ * from wire seq 1. With nothing to catch up (fresh sender, or full-dict mode),
+ * or before a client exists (async initial connect), keep the plain 1:1
+ * {@code fsnAtZero == replayStart} mapping; the catch-up then happens on the
+ * first real connection via swapClient.
+ */
+ private void setWireBaselineWithCatchUp(long replayStart) {
+ // Fresh connection: no data frame has been sent on it yet. Reset before the
+ // catch-up (which sends only dictionary frames) so onClose /
+ // handleServerRejection can tell "only the catch-up went out" from "the
+ // head data frame went out".
+ dataFrameSentThisConnection = false;
+ // Do not gate solely on isDeltaDictEnabled(): a recovered delta slot can
+ // lose access to its side-file while its on-disk frames still start above
+ // zero. hasReplayDictionaryDependency preserves that distinction while
+ // keeping full-dictionary fallback catch-up-free across every reconnect.
+ if (client != null && sentDictCount > 0 && hasReplayDictionaryDependency) {
+ this.nextWireSeq = 0L;
+ // The catch-up may span several frames when the dictionary exceeds the
+ // server's batch cap; each consumes a wire sequence (0 .. n-1) that maps
+ // to an already-acked FSN, so the first real frame still lands on
+ // replayStart.
+ int catchUpFrames = sendDictCatchUp();
+ this.fsnAtZero = replayStart - catchUpFrames;
+ } else {
+ this.fsnAtZero = replayStart;
+ this.nextWireSeq = 0L;
+ }
+ }
+
+ /**
+ * Returns the symbol-dictionary delta start id of a frame, or -1 when the
+ * frame carries no delta section. Used by the pre-send torn-dictionary guard.
+ */
+ private int frameDeltaStart(long payloadAddr, int payloadLen) {
+ if (!isDeltaFrame(payloadAddr, payloadLen)) {
+ return -1;
+ }
+ long encoded = readVarintAt(payloadAddr + QwpConstants.HEADER_SIZE, payloadAddr + payloadLen);
+ // A malformed start id reads as "no delta section", which the caller's
+ // `deltaStart >= 0` gate already handles.
+ return encoded < 0L ? -1 : (int) (encoded >>> 3);
+ }
+
+ // True only for a well-formed QWP frame this encoder produced that carries a
+ // delta symbol-dict section. The magic check keeps the dict logic from
+ // misreading non-QWP payloads (e.g. synthetic frames injected by tests) whose
+ // bytes happen to set the delta flag.
+ private static boolean isDeltaFrame(long payloadAddr, int payloadLen) {
+ if (payloadLen < QwpConstants.HEADER_SIZE
+ || Unsafe.getUnsafe().getInt(payloadAddr) != QwpConstants.MAGIC_MESSAGE) {
+ return false;
+ }
+ byte flags = Unsafe.getUnsafe().getByte(payloadAddr + QwpConstants.HEADER_OFFSET_FLAGS);
+ return (flags & QwpConstants.FLAG_DELTA_SYMBOL_DICT) != 0;
+ }
+
+ /**
+ * Copies the symbol-dictionary delta a just-sent frame carries into the
+ * loop-local mirror ({@link #sentDictBytesAddr}) so a future reconnect can
+ * re-register it. Frames are sent in FSN order carrying monotonically
+ * extending deltas, so a frame whose delta starts exactly at
+ * {@link #sentDictCount} extends the mirror; a replayed or empty-delta frame
+ * (nothing new) is skipped. Only ever called in delta mode, for a frame the
+ * pre-send guard already classified as a delta frame.
+ *
+ * @param payloadAddr address of the QWP message (12-byte header first)
+ * @param payloadLen message length in bytes
+ * @param deltaStart the frame's delta start id, already decoded by the
+ * pre-send guard ({@link #frameDeltaStart}) -- passed in so
+ * the magic/flags and start-id varint are not re-parsed
+ */
+ private void accumulateSentDict(long payloadAddr, int payloadLen, int deltaStart) {
+ long limit = payloadAddr + payloadLen;
+ // deltaStart is known (the guard decoded it); locate deltaCount just past
+ // its canonical LEB128 encoding rather than re-reading the header and the
+ // start-id varint.
+ long p = payloadAddr + QwpConstants.HEADER_SIZE + NativeBufferWriter.varintSize(deltaStart);
+ long encodedCount = readVarintAt(p, limit);
+ if (encodedCount < 0L) {
+ return; // malformed -- never happens for frames we encoded
+ }
+ long deltaCount = encodedCount >>> 3;
+ p += encodedCount & 7L;
+ // The mirror holds ids [0, sentDictCount). Accumulate ONLY the part of this
+ // frame's delta [deltaStart, deltaStart+deltaCount) that extends past the
+ // tip -- ids [sentDictCount, deltaStart+deltaCount). Cases:
+ // - deltaStart > sentDictCount: a gap. trySendOne's torn-dictionary guard
+ // rejects it before send, so it never reaches here; bail defensively
+ // rather than accumulate past a hole.
+ // - deltaEnd <= sentDictCount: a pure replay/overlap we already hold --
+ // nothing new.
+ // - deltaStart <= sentDictCount < deltaEnd: extend the mirror by the tail.
+ // deltaStart == sentDictCount is the steady-state case (skip == 0).
+ // Handling the partial overlap explicitly -- rather than dropping the whole
+ // frame whenever deltaStart != sentDictCount -- keeps the mirror complete
+ // even if a future producer ever emits a delta that overlaps the tip;
+ // silently dropping the new tail would leave the reconnect catch-up
+ // incomplete and shift server-side ids.
+ long deltaEnd = deltaStart + deltaCount;
+ if (deltaCount <= 0 || deltaStart > sentDictCount || deltaEnd <= sentDictCount) {
+ return;
+ }
+ // Keeps no per-entry offset index: the mirror stores each entry as
+ // [len varint][utf8], so the reconnect catch-up (sendDictCatchUp) walks it with
+ // a running pointer on demand. Maintaining an index here would put a store per
+ // new symbol on the per-frame I/O path -- and on the workload this feature
+ // targets (a new symbol per ROW) that is a store per row -- to serve a reconnect
+ // that may never happen; the catch-up's single walk is dwarfed by shipping the
+ // same n bytes over the wire.
+ //
+ // Walk past the already-held prefix [deltaStart, sentDictCount), then copy
+ // the new tail [sentDictCount, deltaEnd).
+ int skip = sentDictCount - deltaStart;
+ for (int i = 0; i < skip; i++) {
+ long encoded = readVarintAt(p, limit);
+ if (encoded < 0L) {
+ return; // malformed -- bail rather than corrupt the mirror
+ }
+ p += (encoded & 7L) + (encoded >>> 3);
+ if (p > limit) {
+ return;
+ }
+ }
+ long regionStart = p;
+ long newCount = deltaEnd - sentDictCount;
+ // Walk the new tail only to find where it ends; the entry boundaries are not
+ // recorded here (see above -- the catch-up re-walks the mirror when it needs them).
+ for (long i = 0; i < newCount; i++) {
+ long encoded = readVarintAt(p, limit);
+ if (encoded < 0L) {
+ // Malformed -- never happens for frames we encoded; bail rather
+ // than corrupt the mirror.
+ return;
+ }
+ p += (encoded & 7L) + (encoded >>> 3);
+ if (p > limit) {
+ return;
+ }
+ }
+ int regionBytes = (int) (p - regionStart);
+ // long sum: sentDictBytesLen + regionBytes can exceed Integer.MAX_VALUE on
+ // a very-high-cardinality lifetime connection; ensureSentDictCapacity then
+ // fails loudly rather than overflowing to a negative int (which would make
+ // the capacity check pass and copyMemory scribble past the buffer).
+ ensureSentDictCapacity((long) sentDictBytesLen + regionBytes);
+ Unsafe.getUnsafe().copyMemory(regionStart, sentDictBytesAddr + sentDictBytesLen, regionBytes);
+ sentDictBytesLen += regionBytes;
+ sentDictCount += (int) newCount;
+ }
+
+ private void ensureSentDictCapacity(long required) {
+ if (sentDictBytesCapacity >= required) {
+ return;
+ }
+ if (required > MAX_SENT_DICT_BYTES) {
+ // Latch a terminal, do NOT just throw: accumulateSentDict runs AFTER
+ // the frame's sendBinary, so a bare throw unwinds to ioLoop -> fail()
+ // -> connectLoop, which (running still true) reconnects and replays the
+ // same frame, which re-overflows the never-shrinking mirror -- an
+ // unbounded reconnect livelock rather than the "fails loudly" the
+ // MAX_SENT_DICT_BYTES ceiling promises. recordFatal flips running=false
+ // so connectLoop's !running guard winds the loop down and checkError()
+ // surfaces the terminal, matching sendCatchUpChunk's guard for the same
+ // ceiling. The throw still unwinds past the pending copyMemory.
+ LineSenderException err = new LineSenderException("symbol dictionary mirror exceeds the maximum size ["
+ + "required=" + required + ", max=" + MAX_SENT_DICT_BYTES + ']');
+ recordFatal(err);
+ throw err;
+ }
+ // Grow in long to avoid the capacity*2 int overflow (negative) that would
+ // otherwise degrade the doubling near 1 GB; clamp to the int ceiling.
+ long newCap = Math.max((long) sentDictBytesCapacity * 2, Math.max(4096L, required));
+ if (newCap > MAX_SENT_DICT_BYTES) {
+ newCap = MAX_SENT_DICT_BYTES;
+ }
+ try {
+ if (sentDictBytesOwned) {
+ sentDictBytesAddr = Unsafe.realloc(
+ sentDictBytesAddr,
+ sentDictBytesCapacity,
+ (int) newCap,
+ MemoryTag.NATIVE_DEFAULT);
+ } else {
+ long newAddr = Unsafe.malloc((int) newCap, MemoryTag.NATIVE_DEFAULT);
+ if (sentDictBytesLen > 0) {
+ Unsafe.getUnsafe().copyMemory(sentDictBytesAddr, newAddr, sentDictBytesLen);
+ }
+ sentDictBytesAddr = newAddr;
+ sentDictBytesOwned = true;
+ }
+ } catch (Throwable t) {
+ // Latch for exactly the reason the MAX_SENT_DICT_BYTES arm above does, and it
+ // was asymmetric until now: accumulateSentDict runs AFTER the frame's
+ // sendBinary, so a bare throw unwinds to ioLoop -> fail() -> connectLoop,
+ // which (running still true) reconnects and replays the same frame, which
+ // re-attempts the same allocation. A genuine out-of-memory therefore became an
+ // unbounded reconnect livelock instead of the loud failure the ceiling arm
+ // promises. recordFatal flips running=false so connectLoop winds down and
+ // checkError() surfaces it; the throw still unwinds past the pending copy.
+ recordFatal(t);
+ throw t;
+ }
+ sentDictBytesCapacity = (int) newCap;
+ }
+
+ private void releaseSentDictBytes() {
+ if (sentDictBytesAddr != 0 && sentDictBytesOwned) {
+ Unsafe.free(sentDictBytesAddr, sentDictBytesCapacity, MemoryTag.NATIVE_DEFAULT);
+ }
+ sentDictBytesAddr = 0;
+ sentDictBytesCapacity = 0;
+ sentDictBytesLen = 0;
+ sentDictBytesOwned = false;
+ sentDictCount = 0;
+ }
+
+ /**
+ * Decodes the varint at {@code [p, limit)} and returns {@code (value << 3) | bytes},
+ * or {@code -1} when it is truncated or runs past a canonical length.
+ *
+ * Packing the consumed length into the return value -- the shape
+ * {@code RecoveredFrameAnalysis.readVarint} already uses -- drops a store to an
+ * instance field on every call, and this runs once per SYMBOL: per frame in
+ * accumulateSentDict, and once per mirror entry in sendDictCatchUp.
+ *
+ * The {@code -1} also makes truncation DETECTABLE. The field version returned 0 with
+ * {@code varintEnd == p} when {@code p >= limit}, so a caller computing
+ * {@code p = varintEnd + len} got {@code p == limit} and its {@code p > limit}
+ * bail-out could not fire at an exact boundary -- guards that could not guard.
+ */
+ private static long readVarintAt(long p, long limit) {
+ long value = 0;
+ int shift = 0;
+ int bytes = 0;
+ while (p < limit && bytes < 6) {
+ byte b = Unsafe.getUnsafe().getByte(p++);
+ value |= (long) (b & 0x7F) << shift;
+ bytes++;
+ if ((b & 0x80) == 0) {
+ return (value << 3) | bytes;
+ }
+ shift += 7;
+ }
+ return -1L;
+ }
+
+ /**
+ * Re-registers the whole symbol dictionary on a fresh connection, split into
+ * as many table-less frames as the server's advertised batch cap requires so
+ * no single frame exceeds it (a large dictionary would otherwise be rejected).
+ * Each chunk carries a contiguous id range {@code [start .. start+count)}, in
+ * order, so the server accumulates them exactly as it would the original
+ * per-frame deltas. Returns the number of frames sent (each consumed a wire
+ * sequence), so the caller can align {@code fsnAtZero}. Throws {@link
+ * CatchUpSendException} on a send error (retriable -- the caller reconnects);
+ * a single entry too large for the cap is non-retriable, so it latches a
+ * terminal before throwing.
+ */
+ private int sendDictCatchUp() {
+ int cap = client.getServerMaxBatchSize();
+ // Two limits, deliberately different when the server advertises no cap.
+ //
+ // packingLimit bounds a multi-entry chunk. With no advertised cap the receive
+ // buffer is unknown, so this stays conservative (UNCAPPED_CATCHUP_PACKING_LIMIT,
+ // well under the 131072 default receive buffer) and a dictionary of ordinary
+ // symbols always chunks into frames a server will accept.
+ //
+ // soloFrameLimit bounds the CAP-GAP terminal below and must not be tightened the
+ // same way: an entry that already went out inside a data frame was bounded by
+ // effectiveAutoFlushBytes, not by our fallback, so tightening it would invent a
+ // new terminal for data the producer sent successfully. When a cap IS advertised
+ // the two coincide, which is what the homogeneous-cluster argument below rests on.
+ //
+ // No unsplit fast path: when the dictionary fits, the walk below emits exactly
+ // one frame anyway, so the special case only ever removed the bound.
+ int packingLimit = cap > 0 ? cap : UNCAPPED_CATCHUP_PACKING_LIMIT;
+ int soloFrameLimit = cap > 0 ? cap : MAX_SENT_DICT_BYTES;
+ // Symbol-bytes budget for PACKING several entries into one chunk, leaving
+ // room for the 12-byte header and the two delta-section varints. Kept
+ // deliberately conservative (reserving 16 for the varints): it only makes a
+ // multi-entry chunk split marginally earlier, never over the limit. It must
+ // NOT gate the single-entry terminal -- that reserve is larger than the
+ // minimal data-frame overhead, so an entry the producer already shipped
+ // under this cap could exceed the reserve yet still fit its own catch-up
+ // frame; the terminal tests the real solo frame against soloFrameLimit instead.
+ int budget = Math.max(1, packingLimit - QwpConstants.HEADER_SIZE - 16);
+ int framesSent = 0;
+ int chunkStartId = 0;
+ long chunkStartAddr = sentDictBytesAddr;
+ int chunkSymbols = 0;
+ long chunkBytes = 0;
+ // Walk the mirror once with a running pointer, deriving each entry's byte span
+ // from its own [len varint][utf8] framing -- no separate offset index is kept
+ // (see accumulateSentDict). A mirror built from CRC-validated frames is always
+ // well-formed, so entryEnd can exceed the limit only under memory corruption;
+ // latch a terminal via recordFatal rather than let a bare throw unwind into
+ // connectLoop as a transient and reconnect-livelock.
+ long entryPtr = sentDictBytesAddr;
+ long mirrorLimit = sentDictBytesAddr + sentDictBytesLen;
+ for (int entryId = 0; entryId < sentDictCount; entryId++) {
+ long entryStart = entryPtr;
+ long encodedLen = readVarintAt(entryPtr, mirrorLimit);
+ long entryEnd = encodedLen < 0L
+ ? -1L
+ : entryPtr + (encodedLen & 7L) + (encodedLen >>> 3);
+ if (entryEnd < 0L || entryEnd > mirrorLimit) {
+ LineSenderException err = new LineSenderException(
+ "invalid symbol dictionary mirror during catch-up [entry="
+ + entryId + ", count=" + sentDictCount + ']');
+ recordFatal(err);
+ throw new CatchUpSendException(err);
+ }
+ entryPtr = entryEnd;
+ long entryBytes = entryEnd - entryStart;
+ // The exact table-less frame sendCatchUpChunk would build for THIS entry
+ // alone: header + deltaStart varint (the entry's own global id) +
+ // deltaCount varint (1) + the entry bytes. A cap gap exists only when even
+ // that solo frame exceeds the cap -- i.e. the entry genuinely cannot be
+ // re-registered. Testing the real solo frame (not the conservative
+ // packing budget above) is what keeps a HOMOGENEOUS cluster
+ // livelock-free: an entry the producer already shipped in a data frame
+ // under this cap (header + delta varints + entry + schema + >=1 row) is
+ // strictly larger than its bare catch-up frame, so it always fits here.
+ long soloFrameLen = QwpConstants.HEADER_SIZE
+ + NativeBufferWriter.varintSize(chunkStartId + chunkSymbols)
+ + NativeBufferWriter.varintSize(1)
+ + entryBytes;
+ // No advertised cap: soloFrameLimit is MAX_SENT_DICT_BYTES (~2GB), so the
+ // cap-gap terminal below cannot fire, and packing only ever splits BETWEEN
+ // entries -- an entry wider than the receiving node's recv buffer therefore
+ // goes out whole, is closed with 1009, and the close carries no policy
+ // semantics, so failExemptPaced recycles and re-emits the byte-identical
+ // frame forever. That retry-forever is CORRECT (Invariant B: never invent a
+ // terminal for data the producer already shipped, and never surface a
+ // transport error to the caller), and tightening soloFrameLimit to the
+ // packing fallback would break it. What is missing is the diagnosis: the
+ // connect itself succeeds every cycle, so the reconnect logging never fires
+ // and the stall looks like a healthy reconnect loop until store-and-forward
+ // fills and surfaces as an unrelated out-of-space error. Name the entry.
+ //
+ // Only reachable against an endpoint that advertises no X-QWP-Max-Batch-Size
+ // AND has a smaller frame limit than an entry already in the mirror -- a
+ // pre-header server, a header-stripping proxy, or a third-party
+ // implementation; the mirror itself may have been seeded from .symbol-dict
+ // under an entirely different server generation, which is the case the
+ // homogeneous-cluster argument above does not cover.
+ if (cap <= 0 && soloFrameLen > packingLimit) {
+ long nowNanos = System.nanoTime();
+ if (lastUncappedOversizeWarnNanos == 0L
+ || nowNanos - lastUncappedOversizeWarnNanos >= RECONNECT_LOG_THROTTLE_NANOS) {
+ lastUncappedOversizeWarnNanos = nowNanos;
+ LOG.warn("symbol dictionary entry {} needs a {}-byte catch-up frame and the server "
+ + "advertises no batch cap, so it cannot be split or rejected; if the "
+ + "connection keeps recycling with no ack progress this entry is the "
+ + "cause -- shorten the symbol value, raise the server's recv buffer "
+ + "so it advertises a cap, or use a varchar column [fallbackLimit={}]",
+ chunkStartId + chunkSymbols, soloFrameLen, packingLimit);
+ }
+ }
+ if (soloFrameLen > soloFrameLimit) {
+ // Cap gap: this entry cannot be re-registered under the fresh
+ // server's advertised cap. A HOMOGENEOUS cluster never reaches here
+ // (an entry that fit its data frame under a cap always fits its bare
+ // catch-up frame under that same cap), so the only way in is a
+ // heterogeneous / rolling-cap failover to a smaller-cap node.
+ //
+ // A foreground sender retries indefinitely: store-and-forward must
+ // contain a transient heterogeneous-cluster window instead of surfacing
+ // it to the producer. Only an orphan drainer may apply the settle budget
+ // below and quarantine its slot after a persistent gap. Under budget the
+ // throw is RETRIABLE (no recordFatal) -- connectLoop reconnects with
+ // backoff and re-runs the catch-up, which resets the episode on a node
+ // that accepts it. An unrelated transport/upgrade/role state also resets
+ // the episode, so its downtime cannot satisfy the orphan dwell. On
+ // exhaustion latch via recordFatal, NOT fail() --
+ // failing from inside the catch-up would re-enter connectLoop (see
+ // CatchUpSendException); the data must be resent after the cap is raised.
+ // Escalation needs BOTH the strike count AND a minimum wall-clock dwell
+ // (see DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS). A count
+ // alone measures how often we looked, not how long the gap has held --
+ // and 16 attempts at the capped backoff take only ~2 minutes, less than
+ // an ordinary rolling restart of the larger-cap node. Escalating on the
+ // count alone would therefore hard-fail a live store-and-forward
+ // producer during a routine cluster operation.
+ //
+ // The STRIKE COUNT -- never the anchor's sign -- says whether an episode is
+ // already open. A System.nanoTime() instant is only meaningful as a
+ // difference: its origin is arbitrary and the spec permits negative values,
+ // so a sign test cannot tell an unset anchor from a real one. Wherever it
+ // misreads a real anchor as unset it re-anchors to now on EVERY strike,
+ // pinning episodeNanos at ~0, and the dwell can then never be satisfied --
+ // the terminal never latches, however long the gap truly persists.
+ // recordHeadRejectionStrike keys its episode off poisonStrikes for the same
+ // reason.
+ if (catchUpCapGapPolicy == CatchUpCapGapPolicy.RETRY_FOREVER) {
+ throw new CatchUpSendException(new LineSenderException(
+ "symbol dictionary entry too large for the server batch cap during catch-up ["
+ + "frameLen=" + soloFrameLen + ", cap=" + cap + ']'
+ + "; retrying indefinitely -- a larger-cap node may return"), true);
+ }
+
+ long nowNanos = System.nanoTime();
+ if (catchUpCapGapAttempts == 0) {
+ catchUpCapGapFirstNanos = nowNanos;
+ }
+ catchUpCapGapAttempts++;
+ long episodeNanos = nowNanos - catchUpCapGapFirstNanos;
+ boolean exhausted = catchUpCapGapAttempts >= MAX_CATCHUP_CAP_GAP_ATTEMPTS
+ && episodeNanos >= catchUpCapGapMinEscalationWindowNanos;
+ LineSenderException err = new LineSenderException(
+ "symbol dictionary entry too large for the server batch cap during catch-up ["
+ + "frameLen=" + soloFrameLen + ", cap=" + cap + ", attempt="
+ + catchUpCapGapAttempts + '/' + MAX_CATCHUP_CAP_GAP_ATTEMPTS
+ + ", episodeMillis=" + (episodeNanos / 1_000_000L)
+ + '/' + (catchUpCapGapMinEscalationWindowNanos / 1_000_000L) + ']'
+ + (exhausted
+ ? "; the data must be resent after the cap is raised"
+ : "; retrying -- a larger-cap node may return"));
+ if (exhausted) {
+ recordFatal(err);
+ }
+ throw new CatchUpSendException(err, true);
+ }
+ if (chunkSymbols > 0 && chunkBytes + entryBytes > budget) {
+ sendCatchUpChunk(chunkStartId, chunkSymbols, chunkStartAddr, (int) chunkBytes);
+ framesSent++;
+ chunkStartId += chunkSymbols;
+ chunkStartAddr = entryStart;
+ chunkSymbols = 0;
+ chunkBytes = 0;
+ }
+ chunkSymbols++;
+ chunkBytes += entryBytes;
+ }
+ if (chunkSymbols > 0) {
+ sendCatchUpChunk(chunkStartId, chunkSymbols, chunkStartAddr, (int) chunkBytes);
+ framesSent++;
+ }
+ // The whole dictionary re-registered without a cap gap: this node accepts
+ // every entry, so the cap-gap episode (if any) is over -- reset BOTH the strike
+ // count and the wall-clock anchor. Resetting only the count would leave a stale
+ // anchor from an old episode, so the very first strike of a LATER episode would
+ // already satisfy the dwell.
+ resetCatchUpCapGapEpisode();
+ return framesSent;
+ }
+
+ /**
+ * Sends one table-less catch-up frame carrying dictionary ids
+ * {@code [deltaStart .. deltaStart+deltaCount)}. Throws {@link
+ * CatchUpSendException} on a send error instead of calling {@link #fail}
+ * (see that type for why the catch-up must not re-enter the reconnect loop);
+ * the caller turns it into a single, non-re-entrant reconnect.
+ */
+ private void sendCatchUpChunk(int deltaStart, int deltaCount, long symbolsAddr, int symbolsLen) {
+ // Compute the frame size in long and fail loud if it would overflow the int
+ // size math into a negative Unsafe.malloc. sendDictCatchUp already caps each
+ // chunk's symbol bytes under the budget, so this is unreachable at real
+ // cardinality -- but the mirror-side ensureSentDictCapacity guards the same
+ // math, and a future caller must not be able to overflow this one silently.
+ long payloadLenL = (long) NativeBufferWriter.varintSize(deltaStart)
+ + NativeBufferWriter.varintSize(deltaCount)
+ + symbolsLen;
+ long frameLenL = QwpConstants.HEADER_SIZE + payloadLenL;
+ if (frameLenL > MAX_SENT_DICT_BYTES) {
+ LineSenderException err = new LineSenderException(
+ "symbol dictionary catch-up frame exceeds the maximum size ["
+ + "frameLen=" + frameLenL + ", max=" + MAX_SENT_DICT_BYTES + ']');
+ recordFatal(err);
+ throw new CatchUpSendException(err);
+ }
+ int payloadLen = (int) payloadLenL;
+ int prefixLen = QwpConstants.HEADER_SIZE
+ + NativeBufferWriter.varintSize(deltaStart)
+ + NativeBufferWriter.varintSize(deltaCount);
+ ensureCatchUpFrameCapacity(prefixLen);
+ long frame = catchUpFrameAddr;
+ try {
+ Unsafe.getUnsafe().putByte(frame, (byte) 'Q');
+ Unsafe.getUnsafe().putByte(frame + 1, (byte) 'W');
+ Unsafe.getUnsafe().putByte(frame + 2, (byte) 'P');
+ Unsafe.getUnsafe().putByte(frame + 3, (byte) '1');
+ Unsafe.getUnsafe().putByte(frame + 4, (byte) client.getServerQwpVersion());
+ // FLAG_DEFER_COMMIT: the catch-up carries dictionary entries but NO
+ // rows, so it must never trigger a server-side commit. Today it is
+ // always the first frame on a fresh (empty-transaction) connection, so
+ // committing nothing is a no-op -- but that invariant is load-bearing
+ // and unasserted. Deferring the (empty) commit removes the dependency:
+ // a future mid-stream catch-up cannot prematurely commit an in-flight
+ // deferred transaction. The dictionary delta still registers (as any
+ // deferred data frame's does); only the row commit is deferred, and the
+ // next real frame commits it.
+ Unsafe.getUnsafe().putByte(frame + QwpConstants.HEADER_OFFSET_FLAGS,
+ (byte) (QwpConstants.FLAG_GORILLA | QwpConstants.FLAG_DELTA_SYMBOL_DICT
+ | QwpConstants.FLAG_DEFER_COMMIT));
+ Unsafe.getUnsafe().putShort(frame + 6, (short) 0); // tableCount
+ Unsafe.getUnsafe().putInt(frame + 8, payloadLen);
+ long q = NativeBufferWriter.writeVarint(frame + QwpConstants.HEADER_SIZE, deltaStart);
+ NativeBufferWriter.writeVarint(q, deltaCount);
+ client.sendBinary(frame, prefixLen, symbolsAddr, symbolsLen);
+ } catch (Throwable t) {
+ // Do NOT fail() here -- see CatchUpSendException. Signal the failure
+ // up so exactly one non-re-entrant reconnect follows. A JVM Error is
+ // never a transient reconnect case; let it propagate as-is so the
+ // I/O loop latches it terminal rather than looping on it.
+ if (t instanceof Error) {
+ throw (Error) t;
+ }
+ throw new CatchUpSendException(t);
+ }
+ nextWireSeq++; // this catch-up chunk consumed a wire sequence
+ lastFrameOrPingNanos = System.nanoTime();
+ totalFramesSent.incrementAndGet();
+ }
+
+ @TestOnly
+ public int catchUpFrameGrowthCount() {
+ return catchUpFrameGrowthCount;
+ }
+
+ @TestOnly
+ public int catchUpCapGapAttempts() {
+ return catchUpCapGapAttempts;
+ }
+
+ @TestOnly
+ public long catchUpCapGapFirstNanos() {
+ return catchUpCapGapFirstNanos;
+ }
+
+ @TestOnly
+ public long catchUpCapGapMinEscalationWindowNanos() {
+ return catchUpCapGapMinEscalationWindowNanos;
+ }
+
+ @TestOnly
+ public long fsnAtZero() {
+ return fsnAtZero;
+ }
+
+ @TestOnly
+ public long poisonFsn() {
+ return poisonFsn;
+ }
+
+ @TestOnly
+ public int poisonStrikes() {
+ return poisonStrikes;
+ }
+
+ @TestOnly
+ public long sentDictBytesAddr() {
+ return sentDictBytesAddr;
+ }
+
+ @TestOnly
+ public int sentDictBytesLen() {
+ return sentDictBytesLen;
+ }
+
+ @TestOnly
+ public boolean sentDictBytesOwned() {
+ return sentDictBytesOwned;
+ }
+
+ @TestOnly
+ public int sentDictCount() {
+ return sentDictCount;
+ }
+
+ @TestOnly
+ public int zeroProgressRecycles() {
+ return zeroProgressRecycles;
+ }
+
+ @TestOnly
+ public void accumulateSentDictForTest(long payloadAddr, int payloadLen, int deltaStart) {
+ accumulateSentDict(payloadAddr, payloadLen, deltaStart);
+ }
+
+ @TestOnly
+ public void connectLoopForTest(Throwable initial, String phase, long paceFirstAttemptMillis) {
+ connectLoop(initial, phase, paceFirstAttemptMillis);
+ }
+
+ @TestOnly
+ public void deliverCloseForTest(int code, String reason) {
+ responseHandler.onClose(code, reason);
+ }
+
+ @TestOnly
+ public void deliverResponseForTest(long payloadPtr, int payloadLen) {
+ responseHandler.onBinaryMessage(payloadPtr, payloadLen);
+ }
+
+ @TestOnly
+ public void ensureSentDictCapacityForTest(long required) {
+ ensureSentDictCapacity(required);
+ }
+
+ @TestOnly
+ public void positionCursorForStartForTest() {
+ positionCursorForStart();
+ }
+
+ @TestOnly
+ public void seedSentDictMirrorForTest(long addr, int bytes, int symbolCount) {
+ sentDictBytesAddr = addr;
+ sentDictBytesCapacity = bytes;
+ sentDictBytesLen = bytes;
+ sentDictCount = symbolCount;
+ sentDictBytesOwned = true;
+ }
+
+ @TestOnly
+ public void sendCatchUpChunkForTest(int deltaStart, int deltaCount, long symbolsAddr, int symbolsLen) {
+ sendCatchUpChunk(deltaStart, deltaCount, symbolsAddr, symbolsLen);
+ }
+
+ @TestOnly
+ public void setCatchUpCapGapFirstNanosForTest(long nanos) {
+ catchUpCapGapFirstNanos = nanos;
+ }
+
+ @TestOnly
+ public void setDataFrameSentThisConnectionForTest(boolean value) {
+ dataFrameSentThisConnection = value;
+ }
+
+ @TestOnly
+ public void setFsnAtZeroForTest(long value) {
+ fsnAtZero = value;
+ }
+
+ @TestOnly
+ public void setNextWireSeqForTest(long value) {
+ nextWireSeq = value;
+ }
+
+ @TestOnly
+ public void setRunningForTest(boolean value) {
+ running = value;
+ }
+
+ @TestOnly
+ public void setWireBaselineWithCatchUpForTest(long replayStart) {
+ setWireBaselineWithCatchUp(replayStart);
+ }
+
+ @TestOnly
+ public boolean trySendOneForTest() {
+ return trySendOne();
+ }
+
+ private void ensureCatchUpFrameCapacity(int required) {
+ if (catchUpFrameCapacity >= required) {
+ return;
+ }
+ long newCapacity = Math.max(required, Math.max(4096L, (long) catchUpFrameCapacity * 2L));
+ if (newCapacity > MAX_SENT_DICT_BYTES) {
+ newCapacity = MAX_SENT_DICT_BYTES;
+ }
+ catchUpFrameAddr = Unsafe.realloc(
+ catchUpFrameAddr,
+ catchUpFrameCapacity,
+ (int) newCapacity,
+ MemoryTag.NATIVE_DEFAULT);
+ catchUpFrameCapacity = (int) newCapacity;
+ catchUpFrameGrowthCount++;
+ }
+
+ private void freeCatchUpFrameBuffer() {
+ if (catchUpFrameAddr != 0L) {
+ Unsafe.free(catchUpFrameAddr, catchUpFrameCapacity, MemoryTag.NATIVE_DEFAULT);
+ catchUpFrameAddr = 0L;
+ catchUpFrameCapacity = 0;
+ }
}
private boolean tryReceiveAcks() {
@@ -2025,10 +3304,32 @@ private boolean trySendOne() {
return false;
}
if (nextWireSeq == 0) {
- // Nothing sent on this connection yet: re-anchor in place past
- // the retired tail. The wireSeq<->FSN mapping is untouched
- // because no wire sequence has been consumed.
- positionCursorForStart();
+ // Reached only when the tail was NOT retirable at connection setup --
+ // both setup sites (swapClient, positionCursorForStart) call
+ // tryRetireOrphanTail first -- which means frames below it still needed
+ // acks, which means they were SENT on this connection. So nextWireSeq is
+ // never 0 here in practice and this arm is effectively dead; it is kept
+ // as a cheap correctness guard rather than removed.
+ //
+ // NB the mapping is untouched only while NO wire sequence has been
+ // consumed. A dictionary catch-up consumes sequences 0..n-1 before any
+ // data frame, so after one this test is false and the recycle below --
+ // which re-anchors the mapping from scratch -- is the correct path, not
+ // an avoidable cost.
+ try {
+ positionCursorForStart();
+ } catch (CatchUpSendException e) {
+ // Re-anchor's catch-up send failed. fail() here is a fresh,
+ // non-re-entrant connectLoop entry from the I/O loop body --
+ // the same recovery a normal trySendOne send failure takes.
+ // Preserve the wrapper on a cap gap so connectLoop's
+ // isCatchUpCapGap keeps the orphan settle episode (attempt count
+ // + dwell anchor) alive across the re-anchor recycle; an ordinary
+ // failure unwraps to the raw cause and restarts the episode, like
+ // any normal send failure.
+ fail(isCatchUpCapGap(e) ? e : e.getCause());
+ return false;
+ }
return true;
}
// Frames were already sent on this connection: the linear
@@ -2079,12 +3380,55 @@ private boolean trySendOne() {
if (frameEnd > pub) {
return false; // payload not fully published yet
}
+ long frameAddr = base + sendOffset + MmapSegment.FRAME_HEADER_SIZE;
+ // Torn-dictionary guard. sentDictCount is this loop's model of how many ids the
+ // CURRENT server has been told about. A frame whose delta starts ABOVE that
+ // coverage references ids the server was never given, and the server now rejects
+ // such a frame with STATUS_DICTIONARY_GAP rather than null-padding the hole. That
+ // rejection is retriable -- a wire recycle re-registers from id 0 -- but this
+ // frame cannot become sendable without one, and against a server old enough to
+ // null-pad it would land rows with silently NULL symbols. Fail terminally here,
+ // where the unreplayable range is still attributable.
+ //
+ // The guard, the mirror and the catch-up are ONE mechanism and are all
+ // ungated (see the constructor). A frame at deltaStart == sentDictCount is
+ // contiguous and extends the coverage, so a slot whose frames start at id 0
+ // replays cleanly with no persisted dictionary at all -- which is exactly why
+ // the mirror below must keep advancing even when the dictionary is missing.
+ // A gap here therefore means genuine loss: a host/power crash tore the
+ // unsynced .symbol-dict below the ids the surviving frames still reference
+ // (SF is process-crash but not host-crash durable).
+ int deltaStart = frameDeltaStart(frameAddr, payloadLen);
+ if (deltaStart > sentDictCount) {
+ recordFatal(new LineSenderException(
+ "recovered store-and-forward symbol dictionary is incomplete (likely a host crash): "
+ + "frame delta start " + deltaStart + " exceeds recovered dictionary size "
+ + sentDictCount + "; cannot replay without corrupting data -- resend required"));
+ return false;
+ }
try {
- client.sendBinary(base + sendOffset + MmapSegment.FRAME_HEADER_SIZE, payloadLen);
+ client.sendBinary(frameAddr, payloadLen);
} catch (Throwable t) {
fail(t);
return false;
}
+ // A real ring frame (data or commit) has now gone out on this connection,
+ // as opposed to only the dictionary catch-up. onClose / handleServerRejection
+ // key their poison-strike vs pre-send decision off this, not off nextWireSeq
+ // (which the catch-up advances).
+ dataFrameSentThisConnection = true;
+ if (deltaStart >= 0) {
+ // Mirror the symbols this frame just registered on the server, so a later
+ // reconnect can rebuild the whole dictionary and the guard above keeps an
+ // accurate view of the server's coverage. Idempotent on replay: a frame
+ // whose delta we already hold advances nothing.
+ //
+ // Ungated (see the constructor): this is the ONLY thing that advances
+ // sentDictCount from the frames themselves, so gating it while leaving the
+ // guard ungated froze the coverage at 0 and terminal'd a slot that replays
+ // perfectly from id 0.
+ accumulateSentDict(frameAddr, payloadLen, deltaStart);
+ }
lastFrameOrPingNanos = System.nanoTime();
sendOffset = frameEnd;
long fsnSent = fsnAtZero + nextWireSeq;
@@ -2114,8 +3458,11 @@ void positionCursorForStart() {
// starts past it. Zero wire cost, no recycle.
tryRetireOrphanTail();
long replayStart = engine.ackedFsn() + 1L;
- this.fsnAtZero = replayStart;
- this.nextWireSeq = 0L;
+ // Recovery / orphan-drain seed the dictionary mirror, so the initial
+ // connection may also need a catch-up (client is non-null in the
+ // sync-start and drainer paths; null in async-initial, where swapClient
+ // handles it on the first connect).
+ setWireBaselineWithCatchUp(replayStart);
positionCursorAt(replayStart);
}
@@ -2141,25 +3488,40 @@ private boolean tryRetireOrphanTail() {
if (orphanSkipTipFsn < 0) {
return true;
}
- if (engine.ackedFsn() < orphanSkipStartFsn - 1L) {
+ if (!engine.retireRecoveredOrphanTailIfReady()) {
return false;
}
- LOG.warn("retiring orphaned deferred tail: {} frame(s) [fsn {}..{}] belong to a transaction "
- + "whose commit was never published; aborting them (never transmitted, slots trimmed)",
- orphanSkipTipFsn - orphanSkipStartFsn + 1, orphanSkipStartFsn, orphanSkipTipFsn);
- engine.acknowledge(orphanSkipTipFsn);
orphanSkipStartFsn = -1L;
orphanSkipTipFsn = -1L;
return true;
}
+ /**
+ * Determines whether an oversized symbol-dictionary catch-up entry is always
+ * retriable or may become terminal after the orphan settle budget.
+ */
+ public enum CatchUpCapGapPolicy {
+ /** Keep reconnecting until a node with a compatible batch cap returns. */
+ RETRY_FOREVER,
+ /** Quarantine an orphan slot after both the attempt and dwell limits expire. */
+ TERMINAL_AFTER_SETTLE_BUDGET
+ }
+
+ /** Identifies who owns data while the reconnect loop is unavailable. */
+ public enum ReconnectPolicy {
+ /** A live producer keeps buffering and retries endpoint-policy failures. */
+ FOREGROUND,
+ /** An orphan drainer returns terminal states to its quarantine owner. */
+ ORPHAN
+ }
+
/**
* Factory used by the I/O loop to build a fresh, connected, upgraded
* {@link WebSocketClient} after a wire failure. Implementations close
* the old client (if needed), build a new one with the same auth/TLS
* config, connect, perform the WebSocket upgrade, and return it ready
- * to send. Throw on a terminal failure (auth rejection, etc.) — the
- * I/O loop will treat the throw as fatal.
+ * to send. The loop's {@link ReconnectPolicy} decides whether endpoint-policy
+ * failures are retried or returned as terminal.
*/
@FunctionalInterface
public interface ReconnectFactory {
@@ -2264,6 +3626,39 @@ void cancel() {
}
}
+ /**
+ * Signals that a symbol-dictionary catch-up frame could not be sent on the
+ * current connection. Thrown by {@link #sendDictCatchUp}/{@link
+ * #sendCatchUpChunk} instead of calling {@link #fail}: the catch-up runs
+ * inside {@link #connectLoop} (via {@link #swapClient}) and, on the initial
+ * connect, inside {@link #start()} / {@link #trySendOne} on the caller
+ * thread. Calling {@code fail()} from there would re-enter {@code
+ * connectLoop} -- corrupting the {@code fsnAtZero}/{@code nextWireSeq} wire
+ * mapping (a subsequent ACK then trims un-acked frames) and growing the
+ * stack until it overflows into a terminal, breaking the "retry a transient
+ * outage forever" invariant -- or run {@code connectLoop} on the caller
+ * thread and block {@code Sender} construction indefinitely. Each catch
+ * site instead turns it into ONE non-re-entrant reconnect: {@code
+ * connectLoop}'s own retry catch (swapClient path), a fresh {@code fail()}
+ * from the I/O loop body (trySendOne path), or dropping the dead client so
+ * the I/O thread reconnects (start path). A JVM {@code Error} is never
+ * wrapped -- it must stay terminal. The {@code capGap} marker lets the reconnect
+ * loop preserve only consecutive incompatible-cap observations; ordinary catch-up
+ * send failures restart the orphan settle episode.
+ */
+ private static final class CatchUpSendException extends RuntimeException {
+ private final boolean isCapGap;
+
+ CatchUpSendException(Throwable cause) {
+ this(cause, false);
+ }
+
+ CatchUpSendException(Throwable cause, boolean isCapGap) {
+ super(cause);
+ this.isCapGap = isCapGap;
+ }
+ }
+
/**
* One slot in the pendingDurable FIFO. Holds a wireSeq plus the per-table
* (name, seqTxn) pairs from its OK frame. Empty entries (tableCount = 0)
@@ -2318,9 +3713,9 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) {
// server hasn't seen.
long highestSent = nextWireSeq - 1;
if (highestSent < 0) return; // ACK before any send — ignore
- long capped = Math.min(wireSeq, highestSent);
- if (capped < wireSeq) {
- LOG.warn("server ACK wire seq {} exceeds highest sent {}, clamping",
+ long capped = Math.max(0L, Math.min(wireSeq, highestSent));
+ if (capped != wireSeq) {
+ LOG.warn("server ACK wire seq {} outside sent range [0, {}], clamping",
wireSeq, highestSent);
}
totalAcks.incrementAndGet();
@@ -2401,7 +3796,7 @@ public void onClose(int code, String reason) {
|| code == WebSocketCloseCode.GOING_AWAY;
LineSenderException cause = new LineSenderException(
"WebSocket closed by server: code=" + code + " reason=" + reason);
- if (!orderly && nextWireSeq > 0) {
+ if (!orderly && dataFrameSentThisConnection) {
if (recordHeadRejectionStrike(Math.max(engine.ackedFsn(), highestOkFsn) + 1L)) {
haltOnPoisonedFrame("ws-close[" + code + ' '
+ WebSocketCloseCode.describe(code) + "]: " + reason,
@@ -2508,26 +3903,56 @@ private void handleServerRejection(long wireSeq) {
// value is only used to attribute an FSN to the error report --
// a rejection never advances the watermark.
long highestSent = nextWireSeq - 1L;
- if (highestSent < 0L) {
- // Pre-send rejection: server emitted an error frame before
- // we sent anything on this connection (typical after a
- // fresh swapClient — auth failure, server-initiated halt,
- // etc.). The server-named wireSeq does not correspond to
- // any frame we sent, so clamping it to 0 and acknowledging
- // fsnAtZero would silently advance ackedFsn past a real
- // unsent batch (fsnAtZero == ackedFsn + 1 right after a
- // swap). Skip the watermark advance entirely; still surface
- // the error so the user's handler sees it and HALT errors
- // remain producer-observable.
+ if (!dataFrameSentThisConnection) {
+ // Pre-send rejection: the server emitted an error frame before we
+ // sent any DATA frame on this connection (typical after a fresh
+ // swapClient -- auth failure, server-initiated halt, or a rejection
+ // of the dictionary catch-up itself). nextWireSeq may be > 0 here
+ // because the catch-up consumed wire sequences, so this keys off
+ // dataFrameSentThisConnection, not highestSent >= 0 -- otherwise a
+ // transient NACK of a catch-up frame would take the post-send
+ // poison-strike path and could escalate a transient outage to a
+ // terminal. The server-named wireSeq does not correspond to any
+ // data frame we sent, so clamping it to 0 and acknowledging
+ // fsnAtZero would silently advance ackedFsn past a real unsent
+ // batch. Skip the watermark advance entirely; still surface the
+ // error so the user's handler sees it and HALT errors remain
+ // producer-observable.
handlePreSendRejection(wireSeq, status, category, policy);
return;
}
long cappedSeq = Math.max(0L, Math.min(wireSeq, highestSent));
- if (cappedSeq < wireSeq) {
- LOG.warn("server NACK wire seq {} exceeds highest sent {}, clamping",
+ if (cappedSeq != wireSeq) {
+ LOG.warn("server NACK wire seq {} outside sent range [0, {}], clamping",
wireSeq, highestSent);
}
long fsn = fsnAtZero + cappedSeq;
+ if (fsn <= engine.ackedFsn()) {
+ // The clamped wire seq maps at or below the replay head, so this
+ // NACK is for a dictionary catch-up frame -- which occupies the
+ // already-acked wire sequences below replayStart -- not a data frame
+ // this connection sent. (dataFrameSentThisConnection can be true here
+ // because trySendOne ships the head data frame before tryReceiveAcks
+ // reads the catch-up's NACK in the same loop iteration.) Attributing
+ // it a data FSN would key recordHeadRejectionStrike() off a
+ // below-baseline FSN -- negative when replayStart < catchUpFrames --
+ // colliding with the poisonFsn == -1 "no suspect" sentinel and
+ // laundering a genuine poison run, and would report a bogus FSN.
+ // Treat it exactly like a pre-send rejection: surface + recycle, no
+ // poison strike, no watermark advance. Symmetric with the success
+ // path, where engine.acknowledge() no-ops at or below ackedFsn. A
+ // real replayed data frame is at fsn > ackedFsn, so it is never
+ // caught here.
+ //
+ // Catch-up frames therefore sit OUTSIDE the poison detector: a
+ // deterministically-NACKed catch-up recycles forever (paced, so no
+ // busy-loop). That is acceptable -- a catch-up only re-registers
+ // symbols the cluster already accepted, so a persistent NACK of one
+ // is a server bug, not a poison-frame the client can quarantine, and
+ // Invariant B's "retry a transient outage forever" applies.
+ handlePreSendRejection(wireSeq, status, category, policy);
+ return;
+ }
// Best-effort table attribution: the parser populates
// response.tableNames on error frames the same way it does on
// STATUS_OK. If exactly one table was named, surface it; if
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/DefaultSenderErrorHandler.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/DefaultSenderErrorHandler.java
index c5eec387..47d15754 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/DefaultSenderErrorHandler.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/DefaultSenderErrorHandler.java
@@ -31,15 +31,12 @@
/**
* Default handler installed when the user does not call
- * {@code LineSenderBuilder.errorHandler(...)}. Logs every server rejection so
+ * {@code LineSenderBuilder.errorHandler(...)}. Logs every server rejection,
+ * plus every client-side {@link SenderError.Category#DATA_LOSS} verdict, so
* silence is never the default — connect-string-only users still see errors
* in their logs.
*
- * {@link SenderError.Policy#TERMINAL} fires at ERROR level; {@link
- * SenderError.Policy#RETRIABLE} / {@link SenderError.Policy#RETRIABLE_OTHER}
- * fire at WARN level (the batch is replayed, not lost). All carry the
- * full structured payload (category, status byte, FSN span, table, server
- * message) so the log line is sufficient for diagnosis.
+ * {@link SenderError.Policy#TERMINAL} and {@link SenderError.Policy#ABANDONED} fire at ERROR level; the retriable policies fire at WARN (the batch is replayed, not lost).
*/
public final class DefaultSenderErrorHandler implements SenderErrorHandler {
@@ -51,6 +48,14 @@ private DefaultSenderErrorHandler() {
@Override
public void onError(SenderError e) {
+ if (e.getCategory() == SenderError.Category.DATA_LOSS) {
+ // No server verdict exists for this category, so the headline must not claim
+ // one; the server-shaped fields (status byte, fsn span, seq, table) are always
+ // sentinels here and would be noise.
+ LOG.error("buffered data abandoned [category={}, policy={}, quarantined={}, msg={}]",
+ e.getCategory(), e.getAppliedPolicy(), e.getQuarantinedPath(), e.getServerMessage());
+ return;
+ }
// Single template; SLF4J fans out the levels so the call site stays
// identical and the message format is reviewable in one place.
String fmt = "server rejected batch [category={}, policy={}, status=0x{}, "
@@ -65,7 +70,8 @@ public void onError(SenderError e) {
e.getMessageSequence(),
e.getServerMessage()
};
- if (e.getAppliedPolicy() == SenderError.Policy.TERMINAL) {
+ if (e.getAppliedPolicy() == SenderError.Policy.TERMINAL
+ || e.getAppliedPolicy() == SenderError.Policy.ABANDONED) {
LOG.error(fmt, args);
} else {
LOG.warn(fmt, args);
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java
index d7d2b44d..779c9900 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java
@@ -47,8 +47,8 @@
*
* On-disk layout — header and frame format:
*
+ * The message is matched on the fragment {@code "unsafe memory access
+ * operation"}, which is common to both HotSpot wordings and NOT
+ * version-stable as a whole: pre-21 JDKs (including the shipping/CI JDK 8)
+ * emit {@code "a fault occurred in a recent unsafe memory access operation
+ * in compiled Java code"}, while JDK 21+ shortened it to {@code "a fault
+ * occurred in an unsafe memory access operation"}. Matching the shared
+ * fragment keeps the guard effective on JDK 8/11/17 as well as 21+, while
+ * still being specific enough that a genuine VirtualMachineError (real OOM,
+ * StackOverflow) is never swallowed.
+ *
+ * Delivery is NOT precise before JDK 21, so callers must guard too.
+ * Pre-21 HotSpot records the fault and raises the {@code InternalError} at
+ * the next return or safepoint check rather than at the faulting
+ * instruction, so it can surface in a CALLER's frame, past the handler that
+ * wraps the faulting read. JDK-8283699 made delivery precise in 21+.
+ *
+ * Segment recovery no longer needs this predicate: {@link #openExisting}
+ * scans through positioned reads into a heap-free bounded buffer and only
+ * maps the file once the scan has validated it, so a sparse or unbacked
+ * page surfaces as an ordinary read failure rather than a SIGBUS. It stays
+ * public for the mappings that ARE still folded over
+ * {@code Crc32c.updateUnsafe}: {@code PersistedSymbolDict}'s append and
+ * heal paths, whose handlers in {@code QwpWebSocketSender} apply this
+ * exemption ahead of their own {@code Error} rethrow so a recognised fault
+ * degrades the sender instead of escaping as a raw {@code InternalError}.
+ */
+ public static boolean isMmapAccessFault(Throwable t) {
+ if (!(t instanceof InternalError)) {
+ return false;
+ }
+ String msg = t.getMessage();
+ return msg != null && msg.contains("unsafe memory access operation");
+ }
+
/**
* Opens an existing segment file for recovery. Validates the header and
* scans frames through positioned reads, then mmaps the validated file RW.
@@ -679,44 +729,17 @@ public long tryAppend(long payloadAddr, int payloadLen) {
}
/**
- * Walks every published frame in this segment and returns the FSN of the
- * LAST frame whose payload does NOT carry the given flag bit, or {@code -1}
- * when every frame carries it (or the segment is empty).
- *
- * A frame counts as carrying the flag ONLY when it positively parses as a
- * message of the expected protocol: payload at least {@code minPayloadLen}
- * bytes AND the little-endian u32 at payload offset 0 equals
- * {@code headerMagic} AND the byte at {@code flagsOffset} has
- * {@code flagMask} set. Anything else -- short frames, foreign payloads,
- * magic mismatches -- counts as NOT carrying the flag. This direction is
- * deliberate: the caller retires (trims) frames ABOVE the returned FSN,
- * so a frame we cannot positively identify must act as a retirement
- * barrier, never as trimmable. Misclassifying an unknown frame as
- * deferred would silently discard data that should replay.
- *
- * Producer-thread only, and only meaningful before new appends race the
- * walk (recovery time). Used to locate the last commit-bearing QWP frame
- * below a potentially orphaned FLAG_DEFER_COMMIT tail: frames above the
- * returned FSN all carry the flag, i.e. they belong to a transaction whose
- * commit frame was never published.
+ * Feeds every recovered frame in this segment to the engine's single-pass
+ * recovery fold. Segments are visited oldest-first by {@link SegmentRing}.
*/
- public long findLastFrameFsnWithoutPayloadFlag(int flagsOffset, int flagMask, int headerMagic, int minPayloadLen) {
- long best = -1L;
+ void scanRecovery(RecoveredFrameAnalysis analysis) {
long off = HEADER_SIZE;
long frames = frameCount;
for (long i = 0; i < frames; i++) {
int payloadLen = Unsafe.getUnsafe().getInt(mmapAddress + off + 4);
- long payload = mmapAddress + off + FRAME_HEADER_SIZE;
- boolean flagSet = payloadLen >= minPayloadLen
- && payloadLen > flagsOffset
- && Unsafe.getUnsafe().getInt(payload) == headerMagic
- && (Unsafe.getUnsafe().getByte(payload + flagsOffset) & flagMask) != 0;
- if (!flagSet) {
- best = baseSeq + i;
- }
+ analysis.accept(baseSeq + i, mmapAddress + off + FRAME_HEADER_SIZE, payloadLen);
off += FRAME_HEADER_SIZE + payloadLen;
}
- return best;
}
/**
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/OrphanScanner.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/OrphanScanner.java
index 833960ec..a8ebaa83 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/OrphanScanner.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/OrphanScanner.java
@@ -61,6 +61,20 @@ public final class OrphanScanner {
/** Name of the sentinel that disqualifies a slot from auto-drain. */
public static final String FAILED_SENTINEL_NAME = ".failed";
+ /**
+ * Reserved infix for a slot {@code Sender.build()} set aside as unreplayable. Such a
+ * copy is kept for forensics and a manual resend; it is NEVER drainable, so the
+ * scanner excludes it BY NAME.
+ *
+ * Excluding it by name rather than by the {@code .failed} sentinel alone is what makes
+ * the exclusion reliable. {@code markFailed} is best-effort and returns silently when
+ * it cannot open the sentinel -- and the condition that makes a slot unreplayable (a
+ * full or read-only disk) is exactly the condition that makes writing the sentinel
+ * fail. Without the name check, the SAME build() that quarantined the slot dispatches
+ * its orphan drainers twelve lines later and re-adopts it: unbounded churn against
+ * data explicitly declared human-in-the-loop, repeating on every restart.
+ */
+ public static final String QUARANTINE_SLOT_INFIX = ".unreplayable-";
private OrphanScanner() {
}
@@ -271,12 +285,29 @@ public static boolean isCandidateOrphan(String slotPath) {
if (!Files.exists(slotPath)) {
return false;
}
+ if (isQuarantinedSlotName(slotPath)) {
+ return false;
+ }
if (Files.exists(slotPath + "/" + FAILED_SENTINEL_NAME)) {
return false;
}
return hasAnySegmentFile(slotPath);
}
+ /**
+ * Whether {@code slotPath}'s last path element names a quarantined slot. Independent
+ * of the {@code .failed} sentinel, which is best-effort -- see
+ * {@link #QUARANTINE_SLOT_INFIX}.
+ */
+ public static boolean isQuarantinedSlotName(String slotPath) {
+ if (slotPath == null) {
+ return false;
+ }
+ int slash = Math.max(slotPath.lastIndexOf('/'), slotPath.lastIndexOf('\\'));
+ String name = slash < 0 ? slotPath : slotPath.substring(slash + 1);
+ return name.contains(QUARANTINE_SLOT_INFIX);
+ }
+
/**
* Drops a {@link #FAILED_SENTINEL_NAME} file in {@code slotPath}.
* Idempotent — touching an existing sentinel is a no-op (its presence
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java
new file mode 100644
index 00000000..34168b44
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java
@@ -0,0 +1,1518 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary;
+import io.questdb.client.cutlass.qwp.client.NativeBufferWriter;
+import io.questdb.client.std.Crc32c;
+import io.questdb.client.std.Files;
+import io.questdb.client.std.FilesFacade;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.ObjList;
+import io.questdb.client.std.QuietCloseable;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.std.str.Utf8s;
+import org.jetbrains.annotations.TestOnly;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Append-only, per-slot persistence of the global symbol dictionary that a
+ * store-and-forward sender ships to the server with delta encoding. Lives at
+ * {@code
+ * Delta-encoded SF frames are NOT self-sufficient: a frame carries only the
+ * symbols it introduces, so recovering (process restart) or draining (orphan
+ * adoption) a slot requires re-registering the whole dictionary on the fresh
+ * server before those frames replay. This file is that dictionary. Unlike
+ * {@link AckWatermark} -- a discardable optimization protected by a
+ * {@code max()} clamp -- this file is load-bearing: a surviving frame
+ * that references an id missing from it is unrecoverable. It is therefore held
+ * to a stronger durability contract, and {@link #open} never destroys it (see
+ * "Never create, recreate, or destroy on the recovery path" below).
+ *
+ * Layout (little-endian):
+ *
+ * Why the checksum is per chunk, not per entry. The only consumer of the
+ * recovered prefix is the send loop's replay guard, which compares a surviving
+ * frame's {@code deltaStart} against the recovered dictionary size -- and every
+ * {@code deltaStart} is a chunk boundary, because chunks and frame deltas are
+ * written one-for-one. A tear inside a chunk therefore invalidates exactly the
+ * frames a per-entry checksum would have invalidated anyway: per-entry
+ * granularity buys no extra recoverable prefix. It costs a great deal, though.
+ * {@link Crc32c#update} is a native call, so checksumming per entry put one JNI
+ * transition -- plus one sub-cache-line copy and one redundant varint decode --
+ * on the producer thread for every new symbol. On the high-cardinality batch this
+ * feature exists to serve (one new symbol per row), that is a thousand native
+ * calls per flush where the chunk needs one.
+ *
+ * Durability / write-ahead ordering: the producer appends the symbols a
+ * frame introduces BEFORE that frame is published to the ring, but does NOT
+ * fsync -- matching the rest of store-and-forward, which is page-cache (not
+ * disk) durable. This ordering is sufficient for a process/JVM crash: the
+ * page cache survives, so both the dictionary and the frames survive and the
+ * dictionary is a superset of every recoverable frame's references. It is NOT
+ * sufficient for a host/power crash, where unflushed pages can be lost out
+ * of order and the dictionary may end up torn relative to the frames it serves --
+ * exactly as the segment frames themselves may be lost on a host crash. Two
+ * layers keep a host-crash tear from silently corrupting data:
+ *
+ * A torn trailing chunk from a crash mid-append is self-healing: {@link #open}
+ * stops parsing at the first incomplete chunk and the next append overwrites it.
+ *
+ * Never create, recreate, or destroy on the recovery path. {@link #open}
+ * -- the RECOVERY entry point -- returns {@code null} ONLY when the content is
+ * provably absent or corrupt (no file, a sub-header stub, a bad magic/version, a
+ * too-large file), never fabricating a fresh side-file over an absent one and
+ * never recreating over an existing one. That degrades the sender to full
+ * self-sufficient frames, and the disposition is sticky across restarts: nothing
+ * was created or destroyed, so a later recovery reaches the same verdict. Any
+ * TRANSIENT I/O failure against a file that does or might exist -- a stat error,
+ * a failed open, a failed mmap/read, a failed torn-tail truncate, a late-delivered
+ * mmap fault -- instead THROWS the retriable {@link SfOperationalException}: a
+ * single transient (an EIO on a flaky disk, a short read) must not be treated the
+ * same as proven corruption, because {@code O_TRUNC}-ing or otherwise discarding
+ * the only copy of load-bearing state on a fault that clears on retry would turn
+ * a recoverable outage into unrecoverable data. Construction aborts loudly on
+ * that exception; {@code Sender.build()} and {@code BackgroundDrainer} both treat
+ * it as abort-without-quarantine rather than a terminal verdict, so a later
+ * attempt, once the transient clears, still recovers the slot in full -- the
+ * promise this class always documented, now actually implemented. This mirrors
+ * the Rust client's {@code open_recovered} disposition matrix. Only
+ * {@link #openClean} -- the FRESH-slot path, where discarding is the whole point
+ * -- truncates.
+ *
+ * Lifecycle: single-writer (the producer / user thread) for appends. Read
+ * once at {@link #open} to seed in-memory state on recovery or orphan-drain. The
+ * owner (the engine) closes it, and {@code close()} is callable from any thread
+ * (a shutdown hook, test cleanup). {@code close()} and the append methods are
+ * therefore {@code synchronized}: without that, a close racing an in-flight append
+ * could unmap the production append region (or free the fault-test scratch buffer),
+ * close the fd, and let an in-flight write corrupt memory or land on a descriptor
+ * the OS has reused for another file. Not thread-safe for concurrent writers.
+ */
+public final class PersistedSymbolDict implements QuietCloseable {
+
+ /**
+ * Filename within the slot directory. Dot-prefixed so directory
+ * enumerators that filter by the {@code .sfa} suffix (segment recovery,
+ * OrphanScanner, trim) skip it automatically.
+ */
+ public static final String FILE_NAME = ".symbol-dict";
+ static final int CRC_SIZE = 4; // u32 CRC-32C trailing every chunk
+ static final int FILE_MAGIC = 0x31445953; // 'SYD1' little-endian
+ static final int HEADER_SIZE = 8;
+ // One bounded, segment-sized append window avoids the allocate/unmap/mmap
+ // cycle every 64 KiB without geometrically reserving up to 2x a large
+ // dictionary. close() truncates the unused tail back to appendOffset.
+ static final int APPEND_MAP_CAPACITY = 4 * 1024 * 1024;
+ /**
+ * Upper bound on a chunk's two header varints ({@code entryCount} and
+ * {@code entryBytes}): each is at most 5 bytes for a 32-bit value. The
+ * encoders reserve this much in front of the entry region so the header can
+ * be back-filled once the region's exact size is known, keeping header,
+ * entries and CRC one contiguous run.
+ */
+ static final int MAX_CHUNK_HEADER_SIZE = 10;
+ /**
+ * Ceiling for the append scratch buffer, mirroring
+ * {@code CursorWebSocketSendLoop.MAX_SENT_DICT_BYTES}: the capacity math is
+ * int-typed, so a larger buffer cannot be addressed. Exceeding it throws --
+ * {@link #ensureScratch} never silently under-allocates.
+ */
+ static final int MAX_SCRATCH_BYTES = Integer.MAX_VALUE - 8;
+ static final byte VERSION = 1;
+ private static final Logger LOG = LoggerFactory.getLogger(PersistedSymbolDict.class);
+ private final int fd;
+ // Filesystem seam. Production is FilesFacade.INSTANCE (straight to Files);
+ // tests inject a fault facade to exercise recovery I/O failures (a truncate
+ // that cannot drop a torn tail, a short write) without a real broken disk.
+ private final FilesFacade ff;
+ // Slot-qualified path, retained purely so diagnostics can name WHICH dictionary they
+ // are about: one JVM routinely holds many (a foreground sender plus N orphan
+ // drainers), so a warning carrying only the bare filename is unattributable exactly
+ // when it fires.
+ private final String filePath;
+ // Production writes directly into segmented append mappings. Wrapping facades retain the
+ // positioned-write path by default so fault tests can inject short writes through ff.write;
+ // mmap-specific fault facades opt in through FilesFacade.isMmapAllowed().
+ private final boolean mappedAppend;
+ // True only when recovery parsed the file through a temporary read-only
+ // mmap instead of allocating a second native buffer as large as the file.
+ // Test-visible so the peak-memory regression has an observable contract.
+ private final boolean mappedRecoveryInput;
+ // Entry count that corresponds EXACTLY to loadedEntriesAddr/loadedEntriesLen,
+ // fixed at open. Distinct from the live `size`, which appends advance -- including
+ // the recovery-time heal in QwpWebSocketSender.healPersistedDictionary, which runs
+ // BEFORE the send loop is constructed. The loop seeds its mirror from the loaded
+ // BYTES, so it must take its count from here; pairing those bytes with the live
+ // size would let sentDictCount claim symbols the mirror does not hold.
+ private final int recoveredSize;
+ private long appendMapAddr;
+ private long appendMapCapacity;
+ private long appendMapOffset;
+ private int appendMapGrowthCount;
+ // Single writer under this object's monitor (every append/close method is
+ // synchronized); volatile so the manager's cap-check gauge (appendedBytes())
+ // reads it wait-free -- and 64-bit-atomically -- without taking the monitor
+ // a producer may hold across append I/O.
+ private volatile long appendOffset;
+ private long appendWriteCount;
+ private boolean closed;
+ // In-memory copy of the WIRE entry region [len][utf8]... -- chunk headers and
+ // CRCs stripped -- populated only when open() recovered existing chunks
+ // (recovery / orphan-drain). Zero/empty for a freshly created file. READ (not
+ // consumed) to seed the producer's id map and to seed the send loop's catch-up
+ // mirror. Foreground construction transfers ownership to its sole loop after
+ // producer seeding; orphan-drainer loops borrow it because one engine may create
+ // several wire sessions. If ownership was not transferred, close() frees it.
+ private long loadedEntriesAddr;
+ private int loadedEntriesLen;
+ // File size the last successful ff.allocate reserved, i.e. what this
+ // side-file actually occupies on disk. ensureAppendMap rounds the append
+ // window up to APPEND_MAP_CAPACITY and allocates the whole thing, and
+ // Files.allocate reserves REAL blocks (it degrades to a sparse ftruncate
+ // only where the filesystem refuses), so the on-disk footprint runs ahead
+ // of appendOffset by up to one window for the sender's whole life -- not
+ // just after a crash. close() returns the tail. Same single-writer /
+ // volatile-reader contract as appendOffset: the manager's cap gauge reads
+ // it wait-free, without taking the monitor a producer holds across append
+ // I/O.
+ private volatile long reservedFileBytes;
+ private long scratchAddr;
+ private int scratchCap;
+ private int size;
+
+ private PersistedSymbolDict(
+ FilesFacade ff,
+ String filePath,
+ int fd,
+ long appendOffset,
+ int size,
+ long loadedEntriesAddr,
+ int loadedEntriesLen,
+ boolean mappedRecoveryInput
+ ) {
+ this.ff = ff;
+ this.filePath = filePath;
+ this.fd = fd;
+ this.mappedAppend = ff.isMmapAllowed();
+ this.mappedRecoveryInput = mappedRecoveryInput;
+ this.appendOffset = appendOffset;
+ this.size = size;
+ this.recoveredSize = size;
+ this.loadedEntriesAddr = loadedEntriesAddr;
+ this.loadedEntriesLen = loadedEntriesLen;
+ }
+
+ /**
+ * Opens the dictionary file in {@code slotDir} for RECOVERY. Never creates,
+ * recreates, or destroys the file -- see the class-level "Never create,
+ * recreate, or destroy on the recovery path" note. Three-way disposition:
+ *
+ * The fresh-start path deliberately does NOT use this -- it opens a clean dictionary
+ * via {@link #openClean} instead, so a failed delete cannot leave a stale dictionary
+ * a new session would trust.
+ *
+ * @return {@code true} when the file is gone afterwards
+ */
+ public static boolean removeOrphan(String slotDir) {
+ return removeOrphan(FilesFacade.INSTANCE, slotDir);
+ }
+
+ /**
+ * Facade-aware variant of {@link #removeOrphan(String)}.
+ *
+ * @return {@code true} when the file is gone afterwards
+ */
+ public static boolean removeOrphan(FilesFacade ff, String slotDir) {
+ return ff.remove(slotDir + "/" + FILE_NAME);
+ }
+
+ /**
+ * Appends {@code count} wire entries -- {@code [len varint][utf8]...}, the
+ * symbol-dict delta section the frame encoder just wrote -- as ONE chunk.
+ *
+ * The consistency walk below decodes each entry's length varint, but the bytes
+ * themselves are copied in a SINGLE {@code copyMemory} and checksummed by a
+ * SINGLE {@link Crc32c#update} covering the whole chunk. A per-entry checksum
+ * would put one JNI transition, one sub-cache-line copy and one redundant varint
+ * decode on the producer thread per new symbol; the chunk needs one of each.
+ *
+ * Advances {@code size} by {@code count}. Same durability/idempotency contract
+ * as {@link #appendSymbols}: no fsync, and a short write throws WITHOUT
+ * advancing {@code size}/{@code appendOffset}, so a retry keyed off
+ * {@link #size()} re-persists the same range at the same offset. No-op when the
+ * range is empty or the dictionary is closed.
+ */
+ public synchronized void appendRawEntries(long addr, int len, int count) {
+ if (closed || count <= 0 || len <= 0) {
+ return;
+ }
+ // Validate the (addr, len, count) triple BEFORE writing anything: an
+ // inconsistent triple would record a chunk whose stored entryCount disagreed
+ // with the entries it holds, shifting the dense id->symbol map on recovery.
+ // The sole caller derives count and len from one beginMessage, so this cannot
+ // fire today -- but the file this guards is the one the "resend required"
+ // contract rests on, so keep the structural guard. Gated behind assert: it
+ // re-walks every entry's length prefix on the common flush path, and the client
+ // library runs without -ea in production (embedded in user apps), so this holds
+ // the guard in the client's own -ea test suite without the per-flush cost in
+ // production.
+ assert validateRawEntries(addr, len, count);
+ int hdrLen = NativeBufferWriter.varintSize(count) + NativeBufferWriter.varintSize(len);
+ if (mappedAppend) {
+ long recLen = (long) hdrLen + len + CRC_SIZE;
+ ensureAppendMap(checkedRequiredOffset(recLen));
+ long recStart = appendMapAddr + appendOffset - appendMapOffset;
+ long p = NativeBufferWriter.writeVarint(recStart, count);
+ NativeBufferWriter.writeVarint(p, len);
+ Unsafe.getUnsafe().copyMemory(addr, recStart + hdrLen, len);
+ commitMappedChunk(recStart, hdrLen, len, count);
+ return;
+ }
+ ensureScratch((long) hdrLen + len + CRC_SIZE);
+ long p = NativeBufferWriter.writeVarint(scratchAddr, count);
+ NativeBufferWriter.writeVarint(p, len);
+ Unsafe.getUnsafe().copyMemory(addr, scratchAddr + hdrLen, len);
+ flushChunk(scratchAddr, hdrLen, len, count);
+ }
+
+ /**
+ * Appends one symbol as a single-entry chunk, extending the on-disk dictionary.
+ * The caller appends a frame's new symbols BEFORE publishing that frame, so the
+ * write ordering (dictionary entry before referencing frame) holds; no fsync is
+ * performed (see the class-level durability note). Assigns the next dense id
+ * implicitly (the entry's position).
+ *
+ * Test-only: production persists a frame's whole new-symbol range in one chunk
+ * via {@link #appendSymbols} / {@link #appendRawEntries}. Tests use this to
+ * build a dictionary one entry at a time.
+ */
+ @TestOnly
+ public synchronized void appendSymbol(CharSequence symbol) {
+ if (closed) {
+ return;
+ }
+ int utf8Len = Utf8s.utf8Bytes(symbol);
+ int wireLen = NativeBufferWriter.varintSize(utf8Len) + utf8Len; // [len][utf8]
+ if (mappedAppend) {
+ int hdrLen = NativeBufferWriter.varintSize(1) + NativeBufferWriter.varintSize(wireLen);
+ long recLen = (long) hdrLen + wireLen + CRC_SIZE;
+ ensureAppendMap(checkedRequiredOffset(recLen));
+ long recStart = appendMapAddr + appendOffset - appendMapOffset;
+ long p = NativeBufferWriter.writeVarint(recStart, 1);
+ p = NativeBufferWriter.writeVarint(p, wireLen);
+ p = NativeBufferWriter.writeVarint(p, utf8Len);
+ if (utf8Len > 0) {
+ Utf8s.strCpyUtf8(symbol, p, utf8Len);
+ }
+ commitMappedChunk(recStart, hdrLen, wireLen, 1);
+ return;
+ }
+ ensureScratch((long) MAX_CHUNK_HEADER_SIZE + wireLen + CRC_SIZE);
+ long entryStart = scratchAddr + MAX_CHUNK_HEADER_SIZE;
+ long p = NativeBufferWriter.writeVarint(entryStart, utf8Len);
+ if (utf8Len > 0) {
+ Utf8s.strCpyUtf8(symbol, p, utf8Len);
+ }
+ writeChunkFromScratch(wireLen, 1);
+ }
+
+ /**
+ * Appends the dense id range {@code [from .. to]} as one chunk with one
+ * checksum. This is the RE-ENCODE path: the steady-state persist ships a frame's
+ * pre-encoded delta bytes through {@link #appendRawEntries}, and only a retry
+ * after a failed publish rebuilds a range straight from the dictionary. The
+ * mapped path stages the entry region in the scratch buffer with a single UTF-8
+ * walk per symbol, then bulk-copies it into the append window after the header,
+ * and still commits WITHOUT a positioned-write syscall. A direct encode into the
+ * window would walk each symbol's UTF-8 length twice -- the exact entriesLen sizes
+ * both the header varint and the mmap reserve -- and the back-to-back chunk format
+ * leaves no room to reserve-and-back-fill the header in place. The
+ * positioned-write fallback runs only behind an injected filesystem facade so
+ * short-write recovery tests retain their fault seam. Callers pass the dictionary
+ * and the range so the ids resolve to their symbol strings.
+ *
+ * Same durability and idempotency contract as {@link #appendSymbol}: no fsync,
+ * and a short write throws WITHOUT advancing {@code size}/{@code appendOffset},
+ * so a retry keyed off {@link #size()} re-encodes the same range and overwrites
+ * at the same offset. No-op when the range is empty or the dictionary is closed.
+ */
+ public synchronized void appendSymbols(GlobalSymbolDictionary dict, int from, int to) {
+ if (closed || to < from) {
+ return;
+ }
+ int count = to - from + 1;
+ if (mappedAppend) {
+ // Stage the entry region in scratch with ONE UTF-8 walk per symbol, then
+ // bulk-copy it into the append window after the header (see the method
+ // javadoc for why a direct in-window encode would have to walk each symbol
+ // twice). ensureScratch enforces the same MAX_SCRATCH_BYTES ceiling the old
+ // sizing pass did, throwing before size/appendOffset advance.
+ int entriesLen = 0;
+ for (int id = from; id <= to; id++) {
+ CharSequence symbol = dict.getSymbol(id);
+ int utf8Len = Utf8s.utf8Bytes(symbol);
+ int wireLen = NativeBufferWriter.varintSize(utf8Len) + utf8Len; // [len][utf8]
+ ensureScratch((long) entriesLen + wireLen);
+ long q = NativeBufferWriter.writeVarint(scratchAddr + entriesLen, utf8Len);
+ if (utf8Len > 0) {
+ Utf8s.strCpyUtf8(symbol, q, utf8Len);
+ }
+ entriesLen += wireLen;
+ }
+ int hdrLen = NativeBufferWriter.varintSize(count)
+ + NativeBufferWriter.varintSize(entriesLen);
+ long recLen = (long) hdrLen + entriesLen + CRC_SIZE;
+ ensureAppendMap(checkedRequiredOffset(recLen));
+ long recStart = appendMapAddr + appendOffset - appendMapOffset;
+ long p = NativeBufferWriter.writeVarint(recStart, count);
+ NativeBufferWriter.writeVarint(p, entriesLen);
+ if (entriesLen > 0) {
+ Unsafe.getUnsafe().copyMemory(scratchAddr, recStart + hdrLen, entriesLen);
+ }
+ commitMappedChunk(recStart, hdrLen, entriesLen, count);
+ return;
+ }
+ int entriesLen = 0;
+ for (int id = from; id <= to; id++) {
+ CharSequence symbol = dict.getSymbol(id);
+ int utf8Len = Utf8s.utf8Bytes(symbol);
+ int wireLen = NativeBufferWriter.varintSize(utf8Len) + utf8Len; // [len][utf8]
+ ensureScratch((long) MAX_CHUNK_HEADER_SIZE + entriesLen + wireLen + CRC_SIZE);
+ long entryStart = scratchAddr + MAX_CHUNK_HEADER_SIZE + entriesLen;
+ long p = NativeBufferWriter.writeVarint(entryStart, utf8Len);
+ if (utf8Len > 0) {
+ Utf8s.strCpyUtf8(symbol, p, utf8Len);
+ }
+ entriesLen += wireLen;
+ }
+ writeChunkFromScratch(entriesLen, count);
+ }
+
+ @Override
+ public synchronized void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ // Each step in its own try/catch, as CursorSendEngine.close() already does.
+ // `closed` is set first, so a throw from any step short-circuits every retry --
+ // stranding the scratch buffer and the fd for the process's lifetime. Unreachable
+ // with FilesFacade.INSTANCE (munmap and truncate are native calls returning
+ // int/boolean), but the ff seam exists precisely so a test CAN inject a throw,
+ // and a close path must not depend on its own steps never failing.
+ if (loadedEntriesAddr != 0L) {
+ try {
+ Unsafe.free(loadedEntriesAddr, loadedEntriesLen, MemoryTag.NATIVE_DEFAULT);
+ } catch (Throwable ignored) {
+ }
+ // Null after freeing (like scratchAddr below) so a CONSTRUCTION-PHASE reader
+ // that runs after close observes 0 rather than a dangling pointer. This is
+ // NOT a cross-thread guard, and the getters' own javadoc is the accurate
+ // statement: they are unsynchronised reads of non-volatile fields, so a
+ // caller on another thread has no guarantee of seeing this write and can
+ // still dereference freed memory. Ordering, not nulling, is what makes the
+ // borrow safe -- every owner closes its loop before the engine.
+ loadedEntriesAddr = 0L;
+ loadedEntriesLen = 0;
+ }
+ if (appendMapAddr != 0L) {
+ long mapAddr = appendMapAddr;
+ long mapCapacity = appendMapCapacity;
+ appendMapAddr = 0L;
+ appendMapCapacity = 0L;
+ appendMapOffset = 0L;
+ try {
+ ff.munmap(mapAddr, mapCapacity, MemoryTag.MMAP_DEFAULT);
+ } catch (Throwable ignored) {
+ }
+ try {
+ // The active window reserves space past the logical end. Return that tail on
+ // orderly close; after a crash open() treats the zero-filled reserve as
+ // a torn trailing chunk and truncates it to the same appendOffset.
+ if (ff.truncate(fd, appendOffset)) {
+ // The reserve is gone; stop reporting it. A failed truncate
+ // deliberately leaves reservedFileBytes alone -- the blocks
+ // are still on disk, and a gauge read racing this close
+ // must not under-report them.
+ reservedFileBytes = appendOffset;
+ } else {
+ LOG.warn("symbol dict {} could not trim mmap reserve to {}; recovery will "
+ + "discard the zero-filled tail on the next open",
+ filePath, appendOffset);
+ }
+ } catch (Throwable ignored) {
+ }
+ }
+ if (scratchAddr != 0L) {
+ try {
+ Unsafe.free(scratchAddr, scratchCap, MemoryTag.NATIVE_DEFAULT);
+ } catch (Throwable ignored) {
+ }
+ scratchAddr = 0L;
+ scratchCap = 0;
+ }
+ if (fd >= 0) {
+ try {
+ ff.close(fd);
+ } catch (Throwable ignored) {
+ }
+ }
+ }
+
+ @TestOnly
+ public synchronized int appendMapGrowthCount() {
+ return appendMapGrowthCount;
+ }
+
+ @TestOnly
+ public synchronized long appendWriteCount() {
+ return appendWriteCount;
+ }
+
+ /**
+ * Durable bytes the side-file currently holds: the header plus every
+ * committed chunk -- the append offset the next chunk starts at.
+ * Excludes the preallocated append-window tail, so this survives a reopen
+ * unchanged. For disk accounting use {@link #occupiedDiskBytes()} instead,
+ * which includes that tail.
+ *
+ * The read is wait-free -- a volatile read of the offset the last
+ * committed chunk advanced -- so a caller never blocks on this
+ * dictionary's monitor while holding its own lock.
+ */
+ public long appendedBytes() {
+ return appendOffset;
+ }
+
+ /**
+ * Base address of the loaded entry region -- the concatenated
+ * {@code [len][utf8]} bytes of every recovered symbol in id order, exactly as a
+ * delta section carries them (chunk headers and CRCs stripped). Zero when
+ * nothing was recovered.
+ *
+ * Construction-phase only. This hands out a raw pointer into native
+ * memory that {@link #close()} frees and nulls, with no closed-guard and no
+ * synchronization. It is safe to read only BEFORE the slot's I/O thread and
+ * any producer append start -- i.e. while the send loop is being constructed
+ * or an orphan-drain is seeding its mirror, both of which happen-before those
+ * threads. A caller that reads it from a running thread races {@code close()}
+ * and can dereference freed memory (use-after-free).
+ */
+ public long loadedEntriesAddr() {
+ return loadedEntriesAddr;
+ }
+
+ /**
+ * Byte length of {@link #loadedEntriesAddr()}. Construction-phase only, for
+ * the same reason -- see {@link #loadedEntriesAddr()}.
+ */
+ public int loadedEntriesLen() {
+ return loadedEntriesLen;
+ }
+
+ /**
+ * Bytes this side-file occupies on disk right now: the committed prefix
+ * ({@link #appendedBytes()}) plus any append window {@code ensureAppendMap}
+ * has reserved past it. {@code SegmentManager} reads this through the gauge
+ * wired at engine registration, so the dictionary counts against
+ * {@code sf_max_total_bytes} at its real footprint rather than its logical
+ * one -- the window is rounded up to {@code APPEND_MAP_CAPACITY} and backed
+ * by real blocks, so reporting {@code appendedBytes()} under-counted a live
+ * slot by up to one window for the whole session.
+ *
+ * Wait-free: two volatile reads, no monitor. Never decreases except across
+ * the truncate in {@link #close()}.
+ */
+ public long occupiedDiskBytes() {
+ // Both fields are single-writer under the monitor, but this reader
+ // takes neither, so the pair can straddle a window growth. The result
+ // is then bounded by the two instants it sampled -- never below the
+ // committed prefix, never above the file's real size -- so the cap can
+ // be off by at most one window for one tick, which is what a gauge
+ // consulted under the manager lock has to trade for staying wait-free.
+ // Max, not the reserve alone: the reserve is zero until the first
+ // append, and zero again after close()'s truncate.
+ long reserved = reservedFileBytes;
+ long committed = appendOffset;
+ return Math.max(committed, reserved);
+ }
+
+ /**
+ * Number of symbols {@link #open} recovered from disk -- the exact entry count of
+ * {@link #loadedEntriesAddr()} / {@link #loadedEntriesLen()}. Unlike {@link #size()}
+ * this never advances, so a caller seeding from the loaded bytes stays in lockstep
+ * with them even after the producer has appended (the recovery heal does exactly
+ * that, before the send loop is built).
+ */
+ public int recoveredSize() {
+ return recoveredSize;
+ }
+
+ @TestOnly
+ public boolean usedMappedRecoveryInput() {
+ return mappedRecoveryInput;
+ }
+
+ /**
+ * Decodes the recovered entries directly into {@code target} in ascending-id
+ * order. This avoids materialising a cardinality-sized temporary list that
+ * the producer would immediately copy into the global dictionary.
+ * Construction-phase only; see {@link #loadedEntriesAddr()}.
+ */
+ public void addLoadedSymbolsTo(GlobalSymbolDictionary target) {
+ decodeLoadedSymbols(target, null);
+ }
+
+ /**
+ * Materialises the loaded entries as symbol strings in ascending-id order.
+ * Retained for recovery-format tests; production decodes directly through
+ * {@link #addLoadedSymbolsTo(GlobalSymbolDictionary)}.
+ */
+ @TestOnly
+ public ObjList
+ * {@link Files#length(String)} frees its native path pointer in a {@code finally},
+ * so on POSIX a libc {@code free()} lands between the failing {@code stat} and the
+ * separate {@code Os.errno()} JNI call. POSIX does not require {@code free()} to
+ * preserve {@code errno} -- glibc only began saving and restoring it in 2.33, and
+ * this client's runtime floor is older (see {@code check-glibc-floor.sh}). A clobber
+ * would invert the disposition below: a genuinely absent file could read as a hard
+ * error and abort {@code build()}, or a real EIO could read as {@code ENOENT} and
+ * degrade this session next to a possibly-populated side-file. The pathPtr overload
+ * keeps the two calls adjacent, which is what every other errno read in this client
+ * already does -- they all follow either a socket call or the fd-based
+ * {@code length(int)} overload. Windows is unaffected either way: its {@code length0}
+ * saves the error into a TLS slot on every failing arm.
+ *
+ * @param errnoOut receives the errno when the stat failed; left untouched otherwise
+ * @return the file length, or a negative value when the stat failed
+ */
+ private static long statLength(FilesFacade ff, String filePath, int[] errnoOut) {
+ long pathPtr = ff.allocNativePath(filePath);
+ try {
+ long len = ff.length(pathPtr);
+ if (len < 0) {
+ errnoOut[0] = ff.errno();
+ }
+ return len;
+ } finally {
+ ff.freeNativePath(pathPtr);
+ }
+ }
+
+ private static RecoveryScan scanAndCopyRecoveredChunks(long inputAddr, int len, long dstAddr) {
+ Varint v = new Varint();
+ int count = 0;
+ int diskPos = HEADER_SIZE;
+ long entriesLen = 0L;
+ while (diskPos < len) {
+ if (!v.decode(inputAddr, diskPos, len)) {
+ break;
+ }
+ long entryCount = v.value;
+ if (!v.decode(inputAddr, v.end, len)) {
+ break;
+ }
+ long entryBytes = v.value;
+ int entriesStart = v.end;
+ long chunkEnd = (long) entriesStart + entryBytes;
+ if (chunkEnd + CRC_SIZE > len) {
+ break;
+ }
+ int chunkEndI = (int) chunkEnd;
+ int crcStored = Unsafe.getUnsafe().getInt(inputAddr + chunkEndI);
+ int crcCalc = Crc32c.updateUnsafe(
+ Crc32c.INIT,
+ inputAddr + diskPos,
+ chunkEndI - diskPos);
+ if (crcCalc != crcStored
+ || entryCount <= 0
+ // Every entry costs at least its own length varint, so a positive
+ // entryCount inside a zero-byte region is self-contradictory. The CRC
+ // proves the bytes are what was WRITTEN, never that the triple is
+ // consistent, and decodeLoadedSymbols would only discover it later --
+ // as a throw two layers up rather than a trimmed trusted prefix.
+ || entryBytes <= 0
+ || (long) count + entryCount > Integer.MAX_VALUE
+ || entriesLen + entryBytes > Integer.MAX_VALUE) {
+ break;
+ }
+ if (!isConsistentEntryRegion(inputAddr, entriesStart, entryBytes, entryCount)) {
+ break;
+ }
+ Unsafe.getUnsafe().copyMemory(inputAddr + entriesStart, dstAddr + entriesLen, entryBytes);
+ entriesLen += entryBytes;
+ diskPos = chunkEndI + CRC_SIZE;
+ count += (int) entryCount;
+ }
+ return new RecoveryScan(count, (int) entriesLen, diskPos);
+ }
+
+ /**
+ * Whether {@code [start, start + bytes)} holds exactly {@code count} well-formed
+ * {@code [len varint][utf8]} entries, consuming the region exactly.
+ *
+ * The chunk CRC proves the bytes are what was WRITTEN; it says nothing about whether
+ * the header triple is self-consistent. The only write-side guard,
+ * {@code validateRawEntries}, sits behind an {@code assert} -- and this library ships
+ * embedded in user applications, which run without {@code -ea}. So a producer bug or
+ * a torn write that happens to re-checksum could record a chunk whose stored
+ * entryCount disagrees with its entries, shifting the dense id->symbol map for
+ * everything above it.
+ *
+ * Checking here ends the trusted prefix at that chunk -- the same treatment a CRC
+ * failure gets -- instead of letting decodeLoadedSymbols discover it later and throw
+ * two layers up, which quarantines the whole slot rather than salvaging its intact
+ * prefix. It costs one varint decode per entry on a cold path that already walks
+ * every entry immediately afterwards.
+ */
+ private static boolean isConsistentEntryRegion(long addr, int start, long bytes, long count) {
+ long p = start;
+ long limit = start + bytes;
+ for (long i = 0; i < count; i++) {
+ long len = 0;
+ int shift = 0;
+ boolean terminated = false;
+ while (p < limit) {
+ byte b = Unsafe.getUnsafe().getByte(addr + p++);
+ len |= (long) (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) {
+ terminated = true;
+ break;
+ }
+ shift += 7;
+ if (shift > 35) {
+ return false; // over-long run: a canonical length varint is <= 5 bytes
+ }
+ }
+ if (!terminated || len < 0 || p + len > limit) {
+ return false;
+ }
+ p += len;
+ }
+ return p == limit;
+ }
+
+ /**
+ * True when {@code path} names a directory. {@code findFirst} opens it as one
+ * ({@code opendir} / {@code FindFirstFileW}) and yields a positive handle;
+ * anything else -- a regular file, an absent path, an unreadable one -- comes back
+ * as {@code -1}. Used only on the fresh-slot error path, to keep the survivor
+ * unlink from rmdir'ing a directory that happens to occupy the dictionary's name.
+ */
+ private static boolean isDirectory(FilesFacade ff, String path) {
+ long findPtr = ff.findFirst(path);
+ if (findPtr > 0) {
+ ff.findClose(findPtr);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Opens {@code filePath} as a FRESH, EMPTY dictionary file, discarding any
+ * surviving content. Serves only the fresh-slot path ({@link #openClean}) --
+ * after the disposition split in {@link #open}, the recovery path never
+ * reaches this method, since it never creates or truncates.
+ *
+ * An existing file MUST be cleared here: a survivor that cannot be truncated
+ * describes a prior generation's id space, and proceeding anyway would leave
+ * it on disk while this session writes rows against a fresh id space from 0.
+ * The next recovery cannot tell the two apart -- ids overlap, the CRC is
+ * valid, and the catch-up would register the wrong strings under ids this
+ * generation's rows reference -- so that case throws
+ * {@link UnreplayableSlotException} rather than degrading to {@code null}.
+ * When no file exists at all, there is nothing to protect, so a create
+ * failure just degrades to {@code null} and the caller proceeds without a
+ * dictionary.
+ */
+ private static PersistedSymbolDict openFresh(FilesFacade ff, String filePath) {
+ int fd = ff.openCleanRW(filePath);
+ if (fd < 0) {
+ // Route the refuse-vs-degrade decision on what a stat PROVES, not
+ // on an errno-blind ff.exists() (access(2) / a boolean
+ // PathFileExists collapses every stat error to "absent"): only a
+ // not-found errno proves there is nothing here to protect. A file
+ // that exists -- or whose state cannot be determined -- takes the
+ // refuse arm, never the null-degrade.
+ int[] probeErrno = {0};
+ long len = statLength(ff, filePath, probeErrno);
+ if (len >= 0 || !Files.isNotFoundError(probeErrno[0])) {
+ // A fresh slot MUST start with an empty dictionary. Proceeding without one
+ // leaves the previous generation's entries on disk while this session
+ // writes rows against a fresh id space from 0, and the next recovery
+ // cannot distinguish the two: the ids overlap, the CRC is valid, and the
+ // catch-up registers the wrong strings under ids this generation's rows
+ // reference.
+ //
+ // But UNLINKING the survivor satisfies that requirement just as well as
+ // truncating it, and unlink needs no descriptor -- so try it before
+ // refusing. openCleanRW returning < 0 says only that this process could
+ // not OPEN the file; it says nothing about whether the file can be
+ // removed. An fd-exhaustion transient (EMFILE/ENFILE) under a large
+ // sender pool, or a Windows scanner holding a handle without
+ // FILE_SHARE_WRITE, fails the truncate while the unlink still succeeds.
+ // Removing is safe HERE precisely because this is the FRESH path: the
+ // slot has no recovered segments, nothing is ever seeded from this file,
+ // and the only property that matters is that no prior generation's id
+ // space survives next to the rows this session is about to write from 0.
+ //
+ // Without this, a transient quarantined a slot holding NO recoverable
+ // frames at all: quarantineTornSlot renames it aside, drops a .failed
+ // sentinel nothing ever clears, and dispatches a DATA_LOSS "the affected
+ // data must be resent" to the caller's error handler -- for data that
+ // never existed. Each recurrence also burns one of the 64 quarantine
+ // indices, after which quarantineTornSlot throws and build() fails
+ // permanently until an operator intervenes.
+ // A regular file ONLY. A directory at this path is not a stale
+ // dictionary -- it is a structural blocker (a misconfiguration, an
+ // operator artifact, possibly holding files of its own) that no retry
+ // ever clears, and FilesFacade.remove would happily rmdir it: the
+ // native is remove(3) on POSIX and an explicit RemoveDirectoryW on
+ // Windows. That case keeps the refusal it always had.
+ if (!isDirectory(ff, filePath) && ff.remove(filePath)) {
+ LOG.warn("symbol dict {} could not be opened for truncation (rc={}); removed the "
+ + "stale survivor instead and proceeding without a dictionary", filePath, fd);
+ return null;
+ }
+ // Neither truncatable nor removable, or its very existence is
+ // unprovable: a survivor MAY be there, and degrading next to it would
+ // hand the next recovery a side-file this session's rows never
+ // described. Typed as UnreplayableSlotException (rather than a plain
+ // LineSenderException) so Sender.build()'s constructor-time catch
+ // quarantines this fresh, dataless slot instead of bricking every
+ // restart under a stable senderId.
+ throw new UnreplayableSlotException("symbol dict ").put(filePath)
+ .put(" already exists (or its state cannot be determined) and")
+ .put(" cannot be truncated or removed (rc=").put(fd)
+ .put("); refusing to start on a slot whose dictionary may describe a")
+ .put(" different id space -- move or remove it by hand");
+ }
+ LOG.warn("symbol dict {} could not be created (rc={}); proceeding without it", filePath, fd);
+ return null;
+ }
+ long hdr = 0L;
+ try {
+ hdr = Unsafe.malloc(HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+ Unsafe.getUnsafe().putInt(hdr, FILE_MAGIC);
+ Unsafe.getUnsafe().putByte(hdr + 4, VERSION);
+ Unsafe.getUnsafe().putByte(hdr + 5, (byte) 0);
+ Unsafe.getUnsafe().putByte(hdr + 6, (byte) 0);
+ Unsafe.getUnsafe().putByte(hdr + 7, (byte) 0);
+ long written = ff.write(fd, hdr, HEADER_SIZE, 0);
+ if (written != HEADER_SIZE) {
+ int fdToClose = fd;
+ fd = -1; // relinquish before close so the catch cannot double-close if close throws
+ ff.close(fdToClose);
+ ff.remove(filePath); // drop the headerless stub rather than litter
+ LOG.warn("symbol dict {} header write failed; proceeding without it", filePath);
+ return null;
+ }
+ } catch (Throwable t) {
+ // Unreachable with FilesFacade.INSTANCE (Files.write is native and returns
+ // -1 rather than throwing; the Unsafe puts target a valid 8-byte buffer and
+ // an 8-byte malloc cannot realistically OOM), but the ff seam exists so
+ // tests CAN inject a throwing facade -- close the fd and drop the stub so
+ // neither leaks.
+ if (fd >= 0) { // the header-write branch relinquished fd to -1 before closing
+ int fdToClose = fd;
+ fd = -1;
+ ff.close(fdToClose);
+ ff.remove(filePath);
+ }
+ LOG.warn("symbol dict {} creation failed; proceeding without it", filePath, t);
+ return null;
+ } finally {
+ if (hdr != 0L) {
+ Unsafe.free(hdr, HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+ return new PersistedSymbolDict(ff, filePath, fd, HEADER_SIZE, 0, 0L, 0, false);
+ }
+
+ /**
+ * Verifies that {@code count} wire entries ({@code [len varint][utf8]}) occupy
+ * exactly {@code len} bytes from {@code addr}. Returns {@code true} when the triple
+ * is consistent; throws {@link IllegalStateException} (naming the offending entry /
+ * count / consumed bytes) otherwise. Called only from an {@code assert} in
+ * {@link #appendRawEntries}: it guards an internal invariant the sole caller cannot
+ * violate today, so it runs under the test suite's {@code -ea} but is elided from
+ * the per-flush production path (client apps run without {@code -ea}).
+ */
+ private static boolean validateRawEntries(long addr, int len, int count) {
+ long src = addr;
+ long srcLimit = addr + len;
+ for (int i = 0; i < count; i++) {
+ long symLen = 0;
+ int shift = 0;
+ while (src < srcLimit) {
+ byte b = Unsafe.getUnsafe().getByte(src++);
+ symLen |= (long) (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) {
+ break;
+ }
+ shift += 7;
+ if (shift > 35) {
+ // A canonical entry-length varint is <= 5 bytes; a longer
+ // continuation run is corrupt. The bound check below rejects it.
+ break;
+ }
+ }
+ src += symLen; // src was just past the len varint
+ if (src > srcLimit) {
+ throw new IllegalStateException("malformed raw symbol-dict entries to " + FILE_NAME
+ + " [entry=" + i + ", count=" + count + ']');
+ }
+ }
+ if (src != srcLimit) {
+ throw new IllegalStateException("raw symbol-dict entries under-filled the buffer to "
+ + FILE_NAME + " [count=" + count + ", len=" + len
+ + ", consumed=" + (int) (src - addr) + ']');
+ }
+ return true;
+ }
+
+ private long checkedRequiredOffset(long recordLen) {
+ long required = appendOffset + recordLen;
+ if (recordLen < 0L || required < appendOffset) {
+ throw new IllegalStateException("symbol dict append offset overflow to "
+ + FILE_NAME + " [offset=" + appendOffset + ", recordLen=" + recordLen + ']');
+ }
+ return required;
+ }
+
+ /**
+ * Commits a chunk already assembled directly in the append mmap. The logical
+ * offset and symbol count advance last, after the CRC and a store fence, so an
+ * interrupted process leaves either a complete chunk or a tail that open()
+ * rejects and truncates.
+ */
+ private void commitMappedChunk(long recStart, int hdrLen, int entriesLen, int count) {
+ long bodyLen = (long) hdrLen + entriesLen;
+ long recLen = bodyLen + CRC_SIZE;
+ // updateUnsafe, NOT the native update: this checksums bytes inside the append
+ // MAPPING. ensureAppendMap grows the file with ff.allocate, whose native fallback
+ // is ftruncate, so the window can cover blocks the filesystem has not committed
+ // (ENOSPC, a quota, a sparse tail left by a crash) -- and touching one raises
+ // SIGBUS. Inside JNI that ABORTS THE JVM with no recovery; at an Unsafe intrinsic
+ // site HotSpot converts it to a catchable InternalError -- which is why
+ // MmapSegment.isMmapAccessFault stays public for exactly these two sites.
+ // MmapSegment itself no longer needs it: its recovery validates every page
+ // through positioned reads before mapping, so it checksums with the native
+ // Crc32c.update. The Java path runs at roughly two thirds the native
+ // throughput per byte -- microseconds on a chunk this size, and the price of
+ // the catchability above.
+ Unsafe.getUnsafe().putInt(
+ recStart + bodyLen,
+ Crc32c.updateUnsafe(Crc32c.INIT, recStart, bodyLen));
+ Unsafe.getUnsafe().storeFence();
+ appendOffset += recLen;
+ size += count;
+ }
+
+ /**
+ * Ensures the production append mmap covers the absolute file offset
+ * {@code required}. The log is mapped in 4 MiB-aligned windows: small flushes
+ * share a segment-sized window, while a large existing dictionary does not force
+ * the process to reserve and remap its whole prefix or geometrically over-allocate it.
+ */
+ private void ensureAppendMap(long required) {
+ if (appendMapAddr != 0L
+ && required >= appendMapOffset
+ && required <= appendMapOffset + appendMapCapacity) {
+ return;
+ }
+ // Page-align, not APPEND_MAP_CAPACITY-align: mmap only requires a page-aligned
+ // file offset. Rounding the START down to a 4 MiB boundary while sizing `needed`
+ // to the record's END meant a chunk straddling that boundary produced a window
+ // spanning BOTH -- 8 MiB mapped and re-allocated, whose lower half covers bytes
+ // already written and never touched again. Steady state then advanced 4 MiB per
+ // remap while always mapping 8.
+ long pageMask = Files.PAGE_SIZE - 1;
+ long newOffset = appendOffset & ~pageMask;
+ long needed = required - newOffset;
+ long newCapacity = Math.max(APPEND_MAP_CAPACITY, needed);
+ long remainder = newCapacity % APPEND_MAP_CAPACITY;
+ if (remainder != 0L) {
+ long padding = APPEND_MAP_CAPACITY - remainder;
+ if (newCapacity > Long.MAX_VALUE - padding) {
+ throw new IllegalStateException("symbol dict mmap capacity overflow to "
+ + FILE_NAME + " [required=" + required + ']');
+ }
+ newCapacity += padding;
+ }
+ if (newOffset > Long.MAX_VALUE - newCapacity) {
+ throw new IllegalStateException("symbol dict mmap file offset overflow to "
+ + FILE_NAME + " [offset=" + newOffset + ", capacity=" + newCapacity + ']');
+ }
+ long newFileSize = newOffset + newCapacity;
+ if (!ff.allocate(fd, newFileSize)) {
+ throw new IllegalStateException("could not grow mmap append region for "
+ + FILE_NAME + " [required=" + required + ", fileSize=" + newFileSize + ']');
+ }
+ // Publish the reservation as soon as it is on disk, and before the
+ // mmap that can still fail: the blocks are committed either way, so a
+ // gauge that only counted the mapped case would under-report exactly
+ // when the filesystem is under pressure. Files.allocate never shrinks,
+ // so this only ever moves forward.
+ if (newFileSize > reservedFileBytes) {
+ reservedFileBytes = newFileSize;
+ }
+ if (appendMapAddr != 0L) {
+ long oldAddr = appendMapAddr;
+ long oldCapacity = appendMapCapacity;
+ appendMapAddr = 0L;
+ appendMapCapacity = 0L;
+ appendMapOffset = 0L;
+ ff.munmap(oldAddr, oldCapacity, MemoryTag.MMAP_DEFAULT);
+ }
+ long newAddr = ff.mmap(
+ fd, newCapacity, newOffset, Files.MAP_RW, MemoryTag.MMAP_DEFAULT);
+ if (newAddr == Files.FAILED_MMAP_ADDRESS) {
+ throw new IllegalStateException("could not mmap append region for "
+ + FILE_NAME + " [offset=" + newOffset + ", capacity=" + newCapacity + ']');
+ }
+ appendMapAddr = newAddr;
+ appendMapCapacity = newCapacity;
+ appendMapOffset = newOffset;
+ appendMapGrowthCount++;
+ }
+
+ /**
+ * Grows the append scratch buffer to hold at least {@code required} bytes.
+ *
+ * Throws when {@code required} exceeds {@link #MAX_SCRATCH_BYTES} rather than
+ * clamping to it: a clamp would hand back a buffer SMALLER than the caller asked
+ * for and return normally, and every caller then writes {@code required} bytes
+ * into it -- turning a clean out-of-memory into a silent native-heap overflow, on
+ * the very write path the dictionary's integrity rests on. This is the same
+ * loud-failure shape {@code CursorWebSocketSendLoop.ensureSentDictCapacity} uses
+ * on the same bound. Unreachable at any realistic cardinality (it needs a ~2 GiB
+ * dictionary section in a single frame, itself bounded by the server's batch
+ * cap), but a size guard on a data-integrity write path must never
+ * under-allocate.
+ */
+ private void ensureScratch(long required) {
+ if (scratchCap >= required) {
+ return;
+ }
+ if (required > MAX_SCRATCH_BYTES) {
+ throw new IllegalStateException("symbol dict scratch buffer exceeds the maximum size to "
+ + FILE_NAME + " [required=" + required + ", max=" + MAX_SCRATCH_BYTES + ']');
+ }
+ // Double in long: scratchCap * 2 as an int overflows negative past ~1 GB and
+ // would make the realloc size negative.
+ long newCap = Math.max(required, Math.max(256L, (long) scratchCap * 2));
+ if (newCap > MAX_SCRATCH_BYTES) {
+ newCap = MAX_SCRATCH_BYTES;
+ }
+ scratchAddr = Unsafe.realloc(scratchAddr, scratchCap, (int) newCap, MemoryTag.NATIVE_DEFAULT);
+ scratchCap = (int) newCap;
+ }
+
+ /**
+ * Checksums {@code [recStart, recStart + hdrLen + entriesLen)} in ONE native
+ * call, appends the CRC, and issues ONE positioned write. Advances
+ * {@code size}/{@code appendOffset} only on a complete write, so a short write
+ * throws and a retry keyed off {@link #size()} re-persists the same range at the
+ * same offset.
+ */
+ private void flushChunk(long recStart, int hdrLen, int entriesLen, int count) {
+ // long math, matching commitMappedChunk: hdrLen + entriesLen can reach
+ // Integer.MAX_VALUE (entriesLen is bounded only by MAX_SCRATCH_BYTES), and an
+ // int sum would wrap negative and hand a bogus length to the CRC and the write.
+ long bodyLen = (long) hdrLen + entriesLen;
+ long recLen = bodyLen + CRC_SIZE;
+ Unsafe.getUnsafe().putInt(recStart + bodyLen, Crc32c.update(Crc32c.INIT, recStart, bodyLen));
+ appendWriteCount++;
+ long written = ff.write(fd, recStart, recLen, appendOffset);
+ if (written != recLen) {
+ throw new IllegalStateException("short write to " + FILE_NAME
+ + " [expected=" + recLen + ", actual=" + written + ']');
+ }
+ appendOffset += recLen;
+ size += count;
+ }
+
+ /**
+ * Writes one chunk whose entry region is ALREADY encoded in scratch at offset
+ * {@link #MAX_CHUNK_HEADER_SIZE}. Back-fills the header immediately in front of
+ * the entries -- the header is at most {@code MAX_CHUNK_HEADER_SIZE} bytes, so it
+ * always fits the reserve -- leaving header, entries and CRC one contiguous run
+ * for a single checksum and a single write.
+ */
+ private void writeChunkFromScratch(int entriesLen, int count) {
+ int hdrLen = NativeBufferWriter.varintSize(count) + NativeBufferWriter.varintSize(entriesLen);
+ long recStart = scratchAddr + MAX_CHUNK_HEADER_SIZE - hdrLen;
+ long p = NativeBufferWriter.writeVarint(recStart, count);
+ NativeBufferWriter.writeVarint(p, entriesLen);
+ flushChunk(recStart, hdrLen, entriesLen, count);
+ }
+
+ private static final class RecoveryScan {
+ private final int count;
+ private final int entriesLen;
+ private final int validLen;
+
+ private RecoveryScan(int count, int entriesLen, int validLen) {
+ this.count = count;
+ this.entriesLen = entriesLen;
+ this.validLen = validLen;
+ }
+ }
+
+ /**
+ * Zero-allocation LEB128 decoder, one instance per recovery pass -- not one
+ * per chunk. The previous {@code long[]}-returning decoder allocated once per
+ * entry, so a million-symbol recovery churned a million throwaway arrays.
+ */
+ private static final class Varint {
+ int end;
+ long value;
+
+ /**
+ * Decodes the varint at {@code buf[pos..limit)}. Returns false -- leaving
+ * {@code value}/{@code end} undefined -- when the varint is truncated (a torn
+ * tail) or runs longer than a canonical 5-byte length.
+ */
+ boolean decode(long buf, int pos, int limit) {
+ long v = 0;
+ int shift = 0;
+ int cur = pos;
+ while (cur < limit) {
+ byte b = Unsafe.getUnsafe().getByte(buf + cur);
+ cur++;
+ v |= (long) (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) {
+ value = v;
+ end = cur;
+ return true;
+ }
+ shift += 7;
+ if (shift > 35) {
+ return false; // implausible for a chunk header; treat as torn
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/RecoveredFrameAnalysis.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/RecoveredFrameAnalysis.java
new file mode 100644
index 00000000..6a7f70df
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/RecoveredFrameAnalysis.java
@@ -0,0 +1,393 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.QuietCloseable;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.std.str.Utf8s;
+
+/**
+ * Engine-owned result of the one ordered frame walk performed during recovery.
+ * It folds commit-boundary detection, delta extrema, gap detection and the raw
+ * symbol suffix into the same pass. Running state is checkpointed only at a
+ * commit-bearing frame, so a deferred orphan tail can be scanned without ever
+ * leaking its metadata or symbols into the committed result.
+ */
+final class RecoveredFrameAnalysis implements QuietCloseable {
+
+ private static final int MAX_RAW_BYTES = Integer.MAX_VALUE - 8;
+ private final long ackedFsn;
+ private final int baseline;
+ private long committedBoundaryFsn = -1L;
+ private long committedCoverage;
+ private boolean committedGap;
+ private long committedMaxDeltaEnd;
+ private long committedMaxDeltaStart;
+ private int committedRawCount;
+ private int committedRawLen;
+ private long framesVisited;
+ // Set when a gap-reset rewinds the raw write cursor, cleared at every commit
+ // checkpoint. Still set at finish() => the committed snapshot's bytes were
+ // overwritten by an uncommitted tail and must not be published. See finish().
+ private boolean hasRewoundSinceCommit;
+ private boolean runningGap;
+ private boolean runningUnackedGap;
+ private long runningMaxDeltaEnd;
+ private long runningMaxDeltaStart;
+ private long symbolEntriesVisited;
+ private long rawAddr;
+ private int rawCapacity;
+ private int runningRawCount;
+ private int runningRawLen;
+ private long runningCoverage;
+
+ RecoveredFrameAnalysis(int baseline, long ackedFsn) {
+ this.baseline = baseline;
+ this.ackedFsn = ackedFsn;
+ this.runningCoverage = baseline;
+ this.committedCoverage = baseline;
+ }
+
+ void accept(long fsn, long payload, int payloadLen) {
+ framesVisited++;
+ boolean isQwp = payloadLen >= QwpConstants.HEADER_SIZE
+ && payloadLen > QwpConstants.HEADER_OFFSET_FLAGS
+ && Unsafe.getUnsafe().getInt(payload) == QwpConstants.MAGIC_MESSAGE;
+ byte flags = isQwp
+ ? Unsafe.getUnsafe().getByte(payload + QwpConstants.HEADER_OFFSET_FLAGS)
+ : 0;
+ if (isQwp && (flags & QwpConstants.FLAG_DELTA_SYMBOL_DICT) != 0) {
+ foldDelta(fsn, payload + QwpConstants.HEADER_SIZE, payload + payloadLen);
+ }
+
+ // Only a positively identified deferred QWP frame can belong to an
+ // uncommitted tail. Short, foreign or otherwise non-QWP payloads remain
+ // retirement barriers.
+ if (!isQwp || (flags & QwpConstants.FLAG_DEFER_COMMIT) == 0) {
+ committedBoundaryFsn = fsn;
+ committedCoverage = runningCoverage;
+ committedGap = runningGap;
+ committedMaxDeltaEnd = runningMaxDeltaEnd;
+ committedMaxDeltaStart = runningMaxDeltaStart;
+ committedRawLen = runningRawLen;
+ committedRawCount = runningRawCount;
+ hasRewoundSinceCommit = false;
+ }
+ }
+
+ void addDecodedSymbolsTo(GlobalSymbolDictionary target) {
+ decodeSymbols(target);
+ }
+
+ private void decodeSymbols(GlobalSymbolDictionary target) {
+ long p = rawAddr;
+ long limit = rawAddr + committedRawLen;
+ // UnreplayableSlotException, not IllegalStateException: Sender.build() routes
+ // exactly the typed recovery exceptions to slot quarantine. An untyped throw
+ // from here escapes build() and re-fails identically on every restart -- the
+ // permanent brick quarantine exists to remove. The :238 arm is data-reachable
+ // (the suffix cap bounds the recovered dictionary's byte size).
+ for (int i = 0; i < committedRawCount; i++) {
+ long encoded = readVarint(p, limit);
+ if (encoded < 0L) {
+ throw new UnreplayableSlotException("malformed cached symbol dictionary suffix");
+ }
+ int varintLen = (int) (encoded & 7L);
+ long symbolLen = encoded >>> 3;
+ p += varintLen;
+ if (symbolLen > limit - p) {
+ throw new UnreplayableSlotException("truncated cached symbol dictionary suffix");
+ }
+ target.addRecoveredSymbol(Utf8s.stringFromUtf8Bytes(p, p + symbolLen));
+ p += symbolLen;
+ }
+ if (p != limit) {
+ throw new UnreplayableSlotException("overfilled cached symbol dictionary suffix");
+ }
+ }
+
+ int baseline() {
+ return baseline;
+ }
+
+ long commitBoundaryFsn() {
+ return committedBoundaryFsn;
+ }
+
+ long coverage() {
+ return committedGap ? -1L : committedCoverage;
+ }
+
+ long framesVisited() {
+ return framesVisited;
+ }
+
+ /**
+ * Discards native bytes accumulated only while scanning an uncommitted
+ * deferred tail. Call exactly once after the ordered recovery walk.
+ */
+ void finish() {
+ if (hasRewoundSinceCommit) {
+ // A gap-reset rewound the write cursor after the last commit checkpoint, so
+ // the deferred tail has overwritten the bytes committedRawLen/Count still
+ // describe. Publishing them would decode the TAIL's entries under the
+ // committed ids -- exactly the silent misattribution this analysis exists to
+ // prevent. Fail clean instead: no suffix, and a gap so coverage() reports -1
+ // and the producer falls back to the persisted prefix (or, when unacked
+ // frames depend on the missing ids, quarantines).
+ committedRawLen = 0;
+ committedRawCount = 0;
+ committedGap = true;
+ }
+ if (rawCapacity == committedRawLen) {
+ return;
+ }
+ if (committedRawLen == 0) {
+ Unsafe.free(rawAddr, rawCapacity, MemoryTag.NATIVE_DEFAULT);
+ rawAddr = 0L;
+ rawCapacity = 0;
+ return;
+ }
+ rawAddr = Unsafe.realloc(
+ rawAddr,
+ rawCapacity,
+ committedRawLen,
+ MemoryTag.NATIVE_DEFAULT);
+ rawCapacity = committedRawLen;
+ }
+
+ long maxDeltaEnd() {
+ return committedMaxDeltaEnd;
+ }
+
+ long maxDeltaStart() {
+ return committedMaxDeltaStart;
+ }
+
+ long rawAddr() {
+ return rawAddr;
+ }
+
+ int rawCount() {
+ return committedRawCount;
+ }
+
+ int rawLen() {
+ return committedRawLen;
+ }
+
+ int rawCapacity() {
+ return rawCapacity;
+ }
+
+ /**
+ * Releases the cached suffix after a foreground producer and its one send
+ * loop have both consumed it. Recovery metadata remains available, but the
+ * raw entries must not be requested again.
+ */
+ void releaseRawStorage() {
+ if (rawAddr != 0L) {
+ Unsafe.free(rawAddr, rawCapacity, MemoryTag.NATIVE_DEFAULT);
+ rawAddr = 0L;
+ rawCapacity = 0;
+ }
+ runningRawLen = 0;
+ runningRawCount = 0;
+ committedRawLen = 0;
+ committedRawCount = 0;
+ }
+
+ long symbolEntriesVisited() {
+ return symbolEntriesVisited;
+ }
+
+ @Override
+ public void close() {
+ releaseRawStorage();
+ }
+
+ /**
+ * Appends one contiguous run of {@code count} wire entries -- {@code [len][utf8]}
+ * repeated, exactly as a delta section carries them -- in a single copy. Callers
+ * pass a whole frame's new-symbol suffix at once; see {@link #foldDelta} for why
+ * that suffix is always contiguous.
+ */
+ private void appendRaw(long addr, int len, int count) {
+ long required = (long) runningRawLen + len;
+ if (required > MAX_RAW_BYTES) {
+ throw new UnreplayableSlotException("recovered symbol dictionary suffix exceeds maximum size "
+ + "[required=" + required + ", max=" + MAX_RAW_BYTES + ']');
+ }
+ if (required > rawCapacity) {
+ long newCapacity = Math.max(required, Math.max(4_096L, (long) rawCapacity * 2L));
+ if (newCapacity > MAX_RAW_BYTES) {
+ newCapacity = MAX_RAW_BYTES;
+ }
+ rawAddr = Unsafe.realloc(rawAddr, rawCapacity, (int) newCapacity, MemoryTag.NATIVE_DEFAULT);
+ rawCapacity = (int) newCapacity;
+ }
+ Unsafe.getUnsafe().copyMemory(addr, rawAddr + runningRawLen, len);
+ runningRawLen += len;
+ runningRawCount += count;
+ }
+
+ private void foldDelta(long fsn, long p, long limit) {
+ long encodedStart = readVarint(p, limit);
+ if (encodedStart < 0L) {
+ markGap(fsn);
+ return;
+ }
+ int startLen = (int) (encodedStart & 7L);
+ long deltaStart = encodedStart >>> 3;
+ p += startLen;
+ if (deltaStart > runningMaxDeltaStart) {
+ runningMaxDeltaStart = deltaStart;
+ }
+
+ long encodedCount = readVarint(p, limit);
+ if (encodedCount < 0L) {
+ markGap(fsn);
+ return;
+ }
+ int countLen = (int) (encodedCount & 7L);
+ long deltaCount = encodedCount >>> 3;
+ p += countLen;
+ long deltaEnd = deltaCount > Long.MAX_VALUE - deltaStart
+ ? Long.MAX_VALUE
+ : deltaStart + deltaCount;
+ if (deltaEnd > runningMaxDeltaEnd) {
+ runningMaxDeltaEnd = deltaEnd;
+ }
+ if (runningGap) {
+ // A full dictionary is a new self-sufficient epoch, but it may only
+ // repair a gap that is entirely behind the durable ACK watermark.
+ // If any gapped frame will replay first, accepting this reset would
+ // hide the unsafe wire-order gap and let the server observe missing
+ // ids before it reaches the full frame.
+ if (deltaStart != 0L || runningUnackedGap) {
+ return;
+ }
+ runningGap = false;
+ runningCoverage = baseline;
+ // Rewinding the write cursor to 0 means the NEXT appendRaw overwrites
+ // [0, committedRawLen) in place -- bytes the committed snapshot still
+ // counts. That is harmless when a commit-bearing frame follows (accept()
+ // re-checkpoints and clears this flag), but a reset inside an uncommitted
+ // deferred TAIL never gets that refresh, and finish() would then hand out
+ // the old counts over the tail's bytes: silent symbol misattribution.
+ hasRewoundSinceCommit = true;
+ runningRawLen = 0;
+ runningRawCount = 0;
+ }
+ if (deltaStart > runningCoverage) {
+ markGap(fsn);
+ return;
+ }
+ // The segment scan already CRC-validated this frame. When its entire
+ // range is covered, entry parsing cannot extend recovery state, so skip
+ // the cardinality-proportional payload walk.
+ if (deltaEnd <= runningCoverage) {
+ return;
+ }
+
+ // runningCoverage is loop-invariant here -- it only advances after the walk --
+ // and id ascends from deltaStart, so `id >= runningCoverage` is a step
+ // predicate: the entries this frame contributes are always ONE contiguous run
+ // at the tail of its delta section. Note where that run starts and copy it in a
+ // single memcpy once the walk succeeds, rather than paying a bound check, a
+ // capacity check and a ~12-byte copyMemory per symbol. Recovery walks the whole
+ // backlog, and the workload this feature exists for introduces a new symbol per
+ // ROW, so that is millions of stub-dispatched small copies where one bulk copy
+ // per frame does the job.
+ //
+ // Deferring the copy past the markGap bail-outs also drops the partial prefix
+ // the per-entry version used to leave behind. That residue was already
+ // unreachable -- a gap pins coverage() at -1, and a later self-sufficient frame
+ // resets runningRawLen/runningRawCount -- so not writing it is equivalent, and
+ // leaves less state to reason about.
+ long id = deltaStart;
+ long suffixStart = 0L;
+ int suffixCount = 0;
+ for (long i = 0; i < deltaCount; i++, id++) {
+ symbolEntriesVisited++;
+ long entryStart = p;
+ long encodedLen = readVarint(p, limit);
+ if (encodedLen < 0L) {
+ markGap(fsn);
+ return;
+ }
+ int varintLen = (int) (encodedLen & 7L);
+ long symbolLen = encodedLen >>> 3;
+ p += varintLen;
+ if (symbolLen > limit - p) {
+ markGap(fsn);
+ return;
+ }
+ p += symbolLen;
+ if (id >= runningCoverage) {
+ if (suffixCount == 0) {
+ suffixStart = entryStart;
+ }
+ suffixCount++;
+ }
+ }
+ if (suffixCount > 0) {
+ appendRaw(suffixStart, (int) (p - suffixStart), suffixCount);
+ }
+ if (deltaEnd > runningCoverage) {
+ runningCoverage = deltaEnd;
+ }
+ }
+
+ private void markGap(long fsn) {
+ runningGap = true;
+ if (fsn > ackedFsn) {
+ runningUnackedGap = true;
+ }
+ }
+
+ /**
+ * Returns {@code (value << 3) | encodedByteCount}, or {@code -1} for an
+ * unterminated/oversized 32-bit protocol varint.
+ */
+ private static long readVarint(long p, long limit) {
+ long value = 0L;
+ int shift = 0;
+ int bytes = 0;
+ while (p < limit && bytes < 5) {
+ byte b = Unsafe.getUnsafe().getByte(p++);
+ value |= (long) (b & 0x7F) << shift;
+ bytes++;
+ if ((b & 0x80) == 0) {
+ return (value << 3) | bytes;
+ }
+ shift += 7;
+ }
+ return -1L;
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java
index 91deddd3..697b3b3d 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java
@@ -69,6 +69,11 @@ public final class SegmentManager implements QuietCloseable {
AtomicIntegerFieldUpdater.newUpdater(RingEntry.class, "state");
private static final Logger LOG = LoggerFactory.getLogger(SegmentManager.class);
private static final int MAX_TRIMS_PER_RING_PASS = 64;
+ // The minimum working set a ring needs to keep making progress: the active
+ // segment plus one hot spare to rotate into. Below this a producer cannot
+ // advance at all, so the cap check must never hold a ring here (see
+ // livenessFloorBytes).
+ private static final int MIN_LIVE_SEGMENTS = 2;
private static final long TRIM_RETRY_INITIAL_NANOS = 4_000_000L;
private static final long TRIM_RETRY_MAX_NANOS = 1_024_000_000L;
private static final int TRIM_RETRY_NONE = 0;
@@ -79,6 +84,19 @@ public final class SegmentManager implements QuietCloseable {
private final AtomicLong fileGeneration = new AtomicLong();
private final FilesFacade filesFacade;
+ // Per-ring segment bytes below which the cap check never refuses to
+ // provision: MIN_LIVE_SEGMENTS * segmentSizeBytes, clamped against
+ // overflow. Segment bytes are reclaimable by ACK-driven trim, so refusing
+ // on them is productive backpressure that clears itself. Side-file bytes
+ // are NOT -- the .symbol-dict is lifetime-monotonic and nothing shrinks it
+ // -- so once they alone pushed a ring under the cap, no ack could ever
+ // free the shortfall and the producer stalled permanently, across
+ // restarts, with the disk-full warning pointing at a trim that cannot
+ // help. Guaranteeing the minimum working set turns that deadlock into
+ // ordinary backpressure: the ring cycles between one and two segments as
+ // acks arrive, ingestion continues, and the cap degrades to best-effort by
+ // exactly the dictionary's overshoot rather than stopping the pipeline.
+ private final long livenessFloorBytes;
private final Object lock = new Object();
private final long maxTotalBytes;
// Reused by the manager worker thread to build spare-segment paths
@@ -249,6 +267,13 @@ public SegmentManager(
this.segmentSizeBytes = segmentSizeBytes;
this.pollNanos = pollNanos;
this.maxTotalBytes = maxTotalBytes;
+ // Clamp rather than multiply blind: segmentSizeBytes is user-supplied
+ // and only bounded below, so a pathological value would wrap the
+ // product negative and make the floor test trivially false -- silently
+ // restoring the deadlock this field exists to remove.
+ this.livenessFloorBytes = segmentSizeBytes > Long.MAX_VALUE / MIN_LIVE_SEGMENTS
+ ? Long.MAX_VALUE
+ : segmentSizeBytes * MIN_LIVE_SEGMENTS;
this.ticks = ticks;
}
@@ -600,6 +625,23 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark) {
* A positive interval requires disk-backed store-and-forward mode.
*/
public void register(SegmentRing ring, String dir, AckWatermark watermark, long syncIntervalNanos) {
+ register(ring, dir, watermark, syncIntervalNanos, null);
+ }
+
+ /**
+ * Same as {@link #register(SegmentRing, String, AckWatermark, long)} but
+ * also wires a live gauge of the slot's {@code .symbol-dict} side-file
+ * bytes. The manager reads the gauge at every provisioning cap check so
+ * the dictionary counts against {@code sf_max_total_bytes} alongside the
+ * {@code .sfa} segments; a {@code null} gauge contributes zero (memory
+ * mode, degraded full-dict sessions).
+ *
+ * The gauge is invoked with the manager's internal lock held, so it must
+ * be wait-free and must not throw: a throwing gauge terminates the
+ * manager worker for every registered ring, and a blocking gauge stalls
+ * register/deregister for all slots.
+ */
+ public void register(SegmentRing ring, String dir, AckWatermark watermark, long syncIntervalNanos, LongSupplier sideFileBytes) {
if (syncIntervalNanos < 0L) {
throw new IllegalArgumentException("syncIntervalNanos must not be negative");
}
@@ -619,7 +661,7 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long
// the in-flight mmap. Memory-mode rings have no dir; nothing to scan.
long minNextGeneration = dir == null ? -1L : scanMaxGeneration(dir) + 1L;
Runnable managerWakeup = this::wakeWorker;
- RingEntry e = new RingEntry(ring, dir, watermark, syncIntervalNanos, ticks.getAsLong());
+ RingEntry e = new RingEntry(ring, dir, watermark, sideFileBytes, syncIntervalNanos, ticks.getAsLong());
// ObjList.add either throws before storing e or makes the entry visible.
// Once visible, only non-throwing state commits may remain.
synchronized (lock) {
@@ -655,6 +697,35 @@ public SegmentRing getInServiceRingForTesting() {
return entry == null ? null : entry.ring;
}
+ // Callers must hold `lock` (the rings list is mutated under it). The
+ // side-file bytes are read live from each slot's gauge instead of being
+ // folded into the incremental totalBytes counter: the dictionary grows
+ // out-of-band on producer threads, so an incremental mirror would
+ // drift, while a live read cannot. Each gauge is WAIT-FREE and takes no
+ // lock at all -- PersistedSymbolDict.occupiedDiskBytes() is a pair of
+ // volatile reads -- and it must stay that way. This runs with `lock` held,
+ // on the worker that drives provisioning and trim for every registered ring,
+ // while a producer can hold that dictionary's monitor across ff.allocate
+ // and mmap. A gauge that took the monitor would park the whole manager
+ // behind one producer's append I/O.
+ private long sideFileBytesLocked() {
+ long total = 0L;
+ for (int i = 0, n = rings.size(); i < n; i++) {
+ LongSupplier gauge = rings.get(i).sideFileBytes;
+ if (gauge != null) {
+ total += gauge.getAsLong();
+ }
+ }
+ return total;
+ }
+
+ @TestOnly
+ public long getCapAccountedBytesForTesting() {
+ synchronized (lock) {
+ return totalBytes + sideFileBytesLocked();
+ }
+ }
+
@TestOnly
public long getTotalBytesForTesting() {
synchronized (lock) {
@@ -887,73 +958,117 @@ private boolean serviceRing0(RingEntry e) {
// DISK_FULL_LOG_THROTTLE_NANOS so a sustained-disk-full state
// doesn't drown the log.
if (e.ring.needsHotSpare()) {
- // Snapshot totalBytes under lock — register/deregister can mutate
- // it from caller threads. Heavy provisioning I/O happens outside
- // the lock; the post-install commit re-acquires it.
+ // Snapshot totalBytes under lock -- register/deregister can mutate
+ // it from caller threads -- and add the live side-file bytes of
+ // every registered slot, so .symbol-dict growth counts against the
+ // cap. Heavy provisioning I/O happens outside the lock; the
+ // post-install commit re-acquires it.
long observedTotal;
+ long observedSideFileBytes;
synchronized (lock) {
- observedTotal = totalBytes;
+ observedSideFileBytes = sideFileBytesLocked();
+ observedTotal = totalBytes + observedSideFileBytes;
}
- if (observedTotal + segmentSizeBytes > maxTotalBytes) {
+ boolean withinCap = observedTotal + segmentSizeBytes <= maxTotalBytes;
+ // Liveness floor. Refusing on segment bytes is productive -- an ack
+ // trims a sealed segment and the shortfall clears. Refusing on
+ // side-file bytes is not: the dictionary never shrinks, so a ring
+ // held below its minimum working set by them would never rotate
+ // again, on this run or any later one. Provision anyway while this
+ // ring is under the floor, and account it honestly below.
+ long ringSegmentBytes = withinCap ? 0L : e.ring.totalSegmentBytes();
+ boolean belowLivenessFloor = !withinCap && ringSegmentBytes < livenessFloorBytes;
+ if (!withinCap && !belowLivenessFloor) {
long now = System.nanoTime();
if (now - lastDiskFullLogNs >= DISK_FULL_LOG_THROTTLE_NANOS) {
LOG.warn("SF {}: cannot provision spare in {} "
- + "(totalBytes={}, cap={}, segmentSize={}). "
- + "Producer is backpressured until ACK-driven trim frees space.",
+ + "(totalBytes={}, sideFileBytes={}, cap={}, segmentSize={}). "
+ + "Producer is backpressured until ACK-driven trim frees segment "
+ + "space; side-file bytes are not reclaimed by trim.",
memoryMode ? "memory cap reached" : "disk-full",
- memoryMode ? "
- * Recovery-time helper: locates the last commit-bearing QWP frame below a
- * potentially orphaned FLAG_DEFER_COMMIT tail left behind by a producer
- * that crashed (or closed) mid-transaction. Call before the I/O loop and
- * producer start appending; the walk is not synchronized against appends
- * into the active segment. See
- * {@link MmapSegment#findLastFrameFsnWithoutPayloadFlag} for the
- * positive-identification contract: frames that do not parse as protocol
- * messages count as commit-bearing (retirement barriers), never as
- * trimmable.
+ * Replaces the former {@code findLastFsnWithoutPayloadFlag} walk: the
+ * deferred-commit tail scan is one of several verdicts the single fold now
+ * produces, so the ring is walked once rather than once per question. Call
+ * before the I/O loop and producer start appending; the walk is not
+ * synchronized against appends into the active segment.
*/
- public synchronized long findLastFsnWithoutPayloadFlag(int flagsOffset, int flagMask, int headerMagic, int minPayloadLen) {
- long best = -1L;
- for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) {
- long fsn = sealedSegments.get(i).findLastFrameFsnWithoutPayloadFlag(flagsOffset, flagMask, headerMagic, minPayloadLen);
- if (fsn > best) {
- best = fsn;
+ synchronized RecoveredFrameAnalysis analyzeRecovery(int symbolBaseline) {
+ RecoveredFrameAnalysis analysis = new RecoveredFrameAnalysis(symbolBaseline, ackedFsn);
+ try {
+ for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) {
+ sealedSegments.get(i).scanRecovery(analysis);
}
+ active.scanRecovery(analysis);
+ analysis.finish();
+ return analysis;
+ } catch (Throwable t) {
+ analysis.close();
+ throw t;
}
- long fsn = active.findLastFrameFsnWithoutPayloadFlag(flagsOffset, flagMask, headerMagic, minPayloadLen);
- return Math.max(best, fsn);
}
public MmapSegment getActive() {
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfOperationalException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfOperationalException.java
index 308b99ee..ba250dc0 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfOperationalException.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfOperationalException.java
@@ -35,4 +35,8 @@ public final class SfOperationalException extends IllegalStateException {
public SfOperationalException(String message) {
super(message);
}
+
+ public SfOperationalException(String message, Throwable cause) {
+ super(message, cause);
+ }
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java
index 17264c5a..e26aa6f3 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java
@@ -26,20 +26,27 @@
import io.questdb.client.std.Compat;
import io.questdb.client.std.Files;
+import io.questdb.client.std.FilesFacade;
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.QuietCloseable;
import io.questdb.client.std.Unsafe;
import org.jetbrains.annotations.TestOnly;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.nio.file.Paths;
/**
- * Advisory exclusive lock for a single SF slot directory.
+ * Advisory exclusive locks for a single SF slot.
*
- * One {@code .lock} file per slot, held via {@code flock}/{@code LockFileEx}
- * for the entire lifetime of the engine that owns the slot. Normal teardown
- * explicitly unlocks it before closing the fd; hard process exit remains a
- * backstop because the kernel cleans up file locks for terminated processes.
+ * {@link #acquire(String)} locks a {@code .lock} file inside the slot directory
+ * for the entire lifetime of the engine that owns it. {@link #acquireLogical(String)}
+ * locks a sibling file under the parent SF directory for short-lived pathname
+ * transitions and orphan adoption; because it is outside the slot directory,
+ * it remains stable if that directory is renamed. Both use
+ * {@code flock}/{@code LockFileEx}. Normal teardown explicitly unlocks the
+ * descriptor before closing it; hard process exit remains a backstop because
+ * the kernel cleans up file locks for terminated processes.
*
* The holder's PID is written to a sibling {@code .lock.pid} file at
* acquisition time. A failed acquisition reads it back so the error message
@@ -60,6 +67,7 @@ public final class SlotLock implements QuietCloseable {
private static final int DEAD_FD_FOR_TESTING = 1_000_000_000;
private static final String LOCK_FILE_NAME = ".lock";
private static final String LOCK_PID_FILE_NAME = ".lock.pid";
+ private static final String LOGICAL_LOCK_DIR_NAME = ".slot-locks";
private static final Object RELEASE_RETRY_LOCK = new Object();
private static SlotLock releaseRetryHead;
private final String slotDir;
@@ -89,48 +97,144 @@ public static SlotLock acquire(String slotDir) {
* parent directory before any segment file is created.
*/
public static SlotLock acquire(String slotDir, boolean syncParentDirectory) {
- if (slotDir == null || slotDir.isEmpty()) {
- throw new IllegalArgumentException("slotDir must not be empty");
- }
+ validateSlotDir(slotDir);
// Construction cleanup may have retained locks after explicit unlock
// failures. Drive every pending owner before opening a new descriptor.
// Path text cannot identify a physical file portably (symlinks and
// Windows case aliases are counterexamples), while the pending list is
// cold, error-only state and normally empty.
retryPendingReleases();
- if (!Files.exists(slotDir)) {
- int rc = Files.mkdir(slotDir, Files.DIR_MODE_DEFAULT);
- if (rc != 0) {
- throw new SfOperationalException(
- "could not create slot dir: " + slotDir + " rc=" + rc);
- }
- }
+ // DIR_MODE_DEFAULT is right here: one process creates its own slot
+ // directory and only that process writes inside it.
+ ensureDirectory(FilesFacade.INSTANCE, slotDir, "slot dir", Files.DIR_MODE_DEFAULT);
if (syncParentDirectory && Files.fsyncParentDir(slotDir) != 0) {
throw new SfOperationalException(
"could not sync parent directory for SF slot: " + slotDir);
}
String lockPath = slotDir + "/" + LOCK_FILE_NAME;
String pidPath = slotDir + "/" + LOCK_PID_FILE_NAME;
- int fd = Files.openRW(lockPath);
+ return acquireAt(FilesFacade.INSTANCE, slotDir, lockPath, pidPath);
+ }
+
+ /**
+ * Acquires the stable logical lock for {@code slotDir}, creating its
+ * {@code .slot-locks} parent at {@link Files#DIR_MODE_DEFAULT}. Unlike the
+ * directory-local {@code .lock}, this lock is anchored in the parent SF
+ * directory, so renaming the slot cannot move the lock inode away from the
+ * logical slot name it guards.
+ *
+ * Callers use this as a short-lived transition/adoption lock, always before
+ * acquiring the directory-local lock. In particular it must cover an
+ * unreplayable slot's close -> rename -> recreate transition, preventing a
+ * queued orphan drainer from adopting the renamed inode and later touching
+ * the fresh directory through the old pathname.
+ */
+ public static SlotLock acquireLogical(String slotDir) {
+ return acquireLogical(FilesFacade.INSTANCE, slotDir);
+ }
+
+ /** Facade-aware variant used to exercise logical-lock I/O failures. */
+ @TestOnly
+ public static SlotLock acquireLogical(FilesFacade ff, String slotDir) {
+ validateSlotDir(slotDir);
+ // Same pre-step as acquire(): a logical lock this process retained after
+ // an unconfirmed unlock would otherwise contend with its own successor.
+ retryPendingReleases();
+ String[] paths = resolveLogicalLock(slotDir);
+ if (paths == null) {
+ throw new IllegalArgumentException(
+ "slotDir must contain a parent and slot name: " + slotDir);
+ }
+ ensureDirectory(ff, paths[0], "logical slot lock dir", Files.DIR_MODE_DEFAULT);
+ return acquireAt(ff, slotDir, paths[1], paths[2]);
+ }
+
+ /**
+ * Best-effort removal of the parent-anchored logical lock files
+ * ({@code
+ * The unlink is best-effort and safe: the retiring engine still holds the
+ * slot's directory-local {@link #acquire} lock (the real multi-writer guard)
+ * across this cleanup, and fully-drained retirement performs no rename, so
+ * the logical lock -- which only guards the close/rename/recreate transition
+ * -- is not in use. An orphan drainer momentarily mid-{@link #acquireLogical}
+ * fails its immediately-following candidacy / directory-lock check (the slot
+ * is gone) and backs off. Unlike {@link #acquireLogical}, an unusable
+ * {@code slotDir} is a silent no-op here rather than a throw.
+ */
+ public static void removeOrphanLogical(String slotDir) {
+ removeOrphanLogical(FilesFacade.INSTANCE, slotDir);
+ }
+
+ /** Facade-aware variant of {@link #removeOrphanLogical(String)}. */
+ public static void removeOrphanLogical(FilesFacade ff, String slotDir) {
+ if (slotDir == null || slotDir.isEmpty()) {
+ return;
+ }
+ String[] paths = resolveLogicalLock(slotDir);
+ if (paths == null) {
+ return;
+ }
+ // Unlink ONLY while holding the lock. Removing a lock file another party holds
+ // does not release that party's flock, but it does free the pathname, so the
+ // next acquirer creates a SECOND inode and locks it successfully -- two owners
+ // of a lock whose only job is mutual exclusion.
+ //
+ // The retiring engine's directory-local lock does not stand in for this. It says
+ // nothing about the LOGICAL lock, which Sender.build() holds across its whole
+ // construct -> connect -> quarantine transition, in a frame ABOVE this one: an
+ // ordinary failed connect closes the engine from inside that scope, reaches here,
+ // and used to unlink the very file build() was holding. Acquiring first narrows
+ // that to a single-syscall window instead of silent double-ownership.
+ SlotLock guard;
+ try {
+ guard = acquireAt(ff, slotDir, paths[1], paths[2]);
+ } catch (Throwable t) {
+ // Contended (someone holds it, possibly a caller above us) or unopenable.
+ // Leaving the pair on disk is the safe outcome -- a live holder's lock must
+ // outlive our cleanup. The next fully-drained retirement reclaims it.
+ return;
+ }
+ try {
+ // Sidecar first: once paths[1] (.lock) is unlinked, a racing acquirer can
+ // create a fresh lock inode and write its own .lock.pid inside our window;
+ // removing the pid file first means no successor's sidecar can be hit.
+ ff.remove(paths[2]);
+ ff.remove(paths[1]);
+ } finally {
+ guard.close();
+ }
+ }
+
+ private static SlotLock acquireAt(FilesFacade ff, String slotDir, String lockPath, String pidPath) {
+ int fd = ff.openRW(lockPath);
if (fd < 0) {
throw new SfOperationalException(
"could not open slot lock file: " + lockPath);
}
boolean ok = false;
try {
- int rc = Files.lock(fd);
+ int rc = ff.lock(fd);
if (rc != 0) {
String holder = readHolder(pidPath);
throw new SlotLockContentionException(
"sf slot already in use by another process [slot="
+ slotDir + ", holder=" + holder + "]");
}
- writePid(pidPath);
+ writePid(ff, pidPath);
ok = true;
return new SlotLock(slotDir, fd);
} finally {
if (!ok) {
- Files.close(fd);
+ ff.close(fd);
}
}
}
@@ -186,6 +290,49 @@ public static String probeHolder(String slotDir) {
return null;
}
+ private static void ensureDirectory(FilesFacade ff, String path, String description, int mode) {
+ if (!ff.exists(path)) {
+ int rc = ff.mkdir(path, mode);
+ // Multiple senders may create the shared parent lock directory
+ // concurrently. Treat EEXIST as success, just as the builder does
+ // for the SF root itself.
+ if (rc != 0 && !ff.exists(path)) {
+ throw new SfOperationalException(
+ "could not create " + description + ": " + path + " rc=" + rc);
+ }
+ }
+ }
+
+ /**
+ * Resolves the parent-anchored logical lock layout for {@code slotDir}:
+ * {@code [0]} the {@code .slot-locks} directory, {@code [1]} the
+ * {@code
+ * A distinct type, rather than a message match, because the difference between "this slot
+ * is unreplayable" and any other {@link LineSenderException} out of the connect path is the
+ * difference between setting a slot aside and silently discarding one that was fine.
+ *
+ * It stays a {@code LineSenderException} so that a caller which does NOT handle it -- a test
+ * constructing the sender directly, say -- keeps the old fail-clean behaviour rather than
+ * seeing a new checked type. {@code Sender.build()} and {@code BackgroundDrainer} both treat
+ * it as recoverable: the former sets the slot aside and starts the producer on a fresh one,
+ * the latter drops the {@code .failed} sentinel so the next orphan scan skips the slot.
+ */
+public class UnreplayableSlotException extends LineSenderException {
+
+ public UnreplayableSlotException(CharSequence message) {
+ super(message);
+ }
+}
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 237839d9..074cf0e3 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
@@ -80,6 +80,20 @@ public final class QwpConstants {
* advertise an oversized name length.
*/
public static final int MAX_COLUMN_NAME_LENGTH = 127;
+ /**
+ * Maximum number of distinct symbol values a sender's global symbol dictionary
+ * may hold. Mirrors the server-side {@code QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE}
+ * (questdb OSS): the ingress decoder rejects any delta or catch-up frame whose
+ * {@code deltaStartId + deltaCount} exceeds this limit, and classifies the
+ * rejection as a parse error, which the sender treats as terminal. The producer
+ * therefore enforces the cap at registration time ({@code GlobalSymbolDictionary#getOrAddSymbol}),
+ * before the row is buffered, so everything already buffered always references
+ * ids the server will accept.
+ *
+ * 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;
/**
* 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/cutlass/qwp/protocol/QwpTableBuffer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java
index 0947e5e1..1bfd2617 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java
@@ -266,20 +266,50 @@ public boolean hasInProgressRow() {
*
* This should be called after all column values for the current row have been set.
*/
- public void nextRow() {
- // Reset sequential access cursor for the next row
- columnAccessCursor = 0;
- inProgressColumnCount = 0;
- // Ensure all columns have the same row count
+ public long nextRow() {
+ return nextRow(0, Long.MAX_VALUE);
+ }
+
+ /**
+ * Advances to the next row, enforcing a per-row byte budget.
+ *
+ * Pads unset columns with nulls and sums the buffered bytes in the same
+ * pass, then commits the row only if the row's bytes -- the new total
+ * minus {@code snapshotBytes}, padding included -- fit {@code maxRowBytes}.
+ * On an oversize row it throws BEFORE the commit motion, so
+ * {@link #cancelCurrentRow()} undoes the row's value writes and the
+ * padding nulls alike; previously committed rows are untouched.
+ *
+ * @param snapshotBytes the total returned by the previous commit; the
+ * baseline the row's own bytes are measured against
+ * @param maxRowBytes the budget; pass {@link Long#MAX_VALUE} for none
+ * @return the new total buffered bytes across all columns
+ */
+ public long nextRow(long snapshotBytes, long maxRowBytes) {
+ // Ensure all columns have the same row count; sum the buffered bytes in
+ // the same pass so the caller does not need a second O(columns) walk to
+ // account the row (sendRow used to call getBufferedBytes() again here).
+ long bytes = 0;
for (int i = 0, n = columns.size(); i < n; i++) {
ColumnBuffer col = fastColumns[i];
// If column wasn't set for this row, add a null
while (col.size < rowCount + 1) {
col.addNull();
}
+ bytes += col.getBufferedBytes();
}
+ long rowBytes = bytes - snapshotBytes;
+ if (rowBytes > maxRowBytes) {
+ throw new LineSenderException("row too large for server batch cap")
+ .put(" [rowBytes=").put(rowBytes)
+ .put(", serverMaxBatchSize=").put(maxRowBytes).put(']');
+ }
+ // Reset sequential access cursor for the next row
+ columnAccessCursor = 0;
+ inProgressColumnCount = 0;
rowCount++;
committedColumnCount = columns.size();
+ return 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 de8cce43..c9529a13 100644
--- a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java
+++ b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java
@@ -77,6 +77,7 @@ public final class ConfigSchema {
// (one vocabulary, side-owned application).
str("max_frame_rejections", Side.INGRESS);
str("poison_min_escalation_window_millis", Side.INGRESS);
+ str("catch_up_cap_gap_min_escalation_window_millis", Side.INGRESS);
str("max_name_len", Side.INGRESS);
str("reconnect_initial_backoff_millis", Side.INGRESS);
str("reconnect_max_backoff_millis", Side.INGRESS);
diff --git a/core/src/main/java/io/questdb/client/impl/SenderPool.java b/core/src/main/java/io/questdb/client/impl/SenderPool.java
index 91ed7019..a8a10d62 100644
--- a/core/src/main/java/io/questdb/client/impl/SenderPool.java
+++ b/core/src/main/java/io/questdb/client/impl/SenderPool.java
@@ -26,11 +26,13 @@
import io.questdb.client.Sender;
import io.questdb.client.SenderConnectionListener;
+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.BackgroundDrainerListener;
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLockContentionException;
import io.questdb.client.std.Files;
@@ -152,6 +154,13 @@ public final class SenderPool implements AutoCloseable {
private final BackgroundDrainerListener drainerListener;
private final SenderErrorHandler errorHandler;
private final long idleTimeoutMillis;
+ // Delivery channel for recovery-delegate errors that pass the
+ // isRecoveryEventUserRelevant filter. Pool-owned so a slow user handler
+ // can never stall the recovery driver / housekeeper thread or overrun
+ // its stop budget; the dispatcher thread starts lazily on first offer,
+ // so pools that never hit a recovery event pay zero thread cost. Null
+ // when the user registered no errorHandler or SF is off.
+ private final SenderErrorDispatcher recoveryErrorDispatcher;
// Test seam. Production builds delegates via defaultSender(); white-box
// tests in io.questdb.client.test.impl reach the package-private
// constructor by reflection to inject a factory that throws a non-
@@ -506,6 +515,10 @@ private SenderPool(
this.slotBaseId = this.storeAndForward ? probe.getConfiguredSenderId() : null;
this.sfDir = this.storeAndForward ? probe.getConfiguredSfDir() : null;
this.slotInUse = this.storeAndForward ? new boolean[maxSize] : null;
+ this.recoveryErrorDispatcher = (errorHandler != null && this.storeAndForward)
+ ? new SenderErrorDispatcher(errorHandler, SenderErrorDispatcher.DEFAULT_CAPACITY,
+ "qdb-sf-pool-recovery-errors")
+ : null;
// Pre-warm minSize connections. Pre-warm runs single-threaded in the
// constructor, so slots 0..minSize-1 are reserved directly.
int built = 0;
@@ -1564,6 +1577,11 @@ public void close() {
// abort the loop and strand the remaining delegates unclosed.
}
}
+ // After delegate teardown so a quarantine fired by a late recovery
+ // step still gets its bounded (100 ms) delivery window.
+ if (recoveryErrorDispatcher != null) {
+ recoveryErrorDispatcher.close();
+ }
}
/**
@@ -1961,6 +1979,20 @@ private Sender defaultRecoverySender(int slotIndex) {
return buildManagedSlotSender(slotIndex, true);
}
+ private Sender.LineSenderBuilder applyRecoveryCallbacks(Sender.LineSenderBuilder builder) {
+ if (recoveryErrorDispatcher != null) {
+ builder.errorHandler(new SenderErrorHandler() {
+ @Override
+ public void onError(SenderError error) {
+ if (isRecoveryEventUserRelevant(error)) {
+ recoveryErrorDispatcher.offer(error);
+ }
+ }
+ });
+ }
+ return builder;
+ }
+
// Applies the user-supplied ingest callbacks to a sender builder. Null
// callbacks are skipped so the sender keeps its loud-not-silent default.
private Sender.LineSenderBuilder applyUserCallbacks(Sender.LineSenderBuilder builder) {
@@ -1976,6 +2008,17 @@ private Sender.LineSenderBuilder applyUserCallbacks(Sender.LineSenderBuilder bui
return builder;
}
+ // Provenance, not severity: "did a server judge these bytes, or is the
+ // client reporting they are gone?" A recovery delegate's own environment
+ // troubles -- the never-connected auth / durable-ack TERMINALs that repeat
+ // roughly once a second while a misconfiguration lasts -- all carry
+ // NO_STATUS_BYTE and stay suppressed; a plain transport failure dispatches
+ // no SenderError at all, so an unreachable server costs this path nothing.
+ private static boolean isRecoveryEventUserRelevant(SenderError e) {
+ return e.getCategory() == SenderError.Category.DATA_LOSS
+ || e.getServerStatusByte() != SenderError.NO_STATUS_BYTE;
+ }
+
private Sender buildManagedSlotSender(int slotIndex, boolean forRecovery) {
if (!storeAndForward) {
return applyUserCallbacks(Sender.builder(configurationString)).build();
@@ -2032,9 +2075,21 @@ private Sender buildManagedSlotSender(int slotIndex, boolean forRecovery) {
// returns).
builder.drainOrphans(false);
}
- // Recovery delegates are internal, short-lived, OFF-mode drain senders;
- // don't surface their connect/error events to the user's callbacks.
- return (forRecovery ? builder : applyUserCallbacks(builder)).build();
+ // Recovery delegates drain the user's OWN data from a previous run, so
+ // the two things they can say about it -- "it was abandoned"
+ // (DATA_LOSS from a build()-time quarantine) and "the server rejected
+ // it" (a NACK carrying its wire status byte) -- must reach the user's
+ // errorHandler: this client ships slf4j-api with no binding, so
+ // LOG.error alone can announce them nowhere. What stays excluded is
+ // the delegate's own environment noise: connection events (up to one
+ // sweep per second while a slot stays stranded) and the
+ // never-connected auth / durable-ack TERMINALs, which the recovery
+ // scan already logs and dedupes per slot. See applyRecoveryCallbacks:
+ // delivery is filtered on provenance and routed through the pool's own
+ // SenderErrorDispatcher so a slow handler cannot stall the recovery
+ // driver or housekeeper thread. connectionListener and drainerListener
+ // remain unset on recovery builds.
+ return (forRecovery ? applyRecoveryCallbacks(builder) : applyUserCallbacks(builder)).build();
}
/**
diff --git a/core/src/main/java/io/questdb/client/std/Crc32c.java b/core/src/main/java/io/questdb/client/std/Crc32c.java
index d0a2e6a8..738efb1b 100644
--- a/core/src/main/java/io/questdb/client/std/Crc32c.java
+++ b/core/src/main/java/io/questdb/client/std/Crc32c.java
@@ -46,6 +46,7 @@
public final class Crc32c {
/** Seed value to start a fresh CRC-32C accumulation. */
public static final int INIT = 0;
+ private static final int[] CRC32C_TABLE = buildCrc32cTable();
private Crc32c() {
}
@@ -65,6 +66,73 @@ private Crc32c() {
*/
public static native int update(int seed, long addr, long len);
+ /**
+ * Java/Unsafe slice-by-8 CRC-32C for memory that can fault while it is read,
+ * such as a recovery mmap over a sparse or concurrently truncated file.
+ * Keeping every load at an {@link Unsafe} intrinsic site lets HotSpot turn an
+ * mmap access fault into a catchable {@link InternalError}; the native
+ * {@link #update} path cannot provide that guarantee because a SIGBUS raised
+ * inside JNI aborts the JVM.
+ *
+ * The hot loop consumes each 8-byte block with two {@code getInt}s rather than
+ * eight {@code getByte}s -- 9 loads per block instead of 16, for the same table
+ * work. {@code getInt} is an {@link Unsafe} intrinsic exactly as {@code getByte}
+ * is, so the fault-catchability above is unaffected. It does make the loop
+ * little-endian, which costs nothing here: the segment and dictionary formats this
+ * checksums are already little-endian throughout (their headers are read back with
+ * {@code getInt}), and the native twin this must agree with asserts the same.
+ *
+ * @param seed previous CRC value, or {@link #INIT} to start fresh
+ * @param addr off-heap address of at least {@code len} readable bytes
+ * @param len number of bytes to consume
+ * @return the new CRC value
+ */
+ public static int updateUnsafe(int seed, long addr, long len) {
+ assert len >= 0L : "CRC length must be non-negative";
+ int crc = ~seed;
+ int[] table = CRC32C_TABLE;
+ while (len >= Long.BYTES) {
+ // Little-endian: lo holds bytes 0..3 and hi bytes 4..7, ascending from the
+ // low byte, so the per-byte table lookups below stay in wire order.
+ int lo = Unsafe.getUnsafe().getInt(addr) ^ crc;
+ int hi = Unsafe.getUnsafe().getInt(addr + Integer.BYTES);
+ crc = table[7 * 256 + (lo & 0xFF)]
+ ^ table[6 * 256 + ((lo >>> 8) & 0xFF)]
+ ^ table[5 * 256 + ((lo >>> 16) & 0xFF)]
+ ^ table[4 * 256 + (lo >>> 24)]
+ ^ table[3 * 256 + (hi & 0xFF)]
+ ^ table[2 * 256 + ((hi >>> 8) & 0xFF)]
+ ^ table[256 + ((hi >>> 16) & 0xFF)]
+ ^ table[hi >>> 24];
+ addr += Long.BYTES;
+ len -= Long.BYTES;
+ }
+ while (len-- > 0L) {
+ crc = (crc >>> 8) ^ table[(crc ^ Unsafe.getUnsafe().getByte(addr++)) & 0xFF];
+ }
+ return ~crc;
+ }
+
+ private static int[] buildCrc32cTable() {
+ int[] table = new int[8 * 256];
+ for (int n = 0; n < 256; n++) {
+ int c = n;
+ for (int k = 0; k < 8; k++) {
+ c = (c & 1) != 0 ? (0x82F63B78 ^ (c >>> 1)) : (c >>> 1);
+ }
+ table[n] = c;
+ }
+ for (int slice = 1; slice < 8; slice++) {
+ int previousOffset = (slice - 1) * 256;
+ int offset = slice * 256;
+ for (int n = 0; n < 256; n++) {
+ int previous = table[previousOffset + n];
+ table[offset + n] = (previous >>> 8) ^ table[previous & 0xFF];
+ }
+ }
+ return table;
+ }
+
static {
Os.init();
}
diff --git a/core/src/main/java/io/questdb/client/std/Files.java b/core/src/main/java/io/questdb/client/std/Files.java
index 522dddc3..47bf60d9 100644
--- a/core/src/main/java/io/questdb/client/std/Files.java
+++ b/core/src/main/java/io/questdb/client/std/Files.java
@@ -99,6 +99,14 @@ public final class Files {
*/
public static final long FAILED_MMAP_ADDRESS = -1L;
+ // Error codes consumed by isNotFoundError(): the POSIX errno and the two
+ // Windows GetLastError() values that prove a path does not exist. ENOENT
+ // is 2 on every platform the native library ships for (Linux, macOS,
+ // FreeBSD); the Windows pair matches what Rust maps to ErrorKind::NotFound.
+ private static final int ENOENT = 2;
+ private static final int ERROR_FILE_NOT_FOUND = 2;
+ private static final int ERROR_PATH_NOT_FOUND = 3;
+
private Files() {
}
@@ -236,6 +244,24 @@ public static long length(long pathPtr) {
return length0(pathPtr);
}
+ /**
+ * Whether {@code errno} -- as reported by {@link Os#errno()} immediately
+ * after a failed path-based filesystem call such as {@link #length(String)}
+ * -- PROVES the path did not exist: {@code ENOENT} on POSIX,
+ * {@code ERROR_FILE_NOT_FOUND} / {@code ERROR_PATH_NOT_FOUND} on Windows
+ * (the same codes Rust's {@code io::ErrorKind::NotFound} maps). Any other
+ * code means the call itself failed -- an EIO on a flaky disk, a permission
+ * fault -- and the path's existence is UNKNOWN; callers deciding between an
+ * "absent" and an "error" disposition must treat it as the latter, never as
+ * absence.
+ */
+ public static boolean isNotFoundError(int errno) {
+ if (Os.type == Os.WINDOWS) {
+ return errno == ERROR_FILE_NOT_FOUND || errno == ERROR_PATH_NOT_FOUND;
+ }
+ return errno == ENOENT;
+ }
+
/**
* Creates a directory at {@code path} with the given mode (POSIX-style
* permission bits, e.g. {@link #DIR_MODE_DEFAULT}). Returns 0 on success,
diff --git a/core/src/main/java/io/questdb/client/std/FilesFacade.java b/core/src/main/java/io/questdb/client/std/FilesFacade.java
index 921684a0..be090593 100644
--- a/core/src/main/java/io/questdb/client/std/FilesFacade.java
+++ b/core/src/main/java/io/questdb/client/std/FilesFacade.java
@@ -65,6 +65,21 @@ public interface FilesFacade {
int close(int fd);
+ /**
+ * The error code of this thread's most recent failed filesystem call:
+ * {@code errno} on POSIX, the saved {@code GetLastError()} value on
+ * Windows (see {@link Os#errno()}). Meaningful only when read immediately
+ * after a facade call reported failure. Lives on the facade so a
+ * fault-injecting test can pin a deterministic code next to an injected
+ * failure -- a facade that fakes {@code length(path) < 0} must also fake
+ * the errno that classifies it (e.g. via
+ * {@link Files#isNotFoundError(int)}), or the classification would read
+ * whatever the last REAL syscall left behind.
+ */
+ default int errno() {
+ return Os.errno();
+ }
+
boolean exists(String path);
void findClose(long findPtr);
@@ -89,6 +104,16 @@ default int fsyncDir(String dir) {
return Files.fsyncDir(dir);
}
+ /**
+ * Whether callers should use this facade's mmap path. The production facade
+ * returns {@code true}; wrapping fault facades retain positioned I/O unless
+ * they explicitly opt in, preserving their ability to inject short reads and
+ * writes.
+ */
+ default boolean isMmapAllowed() {
+ return this == INSTANCE;
+ }
+
/**
* Returns the current byte length of the file referenced by open descriptor
* {@code fd}, or a negative value when the descriptor cannot be statted.
@@ -118,6 +143,10 @@ default int mlock(long addr, long len) {
return Files.mlock(addr, len);
}
+ /**
+ * Maps a file region. Kept on the facade so mmap failures can be injected
+ * without relying on platform-specific filesystem behavior.
+ */
default long mmap(int fd, long len, long offset, int flags, int memoryTag) {
return Files.mmap(fd, len, offset, flags, memoryTag);
}
@@ -134,6 +163,7 @@ default int munlock(long addr, long len) {
return Files.munlock(addr, len);
}
+ /** Releases a region returned by {@link #mmap(int, long, long, int, int)}. */
default void munmap(long address, long len, int memoryTag) {
Files.munmap(address, len, memoryTag);
}
diff --git a/core/src/test/java/io/questdb/client/test/SenderErrorTest.java b/core/src/test/java/io/questdb/client/test/SenderErrorTest.java
index 486b1679..10e91d13 100644
--- a/core/src/test/java/io/questdb/client/test/SenderErrorTest.java
+++ b/core/src/test/java/io/questdb/client/test/SenderErrorTest.java
@@ -28,6 +28,7 @@
import io.questdb.client.SenderError;
import io.questdb.client.SenderErrorHandler;
import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
import org.junit.Assert;
import org.junit.Test;
@@ -40,24 +41,27 @@ public void testAllCategoriesEnumerable() {
// Pin the public enum values — adding/removing requires a deliberate spec change
// (and an update to wire-classification mapping in the I/O loop).
SenderError.Category[] cats = SenderError.Category.values();
- Assert.assertEquals(8, cats.length);
+ Assert.assertEquals(10, cats.length);
Assert.assertEquals(SenderError.Category.SCHEMA_MISMATCH, SenderError.Category.valueOf("SCHEMA_MISMATCH"));
Assert.assertEquals(SenderError.Category.PARSE_ERROR, SenderError.Category.valueOf("PARSE_ERROR"));
Assert.assertEquals(SenderError.Category.INTERNAL_ERROR, SenderError.Category.valueOf("INTERNAL_ERROR"));
Assert.assertEquals(SenderError.Category.SECURITY_ERROR, SenderError.Category.valueOf("SECURITY_ERROR"));
Assert.assertEquals(SenderError.Category.WRITE_ERROR, SenderError.Category.valueOf("WRITE_ERROR"));
Assert.assertEquals(SenderError.Category.NOT_WRITABLE, SenderError.Category.valueOf("NOT_WRITABLE"));
+ Assert.assertEquals(SenderError.Category.DICTIONARY_GAP, SenderError.Category.valueOf("DICTIONARY_GAP"));
Assert.assertEquals(SenderError.Category.PROTOCOL_VIOLATION, SenderError.Category.valueOf("PROTOCOL_VIOLATION"));
+ Assert.assertEquals(SenderError.Category.DATA_LOSS, SenderError.Category.valueOf("DATA_LOSS"));
Assert.assertEquals(SenderError.Category.UNKNOWN, SenderError.Category.valueOf("UNKNOWN"));
}
@Test
public void testAllPoliciesEnumerable() {
SenderError.Policy[] policies = SenderError.Policy.values();
- Assert.assertEquals(3, policies.length);
+ Assert.assertEquals(4, policies.length);
Assert.assertEquals(SenderError.Policy.RETRIABLE, SenderError.Policy.valueOf("RETRIABLE"));
Assert.assertEquals(SenderError.Policy.RETRIABLE_OTHER, SenderError.Policy.valueOf("RETRIABLE_OTHER"));
Assert.assertEquals(SenderError.Policy.TERMINAL, SenderError.Policy.valueOf("TERMINAL"));
+ Assert.assertEquals(SenderError.Policy.ABANDONED, SenderError.Policy.valueOf("ABANDONED"));
}
@Test
@@ -234,4 +238,42 @@ public void testToStringRendersMultiTableTableNameAsMulti() {
);
Assert.assertTrue(e.toString().contains("table=(multi)"));
}
+
+ @Test
+ public void testDataLossFactoryBindsCategoryAndPolicy() {
+ SenderError e = SenderError.dataLoss(
+ "symbol dictionary is incomplete [slot set aside at /tmp/s.unreplayable-0]",
+ "/tmp/s.unreplayable-0");
+ Assert.assertEquals(SenderError.Category.DATA_LOSS, e.getCategory());
+ Assert.assertEquals(SenderError.Policy.ABANDONED, e.getAppliedPolicy());
+ Assert.assertEquals(SenderError.NO_STATUS_BYTE, e.getServerStatusByte());
+ Assert.assertEquals(SenderError.NO_MESSAGE_SEQUENCE, e.getMessageSequence());
+ Assert.assertEquals(SenderError.NO_MESSAGE_SEQUENCE, e.getFromFsn());
+ Assert.assertEquals(SenderError.NO_MESSAGE_SEQUENCE, e.getToFsn());
+ Assert.assertNull(e.getTableName());
+ Assert.assertEquals("/tmp/s.unreplayable-0", e.getQuarantinedPath());
+ Assert.assertEquals(
+ "symbol dictionary is incomplete [slot set aside at /tmp/s.unreplayable-0]",
+ e.getServerMessage());
+ Assert.assertNotEquals(0L, e.getDetectedAtNanos());
+ }
+
+ @Test
+ public void testQuarantinedPathNullForServerErrors() {
+ // The public constructor never sets a quarantined path: only the
+ // dataLoss factory can, which is what keeps the field an honest
+ // "where are my abandoned bytes" answer rather than a free-text slot.
+ SenderError e = new SenderError(SenderError.Category.SCHEMA_MISMATCH,
+ SenderError.Policy.TERMINAL, 0x03, "boom", 1L, 2L, 3L, "t", 4L);
+ Assert.assertNull(e.getQuarantinedPath());
+ }
+
+ @Test
+ public void testDataLossPolicyResolvesToAbandoned() {
+ // DATA_LOSS never arrives from the wire (classify() cannot produce
+ // it), but the resolver must still map it explicitly so a refactor
+ // of the switch cannot silently re-adopt it into the TERMINAL arm.
+ Assert.assertEquals(SenderError.Policy.ABANDONED,
+ CursorWebSocketSendLoop.defaultPolicyFor(SenderError.Category.DATA_LOSS));
+ }
}
diff --git a/core/src/test/java/io/questdb/client/test/SenderPoolDataLossNotificationTest.java b/core/src/test/java/io/questdb/client/test/SenderPoolDataLossNotificationTest.java
new file mode 100644
index 00000000..4a6edeb1
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/SenderPoolDataLossNotificationTest.java
@@ -0,0 +1,421 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+import io.questdb.client.QuestDB;
+import io.questdb.client.Sender;
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+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.client.QwpWireTestUtils;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Paths;
+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.function.BooleanSupplier;
+
+/**
+ * Proves that a pool-managed sender's startup recovery reaches the user's
+ * {@link io.questdb.client.SenderErrorHandler}, registered through
+ * {@link QuestDB#builder()}'s {@code errorHandler}, for the two events the
+ * recovery delegate can genuinely attribute to the user's own data: a
+ * build()-time quarantine ({@link SenderError.Category#DATA_LOSS}) and a real
+ * server NACK of the recovered rows (a wire status byte). It also proves the
+ * filter's other half: the recovery delegate's own environment noise --
+ * connection attempts and never-connected auth/durable-ack TERMINALs against
+ * an unreachable or misconfigured server -- stays silent, and that a blocking
+ * user handler can never stall {@link QuestDB#close()}.
+ */
+public class SenderPoolDataLossNotificationTest {
+
+ private String sfDir;
+
+ @Before
+ public void setUp() {
+ sfDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-pool-data-loss-notification-" + System.nanoTime()).toString();
+ Assert.assertEquals("mkdir sf_dir", 0, Files.mkdir(sfDir, Files.DIR_MODE_DEFAULT));
+ }
+
+ @After
+ public void tearDown() {
+ if (sfDir != null) rmDirRec(sfDir);
+ }
+
+ @Test(timeout = 60_000L)
+ public void testPoolRecoveryQuarantineReachesUserErrorHandler() throws Exception {
+ // The C2 regression test: before the fix, recovery builds skipped
+ // applyUserCallbacks, so a quarantine's data-loss notification reached
+ // nobody. This test FAILS on the pre-fix code.
+ TestUtils.assertMemoryLeak(() -> {
+ writeTornPoolSlot();
+ List
+ * The e2e sibling {@code QwpSenderOversizeRowInBatchTest} asserts a real server's row
+ * count, which pins the COMMIT half: let the exception escape and those rows never
+ * reach a transaction at all. It cannot pin the DRAIN half. Over localhost the earlier
+ * rows are normally acked before close() is even entered, so
+ * {@code drainOnClose} returns at its {@code ackedFsn >= target} early-out and removing
+ * the drain changes nothing there -- verified by mutation: skipping only
+ * {@code drainOnClose} leaves that test green.
+ *
+ * Here the handler withholds every ack until the close-drain witness releases it, so
+ * the earlier row is PROVABLY unacknowledged when close() reaches the drain. The
+ * witness runs only past that early-out, so observing it fire is proof the drain had
+ * real work to do -- and releasing the acks from inside it is what lets close()
+ * finish, making the assertion impossible to satisfy without the drain.
+ */
+ @Test(timeout = 30_000L)
+ public void testCloseDrainsEarlierRowsBeforeSurfacingRetainedBatchError() throws Exception {
+ GatedAckHandler handler = new GatedAckHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, false, "PRIMARY")) {
+ // Small enough that a handful of modest rows cannot fit, while every
+ // individual row stays far below it (so the per-row guard cannot fire).
+ server.setAdvertisedMaxBatchSize(4096);
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ String cfg = "ws::addr=localhost:" + server.getPort()
+ + ";auto_flush_rows=10000"
+ + ";auto_flush_bytes=off"
+ + ";auto_flush_interval=60000"
+ + ";close_flush_timeout_millis=10000;";
+ QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg);
+
+ CountDownLatch drainWaiting = new CountDownLatch(1);
+ sender.setCloseDrainWaitingHook(() -> {
+ drainWaiting.countDown();
+ handler.releaseAcks();
+ });
+
+ // The earlier row: flushed successfully, then held unacked by the handler.
+ sender.table("earlier").longColumn("v", 1L).atNow();
+ sender.flush();
+
+ // 8 x ~1 KB into ONE table: no split can fit this under the 4 KB cap, and no
+ // single row comes close to it.
+ char[] chunkChars = new char[1024];
+ Arrays.fill(chunkChars, 'x');
+ String chunk = new String(chunkChars);
+ LineSenderException flushThrown = null;
+ try {
+ for (int i = 0; i < 8; i++) {
+ sender.table("oversize").stringColumn("payload", chunk).atNow();
+ }
+ sender.flush();
+ } catch (LineSenderException e) {
+ flushThrown = e;
+ }
+ Assert.assertNotNull("flush() must refuse a batch no split can fit under the cap",
+ flushThrown);
+ Assert.assertTrue("expected the batch-cap rejection, got: " + flushThrown.getMessage(),
+ flushThrown.getMessage().contains("batch too large for server batch cap"));
+
+ Assert.assertEquals("the earlier row must still be unacknowledged when close() starts,"
+ + " or the drain has nothing to wait for and this test proves nothing",
+ -1L, sender.getAckedFsn());
+
+ LineSenderException closeThrown = null;
+ try {
+ sender.close();
+ } catch (LineSenderException e) {
+ closeThrown = e;
+ }
+
+ Assert.assertEquals("close() must reach the bounded drain with the earlier row still"
+ + " unacknowledged; the witness fires only past drainOnClose's"
+ + " ackedFsn >= target early-out, so a skipped drain leaves it unfired",
+ 0L, drainWaiting.getCount());
+ Assert.assertNotNull("close() must still surface the retained batch's error after"
+ + " committing and draining the earlier row", closeThrown);
+ Assert.assertTrue("expected the batch-cap rejection from close(), got: "
+ + closeThrown.getMessage(),
+ closeThrown.getMessage().contains("batch too large for server batch cap"));
+ // close() surfaced the batch-cap error rather than a "drain timed out" one, so
+ // the drain ran to completion; the handler's counter is the server-side witness
+ // that the earlier row's frame really was carried across and acked.
+ Assert.assertTrue("the drain must have carried the earlier row's frame to the server"
+ + " and taken its ack [acksSent=" + handler.nextSeq.get() + ']',
+ handler.nextSeq.get() >= 1L);
+ }
+ }
+
private static String sfDirOpt() {
String dir = Paths.get(
System.getProperty("java.io.tmpdir"),
@@ -605,6 +763,32 @@ private static String sfDirOpt() {
return ";sf_dir=" + dir;
}
+ /**
+ * Receives frames but withholds every ack until {@link #releaseAcks()} is called, so a
+ * close-time drain provably has an unacknowledged target to wait on.
+ */
+ 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(buildAck(nextSeq.getAndIncrement()));
+ } catch (IOException | InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+
+ void releaseAcks() {
+ released.countDown();
+ }
+ }
+
/** Acks every binary frame after a fixed delay, so we can observe close() blocking. */
private static class DelayingAckHandler implements TestWebSocketServer.WebSocketServerHandler {
private final long delayMs;
@@ -655,6 +839,23 @@ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] dat
}
}
+ /**
+ * Receives the first frame, closes the connection without acking it, and
+ * ignores everything after -- the drop forces the sender into its reconnect
+ * loop with data still undrained.
+ */
+ private static class ReceiveThenDropHandler implements TestWebSocketServer.WebSocketServerHandler {
+ private boolean dropped;
+
+ @Override
+ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ if (!dropped) {
+ dropped = true;
+ client.close();
+ }
+ }
+ }
+
// Mirrors WebSocketResponse STATUS_OK layout: status u8 | sequence u64 | table_count u16
static byte[] buildAck(long seq) {
byte[] buf = new byte[1 + 8 + 2];
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseSafetyNetTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseSafetyNetTest.java
index 2a266212..639b7c8d 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseSafetyNetTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseSafetyNetTest.java
@@ -30,6 +30,7 @@
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.WebSocketResponse;
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
import org.jetbrains.annotations.NotNull;
import org.junit.Assert;
@@ -37,14 +38,18 @@
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.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
/**
* Pins both branches of {@code QwpWebSocketSender.close()}'s safety-net
- * rethrow, strictly — unlike the close assertions in
- * {@link InitialConnectAsyncTest}, which tolerate either outcome:
+ * rethrow using a deterministic server-side parse rejection:
*
+ * When a memory-mode sender reconnects, the server it lands on has an empty
+ * dictionary (the server discards it on every disconnect). Because the producer
+ * ships monotonic deltas -- each symbol id once -- a naive replay would leave the
+ * fresh server with a dictionary gap. The I/O thread prevents this by sending a
+ * full-dictionary catch-up frame before any post-reconnect traffic. This test
+ * reconstructs the server's per-connection dictionary from the captured wire
+ * bytes and asserts it stays complete and gap-free across the reconnect.
+ */
+public class DeltaDictCatchUpTest {
+
+ @Test
+ public void testReconnectCatchUpRebuildsDictionary() throws Exception {
+ // Connection 1: send "alpha" (id 0), ACK it, then drop the socket so the
+ // sender reconnects. Connection 2 (fresh, empty dict): send "beta" (id 1).
+ // Without catch-up, connection 2's first data frame would carry
+ // deltaStart=1 and the fresh server would never learn id 0.
+ assertMemoryLeak(() -> {
+ CatchUpHandler handler = new CatchUpHandler();
+ 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 + ";")) {
+ sender.table("t").symbol("s", "alpha").longColumn("v", 1L).atNow();
+ sender.flush();
+ waitFor(() -> handler.dictFor(1).size() >= 1, 5_000);
+
+ // Wait until the server has actually closed connection 1 before
+ // sending batch 2, so batch 2 cannot race into connection 1 and
+ // must drive the reconnect + catch-up.
+ waitFor(() -> handler.conn1Closed, 5_000);
+
+ sender.table("t").symbol("s", "beta").longColumn("v", 2L).atNow();
+ sender.flush();
+ waitFor(() -> handler.connectionsAccepted.get() >= 2
+ && handler.dictFor(2).size() >= 2, 5_000);
+ }
+
+ // The fresh (2nd) connection's dictionary, rebuilt purely from the
+ // frames it received, must hold both symbols contiguously with no
+ // null gap -- exactly what the catch-up frame guarantees.
+ List
+ * A file-mode sender writes delta-encoded SYMBOL frames (each frame carries only
+ * the ids it introduces) to a slot but never drains it -- simulating a crash. A
+ * fresh sender then recovers the slot and replays those non-self-sufficient
+ * frames to a brand-new server whose dictionary starts empty. Correctness hinges
+ * on the persisted {@code .symbol-dict}: the recovering sender loads it, the I/O
+ * thread re-registers the whole dictionary via a catch-up frame, and only then do
+ * the delta frames replay. This test reconstructs the server-side dictionary from
+ * the wire and asserts it comes out complete and gap-free.
+ */
+public class DeltaDictRecoveryTest {
+
+ private static final int DISTINCT_SYMBOLS = 8;
+ private static final int ROWS = 40;
+ // writeAndTearUnreplayableSlot arithmetic: 12 one-symbol frames, FSNs 0..11,
+ // acks held at 10 so frame 11 stays unacked and keeps the slot alive across
+ // close(). recoveredCommitBoundaryFsn is therefore 11; stamping the watermark
+ // at 11 afterwards produces the fully-acked recovery, leaving it unstamped produces
+ // the unacked (quarantine) recovery.
+ private static final int ACK_THROUGH = 10;
+ private static final int FRAMES = 12;
+
+ @Rule
+ public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build();
+
+ private String sfDir;
+
+ @Before
+ public void setUp() {
+ sfDir = temporaryFolder.getRoot().toPath().resolve("qdb-delta-recovery").toString();
+ }
+
+ @Test
+ public void testRecoveredSlotReplaysDeltaFramesAgainstFreshServer() throws Exception {
+ assertMemoryLeak(() -> {
+ // Phase 1: silent server (no acks). Sender 1 writes symbol rows and
+ // close-fast (no drain), leaving unacked delta frames + a persisted
+ // dictionary in the slot.
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+
+ String pad = TestUtils.repeat("x", 64);
+ String cfg = "ws::addr=localhost:" + port
+ + ";sf_dir=" + sfDir
+ + ";sf_max_segment_bytes=4096"
+ + ";close_flush_timeout_millis=0;";
+ try (Sender s1 = Sender.fromConfig(cfg)) {
+ for (int i = 0; i < ROWS; i++) {
+ s1.table("m")
+ .symbol("s", "sym-" + (i % DISTINCT_SYMBOLS))
+ .stringColumn("p", pad)
+ .longColumn("v", i)
+ .atNow();
+ s1.flush();
+ }
+ }
+ }
+
+ // Ack a prefix so recovery does NOT replay from the self-sufficient head.
+ // Rows 0..DISTINCT_SYMBOLS-1 register all the symbols, so stamping the
+ // watermark at FSN DISTINCT_SYMBOLS-1 makes recovery replay from FSN
+ // DISTINCT_SYMBOLS onward -- frames whose delta starts at
+ // DISTINCT_SYMBOLS and carries NO new symbols (rows past the first cycle
+ // reuse existing ids). The early ids those frames reference then exist
+ // ONLY in the persisted dictionary, so the reconstructed dictionary below
+ // is complete solely because the catch-up frame re-registered them. That
+ // pins the content assertions to the catch-up: without it (or with a
+ // broken one) the fresh server would null-pad ids 0..DISTINCT_SYMBOLS-1
+ // and the per-id checks would fail.
+ java.nio.file.Path slot = Paths.get(sfDir, "default");
+ writeAckWatermark(slot.resolve(".ack-watermark"), DISTINCT_SYMBOLS - 1);
+
+ // Phase 2: fresh server that reconstructs its per-connection dictionary
+ // from the delta sections. Sender 2 recovers the slot and replays.
+ DictReconstructingHandler handler = new DictReconstructingHandler();
+ try (TestWebSocketServer good = new TestWebSocketServer(handler)) {
+ int port = good.getPort();
+ good.start();
+ Assert.assertTrue(good.awaitStart(5, TimeUnit.SECONDS));
+
+ String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";";
+ try (Sender ignored = Sender.fromConfig(cfg)) {
+ long deadline = System.currentTimeMillis() + 5_000;
+ while (System.currentTimeMillis() < deadline
+ && handler.maxDictSize() < DISTINCT_SYMBOLS) {
+ Thread.sleep(20);
+ }
+ }
+
+ // The recovering sender must have re-registered the dictionary via a
+ // catch-up (0-table) frame before replaying delta frames.
+ Assert.assertTrue("recovery sent a full-dictionary catch-up frame",
+ handler.sawCatchUpFrame);
+ // The reconstructed dictionary must be complete and gap-free: exactly
+ // the DISTINCT_SYMBOLS symbols, no null padding left by a missing id.
+ List
+ * {@code isMmapAllowed()} opts IN deliberately. The inherited default is
+ * {@code this == INSTANCE}, so any wrapping facade otherwise routes the dictionary
+ * down the positioned-write fallback that production never executes -- injecting a
+ * fault would then silently replace the code under test.
+ */
+ private static final class FullDiskDictFacade extends DelegatingFilesFacade {
+ boolean armed;
+
+ @Override
+ public boolean allocate(int fd, long size) {
+ return !armed && INSTANCE.allocate(fd, size);
+ }
+
+ @Override
+ public boolean isMmapAllowed() {
+ return true;
+ }
+ }
+
+
+ /**
+ * The unreplayable-slot contract: a recovered slot whose symbol dictionary cannot be
+ * rebuilt -- not from its own intact prefix, not from the surviving frames' delta
+ * sections -- is SET ASIDE, never silently drained and never allowed to brick the
+ * sender.
+ *
+ * The bytes are kept for forensics and resend, the {@code .failed} sentinel tells the
+ * orphan drainer to treat the copy as human-in-the-loop rather than retry it forever,
+ * and the producer continues on a fresh, empty slot.
+ */
+ private void assertUnreplayableSlotSetAside() {
+ java.nio.file.Path aside = Paths.get(sfDir, "default.unreplayable-0");
+ Assert.assertTrue("the unreplayable slot must be set aside, not deleted: " + aside,
+ java.nio.file.Files.isDirectory(aside));
+ Assert.assertTrue("the set-aside slot must keep its recorded frames for resend",
+ hasSegmentFile(aside));
+ Assert.assertTrue("the set-aside slot must carry the .failed sentinel",
+ java.nio.file.Files.exists(aside.resolve(".failed")));
+ try {
+ String reason = new String(
+ java.nio.file.Files.readAllBytes(aside.resolve(".failed")),
+ java.nio.charset.StandardCharsets.UTF_8);
+ Assert.assertTrue("the set-aside must carry the dictionary-gap verdict, got: " + reason,
+ reason.contains("symbol dictionary is incomplete"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ Assert.assertTrue("the sender must continue on a live slot",
+ java.nio.file.Files.isDirectory(Paths.get(sfDir, "default")));
+ }
+
+ /**
+ * A recovery that rebuilds ids from the surviving frames must write them back to
+ * {@code .symbol-dict} IMMEDIATELY, not wait for a later batch to happen to reach
+ * that high.
+ *
+ * seedGlobalDictionaryFromPersisted resumes sentMaxSymbolId at prefix + frame-suffix,
+ * so every frame published afterwards carries a deltaStart the side-file cannot
+ * describe. The steady-state write-ahead only persists
+ * {@code [pd.size() .. currentBatchMaxSymbolId]} and returns early when the batch's
+ * highest id is below pd.size() -- so it heals only if, and only as far as, later
+ * traffic references the recovered high ids. Meanwhile the frames carrying those ids
+ * are the oldest unacked and therefore the FIRST to be acked and trimmed. Once they
+ * are gone, an ordinary process crash (which store-and-forward promises to survive)
+ * leaves a slot whose frames reference ids nothing holds -> quarantine, "resend the
+ * affected data".
+ *
+ * So: recover, touch nothing, and require the file to already be whole. Remove
+ * healPersistedDictionary and this drops back to the two torn entries.
+ */
+ @Test(timeout = 60_000L)
+ public void testRecoveryHealsThePersistedDictionaryBeforeAnyNewFrame() throws Exception {
+ assertMemoryLeak(() -> {
+ // Phase 1: three delta frames (a@0, b@1, c@2), nothing acked.
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+ String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
+ + ";close_flush_timeout_millis=0;";
+ Sender s1 = Sender.fromConfig(cfg);
+ try {
+ s1.table("m").symbol("s", "a").longColumn("v", 0).atNow();
+ s1.flush();
+ s1.table("m").symbol("s", "b").longColumn("v", 1).atNow();
+ s1.flush();
+ s1.table("m").symbol("s", "c").longColumn("v", 2).atNow();
+ s1.flush();
+ } finally {
+ s1.close();
+ }
+ }
+
+ // Host-crash tear: drop c@2 from the side-file, keep the frame that defines it.
+ java.nio.file.Path slot = Paths.get(sfDir, "default");
+ String slotDir = slot.toString();
+ try (PersistedSymbolDict torn = PersistedSymbolDict.openClean(slotDir)) {
+ Assert.assertNotNull(torn);
+ torn.appendSymbol("a");
+ torn.appendSymbol("b");
+ Assert.assertEquals(2, torn.size());
+ }
+
+ // Phase 2: recover against a silent server and ingest NOTHING. Nothing is
+ // acked, so the slot is not fully drained and the side-file survives close.
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+ String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
+ + ";close_flush_timeout_millis=0;";
+ Sender.fromConfig(cfg).close();
+ }
+
+ // The write-ahead invariant must hold again on disk, with no new traffic.
+ try (PersistedSymbolDict healed = PersistedSymbolDict.open(slotDir)) {
+ Assert.assertNotNull("the healed dictionary must still be readable", healed);
+ ObjList
+ * deltaDictEnabled was written once at setCursorEngine with no runtime disable, so
+ * the first persist failure killed flush() permanently -- every later flush re-threw.
+ * A full disk reaches exactly that state and is survivable: SF's segments are
+ * pre-allocated mmap files and stay writable while the growing .symbol-dict does not,
+ * and full self-sufficient frames need no side file at all (each carries the whole
+ * dictionary from id 0, which is what recovery replays anyway).
+ *
+ * So the FIRST flush must still fail -- beginMessage has already baked a delta
+ * deltaStart into the staged frame, and publishing it would put ids on the ring the
+ * side-file cannot describe -- but the SECOND must succeed, in full-dict mode. Remove
+ * the disableDeltaDict call and the second flush throws exactly like the first.
+ */
+ @Test(timeout = 60_000L)
+ public void testPersistFailureDegradesToFullDictInsteadOfKillingFlushForever() throws Exception {
+ assertMemoryLeak(() -> {
+ DictReconstructingHandler handler = new DictReconstructingHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ 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));
+ FullDiskDictFacade ff = new FullDiskDictFacade();
+ CursorSendEngine engine = new CursorSendEngine(
+ slot, 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
+ CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff);
+ Sender sender = QwpWebSocketSender.connect(
+ "localhost", port, null, 0, 0, 0L, null, false, engine);
+ try {
+ // Armed from the start, so the very first ensureAppendMap is refused --
+ // a later append would sit inside the window already mapped and never
+ // call allocate at all.
+ ff.armed = true;
+ sender.table("m").symbol("s", "a").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("the first flush must fail: its staged frame carries a delta "
+ + "deltaStart the side-file cannot describe");
+ } catch (LineSenderException expected) {
+ Assert.assertTrue("expected the persist-failure message, got: "
+ + expected.getMessage(),
+ expected.getMessage().contains("failed to persist symbol dictionary"));
+ }
+
+ // The rows are still buffered (the throw precedes every publish), so the
+ // retry re-encodes them from id 0. Pre-fix this threw forever.
+ sender.flush();
+
+ long deadline = System.currentTimeMillis() + 5_000;
+ while (System.currentTimeMillis() < deadline && handler.dataFrameCount() < 1) {
+ Thread.sleep(20);
+ }
+ Assert.assertTrue("the degraded flush must actually reach the server",
+ handler.dataFrameCount() >= 1);
+ Assert.assertEquals("a degraded frame must be self-sufficient (deltaStart 0)",
+ 0, handler.lastDataDeltaStart());
+ Assert.assertEquals("the full dictionary must ride along",
+ java.util.Collections.singletonList("a"), handler.dictSnapshot());
+ } finally {
+ try {
+ sender.close();
+ } catch (LineSenderException ignored) {
+ // not what we assert
+ }
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testTransientDictFaultOnRecoveredSlotFailsLoudAndRetryRecoversInFull() throws Exception {
+ // The cross-generation misattribution chain (sessions A -> B -> C) needed
+ // session B to hit a transient opening the side-file, silently degrade to
+ // full-dict, and write frames next to A's stale populated dictionary --
+ // which session C then trusted, resolving A's ids to B's strings with no
+ // detectable gap. Recovery now follows the Rust client's disposition:
+ // the transient fails session B's construction LOUDLY with the retriable
+ // SfOperationalException (the type Sender.build() refuses to quarantine
+ // on), the slot's bytes stay byte-identical, and once the transient
+ // clears the SAME slot recovers in full -- delta mode on, every id
+ // resolving to the string session A registered.
+ assertMemoryLeak(() -> {
+ java.nio.file.Path slot = Paths.get(sfDir, "default");
+ java.nio.file.Path dict = slot.resolve(".symbol-dict");
+
+ // Session A: a real delta-mode sender persists DISTINCT_SYMBOLS
+ // symbols and their unacked delta frames, then dies (close-fast
+ // against a server that never acks).
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+ String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
+ + ";close_flush_timeout_millis=0;";
+ try (Sender s1 = Sender.fromConfig(cfg)) {
+ for (int i = 0; i < DISTINCT_SYMBOLS; i++) {
+ s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
+ s1.flush();
+ }
+ }
+ }
+ Assert.assertTrue("session A must leave a populated side-file",
+ java.nio.file.Files.exists(dict));
+ byte[] dictBefore = java.nio.file.Files.readAllBytes(dict);
+ List
+ * This is the strong form, and it is the point: the fresh server starts with an empty
+ * dictionary, so ids the replayed frames' deltas start ABOVE can only come from a catch-up
+ * frame -- which can only carry them if the mirror was seeded from the frames still on
+ * disk. A dictionary that came back null-padded (the server's response to an id it has
+ * never seen) or shifted by one would fail here, and that is precisely the corruption the
+ * old guard condemned these slots to avoid. It never had to.
+ */
+ private void assertSlotRecoversWithCompleteDictionary() throws Exception {
+ DictReconstructingHandler handler = new DictReconstructingHandler();
+ try (TestWebSocketServer good = new TestWebSocketServer(handler)) {
+ int port = good.getPort();
+ good.start();
+ Assert.assertTrue(good.awaitStart(5, TimeUnit.SECONDS));
+ String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";";
+ try (Sender s2 = Sender.fromConfig(cfg)) {
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (System.currentTimeMillis() < deadline && handler.maxDictSize() < 6) {
+ Thread.sleep(20);
+ }
+ s2.flush();
+ }
+ Assert.assertEquals(
+ "every id is still held by a frame on disk, so the catch-up must rebuild the "
+ + "dictionary COMPLETE and gap-free -- not null-pad it",
+ Arrays.asList("sym-0", "sym-1", "sym-2", "sym-3", "sym-4", "sym-5"),
+ handler.dictSnapshot());
+ }
+ }
+
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DictionaryGapNackTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DictionaryGapNackTest.java
new file mode 100644
index 00000000..b32aa27b
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DictionaryGapNackTest.java
@@ -0,0 +1,153 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.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.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * End-to-end proof of the tandem's load-bearing claim for STATUS_DICTIONARY_GAP
+ * (0x0D): a real gap NACK recycles the wire and replays -- it does not
+ * terminate the sender, does not advance the ack watermark past the rejected
+ * frame, and a single gap never escalates to the poison terminal.
+ */
+public class DictionaryGapNackTest {
+
+ @Test
+ public void testSingleDictionaryGapNackRecyclesAndReplaysWithoutDataLoss() throws Exception {
+ assertMemoryLeak(() -> {
+ GapOnceHandler handler = new GapOnceHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ // Small reconnect backoff so the paced recycle does not slow the
+ // test; the poison threshold stays at its default (4) because "no
+ // escalation on a single gap" is part of what is under test.
+ String cfg = "ws::addr=localhost:" + port
+ + ";reconnect_initial_backoff_millis=10;reconnect_max_backoff_millis=50;";
+ QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg);
+ try {
+ sender.table("t").symbol("s", "alpha").longColumn("v", 1L).atNow();
+ long targetFsn = sender.flushAndGetSequence();
+
+ // The double NACKs this exact frame once with STATUS_DICTIONARY_GAP,
+ // then ACKs the byte-identical replay on the recycled connection.
+ Assert.assertTrue("the client must recover and drain after a single gap NACK",
+ sender.awaitAckedFsn(targetFsn, 10_000));
+
+ // Not terminal: no latched terminal error, watermark advanced.
+ Assert.assertNull("a single DICTIONARY_GAP must never latch a terminal",
+ sender.getLastTerminalError());
+ Assert.assertEquals(targetFsn, sender.getAckedFsn());
+
+ // The NACK was surfaced (informational dispatch counts it) and
+ // recycled the wire: a fresh WS upgrade happened.
+ Assert.assertTrue("the NACK must be counted as a server error",
+ sender.getTotalServerErrors() >= 1);
+ Assert.assertTrue("the NACK must force a fresh WS upgrade",
+ server.handshakeCount() >= 2);
+ Assert.assertTrue("the loop must count a successful reconnect",
+ sender.getTotalReconnectsSucceeded() >= 1);
+
+ // Replay: the frame reached the wire at least twice (once NACKed,
+ // once ACKed), while the server-side dictionary materialised the
+ // symbol exactly once and gap-free.
+ Assert.assertTrue("the frame must be retransmitted after the NACK",
+ handler.dataFramesSeen.get() >= 2);
+ Assert.assertEquals("the double must have sent exactly one gap NACK",
+ 1, handler.gapNacksSent.get());
+ List
+ * The fault is injected deterministically at the {@link io.questdb.client.std.FilesFacade#mmap}
+ * seam {@code PersistedSymbolDict} already takes for testing -- the same technique
+ * {@code MmapSegmentRecoveryFaultTest.testSegmentRingRefusesSlotOnUnconvertedMmapFault} uses
+ * for {@code SegmentRing} -- rather than relying on a real SIGBUS, whose delivery frame is
+ * JIT/JDK-version dependent (see {@code MmapSegmentRecoveryFaultTest.hasPreciseUnsafeAccessFaults}).
+ *
+ * Before the guard existed (an unqualified {@code instanceof Error}), the injected
+ * {@code InternalError} would be rethrown raw: {@code flush()} would surface it instead of
+ * a {@link LineSenderException}, and {@link QwpWebSocketSender#isDeltaDictEnabledForTest()}
+ * would stay {@code true} instead of degrading. Reverting either guard back to a bare
+ * {@code instanceof Error} turns this test red.
+ */
+ @Test
+ public void testMmapAccessFaultDegradesPersistInsteadOfPropagating() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String sfDir = temporaryFolder.getRoot().toPath().resolve("mmap-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 silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+
+ MmapFaultDictFacade ff = new MmapFaultDictFacade();
+ // The engine owns the dictionary; the fault facade reaches only its mmap
+ // growth, so segment files still write normally and the ONLY failure is
+ // the persist.
+ CursorSendEngine engine = new CursorSendEngine(
+ slot, 4L * 1024 * 1024, 64L * 1024 * 1024,
+ CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff);
+ QwpWebSocketSender sender = QwpWebSocketSender.connect(
+ "localhost", port, null, 0, 0, 0L, null, false, engine);
+ try {
+ ff.armed = true; // the next dictionary mmap growth raises the fault
+ sender.table("m").symbol("s", "boom").longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("a recognised mmap access fault during persist must still "
+ + "surface as a sender error, not silently succeed");
+ } catch (LineSenderException expected) {
+ 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 to "
+ + "full self-sufficient frames, not merely fail this one flush",
+ sender.isDeltaDictEnabledForTest());
+ } finally {
+ try {
+ sender.close();
+ } catch (LineSenderException ignored) {
+ // close() re-flushes the still-buffered row; the facade has disarmed
+ // and delta-dict is now off, so this normally succeeds. Either way it
+ // is not what this test asserts.
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * The recovery-side sibling of {@link #testMmapAccessFaultDegradesPersistInsteadOfPropagating}:
+ * drives a recognised mmap access fault through the OTHER guard of the identical shape, in
+ * {@code QwpWebSocketSender.healPersistedDictionary} (the {@code instanceof Error &&
+ * !isMmapAccessFault(t)} around :4242). That guard is reachable ONLY on a RECOVERED slot
+ * whose surviving frames reference ids above the (torn) side-file -- so the persist-path
+ * test above cannot exercise it, as its own javadoc states.
+ *
+ * Here a host-crash tear drops the dictionary's highest entry (c@2) while the frame that
+ * introduced it survives, so recovery rebuilds c from that frame and heals it back into the
+ * side-file. A recognised mmap access fault during that heal must DEGRADE the sender (drop to
+ * self-sufficient frames), not escape {@code Sender.build()} -- where, past both quarantine
+ * arms, the generic {@code catch (Throwable)} would leave the slot neither quarantined nor
+ * reported, {@code disableDeltaDict} never run, and every restart re-fault the same page.
+ * Reverting the guard to a bare {@code instanceof Error} rethrows the {@code InternalError}
+ * out of build() and turns this red.
+ */
+ @Test
+ public void testMmapAccessFaultDuringDictHealDegradesInsteadOfEscapingBuild() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String sfDir = temporaryFolder.getRoot().toPath().resolve("heal-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 silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+
+ // Phase 1: three delta frames (a@0, b@1, c@2), nothing acked, so all survive.
+ try (Sender s1 = Sender.fromConfig("ws::addr=localhost:" + port
+ + ";sf_dir=" + sfDir + ";close_flush_timeout_millis=0;")) {
+ s1.table("m").symbol("s", "a").longColumn("v", 0L).atNow();
+ s1.flush();
+ s1.table("m").symbol("s", "b").longColumn("v", 1L).atNow();
+ s1.flush();
+ s1.table("m").symbol("s", "c").longColumn("v", 2L).atNow();
+ s1.flush();
+ }
+
+ // Tear: drop the dictionary's highest entry (c@2), keeping a@0,b@1, while the
+ // frame that introduced c stays on disk. The resume rebuilds c from that frame,
+ // so healPersistedDictionary appends it back -- and that append is what faults.
+ try (PersistedSymbolDict torn = PersistedSymbolDict.openClean(slot)) {
+ Assert.assertNotNull(torn);
+ torn.appendSymbol("a");
+ torn.appendSymbol("b");
+ Assert.assertEquals(2, torn.size());
+ }
+
+ // Phase 2: recover with only the heal's MAP_RW growth of the dictionary faulting.
+ HealMmapFaultFacade ff = new HealMmapFaultFacade();
+ CursorSendEngine engine = new CursorSendEngine(
+ slot, 4L * 1024 * 1024, 64L * 1024 * 1024,
+ CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff);
+ // Recovery has already opened the dictionary (MAP_RO) and recorded its fd; arm
+ // now so ONLY the heal's later MAP_RW growth of that same fd raises the fault.
+ ff.armed = true;
+ QwpWebSocketSender resumed = QwpWebSocketSender.connect(
+ "localhost", port, null, 0, 0, 0L, null, false, engine);
+ try {
+ Assert.assertFalse("a recognised mmap access fault during the dictionary heal "
+ + "must degrade the sender to self-sufficient frames, not escape build()",
+ resumed.isDeltaDictEnabledForTest());
+ } finally {
+ try {
+ resumed.close();
+ } catch (LineSenderException ignored) {
+ // close() drains the recovered frames against a silent server; not asserted.
+ }
+ }
+ }
+ });
+ }
+
+ private static final class SilentHandler implements TestWebSocketServer.WebSocketServerHandler {
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ // never acks -- irrelevant here, the assertions run before any drain
+ }
+ }
+
+ /**
+ * Records the {@code .symbol-dict} fd at open time and raises a recognised mmap access fault
+ * out of that file's next MAP_RW growth (the dictionary heal's append window) when
+ * {@link #armed}. Faulting only MAP_RW on the dictionary fd leaves the recovery-open MAP_RO
+ * read -- and every segment mmap -- untouched, so the fault lands squarely on
+ * {@code healPersistedDictionary.appendSymbols}.
+ */
+ private static final class HealMmapFaultFacade extends DelegatingFilesFacade {
+ boolean armed;
+ private int dictFd = -1;
+
+ @Override
+ public boolean isMmapAllowed() {
+ return true;
+ }
+
+ @Override
+ public long mmap(int fd, long len, long offset, int flags, int memoryTag) {
+ if (armed && fd == dictFd && flags == io.questdb.client.std.Files.MAP_RW) {
+ 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);
+ }
+
+ @Override
+ public int openRW(String path) {
+ int fd = INSTANCE.openRW(path);
+ if (path != null && path.endsWith(PersistedSymbolDict.FILE_NAME)) {
+ dictFd = fd;
+ }
+ return fd;
+ }
+ }
+
+ /**
+ * Raises a RECOGNISED mmap access fault out of the persisted dictionary's next mmap
+ * growth, once, when {@link #armed}. Mirrors {@code DeltaDictRecoveryTest.FullDiskDictFacade}
+ * but injects the fault {@code QwpWebSocketSender}'s guards are specifically meant to
+ * absorb, instead of the plain {@code IllegalStateException} an {@code ff.allocate} refusal
+ * produces.
+ */
+ 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/NativeBufferWriterTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/NativeBufferWriterTest.java
index 0fb85406..1eadc103 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/NativeBufferWriterTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/NativeBufferWriterTest.java
@@ -273,6 +273,43 @@ public void testQwpBufferWriterUtf8LengthInvalidSurrogatePair() throws Exception
});
}
+ @Test
+ public void testRawVarintBoundariesAndReturnedAddress() throws Exception {
+ assertMemoryLeak(() -> {
+ try (NativeBufferWriter writer = new NativeBufferWriter(16)) {
+ assertRawVarint(writer, 0, 0x00);
+ assertRawVarint(writer, 1, 0x01);
+ assertRawVarint(writer, 127, 0x7F);
+ assertRawVarint(writer, 128, 0x80, 0x01);
+ assertRawVarint(writer, 16_383, 0xFF, 0x7F);
+ assertRawVarint(writer, 16_384, 0x80, 0x80, 0x01);
+ assertRawVarint(writer, Long.MAX_VALUE,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F);
+ }
+ });
+ }
+
+ @Test
+ public void testRawVarintRejectsNegativeWithoutWriting() throws Exception {
+ assertMemoryLeak(() -> {
+ try (NativeBufferWriter writer = new NativeBufferWriter(16)) {
+ long addr = writer.getBufferPtr();
+ Unsafe.getUnsafe().putByte(addr, (byte) 0x5A);
+ boolean rejected = false;
+ try {
+ NativeBufferWriter.writeVarint(addr, -1);
+ } catch (AssertionError expected) {
+ rejected = true;
+ assertTrue(expected.getMessage().contains(
+ "unsigned LEB128 varint requires a non-negative value: -1"));
+ }
+ assertTrue("negative raw varint must be rejected", rejected);
+ assertEquals("rejection must happen before the first write",
+ (byte) 0x5A, Unsafe.getUnsafe().getByte(addr));
+ }
+ });
+ }
+
@Test
public void testReset() throws Exception {
assertMemoryLeak(() -> {
@@ -618,4 +655,21 @@ public void testWriteVarintSmall() throws Exception {
}
});
}
+
+ private static void assertRawVarint(NativeBufferWriter writer, long value, int... expectedBytes) {
+ long addr = writer.getBufferPtr();
+ for (int i = 0; i <= expectedBytes.length; i++) {
+ Unsafe.getUnsafe().putByte(addr + i, (byte) 0x5A);
+ }
+ long end = NativeBufferWriter.writeVarint(addr, value);
+ assertEquals("returned address for value=" + value, addr + expectedBytes.length, end);
+ assertEquals("varintSize for value=" + value,
+ expectedBytes.length, NativeBufferWriter.varintSize(value));
+ for (int i = 0; i < expectedBytes.length; i++) {
+ assertEquals("byte " + i + " for value=" + value,
+ (byte) expectedBytes[i], Unsafe.getUnsafe().getByte(addr + i));
+ }
+ assertEquals("writer overran value=" + value,
+ (byte) 0x5A, Unsafe.getUnsafe().getByte(end));
+ }
}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java
index 4d7d7931..44f711a3 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java
@@ -25,6 +25,7 @@
package io.questdb.client.test.cutlass.qwp.client;
import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary;
+import io.questdb.client.cutlass.qwp.client.MicrobatchBuffer;
import io.questdb.client.cutlass.qwp.client.QwpBufferWriter;
import io.questdb.client.cutlass.qwp.client.QwpWebSocketEncoder;
import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer;
@@ -1284,6 +1285,144 @@ public void testReset() throws Exception {
});
}
+ @Test
+ public void testSplitMessageValidatesArgumentsAndSizeOverflow() throws Exception {
+ assertMemoryLeak(() -> {
+ try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
+ MicrobatchBuffer target = new MicrobatchBuffer(64);
+ QwpTableBuffer table = new QwpTableBuffer("alpha")) {
+ GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
+ int aapl = dict.getOrAddSymbol("AAPL");
+ dict.getOrAddSymbol("GOOG");
+ table.getOrCreateColumn("sym", TYPE_SYMBOL, false)
+ .addSymbolWithGlobalId("AAPL", aapl);
+ table.nextRow();
+
+ encoder.beginMessage(1, dict, -1, 1);
+ int tableBodyOffset = encoder.getBuffer().getPosition();
+ encoder.addTable(table);
+ int tableBodyLength = encoder.getBuffer().getPosition() - tableBodyOffset;
+ encoder.finishMessage();
+
+ target.setBufferPos(1);
+ assertFailure(IllegalStateException.class,
+ "split message target is not empty",
+ () -> encoder.copySplitMessage(
+ target, tableBodyOffset, tableBodyLength, false, -1, 1));
+ Assert.assertEquals("invalid target must not be modified", 1, target.getBufferPos());
+ target.reset();
+
+ assertFailure(IllegalArgumentException.class,
+ "table body slice is outside the staged message",
+ () -> encoder.copySplitMessage(
+ target, tableBodyOffset - 1, tableBodyLength, false, -1, 1));
+ assertFailure(IllegalArgumentException.class,
+ "table body slice is outside the staged message",
+ () -> encoder.copySplitMessage(
+ target, tableBodyOffset, -1, false, -1, 1));
+ assertFailure(IllegalArgumentException.class,
+ "table body slice is outside the staged message",
+ () -> encoder.copySplitMessage(
+ target, tableBodyOffset, tableBodyLength + 1, false, -1, 1));
+ Assert.assertEquals("invalid slices must not write the target", 0, target.getBufferPos());
+
+ assertFailure(IllegalStateException.class,
+ "split delta does not match the staged message"
+ + " [stagedStart=0, stagedCount=2, splitStart=1, splitCount=1]",
+ () -> encoder.copySplitMessage(
+ target, tableBodyOffset, tableBodyLength, false, 0, 1));
+ Assert.assertEquals("delta mismatch must not write the target", 0, target.getBufferPos());
+
+ assertFailure(IllegalArgumentException.class,
+ "tableBodyLength must be non-negative",
+ () -> encoder.getSplitMessageSize(-1, -1, 1));
+ long overflowSize = (long) HEADER_SIZE
+ + 1 // delta start 0
+ + 1 // delta count 2
+ + encoder.getDeltaEntriesLen()
+ + Integer.MAX_VALUE;
+ assertFailure(OutOfMemoryError.class,
+ "split QWP message size overflow: " + overflowSize,
+ () -> encoder.getSplitMessageSize(Integer.MAX_VALUE, -1, 1));
+ }
+ });
+ }
+
+ @Test
+ public void testSplitMessageCopiesStagedTableBodies() throws Exception {
+ assertMemoryLeak(() -> {
+ try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
+ MicrobatchBuffer target = new MicrobatchBuffer(64);
+ QwpTableBuffer t0 = new QwpTableBuffer("alpha");
+ QwpTableBuffer t1 = new QwpTableBuffer("bravo")) {
+ GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
+ int aapl = dict.getOrAddSymbol("AAPL"); // 0
+ int goog = dict.getOrAddSymbol("GOOG"); // 1
+
+ t0.getOrCreateColumn("sym", TYPE_SYMBOL, false).addSymbolWithGlobalId("AAPL", aapl);
+ t0.getOrCreateColumn("v", TYPE_LONG, false).addLong(1L);
+ t0.nextRow();
+
+ t1.getOrCreateColumn("sym", TYPE_SYMBOL, false).addSymbolWithGlobalId("GOOG", goog);
+ t1.getOrCreateColumn("d", TYPE_DOUBLE, false).addDouble(2.5);
+ t1.getOrCreateColumn("s", TYPE_VARCHAR, true).addString("hello");
+ t1.nextRow();
+
+ encoder.beginMessage(2, dict, -1, 1);
+ int t0BodyOffset = encoder.getBuffer().getPosition();
+ encoder.addTable(t0);
+ int t0BodyLength = encoder.getBuffer().getPosition() - t0BodyOffset;
+ int t1BodyOffset = encoder.getBuffer().getPosition();
+ encoder.addTable(t1);
+ int t1BodyLength = encoder.getBuffer().getPosition() - t1BodyOffset;
+ encoder.finishMessage();
+ long staged = encoder.getBuffer().getBufferPtr();
+
+ // Erase the source tables after their one combined encode. Split
+ // assembly must still reproduce both bodies from staged bytes.
+ t0.clear();
+ t1.clear();
+
+ int firstSize = encoder.copySplitMessage(target, t0BodyOffset, t0BodyLength,
+ true, -1, 1);
+ long first = target.getBufferPtr();
+ Assert.assertEquals(firstSize, target.getBufferPos());
+ Assert.assertEquals(1, Unsafe.getUnsafe().getShort(first + 6));
+ Assert.assertEquals(firstSize - HEADER_SIZE, Unsafe.getUnsafe().getInt(first + 8));
+ Assert.assertEquals(FLAG_DEFER_COMMIT,
+ (byte) (Unsafe.getUnsafe().getByte(first + HEADER_OFFSET_FLAGS) & FLAG_DEFER_COMMIT));
+ Cursor cursor = new Cursor(first + HEADER_SIZE);
+ Assert.assertEquals(0, cursor.readVarint());
+ Assert.assertEquals(2, cursor.readVarint());
+ Assert.assertEquals("AAPL", cursor.readString());
+ Assert.assertEquals("GOOG", cursor.readString());
+ for (int i = 0; i < t0BodyLength; i++) {
+ Assert.assertEquals("first staged table body byte " + i,
+ Unsafe.getUnsafe().getByte(staged + t0BodyOffset + i),
+ Unsafe.getUnsafe().getByte(cursor.address + i));
+ }
+
+ target.reset();
+ int secondSize = encoder.copySplitMessage(target, t1BodyOffset, t1BodyLength,
+ false, 1, 1);
+ long second = target.getBufferPtr();
+ Assert.assertEquals(secondSize, target.getBufferPos());
+ Assert.assertEquals(1, Unsafe.getUnsafe().getShort(second + 6));
+ Assert.assertEquals(secondSize - HEADER_SIZE, Unsafe.getUnsafe().getInt(second + 8));
+ Assert.assertEquals(0,
+ Unsafe.getUnsafe().getByte(second + HEADER_OFFSET_FLAGS) & FLAG_DEFER_COMMIT);
+ cursor = new Cursor(second + HEADER_SIZE);
+ Assert.assertEquals(2, cursor.readVarint());
+ Assert.assertEquals(0, cursor.readVarint());
+ for (int i = 0; i < t1BodyLength; i++) {
+ Assert.assertEquals("second staged table body byte " + i,
+ Unsafe.getUnsafe().getByte(staged + t1BodyOffset + i),
+ Unsafe.getUnsafe().getByte(cursor.address + i));
+ }
+ }
+ });
+ }
+
@Test
public void testVersionByteInHeader() throws Exception {
assertMemoryLeak(() -> {
@@ -1312,6 +1451,22 @@ public void testVersionByteInHeader() throws Exception {
});
}
+ private static void assertFailure(
+ Class extends Throwable> expectedType,
+ String expectedMessage,
+ Runnable action
+ ) {
+ Throwable thrown = null;
+ try {
+ action.run();
+ } catch (Throwable t) {
+ thrown = t;
+ }
+ Assert.assertNotNull("expected " + expectedType.getSimpleName(), thrown);
+ Assert.assertEquals(expectedType, thrown.getClass());
+ Assert.assertEquals(expectedMessage, thrown.getMessage());
+ }
+
private static final class Cursor {
private long address;
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java
index f5805bb9..b1e21870 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java
@@ -30,6 +30,7 @@
import io.questdb.client.cutlass.qwp.client.MicrobatchBuffer;
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.MmapSegment;
import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer;
import io.questdb.client.std.Decimal128;
import io.questdb.client.std.Decimal256;
@@ -340,10 +341,12 @@ public void testFlushAppendFailureDoesNotLeaveMicrobatchBufferInUse() throws Exc
server.start();
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
- // Memory-only engine with a 33-byte budget and a 1 ns append
+ // Memory-only engine with the smallest viable segment budget (just
+ // enough for the header plus one minimal frame) and a 1 ns append
// deadline guarantees every appendBlocking() call trips the
// backpressure deadline and throws.
- CursorSendEngine engine = new CursorSendEngine(null, 33, 33, 1L);
+ long minSegmentBytes = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 1;
+ CursorSendEngine engine = new CursorSendEngine(null, minSegmentBytes, minSegmentBytes, 1L);
try (QwpWebSocketSender sender = QwpWebSocketSender.connect(
"localhost", port, null, Integer.MAX_VALUE, 0, 0L, null,
false, engine, 0L)) {
@@ -624,6 +627,104 @@ public void testResetAfterCloseThrows() throws Exception {
});
}
+ @Test
+ public void testRowAcceptedWhenServerAdvertisesNoCap() throws Exception {
+ // serverMaxBatchSize == 0 means "no cap" (an older server, or a failover to a
+ // node that advertises none). The per-row guard in sendRow must be skipped, so
+ // even a large row is accepted rather than rejected against a "cap" of 0. This
+ // is the invariant the sendRow cap snapshot protects: the I/O thread can lower
+ // the volatile serverMaxBatchSize to 0 mid-row, and a torn read that observed
+ // the drop between the guard and the throw must not spuriously reject the row.
+ assertMemoryLeak(() -> {
+ try (QwpWebSocketSender sender = QwpWebSocketSender.createForTesting(
+ "localhost", 9000,
+ /*autoFlushRows*/ Integer.MAX_VALUE,
+ /*autoFlushBytes*/ 0,
+ /*autoFlushIntervalNanos*/ 0L)) {
+ sender.setConnectedForTest(true);
+ sender.applyServerBatchSizeLimit(0);
+ Assert.assertEquals(0, sender.getServerMaxBatchSize());
+
+ StringBuilder big = new StringBuilder();
+ for (int i = 0; i < 256; i++) {
+ big.append('x');
+ }
+ // A row far larger than any small cap still commits when the server
+ // advertises no cap; the guard leaves it alone.
+ sender.table("t").stringColumn("s", big.toString()).atNow();
+
+ QwpTableBuffer buf = sender.getTableBuffer("t");
+ Assert.assertEquals(1, buf.getRowCount());
+ }
+ });
+ }
+
+ @Test
+ public void testRowExceedingServerBatchCapThrows() throws Exception {
+ // The per-row guard in sendRow rejects a single row whose encoded bytes
+ // already exceed the server's advertised cap, before nextRow() commits it, so
+ // the flush cannot build an oversize WS frame the server closes with 1009.
+ assertMemoryLeak(() -> {
+ try (QwpWebSocketSender sender = QwpWebSocketSender.createForTesting(
+ "localhost", 9000,
+ /*autoFlushRows*/ Integer.MAX_VALUE,
+ /*autoFlushBytes*/ 0,
+ /*autoFlushIntervalNanos*/ 0L)) {
+ sender.setConnectedForTest(true);
+ sender.applyServerBatchSizeLimit(64);
+ Assert.assertEquals(64, sender.getServerMaxBatchSize());
+
+ StringBuilder big = new StringBuilder();
+ for (int i = 0; i < 256; i++) {
+ big.append('x');
+ }
+ sender.table("t").stringColumn("s", big.toString());
+ try {
+ sender.atNow();
+ Assert.fail("Expected LineSenderException");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(
+ "expected 'row too large for server batch cap', got: " + e.getMessage(),
+ e.getMessage().contains("row too large for server batch cap"));
+ // The message reports the single snapshotted cap, not a torn value.
+ Assert.assertTrue(
+ "expected the snapshotted cap in the message, got: " + e.getMessage(),
+ e.getMessage().contains("serverMaxBatchSize=64"));
+ }
+ // The rejected row was rolled back before commit, leaving no row behind.
+ QwpTableBuffer buf = sender.getTableBuffer("t");
+ Assert.assertEquals(0, buf.getRowCount());
+ }
+ });
+ }
+
+ @Test
+ public void testCumulativeBufferedBytesDoNotTripPerRowCap() throws Exception {
+ // The guard budget is nextRow(currentTableBufferSnapshotBytes, cap): each
+ // row is measured against the snapshot taken at the previous commit, not
+ // against offset zero. A mutation to nextRow(0, cap) would reject every
+ // row once the table's CUMULATIVE buffered bytes crossed the cap; this
+ // pins the wiring the single-row tests cannot distinguish.
+ assertMemoryLeak(() -> {
+ try (QwpWebSocketSender sender = QwpWebSocketSender.createForTesting(
+ "localhost", 9000,
+ /*autoFlushRows*/ Integer.MAX_VALUE,
+ /*autoFlushBytes*/ 0,
+ /*autoFlushIntervalNanos*/ 0L)) {
+ sender.setConnectedForTest(true);
+ sender.applyServerBatchSizeLimit(64);
+
+ // Three rows of 22-26 bytes each: every row fits the 64-byte cap,
+ // but the running total (70 after row 3) exceeds it.
+ for (int i = 0; i < 3; i++) {
+ sender.table("t").stringColumn("s", "abcdefghijklmnopqr").atNow();
+ }
+ QwpTableBuffer buf = sender.getTableBuffer("t");
+ Assert.assertEquals(3, buf.getRowCount());
+ }
+ });
+ }
+
@Test
public void testStringColumnAfterCloseThrows() 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
new file mode 100644
index 00000000..1ed78752
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWireTestUtils.java
@@ -0,0 +1,183 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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 java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.FLAG_DELTA_SYMBOL_DICT;
+import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE;
+
+public final class QwpWireTestUtils {
+
+ /**
+ * As {@link #accumulateDeltaDictionary(byte[], List, boolean)} with {@code allowGap}
+ * false: the default, server-accurate model.
+ */
+ public static void accumulateDeltaDictionary(byte[] frame, List
+ * {@code allowGap} keeps the old null-padding so a caller can OBSERVE a gap instead
+ * of having it thrown. Only catch-up tiling assertions -- which exist to PROVE there
+ * is no hole -- should pass {@code true}.
+ */
+ public static void accumulateDeltaDictionary(byte[] frame, List
* This commit covers the mechanics with a single-attempt retry; backoff,
* per-outage time cap, and auth-failure detection follow.
@@ -202,14 +202,13 @@ public void testReconnectNeverGivesUpInvariantB() throws Exception {
}
@Test
- public void testTerminalUpgradeErrorAbortsReconnect() throws Exception {
+ public void testPostStartAuthErrorRemainsBuffered() throws Exception {
// Bespoke raw-socket fixture: first connection completes the
// WebSocket upgrade and feeds back STATUS_OK ACKs; any subsequent
// connection gets HTTP 401 Unauthorized — exercising the
- // auth-terminal path. With reconnect_max_duration_millis=10s and
- // a 401 happening on the very first reconnect, the cursor I/O
- // loop should surface the terminal error within hundreds of ms,
- // not after 10s.
+ // post-start auth-rotation path. Once a foreground sender has been
+ // live, store-and-forward owns its unacked data: repeated 401s must
+ // remain contained and retried rather than stopping the producer.
try (Auth401AfterFirstConnectionFixture fixture =
new Auth401AfterFirstConnectionFixture()) {
fixture.start();
@@ -223,9 +222,8 @@ public void testTerminalUpgradeErrorAbortsReconnect() throws Exception {
// Wait for first connection to ACK + close
waitFor(() -> fixture.acceptedConnections.get() >= 2, 5_000);
- long t0 = System.nanoTime();
Throwable observed = null;
- long deadline = System.currentTimeMillis() + 5_000;
+ long deadline = System.currentTimeMillis() + 750;
while (System.currentTimeMillis() < deadline) {
try {
sender.table("foo").longColumn("v", 2L).atNow();
@@ -236,23 +234,10 @@ public void testTerminalUpgradeErrorAbortsReconnect() throws Exception {
}
Thread.sleep(50);
}
- long elapsedMs = (System.nanoTime() - t0) / 1_000_000L;
- Assert.assertNotNull("expected terminal error after auth rejection",
+ Assert.assertNull("post-start auth rejection must stay buffered and retriable",
observed);
- Assert.assertTrue(
- "terminal upgrade error must surface well inside the cap; took "
- + elapsedMs + "ms (cap was 10000ms)",
- elapsedMs < 5_000);
- String msg = observed.getMessage() == null ? "" : observed.getMessage();
- Assert.assertTrue(
- "error must mention the terminal upgrade failure: " + msg,
- msg.contains("WebSocket upgrade failed")
- || msg.contains("I/O thread failed")
- || msg.contains("401"));
- } catch (LineSenderException ignored) {
+ waitFor(() -> fixture.acceptedConnections.get() >= 3, 5_000);
}
- // close() rethrows the latched terminal upgrade error
- // (commit 052f6ee). Already observed and asserted above.
}
}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RecoveryReplayTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RecoveryReplayTest.java
index 98a59e6f..528c93f5 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RecoveryReplayTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/RecoveryReplayTest.java
@@ -154,7 +154,8 @@ private static int countPopulatedSegmentFiles(String dir) {
int rc = 1;
while (rc > 0) {
String name = Files.utf8ToString(Files.findName(find));
- if (name != null && name.endsWith(".sfa")) {
+ if (name != null
+ && name.endsWith(".sfa")) {
try {
try (MmapSegment seg = MmapSegment.openExisting(dir + "/" + name)) {
if (seg.frameCount() > 0) n++;
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SegmentSkipQuarantineTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SegmentSkipQuarantineTest.java
new file mode 100644
index 00000000..95beb1c3
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SegmentSkipQuarantineTest.java
@@ -0,0 +1,429 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
+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 org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.file.Paths;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Critical-review pin-down for the {@code Sender.build()} half of the C5 fix: a slot whose
+ * recovery had to skip an unreadable segment must be quarantined -- the same whole-directory
+ * rename-and-continue treatment already proven for the symbol-dictionary refusal -- not merely
+ * refuse to construct.
+ *
+ * Before this fix, {@code CursorSendEngine}'s constructor could throw
+ * {@code UnreplayableSlotException} ({@code SegmentRing.openExisting} refuses when it had to skip
+ * an unreadable segment), but {@code Sender.build()}'s {@code catch (UnreplayableSlotException e)}
+ * wrapped only {@code QwpWebSocketSender.connect(...)}, not the constructor call itself -- so the
+ * refusal escaped {@code build()} entirely, uncaught. Worse: because the skipped file gets renamed
+ * to {@code
+ * The fix reuses {@code quarantineTornSlot} (with a {@code null} live engine) to rename the WHOLE
+ * slot directory aside, the same mechanism already proven for the dictionary-refusal case in
+ * {@code DeltaDictRecoveryTest}. That is what closes the gap: the replacement engine is built at
+ * the original {@code slotPath}, which is now a genuinely fresh, empty directory with nothing
+ * left to skip.
+ */
+public class SegmentSkipQuarantineTest {
+
+ private String sfDir;
+
+ @Before
+ public void setUp() {
+ sfDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-skip-quarantine-" + System.nanoTime()).toString();
+ }
+
+ @After
+ public void tearDown() {
+ if (sfDir != null) rmDirRec(sfDir);
+ }
+
+ @Test(timeout = 30_000L)
+ public void testConstructionTimeSkipQuarantinesTheWholeSlotAndProducerContinues() throws Exception {
+ assertMemoryLeak(() -> {
+ java.nio.file.Path liveSlot = writeMultiSegmentSlotWithCorruptedOldest();
+
+ // Phase 2: a fresh server. The recovering sender's CONSTRUCTOR now hits an
+ // unreadable oldest segment. build() must not throw -- the slot must be
+ // quarantined and the producer must keep working on a fresh one.
+ AtomicBoolean sawBinary = new AtomicBoolean();
+ try (TestWebSocketServer good = new TestWebSocketServer(new MarkerHandler(sawBinary))) {
+ int port = good.getPort();
+ good.start();
+ Assert.assertTrue(good.awaitStart(5, TimeUnit.SECONDS));
+ String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
+ + ";close_flush_timeout_millis=0;";
+ try (Sender s2 = Sender.fromConfig(cfg)) {
+ s2.table("foo").stringColumn("p", "after-quarantine").longColumn("v", 1).atNow();
+ s2.flush();
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (System.currentTimeMillis() < deadline && !sawBinary.get()) {
+ Thread.sleep(20);
+ }
+ }
+ Assert.assertTrue("the producer must keep producing after the construction-time "
+ + "refusal, not brick build()", sawBinary.get());
+ }
+
+ // The tainted directory was renamed aside WHOLESALE -- the same treatment
+ // DeltaDictRecoveryTest proves for the dictionary-refusal case -- not a narrower
+ // workaround that leaves it sitting at the original path.
+ java.nio.file.Path quarantined = Paths.get(sfDir, "default.unreplayable-0");
+ Assert.assertTrue("the tainted slot must be quarantined wholesale, not left in place",
+ java.nio.file.Files.isDirectory(quarantined));
+ Assert.assertTrue("the quarantined copy must carry the .failed sentinel so the orphan "
+ + "drainer never re-adopts it (OrphanScanner also excludes it by the "
+ + "\"unreplayable-\" infix, independent of this sentinel)",
+ java.nio.file.Files.exists(quarantined.resolve(".failed")));
+ // Recovery failed CLOSED, so it never reached its deferred quarantine step:
+ // the corrupt segment keeps its original name and every byte stays put.
+ // Preserve-by-rename happens at the SLOT level here (the whole directory
+ // moved aside), which is what keeps the data available for a manual resend.
+ Assert.assertTrue("the corrupted segment's bytes must survive inside the "
+ + "quarantined copy, untouched by the failed recovery",
+ java.nio.file.Files.exists(quarantined.resolve("sf-initial.sfa")));
+
+ // The critical regression check: the live slot is a GENUINELY FRESH directory, not
+ // the tainted one left in place with the corrupt file merely invisible to a
+ // re-scan. Before this fix, a second SegmentRing.openExisting on the ORIGINAL
+ // directory would silently recover a ring missing the oldest segment (the
+ // corrupt-renamed file no longer matches ".sfa"), seeding ackedFsn past its
+ // frames. Here there is no "original directory" left to re-scan at all --
+ // sf-initial.sfa in the LIVE slot must be a fresh baseSeq=0 segment holding only
+ // what sender 2 wrote, not a continuation of sender 1's FSN sequence.
+ Assert.assertTrue("the live slot must exist as a fresh directory",
+ java.nio.file.Files.isDirectory(liveSlot));
+ java.nio.file.Path liveInitial = liveSlot.resolve("sf-initial.sfa");
+ Assert.assertTrue("the live slot must have its own fresh sf-initial.sfa",
+ java.nio.file.Files.exists(liveInitial));
+ try (MmapSegment seg = MmapSegment.openExisting(liveInitial.toString())) {
+ Assert.assertEquals("a genuinely fresh ring must restart FSNs at 0, not continue "
+ + "sender 1's sequence -- continuing would mean the live slot is "
+ + "really the tainted one, just relabelled",
+ 0L, seg.baseSeq());
+ Assert.assertEquals("the fresh slot must hold only sender 2's one row",
+ 1L, seg.frameCount());
+ }
+ });
+ }
+
+ /**
+ * The construction-time quarantine reuses {@code quarantineTornSlot} unchanged, so the
+ * existing {@code MAX_QUARANTINE_SLOT_ATTEMPTS} (64) cap on {@code default.unreplayable-}
+ * candidates applies here exactly as it does to the connect()-time dictionary-refusal case
+ * ({@code DeltaDictRecoveryTest#testQuarantineFailsLoudlyWhenAllSlotNamesSaturated}).
+ * Mirrors that test for the construction-time trigger: when every candidate name is already
+ * taken, {@code build()} must fail LOUDLY -- a {@link LineSenderException} naming the
+ * problem -- rather than silently dropping the tainted slot's bytes. This is the failure
+ * mode an operator eventually meets if quarantined slots pile up faster than they are
+ * cleaned out.
+ *
+ * No live server is needed: quarantine (and its failure) happens entirely inside
+ * {@code build()}, before {@code connect()} is ever attempted.
+ */
+ @Test(timeout = 30_000L)
+ public void testConstructionTimeQuarantineFailsLoudlyWhenAllSlotNamesSaturated() throws Exception {
+ assertMemoryLeak(() -> {
+ java.nio.file.Path liveSlot = writeMultiSegmentSlotWithCorruptedOldest();
+ for (int i = 0; i < 64; i++) {
+ java.nio.file.Files.createDirectories(Paths.get(sfDir, "default.unreplayable-" + i));
+ }
+
+ String cfg = "ws::addr=localhost:1;sf_dir=" + sfDir + ";";
+ Sender s = null;
+ try {
+ s = Sender.fromConfig(cfg);
+ Assert.fail("build() must throw when the unreplayable slot cannot be set aside");
+ } catch (LineSenderException expected) {
+ Assert.assertTrue("unexpected message: " + expected.getMessage(),
+ expected.getMessage().contains("too many quarantined slots already under")
+ && expected.getMessage().contains("moved or removed by hand"));
+ } finally {
+ if (s != null) {
+ s.close();
+ }
+ }
+ // The tainted slot's bytes must survive on disk for a manual resend -- the guard
+ // fails loudly rather than dropping data, exactly like the connect()-time case.
+ // Recovery fails closed BEFORE its deferred quarantine step, so the corrupted
+ // segment is still at its original name: a failed recovery never mutates the
+ // slot it could not prove safe.
+ Assert.assertTrue("the slot dir must be preserved", java.nio.file.Files.exists(liveSlot));
+ Assert.assertTrue("the corrupted segment's frame data must be preserved",
+ java.nio.file.Files.exists(liveSlot.resolve("sf-initial.sfa")));
+ });
+ }
+
+ /**
+ * A build()-time quarantine abandons buffered rows, and the synchronous {@link SenderError}
+ * dispatch (fired from inside {@code build()} itself -- see {@code Sender.java} around the
+ * quarantine block) is the only programmatic channel telling the application that data
+ * needs resending: the client ships {@code slf4j-api} with no binding, so {@code LOG.error}
+ * alone can vanish into a NOP logger. Pins that a capturing handler actually receives the
+ * error, and that it carries the expected category, policy, and a message naming the
+ * quarantined location.
+ */
+ @Test(timeout = 30_000L)
+ public void testQuarantineDispatchesSenderErrorToHandler() throws Exception {
+ assertMemoryLeak(() -> {
+ writeMultiSegmentSlotWithCorruptedOldest();
+ AtomicReference
+ * {@code sf_max_segment_bytes} is set well below the 20 rows' total encoded size (each
+ * row seals to a ~108-byte frame, so 20 rows never fill the default 4096-byte segment) so
+ * the append path itself performs a genuine rotation -- {@code SegmentRing.appendOrFsn}
+ * only rotates when {@code tryAppend} reports the active segment full. Without this, the
+ * setup's old {@code > 1} segment-file precondition could be satisfied by nothing more than
+ * the empty hot spare {@code SegmentManager} provisions asynchronously the moment the ring
+ * registers ({@code SegmentRing.needsHotSpare} is simply {@code hotSpare == null}, independent
+ * of bytes written) -- a background-thread race against this method's {@code Sender} closing,
+ * observed to fail consistently on Windows CI and to coin-flip on the JDK 8 Linux leg. Forcing
+ * a genuine rotation makes the precondition -- and the corruption scenario it sets up, a skip
+ * among data-bearing survivors -- true by construction rather than by timing.
+ */
+ private java.nio.file.Path writeMultiSegmentSlotWithCorruptedOldest() throws Exception {
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+ String pad = io.questdb.client.test.tools.TestUtils.repeat("x", 64);
+ String cfg = "ws::addr=localhost:" + port
+ + ";sf_dir=" + sfDir
+ + ";sf_max_segment_bytes=512"
+ + ";close_flush_timeout_millis=0;";
+ try (Sender s1 = Sender.fromConfig(cfg)) {
+ for (int i = 0; i < 20; i++) {
+ s1.table("foo").stringColumn("p", pad).longColumn("v", i).atNow();
+ s1.flush();
+ }
+ }
+ }
+
+ java.nio.file.Path liveSlot = Paths.get(sfDir, "default");
+ java.nio.file.Path oldest = liveSlot.resolve("sf-initial.sfa");
+ Assert.assertTrue("setup: sf-initial.sfa must survive -- nothing acked it",
+ java.nio.file.Files.exists(oldest));
+ java.util.List
- * The cursor SF path used to elide previously-sent symbols on subsequent
- * batches over the same connection, emitting a delta-dict that carried only
- * the new entries. That's wrong for SF: the bytes survive process restarts and
- * replay against fresh server connections (post-reconnect, or via a background
- * drainer adopting an orphan slot). A delta that references symbol ids the new
- * server has never seen is unrecoverable.
+ * Both engine modes ship monotonic deltas -- each symbol id travels once,
+ * not the whole dictionary per message -- which is the bandwidth win this feature
+ * adds. The I/O thread re-registers the dictionary with a catch-up frame whenever
+ * it (re)connects, so a fresh server can resolve the non-self-sufficient delta
+ * frames that follow.
*
- * Today every frame must carry a complete symbol-dict delta starting at id 0
- * (column schemas travel inline on the first batch too). This test asserts the
- * symbol-dict invariant on the wire.
+ * The modes differ only in where the catch-up's dictionary comes from: memory
+ * mode keeps it in an in-process mirror; file-backed store-and-forward keeps it in
+ * a per-slot {@code .symbol-dict} file so a recovered or orphan-drained slot (a
+ * fresh process with no in-memory mirror) can rebuild it. This test asserts the
+ * monotonic wire framing in both modes and the presence of that dictionary file.
*/
public class SelfSufficientFramesTest {
/** First byte of the symbol-dict delta payload after the 12-byte QWP header. */
private static final int DELTA_START_OFFSET = 12;
+ @Rule
+ public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build();
+
+ @Test
+ public void testFileModeShipsMonotonicDeltaAndPersistsDict() throws Exception {
+ // File-backed SF also ships monotonic deltas now: batch 2 carries only
+ // "beta" (deltaStart=1). The dictionary is durably kept in .symbol-dict
+ // so a recovered/orphan-drained slot can rebuild it.
+ Path sfDir = temporaryFolder.newFolder("qwp-sf-selfsufficient").toPath();
+ assertMemoryLeak(() -> {
+ CapturingHandler handler = new CapturingHandler();
+ 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 + ";";
+ // The engine places slot files under sf_dir/
+ * flushPendingRowsSplit publishes frames one at a time and documents that a failure
+ * partway through leaves frames 1..k-1 on the ring as deferred, with the next flush
+ * re-emitting the whole batch -- at-least-once, absorbed by DEDUP or a durable-ack
+ * await. Nothing exercised the k>1 shape at all: every other failed-publish test
+ * fails at frame 1.
+ *
+ * This is a CHARACTERISATION test, not a mutation guard, and the reason is worth
+ * recording. It confirms the javadoc's claim that "the re-sent frames carry empty
+ * deltas and the write-ahead persist is a pd.size() no-op" -- but it does not
+ * discriminate against reverting that pd.size() key, because frame 1 published
+ * SUCCESSFULLY and advanceSentMaxSymbolId therefore already moved the baseline past
+ * the whole batch. The retry is an early return under either key. That is precisely
+ * why the k>1 path is safe, and it is the fact the test pins.
+ *
+ * The failure is injected at the RING, not the cap: both frames pass the pre-flight
+ * (each is under the advertised cap) but "big"'s frame does not fit a fresh
+ * sf_max_segment_bytes segment, so it fails in sealAndSwapBuffer AFTER "a" was published.
+ */
+ @Test(timeout = 60_000L)
+ public void testMidSplitPublishFailureLeavesTheDictionaryIdempotent() throws Exception {
+ Path sfDir = temporaryFolder.newFolder("qwp-sf-midsplit").toPath();
+ assertMemoryLeak(() -> {
+ CapturingHandler handler = new CapturingHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ server.setAdvertisedMaxBatchSize(3_400);
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ String pad = new String(new char[100]).replace('\0', 'x');
+ String cfg = "ws::addr=localhost:" + port
+ + ";sf_dir=" + sfDir
+ + ";sf_max_segment_bytes=1024"
+ + ";auto_flush_bytes=off;auto_flush_rows=1000000;auto_flush_interval=60000"
+ + ";close_flush_timeout_millis=0;";
+ Sender sender = Sender.fromConfig(cfg);
+ try {
+ // "a" first (small: fits a segment), "big" second (does not).
+ sender.table("a").symbol("s", "alpha").longColumn("v", 1L).atNow();
+ for (int i = 0; i < 28; i++) {
+ sender.table("big").symbol("s", "bravo")
+ .stringColumn("p", pad).longColumn("v", (long) i).atNow();
+ }
+
+ ObjList
+ * Before this fix, {@code BackgroundDrainer.run()} caught only {@code IllegalStateException}
+ * around {@code new CursorSendEngine(...)}. {@code UnreplayableSlotException} fell through
+ * to the generic {@code catch (Throwable)}, whose {@code engine != null} gate -- correct for
+ * a genuinely pre-adoption failure -- also suppressed the sentinel write here, because
+ * {@code engine} is null precisely because the constructor threw. With no sentinel, the slot
+ * stayed a valid orphan candidate: the skipped segment is already renamed {@code .corrupt} on
+ * disk, so a second recovery attempt recomputes {@code skippedSegmentCount} as zero, sees
+ * only the two survivors, and seeds {@code ackedFsn} one below their lowest baseSeq --
+ * declaring the frames the skipped segment held as already-acked and reporting a clean drain
+ * over data that was never actually sent.
+ */
+public class BackgroundDrainerUnreplayableSlotQuarantineTest {
+
+ private static final int FRAME_PAYLOAD_BYTES = 16;
+
+ @Rule
+ public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build();
+
+ @Test
+ public void testSecondDrainerNeverReAdoptsAQuarantinedSlot() throws Exception {
+ String slotPath = temporaryFolder.newFolder("slot").getAbsolutePath();
+ long segSize = MmapSegment.HEADER_SIZE + 10 * (MmapSegment.FRAME_HEADER_SIZE + FRAME_PAYLOAD_BYTES);
+ TestUtils.assertMemoryLeak(() -> {
+ // Three segments: bases 0, 10, 20. The oldest is corrupted after being
+ // written, so recovery has to skip it -- exactly the loss chain CRITICAL-1
+ // describes.
+ String oldestPath = slotPath + "/skip-oldest-0.sfa";
+ writeSegment(oldestPath, 0, segSize, 10);
+ writeSegment(slotPath + "/skip-oldest-1.sfa", 10, segSize, 10);
+ writeSegment(slotPath + "/skip-oldest-2.sfa", 20, segSize, 1);
+ corruptMagic(oldestPath);
+
+ AtomicInteger connectAttempts = new AtomicInteger();
+ BackgroundDrainer drainer1 = new BackgroundDrainer(
+ slotPath,
+ segSize,
+ 1L << 20,
+ () -> {
+ connectAttempts.incrementAndGet();
+ throw new AssertionError(
+ "recovery failure must be caught before any connect attempt");
+ },
+ 5_000L, 1L, 5L, true, 200L);
+
+ List
+ * Every frame the shipped client ever wrote passes confirmedMaxId = -1, so deltaStart
+ * is 0 and maxDeltaStart is 0; such a slot has no .symbol-dict, so recovery creates a
+ * fresh empty one. All three discard conditions therefore hold on the FIRST restart
+ * after upgrading, for every non-empty slot -- and the first fold was already keyed to
+ * that size-0 baseline, so the re-fold recomputed a bit-identical result over the whole
+ * backlog. Full-dict frames carry the entire dictionary, so that is a second
+ * O(payload bytes) walk with a byte-at-a-time varint decode, inside build().
+ */
+ @Test(timeout = 20_000L)
+ public void testEmptyDictionaryDiscardDoesNotReFoldTheBacklog() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 4096)) {
+ appendFullDictFrame(engine, 'a', 'b', 'c');
+ }
+ try (CursorSendEngine reopened = new CursorSendEngine(tmpDir, 4096)) {
+ assertTrue("scaffolding: the discard must actually fire",
+ reopened.recoveredMaxSymbolDeltaStart() == 0L
+ && reopened.recoveredMaxSymbolId() >= 0L);
+ assertEquals("an empty discarded dictionary needs no second fold",
+ 1, reopened.recoveryFoldCount());
+ }
+ });
+ }
+
+ /**
+ * The counterpart: discarding a POPULATED dictionary really does desynchronise the
+ * fold from the baseline its consumers derive, so that case must still re-fold.
+ */
+ @Test(timeout = 20_000L)
+ public void testPopulatedDictionaryDiscardStillReFolds() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 4096)) {
+ appendFullDictFrame(engine, 'a', 'b', 'c');
+ }
+ // Two persisted entries, below the three the frames define, so
+ // recoveredMaxSymbolId (2) >= size (2) and the discard fires with size > 0.
+ try (PersistedSymbolDict pd = PersistedSymbolDict.openClean(tmpDir)) {
+ assertNotNull(pd);
+ pd.appendSymbol("a");
+ pd.appendSymbol("b");
+ }
+ try (CursorSendEngine reopened = new CursorSendEngine(tmpDir, 4096)) {
+ assertEquals("a populated discard must re-fold at baseline 0",
+ 2, reopened.recoveryFoldCount());
+ }
+ });
+ }
+
+ /**
+ * A delta-symbol-dictionary frame starting at {@code deltaStart}, one 1-byte symbol
+ * per char. deltaStart and deltaCount are small enough to be single-byte varints.
+ */
+ private static void appendDeltaDictFrame(CursorSendEngine engine, int deltaStart, char... symbols) {
+ int size = QwpConstants.HEADER_SIZE + 2 + symbols.length * 2;
+ long buf = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < size; i++) {
+ Unsafe.getUnsafe().putByte(buf + i, (byte) 0);
+ }
+ Unsafe.getUnsafe().putInt(buf, QwpConstants.MAGIC_MESSAGE);
+ Unsafe.getUnsafe().putByte(buf + QwpConstants.HEADER_OFFSET_FLAGS,
+ QwpConstants.FLAG_DELTA_SYMBOL_DICT);
+ long p = buf + QwpConstants.HEADER_SIZE;
+ Unsafe.getUnsafe().putByte(p, (byte) deltaStart); // deltaStart
+ Unsafe.getUnsafe().putByte(p + 1, (byte) symbols.length); // deltaCount
+ long q = p + 2;
+ for (char c : symbols) {
+ Unsafe.getUnsafe().putByte(q, (byte) 1);
+ Unsafe.getUnsafe().putByte(q + 1, (byte) c);
+ q += 2;
+ }
+ engine.appendBlocking(buf, size);
+ } finally {
+ Unsafe.free(buf, size, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ /** A self-sufficient frame: deltaStart 0, one 1-byte symbol per char. */
+ private static void appendFullDictFrame(CursorSendEngine engine, char... symbols) {
+ int size = QwpConstants.HEADER_SIZE + 2 + symbols.length * 2;
+ long buf = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < size; i++) {
+ Unsafe.getUnsafe().putByte(buf + i, (byte) 0);
+ }
+ Unsafe.getUnsafe().putInt(buf, QwpConstants.MAGIC_MESSAGE);
+ Unsafe.getUnsafe().putByte(buf + QwpConstants.HEADER_OFFSET_FLAGS,
+ QwpConstants.FLAG_DELTA_SYMBOL_DICT);
+ long p = buf + QwpConstants.HEADER_SIZE;
+ Unsafe.getUnsafe().putByte(p, (byte) 0); // deltaStart
+ Unsafe.getUnsafe().putByte(p + 1, (byte) symbols.length); // deltaCount
+ long q = p + 2;
+ for (char c : symbols) {
+ Unsafe.getUnsafe().putByte(q, (byte) 1);
+ Unsafe.getUnsafe().putByte(q + 1, (byte) c);
+ q += 2;
+ }
+ engine.appendBlocking(buf, size);
+ } finally {
+ Unsafe.free(buf, size, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+
+ /**
+ * The must-NOT-fire twin of {@link #testPopulatedDictionaryDiscardStillReFolds}.
+ *
+ * A sender that ran in delta mode and then hit a disk-full / mmap fault on its
+ * write-ahead dictionary persist calls disableDeltaDict: it keeps the delta frames it
+ * already published and appends self-sufficient full-dict frames for the rest of its
+ * life. The recovered slot therefore carries a DELTA PREFIX (deltaStart 0, 1) followed
+ * by a FULL-DICT SUFFIX (deltaStart 0), so maxDeltaStart() stays > 0 even though the
+ * last frame re-registers from id 0. The discard guard's third conjunct
+ * (recoveredMaxSymbolDeltaStart == 0) is thus false and it must NOT discard the
+ * side-file -- the delta-prefix frames are not self-sufficient and still need it.
+ * Discarding here would re-fold at baseline 0 and drop a still-delta slot into
+ * full-dict mode.
+ *
+ * {@code CursorWebSocketSendLoopOrphanTailTest#testTornDeltaSlotKeepsItsDictionaryInstead-
+ * OfBeingDiscarded} already guards the deltaStart conjunct with an all-delta torn slot
+ * (deltaStart 2, 3). This adds the mid-life shape's distinguishing tail: a full-dict
+ * SUFFIX at deltaStart 0 above a lower delta prefix, so maxDeltaStart (1) exceeds the
+ * LAST frame's deltaStart (0) -- a mutant reading the last frame's deltaStart instead of
+ * the running max would slip through the all-delta fixture but is caught here. It also
+ * pins recoveryFoldCount() == 1, i.e. that the whole discard/re-fold block is skipped.
+ */
+ @Test(timeout = 20_000L)
+ public void testMidLifeDegradedSlotKeepsItsDictionaryOnRecovery() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // Delta prefix (two frames, one new id each -> maxDeltaStart 1) then the
+ // full-dict suffix the degraded producer emits (deltaStart 0, whole dict).
+ try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 4096)) {
+ appendDeltaDictFrame(engine, 0, 'a');
+ appendDeltaDictFrame(engine, 1, 'b');
+ appendFullDictFrame(engine, 'a', 'b', 'c');
+ }
+ // The side-file the delta prefix persisted before the fault holds ids 0 and 1
+ // (a, b); id 2 (c) is the write that faulted, so it never persisted. This is
+ // the same recoveredMaxSymbolId (2) >= size (2) geometry as the discard test,
+ // but the delta prefix keeps maxDeltaStart > 0.
+ try (PersistedSymbolDict pd = PersistedSymbolDict.openClean(tmpDir)) {
+ assertNotNull(pd);
+ pd.appendSymbol("a");
+ pd.appendSymbol("b");
+ }
+ try (CursorSendEngine reopened = new CursorSendEngine(tmpDir, 4096)) {
+ assertTrue("the delta prefix must survive the fold, keeping maxDeltaStart > 0",
+ reopened.recoveredMaxSymbolDeltaStart() > 0L);
+ assertTrue("the full-dict suffix references id 2, above the side-file's size",
+ reopened.recoveredMaxSymbolId() >= 2L);
+ assertTrue("the discard guard must NOT fire: the side-file is load-bearing for "
+ + "the delta prefix, so it is kept and delta mode preserved",
+ reopened.isDeltaDictEnabled());
+ assertNotNull("the persisted dictionary must be kept, not discarded",
+ reopened.getPersistedSymbolDict());
+ assertEquals("a kept dictionary needs no re-fold -- the discard block was skipped",
+ 1, reopened.recoveryFoldCount());
+ }
+ });
+ }
+
+ /**
+ * A recovery-seed baseline mismatch must be quarantinable, not a permanent brick.
+ *
+ * checkedRecoveryAnalysis threw a raw IllegalStateException, and Sender.build() routes
+ * on the TYPE -- only UnreplayableSlotException reaches its quarantine handler. With a
+ * stable senderId and a not-fully-drained slot retained on close, every restart
+ * re-recovered the same slot and rethrew: the application could never construct a
+ * Sender, so it could not even BUFFER new rows. Revert the type and this goes red.
+ */
+ @Test(timeout = 10_000L)
+ public void testRecoveryBaselineMismatchIsQuarantinableNotAPermanentBrick() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT);
+ try {
+ try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 4096)) {
+ engine.appendBlocking(buf, 64);
+ }
+ } finally {
+ Unsafe.free(buf, 64, MemoryTag.NATIVE_DEFAULT);
+ }
+ try (CursorSendEngine reopened = new CursorSendEngine(tmpDir, 4096)) {
+ assertTrue("scaffolding: the slot must have been recovered",
+ reopened.wasRecoveredFromDisk());
+ try {
+ // The fold was keyed to the persisted prefix size; ask for a baseline
+ // that cannot match it.
+ reopened.addRecoveredSymbolsTo(9_999, new GlobalSymbolDictionary());
+ fail("a baseline mismatch must be reported");
+ } catch (UnreplayableSlotException expected) {
+ assertTrue("expected the baseline-mismatch message, got: "
+ + expected.getMessage(),
+ expected.getMessage().contains("recovery symbol baseline mismatch"));
+ }
+ }
+ });
+ }
+
+
private String tmpDir;
@Before
@@ -180,9 +388,11 @@ public void testAppendBlockingThrowsOnDeadlineExpiryUnderCap() throws Exception
// cap = 3*segSize and segSize fitting 2 frames, the producer can land
// initial (2) + spare1 (2) + spare2 (2) = 6 frames. The 7th rotation
// needs a spare3 that the cap forbids → backpressure → deadline.
+ // The cap also includes the dictionary side-file bytes (8), so we add
+ // that to ensure the same number of segments fit.
long segSize = MmapSegment.HEADER_SIZE
+ 2 * (MmapSegment.FRAME_HEADER_SIZE + 64);
- long cap = 3 * segSize;
+ long cap = 3 * segSize + 8;
long shortDeadlineNanos = 200_000_000L; // 200 ms
long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT);
try (CursorSendEngine engine = new CursorSendEngine(tmpDir, segSize, cap, shortDeadlineNanos)) {
@@ -310,6 +520,29 @@ public void testConstructorFailureWithSharedManagerReleasesSlotButKeepsManagerRu
});
}
+ @Test
+ public void testConstructorRefusesFreshSlotWhoseDictionaryPathIsARealDirectory() throws Exception {
+ // C9-A end-to-end, through a REAL EISDIR on the real FilesFacade -- not
+ // PersistedSymbolDict in isolation, and not a stubbed facade. openCleanRW
+ // is open(O_CREAT|O_TRUNC|O_RDWR, 0644), which fails with EISDIR against
+ // an existing directory, so this reproduces the real failure mode
+ // (a Windows share lock, an EIO, or anything else occupying the path that
+ // a plain open() cannot clear) and proves the refusal actually propagates
+ // out of the constructor's catch (Throwable) cleanup-and-rethrow, rather
+ // than being swallowed there.
+ TestUtils.assertMemoryLeak(() -> {
+ assertEquals(0, Files.mkdir(tmpDir + "/.symbol-dict", Files.DIR_MODE_DEFAULT));
+ try {
+ new CursorSendEngine(tmpDir, 4096);
+ fail("expected construction to refuse a fresh slot whose dictionary "
+ + "path is blocked by an existing directory");
+ } catch (LineSenderException expected) {
+ assertTrue("expected the truncate-refusal message, got: " + expected.getMessage(),
+ expected.getMessage().contains("cannot be truncated"));
+ }
+ });
+ }
+
@Test
public void testMemoryModeSkipsDirAndStillWorks() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -528,7 +761,10 @@ public void testRestartIntoNonEmptySfDirContinuesFsnSequence() throws Exception
int rc = 1;
while (rc > 0) {
String name = Files.utf8ToString(Files.findName(find));
- if (name != null && name.endsWith(".sfa")) sfaCount++;
+ if (name != null
+ && name.endsWith(".sfa")) {
+ sfaCount++;
+ }
rc = Files.findNext(find);
}
} finally {
@@ -715,6 +951,40 @@ public void testWasRecoveredFromDiskTrueOnReopen() throws Exception {
});
}
+ @Test
+ public void testRegistrationWiresDictSideFileBytesIntoCapAccounting() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ SegmentManager manager = new SegmentManager(4096);
+ try {
+ try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 4096, manager)) {
+ PersistedSymbolDict dict = engine.getPersistedSymbolDict();
+ assertNotNull("disk-mode engine must own a symbol dictionary", dict);
+ // A fresh dictionary already holds its file header, so the
+ // wiring is observable before any append.
+ assertEquals("cap accounting must include the dictionary side-file bytes",
+ manager.getTotalBytesForTesting() + dict.occupiedDiskBytes(),
+ manager.getCapAccountedBytesForTesting());
+ dict.appendSymbol("AAPL");
+ assertEquals("side-file growth must be visible to the cap accounting live",
+ manager.getTotalBytesForTesting() + dict.occupiedDiskBytes(),
+ manager.getCapAccountedBytesForTesting());
+ // The gauge is the DISK footprint, not the logical offset:
+ // the first append reserves a whole APPEND_MAP_CAPACITY
+ // window of real blocks past the committed prefix, and the
+ // cap must see those.
+ assertTrue("the gauge must count the reserved append window, not just the"
+ + " committed prefix",
+ dict.occupiedDiskBytes() > dict.appendedBytes());
+ }
+ assertEquals("deregistration must drop the slot's side-file gauge",
+ manager.getTotalBytesForTesting(),
+ manager.getCapAccountedBytesForTesting());
+ } finally {
+ manager.close();
+ }
+ });
+ }
+
private static void assertSlotCanBeReacquired(String sfDir) {
try (SlotLock ignored = SlotLock.acquire(sfDir)) {
// good
@@ -724,11 +994,13 @@ private static void assertSlotCanBeReacquired(String sfDir) {
private static Throwable invokeOwnedPrivateConstructorExpectingFailure(
String sfDir, long segmentSizeBytes, SegmentManager manager) throws Exception {
Constructor
+ * On a fresh connection the loop re-registers the whole dictionary with a
+ * catch-up frame BEFORE replaying data frames. Each catch-up frame consumes a
+ * wire sequence, so the loop anchors {@code fsnAtZero = replayStart - catchUpFrames}
+ * to keep every catch-up frame mapped to an already-acked FSN. Dropping the
+ * {@code - catchUpFrames} term is silent data loss: a server ACK for a catch-up
+ * frame then translates through {@code engine.acknowledge(fsnAtZero + wireSeq)}
+ * to an FSN at or above {@code replayStart}, trimming a not-yet-delivered data
+ * frame from the store-and-forward log.
+ *
+ * The loop is constructed but never {@link CursorWebSocketSendLoop#start started};
+ * the catch-up runs against a stub {@link WebSocketClient} that counts frames, and
+ * the OK is delivered straight into the inner {@code ResponseHandler} -- the same
+ * white-box idiom {@code CursorWebSocketSendLoopDurableAckTest} uses, because
+ * {@code setWireBaselineWithCatchUp} and the wire ports have no public entry point.
+ * {@link CursorSendEngine#ackedFsn()} is the authoritative trim watermark asserted
+ * against.
+ */
+public class CursorWebSocketSendLoopCatchUpAlignmentTest {
+
+ private String tmpDir;
+
+ @Before
+ public void setUp() {
+ tmpDir = TestUtils.createTmpDir("qdb-cursor-catchup-");
+ }
+
+ @After
+ public void tearDown() {
+ TestUtils.removeTmpDir(tmpDir);
+ }
+
+ @Test
+ public void testCatchUpFrameAckDoesNotAdvanceTrimWatermark() throws Exception {
+ // Single catch-up frame (server advertises no cap). Two frames were
+ // acked before the reconnect (ackedFsn=1), FSN 2 is unacked. The catch-up
+ // frame's OK must NOT advance the watermark past 1 -- it carries no data,
+ // only the dictionary the fresh server needs before replay.
+ TestUtils.assertMemoryLeak(() -> {
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0); // 0 => no cap => one frame
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 3); // FSN 0,1,2 published
+ engine.acknowledge(1); // ackedFsn=1 => replayStart=2, FSN 2 still unacked
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ seedMirror(loop, "s0", "s1"); // sentDictCount=2 => catch-up fires
+ long replayStart = engine.ackedFsn() + 1L; // = 2
+
+ invokeSetWireBaselineWithCatchUp(loop, replayStart);
+
+ assertEquals("whole dictionary fits one frame under no cap",
+ 1, client.framesSent);
+
+ // Behavioural (the harm): the catch-up frame (wire seq 0) is
+ // OK'd by the fresh server. It carries no data, so it must
+ // resolve to an already-acked FSN and leave the trim watermark
+ // untouched -- advancing it would trim the undelivered FSN 2.
+ deliverOk(loop, 0);
+ assertEquals("catch-up frame ACK must not advance the trim watermark "
+ + "(would trim an undelivered data frame -> silent data loss)",
+ 1L, engine.ackedFsn());
+ // Mechanism: the catch-up frames are anchored below replayStart.
+ assertEquals("fsnAtZero must be anchored catchUpFrames below replayStart",
+ replayStart - client.framesSent, loop.fsnAtZero());
+ } finally {
+ loop.close(); // frees the seeded mirror + the stub client's buffers
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testSplitCatchUpFramesAcksDoNotAdvanceTrimWatermark() throws Exception {
+ // A small advertised cap splits the dictionary across several catch-up
+ // frames, so the fsnAtZero offset must subtract the full frame count. Ack
+ // the LAST catch-up wire sequence: it still maps below replayStart. With
+ // the offset dropped it would translate to replayStart+1 and over-trim.
+ TestUtils.assertMemoryLeak(() -> {
+ CatchUpCapturingClient client = new CatchUpCapturingClient(40); // budget 12 => one 11-byte symbol per frame
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 5); // FSN 0..4 published
+ engine.acknowledge(2); // ackedFsn=2 => replayStart=3, FSN 3,4 unacked
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ seedMirror(loop, "symbol0000", "symbol0001"); // 11 bytes each -> two frames
+ long replayStart = engine.ackedFsn() + 1L; // = 3
+
+ invokeSetWireBaselineWithCatchUp(loop, replayStart);
+
+ assertEquals("cap must split the two symbols across two frames",
+ 2, client.framesSent);
+
+ // ACK the highest catch-up wire sequence (the last catch-up
+ // frame). It too must map below replayStart -- with the offset
+ // dropped it translates to replayStart+1 and over-trims.
+ deliverOk(loop, client.framesSent - 1);
+ assertEquals("no catch-up frame ACK may advance the trim watermark",
+ 2L, engine.ackedFsn());
+ assertEquals("fsnAtZero must subtract the full split frame count",
+ replayStart - client.framesSent, loop.fsnAtZero());
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testSplitCatchUpStagesOnlyPrefixAcrossReconnects() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ CatchUpCapturingClient client = new CatchUpCapturingClient(3_100);
+ try (CursorSendEngine engine = newEngine()) {
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ String symX = TestUtils.repeat("x", 3_000);
+ String symY = TestUtils.repeat("y", 3_000);
+ seedMirror(loop, symX, symY);
+
+ invokeSetWireBaselineWithCatchUp(loop, 0L);
+ assertEquals("the small cap must split the dictionary", 2, client.framesSent);
+ assertEquals("catch-up must send the symbol bytes as a second payload slice",
+ 2, client.multipartFramesSent);
+ assertEquals("the split chunks need one small prefix buffer",
+ 1, loop.catchUpFrameGrowthCount());
+ // Splitting is only correct if the chunks reassemble gap-free.
+ assertCatchUpReassembles(client, symX, symY);
+
+ client.cap = 7_000;
+ invokeSetWireBaselineWithCatchUp(loop, 0L);
+ assertEquals("the larger cap must combine the dictionary", 3, client.framesSent);
+ assertEquals("combining symbols must not grow the prefix-only buffer",
+ 1, loop.catchUpFrameGrowthCount());
+ assertCatchUpReassembles(client, symX, symY);
+
+ invokeSetWireBaselineWithCatchUp(loop, 0L);
+ assertEquals("the next reconnect sends one combined frame", 4, client.framesSent);
+ assertEquals("the prefix buffer must be reused across reconnects",
+ 1, loop.catchUpFrameGrowthCount());
+ assertCatchUpReassembles(client, symX, symY);
+ } finally {
+ // assertMemoryLeak verifies that close releases the retained buffer.
+ loop.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testSplitCatchUpChunksTileTheDictionaryExactly() throws Exception {
+ // Three chunks rather than two: a start-id error that happens to cancel
+ // out across a single boundary cannot survive two of them, and only a
+ // multi-chunk walk exercises the accumulating "chunkStartId +=
+ // chunkSymbols" drift. Six 100-byte symbols under a cap that fits two
+ // per frame; the reassembly must return all six, in order, with no null.
+ TestUtils.assertMemoryLeak(() -> {
+ String[] symbols = new String[6];
+ for (int i = 0; i < symbols.length; i++) {
+ symbols[i] = TestUtils.repeat(String.valueOf((char) ('a' + i)), 100);
+ }
+ CatchUpCapturingClient client = new CatchUpCapturingClient(250);
+ try (CursorSendEngine engine = newEngine()) {
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ seedMirror(loop, symbols);
+ invokeSetWireBaselineWithCatchUp(loop, 0L);
+ assertEquals("the cap must force a three-way split", 3, client.framesSent);
+ assertCatchUpReassembles(client, symbols);
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testEmptyDictionaryReconnectSendsNoCatchUpFrame() throws Exception {
+ // The sentDictCount > 0 half of setWireBaselineWithCatchUp's gate. Every
+ // other test here seeds at least one symbol, so the empty-dictionary
+ // branch -- a delta-enabled connection that has not registered anything
+ // yet, i.e. every reconnect before the first symbol -- was never
+ // exercised. Emitting a zero-entry catch-up frame there would burn a wire
+ // sequence and, via fsnAtZero = replayStart - catchUpFrames, shift the
+ // baseline so the first real frame no longer lands on replayStart.
+ //
+ // framesSent and fsnAtZero alone do NOT discriminate the sentDictCount > 0
+ // conjunct: sendDictCatchUp has no unsplit fast path, so with sentDictCount == 0
+ // its walk is a no-op and both values come out identical whether or not the
+ // guard skips the call. capReads is what does discriminate it: sendDictCatchUp's
+ // first statement reads client.getServerMaxBatchSize(), so removing the
+ // sentDictCount > 0 conjunct (this test's engine has hasReplayDictionaryDependency
+ // == true and a real client, so the other two conjuncts hold) would call
+ // sendDictCatchUp anyway and register that read even though it ships nothing.
+ TestUtils.assertMemoryLeak(() -> {
+ // cap 0 ("server advertises no cap"); the value is otherwise incidental here.
+ AtomicInteger capReads = new AtomicInteger();
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0, false, capReads::incrementAndGet);
+ try (CursorSendEngine engine = newEngine()) {
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ // Deliberately no seedMirror: sentDictCount stays 0.
+ invokeSetWireBaselineWithCatchUp(loop, 7L);
+ assertEquals("an empty dictionary must not ship a catch-up frame",
+ 0, client.framesSent);
+ assertEquals("the baseline must stay at replayStart when nothing is re-registered",
+ 7L, loop.fsnAtZero());
+ assertEquals("an empty dictionary must skip sendDictCatchUp entirely -- "
+ + "not call it and discover there is nothing to send",
+ 0, capReads.get());
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testDegradedDeltaPrefixSlotWithLostDictStillEmitsReconnectCatchUp() throws Exception {
+ // Isolates the second disjunct of hasReplayDictionaryDependency
+ // (CursorWebSocketSendLoop constructor): isDeltaDictEnabled() ||
+ // recoveredMaxSymbolDeltaStart() > 0. It is load-bearing only when the first
+ // disjunct is false. A sender that degraded to full-dict (disableDeltaDict) and
+ // whose .symbol-dict then did not survive -- a host-crash tear on top of the
+ // degrade -- reports isDeltaDictEnabled() == false, yet its recovered ring still
+ // carries a DELTA PREFIX (deltaStart > 0) and the full-dict suffix it shipped after
+ // degrading. The loop rebuilds the mirror from the frames' own deltas (they carry
+ // their symbols inline) and MUST still re-register the whole dictionary with a
+ // catch-up: dropping the recoveredMaxSymbolDeltaStart > 0 disjunct makes
+ // hasReplayDictionaryDependency false, skips the catch-up, and replays the
+ // deltaStart > 0 prefix against a server that never registered ids 0..n -- a gap.
+ // Not covered elsewhere: CursorWebSocketSendLoopTornDictGuardTest exercises the
+ // GAPPED torn slot (frames out-reach what any frame defines -> refuse, no catch-up),
+ // and the seedMirror catch-up tests inject the mirror instead of recovering a slot.
+ TestUtils.assertMemoryLeak(() -> {
+ // Delta prefix (deltaStart 0, 1) then the full-dict suffix the degraded producer
+ // ships (deltaStart 0, whole dict).
+ try (CursorSendEngine writer = newEngine()) {
+ appendDeltaDictFrame(writer, 0, 'a');
+ appendDeltaDictFrame(writer, 1, 'b');
+ appendDeltaDictFrame(writer, 0, 'a', 'b', 'c');
+ }
+ // Tear the dictionary away, so recovery finds none: PersistedSymbolDict.open()
+ // returns null for an absent file and the recovery path never fabricates one,
+ // leaving isDeltaDictEnabled() false while the delta prefix keeps maxDeltaStart > 0.
+ Files.remove(tmpDir + "/" + PersistedSymbolDict.FILE_NAME);
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0); // no cap => one frame
+ try (CursorSendEngine engine = newEngine()) { // re-open recovers the slot
+ assertFalse("a torn/absent side-file leaves the delta dict disabled",
+ engine.isDeltaDictEnabled());
+ assertTrue("the delta prefix survives the fold, so maxDeltaStart > 0",
+ engine.recoveredMaxSymbolDeltaStart() > 0L);
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ invokeSetWireBaselineWithCatchUp(loop, engine.ackedFsn() + 1L);
+ assertTrue("a degraded delta-prefix slot must STILL emit a reconnect catch-up "
+ + "via the recoveredMaxSymbolDeltaStart > 0 disjunct, even with "
+ + "the delta dict disabled", client.framesSent >= 1);
+ // The catch-up re-registers the whole rebuilt dictionary [a, b, c] -- the
+ // ids the delta prefix and full-dict suffix reference -- so the replayed
+ // deltaStart=0 suffix redefines ids the server already holds.
+ assertCatchUpReassembles(client, "a", "b", "c");
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testCatchUpChunksBelowTheDefaultReceiveBufferWhenNoCapIsAdvertised() throws Exception {
+ // getServerMaxBatchSize() == 0 means "not advertised", not "unbounded". The
+ // transport still closes anything larger than the server's receive buffer -- the
+ // default is 131072 -- with 1009, which is correctly non-terminal for a
+ // catch-up-only connection, so the reconnect ships the identical frame forever.
+ TestUtils.assertMemoryLeak(() -> {
+ List
+ * readVarintAt used to return 0 with its end position AT the limit when the payload
+ * was already exhausted, so a caller computing {@code p = end + len} got exactly
+ * {@code p == limit} and its {@code p > limit} bail-out could not fire. The walk then
+ * ran the remaining pseudo-entries for free and {@code sentDictCount += newCount}
+ * claimed symbols the mirror has no bytes for -- after which the reconnect catch-up
+ * ships a chunk whose deltaCount exceeds its payload. Returning -1 for a truncated
+ * varint makes the boundary detectable and every bail-out fire.
+ *
+ * The frame here is well-formed except for its count: two entries declared, one
+ * supplied, ending exactly on the boundary that used to slip through.
+ */
+ @Test
+ public void testDeltaDeclaringMoreEntriesThanItCarriesDoesNotAdvanceTheMirror() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0);
+ try (CursorSendEngine engine = newEngine()) {
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ // [deltaStart=0][deltaCount=2][len=1]['a'] -- the second entry is absent
+ // and the payload ends exactly where its length varint would start.
+ int payloadLen = QwpConstants.HEADER_SIZE + 4;
+ long frame = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Unsafe.getUnsafe().setMemory(frame, payloadLen, (byte) 0);
+ Unsafe.getUnsafe().putInt(frame, QwpConstants.MAGIC_MESSAGE);
+ Unsafe.getUnsafe().putByte(frame + QwpConstants.HEADER_OFFSET_FLAGS,
+ QwpConstants.FLAG_DELTA_SYMBOL_DICT);
+ long p = frame + QwpConstants.HEADER_SIZE;
+ Unsafe.getUnsafe().putByte(p, (byte) 0); // deltaStart
+ Unsafe.getUnsafe().putByte(p + 1, (byte) 2); // deltaCount: a lie
+ Unsafe.getUnsafe().putByte(p + 2, (byte) 1); // entry 0 length
+ Unsafe.getUnsafe().putByte(p + 3, (byte) 'a'); // entry 0 payload
+ loop.accumulateSentDictForTest(frame, payloadLen, 0);
+ } finally {
+ Unsafe.free(frame, payloadLen, MemoryTag.NATIVE_DEFAULT);
+ }
+ assertEquals("a truncated delta must not advance the mirror at all",
+ 0, loop.sentDictCount());
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testAccumulateSentDictPartialOverlapExtendsMirror() throws Exception {
+ // M3: accumulateSentDict must handle a delta that STRADDLES the mirror tip
+ // (deltaStart < sentDictCount < deltaStart+deltaCount) by copying only the
+ // new tail, not dropping the whole frame. The monotonic producer never emits
+ // a straddling delta in steady state (so the pre-fix drop-whole-frame guard
+ // passed every test), but a torn-dict replay can seed the mirror smaller than
+ // a frame's coverage. Seed the mirror with 1 symbol, feed a [0..2] delta, and
+ // assert the mirror extends to all 3 -- pre-fix it stayed at 1, leaving the
+ // reconnect catch-up incomplete and shifting server-side ids.
+ TestUtils.assertMemoryLeak(() -> {
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0);
+ try (CursorSendEngine engine = newEngine()) {
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ seedMirror(loop, "aa"); // sentDictCount = 1, mirror holds "aa"
+ int[] frameLen = new int[1];
+ long frame = buildDeltaFrame(0, new String[]{"aa", "bb", "cc"}, frameLen);
+ try {
+ loop.accumulateSentDictForTest(frame, frameLen[0], 0);
+ } finally {
+ Unsafe.free(frame, frameLen[0], MemoryTag.NATIVE_DEFAULT);
+ }
+ assertEquals("straddling delta must extend the mirror to all 3 ids",
+ 3, loop.sentDictCount());
+ assertEquals("mirror must hold the two new tail symbols after the "
+ + "already-held prefix, gap-free",
+ Arrays.asList("aa", "bb", "cc"), readMirrorSymbols(loop));
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testUncappedOversizeCatchUpEntryIsNamedInTheLog() throws Exception {
+ // With no advertised cap the loop has NO terminal to fall back on: soloFrameLimit
+ // becomes MAX_SENT_DICT_BYTES (~2GB) so the cap-gap arm cannot fire, and packing
+ // only ever splits BETWEEN entries, so an entry wider than the receiving node's
+ // recv buffer goes out whole, gets closed with 1009, and -- because close codes
+ // carry no policy semantics -- is retried byte-identically forever.
+ //
+ // That retry-forever is CORRECT and must not be tightened: inventing a terminal
+ // here would kill a producer over data it already shipped. What was missing is
+ // the DIAGNOSIS. The connect succeeds on every cycle, so the reconnect logging
+ // never fires and the stall reads as a healthy reconnect loop until
+ // store-and-forward fills and surfaces as an unrelated out-of-space error. The
+ // WARN is the only thing that names the cause, so pin it.
+ TestUtils.assertMemoryLeak(() -> {
+ // cap 0 == the server advertised no X-QWP-Max-Batch-Size header.
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0);
+ ch.qos.logback.classic.Logger loopLogger = (ch.qos.logback.classic.Logger)
+ org.slf4j.LoggerFactory.getLogger(CursorWebSocketSendLoop.class);
+ ch.qos.logback.core.read.ListAppender
+ * A recovered sender re-registers its dictionary with a catch-up on the very first
+ * connect, and in SYNC/OFF startup that runs on the CALLER's thread inside start().
+ * Without the catch, a server that drops during the catch-up turns a transient outage
+ * into a build() failure -- an Invariant B violation, and the one thing
+ * store-and-forward exists to prevent. Delete the block and everything else stays
+ * green: no other test combines a seeded mirror with a failing client through start().
+ *
+ * The failure must also stay non-terminal: the client is dropped so the I/O thread
+ * reconnects and re-sends the catch-up off the caller's thread.
+ */
+ @Test
+ public void testStartAbsorbsATransientCatchUpSendFailure() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0, true);
+ CursorWebSocketSendLoop loop = newForegroundLoop(engine, client);
+ try {
+ // One mirror entry is enough to make setWireBaselineWithCatchUp ship a
+ // catch-up, which the client then fails. loop.close() frees addr.
+ long addr = Unsafe.malloc(2, MemoryTag.NATIVE_DEFAULT);
+ long p = writeVarint(addr, 1);
+ Unsafe.getUnsafe().putByte(p, (byte) 'a');
+ loop.seedSentDictMirrorForTest(addr, 2, 1);
+
+ loop.start(); // must NOT throw: build() would fail on a transient
+ loop.checkError(); // and must not have latched a terminal
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ private CursorWebSocketSendLoop newLoop(CursorSendEngine engine, WebSocketClient client) {
+ return newLoop(engine, client, 0L);
+ }
+
+ /**
+ * As {@link #newLoop(CursorSendEngine, WebSocketClient)} but with an explicit
+ * cap-gap escalation dwell. These white-box tests model an orphan drainer, where
+ * {@code 0} means count-only quarantine; foreground loops retry indefinitely.
+ */
+ private CursorWebSocketSendLoop newLoop(
+ CursorSendEngine engine, WebSocketClient client, long capGapWindowMillis
+ ) {
+ return new CursorWebSocketSendLoop(
+ client, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
+ () -> {
+ throw new UnsupportedOperationException("test loop is never started");
+ },
+ 100L, 5_000L, false,
+ CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
+ CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
+ 0L, capGapWindowMillis,
+ CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN);
+ }
+
+ private CursorWebSocketSendLoop newForegroundLoop(
+ CursorSendEngine engine, WebSocketClient client
+ ) {
+ // Deliberately use the compatibility overload: its safe default must remain the
+ // foreground RETRY_FOREVER policy for external callers.
+ return new CursorWebSocketSendLoop(
+ client, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
+ () -> {
+ throw new UnsupportedOperationException("test loop is never started");
+ },
+ 100L, 5_000L, false,
+ CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
+ CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
+ 0L, 0L);
+ }
+
+ @Test
+ public void testHostileNegativeAckSequenceCannotTrimUnsentFrames() throws Exception {
+ // fsnAtZero is NEGATIVE whenever a reconnect's dictionary catch-up spans more
+ // frames than the replay start -- a shape this feature introduced. Add a corrupt
+ // or hostile negative wire sequence and the OK path's "fsnAtZero + capped" wraps
+ // POSITIVE, so acknowledge() would trim every published frame the server never
+ // received. The lower clamp is the only thing between that and silent data loss,
+ // and reverting it left the whole suite green.
+ TestUtils.assertMemoryLeak(() -> {
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0);
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 4);
+ long ackedBefore = engine.ackedFsn();
+ long published = engine.publishedFsn();
+ assertTrue("precondition: frames are published but unacked", published > ackedBefore);
+
+ CursorWebSocketSendLoop loop = newForegroundLoop(engine, client);
+ try {
+ // The exact shape the catch-up produces: a negative baseline, with
+ // one wire sequence already consumed so highestSent == 0.
+ loop.setFsnAtZeroForTest(-5L);
+ loop.setNextWireSeqForTest(1L);
+ // Long.MIN_VALUE is what makes the unclamped sum wrap positive:
+ // -5 + Long.MIN_VALUE == Long.MAX_VALUE - 4.
+ deliverOk(loop, Long.MIN_VALUE);
+
+ assertEquals("a negative wire sequence must not advance the ack watermark",
+ ackedBefore, engine.ackedFsn());
+ assertTrue("no published frame may be acked by a hostile sequence",
+ engine.ackedFsn() < published);
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testConnectLoopEntryKeepsTheCapGapEpisodeForACapGapCause() throws Exception {
+ // connectLoop's ENTRY guard decides whether a RE-ENTRY keeps the orphan
+ // drainer's cap-gap settle budget. Accrual inside a single connectLoop
+ // invocation is guarded separately, so making this line unconditional leaves
+ // the rest of the suite green.
+ assertConnectLoopEntry(true);
+ }
+
+ @Test
+ public void testConnectLoopEntryRestartsTheCapGapEpisodeForAnUnrelatedCause() throws Exception {
+ // The other direction: a transport outage says nothing about whether the
+ // cluster's batch cap stayed incompatible while nobody answered, so its
+ // downtime must not count toward the orphan dwell. Deleting the line leaves
+ // the rest of the suite green.
+ assertConnectLoopEntry(false);
+ }
+
+ /**
+ * Accrues one orphan cap-gap strike, then re-enters {@code connectLoop} with either
+ * the cap gap itself or an unrelated transport failure, and asserts the episode is
+ * respectively preserved or restarted.
+ *
+ * The observation is taken inside the reconnect factory: that is the first point
+ * after the entry guard runs, and before the loop body's own reset (which fires for
+ * every non-cap-gap outcome and would otherwise mask the difference).
+ */
+ private void assertConnectLoopEntry(boolean reenterWithCapGap) throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ CursorWebSocketSendLoop[] loopRef = new CursorWebSocketSendLoop[1];
+ int[] observedAttempts = {-1};
+ long[] observedAnchor = {Long.MIN_VALUE};
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 1);
+ CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop(
+ // A 160-byte cap against a 200-byte mirror entry: the catch-up
+ // cannot re-register it, which is a genuine cap gap.
+ new CatchUpCapturingClient(160), engine, 0L,
+ CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
+ () -> {
+ // First point after the entry guard; the no-cap client lets
+ // the ensuing catch-up succeed so the loop exits promptly.
+ observedAttempts[0] = loopRef[0].catchUpCapGapAttempts();
+ observedAnchor[0] = loopRef[0].catchUpCapGapFirstNanos();
+ return new CatchUpCapturingClient(0);
+ },
+ 100L, 5_000L, false,
+ CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
+ CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
+ 0L, 0L,
+ CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN);
+ loopRef[0] = loop;
+ try {
+ seedMirror(loop, TestUtils.repeat("x", 200));
+ Throwable capGap = null;
+ try {
+ invokeSetWireBaselineWithCatchUp(loop, engine.ackedFsn() + 1L);
+ fail("an entry larger than the cap must raise a cap gap");
+ } catch (RuntimeException e) {
+ // CatchUpSendException is private to the loop, so raising a real
+ // one is the only way a test can obtain a cap-gap throwable.
+ assertEquals("CatchUpSendException", e.getClass().getSimpleName());
+ capGap = e;
+ }
+ int accrued = loop.catchUpCapGapAttempts();
+ long anchor = loop.catchUpCapGapFirstNanos();
+ assertTrue("precondition: the cap gap must have accrued a strike", accrued > 0);
+
+ loop.setRunningForTest(true);
+ loop.connectLoopForTest(
+ reenterWithCapGap ? capGap : new LineSenderException("transport outage"),
+ "reconnect", 0L);
+
+ assertTrue("precondition: the reconnect factory must have been consulted",
+ observedAttempts[0] >= 0);
+ if (reenterWithCapGap) {
+ assertEquals("a cap-gap re-entry must not restart the strike count",
+ accrued, observedAttempts[0]);
+ assertEquals("a cap-gap re-entry must keep the dwell anchor",
+ anchor, observedAnchor[0]);
+ } else {
+ assertEquals("an unrelated cause must restart the strike count",
+ 0, observedAttempts[0]);
+ assertEquals("an unrelated cause must clear the dwell anchor",
+ -1L, observedAnchor[0]);
+ }
+ } finally {
+ loop.setRunningForTest(false);
+ loop.close();
+ }
+ }
+ });
+ }
+
+ private CursorSendEngine newEngine() {
+ return new CursorSendEngine(tmpDir, 16_384);
+ }
+
+ /**
+ * Seeds a fresh dictionary of {@code symbolCount} distinct ordinary symbols, runs a
+ * catch-up against a client that advertises {@code advertisedCap}, and returns the
+ * captured frames. Self-contained -- the engine and loop it builds are opened and
+ * closed entirely within this call, so the caller only sees the heap-copied frame
+ * bytes {@link CatchUpCapturingClient#capture} produces.
+ */
+ private List
+ * This is what frame counting cannot do. A catch-up split ships its chunks as
+ * {@code [deltaStart, deltaStart+count)} ranges that must tile {@code [0, n)}
+ * exactly; an off-by-one in the walk's start id keeps the frame COUNT intact
+ * while overlapping a range (an id silently takes its neighbour's symbol) or
+ * skipping one (surfaced here as a null entry; against a real server that id
+ * would instead be REJECTED as a dictionary gap). Comparing the reassembled
+ * dictionary catches all three shapes -- overlap, gap and shift -- because it
+ * compares content per id, not just the ranges.
+ */
+ /**
+ * As {@link #assertCatchUpReassembles(CatchUpCapturingClient, String...)}, but for
+ * {@link #captureCatchUpFrames} / {@link #captureCatchUpFramesWithOneLargeSymbol},
+ * which return the captured frames directly instead of a client -- and where the
+ * caller only knows the dictionary's SIZE, not each generated symbol's literal text.
+ * Proves the frames tile {@code [0, expectedCount)} exactly, gap-free.
+ */
+ private static void assertCatchUpReassembles(List
+ * On recovery / orphan-drain the {@link CursorWebSocketSendLoop} constructor
+ * seeds a native mirror ({@code sentDictBytesAddr}) from the slot's persisted
+ * dictionary so the first connection can re-register it. That mirror is freed on
+ * the I/O thread's exit path -- so if the loop is closed WITHOUT ever starting
+ * (start() never called, or Thread.start() failing before the loop runs), the
+ * free never happens. {@code close()} must free it in that case.
+ */
+public class CursorWebSocketSendLoopMirrorLeakTest {
+
+ private static final int DISTINCT_SYMBOLS = 8;
+ private static final int ROWS = 40;
+
+ @Rule
+ public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build();
+
+ @Test
+ public void testSeededMirrorFreedWhenLoopClosedWithoutStart() throws Exception {
+ Path sfDir = temporaryFolder.newFolder("qwp-mirror-leak").toPath();
+ // Populate a slot with delta frames + a non-empty .symbol-dict, then
+ // abandon it (silent server, close-fast) -- outside assertMemoryLeak,
+ // because a full Sender+server round trip is not net-zero on its own.
+ populateRecoverableSlot(sfDir);
+
+ Path slot = sfDir.resolve("default");
+ Assert.assertTrue("populate must leave a persisted dictionary",
+ Files.exists(slot.resolve(".symbol-dict")));
+
+ // Only the recovery construct + close is leak-checked: the engine
+ // recovers (loading the dict), the loop ctor seeds the mirror from it,
+ // and close() -- with NO start() -- must free every native allocation.
+ // Pre-fix the seeded mirror leaks here and this assertion fails.
+ assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = new CursorSendEngine(slot.toString(), 4096)) {
+ PersistedSymbolDict pd = engine.getPersistedSymbolDict();
+ Assert.assertNotNull("disk-mode engine must open a persisted dict", pd);
+ Assert.assertTrue("recovery must load the persisted symbols (seeds the mirror)",
+ pd.size() > 0 && pd.loadedEntriesLen() > 0);
+ long persistedAddr = pd.loadedEntriesAddr();
+
+ CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop(
+ null, engine, 0, 1_000_000L,
+ () -> {
+ throw new IOException("no reconnect in this test");
+ },
+ 0, 1);
+ // Close without start(): the ctor-seeded mirror is this
+ // thread's to free, since the I/O loop never ran.
+ Assert.assertTrue("precondition: the ctor seeded a non-empty mirror",
+ loop.sentDictCount() > 0);
+ Assert.assertEquals("foreground loop must borrow the persisted buffer without copying",
+ persistedAddr, loop.sentDictBytesAddr());
+ Assert.assertEquals("borrowing must leave the engine's pointer intact so a retried "
+ + "construction can re-seed from it",
+ persistedAddr, pd.loadedEntriesAddr());
+ Assert.assertFalse("a borrowed mirror must not be loop-owned",
+ loop.sentDictBytesOwned());
+ loop.close();
+ Assert.assertEquals("close() must not free a buffer the loop only borrowed -- the "
+ + "engine still owns it",
+ persistedAddr, pd.loadedEntriesAddr());
+ // close() must reset sentDictCount alongside freeing the buffer,
+ // so the mirror stays all-or-nothing: a hypothetical post-close
+ // start() (no closed guard) cannot read a stale count against a
+ // freed buffer and drive a null-mirror catch-up.
+ Assert.assertEquals("close() must reset sentDictCount to 0",
+ 0, loop.sentDictCount());
+ }
+ });
+ }
+
+ @Test
+ public void testRecycledLoopReSeedsMirrorFromPersistedDict() throws Exception {
+ // C1 regression: the orphan drainer (BackgroundDrainer) builds a NEW
+ // CursorWebSocketSendLoop per wire session against the SAME, persistent
+ // engine when a durable-ack capability gap forces a mid-drain recycle. The
+ // recovery mirror seed must survive that recycle. If the first loop consumes
+ // the persisted dictionary's loaded entries (a one-shot ownership transfer),
+ // the second loop seeds an EMPTY mirror (sentDictCount = 0), sends no
+ // reconnect catch-up, and the first replayed delta frame (deltaStart > 0)
+ // trips the torn-dict guard -- falsely quarantining a healthy slot with a
+ // bogus "resend required" terminal. Borrowing the entries leaves the
+ // dictionary intact for the engine's lifetime without making another native
+ // copy, so every recycled loop can re-seed.
+ Path sfDir = temporaryFolder.newFolder("qwp-mirror-reseed").toPath();
+ populateRecoverableSlot(sfDir);
+ Path slot = sfDir.resolve("default");
+ assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = new CursorSendEngine(slot.toString(), 4096)) {
+ PersistedSymbolDict pd = engine.getPersistedSymbolDict();
+ Assert.assertNotNull(pd);
+ int dictSize = pd.size();
+ Assert.assertTrue("recovery must load a non-empty dictionary", dictSize > 0);
+ long persistedAddr = pd.loadedEntriesAddr();
+
+ // Session 1 seeds its mirror from the persisted dictionary.
+ CursorWebSocketSendLoop loop1 = newRecoveryLoop(engine);
+ try {
+ Assert.assertEquals("session-1 mirror must seed from the persisted dict",
+ dictSize, loop1.sentDictCount());
+ Assert.assertEquals("orphan session must borrow the persisted bytes",
+ persistedAddr, loop1.sentDictBytesAddr());
+ Assert.assertFalse("borrowed orphan mirror must not own the persisted bytes",
+ loop1.sentDictBytesOwned());
+ } finally {
+ loop1.close();
+ }
+ Assert.assertEquals("closing a borrowed loop must leave the engine prefix alive",
+ persistedAddr, pd.loadedEntriesAddr());
+
+ // Session 2 against the SAME engine (the drainer recycle): the
+ // seed must NOT have been consumed -- the mirror must re-seed to
+ // the full dictionary so the reconnect catch-up is complete.
+ CursorWebSocketSendLoop loop2 = newRecoveryLoop(engine);
+ try {
+ Assert.assertEquals("recycled session-2 mirror must re-seed from the "
+ + "persisted dict (pre-fix it was 0)",
+ dictSize, loop2.sentDictCount());
+ Assert.assertEquals(persistedAddr, loop2.sentDictBytesAddr());
+ } finally {
+ loop2.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testCtorFreesSeededMirrorWhenFrameSeedThrows() throws Exception {
+ // C1 regression: the constructor seeds the recovery mirror in TWO steps. It
+ // first BORROWS the persisted dictionary's intact prefix by reference, then
+ // extends it with the recovered suffix the engine retains -- and that extension
+ // calls ensureSentDictCapacity, which copy-on-writes the borrowed prefix into a
+ // fresh loop-OWNED allocation. A throw just past that grow (a native realloc
+ // OOM, or the MAX_SENT_DICT_BYTES ceiling) leaves the constructor with the
+ // object unpublished, so neither ensureConnected's catch nor BackgroundDrainer's
+ // finally can ever close() it -- and the owned mirror leaks. The constructor
+ // must free it on the throw. Delete that free and assertMemoryLeak fails here.
+ //
+ // The fault therefore has to sit AFTER the grow. Injected before it, the mirror
+ // is still borrowed (sentDictBytesOwned == false), the cleanup is a no-op, and
+ // this test would pass with the cleanup deleted.
+ Path sfDir = temporaryFolder.newFolder("qwp-mirror-ctor-throw").toPath();
+ // A torn-dict SUBSET: three delta frames a@0,b@1,c@2 survive on disk, but the
+ // .symbol-dict is rewritten to hold only [a,b] (a host-crash tail tear). On
+ // recovery pd.size()==2 seeds the borrowed prefix, then the frame-seed grows the
+ // mirror to take c@2 from the recovered suffix -- the step the fault interrupts.
+ populateThreeFrameSlot(sfDir);
+ Path slot = sfDir.resolve("default");
+ replacePersistedDictionaryWithTwoSymbolPrefix(slot);
+
+ assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = new CursorSendEngine(slot.toString(), 4096)) {
+ PersistedSymbolDict pd = engine.getPersistedSymbolDict();
+ Assert.assertNotNull("recovery must open the torn subset dict", pd);
+ Assert.assertEquals("prefix seed must malloc a 2-entry mirror", 2, pd.size());
+ Assert.assertTrue("the frame-seed path must run (frames out-reach the dict)",
+ engine.recoveredMaxSymbolDeltaStart() > 0L);
+
+ CursorWebSocketSendLoop.forceMirrorSeedFailureForTest = true;
+ try {
+ new CursorWebSocketSendLoop(
+ null, engine, 0, 1_000_000L,
+ () -> {
+ throw new IOException("no reconnect in this test");
+ },
+ 0, 1);
+ Assert.fail("ctor must propagate the injected mirror-seed failure");
+ } catch (LineSenderException expected) {
+ Assert.assertTrue("unexpected message: " + expected.getMessage(),
+ expected.getMessage().contains("simulated mirror seed allocation failure"));
+ } finally {
+ CursorWebSocketSendLoop.forceMirrorSeedFailureForTest = false;
+ }
+ Assert.assertTrue("failed foreground construction must leave the persisted prefix owned",
+ pd.loadedEntriesAddr() != 0L);
+ Assert.assertTrue("failed construction must retain the recovered suffix for retry",
+ engine.recoverySymbolNativeCapacity() > 0);
+ // The outer assertMemoryLeak proves the prefix-seeded mirror the ctor
+ // malloc'd was freed on the throw -- pre-fix it leaks here.
+ }
+ });
+ }
+
+ @Test
+ public void testForegroundLoopRetainsRecoveredSuffixForRetry() throws Exception {
+ Path sfDir = temporaryFolder.newFolder("qwp-mirror-suffix-release").toPath();
+ populateThreeFrameSlot(sfDir);
+ Path slot = sfDir.resolve("default");
+ replacePersistedDictionaryWithTwoSymbolPrefix(slot);
+
+ assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = new CursorSendEngine(slot.toString(), 4096)) {
+ Assert.assertTrue("recovery must retain c@2 above the persisted [a,b] prefix",
+ engine.recoverySymbolNativeCapacity() > 0);
+ CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop(
+ null, engine, 0, 1_000_000L,
+ () -> {
+ throw new IOException("no reconnect in this test");
+ },
+ 0, 1);
+ try {
+ Assert.assertEquals("foreground mirror must include prefix and recovered suffix",
+ 3, loop.sentDictCount());
+ Assert.assertTrue("the engine must RETAIN its recovery suffix: the foreground "
+ + "loop borrows it, and a retried construction re-seeds from it",
+ engine.recoverySymbolNativeCapacity() > 0);
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ /**
+ * A foreground loop must be reconstructible against the same engine.
+ *
+ * QwpWebSocketSender.ensureConnected's catch closes and nulls cursorSendLoop exactly so
+ * a caller can retry, and `connected` is only set at the very end -- so a second
+ * construction is reachable. The old foreground path consumed the engine's recovery
+ * sources (pd.takeLoadedEntries + releaseRecoveredSymbolStorage), and neither cleared
+ * the counts that gate re-seeding: the retry saw recoveredSize() > 0 with
+ * loadedEntriesLen() == 0, left sentDictCount at 0, and then either tripped the
+ * baseline mismatch or latched "resend required" on a healthy slot.
+ *
+ * This is the foreground twin of testRecycledLoopReSeedsMirrorFromPersistedDict.
+ */
+ @Test
+ public void testForegroundLoopReSeedsAfterAClosedFirstAttempt() throws Exception {
+ Path sfDir = temporaryFolder.newFolder("qwp-mirror-foreground-retry").toPath();
+ populateThreeFrameSlot(sfDir);
+ Path slot = sfDir.resolve("default");
+ replacePersistedDictionaryWithTwoSymbolPrefix(slot);
+
+ assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = new CursorSendEngine(slot.toString(), 4096)) {
+ int firstCount;
+ CursorWebSocketSendLoop first = newForegroundLoop(engine);
+ try {
+ firstCount = first.sentDictCount();
+ Assert.assertTrue("precondition: the first loop must seed a mirror",
+ firstCount > 0);
+ } finally {
+ // Exactly what ensureConnected's catch does before a retry.
+ first.close();
+ }
+
+ CursorWebSocketSendLoop second = newForegroundLoop(engine);
+ try {
+ Assert.assertEquals("a retried foreground construction must re-seed identically",
+ firstCount, second.sentDictCount());
+ } finally {
+ second.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testOrphanLoopsRetainRecoveredSuffixForRecycle() throws Exception {
+ Path sfDir = temporaryFolder.newFolder("qwp-mirror-suffix-recycle").toPath();
+ populateThreeFrameSlot(sfDir);
+ Path slot = sfDir.resolve("default");
+ replacePersistedDictionaryWithTwoSymbolPrefix(slot);
+
+ assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = new CursorSendEngine(slot.toString(), 4096)) {
+ int suffixCapacity = engine.recoverySymbolNativeCapacity();
+ Assert.assertTrue("recovery must retain c@2 above the persisted [a,b] prefix",
+ suffixCapacity > 0);
+ for (int session = 0; session < 2; session++) {
+ CursorWebSocketSendLoop loop = newRecoveryLoop(engine);
+ try {
+ Assert.assertEquals("orphan mirror must include prefix and recovered suffix",
+ 3, loop.sentDictCount());
+ Assert.assertEquals("orphan engine must retain suffix for the next session",
+ suffixCapacity, engine.recoverySymbolNativeCapacity());
+ } finally {
+ loop.close();
+ }
+ }
+ }
+ });
+ }
+
+ // Constructs a recovery send loop but does NOT start it: the ctor seeds the
+ // catch-up mirror synchronously, which is all these tests observe. The
+ // reconnect factory is never invoked.
+ private static CursorWebSocketSendLoop newRecoveryLoop(CursorSendEngine engine) {
+ return new CursorWebSocketSendLoop(
+ null, engine, 0, 1_000_000L,
+ () -> {
+ throw new IOException("no reconnect in this test");
+ },
+ 0, 1,
+ false, 0L, 3, 0L, 0L,
+ CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN);
+ }
+
+ private static CursorWebSocketSendLoop newForegroundLoop(CursorSendEngine engine) {
+ // The 7-arg ctor resolves to ReconnectPolicy.FOREGROUND -- newRecoveryLoop above
+ // builds an ORPHAN loop, which borrows by construction and so would not exercise
+ // the foreground path this test exists for.
+ return new CursorWebSocketSendLoop(
+ null, engine, 0, 1_000_000L,
+ () -> {
+ throw new IOException("no reconnect in this test");
+ },
+ 0, 1);
+ }
+
+ private static void populateRecoverableSlot(Path sfDir) throws Exception {
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+ String cfg = "ws::addr=localhost:" + port
+ + ";sf_dir=" + sfDir
+ + ";sf_max_segment_bytes=4096"
+ + ";close_flush_timeout_millis=0;";
+ try (Sender s1 = Sender.fromConfig(cfg)) {
+ for (int i = 0; i < ROWS; i++) {
+ s1.table("m")
+ .symbol("s", "sym-" + (i % DISTINCT_SYMBOLS))
+ .longColumn("v", i)
+ .atNow();
+ s1.flush();
+ }
+ }
+ }
+ }
+
+ // Three delta frames a@0, b@1, c@2, nothing acked, so all three survive and
+ // replay from frame 0. Paired with a dictionary truncated to [a,b], this is a
+ // torn-dict SUBSET whose recovery drives the constructor's frame-seed path.
+ private static void populateThreeFrameSlot(Path sfDir) throws Exception {
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+ String cfg = "ws::addr=localhost:" + port
+ + ";sf_dir=" + sfDir
+ + ";close_flush_timeout_millis=0;";
+ try (Sender s = Sender.fromConfig(cfg)) {
+ s.table("m").symbol("s", "a").longColumn("v", 0).atNow();
+ s.flush();
+ s.table("m").symbol("s", "b").longColumn("v", 1).atNow();
+ s.flush();
+ s.table("m").symbol("s", "c").longColumn("v", 2).atNow();
+ s.flush();
+ }
+ }
+ }
+
+ private static void replacePersistedDictionaryWithTwoSymbolPrefix(Path slot) throws IOException {
+ try (PersistedSymbolDict torn = PersistedSymbolDict.openClean(slot.toString())) {
+ Assert.assertNotNull(torn);
+ torn.appendSymbol("a");
+ torn.appendSymbol("b");
+ Assert.assertEquals(2, torn.size());
+ }
+ }
+
+ private static class SilentHandler implements TestWebSocketServer.WebSocketServerHandler {
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ // never acks -- the sender leaves everything unacked in the slot
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopOrphanTailTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopOrphanTailTest.java
index ff710fe1..89fd39bb 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopOrphanTailTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopOrphanTailTest.java
@@ -27,9 +27,13 @@
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
+import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary;
+import io.questdb.client.cutlass.qwp.client.NativeBufferWriter;
import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
+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.CursorWebSocketSendLoop;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
import io.questdb.client.network.PlainSocketFactory;
import io.questdb.client.std.Files;
import io.questdb.client.std.MemoryTag;
@@ -44,6 +48,8 @@
import java.util.List;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
/**
@@ -75,7 +81,9 @@
public class CursorWebSocketSendLoopOrphanTailTest {
private static final int FLAG_DEFER_COMMIT = 0x01;
+ private static final int FLAG_DELTA_SYMBOL_DICT = 0x08;
private static final int HEADER_OFFSET_FLAGS = 5;
+ private static final int HEADER_SIZE = 12;
private static final int MAGIC_MESSAGE = 0x31505751; // "QWP1" little-endian
private String tmpDir;
@@ -176,6 +184,73 @@ public void testFastPathRetiresWholeDeferredLogBeforeAnySend() throws Exception
});
}
+ /**
+ * A whole-deferred slot that ALSO ships a dictionary catch-up must still retire its
+ * tail at start(), before any connection work, and must not reconnect.
+ *
+ * testFastPathRetiresWholeDeferredLogBeforeAnySend covers the same shape without a
+ * dictionary, so nothing pinned that adding a catch-up -- which consumes wire
+ * sequences before any data frame -- leaves the start()-time retirement intact.
+ *
+ * This deliberately does NOT pin trySendOne's in-place re-anchor arm: both connection
+ * setup sites call tryRetireOrphanTail first, so that arm is only reached when frames
+ * below the tail still needed acks -- which means they were sent on this connection.
+ * Verified by reverting its guard: this test still passes.
+ */
+ @Test
+ public void testCatchUpBearingWholeDeferredSlotRetiresAtStartWithoutReconnecting() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrame(engine, true);
+ appendFrame(engine, true);
+ appendFrame(engine, true);
+ }
+ // A populated dictionary is what makes the loop ship a catch-up. The frames
+ // carry no symbols, so recoveredMaxSymbolId stays -1 and the full-dict
+ // discard cannot fire on it.
+ try (PersistedSymbolDict pd = PersistedSymbolDict.openClean(tmpDir)) {
+ assertNotNull(pd);
+ pd.appendSymbol("a");
+ pd.appendSymbol("b");
+ }
+
+ try (CursorSendEngine engine = newEngine()) {
+ assertEquals(-1L, engine.recoveredCommitBoundaryFsn());
+ assertEquals(2L, engine.recoveredOrphanTipFsn());
+
+ List
+ * accept() checkpoints committedRawLen/Count only at a commit-bearing frame, while
+ * foldDelta's gap-reset rewinds runningRawLen/Count to 0 without touching them. The
+ * next appendRaw therefore overwrites [0, committedRawLen) IN PLACE. That is harmless
+ * when a commit-bearing frame follows -- it re-checkpoints -- but a reset in the
+ * deferred tail never gets that refresh, and finish() then hands out the OLD counts
+ * over the TAIL's bytes.
+ *
+ * Here fsn 0 commits symbol 'a' (committedRawCount=1 over 2 bytes), fsn 1 is a DEFERRED
+ * gap that is durably acked (so the reset is permitted), and fsn 2 is a DEFERRED
+ * self-sufficient frame carrying 'b' -- which rewinds and overwrites 'a' in place. No
+ * commit-bearing frame follows. Without the guard, recovery reports coverage 1 and
+ * decodes "b" as id 0, while the frame that actually replays registered "a": silent
+ * symbol misattribution. With it, recovery fails clean.
+ */
+ @Test
+ public void testGapResetInsideTheDeferredTailFailsCleanInsteadOfMisattributing() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ appendDeltaSymbolFrame(engine, 0, 'a'); // fsn 0, commit-bearing
+ appendDeltaFrame(engine, true, 5, 0); // fsn 1, deferred gap
+ appendDeferredDeltaSymbolFrame(engine, 0, 'b'); // fsn 2, deferred reset
+ }
+ // Ack through the gap so the reset at fsn 2 is permitted (an UNACKED gap is
+ // latched instead -- testSelfSufficientFrameCannotHideUnackedRecoveryGap).
+ try (AckWatermark watermark = AckWatermark.open(tmpDir)) {
+ assertNotNull(watermark);
+ watermark.write(1L);
+ }
+
+ try (CursorSendEngine engine = newEngine()) {
+ assertEquals(1L, engine.ackedFsn());
+ GlobalSymbolDictionary recovered = new GlobalSymbolDictionary();
+ assertEquals("a rewind with no following commit must fail clean, not publish "
+ + "the committed counts over the tail's bytes",
+ -1L, engine.addRecoveredSymbolsTo(0, recovered));
+ assertEquals("nothing may be recovered from an overwritten snapshot",
+ 0, recovered.size());
+ }
+ });
+ }
+
+ /**
+ * A torn DELTA slot must KEEP its dictionary: the full-dict-fallback discard is
+ * gated on {@code recoveredMaxSymbolDeltaStart == 0L} and that conjunct is
+ * load-bearing in the negative direction.
+ *
+ * Here the side-file holds [a,b] and the surviving frames register c@2 and d@3 --
+ * so the frames out-reach the dictionary (recoveredMaxSymbolId 3 >= size 2, the
+ * other conjunct) but are NOT self-sufficient. Drop the deltaStart conjunct and
+ * recovery discards the only source of ids 0..1, re-folds at baseline 0, finds
+ * deltaStart 2 above a coverage of 0, marks a gap and quarantines a slot that is
+ * perfectly recoverable.
+ *
+ * Every existing candidate passes either way: the torn-dict tests have frames
+ * covering from id 0, and testRecoverySkipsEntriesAlreadyCoveredByPersistedPrefix
+ * fails the size conjunct first, so neither one is load-bearing there.
+ */
+ @Test
+ public void testTornDeltaSlotKeepsItsDictionaryInsteadOfBeingDiscarded() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ appendDeltaSymbolFrame(engine, 2, 'c');
+ appendDeltaSymbolFrame(engine, 3, 'd');
+ }
+ try (PersistedSymbolDict pd = PersistedSymbolDict.openClean(tmpDir)) {
+ assertNotNull(pd);
+ pd.appendSymbol("a");
+ pd.appendSymbol("b");
+ }
+
+ try (CursorSendEngine engine = newEngine()) {
+ assertNotNull("a torn DELTA slot must keep its dictionary -- its frames "
+ + "cannot rebuild the ids below their own delta start",
+ engine.getPersistedSymbolDict());
+ assertEquals("scaffolding: the frames must out-reach the dictionary, so the "
+ + "OTHER discard conjunct is satisfied and this test really "
+ + "pins the deltaStart one",
+ 3L, engine.recoveredMaxSymbolId());
+ assertEquals(2, engine.getPersistedSymbolDict().size());
+
+ GlobalSymbolDictionary recovered = new GlobalSymbolDictionary();
+ assertEquals("recovery must cover ids 0..3, not fail clean",
+ 4L, engine.addRecoveredSymbolsTo(2, recovered));
+ assertEquals("only the suffix above the persisted prefix is added",
+ 2, recovered.size());
+ assertEquals("c", recovered.getSymbol(0));
+ assertEquals("d", recovered.getSymbol(1));
+ }
+ });
+ }
+
+ @Test
+ public void testSelfSufficientFrameRepairsAckedRecoveryGap() throws Exception {
+ // fsn 0 models the tail of an old delta epoch whose registering frames
+ // have already been trimmed: with an empty persisted dictionary its
+ // deltaStart=1 is a gap. It is durably ACKed, so it will not replay.
+ // fsn 1 starts a new, self-sufficient epoch from id 0 and is the first
+ // frame that WILL replay. Recovery must use that full frame as the new
+ // source of truth instead of permanently latching the earlier gap.
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ appendDeltaFrame(engine, false, 1, 0);
+ appendDeltaSymbolFrame(engine, 0, 'a');
+ }
+ try (AckWatermark watermark = AckWatermark.open(tmpDir)) {
+ assertNotNull(watermark);
+ watermark.write(0L);
+ }
+
+ try (CursorSendEngine engine = newEngine()) {
+ assertEquals(0L, engine.ackedFsn());
+ GlobalSymbolDictionary recovered = new GlobalSymbolDictionary();
+ assertEquals("the full frame must re-anchor replay coverage",
+ 1L, engine.addRecoveredSymbolsTo(0, recovered));
+ assertEquals(1, recovered.size());
+ assertEquals("a", recovered.getSymbol(0));
+ }
+ });
+ }
+
+ @Test
+ public void testSelfSufficientFrameCannotHideUnackedRecoveryGap() throws Exception {
+ // Safety twin: when the gapped frame itself is unacked, it reaches the
+ // wire before the later full frame. Recovery must keep the gap latched
+ // and quarantine rather than pretending the later reset repairs the
+ // invalid replay order.
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ appendDeltaFrame(engine, false, 1, 0);
+ appendDeltaSymbolFrame(engine, 0, 'a');
+ }
+ try (CursorSendEngine engine = newEngine()) {
+ GlobalSymbolDictionary recovered = new GlobalSymbolDictionary();
+ assertEquals("an unacked gap remains unreplayable",
+ -1L, engine.addRecoveredSymbolsTo(0, recovered));
+ assertEquals(0, recovered.size());
+ }
+ });
+ }
+
// ---------------------------------------------------------------------
// harness
// ---------------------------------------------------------------------
@@ -291,6 +711,95 @@ private static void appendFrame(CursorSendEngine engine, boolean defer) {
}
}
+ // Appends a QWP frame carrying a symbol-dict delta section ([deltaStart varint]
+ // [deltaCount varint]) so the recovery walk's maxSymbolDeltaEnd counts it.
+ // deltaStart/deltaCount stay < 128 so each encodes in a single LEB128 byte.
+ private static void appendDeltaFrame(CursorSendEngine engine, boolean defer, int deltaStart, int deltaCount) {
+ int size = HEADER_SIZE + 2;
+ long buf = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < size; i++) {
+ Unsafe.getUnsafe().putByte(buf + i, (byte) 0);
+ }
+ Unsafe.getUnsafe().putInt(buf, MAGIC_MESSAGE);
+ Unsafe.getUnsafe().putByte(buf + HEADER_OFFSET_FLAGS,
+ (byte) (FLAG_DELTA_SYMBOL_DICT | (defer ? FLAG_DEFER_COMMIT : 0)));
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE, (byte) deltaStart);
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE + 1, (byte) deltaCount);
+ engine.appendBlocking(buf, size);
+ } finally {
+ Unsafe.free(buf, size, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ private static void appendDeferredDeltaSymbolFrame(
+ CursorSendEngine engine, int deltaStart, char symbol) {
+ int size = HEADER_SIZE + 4;
+ long buf = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < size; i++) {
+ Unsafe.getUnsafe().putByte(buf + i, (byte) 0);
+ }
+ Unsafe.getUnsafe().putInt(buf, MAGIC_MESSAGE);
+ Unsafe.getUnsafe().putByte(buf + HEADER_OFFSET_FLAGS,
+ (byte) (FLAG_DELTA_SYMBOL_DICT | FLAG_DEFER_COMMIT));
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE, (byte) deltaStart);
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE + 1, (byte) 1);
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE + 2, (byte) 1);
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE + 3, (byte) symbol);
+ engine.appendBlocking(buf, size);
+ } finally {
+ Unsafe.free(buf, size, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ private static void appendDeltaSymbolFrame(CursorSendEngine engine, int deltaStart, char symbol) {
+ int size = HEADER_SIZE + 4; // start, count=1, symbolLen=1, symbol byte
+ long buf = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < size; i++) {
+ Unsafe.getUnsafe().putByte(buf + i, (byte) 0);
+ }
+ Unsafe.getUnsafe().putInt(buf, MAGIC_MESSAGE);
+ Unsafe.getUnsafe().putByte(buf + HEADER_OFFSET_FLAGS, (byte) FLAG_DELTA_SYMBOL_DICT);
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE, (byte) deltaStart);
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE + 1, (byte) 1);
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE + 2, (byte) 1);
+ Unsafe.getUnsafe().putByte(buf + HEADER_SIZE + 3, (byte) symbol);
+ engine.appendBlocking(buf, size);
+ } finally {
+ Unsafe.free(buf, size, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ private static void appendLargeDeferredDeltaSymbolFrame(
+ CursorSendEngine engine,
+ int deltaStart,
+ int symbolLen
+ ) {
+ int size = HEADER_SIZE
+ + NativeBufferWriter.varintSize(deltaStart)
+ + NativeBufferWriter.varintSize(1)
+ + NativeBufferWriter.varintSize(symbolLen)
+ + symbolLen;
+ long buf = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < size; i++) {
+ Unsafe.getUnsafe().putByte(buf + i, (byte) 0);
+ }
+ Unsafe.getUnsafe().putInt(buf, MAGIC_MESSAGE);
+ Unsafe.getUnsafe().putByte(buf + HEADER_OFFSET_FLAGS,
+ (byte) (FLAG_DEFER_COMMIT | FLAG_DELTA_SYMBOL_DICT));
+ long p = NativeBufferWriter.writeVarint(buf + HEADER_SIZE, deltaStart);
+ p = NativeBufferWriter.writeVarint(p, 1);
+ p = NativeBufferWriter.writeVarint(p, symbolLen);
+ Unsafe.getUnsafe().setMemory(p, symbolLen, (byte) 'z');
+ engine.appendBlocking(buf, size);
+ } finally {
+ Unsafe.free(buf, size, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
private static void awaitAckedFsn(CursorSendEngine engine, long target) throws InterruptedException {
long deadline = System.nanoTime() + 10_000_000_000L;
while (engine.ackedFsn() < target) {
@@ -341,6 +850,6 @@ private CursorWebSocketSendLoop newLoop(CursorSendEngine engine, List
+ * That guard is the drainer path's SOLE defense: an orphan drainer adopts a slot
+ * without running {@code Sender.build()}'s seed-time guard, so on a slot whose
+ * registering frames were trimmed away and whose {@code .symbol-dict} was torn, the
+ * send loop must detect the gap itself, ship ZERO frames, and latch a terminal --
+ * never null-pad the hole on the server. The foreground sender is always quarantined
+ * earlier (at build time), so this fire direction runs only here. Driven at the send
+ * loop level against a frame-counting stub client so it exercises the guard
+ * deterministically, without a real network connection.
+ */
+public class CursorWebSocketSendLoopTornDictGuardTest {
+
+ private static final int ACK_THROUGH = 10;
+ private static final int FRAMES = 12;
+
+ private String sfDir;
+
+ @Before
+ public void setUp() {
+ sfDir = TestUtils.createTmpDir("qdb-torn-dict-guard-");
+ }
+
+ @After
+ public void tearDown() {
+ // Recursive: this test builds the store-and-forward slot layout
+ // (
+ * The factory throws an {@link Error} rather than a retriable transport
+ * failure on purpose: under a {@code Long.MAX_VALUE} budget a retriable
+ * failure would retry forever, whereas an Error propagates on the first
+ * attempt -- and it can only propagate if the loop body ran at all, which is
+ * precisely the signal the pre-fix overflow destroyed.
+ */
+ @Test(timeout = 30_000)
+ public void connectWithRetryWithSaturatingBudgetStillMakesAttempts() {
+ AtomicInteger attempts = new AtomicInteger();
+ try {
+ CursorWebSocketSendLoop.connectWithRetry(
+ () -> {
+ attempts.incrementAndGet();
+ throw new LinkageError("attempt reached (test)");
+ },
+ Long.MAX_VALUE, // pre-fix: startNanos + MAX * 1_000_000L wraps negative
+ 1L,
+ 4L,
+ "test-connect-budget-overflow");
+ Assert.fail("expected the factory's Error to propagate after an attempt");
+ } catch (LinkageError expected) {
+ Assert.assertEquals("attempt reached (test)", expected.getMessage());
+ }
+ Assert.assertEquals(
+ "a Long.MAX_VALUE reconnect budget must not collapse to zero attempts "
+ + "(pre-fix millis * 1_000_000L overflow)",
+ 1, attempts.get());
+ }
}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/DictionaryGapPolicyTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/DictionaryGapPolicyTest.java
new file mode 100644
index 00000000..fa520fcf
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/DictionaryGapPolicyTest.java
@@ -0,0 +1,63 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.SenderError;
+import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class DictionaryGapPolicyTest {
+
+ @Test
+ public void testDictionaryGapIsRetriable() {
+ // The gap verdict is a function of the server's per-connection dictionary size,
+ // so the same frame succeeds once the catch-up has re-registered from id 0.
+ // Latching it TERMINAL forbids the one recovery that works and strands the slot.
+ Assert.assertEquals((byte) 0x0D, WebSocketResponse.STATUS_DICTIONARY_GAP);
+ Assert.assertEquals(SenderError.Category.DICTIONARY_GAP,
+ CursorWebSocketSendLoop.classify(WebSocketResponse.STATUS_DICTIONARY_GAP));
+ Assert.assertEquals(SenderError.Policy.RETRIABLE,
+ CursorWebSocketSendLoop.defaultPolicyFor(SenderError.Category.DICTIONARY_GAP));
+ }
+
+ @Test
+ public void testParseErrorStaysTerminal() {
+ // A genuinely malformed frame must not become retriable as a side effect.
+ Assert.assertEquals(SenderError.Policy.TERMINAL,
+ CursorWebSocketSendLoop.defaultPolicyFor(SenderError.Category.PARSE_ERROR));
+ }
+
+ @Test
+ public void testUnknownStatusFromANewerServerStaysRetriable() {
+ // The fail-open rule is what makes adding a server status byte safe without a
+ // version bump: an older client sees UNKNOWN and retries rather than latching.
+ Assert.assertEquals(SenderError.Category.UNKNOWN,
+ CursorWebSocketSendLoop.classify((byte) 0x7E));
+ Assert.assertEquals(SenderError.Policy.RETRIABLE,
+ CursorWebSocketSendLoop.defaultPolicyFor(SenderError.Category.UNKNOWN));
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EmptyOrphanSlotChurnTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EmptyOrphanSlotChurnTest.java
index 5c7607a6..97e8b39a 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EmptyOrphanSlotChurnTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EmptyOrphanSlotChurnTest.java
@@ -25,6 +25,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
import io.questdb.client.std.Files;
import io.questdb.client.test.tools.TestUtils;
import org.junit.After;
@@ -35,6 +36,7 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
/**
* Regression test for M6 — drainer adopting an empty orphan slot would
@@ -87,6 +89,49 @@ public void tearDown() {
Files.remove(sfDir);
}
+ @Test
+ public void testFreshStartDiscardsSurvivingStaleDictionary() throws Exception {
+ // Regression: a prior fully-drained lifecycle can leave a stale
+ // .symbol-dict behind (a best-effort delete that failed, or a crash in the
+ // close window) with NO segments. A fresh start must DISCARD it -- the
+ // dictionary is load-bearing and the fresh-start producer is not seeded
+ // from it, so trusting a survivor would diverge the producer ids from the
+ // dictionary the send loop replays and misattribute symbols on reconnect.
+ TestUtils.assertMemoryLeak(() -> {
+ // Pre-seed a stale dictionary in the slot, with no segments behind it.
+ PersistedSymbolDict stale = PersistedSymbolDict.openClean(sfDir);
+ assertNotNull(stale);
+ try {
+ stale.appendSymbol("staleX");
+ stale.appendSymbol("staleY");
+ assertEquals(2, stale.size());
+ } finally {
+ stale.close();
+ }
+
+ // A fresh start (no recovered segments) must open a CLEAN, empty
+ // dictionary -- not inherit the survivor.
+ try (CursorSendEngine engine = new CursorSendEngine(sfDir, 4L * 1024 * 1024)) {
+ assertFalse("fresh start must not report a disk recovery",
+ engine.wasRecoveredFromDisk());
+ PersistedSymbolDict pd = engine.getPersistedSymbolDict();
+ assertNotNull(pd);
+ assertEquals("fresh start must discard the surviving stale dictionary",
+ 0, pd.size());
+ }
+
+ // The survivor's bytes are physically gone, not just hidden: this fresh-start
+ // engine never publishes a frame, so its close() is the fully-drained path,
+ // which unlinks the freshly-truncated dictionary alongside the ring and
+ // watermark (see CursorSendEngine's finishClose: "the dictionary has no
+ // frames behind it"). open() now honestly reports that as ABSENT (null)
+ // rather than fabricating a replacement file to reopen, so the strongest
+ // proof left that the stale bytes are gone is the file's own absence.
+ assertFalse("fully-drained close must remove the discarded dictionary",
+ java.nio.file.Files.exists(Paths.get(sfDir, ".symbol-dict")));
+ });
+ }
+
@Test
public void testNeverPublishedCloseLeavesNoSfaFiles() throws Exception {
TestUtils.assertMemoryLeak(() -> {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java
index 804ab6d3..3233ac4c 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java
@@ -24,6 +24,8 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+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.cutlass.qwp.client.sf.cursor.SegmentRing;
@@ -35,9 +37,13 @@
import org.junit.Test;
import java.lang.reflect.Field;
+import java.net.InetAddress;
+import java.net.ServerSocket;
import java.nio.file.Paths;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
@@ -84,14 +90,26 @@ public void setUp() {
@After
public void tearDown() {
if (sfDir == null) return;
- long find = Files.findFirst(sfDir);
+ rmDirRecursive(sfDir);
+ }
+
+ private static void rmDirRecursive(String dir) {
+ if (!Files.exists(dir)) return;
+ 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)) {
- Files.remove(sfDir + "/" + name);
+ String child = dir + "/" + name;
+ long probe = Files.findFirst(child);
+ if (probe > 0) {
+ Files.findClose(probe);
+ rmDirRecursive(child);
+ } else {
+ Files.remove(child);
+ }
}
rc = Files.findNext(find);
}
@@ -99,7 +117,126 @@ public void tearDown() {
Files.findClose(find);
}
}
- Files.remove(sfDir);
+ Files.remove(dir);
+ }
+
+ /**
+ * A close driven by a caller that HOLDS the logical slot lock must not unlink it.
+ *
+ * A fresh slot is "fully drained" by definition ({@code publishedFsn() < 0}), and
+ * {@code Sender.build()} closes the engine from inside its {@code acquireLogical}
+ * scope whenever connect fails -- the ordinary "server isn't up yet" startup. An
+ * unconditional unlink there frees the pathname while build() still holds the flock,
+ * so on POSIX the next acquirer creates a SECOND inode and locks it successfully:
+ * two owners of the lock that exists solely to serialise the quarantine
+ * close->rename->recreate window.
+ *
+ * Swap {@code close(false)} for {@code close()} and the second acquireLogical below
+ * succeeds instead of throwing.
+ */
+ @Test(timeout = 10_000L)
+ public void testCloseDoesNotUnlinkALogicalLockItsCallerHolds() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String slotDir = sfDir + "/slot0";
+ assertEquals(0, Files.mkdir(slotDir, Files.DIR_MODE_DEFAULT));
+ String logicalLockPath = sfDir + "/.slot-locks/slot0.lock";
+
+ try (SlotLock held = SlotLock.acquireLogical(slotDir)) {
+ assertTrue("scaffolding: the logical lock file must exist once acquired",
+ Files.exists(logicalLockPath));
+
+ // A fresh slot: publishedFsn() < 0, so close() takes the fully-drained arm.
+ CursorSendEngine engine = new CursorSendEngine(slotDir, 4L * 1024 * 1024);
+ engine.close(false);
+
+ assertTrue("close(false) must leave the logical lock file alone -- its caller "
+ + "still holds the flock on it",
+ Files.exists(logicalLockPath));
+ try {
+ SlotLock stolen = SlotLock.acquireLogical(slotDir);
+ stolen.close();
+ fail("the logical lock was voided while still held: a second acquireLogical "
+ + "succeeded, so two parties now believe they own the slot");
+ } catch (IllegalStateException expected) {
+ assertTrue("expected lock contention, got: " + expected.getMessage(),
+ expected.getMessage().contains("already in use"));
+ }
+ }
+ });
+ }
+
+ /**
+ * The counterpart: a close by a caller that does NOT hold the logical lock still
+ * reclaims it, so {@code .slot-locks} does not accumulate a dead lock+pid pair per
+ * distinct slot name for the lifetime of {@code sf_dir}.
+ */
+ @Test(timeout = 10_000L)
+ public void testFullyDrainedCloseStillReclaimsAnUnheldLogicalLock() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String slotDir = sfDir + "/slot1";
+ assertEquals(0, Files.mkdir(slotDir, Files.DIR_MODE_DEFAULT));
+ String logicalLockPath = sfDir + "/.slot-locks/slot1.lock";
+
+ SlotLock.acquireLogical(slotDir).close(); // materialise the lock file, release it
+ assertTrue(Files.exists(logicalLockPath));
+
+ CursorSendEngine engine = new CursorSendEngine(slotDir, 4L * 1024 * 1024);
+ engine.close();
+
+ assertFalse("a fully-drained close with no logical-lock holder must reclaim it",
+ Files.exists(logicalLockPath));
+ });
+ }
+
+ /**
+ * The sender wiring one frame above {@link #testCloseDoesNotUnlinkALogicalLockItsCallerHolds}
+ * and {@link #testFullyDrainedCloseStillReclaimsAnUnheldLogicalLock}: those pin
+ * {@code CursorSendEngine.close(boolean)} directly, but nothing exercised the path that SETS
+ * the flag -- {@code QwpWebSocketSender.connect()}'s rollback flipping
+ * {@code reclaimLogicalSlotLockOnClose} to false and threading it into
+ * {@code engine.close(...)}. That field has 4 production references and, before this, 0 test
+ * references.
+ *
+ * When a fresh slot's FIRST connect fails (the ordinary "server isn't up yet" startup), the
+ * slot is fully drained, so the DEFAULT close reclaims (unlinks) the logical lock
+ * {@code Sender.build()} still holds one frame up -- freeing the pathname while the flock is
+ * held, so the next {@code acquireLogical} mints a second inode. The rollback must instead
+ * thread {@code close(false)}. Here the lock is materialised UN-held (so the reclaim is not
+ * additionally blocked by the acquire-before-unlink guard), which isolates the flag: correct
+ * code skips the reclaim and the file survives; flipping the rollback back to the default
+ * reclaim removes it and turns this red.
+ */
+ @Test(timeout = 10_000L)
+ public void testConnectRollbackDoesNotReclaimTheLogicalLock() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String slotDir = sfDir + "/slot2";
+ assertEquals(0, Files.mkdir(slotDir, Files.DIR_MODE_DEFAULT));
+ String logicalLockPath = sfDir + "/.slot-locks/slot2.lock";
+
+ // Materialise the logical lock file (release the flock, leave the file), as
+ // Sender.build() would have around the failing connect.
+ SlotLock.acquireLogical(slotDir).close();
+ assertTrue(Files.exists(logicalLockPath));
+
+ // A free-but-closed loopback port refuses the connect deterministically.
+ int refusedPort;
+ try (ServerSocket s = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+ refusedPort = s.getLocalPort();
+ }
+
+ // A fresh slot: publishedFsn() < 0, so the rollback close takes the fully-drained arm.
+ CursorSendEngine engine = new CursorSendEngine(slotDir, 4L * 1024 * 1024);
+ try {
+ QwpWebSocketSender.connect("localhost", refusedPort, null, 0, 0, 0L, null, false, engine);
+ fail("connect to a refused port must fail and roll back");
+ } catch (LineSenderException expected) {
+ // the connect()-rollback path ran and closed the engine
+ }
+
+ assertTrue("the connect() rollback must thread close(false) so the logical lock its "
+ + "caller (Sender.build) still holds is left intact, not reclaimed",
+ Files.exists(logicalLockPath));
+ });
}
@Test(timeout = 10_000L)
@@ -176,4 +313,33 @@ public void testSlotLockReleasedEvenIfRingCloseThrows() throws Exception {
}
});
}
+
+ @Test(timeout = 10_000L)
+ public void testFullyDrainedCloseRemovesLogicalSlotLock() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // The engine's slot lives one level under sfDir, so the parent-anchored
+ // logical lock lands at
+ * The CRC proves the bytes are what was WRITTEN; it says nothing about whether the
+ * header triple is self-consistent, and the only write-side guard
+ * ({@code validateRawEntries}) sits behind an assert -- which this library, shipping
+ * embedded in user applications, runs without. The scan used to accept such a chunk
+ * and count its declared 3 entries, leaving decodeLoadedSymbols to discover the
+ * problem later and throw two layers up: a quarantine of the whole slot instead of
+ * salvaging its intact prefix.
+ *
+ * Validating during the scan is also what makes decodeLoadedSymbols' own throws
+ * unreachable through {@code open()}. Their type change -- IllegalStateException to
+ * UnreplayableSlotException, so Sender.build() can set a slot aside rather than
+ * rethrow forever -- stays pinned on its reachable sibling by
+ * CursorSendEngineTest#testRecoveryBaselineMismatchIsQuarantinableNotAPermanentBrick.
+ */
+ @Test
+ public void testChunkWhoseCountDisagreesWithItsEntriesEndsTheTrustedPrefix() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = newFolder("qwp-symdict");
+ Path f = dir.resolve(".symbol-dict");
+
+ // [entryCount=3][entryBytes=2][len=1]['a'] -- 3 entries claimed, 1 supplied.
+ byte[] body = new byte[]{0x03, 0x02, 0x01, (byte) 'a'};
+ int crc;
+ long scratch = Unsafe.malloc(body.length, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < body.length; i++) {
+ Unsafe.getUnsafe().putByte(scratch + i, body[i]);
+ }
+ crc = Crc32c.update(Crc32c.INIT, scratch, body.length);
+ } finally {
+ Unsafe.free(scratch, body.length, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ byte[] file = new byte[HEADER_SIZE + body.length + 4];
+ file[0] = 'S';
+ file[1] = 'Y';
+ file[2] = 'D';
+ file[3] = '1';
+ file[4] = 1; // VERSION
+ System.arraycopy(body, 0, file, HEADER_SIZE, body.length);
+ int crcAt = HEADER_SIZE + body.length;
+ file[crcAt] = (byte) crc;
+ file[crcAt + 1] = (byte) (crc >>> 8);
+ file[crcAt + 2] = (byte) (crc >>> 16);
+ file[crcAt + 3] = (byte) (crc >>> 24);
+ Files.write(f, file);
+
+ try (PersistedSymbolDict dict = PersistedSymbolDict.open(dir.toString())) {
+ Assert.assertNotNull("a valid CRC must not destroy the file", dict);
+ Assert.assertEquals("3 entries claimed inside a region holding 1 must end the "
+ + "trusted prefix, not be counted", 0, dict.size());
+ // Nothing was trusted, so nothing decodes -- and no throw escapes.
+ dict.addLoadedSymbolsTo(new GlobalSymbolDictionary());
+ }
+ Assert.assertEquals("the untrusted tail must be truncated away",
+ (long) HEADER_SIZE, Files.size(f));
+ });
+ }
+
+ @Test
+ public void testChunkClaimingEntriesInAZeroByteRegionEndsTheTrustedPrefix() throws Exception {
+ // The CRC proves the bytes are what was WRITTEN, never that the chunk header is
+ // self-consistent. Every entry costs at least its own length varint, so entryCount
+ // > 0 inside a zero-byte region is impossible -- yet the scan used to accept it and
+ // leave decodeLoadedSymbols to discover the problem later, as a throw two layers up
+ // instead of a trimmed trusted prefix. Validating it during the scan gives such a
+ // chunk the same treatment a CRC failure already gets.
+ assertMemoryLeak(() -> {
+ Path dir = newFolder("qwp-symdict");
+ Path f = dir.resolve(".symbol-dict");
+
+ byte[] body = new byte[]{0x01, 0x00}; // entryCount=1, entryBytes=0
+ int crc;
+ long scratch = Unsafe.malloc(body.length, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < body.length; i++) {
+ Unsafe.getUnsafe().putByte(scratch + i, body[i]);
+ }
+ crc = Crc32c.update(Crc32c.INIT, scratch, body.length);
+ } finally {
+ Unsafe.free(scratch, body.length, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ byte[] file = new byte[HEADER_SIZE + body.length + 4];
+ file[0] = 'S';
+ file[1] = 'Y';
+ file[2] = 'D';
+ file[3] = '1';
+ file[4] = 1; // VERSION
+ System.arraycopy(body, 0, file, HEADER_SIZE, body.length);
+ int crcAt = HEADER_SIZE + body.length;
+ file[crcAt] = (byte) crc;
+ file[crcAt + 1] = (byte) (crc >>> 8);
+ file[crcAt + 2] = (byte) (crc >>> 16);
+ file[crcAt + 3] = (byte) (crc >>> 24);
+ Files.write(f, file);
+
+ try (PersistedSymbolDict dict = PersistedSymbolDict.open(dir.toString())) {
+ Assert.assertNotNull(dict);
+ Assert.assertEquals("an inconsistent chunk must end the trusted prefix, "
+ + "not be counted", 0, dict.size());
+ }
+ Assert.assertEquals("the untrusted tail must be truncated away",
+ (long) HEADER_SIZE, Files.size(f));
+ });
+ }
+
+ private static int varintSize(int v) {
+ int n = 1;
+ while ((v >>>= 7) != 0) {
+ n++;
+ }
+ return n;
+ }
+
+ @Test
+ public void testBadMagicDegradesWithoutDestroyingTheFile() throws Exception {
+ // An unparseable existing file must degrade to null -- the sender falls back
+ // to full self-sufficient frames -- and must NOT be recreated. open() used to
+ // fall through to openFresh(), which is O_TRUNC: a single unreadable byte
+ // destroyed the only copy of load-bearing state.
+ assertMemoryLeak(() -> {
+ Path dir = newFolder("qwp-symdict");
+ Path f = dir.resolve(".symbol-dict");
+ // At least HEADER_SIZE bytes so open() takes the parseable-but-bad-magic
+ // path (openExisting) rather than treating this as a sub-header stub with
+ // nothing to lose.
+ byte[] original = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
+ Files.write(f, original);
+ Assert.assertNull("an unparseable existing dict must degrade to null",
+ PersistedSymbolDict.open(dir.toString()));
+ Assert.assertArrayEquals("open() must NOT destroy an existing dictionary",
+ original, Files.readAllBytes(f));
+ });
+ }
+
+ @Test
+ public void testBadVersionDegradesWithoutDestroyingTheFile() throws Exception {
+ // A file with correct 'SYD1' magic and a VALID chunk but an unknown version
+ // byte belongs to a foreign/future format and must not be parsed as v3.
+ // Covers the version sub-condition specifically (testBadMagicDegrades... covers
+ // the magic one).
+ //
+ // The file must SURVIVE. This is the client-rollback trap: a newer client
+ // writes v4, ops roll back to this build, and if open() recreated the file the
+ // v4 dictionary would be gone for good -- rolling forward again could not
+ // recover it, and every surviving delta frame would be permanently
+ // unreplayable. Degrading to null instead leaves the bytes for the client that
+ // does understand them.
+ assertMemoryLeak(() -> {
+ Path dir = newFolder("qwp-symdict");
+ Path f = dir.resolve(".symbol-dict");
+ try (PersistedSymbolDict seed = PersistedSymbolDict.openClean(dir.toString())) {
+ Assert.assertNotNull(seed);
+ seed.appendSymbol("a");
+ }
+ // Corrupt ONLY the version byte (offset 4) to an unknown value.
+ byte[] bytes = Files.readAllBytes(f);
+ bytes[4] = (byte) 99;
+ Files.write(f, bytes);
+
+ Assert.assertNull("an unknown-version dict must degrade to null",
+ PersistedSymbolDict.open(dir.toString()));
+ Assert.assertArrayEquals("open() must NOT destroy a future-version dictionary",
+ bytes, Files.readAllBytes(f));
+ });
+ }
+
+ @Test
+ public void testCloseNullsLoadedEntries() throws Exception {
+ // close() must null loadedEntriesAddr/Len after freeing them (like
+ // scratchAddr), so an accidental post-close read of the getters cannot
+ // dereference freed native memory. Pre-fix the pointer survived close()
+ // non-zero.
+ assertMemoryLeak(() -> {
+ Path dir = newFolder("qwp-symdict");
+ try (PersistedSymbolDict d = PersistedSymbolDict.openClean(dir.toString())) {
+ d.appendSymbol("AAPL");
+ }
+
+ // Reopen so recovery loads the entries into native memory. This test
+ // closes explicitly because the post-close state is the assertion target.
+ PersistedSymbolDict re = PersistedSymbolDict.open(dir.toString());
+ Assert.assertTrue("recovery must load entries into native memory",
+ re.loadedEntriesAddr() != 0L && re.loadedEntriesLen() > 0);
+ re.close();
+ Assert.assertEquals("close() must null loadedEntriesAddr", 0L, re.loadedEntriesAddr());
+ Assert.assertEquals("close() must null loadedEntriesLen", 0, re.loadedEntriesLen());
+ });
+ }
+
+ @Test
+ public void testEmptySymbolRoundTrips() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = newFolder("qwp-symdict");
+ try (PersistedSymbolDict d = PersistedSymbolDict.openClean(dir.toString())) {
+ d.appendSymbol("");
+ d.appendSymbol("nonempty");
+ }
+ try (PersistedSymbolDict re = PersistedSymbolDict.open(dir.toString())) {
+ Assert.assertEquals(2, re.size());
+ ObjList
+ * The length is {@code 2^32 + 100}, NOT {@code Integer.MAX_VALUE + 1}. The latter
+ * casts to {@code Integer.MIN_VALUE}, which {@code Unsafe.malloc} rejects on its
+ * own, so {@code openExisting}'s catch would produce the same {@code null} with or
+ * without the guard -- which is precisely what made the old version of this test
+ * vacuous. {@code 2^32 + k} casts to a small POSITIVE prefix instead, so without
+ * the guard {@code openExisting} really would open the file and parse a truncated
+ * prefix of it. {@link #openRwCalls} is what catches that.
+ */
+ private static final class HugeLengthFacade extends DelegatingFilesFacade {
+ int openRwCalls;
+
+ @Override
+ public long length(String path) {
+ return (1L << 32) + 100L;
+ }
+ @Override
+ public long length(long pathPtr) {
+ // Twin of the String overload: PersistedSymbolDict stats through the
+ // pathPtr form so it can read errno with no free() in between, and the
+ // fault must reach it whichever overload production picks.
+ return (1L << 32) + 100L;
+ }
+
+ @Override
+ public int openRW(String path) {
+ openRwCalls++;
+ return super.openRW(path);
+ }
+ }
+
+ /**
+ * Lands ONE armed entry append short -- writes {@code len-1} of the {@code len}
+ * requested bytes and reports {@code len-1} -- reproducing a disk-full / quota
+ * short write mid-persist. Fires only on an entry append (offset past the
+ * 8-byte header), never the header write, and disarms after firing so the retry
+ * writes cleanly.
+ */
+ private static final class ShortWriteOnceFacade extends DelegatingFilesFacade {
+ boolean armed;
+
+ @Override
+ public long write(int fd, long addr, long len, long offset) {
+ if (armed && offset > 0 && len > 1) {
+ armed = false;
+ return INSTANCE.write(fd, addr, len - 1, offset);
+ }
+ return INSTANCE.write(fd, addr, len, offset);
+ }
+ }
+
+ /**
+ * Reports a length of -1 -- the stat-error sentinel -- for the dictionary file,
+ * reproducing a transient stat failure (an EIO on a flaky disk) where the file is
+ * present but its size cannot be read. open() must throw the retriable
+ * SfOperationalException, leaving every byte on disk. The paired errno()
+ * override is what classifies the fault: a facade faking length() < 0
+ * without pinning an errno would leave the not-found classification to
+ * whatever the last REAL syscall set.
+ */
+ private static final class StatFailsLengthFacade extends DelegatingFilesFacade {
+ @Override
+ public int errno() {
+ return FAKE_EIO;
+ }
+
+ @Override
+ public long length(String path) {
+ return -1L;
+ }
+ @Override
+ public long length(long pathPtr) {
+ // Twin of the String overload: PersistedSymbolDict stats through the
+ // pathPtr form so it can read errno with no free() in between, and the
+ // fault must reach it whichever overload production picks.
+ return -1L;
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PrReviewRedTests.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PrReviewRedTests.java
deleted file mode 100644
index ae987fee..00000000
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PrReviewRedTests.java
+++ /dev/null
@@ -1,245 +0,0 @@
-/*******************************************************************************
- * ___ _ ____ ____
- * / _ \ _ _ ___ ___| |_| _ \| __ )
- * | | | | | | |/ _ \/ __| __| | | | _ \
- * | |_| | |_| | __/\__ \ |_| |_| | |_) |
- * \__\_\\__,_|\___||___/\__|____/|____/
- *
- * 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.cutlass.qwp.client.sf.cursor.MmapSegment;
-import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing;
-import io.questdb.client.std.Files;
-import io.questdb.client.std.MemoryTag;
-import io.questdb.client.std.Unsafe;
-import io.questdb.client.test.tools.TestUtils;
-import org.junit.After;
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.Test;
-
-import java.nio.file.Paths;
-
-/**
- * Red tests for the critical findings raised during the PR-17 code review.
- * Each {@code @Test} here is intentionally written to FAIL on current
- * {@code vi_sf} HEAD; once the corresponding finding is fixed, the test
- * should pass. See the inline javadoc on each test for the matching
- * finding identifier.
- */
-public class PrReviewRedTests {
-
- private String tmpDir;
-
- @Before
- public void setUp() {
- tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
- "qdb-pr-red-" + System.nanoTime()).toString();
- Assert.assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
- }
-
- @After
- public void tearDown() {
- if (tmpDir == null) return;
- long find = Files.findFirst(tmpDir);
- if (find > 0) {
- try {
- int rc = 1;
- while (rc > 0) {
- String name = Files.utf8ToString(Files.findName(find));
- if (name != null && !".".equals(name) && !"..".equals(name)) {
- Files.remove(tmpDir + "/" + name);
- }
- rc = Files.findNext(find);
- }
- } finally {
- Files.findClose(find);
- }
- }
- Files.remove(tmpDir);
- }
-
- /**
- * Finding C1 / C10 — first-frame CRC corruption silently deletes the segment.
- *
- * If frame[0] of a recovered .sfa fails CRC validation, the recovery scan
- * returns lastGood=HEADER_SIZE and frameCount=0, and SegmentRing.openExisting
- * unlinks the file as an "empty hot-spare leftover" — destroying every frame
- * that physically followed the corrupt header. The torn-tail WARN inside
- * MmapSegment.openExisting is dropped on the floor.
- *
- * Trigger: a single bit flip on the CRC field of frame[0] (bit rot, partial
- * page write at crash, etc.).
- */
- @Test
- public void testC1_recoveryMustNotUnlinkSegmentWithCorruptFirstFrame() throws Exception {
- TestUtils.assertMemoryLeak(() -> {
- String segPath = tmpDir + "/sf-data.sfa";
- // Build a segment with several real frames so we have something to lose.
- MmapSegment seg = MmapSegment.create(segPath, 0L, 64 * 1024);
- long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
- try {
- for (int i = 0; i < 32; i++) {
- Unsafe.getUnsafe().putByte(buf + i, (byte) i);
- }
- Assert.assertTrue("setup: first append must succeed", seg.tryAppend(buf, 32) >= 0);
- Assert.assertTrue("setup: second append must succeed", seg.tryAppend(buf, 32) >= 0);
- Assert.assertTrue("setup: third append must succeed", seg.tryAppend(buf, 32) >= 0);
- Assert.assertEquals("setup: three frames written", 3L, seg.frameCount());
- } finally {
- Unsafe.free(buf, 32, MemoryTag.NATIVE_DEFAULT);
- }
- seg.close();
- Assert.assertTrue("setup: file must exist on disk", Files.exists(segPath));
-
- // Corrupt the CRC field of frame[0] (offset HEADER_SIZE..HEADER_SIZE+4).
- // A single bit flip is enough; we overwrite the whole 4-byte field with
- // a value statistically guaranteed to mismatch any real CRC.
- int fd = Files.openRW(segPath);
- Assert.assertTrue("setup: openRW failed", fd >= 0);
- long badCrcBuf = Unsafe.malloc(4, MemoryTag.NATIVE_DEFAULT);
- try {
- Unsafe.getUnsafe().putInt(badCrcBuf, 0xDEADBEEF);
- Files.write(fd, badCrcBuf, 4, MmapSegment.HEADER_SIZE);
- } finally {
- Unsafe.free(badCrcBuf, 4, MemoryTag.NATIVE_DEFAULT);
- Files.close(fd);
- }
- Assert.assertTrue("setup: file should still exist after CRC clobber",
- Files.exists(segPath));
-
- // Run recovery.
- SegmentRing recovered = SegmentRing.openExisting(tmpDir, 64 * 1024);
- try {
- // The bug: openExisting sees frameCount=0 (because the recovery
- // scan bailed at the corrupt frame[0]) and treats the segment as
- // an "empty hot-spare leftover" — closing AND UNLINKING the
- // file. The user's frames 1, 2, 3 are gone forever; the only
- // record was a WARN log line that's already been emitted.
- //
- // Spec / desired behavior: a segment with non-zero contents
- // past the header (tornTailBytes > 0) must be preserved or
- // quarantined to
- * Combined with the unclamped DROP path in
- * {@code CursorWebSocketSendLoop.handleServerRejection}, a malformed/poisoned
- * server NACK with a bogus {@code wireSeq} can move {@code ackedFsn} far
- * beyond what the I/O thread has actually sent. The segment manager then
- * trims segments that the I/O thread is still iterating; the next
- * {@code Unsafe.getInt} on the unmapped region SEGVs the JVM.
- *
- * Defense-in-depth fix: clamp inside {@code acknowledge} —
- * {@code if (seq > publishedFsn) seq = publishedFsn;}
- */
- @Test
- public void testC2_acknowledgeMustClampAtPublishedFsn() throws Exception {
- TestUtils.assertMemoryLeak(() -> {
- MmapSegment seg = MmapSegment.create(tmpDir + "/c2.sfa", 0L, 64 * 1024);
- long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
- try {
- for (int i = 0; i < 32; i++) {
- Unsafe.getUnsafe().putByte(buf + i, (byte) i);
- }
- try (SegmentRing ring = new SegmentRing(seg, 64 * 1024)) {
- Assert.assertEquals("setup: first append yields FSN 0", 0L,
- ring.appendOrFsn(buf, 32));
- Assert.assertEquals("setup: publishedFsn matches", 0L,
- ring.publishedFsn());
- Assert.assertEquals("setup: nothing acked yet", -1L,
- ring.ackedFsn());
-
- // Hostile input: a server bug, fuzzer, or version-skew
- // could send a NACK / ACK with any wireSeq. The DROP-policy
- // path (CursorWebSocketSendLoop.handleServerRejection) does
- // not clamp — so this maps to engine.acknowledge(huge) under
- // a real adversarial server.
- long bogusSeq = Long.MAX_VALUE / 2L;
- ring.acknowledge(bogusSeq);
-
- // Defense-in-depth invariant: ackedFsn MUST NEVER exceed
- // publishedFsn. The segment manager's drainTrimmable uses
- // ackedFsn to decide which segments to munmap+unlink. If
- // ackedFsn races past publishedFsn, the manager can trim
- // a segment the I/O thread is currently iterating —
- // SEGV in the JVM.
- Assert.assertTrue(
- "FINDING C2: SegmentRing.acknowledge accepted "
- + bogusSeq + " against publishedFsn=" + ring.publishedFsn()
- + ". ackedFsn is now " + ring.ackedFsn()
- + " — far past anything the I/O thread has actually sent. "
- + "The segment manager will trim segments the I/O thread is "
- + "still reading; next Unsafe.getInt on the unmapped region "
- + "SEGVs the JVM. acknowledge must clamp at publishedFsn.",
- ring.ackedFsn() <= ring.publishedFsn());
- }
- } finally {
- Unsafe.free(buf, 32, MemoryTag.NATIVE_DEFAULT);
- }
- });
- }
-
- /**
- * Finding C7 — {@code QWP_CLIENT_REVIEW.md} at the repo root is review notes
- * for a different branch ({@code vi_egress}, not {@code vi_sf}) and was
- * accidentally committed in this PR.
- */
- @Test
- public void testC7_strayBranchReviewMarkdownAbsent() {
- // The test runs from the repo root or a subdirectory (typically `core/`).
- // Walk up looking for `.git`, which only exists at the project root —
- // stopping at the first `pom.xml` would land at the `core/` module.
- java.io.File cwd = new java.io.File(".").getAbsoluteFile();
- java.io.File root = cwd;
- while (root != null && !new java.io.File(root, ".git").exists()) {
- root = root.getParentFile();
- }
- Assert.assertNotNull("could not locate repo root from " + cwd, root);
- java.io.File stray = new java.io.File(root, "QWP_CLIENT_REVIEW.md");
- Assert.assertFalse(
- "FINDING C7: " + stray.getAbsolutePath() + " is review notes for branch "
- + "vi_egress (not vi_sf) and was accidentally committed in PR #17. "
- + "Run `git rm QWP_CLIENT_REVIEW.md`.",
- stray.exists());
- }
-}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/RecoveredFrameAnalysisTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/RecoveredFrameAnalysisTest.java
new file mode 100644
index 00000000..d6ec8151
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/RecoveredFrameAnalysisTest.java
@@ -0,0 +1,97 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.cutlass.qwp.client.GlobalSymbolDictionary;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.file.Path;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+public class RecoveredFrameAnalysisTest {
+
+ @Rule
+ public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build();
+
+ @Test
+ public void testTruncatedSymbolBytesMarkRecoveredDeltaAsGap() throws Exception {
+ assertMalformedDeltaMarksGap(new byte[]{0, 1, 3, 'x'});
+ }
+
+ @Test
+ public void testUnterminatedDeltaCountMarksRecoveredDeltaAsGap() throws Exception {
+ assertMalformedDeltaMarksGap(new byte[]{0, (byte) 0x80});
+ }
+
+ @Test
+ public void testUnterminatedDeltaStartMarksRecoveredDeltaAsGap() throws Exception {
+ assertMalformedDeltaMarksGap(new byte[]{(byte) 0x80});
+ }
+
+ @Test
+ public void testUnterminatedSymbolLengthMarksRecoveredDeltaAsGap() throws Exception {
+ assertMalformedDeltaMarksGap(new byte[]{0, 1, (byte) 0x80});
+ }
+
+ private void assertMalformedDeltaMarksGap(byte[] deltaSection) throws Exception {
+ assertMemoryLeak(() -> {
+ Path slot = temporaryFolder.newFolder("qwp-malformed-recovery").toPath();
+ int payloadLen = QwpConstants.HEADER_SIZE + deltaSection.length;
+ long payload = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Unsafe.getUnsafe().setMemory(payload, payloadLen, (byte) 0);
+ Unsafe.getUnsafe().putInt(payload, QwpConstants.MAGIC_MESSAGE);
+ Unsafe.getUnsafe().putByte(
+ payload + QwpConstants.HEADER_OFFSET_FLAGS,
+ QwpConstants.FLAG_DELTA_SYMBOL_DICT);
+ for (int i = 0; i < deltaSection.length; i++) {
+ Unsafe.getUnsafe().putByte(payload + QwpConstants.HEADER_SIZE + i, deltaSection[i]);
+ }
+ try (CursorSendEngine writer = new CursorSendEngine(slot.toString(), 4_096)) {
+ Assert.assertEquals(0L, writer.appendBlocking(payload, payloadLen));
+ }
+ } finally {
+ Unsafe.free(payload, payloadLen, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ try (CursorSendEngine recovered = new CursorSendEngine(slot.toString(), 4_096)) {
+ GlobalSymbolDictionary symbols = new GlobalSymbolDictionary();
+ Assert.assertEquals("malformed recovered delta must be fail-clean",
+ -1L, recovered.addRecoveredSymbolsTo(0, symbols));
+ Assert.assertEquals("malformed delta must not recover partial symbols", 0, symbols.size());
+ Assert.assertEquals("the malformed frame must be visited during recovery",
+ 1L, recovered.recoveryFramesVisited());
+ }
+ });
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java
new file mode 100644
index 00000000..3006ec1b
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java
@@ -0,0 +1,236 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.cutlass.qwp.client.sf.cursor.MmapSegment;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing;
+import io.questdb.client.std.Files;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.file.Paths;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Regression for P-C8: the {@code sf_max_total_bytes} cap check compared
+ * {@code .sfa} segment bytes only, while the {@code .symbol-dict} side-file
+ * is lifetime-monotonic on symbol-heavy workloads -- so a producer could
+ * fill the SF filesystem while the cap reported headroom. The manager now
+ * reads a per-slot side-file gauge at the provisioning cap check. The two
+ * tests differ ONLY in the gauge, proving the gauge alone flips the
+ * provisioning decision.
+ */
+public class SegmentManagerSideFileCapTest {
+
+ private static final long SEGMENT_SIZE = 64 * 1024;
+ private String slotDir;
+
+ @Before
+ public void setUp() {
+ slotDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-mgr-sidefile-cap-" + System.nanoTime()).toString();
+ Assert.assertEquals(0, Files.mkdir(slotDir, Files.DIR_MODE_DEFAULT));
+ }
+
+ @After
+ public void tearDown() {
+ if (slotDir == null) return;
+ rmDirRec(slotDir);
+ }
+
+ @Test
+ public void testCapCountsSideFileBytesAgainstProvisioning() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // Cap = 3 segments. The slot holds 2 recovered segments plus a
+ // side-file gauge reporting one further segment's worth of
+ // dictionary bytes: 2 x seg (segments) + 1 x seg (side-file)
+ // + 1 x seg (the spare under consideration) > cap, so the
+ // manager must refuse to provision. Pre-fix the gauge was
+ // invisible and the manager provisioned a third .sfa, taking
+ // real disk usage past the cap by the side-file's size.
+ long cap = 3 * SEGMENT_SIZE;
+ prepopulate(slotDir, 2);
+ SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE);
+ Assert.assertNotNull("recovery should produce a ring", ring);
+
+ SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 1_000_000L /* 1ms */, cap);
+ try (SegmentManager ignored = manager) {
+ manager.start();
+ manager.register(ring, slotDir, null, 0L, () -> SEGMENT_SIZE);
+ Assert.assertEquals("accounted bytes must be segments + side-file gauge",
+ 3 * SEGMENT_SIZE, manager.getCapAccountedBytesForTesting());
+ Thread.sleep(100);
+ }
+
+ Assert.assertEquals("side-file bytes must count against sf_max_total_bytes -- "
+ + "pre-fix the cap check saw only .sfa bytes and provisioned "
+ + "past the cap",
+ 2, countSfaFiles(slotDir));
+ ring.close();
+ });
+ }
+
+ @Test
+ public void testControlWithoutSideFileGaugeProvisionsToTheCap() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // Identical setup, no gauge: 2 x seg + 1 x seg spare == cap
+ // (not above it), so the manager provisions exactly one spare
+ // and then stops. Pins that the refusal in the sibling test is
+ // caused by the gauge alone.
+ long cap = 3 * SEGMENT_SIZE;
+ prepopulate(slotDir, 2);
+ SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE);
+ Assert.assertNotNull("recovery should produce a ring", ring);
+
+ SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 1_000_000L /* 1ms */, cap);
+ try (SegmentManager ignored = manager) {
+ manager.start();
+ manager.register(ring, slotDir, null, 0L, null);
+ // Poll instead of a fixed sleep: this is a must-provision assertion, and
+ // a flat 100ms sleep flakes on a loaded CI box where the worker's 1ms tick
+ // gets delayed past the sleep window. Bound the poll with a deadline so a
+ // genuine regression still fails instead of hanging.
+ long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (countSfaFiles(slotDir) != 3 && System.nanoTime() < deadlineNanos) {
+ Thread.sleep(5);
+ }
+ }
+
+ Assert.assertEquals("without the gauge the manager must fill the cap exactly",
+ 3, countSfaFiles(slotDir));
+ ring.close();
+ });
+ }
+
+ @Test
+ public void testLivenessFloorProvisionsDespiteSideFileBytesOverTheCap() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // The dictionary is lifetime-monotonic and no trim reclaims it, so a
+ // side-file that alone eats the cap used to hold the ring at ONE
+ // segment forever: no spare, no rotation, and no ack could ever free
+ // the shortfall -- a permanent stall that survived restarts, while
+ // the disk-full warning pointed at a trim that cannot help. The cap
+ // must still guarantee each ring its minimum working set (the active
+ // segment plus one spare) and exceed itself by the dictionary's
+ // overshoot instead.
+ long cap = 3 * SEGMENT_SIZE;
+ prepopulate(slotDir, 1);
+ SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE);
+ Assert.assertNotNull("recovery should produce a ring", ring);
+
+ SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 1_000_000L /* 1ms */, cap);
+ try (SegmentManager ignored = manager) {
+ manager.start();
+ // 10 segments' worth of dictionary against a 3-segment cap: no
+ // arithmetic makes this fit, so only the floor can provision.
+ manager.register(ring, slotDir, null, 0L, () -> 10 * SEGMENT_SIZE);
+ long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (countSfaFiles(slotDir) < 2 && System.nanoTime() < deadlineNanos) {
+ Thread.sleep(5);
+ }
+ Assert.assertEquals("a ring below its minimum working set must be provisioned "
+ + "even when non-reclaimable side-file bytes have eaten the cap",
+ 2, countSfaFiles(slotDir));
+ // ...and the floor is a floor, not a bypass: once the working set
+ // is whole, the cap governs again.
+ Thread.sleep(100);
+ Assert.assertEquals("the floor must not license unbounded provisioning past the cap",
+ 2, countSfaFiles(slotDir));
+ }
+ ring.close();
+ });
+ }
+
+ /**
+ * Pre-populates {@code dir} with {@code n} valid {@code .sfa} segment
+ * files, each containing one frame so {@link SegmentRing#openExisting}
+ * doesn't filter them as empty orphans. Each segment's baseSeq is
+ * positioned so the contiguity check in {@code openExisting} passes.
+ */
+ private static void prepopulate(String dir, int n) {
+ long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < 64; i++) {
+ Unsafe.getUnsafe().putByte(buf + i, (byte) i);
+ }
+ for (int i = 0; i < n; i++) {
+ try (MmapSegment seg = MmapSegment.create(
+ dir + "/sf-pre-" + i + ".sfa",
+ i, // baseSeq=0,1 each holding 1 frame -> contiguous
+ SEGMENT_SIZE)) {
+ Assert.assertTrue("setup append should succeed",
+ seg.tryAppend(buf, 64) >= 0);
+ }
+ }
+ } finally {
+ Unsafe.free(buf, 64, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ private static int countSfaFiles(String dir) {
+ if (!Files.exists(dir)) return 0;
+ long find = Files.findFirst(dir);
+ if (find <= 0) return 0;
+ int n = 0;
+ try {
+ int rc = 1;
+ while (rc > 0) {
+ String name = Files.utf8ToString(Files.findName(find));
+ if (name != null && name.endsWith(".sfa")) n++;
+ rc = Files.findNext(find);
+ }
+ } finally {
+ Files.findClose(find);
+ }
+ return n;
+ }
+
+ private static void rmDirRec(String dir) {
+ if (!Files.exists(dir)) return;
+ 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)) {
+ String child = dir + "/" + name;
+ if (!Files.remove(child)) rmDirRec(child);
+ }
+ rc = Files.findNext(find);
+ }
+ } finally {
+ Files.findClose(find);
+ }
+ }
+ Files.remove(dir);
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java
index a00faea4..2381d8b1 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java
@@ -225,6 +225,49 @@ public void testManagerTrimsAckedSegmentFiles() throws Exception {
});
}
+ /**
+ * SegmentManager creates every rotation spare with manifestRequired
+ * stamped from birth (unlike the engine's own initial segment, which is
+ * created without the flag and stamped separately only after its
+ * manifest is durable -- see CursorSendEngine's fresh-start branch). By
+ * the time a rotation can happen the manifest this slot needs already
+ * exists, so there is no crash window to protect against here; this
+ * pins the byte manager actually writes rather than just the argument it
+ * passes, the same way testHeaderShapeMatchesTheDocumentedLayout pins
+ * MmapSegment's own header bytes rather than restating a constant.
+ */
+ @Test
+ public void testRotationSpareCarriesManifestRequiredFlagOnDisk() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long segSize = MmapSegment.HEADER_SIZE
+ + 2 * (MmapSegment.FRAME_HEADER_SIZE + 32);
+ MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize);
+ long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ try (SegmentRing ring = new SegmentRing(seg0, segSize);
+ SegmentManager mgr = new SegmentManager(segSize, 200_000L)) {
+ mgr.start();
+ mgr.register(ring, tmpDir);
+
+ // Fill seg0 (2 frames) and force rotation by appending a third.
+ for (int i = 0; i < 2; i++) ring.appendOrFsn(buf, 32);
+ assertTrue("spare must land before rotation",
+ waitFor(() -> !ring.needsHotSpare(), 2000));
+ ring.appendOrFsn(buf, 32); // FSN 2, rotates active to the spare
+
+ MmapSegment spare = ring.getActive();
+ assertNotEquals("rotation must have installed a new active segment", seg0, spare);
+ byte[] bytes = java.nio.file.Files.readAllBytes(Paths.get(spare.path()));
+ assertEquals("spare must carry the current on-disk VERSION",
+ MmapSegment.VERSION, bytes[4]);
+ assertEquals("spare must be created with the manifestRequired flag "
+ + "already stamped in its header",
+ MmapSegment.MANIFEST_REQUIRED_FLAG, bytes[5]);
+ } finally {
+ Unsafe.free(buf, 32, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
@Test
public void testMaxTotalBytesCapBlocksProvisioningUntilTrimFrees() throws Exception {
TestUtils.assertMemoryLeak(() -> {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java
index 1b6260b9..039e77de 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java
@@ -29,6 +29,7 @@
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SfManifest;
import io.questdb.client.std.Crc32c;
import io.questdb.client.std.Files;
import io.questdb.client.std.FilesFacade;
@@ -950,6 +951,46 @@ public void testDrainCrashSurvivingSpareRecoversEmpty() throws Exception {
});
}
+ /**
+ * The clean-drain crash window discards leftover segment files and reports
+ * EMPTY, which makes the caller start fresh at baseSeq 0. If a leftover
+ * survives that removal, the fresh session writes its own segments into a
+ * directory that still holds a prior generation's file: two producer
+ * generations sharing one slot, with overlapping ids describing different
+ * strings. Nothing downstream can see it -- both generations number their
+ * FSNs from the same origin, so neither validateContiguous nor a manifest
+ * boundary can tell them apart. Refuse instead, leaving every byte on disk.
+ */
+ @Test
+ public void testDrainWindowFailsClosedWhenLeftoverCannotBeRemoved() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String leftoverPath = tmpDir + "/sf-000000000000000a.sfa";
+ MmapSegment leftover = MmapSegment.create(leftoverPath, 0L, SEGMENT_SIZE);
+ leftover.close();
+ // Collapsed boundaries above the leftover's base: the drain declared
+ // everything acked, and no file sits at the committed active base.
+ SfManifest.create(FilesFacade.INSTANCE, tmpDir, 5L, 5L).close();
+
+ FilesFacade facade = new DelegatingFacade() {
+ @Override
+ public boolean remove(String path) {
+ return !leftoverPath.equals(path) && super.remove(path);
+ }
+ };
+ try {
+ SegmentRing ring = SegmentRing.openExisting(facade, tmpDir, SEGMENT_SIZE);
+ if (ring != null) {
+ ring.close();
+ }
+ throw new AssertionError("a surviving leftover must refuse the slot");
+ } catch (MmapSegmentException expected) {
+ TestUtils.assertContains(expected.getMessage(), "could not remove drained SF leftover");
+ }
+ Assert.assertTrue("the refused leftover must stay on disk",
+ Files.exists(leftoverPath));
+ });
+ }
+
@Test
public void testCorruptActiveWithSameBaseEmptyStandInFailsClosed() throws Exception {
TestUtils.assertMemoryLeak(() -> {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java
index 988dbb85..4e79c6b1 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java
@@ -27,6 +27,7 @@
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SfRecoveryException;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SfSanitizedResidueException;
import io.questdb.client.std.Files;
import io.questdb.client.std.MemoryTag;
@@ -45,6 +46,7 @@
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
@@ -748,19 +750,32 @@ public void testOpenExistingDetectsFsnGap() throws Exception {
});
}
+ /**
+ * A bad-magic stray file beside a chain BASED AT ZERO is provably not that
+ * chain's missing head -- nothing can precede baseSeq 0 -- so recovery
+ * quarantines the stray to {@code .corrupt} and recovers the good segment.
+ * The bytes are preserved for a postmortem either way; what recovery must
+ * never do is treat the stray as absent and leave no evidence.
+ *
+ * Contrast {@link #testOpenExistingRefusesSlotWhenOldestSegmentIsUnreadable}:
+ * there the survivors start above zero, so a corrupt file of unknown
+ * identity COULD be the head and recovery fails closed instead.
+ */
@Test
- public void testOpenExistingSkipsBadMagicFile() throws Exception {
+ public void testStrayBadMagicFileIsQuarantinedAndTheChainRecovers() throws Exception {
TestUtils.assertMemoryLeak(() -> {
long segSize = MmapSegment.HEADER_SIZE
+ (MmapSegment.FRAME_HEADER_SIZE + 16);
long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
try {
// One good segment.
- MmapSegment s0 = MmapSegment.create(tmpDir + "/good.sfa", 0, segSize);
+ String goodPath = tmpDir + "/good.sfa";
+ MmapSegment s0 = MmapSegment.create(goodPath, 0, segSize);
s0.tryAppend(buf, 16);
s0.close();
- // One stray .sfa with no proper header — must be ignored.
- int fd = Files.openCleanRW(tmpDir + "/stray.sfa");
+ // One stray .sfa with no proper header.
+ String strayPath = tmpDir + "/stray.sfa";
+ int fd = Files.openCleanRW(strayPath);
long hdr = Unsafe.malloc(8, MemoryTag.NATIVE_DEFAULT);
try {
Unsafe.getUnsafe().putLong(hdr, 0xBADBADBADBADBADBL);
@@ -771,17 +786,315 @@ public void testOpenExistingSkipsBadMagicFile() throws Exception {
Unsafe.free(hdr, 8, MemoryTag.NATIVE_DEFAULT);
}
- try (SegmentRing recovered = SegmentRing.openExisting(tmpDir, segSize)) {
- assertNotNull(recovered);
- assertEquals(0, recovered.getActive().baseSeq());
- assertEquals(0, recovered.getSealedSegments().size());
+ SegmentRing recovered = SegmentRing.openExisting(tmpDir, segSize);
+ assertNotNull("a chain based at 0 must recover around a stray", recovered);
+ try {
+ assertEquals("the good segment's frame must be recovered",
+ 0L, recovered.publishedFsn());
+ } finally {
+ recovered.close();
}
+ assertFalse("the unreadable stray file must be renamed aside", Files.exists(strayPath));
+ assertTrue("the renamed file must survive for a postmortem",
+ Files.exists(strayPath + ".corrupt"));
+ assertTrue("the good segment must be untouched", Files.exists(goodPath));
} finally {
Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
}
});
}
+ /**
+ * Skipped OLDEST segment: {@code firstSealed()} would normally return the
+ * second-oldest survivor, and {@code CursorSendEngine} seeds
+ * {@code ackedFsn = lowestBase - 1} on the premise "anything trimmed off the
+ * ring's bottom must have been acked, because trim is ack-driven". A
+ * segment can leave the bottom because of a read fault instead of an ACK,
+ * so that premise is false -- recovery must refuse rather than hand back a
+ * ring whose sealed list quietly starts one segment higher than what
+ * really produced acked data.
+ */
+ @Test
+ public void testOpenExistingRefusesSlotWhenOldestSegmentIsUnreadable() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long segSize = MmapSegment.HEADER_SIZE
+ + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16);
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ String oldestPath = tmpDir + "/skip-oldest-0.sfa";
+ MmapSegment s0 = MmapSegment.create(oldestPath, 0, segSize);
+ for (int i = 0; i < 4; i++) s0.tryAppend(buf, 16);
+ s0.close();
+
+ MmapSegment s1 = MmapSegment.create(tmpDir + "/skip-oldest-1.sfa", 4, segSize);
+ for (int i = 0; i < 4; i++) s1.tryAppend(buf, 16);
+ s1.close();
+
+ MmapSegment s2 = MmapSegment.create(tmpDir + "/skip-oldest-2.sfa", 8, segSize);
+ s2.tryAppend(buf, 16);
+ s2.close();
+
+ corruptMagic(oldestPath);
+
+ try {
+ Misc.free(SegmentRing.openExisting(tmpDir, segSize));
+ throw new AssertionError(
+ "expected recovery to refuse rather than silently drop the oldest segment");
+ } catch (SfRecoveryException expected) {
+ assertTrue(expected.getMessage(),
+ expected.getMessage().contains("could be its head"));
+ }
+ // Fail-closed: the chain never validated, so the deferred quarantine
+ // never ran. Every byte stays where it was, under its own name, for
+ // an operator to extract.
+ assertTrue("a failed recovery must not mutate the slot",
+ Files.exists(oldestPath));
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ /**
+ * Corrupt NEWEST segment, chain based at zero. The corrupt file is quarantined
+ * to {@code .corrupt} -- which is what closes the FSN-reuse hazard this test
+ * was written for: once it leaves the {@code .sfa} namespace, a later recovery
+ * cannot sort it onto a baseSeq the resumed producer has since re-issued, and
+ * the manifest this recovery writes records the surviving chain's
+ * {@code activeBase} as the boundary. The survivors replay.
+ */
+ @Test
+ public void testCorruptNewestSegmentIsQuarantinedAndSurvivorsRecover() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long segSize = MmapSegment.HEADER_SIZE
+ + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16);
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ MmapSegment s0 = MmapSegment.create(tmpDir + "/skip-newest-0.sfa", 0, segSize);
+ for (int i = 0; i < 4; i++) s0.tryAppend(buf, 16);
+ s0.close();
+
+ String newestPath = tmpDir + "/skip-newest-1.sfa";
+ MmapSegment s1 = MmapSegment.create(newestPath, 4, segSize);
+ s1.tryAppend(buf, 16);
+ s1.close();
+
+ corruptMagic(newestPath);
+
+ SegmentRing recovered = SegmentRing.openExisting(tmpDir, segSize);
+ assertNotNull("a chain based at 0 must recover without its corrupt tail", recovered);
+ try {
+ assertEquals("every frame of the surviving chain must replay",
+ 3L, recovered.publishedFsn());
+ } finally {
+ recovered.close();
+ }
+ assertFalse("the unreadable newest segment must be renamed aside",
+ Files.exists(newestPath));
+ assertTrue("the renamed file must survive for a postmortem",
+ Files.exists(newestPath + ".corrupt"));
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ /**
+ * Skipped INTERIOR segment: three segments at baseSeq 0/4/8, the MIDDLE one
+ * unreadable. Unlike the oldest/newest cases, skipping an interior segment also
+ * opens an FSN gap between its surviving neighbours (baseSeq 0 with 4 frames,
+ * then baseSeq 8 -- expected 4). If the contiguity check below ran before the
+ * skip-tally refusal, that gap would throw the untyped {@code MmapSegmentException}
+ * first, escaping BOTH {@code UnreplayableSlotException} catches in
+ * {@code Sender.build()} (the constructor-time one and the connect()-time one) and
+ * repeating identically forever: the interior file is already renamed to
+ * {@code .corrupt}, so every retry recomputes {@code skippedSegmentCount} as 0 and
+ * hits the same gap again. The skip-tally refusal must run first.
+ */
+ @Test
+ public void testOpenExistingRefusesSlotWhenInteriorSegmentIsUnreadable() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long segSize = MmapSegment.HEADER_SIZE
+ + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16);
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ MmapSegment s0 = MmapSegment.create(tmpDir + "/skip-interior-0.sfa", 0, segSize);
+ for (int i = 0; i < 4; i++) s0.tryAppend(buf, 16);
+ s0.close();
+
+ String interiorPath = tmpDir + "/skip-interior-1.sfa";
+ MmapSegment s1 = MmapSegment.create(interiorPath, 4, segSize);
+ for (int i = 0; i < 4; i++) s1.tryAppend(buf, 16);
+ s1.close();
+
+ MmapSegment s2 = MmapSegment.create(tmpDir + "/skip-interior-2.sfa", 8, segSize);
+ s2.tryAppend(buf, 16);
+ s2.close();
+
+ corruptMagic(interiorPath);
+
+ try {
+ Misc.free(SegmentRing.openExisting(tmpDir, segSize));
+ throw new AssertionError("expected recovery to refuse rather than replay "
+ + "survivors across the hole the interior segment left");
+ } catch (SfRecoveryException expected) {
+ assertTrue(expected.getMessage(),
+ expected.getMessage().contains("FSN gap in recovered segments"));
+ }
+ // Fail-closed: the chain never validated, so the deferred quarantine
+ // never ran and every byte is left exactly where it was for an
+ // operator to extract.
+ assertTrue("a failed recovery must not mutate the slot",
+ Files.exists(interiorPath));
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ /**
+ * Every {@code .sfa} in a LEGACY (manifest-less) directory is positively
+ * corrupt, so nothing survives to anchor a chain. There is no recorded
+ * boundary to fail closed against, so recovery quarantines every file --
+ * preserving the bytes under {@code .corrupt}, out of the {@code .sfa}
+ * namespace -- and reports the slot empty so the producer can start fresh.
+ * The one thing it must never do is drop the files silently.
+ *
+ * With a manifest present this same state throws instead: the manifest
+ * proves durable frames existed, and that path is covered by
+ * {@code SegmentRecoveryIntegrityTest}.
+ */
+ @Test
+ public void testEveryCorruptSegmentIsQuarantinedInALegacySlot() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long segSize = MmapSegment.HEADER_SIZE
+ + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16);
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ String path0 = tmpDir + "/skip-all-0.sfa";
+ MmapSegment s0 = MmapSegment.create(path0, 0, segSize);
+ for (int i = 0; i < 4; i++) s0.tryAppend(buf, 16);
+ s0.close();
+
+ String path1 = tmpDir + "/skip-all-1.sfa";
+ MmapSegment s1 = MmapSegment.create(path1, 4, segSize);
+ s1.tryAppend(buf, 16);
+ s1.close();
+
+ corruptMagic(path0);
+ corruptMagic(path1);
+
+ assertNull("nothing survives, so the slot must report empty",
+ SegmentRing.openExisting(tmpDir, segSize));
+ // Both files quarantined, not just the last one seen: a
+ // miscounting regression would leave one behind under its
+ // original name.
+ assertFalse(Files.exists(path0));
+ assertFalse(Files.exists(path1));
+ assertTrue("quarantine preserves the bytes as evidence",
+ Files.exists(path0 + ".corrupt"));
+ assertTrue("quarantine preserves the bytes as evidence",
+ Files.exists(path1 + ".corrupt"));
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ /**
+ * The empty-with-torn-tail branch (frame[0] itself fails CRC, so
+ * {@code MmapSegment.openExisting} returns successfully but with
+ * {@code frameCount() == 0} and {@code tornTailBytes() > 0}) is a second, separate
+ * path into the same hole this task closes: it quarantines the file to
+ * {@code .corrupt} and moves on WITHOUT incrementing {@code skippedSegmentCount},
+ * because that branch predates this task and sits outside the {@code catch
+ * (Throwable)} arm entirely. If this is the OLDEST segment, the contiguity check
+ * never sees a gap (it only compares files that opened successfully), so recovery
+ * would otherwise seed {@code ackedFsn} past frames nothing shows were delivered --
+ * the same silent loss, reached through a different branch. Corrupts frame[0]'s CRC
+ * directly (the same technique {@link #testOpenExistingPreservesSoleSegmentWithTornFirstFrame}
+ * uses for the "must not silently unlink" guarantee), which is a genuinely different
+ * code path from {@link #corruptMagic}: a bad magic byte fails inside
+ * {@code MmapSegment.openExisting} and hits the {@code catch (Throwable)} arm; a bad
+ * frame[0] CRC lets {@code MmapSegment.openExisting} return normally and hits this
+ * "empty leftover" branch instead.
+ */
+ @Test
+ public void testOpenExistingRefusesSlotWhenOldestSegmentHasATornFirstFrame() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long segSize = MmapSegment.HEADER_SIZE
+ + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16);
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ String oldestPath = tmpDir + "/torn-oldest-0.sfa";
+ MmapSegment s0 = MmapSegment.create(oldestPath, 0, segSize);
+ for (int i = 0; i < 4; i++) s0.tryAppend(buf, 16);
+ s0.close();
+
+ MmapSegment s1 = MmapSegment.create(tmpDir + "/torn-oldest-1.sfa", 4, segSize);
+ s1.tryAppend(buf, 16);
+ s1.close();
+
+ corruptFrameZeroCrc(oldestPath);
+
+ try {
+ Misc.free(SegmentRing.openExisting(tmpDir, segSize));
+ throw new AssertionError("expected recovery to refuse rather than silently "
+ + "seed ackedFsn past the torn oldest segment's frames");
+ } catch (SfRecoveryException expected) {
+ assertTrue(expected.getMessage(),
+ expected.getMessage().contains("lost its frames to a torn write"));
+ }
+ // Fail-closed leaves the evidence in place under its own name.
+ assertTrue("a failed recovery must not mutate the slot",
+ Files.exists(oldestPath));
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ /**
+ * The single-segment variant of {@link #testOpenExistingRefusesSlotWhenOldestSegmentHasATornFirstFrame}:
+ * here the torn segment IS the whole slot, so there is no valid sibling to recover
+ * around and no chain to refuse -- recovery reports the slot empty and returns.
+ *
+ * The bytes must survive that. {@code scanFrames} bails at frame[0], so the recovery
+ * scan reports {@code frameCount() == 0} while three intact frames still sit
+ * physically behind the torn one. Treating that as an "empty hot-spare leftover" and
+ * unlinking the file destroys all of them on nothing more than a WARN -- a single bit
+ * flip (bit rot, a partial page write at crash) turning into silent data loss.
+ * Preserving the file in place, or quarantining it to {@code .corrupt}, keeps a
+ * postmortem able to recover the surviving frames.
+ */
+ @Test
+ public void testOpenExistingPreservesSoleSegmentWithTornFirstFrame() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ try {
+ fillPattern(buf, 32, 0);
+ String segPath = tmpDir + "/sole-torn.sfa";
+ MmapSegment seg = MmapSegment.create(segPath, 0L, 64 * 1024);
+ assertTrue("setup: first append must succeed", seg.tryAppend(buf, 32) >= 0);
+ assertTrue("setup: second append must succeed", seg.tryAppend(buf, 32) >= 0);
+ assertTrue("setup: third append must succeed", seg.tryAppend(buf, 32) >= 0);
+ assertEquals("setup: three frames written", 3L, seg.frameCount());
+ seg.close();
+
+ corruptFrameZeroCrc(segPath);
+
+ Misc.free(SegmentRing.openExisting(tmpDir, 64 * 1024));
+
+ assertTrue("recovery silently unlinked a segment whose first frame failed CRC; "
+ + "three valid frames followed it and recovery destroyed all of "
+ + "them with only a WARN",
+ Files.exists(segPath) || Files.exists(segPath + ".corrupt"));
+ } finally {
+ Unsafe.free(buf, 32, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
@Test
public void testAcknowledgeIsMonotonic() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -817,6 +1130,42 @@ public void testAcknowledgeIsMonotonic() throws Exception {
});
}
+ /**
+ * {@link #testAcknowledgeIsMonotonic} states the clamp in prose but never feeds
+ * {@code acknowledge} a value ABOVE {@code publishedFsn} -- every ack it makes is in
+ * range, so it pins the regression rule only. This pins the clamp itself.
+ *
+ * A malformed or poisoned server NACK can carry any {@code wireSeq}, and the DROP
+ * path in {@code CursorWebSocketSendLoop.handleServerRejection} does not clamp before
+ * it reaches the engine. Should {@code ackedFsn} run past {@code publishedFsn}, the
+ * segment manager's trim pass munmaps and unlinks segments the I/O thread is still
+ * iterating, and the next {@code Unsafe.getInt} on the unmapped region SEGVs the JVM.
+ * {@code acknowledge} therefore clamps as defense-in-depth.
+ */
+ @Test
+ public void testAcknowledgeClampsAtPublishedFsn() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ try {
+ fillPattern(buf, 32, 0);
+ MmapSegment seg = MmapSegment.create(tmpDir + "/clamp.sfa", 0L, 64 * 1024);
+ try (SegmentRing ring = new SegmentRing(seg, 64 * 1024)) {
+ assertEquals("setup: first append yields FSN 0", 0L, ring.appendOrFsn(buf, 32));
+ assertEquals("setup: publishedFsn matches", 0L, ring.publishedFsn());
+ assertEquals("setup: nothing acked yet", -1L, ring.ackedFsn());
+
+ ring.acknowledge(Long.MAX_VALUE / 2L);
+
+ assertEquals("acknowledge must clamp a bogus seq at publishedFsn, not "
+ + "advance ackedFsn past what the I/O thread has sent",
+ ring.publishedFsn(), ring.ackedFsn());
+ }
+ } finally {
+ Unsafe.free(buf, 32, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
@Test
public void testNextSealedAfterWalksThousandsOfSegmentsInLinearOperations() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -868,6 +1217,7 @@ public void testNextSealedAfterWalksThousandsOfSegmentsInLinearOperations() thro
assertEquals(0, cursor.baseSeq());
int visited = 1;
long prevBase = cursor.baseSeq();
+ int maxSearchComparisons = 0;
while (true) {
MmapSegment next = ring.nextSealedAfter(cursor);
if (next == null) break;
@@ -1255,6 +1605,52 @@ public void testMaxBytesPerSegmentSurvivesOpenExisting() throws Exception {
});
}
+ /**
+ * Overwrites frame[0]'s 4-byte CRC field with a value statistically guaranteed to
+ * mismatch, leaving the frame's length field and payload -- and every later frame --
+ * untouched. {@code MmapSegment.openExisting} still returns normally (the header is
+ * fine), but {@code scanFrames} bails at frame[0], so {@code frameCount() == 0} and
+ * {@code tornTailBytes() > 0} -- landing in {@code SegmentRing}'s
+ * empty-with-torn-tail branch rather than its {@code catch (Throwable)} arm. Shared by
+ * {@link #testOpenExistingRefusesSlotWhenOldestSegmentHasATornFirstFrame} (torn oldest
+ * beside a valid sibling) and
+ * {@link #testOpenExistingPreservesSoleSegmentWithTornFirstFrame} (torn segment is the
+ * whole slot).
+ */
+ private static void corruptFrameZeroCrc(String path) {
+ int fd = Files.openRW(path);
+ assertTrue("openRW failed", fd >= 0);
+ long buf = Unsafe.malloc(4, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Unsafe.getUnsafe().putInt(buf, 0xDEADBEEF);
+ Files.write(fd, buf, 4, MmapSegment.HEADER_SIZE);
+ } finally {
+ Unsafe.free(buf, 4, MemoryTag.NATIVE_DEFAULT);
+ Files.close(fd);
+ }
+ }
+
+ /**
+ * Overwrites the 4-byte {@code FILE_MAGIC} field at offset 0 with a value
+ * that cannot match {@link MmapSegment#FILE_MAGIC}, leaving every other
+ * byte -- including real frame data -- untouched. Forces
+ * {@link MmapSegment#openExisting} to throw at the magic check, landing in
+ * {@code SegmentRing}'s per-file skip arm without going anywhere near the
+ * "empty leftover" branch a torn-tail CRC failure would hit instead.
+ */
+ private static void corruptMagic(String path) {
+ int fd = Files.openRW(path);
+ 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);
+ }
+ }
+
/**
* Pins findSegmentContaining across the full boundary matrix so the
* O(log N) lookup rewrite is provably semantics-preserving: empty ring,
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SlotLockTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SlotLockTest.java
index 3529a5d9..2cc453eb 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SlotLockTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SlotLockTest.java
@@ -27,6 +27,7 @@
import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLockContentionException;
import io.questdb.client.std.Files;
+import io.questdb.client.test.tools.DelegatingFilesFacade;
import io.questdb.client.test.tools.TestUtils;
import org.junit.After;
import org.junit.Before;
@@ -107,6 +108,32 @@ public void testCloseReleasesLock() throws Exception {
});
}
+ @Test
+ public void testLogicalLockRemainsContendedAcrossSlotRenameAndRecreate() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // Use the platform-native separator. In particular, this exercises
+ // acquireLogical with a backslash-only path on Windows.
+ String slot = Paths.get(parentDir, "rename").toString();
+ String moved = Paths.get(parentDir, "rename.quarantined").toString();
+ assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
+
+ try (SlotLock ignored = SlotLock.acquireLogical(slot)) {
+ assertEquals(0, Files.rename(slot, moved));
+ assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
+
+ try (SlotLock unexpected = SlotLock.acquireLogical(slot)) {
+ fail("logical slot lock must survive rename and recreate");
+ } catch (IllegalStateException expected) {
+ assertTrue(expected.getMessage().contains("already in use"));
+ }
+ }
+
+ try (SlotLock reacquired = SlotLock.acquireLogical(slot)) {
+ assertEquals(slot, reacquired.slotDir());
+ }
+ });
+ }
+
@Test
public void testReleaseConfirmsAndIsIdempotent() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -126,6 +153,55 @@ public void testReleaseConfirmsAndIsIdempotent() throws Exception {
});
}
+ @Test
+ public void testLogicalLockRejectsInvalidPaths() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ assertLogicalPathRejected(null, "slotDir must not be empty");
+ assertLogicalPathRejected("", "slotDir must not be empty");
+ assertLogicalPathRejected("slot",
+ "slotDir must contain a parent and slot name: slot");
+ });
+ }
+
+ @Test
+ public void testLockDirectoryIsCreatedWithDefaultMode() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String slot = parentDir + "/mode-check";
+ String lockDir = parentDir + "/.slot-locks";
+ RecordingMkdirFacade ff = new RecordingMkdirFacade();
+ try (SlotLock ignored = SlotLock.acquireLogical(ff, slot)) {
+ assertEquals("the lock dir must be created with the default restrictive mode",
+ Files.DIR_MODE_DEFAULT, ff.modeFor(lockDir));
+ }
+ });
+ }
+
+ @Test
+ public void testLogicalLockReportsLockDirectoryCreationFailure() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String slot = parentDir + "/mkdir-failure";
+ // Build the lock-dir path exactly as SlotLock.acquireLogical does
+ // (parent + "/" + ".slot-locks"), NOT via Paths.get: on Windows
+ // Paths.get yields a '\' separator, so the facade's lockDir.equals(path)
+ // check never matched the production forward-slash path and the mkdir
+ // failure was never injected -- the sole cause of the Windows-only
+ // failure of this test.
+ String lockDir = parentDir + "/.slot-locks";
+ LockDirectoryFailureFacade ff = new LockDirectoryFailureFacade(lockDir);
+ try {
+ SlotLock.acquireLogical(ff, slot);
+ fail("expected logical lock directory creation failure");
+ } catch (IllegalStateException expected) {
+ assertEquals("could not create logical slot lock dir: " + lockDir + " rc=-1",
+ expected.getMessage());
+ }
+ assertEquals("lock file must not be opened after directory creation fails",
+ 0, ff.openRwCalls);
+ assertFalse("failed mkdir must not leave the lock directory behind",
+ Files.exists(lockDir));
+ });
+ }
+
/**
* The {@code release() == false} branch: when the OS reports an explicit
* unlock failure, release must (a) return {@code false} so owners gating a
@@ -170,6 +246,27 @@ public void testFailedCloseRetainsRetryOwnerUntilNextAcquire() throws Exception
});
}
+ @Test
+ public void testRemoveOrphanLogicalDeletesLockAndPidFiles() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String slot = parentDir + "/alpha";
+ assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
+ // acquireLogical anchors the lock under the shared parent .slot-locks dir.
+ String lockFile = parentDir + "/.slot-locks/alpha.lock";
+ String pidFile = parentDir + "/.slot-locks/alpha.lock.pid";
+ try (SlotLock ignored = SlotLock.acquireLogical(slot)) {
+ assertTrue("logical .lock created", Files.exists(lockFile));
+ assertTrue("logical .lock.pid created", Files.exists(pidFile));
+ }
+ // close() releases the flock but deliberately keeps the file (it must
+ // outlast a slot rename); only the fully-drained retirement reclaims it.
+ assertTrue("logical .lock survives close", Files.exists(lockFile));
+ SlotLock.removeOrphanLogical(slot);
+ assertFalse("logical .lock removed on retirement", Files.exists(lockFile));
+ assertFalse("logical .lock.pid removed on retirement", Files.exists(pidFile));
+ });
+ }
+
@Test
public void testFailedCloseRetainsRetryOwnerWithEquivalentPathAlias() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -201,6 +298,85 @@ public void testFailedCloseRetainsRetryOwnerWithEquivalentPathAlias() throws Exc
});
}
+ @Test
+ public void testRemoveOrphanLogicalLeavesAHeldLockFileIntact() throws Exception {
+ // removeOrphanLogical must NEVER unlink a lock file another party still holds.
+ // Sender.build() holds the logical lock across its construct -> connect ->
+ // quarantine transition, and a connect failure closes the engine from inside that
+ // scope -- reaching this cleanup while build() is still holding the lock one frame
+ // up. Unlinking it there frees the pathname without releasing the flock, so the
+ // next acquireLogical creates a SECOND inode and locks it: two owners of a lock
+ // whose only job is mutual exclusion. flock is per open-file-description, so a
+ // second open+lock contends even within one process -- which is exactly what makes
+ // the acquire-before-unlink guard observable here.
+ TestUtils.assertMemoryLeak(() -> {
+ String slot = parentDir + "/beta";
+ assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
+ String lockFile = parentDir + "/.slot-locks/beta.lock";
+ String pidFile = parentDir + "/.slot-locks/beta.lock.pid";
+ try (SlotLock held = SlotLock.acquireLogical(slot)) {
+ assertTrue("logical .lock created", Files.exists(lockFile));
+
+ // Cleanup fires while `held` still owns the lock. It must find the lock
+ // contended and leave BOTH files on disk.
+ SlotLock.removeOrphanLogical(slot);
+ assertTrue("a held logical .lock must survive removeOrphanLogical",
+ Files.exists(lockFile));
+ assertTrue("a held logical .lock.pid must survive removeOrphanLogical",
+ Files.exists(pidFile));
+
+ // And the holder must still be the sole owner: a fresh acquire contends.
+ try {
+ SlotLock.acquireLogical(slot).close();
+ fail("a second acquireLogical must contend while the first is held");
+ } catch (IllegalStateException expected) {
+ assertTrue(expected.getMessage(), expected.getMessage().contains("already in use"));
+ }
+ }
+ // Once released, retirement reclaims the files as before -- no leak of the
+ // dead lock+pid pair.
+ SlotLock.removeOrphanLogical(slot);
+ assertFalse("logical .lock reclaimed after release", Files.exists(lockFile));
+ assertFalse("logical .lock.pid reclaimed after release", Files.exists(pidFile));
+ });
+ }
+
+ @Test
+ public void testRemoveOrphanLogicalIsSilentNoOpWhenAbsentOrInvalid() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // Never-locked slot: nothing to remove, must not throw.
+ SlotLock.removeOrphanLogical(parentDir + "/never-locked");
+ // Unlike acquireLogical (which throws on these), the retirement cleanup
+ // is best-effort and tolerates unusable input silently.
+ SlotLock.removeOrphanLogical(null);
+ SlotLock.removeOrphanLogical("");
+ SlotLock.removeOrphanLogical("slot"); // no parent component
+ });
+ }
+
+ @Test
+ public void testRemoveOrphanLogicalUnlinksPidSidecarBeforeLockFile() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String slot = parentDir + "/unlink-order";
+ // Create the lock pair, then release so removal is uncontended.
+ SlotLock.acquireLogical(slot).close();
+ java.util.List
+ * {@code FilesFacade} deliberately leaves most operations abstract, so the
+ * delegation boilerplate lives here once rather than being stamped into each
+ * test that needs a fault seam.
+ */
+public class DelegatingFilesFacade implements FilesFacade {
+ @Override
+ public long allocNativePath(String path) {
+ return INSTANCE.allocNativePath(path);
+ }
+
+ @Override
+ public boolean allocate(int fd, long size) {
+ return INSTANCE.allocate(fd, size);
+ }
+
+ @Override
+ public int close(int fd) {
+ return INSTANCE.close(fd);
+ }
+
+ @Override
+ public int errno() {
+ return INSTANCE.errno();
+ }
+
+ @Override
+ public boolean exists(String path) {
+ return INSTANCE.exists(path);
+ }
+
+ @Override
+ public void findClose(long findPtr) {
+ INSTANCE.findClose(findPtr);
+ }
+
+ @Override
+ public long findFirst(String dir) {
+ return INSTANCE.findFirst(dir);
+ }
+
+ @Override
+ public long findName(long findPtr) {
+ return INSTANCE.findName(findPtr);
+ }
+
+ @Override
+ public int findNext(long findPtr) {
+ return INSTANCE.findNext(findPtr);
+ }
+
+ @Override
+ public int findType(long findPtr) {
+ return INSTANCE.findType(findPtr);
+ }
+
+ @Override
+ public void freeNativePath(long pathPtr) {
+ INSTANCE.freeNativePath(pathPtr);
+ }
+
+ @Override
+ public int fsync(int fd) {
+ return INSTANCE.fsync(fd);
+ }
+
+ @Override
+ public long length(int fd) {
+ return INSTANCE.length(fd);
+ }
+
+ @Override
+ public long length(String path) {
+ return INSTANCE.length(path);
+ }
+
+ @Override
+ public long length(long pathPtr) {
+ return INSTANCE.length(pathPtr);
+ }
+
+ @Override
+ public int lock(int fd) {
+ return INSTANCE.lock(fd);
+ }
+
+ @Override
+ public int mkdir(String path, int mode) {
+ return INSTANCE.mkdir(path, mode);
+ }
+
+ @Override
+ public int openCleanRW(String path) {
+ return INSTANCE.openCleanRW(path);
+ }
+
+ @Override
+ public int openCleanRW(long pathPtr) {
+ return INSTANCE.openCleanRW(pathPtr);
+ }
+
+ @Override
+ public int openRW(String path) {
+ return INSTANCE.openRW(path);
+ }
+
+ @Override
+ public int openRW(long pathPtr) {
+ return INSTANCE.openRW(pathPtr);
+ }
+
+ @Override
+ public long read(int fd, long addr, long len, long offset) {
+ return INSTANCE.read(fd, addr, len, offset);
+ }
+
+ @Override
+ public boolean remove(String path) {
+ return INSTANCE.remove(path);
+ }
+
+ @Override
+ public boolean remove(long pathPtr) {
+ return INSTANCE.remove(pathPtr);
+ }
+
+ @Override
+ public int rename(String oldPath, String newPath) {
+ return INSTANCE.rename(oldPath, newPath);
+ }
+
+ @Override
+ public boolean truncate(int fd, long size) {
+ return INSTANCE.truncate(fd, size);
+ }
+
+ @Override
+ public long write(int fd, long addr, long len, long offset) {
+ return INSTANCE.write(fd, addr, len, offset);
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/tools/TestUtils.java b/core/src/test/java/io/questdb/client/test/tools/TestUtils.java
index 7572880b..82f65e6b 100644
--- a/core/src/test/java/io/questdb/client/test/tools/TestUtils.java
+++ b/core/src/test/java/io/questdb/client/test/tools/TestUtils.java
@@ -37,9 +37,15 @@
import org.junit.Assert;
import org.slf4j.Logger;
+import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
+import java.nio.file.FileVisitResult;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays;
import static org.junit.Assert.assertNotNull;
@@ -215,6 +221,91 @@ public static void removeTmpDir(String tmpDir) {
Files.remove(tmpDir);
}
+ /**
+ * Recursive counterpart to {@link #removeTmpDir(String)} for tests whose temp
+ * directory has subdirectories -- e.g. the store-and-forward slot layout
+ * {@code
@@ -60,6 +62,7 @@ public final class SenderError {
private final long detectedAtNanos;
private final long fromFsn;
private final long messageSequence;
+ private final String quarantinedPath;
private final String serverMessage;
private final int serverStatusByte;
private final String tableName;
@@ -74,6 +77,22 @@ public SenderError(
long toFsn,
@Nullable String tableName,
long detectedAtNanos
+ ) {
+ this(category, appliedPolicy, serverStatusByte, serverMessage, messageSequence,
+ fromFsn, toFsn, tableName, detectedAtNanos, null);
+ }
+
+ private SenderError(
+ @NotNull Category category,
+ @NotNull Policy appliedPolicy,
+ int serverStatusByte,
+ @Nullable String serverMessage,
+ long messageSequence,
+ long fromFsn,
+ long toFsn,
+ @Nullable String tableName,
+ long detectedAtNanos,
+ @Nullable String quarantinedPath
) {
this.category = category;
this.appliedPolicy = appliedPolicy;
@@ -84,6 +103,26 @@ public SenderError(
this.toFsn = toFsn;
this.tableName = tableName;
this.detectedAtNanos = detectedAtNanos;
+ this.quarantinedPath = quarantinedPath;
+ }
+
+ /**
+ * The only way to build a {@link Category#DATA_LOSS} report. Binds the
+ * category/policy pair the two enum constants promise each other and fills
+ * the server-shaped fields with their sentinels — there is no server
+ * verdict to report. The FSN span is {@link #NO_MESSAGE_SEQUENCE} on both
+ * bounds: the abandoned span is unknown at quarantine time (the engine is
+ * closed or was never built).
+ *
+ * @param detail why the data is unreachable, from the recovery verdict;
+ * becomes {@link #getServerMessage()}
+ * @param quarantinedPath where the abandoned bytes remain on disk, for
+ * forensics and a manual resend
+ */
+ public static SenderError dataLoss(@NotNull String detail, @NotNull String quarantinedPath) {
+ return new SenderError(Category.DATA_LOSS, Policy.ABANDONED, NO_STATUS_BYTE, detail,
+ NO_MESSAGE_SEQUENCE, NO_MESSAGE_SEQUENCE, NO_MESSAGE_SEQUENCE, null,
+ System.nanoTime(), quarantinedPath);
}
/**
@@ -112,6 +151,7 @@ public long getDetectedAtNanos() {
/**
* @return inclusive lower bound of the FSN span for the rejected batch — correlation key for producer-side logs.
+ * For {@link Category#DATA_LOSS} this is {@link #NO_MESSAGE_SEQUENCE} — the abandoned span is unknown at quarantine time.
*/
public long getFromFsn() {
return fromFsn;
@@ -125,6 +165,16 @@ public long getMessageSequence() {
return messageSequence;
}
+ /**
+ * @return for {@link Category#DATA_LOSS}: the on-disk path where the abandoned
+ * bytes remain (a quarantined {@code .unreplayable-N} directory, or the slot
+ * directory itself when a drainer left it behind a {@code .failed} sentinel).
+ * Null for every other category.
+ */
+ public @Nullable String getQuarantinedPath() {
+ return quarantinedPath;
+ }
+
/**
* @return the human-readable message provided by the server (≤1024 UTF-8 bytes for QWP error frames,
* or the WebSocket close reason for protocol violations). May be null if the server provided no text.
@@ -152,6 +202,7 @@ public int getServerStatusByte() {
/**
* @return inclusive upper bound of the FSN span for the rejected batch.
+ * For {@link Category#DATA_LOSS} this is {@link #NO_MESSAGE_SEQUENCE} — the abandoned span is unknown at quarantine time.
*/
public long getToFsn() {
return toFsn;
@@ -166,13 +217,17 @@ public String toString() {
", fsn=[" + fromFsn + ',' + toFsn + ']' +
", table=" + (tableName == null ? "(multi)" : tableName) +
", msg=" + serverMessage +
+ (quarantinedPath == null ? "" : ", quarantined=" + quarantinedPath) +
'}';
}
/**
- * Server-distinguishable rejection categories. Aligned 1:1 with the stable
- * QWP wire status bytes for ingress, plus {@link #PROTOCOL_VIOLATION} for
- * WebSocket-level close frames and {@link #UNKNOWN} for forward compatibility.
+ * Server-distinguishable rejection categories, aligned 1:1 with the stable
+ * QWP wire status bytes for ingress, plus three client-originated ones:
+ * {@link #PROTOCOL_VIOLATION} for the poison-frame detector,
+ * {@link #DATA_LOSS} for permanently abandoned store-and-forward data (the
+ * only category with no server involvement at all), and {@link #UNKNOWN}
+ * for forward compatibility.
*/
public enum Category {
/**
@@ -202,6 +257,13 @@ public enum Category {
* mapped for forward compatibility with servers that NACK it explicitly.
*/
NOT_WRITABLE,
+ /**
+ * A delta symbol dictionary began above the server's per-connection dictionary.
+ * Wire {@code 0x0D}. Unlike {@link #PARSE_ERROR} this is a function of server
+ * state, not of the frame's bytes, so the same frame succeeds after the
+ * connection's dictionary catch-up has run.
+ */
+ DICTIONARY_GAP,
/**
* A frame the server (or an intermediary) deterministically rejects: the
* poison-frame detector observed the same head-of-line frame fail
@@ -209,6 +271,26 @@ public enum Category {
* consecutive times with no ack progress — replaying it cannot succeed.
*/
PROTOCOL_VIOLATION,
+ /**
+ * Rows this client had durably buffered will never be sent, and no retry
+ * will change that. The only category with no server involvement: the
+ * server never saw these bytes and issued no verdict on them, so
+ * {@link #getServerStatusByte()} is always {@link #NO_STATUS_BYTE} and
+ * {@link #getServerMessage()} carries a client-side explanation. Fired
+ * when store-and-forward recovery sets an unreplayable slot aside
+ * (its symbol dictionary cannot be rebuilt from any source, or its
+ * durable chain is proven corrupt or incomplete) and when an orphan
+ * drainer abandons a slot behind a {@code .failed} sentinel that
+ * nothing clears automatically.
+ *
+ *
Threading
- * Implementations are invoked on a dedicated daemon dispatcher thread, never on the I/O
- * thread or the producer thread. Slow handlers cannot stall publishing; if the bounded
+ * Handlers normally run on a dedicated daemon dispatcher thread, never on the
+ * I/O thread or the producer thread. One exception: a build()-time quarantine
+ * ({@link SenderError.Category#DATA_LOSS}) is dispatched synchronously on the
+ * thread calling {@code build()} — the async dispatcher belongs to the
+ * connected sender, which does not exist yet at build time. Handlers must not
+ * block: for the build-time case, {@code build()} is waiting.
+ * Slow handlers cannot stall publishing; if the bounded
* inbox fills up, surplus notifications are dropped (visible via
* {@code QwpWebSocketSender.getDroppedErrorNotifications()}).
*
diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java
index 94018fff..1290fcab 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java
@@ -469,6 +469,35 @@ public void sendBinary(long dataPtr, int length, int timeout) {
sendBuffer.reset();
}
+ /**
+ * Sends two native-memory slices as one WebSocket binary frame. The slices
+ * are copied directly into the WebSocket send buffer and masked there, so a
+ * caller assembling a small protocol prefix around a large immutable body
+ * does not need a second contiguous staging buffer.
+ *
+ * @param firstPtr pointer to the first payload slice
+ * @param firstLength first payload-slice length
+ * @param secondPtr pointer to the second payload slice
+ * @param secondLength second payload-slice length
+ * @param timeout timeout in milliseconds
+ */
+ public void sendBinary(
+ long firstPtr,
+ int firstLength,
+ long secondPtr,
+ int secondLength,
+ int timeout
+ ) {
+ checkConnected();
+ sendBuffer.reset();
+ sendBuffer.beginFrame();
+ sendBuffer.putBlockOfBytes(firstPtr, firstLength);
+ sendBuffer.putBlockOfBytes(secondPtr, secondLength);
+ WebSocketSendBuffer.FrameInfo frame = sendBuffer.endBinaryFrame();
+ doSend(sendBuffer.getBufferPtr() + frame.offset, frame.length, timeout);
+ sendBuffer.reset();
+ }
+
/**
* Sends binary data with default timeout.
*/
@@ -476,6 +505,13 @@ public void sendBinary(long dataPtr, int length) {
sendBinary(dataPtr, length, defaultTimeout);
}
+ /**
+ * Sends two native-memory slices as one binary frame with the default timeout.
+ */
+ public void sendBinary(long firstPtr, int firstLength, long secondPtr, int secondLength) {
+ sendBinary(firstPtr, firstLength, secondPtr, secondLength, defaultTimeout);
+ }
+
/**
* Sends a close frame.
*/
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/BatchTooLargeForCapException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/BatchTooLargeForCapException.java
new file mode 100644
index 00000000..b9ef97e8
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/BatchTooLargeForCapException.java
@@ -0,0 +1,44 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.cutlass.qwp.client;
+
+import io.questdb.client.cutlass.line.LineSenderException;
+
+/**
+ * A batch that cannot fit the server's advertised cap however it is split. Distinct from
+ * a plain {@link LineSenderException} because the batch is RETAINED for a later flush
+ * against a larger-cap node, so {@code close()} has to recognise it and discard the batch
+ * rather than abandon every row an earlier flush already published. Matching on the
+ * message text would be the alternative, and would silently swallow unrelated failures.
+ * {@link io.questdb.client.Sender#reset()} discards the retained batch and leaves the
+ * sender usable -- the non-destructive recovery when producing smaller batches is not
+ * an option.
+ */
+public class BatchTooLargeForCapException extends LineSenderException {
+
+ public BatchTooLargeForCapException(CharSequence message) {
+ super(message);
+ }
+}
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 3b4c9a90..3e9fa1a3 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
@@ -24,6 +24,8 @@
package io.questdb.client.cutlass.qwp.client;
+import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
import io.questdb.client.std.CharSequenceIntHashMap;
import io.questdb.client.std.ObjList;
@@ -50,6 +52,41 @@ public GlobalSymbolDictionary(int initialCapacity) {
this.idToSymbol = new ObjList<>(initialCapacity);
}
+ /**
+ * Appends {@code symbol} at the next sequential id, matching a recovered /
+ * persisted dictionary's dense id order, WITHOUT de-duplicating.
+ *
+ *
+ * Handing either to a different string is the silent misattribution the dense
+ * id space exists to prevent, so the floor is the max of the two.
+ *
+ *
+ * Those are exactly the two sources, in exactly the order, that the send loop's mirror
+ * is built from: its constructor seeds {@code sentDictCount} from the same dictionary,
+ * and {@code accumulateSentDict} then extends it from the same frames as they replay. So
+ * the producer's {@code sentMaxSymbolId + 1} and the loop's {@code sentDictCount} land on
+ * the same number BY CONSTRUCTION -- the invariant the torn-dictionary guard rests on --
+ * rather than by the two happening to agree.
+ *
- *
* No locks on the steady-state path. The producer thread (user) writes
@@ -147,17 +154,97 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
* briefly down), so a strike count alone can false-positive a transient into
* a producer-fatal terminal. Requiring the suspect to survive this window
* gives a brief outage time to clear (an OK at/beyond the suspect resets the
- * detector) before escalation. {@code 0} = legacy immediate escalation at the
+ * detector) before escalation. {@code 0} = immediate escalation at the
* strike threshold. Configurable per sender via the
* {@code poison_min_escalation_window_millis} connect-string key or
* {@code LineSenderBuilder.poisonMinEscalationWindowMillis(long)}.
*/
public static final long DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS = 5_000L;
+ /**
+ * Default minimum wall-clock dwell (millis) a symbol-dict catch-up CAP GAP must
+ * persist before an orphan drainer may latch a terminal, even once
+ * {@link #MAX_CATCHUP_CAP_GAP_ATTEMPTS} cap gaps have accrued. Same idea as
+ * {@link #DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS}, for the same reason: a
+ * strike count measures "how many times did we look", not "how long has this been
+ * true", and only the latter distinguishes a permanent cluster capability gap from
+ * a node that is briefly away.
+ *
+ *
+ * Once a FOREGROUND sender has reached the server even once, initialization is
+ * over and store-and-forward owns the buffered data: every endpoint-policy
+ * failure is then a transient the drainer must ride out, never a producer-fatal
+ * terminal (Invariant B).
+ */
+ private boolean endpointPolicyFailureIsTerminal() {
+ return reconnectPolicy == ReconnectPolicy.ORPHAN || !hasEverConnected;
+ }
+
/**
* Send {@code err} to the async-delivery dispatcher if one is configured.
* Producer-side typed throw (TERMINAL) goes through {@code recordFatal} +
@@ -1477,7 +2052,7 @@ private boolean recordHeadRejectionStrike(long rejectedFsn) {
// middlebox, or a fast NACK loop), so the count alone would false-
// positive a brief outage into a producer-fatal terminal. Any OK at/
// beyond the suspect during the window resets the detector (see the
- // STATUS_OK handler). Window 0 = legacy immediate escalation.
+ // STATUS_OK handler). Window 0 = immediate escalation.
return (now - poisonFirstStrikeNanos) >= poisonMinEscalationWindowNanos;
}
@@ -1582,8 +2157,8 @@ private void enqueuePendingOk(long wireSeq) {
* listener both non-null), enters the per-outage retry loop: capped
* exponential backoff with jitter, retried for as long as the loop is
* running -- there is NO wall-clock give-up (Invariant B: a store-and-
- * forward drainer only terminates on SF exhaustion or a genuinely non-
- * retriable auth/upgrade reject). On the first successful reconnect the
+ * forward drainer does not give up on endpoint or transport state). On the
+ * first successful reconnect the
* I/O loop resumes with reset wire state and replays from
* {@code engine.ackedFsn() + 1}.
*
- * [u32 magic 'SF01'] [u8 ver=1] [u8 flags=0] [u16 reserved=0]
- * [u64 baseSeq] [u64 createdMicros] 24-byte header
+ * [u32 magic 'SF01'] [u8 ver=1] [u8 flags] [u16 reserved=0]
+ * [u64 baseSeq] [u64 createdMicros] 24-byte header
* frame, frame, ... each frame:
* [u32 crc32c]
* [u32 payloadLen]
@@ -182,7 +182,8 @@ public static MmapSegment create(FilesFacade ff, String path, long baseSeq, long
return create(ff, path, baseSeq, sizeBytes, false);
}
- static MmapSegment create(FilesFacade ff, String path, long baseSeq, long sizeBytes, boolean manifestRequired) {
+ static MmapSegment create(FilesFacade ff, String path, long baseSeq, long sizeBytes,
+ boolean manifestRequired) {
long pathPtr = ff.allocNativePath(path);
try {
return create(ff, pathPtr, path, baseSeq, sizeBytes, manifestRequired);
@@ -199,11 +200,13 @@ static MmapSegment create(FilesFacade ff, String path, long baseSeq, long sizeBy
* {@code DirectUtf8Sink} by the rotation hot path so it does not incur a
* per-call {@code byte[]} + native-malloc the way the String overload does.
*/
- public static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long baseSeq, long sizeBytes) {
+ public static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long baseSeq,
+ long sizeBytes) {
return create(ff, pathPtr, displayPath, baseSeq, sizeBytes, false);
}
- static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long baseSeq, long sizeBytes, boolean manifestRequired) {
+ static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long baseSeq, long sizeBytes,
+ boolean manifestRequired) {
if (sizeBytes < HEADER_SIZE + FRAME_HEADER_SIZE + 1) {
throw new IllegalArgumentException(
"sizeBytes too small for header + one minimal frame: " + sizeBytes);
@@ -239,7 +242,8 @@ static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long
Unsafe.getUnsafe().putShort(addr + 6, (short) 0); // reserved
Unsafe.getUnsafe().putLong(addr + 8, baseSeq);
Unsafe.getUnsafe().putLong(addr + 16, Os.currentTimeMicros());
- return new MmapSegment(ff, displayPath, fd, addr, sizeBytes, baseSeq, HEADER_SIZE, 0, false, 0L);
+ return new MmapSegment(ff, displayPath, fd, addr, sizeBytes, baseSeq,
+ HEADER_SIZE, 0, false, 0L);
} catch (Throwable t) {
if (addr != Files.FAILED_MMAP_ADDRESS) {
ff.munmap(addr, sizeBytes, MemoryTag.MMAP_DEFAULT);
@@ -276,13 +280,59 @@ public static MmapSegment createInMemory(long baseSeq, long sizeBytes) {
Unsafe.getUnsafe().putShort(addr + 6, (short) 0);
Unsafe.getUnsafe().putLong(addr + 8, baseSeq);
Unsafe.getUnsafe().putLong(addr + 16, Os.currentTimeMicros());
- return new MmapSegment(null, null, -1, addr, sizeBytes, baseSeq, HEADER_SIZE, 0, true, 0L);
+ return new MmapSegment(null, null, -1, addr, sizeBytes, baseSeq,
+ HEADER_SIZE, 0, true, 0L);
} catch (Throwable t) {
Unsafe.free(addr, sizeBytes, MemoryTag.NATIVE_DEFAULT);
throw t;
}
}
+ /**
+ * True when {@code t} is the JVM's recoverable signal for a fault while
+ * accessing a memory-mapped region -- a SIGBUS/SIGSEGV that HotSpot
+ * translates into an {@code InternalError} at an {@code Unsafe} intrinsic
+ * site instead of aborting the process. It surfaces when a mapped page is
+ * not backed by real file blocks: a sparse {@code .sfa} tail on a
+ * filesystem whose pre-allocation leaves holes (e.g. ZFS, where a
+ * truncate-based {@code allocate} does not materialize blocks), or a file
+ * truncated under the mapping. Recovery treats this as an I/O boundary --
+ * the same way MappedByteBuffer readers do -- not a fatal VM error.
+ *
+ * offset 0: u32 magic = 'SYD1'
+ * offset 4: u8 version = 1
+ * offset 5: 3 bytes reserved (zero)
+ * offset 8: chunks, each
+ * [entryCount: varint][entryBytes: varint][entries][crc32c: u32]
+ * where entries = [len: varint][utf8] repeated entryCount times,
+ * occupying exactly entryBytes bytes, and the CRC-32C covers the
+ * two header varints AND the entry region.
+ *
+ * A recovered dictionary is trusted on its magic, version and per-chunk CRCs.
+ * It cannot outlive the id space it describes: a fresh start truncates it via
+ * {@link #openClean} and refuses the slot outright if that truncation fails,
+ * so a survivor from a prior producer generation is never reachable here.
+ * A chunk is one append -- i.e. exactly the set of symbols one frame
+ * introduces, since the producer persists a frame's new symbols in a single call
+ * before publishing it. Symbol id {@code i} is the {@code i}-th entry across all
+ * chunks (ids are dense and assigned sequentially from 0), so no id is stored.
+ *
+ *
+ * Together these turn every detectable host-crash tear into a fail-clean
+ * "resend required" instead of a silent symbol misattribution -- the same
+ * CRC-32C protection the segment frames carry. A tear that happened to leave a
+ * byte run whose CRC still matches is not distinguished, but that is a 1-in-2^32
+ * collision per corrupted chunk, no weaker than the frames' own checksum.
+ *
+ *
+ */
+ public static PersistedSymbolDict open(String slotDir) {
+ return open(FilesFacade.INSTANCE, slotDir);
+ }
+
+ /**
+ * Facade-aware variant of {@link #open(String)}. Production passes
+ * {@link FilesFacade#INSTANCE}; tests inject a fault facade to drive recovery
+ * I/O failures (e.g. a truncate that cannot drop a torn tail).
+ */
+ public static PersistedSymbolDict open(FilesFacade ff, String slotDir) {
+ String filePath = slotDir + "/" + FILE_NAME;
+ int[] statErrno = {0};
+ long existing = statLength(ff, filePath, statErrno);
+ if (existing < 0) {
+ // The stat failed. Only a proven not-found errno may take the
+ // absent arm below -- mirroring the Rust client's open_recovered,
+ // which maps ErrorKind::NotFound to "absent" and errors on every
+ // other stat failure. Any other errno (a transient EIO on a flaky
+ // disk, a permission fault) leaves existence UNKNOWN, and treating
+ // unknown as absent would silently degrade this session to
+ // full-dict frames next to a possibly populated side-file -- the
+ // entry point of the cross-generation misattribution chain: a
+ // later recovery would trust the survivor against frames it never
+ // described. (An errno-blind ff.exists() -- access(2) / a boolean
+ // PathFileExists -- used to route here and had exactly that hole:
+ // every stat error read as "absent".) Fail LOUDLY and retriably
+ // instead: the file, if any, is left intact; Sender.build() aborts
+ // without quarantining and BackgroundDrainer leaves the slot for a
+ // later scan, so a retry once the transient clears recovers the
+ // slot in full.
+ int errno = statErrno[0];
+ if (!Files.isNotFoundError(errno)) {
+ throw new SfOperationalException("symbol dict " + filePath
+ + " length could not be read (errno=" + errno
+ + "); file left intact for a retry");
+ }
+ // Proven absent: fall through to the absent arm below.
+ } else if (existing >= HEADER_SIZE) {
+ // Chunk lengths and the retained contiguous entry region are int-sized,
+ // so a dictionary at or past Integer.MAX_VALUE cannot be represented
+ // safely even though production recovery maps rather than reads it.
+ // A permanent property of the file, not a transient: degrade to
+ // full-dictionary frames. The disposition is sticky -- full-dict mode
+ // never appends the file -- so every later recovery decides the same.
+ if (existing >= Integer.MAX_VALUE) {
+ LOG.warn("symbol dict {} too large ({} bytes) to reopen; "
+ + "falling back to full-dictionary frames (file left intact)", filePath, existing);
+ return null;
+ }
+ // The holder catches what openExisting's own catch structurally cannot.
+ // Recovery reads the file through a mapping (Unsafe loads in
+ // scanAndCopyRecoveredChunks), and pre-JDK-21 HotSpot delivers an
+ // unsafe-access fault ASYNCHRONOUSLY -- at the next return or safepoint,
+ // which can be openExisting's own return, in THIS frame. The instance is
+ // fully built by then and owns an fd plus a buffer as large as the file,
+ // but the assignment never happens, so nothing else can release them.
+ // MmapSegment.openExisting takes an inFlight[] for exactly this shape; the
+ // dictionary is at least as exposed, because ensureAppendMap grows the file
+ // with ff.allocate and the reserve truncate is skipped after a crash, so a
+ // sparse tail is routine. Without this, the fault also escapes open()
+ // entirely -- past CursorSendEngine's constructor and out of Sender.build()
+ // -- turning the documented "degrade to full-dictionary frames" into a
+ // sender that cannot be constructed at all.
+ PersistedSymbolDict[] inFlight = new PersistedSymbolDict[1];
+ try {
+ return openExisting(ff, filePath, existing, inFlight);
+ } catch (Throwable t) {
+ if (inFlight[0] != null) {
+ inFlight[0].close();
+ }
+ if (t instanceof SfOperationalException) {
+ throw (SfOperationalException) t;
+ }
+ // A late-delivered mmap access fault is an I/O failure in
+ // disguise: the file is intact, so it is retriable like every
+ // other transient above.
+ throw new SfOperationalException("symbol dict " + filePath
+ + " recovery faulted late; file left intact for a retry", t);
+ }
+ }
+ // Absent, or a sub-header stub left by a crash inside openFresh: there is
+ // no valid dictionary here, and the RECOVERY path never fabricates one.
+ // A fresh empty side-file planted next to recovered segments would
+ // manufacture disagreeing state; leaving the slot dictionary-less is
+ // naturally sticky instead (full-dict mode never creates or appends the
+ // file), so every later recovery makes the same call. The caller runs
+ // this session on full self-sufficient frames.
+ return null;
+ }
+
+ /**
+ * Opens the dictionary in {@code slotDir} as a FRESH, EMPTY file, discarding
+ * any surviving content. This is the fresh-start counterpart to {@link #open}:
+ * a slot with no recovered segments must start with an empty dictionary, so a
+ * dictionary left by a prior lifecycle -- a fully-drained slot whose
+ * best-effort delete failed, or a crash in the close window -- must NOT be
+ * inherited. Unlike {@link #open}, which parses and TRUSTS an existing file for
+ * recovery/orphan-drain replay and never destroys it, this truncates: the
+ * fresh-start producer is not seeded from the dictionary, so trusting a survivor
+ * would leave the producer's ids diverged from the dictionary the send loop
+ * replays and silently misattribute symbols on the next reconnect. Truncating
+ * (rather than relying on an unlink succeeding first) closes the gap even when
+ * the delete is refused -- e.g. a Windows share lock. Returns {@code null} when
+ * the slot has no dictionary at all and a fresh one cannot be created either,
+ * so the caller falls back to full self-sufficient frames exactly as
+ * {@link #open} does. But when a file DOES survive and cannot be truncated,
+ * returning null instead of refusing would proceed anyway: this session would
+ * run full-dict from id 0 while the survivor -- describing a prior
+ * generation's id space -- stays on disk, and the next recovery would trust
+ * it and misattribute symbols with no detectable gap. So that case throws
+ * {@link UnreplayableSlotException} instead of degrading -- see {@link #openFresh}.
+ */
+ public static PersistedSymbolDict openClean(String slotDir) {
+ return openClean(FilesFacade.INSTANCE, slotDir);
+ }
+
+ /**
+ * Facade-aware variant of {@link #openClean(String)}.
+ */
+ public static PersistedSymbolDict openClean(FilesFacade ff, String slotDir) {
+ return openFresh(ff, slotDir + "/" + FILE_NAME);
+ }
+
+ /**
+ * Removal of a stale dictionary file, mirroring {@link AckWatermark#removeOrphan}.
+ * Used at fully-drained close (the slot is empty, nothing references the dictionary
+ * any more), and by recovery whenever it decides to run a slot in FULL-DICT mode
+ * next to a side-file it is not going to use -- see the callers in
+ * {@code CursorSendEngine}'s constructor. Leaving the file behind in that second
+ * case is what lets a LATER recovery, one whose frames reference fewer ids than the
+ * survivor holds, seed the producer from a previous generation's strings.
+ *
+ *
+ * In the first two cases the frames reference ids that nothing still holds, so replaying
+ * them would make the server reject the frame with STATUS_DICTIONARY_GAP.
+ *
*
> dictsByConn = new CopyOnWriteArrayList<>();
+ private TestWebSocketServer.ClientHandler currentClient;
+ private boolean hasUnresolvedSequence;
+ private final AtomicLong nextSeq = new AtomicLong(0);
+
+ List
> dictsByConn = new CopyOnWriteArrayList<>();
+ private final int dropConn1AtDictSize;
+ private final AtomicLong nextSeq = new AtomicLong(0);
+ private boolean conn1Dropped;
+ private TestWebSocketServer.ClientHandler currentClient;
+ private boolean hasUnresolvedSequence;
+
+ SplitCatchUpHandler(int dropConn1AtDictSize) {
+ this.dropConn1AtDictSize = dropConn1AtDictSize;
+ }
+
+ List