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..d1b0f13e 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
@@ -50,6 +50,37 @@ 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.
+ *
+ * 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.
+ *
+ * @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.
*
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..cb6ebb92 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,21 @@ 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.
+ */
+ public static long writeVarint(long addr, long 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) {
@@ -336,11 +351,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..8013330a 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
@@ -39,6 +39,14 @@ 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 deltaEntriesEnd;
+ private int deltaEntriesStart;
// 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.
@@ -75,10 +83,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);
}
@@ -122,6 +132,22 @@ 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 void setDeferCommit(boolean defer) {
if (defer) {
flags |= FLAG_DEFER_COMMIT;
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 d1744065..2fd785a6 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
@@ -46,6 +46,7 @@
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.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;
@@ -221,6 +222,17 @@ public class QwpWebSocketSender implements Sender {
private CursorSendEngine cursorEngine;
private CursorWebSocketSendLoop cursorSendLoop;
private boolean deferCommit;
+ // True when the sender emits incremental (delta) symbol dictionaries: each
+ // message carries only symbol ids not yet sent on the wire, rather than the
+ // full dictionary from id 0. Enabled in memory-mode (a reconnect replays from
+ // the in-process ring) and in file-mode store-and-forward when the per-slot
+ // persisted dictionary opened. In both, the I/O thread re-registers the whole
+ // dictionary via a catch-up frame before replaying, so a non-self-sufficient
+ // delta frame never dangles an id on a fresh server. Falls back to full
+ // self-sufficient frames only when the persisted dictionary is unavailable in
+ // file-mode (recovery/orphan-drain would then have nothing to rebuild the
+ // deltas from). Set in setCursorEngine.
+ private boolean deltaDictEnabled;
// User-supplied observer for background orphan-slot drainer events.
// Volatile: written by setDrainerListener (any thread, before or after
// startOrphanDrainers) and read at pool-creation time. Null -> drainers
@@ -313,6 +325,12 @@ public class QwpWebSocketSender implements Sender {
// beginRound(true) call. roundSeq=1 is the first round; CONNECTED in the
// first round indicates the initial connect.
private long roundSeq;
+ // Highest global symbol id the producer has baked into a frame so far, or -1.
+ // Lifetime-monotonic in delta mode -- it is NOT reset on reconnect, because
+ // the I/O thread re-registers the full dictionary via a catch-up frame before
+ // replaying, so the producer's delta baseline stays valid across the wire
+ // boundary. Used only when deltaDictEnabled; ignored in full-dict mode.
+ private int sentMaxSymbolId = -1;
// When true, auto-flush sends messages with FLAG_DEFER_COMMIT and only
// explicit flush() triggers the server-side commit. Enables accumulating
// arbitrarily large datasets that exceed the server's recv buffer.
@@ -2215,6 +2233,18 @@ public void setCursorEngine(CursorSendEngine engine, boolean takeOwnership) {
}
this.cursorEngine = engine;
this.ownsCursorEngine = takeOwnership && engine != null;
+ // Delta encoding is available in memory-mode (in-process catch-up) and in
+ // file-mode when the persisted dictionary opened (recovery / orphan-drain
+ // rebuild the dictionary from it). Otherwise fall back to full self-
+ // sufficient frames. See CursorSendEngine.isDeltaDictEnabled.
+ this.deltaDictEnabled = engine != null && engine.isDeltaDictEnabled();
+ // Recovery: repopulate the producer's global dictionary from the slot's
+ // persisted dictionary so newly ingested symbols continue from the
+ // recovered ids (rather than colliding with them at 0), and the delta
+ // baseline resumes where the crashed session left off.
+ if (deltaDictEnabled && engine.wasRecoveredFromDisk()) {
+ seedGlobalDictionaryFromPersisted(engine.getPersistedSymbolDict());
+ }
}
/**
@@ -3332,6 +3362,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;
@@ -3359,18 +3400,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);
@@ -3423,14 +3464,16 @@ private void flushPendingRows(boolean deferCommit) {
}
ensureActiveBufferReady();
- // Cursor SF requires every on-disk frame to be self-sufficient:
- // recorded frames replay to fresh server connections (orphan-slot
- // drainers and post-reconnect replay), so always emit the full
- // symbol-dict delta from id=0 and the full column schema inline,
- // never a back-reference the target server may not have seen.
+ // In full-dict mode every frame is self-sufficient: it carries the whole
+ // symbol dictionary from id 0 so orphan-drain / recovery replay to a fresh
+ // server never dangles a symbol id. In delta mode (memory-mode, and
+ // file-mode store-and-forward once the persisted dictionary opened) each
+ // frame carries only ids above sentMaxSymbolId; a reconnect re-registers
+ // the dictionary via an I/O-thread catch-up frame before replay, so the
+ // producer's monotonic baseline stays valid across the wire boundary.
encoder.setDeferCommit(deferCommit);
encoder.beginMessage(tableCount, globalSymbolDictionary,
- /*confirmedMaxId=*/ -1, currentBatchMaxSymbolId);
+ symbolDeltaBaseline(), currentBatchMaxSymbolId);
for (int i = 0, n = keys.size(); i < n; i++) {
CharSequence tableName = keys.getQuick(i);
if (tableName == null) {
@@ -3456,10 +3499,18 @@ private void flushPendingRows(boolean deferCommit) {
return;
}
+ // Write-ahead: durably persist this frame's new symbols BEFORE it is
+ // published, so a recovered/orphan-drained slot can always rebuild the
+ // dictionary the (non-self-sufficient) delta frame references. No-op in
+ // memory mode and when the frame introduces no new symbols.
+ persistNewSymbolsBeforePublish();
activeBuffer.ensureCapacity(messageSize);
activeBuffer.write(buffer.getBufferPtr(), messageSize);
activeBuffer.incrementRowCount();
sealAndSwapBuffer();
+ // The frame carrying ids up to currentBatchMaxSymbolId is now on the ring;
+ // advance the delta baseline so the next frame ships only newer ids.
+ advanceSentMaxSymbolId();
hasDeferredMessages = deferCommit;
if (!deferCommit) {
@@ -3475,6 +3526,22 @@ private void flushPendingRows(boolean deferCommit) {
* own message. All messages except the last carry FLAG_DEFER_COMMIT
* so the server appends rows without committing until the final
* message arrives.
+ *
+ * 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
@@ -3485,16 +3552,48 @@ private void flushPendingRowsSplit(ObjList keys, boolean deferComm
LOG.debug("Splitting flush across multiple messages [serverMaxBatchSize={}, defer={}]", serverMaxBatchSize, deferCommit);
}
- // Collect non-empty table indices so we know which is last.
+ // Collect non-empty table indices so we know which is last, AND pre-flight
+ // every split frame's size BEFORE publishing any of them. The split hands
+ // frames to the ring one at a time (all but the last deferred -- appended but
+ // uncommitted); if a later table's frame were only found oversized
+ // mid-publish, the already-published prefix would strand on the ring, a
+ // subsequent commit would deliver it as a partial batch, and
+ // resetTableBuffersAfterFlush would discard every source row -- a partial
+ // commit the caller was told (by the throw) had failed. Checking all sizes up
+ // front makes the split all-or-nothing: either every frame fits and all
+ // publish, or none publish and we throw with nothing stranded. The cost is a
+ // second encode pass over the split batch, which is already the exceptional
+ // large-batch path. encode is read-only on the table buffer, and simBaseline
+ // mirrors the publish loop's baseline advance (advanceSentMaxSymbolId), so
+ // each measured size equals the frame the publish loop will build; this pass
+ // mutates no delta/persist state (the defer-commit flag is a header bit that
+ // does not change frame size).
int nonEmptyCount = 0;
+ int simBaseline = symbolDeltaBaseline();
for (int i = 0, n = keys.size(); i < n; i++) {
CharSequence tableName = keys.getQuick(i);
if (tableName == null) {
continue;
}
QwpTableBuffer tableBuffer = tableBuffers.get(tableName);
- if (tableBuffer != null && tableBuffer.getRowCount() > 0) {
- nonEmptyCount++;
+ if (tableBuffer == null || tableBuffer.getRowCount() == 0) {
+ continue;
+ }
+ nonEmptyCount++;
+ encoder.beginMessage(1, globalSymbolDictionary, simBaseline, currentBatchMaxSymbolId);
+ encoder.addTable(tableBuffer);
+ int messageSize = encoder.finishMessage();
+ if (messageSize > serverMaxBatchSize) {
+ resetTableBuffersAfterFlush(keys);
+ throw new LineSenderException("single table batch too large for server batch cap")
+ .put(" [table=").put(tableName)
+ .put(", messageSize=").put(messageSize)
+ .put(", serverMaxBatchSize=").put(serverMaxBatchSize).put(']');
+ }
+ // Mirror advanceSentMaxSymbolId: once the first frame ships the batch's
+ // new ids, the remaining frames carry an empty delta above the baseline.
+ if (deltaDictEnabled && currentBatchMaxSymbolId > simBaseline) {
+ simBaseline = currentBatchMaxSymbolId;
}
}
@@ -3514,25 +3613,37 @@ private void flushPendingRowsSplit(ObjList keys, boolean deferComm
boolean deferThis = deferCommit || !isLast;
encoder.setDeferCommit(deferThis);
+ // Each split frame emits the delta above sentMaxSymbolId; the first
+ // frame ships the whole batch's new ids and advances the baseline, so
+ // the remaining frames carry an empty delta and just reference ids the
+ // first frame already registered.
encoder.beginMessage(1, globalSymbolDictionary,
- /*confirmedMaxId=*/ -1, currentBatchMaxSymbolId);
+ symbolDeltaBaseline(), currentBatchMaxSymbolId);
encoder.addTable(tableBuffer);
int messageSize = encoder.finishMessage();
QwpBufferWriter buffer = encoder.getBuffer();
-
- if (messageSize > serverMaxBatchSize) {
- resetTableBuffersAfterFlush(keys);
- throw new LineSenderException("single table batch too large for server batch cap")
- .put(" [table=").put(tableName)
- .put(", messageSize=").put(messageSize)
- .put(", serverMaxBatchSize=").put(serverMaxBatchSize).put(']');
- }
-
+ // The pre-flight pass above already verified every split frame fits the
+ // cap, so none can be found oversized here -- which is what keeps this
+ // loop from publishing (and stranding) a deferred prefix before an
+ // oversized table. The assert guards a future divergence between the two
+ // passes; it deliberately does NOT reset+throw here, because by this
+ // point a prefix may already be on the ring.
+ assert messageSize <= serverMaxBatchSize
+ : "split frame exceeded serverMaxBatchSize after pre-flight [table=" + tableName
+ + ", messageSize=" + messageSize + ", serverMaxBatchSize=" + serverMaxBatchSize + ']';
+
+ // Write-ahead persist before publish (see flushPendingRows). The
+ // first split frame carries the batch's new symbols; the rest are
+ // no-ops once the baseline has advanced past them.
+ persistNewSymbolsBeforePublish();
ensureActiveBufferReady();
activeBuffer.ensureCapacity(messageSize);
activeBuffer.write(buffer.getBufferPtr(), messageSize);
activeBuffer.incrementRowCount();
sealAndSwapBuffer();
+ // Frame queued: advance so the next split frame's delta starts above
+ // the ids this one just registered.
+ advanceSentMaxSymbolId();
}
encoder.setDeferCommit(false);
@@ -3573,8 +3684,23 @@ private void sendCommitMessage() {
LOG.debug("Sending commit message for deferred batch");
}
encoder.setDeferCommit(false);
+ // A commit carries no rows, and it must also carry NO new symbols. Unlike
+ // the flush paths, sendCommitMessage does NOT write-ahead-persist the
+ // dictionary, so shipping a symbol here would put an id on the wire that a
+ // recovered slot cannot rebuild from the persisted .symbol-dict, diverging
+ // the producer dictionary from the surviving frames and silently
+ // misattributing reused ids after a crash. currentBatchMaxSymbolId can sit
+ // ABOVE sentMaxSymbolId (e.g. a cancelled row: cancelRow does not roll back
+ // currentBatchMaxSymbolId or unregister the symbol), so bound the delta at
+ // what has already been sent -- and therefore already persisted. In delta
+ // mode pass sentMaxSymbolId, yielding an empty delta
+ // [sentMaxSymbolId+1 .. sentMaxSymbolId]; in full-dict mode keep
+ // currentBatchMaxSymbolId so the frame stays self-sufficient. Any symbol a
+ // cancelled row leaked is picked up (and persisted) by the next real flush,
+ // whose persistNewSymbolsBeforePublish resumes from pd.size().
+ int commitBatchMaxId = deltaDictEnabled ? sentMaxSymbolId : currentBatchMaxSymbolId;
encoder.beginMessage(0, globalSymbolDictionary,
- /*confirmedMaxId=*/ -1, currentBatchMaxSymbolId);
+ symbolDeltaBaseline(), commitBatchMaxId);
int messageSize = encoder.finishMessage();
QwpBufferWriter buffer = encoder.getBuffer();
ensureActiveBufferReady();
@@ -3586,15 +3712,137 @@ private void sendCommitMessage() {
lastCommitBoundaryFsn = cursorEngine.publishedFsn();
}
+ /**
+ * Advances the delta baseline once a frame carrying the current batch's
+ * symbols has been queued onto the ring. No-op in full-dict mode. Only ever
+ * moves the baseline forward, so a batch that used no new symbols leaves it
+ * unchanged.
+ */
+ private void advanceSentMaxSymbolId() {
+ if (deltaDictEnabled && currentBatchMaxSymbolId > sentMaxSymbolId) {
+ sentMaxSymbolId = currentBatchMaxSymbolId;
+ }
+ }
+
+ /**
+ * 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 short write (disk full / quota) to the persisted dictionary throws
+ // a low-level IllegalStateException. 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 short
+ // write, so the still-buffered rows re-persist the same range
+ // idempotently on retry. A JVM Error is never a persist failure; let it
+ // propagate.
+ if (t instanceof Error) {
+ throw (Error) t;
+ }
+ throw new LineSenderException("failed to persist symbol dictionary before publish", t);
+ }
+ }
+
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} from
+ * the slot's persisted dictionary (ids assigned in the same ascending order,
+ * so they match the recovered frames) and resumes the delta baseline at the
+ * recovered tip, so newly ingested symbols continue above the recovered ids.
+ *
+ * Uses {@link GlobalSymbolDictionary#addRecoveredSymbol} (append, NOT de-dup):
+ * the persisted dictionary, the on-wire delta and the send-loop catch-up mirror
+ * all key on the entry POSITION (id), so the producer id space must match the
+ * persisted 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 {@code pd.size()} and desyncing
+ * {@code sentMaxSymbolId} from the mirror's {@code sentDictCount = pd.size()},
+ * which silently misattributes later symbols after a reconnect.
+ */
+ private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
+ if (pd == null || pd.size() == 0) {
+ return;
+ }
+ ObjList symbols = pd.readLoadedSymbols();
+ for (int i = 0, n = symbols.size(); i < n; i++) {
+ globalSymbolDictionary.addRecoveredSymbol(symbols.getQuick(i));
+ }
+ sentMaxSymbolId = globalSymbolDictionary.size() - 1;
+ }
+
+ /**
+ * 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();
@@ -3656,7 +3904,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()) {
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 64ca75d0..6c34124d 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
@@ -84,9 +84,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
@@ -110,6 +110,13 @@ public final class CursorSendEngine implements QuietCloseable {
// in the 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;
// 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.
@@ -199,6 +206,7 @@ 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;
try {
// Disk mode: try to recover any *.sfa files left behind by a prior
// session before deciding to start fresh. Without this the engine
@@ -257,6 +265,10 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
// mmap doesn't take down the engine -- we just fall
// back to the bare lowestBase - 1 seed.
watermarkInProgress = AckWatermark.open(sfDir);
+ // Load the persisted symbol dictionary so delta-encoded frames
+ // in this recovered slot can be re-registered on the fresh
+ // server before replay. Null on open failure -> delta disabled.
+ persistedDictInProgress = PersistedSymbolDict.open(sfDir);
long baseSeed = lowestBase - 1;
long watermarkFsn = watermarkInProgress != null
? watermarkInProgress.read()
@@ -309,6 +321,19 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
if (!memoryMode) {
AckWatermark.removeOrphan(sfDir);
watermarkInProgress = AckWatermark.open(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 clean open itself fails,
+ // persistedSymbolDict stays null and the sender falls back to full
+ // self-sufficient frames, which is also safe.
+ persistedDictInProgress = PersistedSymbolDict.openClean(sfDir);
}
MmapSegment initial;
String initialPath = null;
@@ -333,10 +358,11 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
manager.start();
}
manager.register(ringInProgress, sfDir, watermarkInProgress);
- // All construction succeeded — commit the ring and
- // watermark references.
+ // All construction succeeded — commit the ring, watermark and
+ // symbol-dictionary references.
this.ring = ringInProgress;
this.watermark = watermarkInProgress;
+ this.persistedSymbolDict = persistedDictInProgress;
} 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
@@ -362,6 +388,12 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
} catch (Throwable ignored) {
}
}
+ if (persistedDictInProgress != null) {
+ try {
+ persistedDictInProgress.close();
+ } catch (Throwable ignored) {
+ }
+ }
if (acquiredLock != null) {
try {
acquiredLock.close();
@@ -541,6 +573,12 @@ public synchronized void close() {
} catch (Throwable ignored) {
}
}
+ if (persistedSymbolDict != null) {
+ try {
+ persistedSymbolDict.close();
+ } catch (Throwable ignored) {
+ }
+ }
if (fullyDrained) {
try {
unlinkAllSegmentFiles(sfDir);
@@ -550,6 +588,11 @@ public synchronized void close() {
AckWatermark.removeOrphan(sfDir);
} catch (Throwable ignored) {
}
+ try {
+ // Slot fully drained: the dictionary has no frames behind it.
+ PersistedSymbolDict.removeOrphan(sfDir);
+ } catch (Throwable ignored) {
+ }
}
} finally {
if (slotLock != null) {
@@ -576,6 +619,15 @@ public MmapSegment firstSealed() {
return ring.firstSealed();
}
+ /**
+ * The engine's persisted symbol dictionary, or {@code null} in memory mode
+ * (and in disk mode if it failed to open). 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
@@ -586,6 +638,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)}.
*/
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 13a69f77..a24776d3 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;
@@ -130,6 +133,12 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
*/
public static final long DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS = 5_000L;
private static final Logger LOG = LoggerFactory.getLogger(CursorWebSocketSendLoop.class);
+ // 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.
*/
@@ -177,6 +186,44 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
private final long reconnectMaxDurationMillis;
private final WebSocketResponse response = new WebSocketResponse();
private final ResponseHandler responseHandler = new ResponseHandler();
+ // Delta symbol dictionary catch-up state (see swapClient). Active in memory
+ // mode, and in disk mode whenever the per-slot persisted dictionary opened --
+ // fresh slots included, not just recovered / orphan-drained ones. On a
+ // recovered / orphan-drained slot the constructor additionally SEEDS sentDict*
+ // from that persisted dictionary; a fresh slot starts with an empty mirror and
+ // grows it as frames are sent.
+ // deltaDictEnabled gates all of it. 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 final boolean deltaDictEnabled;
+ // 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 long sentDictBytesAddr;
+ private int sentDictBytesCapacity;
+ private int sentDictBytesLen;
+ private int sentDictCount;
+ // End position (native address) written by the last readVarintAt() call.
+ private long varintEnd;
private final CountDownLatch shutdownLatch = new CountDownLatch(1);
private final AtomicLong totalAcks = new AtomicLong();
// Counters for observability of the durable-ack path. Both are zero
@@ -489,6 +536,41 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
}
this.client = client;
this.engine = engine;
+ this.deltaDictEnabled = engine.isDeltaDictEnabled();
+ // 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.
+ if (deltaDictEnabled) {
+ PersistedSymbolDict pd = engine.getPersistedSymbolDict();
+ if (pd != null && pd.size() > 0) {
+ int len = pd.loadedEntriesLen();
+ if (len > 0) {
+ // COPY the persisted dictionary's loaded-entries buffer into this
+ // loop's own mirror rather than taking ownership of it. The engine
+ // (and its PersistedSymbolDict) OUTLIVES this loop on the orphan
+ // drainer path: BackgroundDrainer builds a fresh send loop per wire
+ // session against the same engine on a durable-ack capability-gap
+ // recycle. A one-shot ownership transfer would leave every loop
+ // after the first with an EMPTY mirror -- it would then send no
+ // reconnect catch-up, and the first replayed delta frame
+ // (deltaStart > 0) would trip the torn-dict guard, falsely
+ // quarantining a healthy slot. Copying keeps the dictionary's
+ // loaded entries intact for the engine's lifetime so every
+ // recycled loop re-seeds; pd.close() (at engine close) frees the
+ // dictionary's copy, this loop frees its own copy on exit.
+ sentDictBytesAddr = Unsafe.malloc(len, MemoryTag.NATIVE_DEFAULT);
+ Unsafe.getUnsafe().copyMemory(pd.loadedEntriesAddr(), sentDictBytesAddr, len);
+ sentDictBytesCapacity = len;
+ sentDictBytesLen = len;
+ // Set the count only alongside the bytes so sentDictCount can
+ // never claim symbols the mirror does not hold. A recovered slot
+ // always has loadedEntriesLen > 0 when size > 0, so this is the
+ // same result -- it just makes the coupling explicit.
+ sentDictCount = pd.size();
+ }
+ }
+ }
this.fsnAtZero = fsnAtZero;
this.parkNanos = parkNanos;
this.reconnectFactory = reconnectFactory;
@@ -754,6 +836,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.
@@ -812,6 +900,22 @@ 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) {
+ Unsafe.free(sentDictBytesAddr, sentDictBytesCapacity, MemoryTag.NATIVE_DEFAULT);
+ sentDictBytesAddr = 0;
+ sentDictBytesCapacity = 0;
+ sentDictBytesLen = 0;
+ // Reset the count alongside the buffer so the mirror stays all-or-
+ // nothing: a hypothetical close()-then-start() (start() has no closed
+ // guard) must not observe a non-zero sentDictCount against a freed
+ // buffer and drive setWireBaselineWithCatchUp into a null-mirror catch-up.
+ sentDictCount = 0;
+ }
}
/**
@@ -996,7 +1100,28 @@ public synchronized void start() {
// walks back to the lowest unacked frame so sealed-segment data
// actually reaches the wire — without it, start() would skip
// straight to the active and orphan everything in sealed.
- positionCursorForStart();
+ 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
+ }
+ }
+ }
Thread t = new Thread(this::ioLoop, "qdb-cursor-ws-io");
t.setDaemon(true);
try {
@@ -1669,6 +1794,15 @@ 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) {
+ Unsafe.free(sentDictBytesAddr, sentDictBytesCapacity, MemoryTag.NATIVE_DEFAULT);
+ sentDictBytesAddr = 0;
+ sentDictBytesCapacity = 0;
+ sentDictBytesLen = 0;
+ sentDictCount = 0; // keep the mirror all-or-nothing (see close())
+ }
shutdownLatch.countDown();
// Failed-stop hand-off (see delegateEngineClose): the owner could
// not free the engine safely while this thread was alive, so the
@@ -1691,8 +1825,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,
@@ -1849,8 +1986,6 @@ private void swapClient(WebSocketClient newClient) {
// past the tail instead of replaying into it.
tryRetireOrphanTail();
long replayStart = engine.ackedFsn() + 1L;
- this.fsnAtZero = replayStart;
- this.nextWireSeq = 0L;
// Snapshot publishedFsn at swap time — frames at FSN ≤ this value
// were already on the wire before the drop and will be replayed.
// trySendOne resets replayTargetFsn to -1 once we cross the boundary.
@@ -1862,9 +1997,359 @@ 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);
}
+ /**
+ * 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;
+ if (client != null && deltaDictEnabled && sentDictCount > 0) {
+ 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;
+ }
+ return (int) readVarintAt(payloadAddr + QwpConstants.HEADER_SIZE, payloadAddr + payloadLen);
+ }
+
+ // 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 deltaCount = readVarintAt(p, limit);
+ p = varintEnd;
+ // 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;
+ }
+ // 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 len = readVarintAt(p, limit);
+ p = varintEnd + len;
+ if (p > limit) {
+ return; // malformed -- bail rather than corrupt the mirror
+ }
+ }
+ long regionStart = p;
+ long newCount = deltaEnd - sentDictCount;
+ for (long i = 0; i < newCount; i++) {
+ long len = readVarintAt(p, limit);
+ p = varintEnd + len;
+ if (p > limit) {
+ // Malformed -- never happens for frames we encoded; bail rather
+ // than corrupt the mirror.
+ 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;
+ }
+ sentDictBytesAddr = Unsafe.realloc(sentDictBytesAddr, sentDictBytesCapacity, (int) newCap, MemoryTag.NATIVE_DEFAULT);
+ sentDictBytesCapacity = (int) newCap;
+ }
+
+ private long readVarintAt(long p, long limit) {
+ long value = 0;
+ int shift = 0;
+ long cur = p;
+ while (cur < limit) {
+ byte b = Unsafe.getUnsafe().getByte(cur++);
+ value |= (long) (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) {
+ break;
+ }
+ shift += 7;
+ if (shift > 35) {
+ // Defensive bound, matching PersistedSymbolDict.decodeVarint: a
+ // canonical entry-length / delta varint is <= 5 bytes. Every caller
+ // reads freshly-encoded, CRC- or openExisting-validated bytes, so
+ // this is unreachable, but it stops a corrupt continuation run from
+ // over-shifting into a garbage length.
+ break;
+ }
+ }
+ varintEnd = cur;
+ return value;
+ }
+
+ /**
+ * 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();
+ // The frame ceiling a catch-up chunk must not exceed: the server's
+ // advertised cap, or -- when the server advertises none (cap <= 0) --
+ // MAX_SENT_DICT_BYTES so sendCatchUpChunk's int frameLen (HEADER_SIZE +
+ // varints + symbolsLen) cannot overflow on a pathological multi-GB
+ // dictionary (unreachable at real cardinality; defensive). Used by the
+ // single-entry terminal below, which measures the real solo frame.
+ int frameLimit = 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 cap. 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 frameLimit instead.
+ int budget = cap > 0
+ ? Math.max(1, cap - QwpConstants.HEADER_SIZE - 16)
+ : MAX_SENT_DICT_BYTES - QwpConstants.HEADER_SIZE - 16;
+ int framesSent = 0;
+ int chunkStartId = 0;
+ long chunkStartAddr = sentDictBytesAddr;
+ int chunkSymbols = 0;
+ long chunkBytes = 0;
+ long p = sentDictBytesAddr;
+ long limit = sentDictBytesAddr + sentDictBytesLen;
+ while (p < limit) {
+ long entryStart = p;
+ long len = readVarintAt(p, limit); // entry length prefix; varintEnd -> just past prefix
+ long entryEnd = varintEnd + len; // just past [len varint][utf8 bytes]
+ 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. Terminal 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;
+ if (soloFrameLen > frameLimit) {
+ // Non-retriable: the entry will not shrink and the same cluster
+ // re-advertises the same cap, so reconnecting would livelock.
+ // Latch a terminal (the data must be resent after the cap is
+ // raised) rather than calling fail() -- which, from inside the
+ // catch-up, would re-enter connectLoop (see CatchUpSendException).
+ //
+ // Tradeoff (heterogeneous / rolling-cap clusters): a symbol
+ // accepted under a larger/absent cap can hit this on failover to a
+ // smaller-cap node, and the hard terminal does NOT self-recover if
+ // a later node advertises a larger cap -- the producer must be
+ // resumed after the data is resent (or the cap raised). Bounding
+ // symbol size at ingest, or a settle budget across reconnects
+ // before latching, would relax this, but both are larger changes;
+ // the terminal keeps the homogeneous common case livelock-free.
+ LineSenderException err = new LineSenderException(
+ "symbol dictionary entry too large for the server batch cap during catch-up ["
+ + "frameLen=" + soloFrameLen + ", cap=" + cap + ']');
+ recordFatal(err);
+ throw new CatchUpSendException(err);
+ }
+ if (chunkSymbols > 0 && chunkBytes + entryBytes > budget) {
+ sendCatchUpChunk(chunkStartId, chunkSymbols, chunkStartAddr, (int) chunkBytes);
+ framesSent++;
+ chunkStartId += chunkSymbols;
+ chunkStartAddr = entryStart;
+ chunkSymbols = 0;
+ chunkBytes = 0;
+ }
+ chunkSymbols++;
+ chunkBytes += entryBytes;
+ p = entryEnd;
+ }
+ if (chunkSymbols > 0) {
+ sendCatchUpChunk(chunkStartId, chunkSymbols, chunkStartAddr, (int) chunkBytes);
+ framesSent++;
+ }
+ 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 frameLen = (int) frameLenL;
+ long frame = Unsafe.malloc(frameLen, MemoryTag.NATIVE_DEFAULT);
+ 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);
+ q = NativeBufferWriter.writeVarint(q, deltaCount);
+ Unsafe.getUnsafe().copyMemory(symbolsAddr, q, symbolsLen);
+ client.sendBinary(frame, frameLen);
+ } 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);
+ } finally {
+ Unsafe.free(frame, frameLen, MemoryTag.NATIVE_DEFAULT);
+ }
+ nextWireSeq++; // this catch-up chunk consumed a wire sequence
+ lastFrameOrPingNanos = System.nanoTime();
+ totalFramesSent.incrementAndGet();
+ }
+
private boolean tryReceiveAcks() {
boolean any = false;
try {
@@ -1898,7 +2383,15 @@ private boolean trySendOne() {
// 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();
+ 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.
+ fail(e.getCause());
+ return false;
+ }
return true;
}
// Frames were already sent on this connection: the linear
@@ -1949,12 +2442,50 @@ private boolean trySendOne() {
if (frameEnd > pub) {
return false; // payload not fully published yet
}
+ long frameAddr = base + sendOffset + MmapSegment.FRAME_HEADER_SIZE;
+ // Torn-dictionary guard. Decode the delta start unconditionally (-1 for a
+ // non-delta frame); the guard MUST run even when deltaDictEnabled is false.
+ // A disk slot recovered with its persisted dictionary unavailable
+ // (PersistedSymbolDict.open() returned null -- fd exhaustion, a read-only
+ // remount, ENOSPC) reports deltaDictEnabled=false, yet its recorded frames
+ // are still DELTA frames (deltaStart > 0). Replaying those against a fresh
+ // empty-dictionary server would null-pad the missing ids and SILENTLY
+ // corrupt the table -- precisely what this guard exists to prevent -- so it
+ // cannot be gated on the very flag that goes false in that failure mode. In
+ // normal operation a delta frame's start id never exceeds the dictionary
+ // coverage established so far (replayed frames overlap the catch-up dict;
+ // fresh frames extend it contiguously), so a gap here means the recovered
+ // dictionary is incomplete (a host/power crash that lost recently-written
+ // entries, SF being process-crash but not host-crash durable). Fail
+ // terminally; the unreplayable data must be resent. Full-dict / fallback
+ // frames carry deltaStart=0 with sentDictCount=0, so 0 > 0 never
+ // false-positives; only the sent-dictionary mirror below stays gated on
+ // deltaDictEnabled.
+ 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 (deltaDictEnabled && deltaStart >= 0) {
+ // Mirror the symbols this frame introduced so a later reconnect can
+ // rebuild the whole dictionary. Idempotent on replay: a frame whose
+ // delta we already hold advances nothing.
+ accumulateSentDict(frameAddr, payloadLen, deltaStart);
+ }
lastFrameOrPingNanos = System.nanoTime();
sendOffset = frameEnd;
long fsnSent = fsnAtZero + nextWireSeq;
@@ -1984,8 +2515,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);
}
@@ -2036,6 +2570,30 @@ public interface ReconnectFactory {
WebSocketClient reconnect() throws Exception;
}
+ /**
+ * 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.
+ */
+ private static final class CatchUpSendException extends RuntimeException {
+ CatchUpSendException(Throwable cause) {
+ super(cause);
+ }
+ }
+
/**
* One slot in the pendingDurable FIFO. Holds a wireSeq plus the per-table
* (name, seqTxn) pairs from its OK frame. Empty entries (tableCount = 0)
@@ -2173,7 +2731,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,
@@ -2280,17 +2838,21 @@ 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;
}
@@ -2300,6 +2862,25 @@ private void handleServerRejection(long wireSeq) {
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.
+ 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/PersistedSymbolDict.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java
new file mode 100644
index 00000000..1d545f10
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java
@@ -0,0 +1,678 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.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.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 /.symbol-dict} alongside the segment files, the slot lock and
+ * {@code .ack-watermark}.
+ *
+ * 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.
+ *
+ * Layout (little-endian):
+ *
+ * offset 0: u32 magic = 'SYD1'
+ * offset 4: u8 version = 2
+ * offset 5: 3 bytes reserved (zero)
+ * offset 8: entries, each [len: varint][utf8 bytes][crc32c: u32], in ascending global-id order
+ *
+ * Symbol id {@code i} is the {@code i}-th entry (ids are dense and assigned
+ * sequentially from 0), so no id needs to be stored. Each entry carries a
+ * CRC-32C over its {@code [len][utf8]} bytes (the same checksum the SF segment
+ * frames use), so a torn or stale entry is detected on recovery instead of
+ * being silently mis-parsed.
+ *
+ * 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:
+ *
+ * - The per-entry CRC-32C: {@link #open} verifies every entry and stops at
+ * the first one whose checksum fails, so an interior page lost out of
+ * order (reading back as zeroes) or a stale entry left past the end by a
+ * failed truncate is DETECTED and the trusted region ends before it --
+ * recovery never mis-parses a corrupt entry as a real symbol nor shifts
+ * the dense id->symbol map. The truncate that drops a torn/stale tail is
+ * now failure-checked (see {@link #open}): a file that cannot be trimmed
+ * is untrusted and recreated empty rather than left exposing stale bytes.
+ * - The send loop's replay guard: once recovery trusts only the intact
+ * prefix, a surviving frame whose delta start id exceeds that prefix
+ * fails loudly (the unreplayable data must be resent) rather than sending
+ * a gapped frame.
+ *
+ * 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 entry, no weaker than the frames' own checksum.
+ *
+ * A torn trailing entry from a crash mid-append is self-healing: {@link #open}
+ * stops parsing at the first incomplete entry and the next append overwrites it.
+ *
+ * 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 free the scratch buffer or close the fd mid-write and let the write land
+ * on a descriptor the OS has reused for another file (silent cross-file
+ * corruption). 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 entry
+ static final int FILE_MAGIC = 0x31445953; // 'SYD1' little-endian
+ static final int HEADER_SIZE = 8;
+ static final byte VERSION = 2; // v2 appended the per-entry CRC-32C
+ 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;
+ private long appendOffset;
+ private boolean closed;
+ // In-memory copy of the entry region [len][utf8]... exactly as on disk,
+ // populated only when open() recovered existing entries (recovery /
+ // orphan-drain). Zero/empty for a freshly created file. READ (not consumed) to
+ // seed the producer's id map (readLoadedSymbols) and to seed the send loop's
+ // catch-up mirror, which COPIES it. This file retains ownership for the engine's
+ // lifetime -- the orphan drainer builds a fresh send loop per wire session
+ // against the same engine, and each must re-seed its mirror -- and frees this
+ // buffer in close().
+ private long loadedEntriesAddr;
+ private int loadedEntriesLen;
+ private long scratchAddr;
+ private int scratchCap;
+ private int size;
+
+ private PersistedSymbolDict(FilesFacade ff, int fd, long appendOffset, int size, long loadedEntriesAddr, int loadedEntriesLen) {
+ this.ff = ff;
+ this.fd = fd;
+ this.appendOffset = appendOffset;
+ this.size = size;
+ this.loadedEntriesAddr = loadedEntriesAddr;
+ this.loadedEntriesLen = loadedEntriesLen;
+ }
+
+ /**
+ * Opens (creating if absent) the dictionary file in {@code slotDir}. An
+ * existing file is parsed and its complete entries are loaded into memory
+ * (see {@link #loadedEntriesAddr()}); a missing or invalid file is (re)created
+ * with a fresh header. Returns {@code null} on any I/O failure -- the caller
+ * then falls back to full-dictionary (self-sufficient) frames for this slot,
+ * so a broken side-file degrades gracefully rather than aborting the sender.
+ */
+ 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;
+ long existing = ff.exists(filePath) ? ff.length(filePath) : -1L;
+ // A dictionary that legitimately grew past Integer.MAX_VALUE cannot be
+ // reopened: openExisting reads it into ONE int-sized native buffer, and
+ // the (int) cast of a >2GB length is either negative (malloc rejects it),
+ // exactly zero (getInt then reads 4 bytes past a zero-size allocation), or
+ // a small positive prefix (whose validLen < len branch would then
+ // DESTRUCTIVELY truncate the multi-GB file). Recreate empty instead --
+ // fail-clean, exactly like every other unreadable-file case here, so the
+ // sender falls back to full self-sufficient frames. Reaching this needs
+ // ~100M+ distinct symbols on one slot (far past realistic symbol
+ // cardinality); the guard keeps the read/write size boundary safe anyway.
+ if (existing > Integer.MAX_VALUE) {
+ LOG.warn("symbol dict {} too large ({} bytes) to reopen; recreating empty", filePath, existing);
+ return openFresh(ff, filePath);
+ }
+ if (existing >= HEADER_SIZE) {
+ PersistedSymbolDict d = openExisting(ff, filePath, existing);
+ if (d != null) {
+ return d;
+ }
+ // Fall through to a clean re-create: a header/parse failure on an
+ // existing file means it cannot be trusted for delta replay.
+ }
+ return openFresh(ff, filePath);
+ }
+
+ /**
+ * 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, this truncates it: 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} on I/O failure, so
+ * the caller falls back to full self-sufficient frames exactly as {@link #open}
+ * does.
+ */
+ 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);
+ }
+
+ /**
+ * Best-effort removal of a stale dictionary file. Used at fully-drained close
+ * (the slot is empty, nothing references the dictionary any more), mirroring
+ * {@link AckWatermark#removeOrphan}. 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.
+ */
+ public static void removeOrphan(String slotDir) {
+ removeOrphan(FilesFacade.INSTANCE, slotDir);
+ }
+
+ /**
+ * Facade-aware variant of {@link #removeOrphan(String)}.
+ */
+ public static void removeOrphan(FilesFacade ff, String slotDir) {
+ ff.remove(slotDir + "/" + FILE_NAME);
+ }
+
+ /**
+ * Appends {@code count} wire entries -- {@code [len varint][utf8]...}, the
+ * symbol-dict delta section the frame encoder just wrote -- to the on-disk
+ * dictionary, computing and appending a per-entry CRC-32C as it copies so the
+ * producer does not re-encode the symbols. The on-disk layout is
+ * {@code [len varint][utf8][crc32c]} per entry (see the class layout note); the
+ * {@code addr}/{@code len} bytes carry no CRC, so this walks the {@code count}
+ * entries to insert one. 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;
+ }
+ int outLen = len + count * CRC_SIZE;
+ ensureScratch(outLen);
+ long src = addr;
+ long srcLimit = addr + len;
+ long dst = scratchAddr;
+ for (int i = 0; i < count; i++) {
+ long entryStart = src;
+ 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 downstream entryEnd > srcLimit
+ // check then rejects it. Matches decodeVarint / readVarintAt.
+ break;
+ }
+ }
+ long entryEnd = src + symLen; // src is just past the len varint
+ if (entryEnd > srcLimit) {
+ throw new IllegalStateException("malformed raw symbol-dict entries to " + FILE_NAME
+ + " [entry=" + i + ", count=" + count + ']');
+ }
+ int wireSpan = (int) (entryEnd - entryStart); // [len][utf8]
+ Unsafe.getUnsafe().copyMemory(entryStart, dst, wireSpan);
+ Unsafe.getUnsafe().putInt(dst + wireSpan, Crc32c.update(Crc32c.INIT, entryStart, wireSpan));
+ dst += wireSpan + CRC_SIZE;
+ src = entryEnd;
+ }
+ if (src != srcLimit) {
+ // The count entries did not consume exactly len bytes -- a caller passed an
+ // inconsistent (addr, len, count) triple. Writing outLen would flush an
+ // uninitialised scratch tail and mis-advance size, so fail loudly. The sole
+ // caller derives count and len from one beginMessage, so this cannot fire
+ // today.
+ throw new IllegalStateException("raw symbol-dict entries under-filled the buffer to "
+ + FILE_NAME + " [count=" + count + ", len=" + len
+ + ", consumed=" + (int) (src - addr) + ']');
+ }
+ long written = ff.write(fd, scratchAddr, outLen, appendOffset);
+ if (written != outLen) {
+ throw new IllegalStateException("short write to " + FILE_NAME
+ + " [expected=" + outLen + ", actual=" + written + ']');
+ }
+ appendOffset += outLen;
+ size += count;
+ }
+
+ /**
+ * Appends one symbol, 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). Writes {@code [len varint][utf8][crc32c]}, the CRC
+ * covering the {@code [len][utf8]} bytes so a torn/stale entry is detected on
+ * recovery.
+ */
+ public synchronized void appendSymbol(CharSequence symbol) {
+ if (closed) {
+ return;
+ }
+ int utf8Len = Utf8s.utf8Bytes(symbol);
+ int varLen = NativeBufferWriter.varintSize(utf8Len);
+ int wireLen = varLen + utf8Len; // [len][utf8]
+ int recLen = wireLen + CRC_SIZE; // + trailing crc
+ ensureScratch(recLen);
+ long p = NativeBufferWriter.writeVarint(scratchAddr, utf8Len);
+ if (utf8Len > 0) {
+ Utf8s.strCpyUtf8(symbol, p, utf8Len);
+ }
+ Unsafe.getUnsafe().putInt(scratchAddr + wireLen, Crc32c.update(Crc32c.INIT, scratchAddr, wireLen));
+ long written = ff.write(fd, scratchAddr, recLen, appendOffset);
+ if (written != recLen) {
+ throw new IllegalStateException("short write to " + FILE_NAME
+ + " [expected=" + recLen + ", actual=" + written + ']');
+ }
+ appendOffset += recLen;
+ size++;
+ }
+
+ /**
+ * Appends the dense id range {@code [from .. to]} in a SINGLE write. Encodes
+ * the whole {@code [len varint][utf8][crc32c]...} region into scratch first,
+ * then issues one positioned write -- versus one {@code pwrite(2)} per symbol
+ * via {@link #appendSymbol}. That per-symbol syscall count is the hot-path
+ * cost on a high-cardinality batch (one new symbol per row), which is exactly
+ * the store-and-forward workload delta encoding targets. Each entry carries a
+ * trailing CRC-32C over its {@code [len][utf8]} bytes. 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 len = 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(len + wireLen + CRC_SIZE);
+ long entryStart = scratchAddr + len;
+ long p = NativeBufferWriter.writeVarint(entryStart, utf8Len);
+ if (utf8Len > 0) {
+ Utf8s.strCpyUtf8(symbol, p, utf8Len);
+ }
+ Unsafe.getUnsafe().putInt(entryStart + wireLen, Crc32c.update(Crc32c.INIT, entryStart, wireLen));
+ len += wireLen + CRC_SIZE;
+ }
+ long written = ff.write(fd, scratchAddr, len, appendOffset);
+ if (written != len) {
+ throw new IllegalStateException("short write to " + FILE_NAME
+ + " [expected=" + len + ", actual=" + written + ']');
+ }
+ appendOffset += len;
+ size += to - from + 1;
+ }
+
+ @Override
+ public synchronized void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ if (loadedEntriesAddr != 0L) {
+ Unsafe.free(loadedEntriesAddr, loadedEntriesLen, MemoryTag.NATIVE_DEFAULT);
+ // Null after freeing (like scratchAddr below) so a future accessor that
+ // reads loadedEntriesAddr()/loadedEntriesLen() post-close cannot
+ // dereference freed native memory; the getters are not closed-guarded.
+ loadedEntriesAddr = 0L;
+ loadedEntriesLen = 0;
+ }
+ if (scratchAddr != 0L) {
+ Unsafe.free(scratchAddr, scratchCap, MemoryTag.NATIVE_DEFAULT);
+ scratchAddr = 0L;
+ scratchCap = 0;
+ }
+ if (fd >= 0) {
+ ff.close(fd);
+ }
+ }
+
+ /**
+ * 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. 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;
+ }
+
+ /**
+ * Materialises the loaded entries as symbol strings in ascending-id order
+ * (entry {@code i} is symbol id {@code i}). Used once on recovery to
+ * repopulate the producer's global dictionary. Empty when nothing was
+ * recovered.
+ *
+ * Construction-phase only -- like {@link #loadedEntriesAddr()}, this
+ * walks the native entry region {@link #close()} frees, with no closed-guard,
+ * so it must run before the I/O thread and any producer append start.
+ */
+ public ObjList readLoadedSymbols() {
+ ObjList out = new ObjList<>(Math.max(size, 1));
+ long p = loadedEntriesAddr;
+ long limit = p + loadedEntriesLen;
+ for (int i = 0; i < size && p < limit; i++) {
+ long len = 0;
+ int shift = 0;
+ while (p < limit) {
+ byte b = Unsafe.getUnsafe().getByte(p++);
+ len |= (long) (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) {
+ break;
+ }
+ shift += 7;
+ }
+ if (p + len > limit) {
+ break; // defensive: torn tail (should not happen past parse in open)
+ }
+ out.add(Utf8s.stringFromUtf8Bytes(p, p + len));
+ p += len;
+ }
+ return out;
+ }
+
+ /**
+ * Number of symbols the dictionary holds (highest id + 1).
+ */
+ public int size() {
+ return size;
+ }
+
+ /**
+ * Decodes an unsigned LEB128 varint from {@code buf[pos..limit)}. Returns
+ * {@code [value, newPos]} or {@code null} if the varint is truncated
+ * (torn tail).
+ */
+ private static long[] decodeVarint(long buf, int pos, int limit) {
+ long value = 0;
+ int shift = 0;
+ int cur = pos;
+ while (cur < limit) {
+ byte b = Unsafe.getUnsafe().getByte(buf + cur);
+ cur++;
+ value |= (long) (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) {
+ return new long[]{value, cur};
+ }
+ shift += 7;
+ if (shift > 35) {
+ return null; // implausible for an entry length; treat as torn
+ }
+ }
+ return null;
+ }
+
+ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath, long fileLen) {
+ int fd = ff.openRW(filePath);
+ if (fd < 0) {
+ LOG.warn("symbol dict {} could not be opened (rc={}); recreating", filePath, fd);
+ return null;
+ }
+ long buf = 0L;
+ long entriesAddr = 0L;
+ int entriesLen = 0;
+ try {
+ int len = (int) fileLen;
+ buf = Unsafe.malloc(len, MemoryTag.NATIVE_DEFAULT);
+ long read = ff.read(fd, buf, len, 0);
+ if (read != len
+ || Unsafe.getUnsafe().getInt(buf) != FILE_MAGIC
+ || Unsafe.getUnsafe().getByte(buf + 4) != VERSION) {
+ LOG.warn("symbol dict {} unreadable, bad magic or unknown version; recreating", filePath);
+ Unsafe.free(buf, len, MemoryTag.NATIVE_DEFAULT);
+ ff.close(fd);
+ return null;
+ }
+ // Parse complete, CRC-valid entries after the header; stop at the first
+ // torn/incomplete OR crc-mismatched entry. The CRC turns an interior
+ // tear (a lost page reading back as zeroes) or a stale entry left past
+ // the end by a failed truncate into a clean stop point, so recovery
+ // trusts only the intact prefix instead of silently mis-parsing a
+ // corrupt entry and shifting the dense id->symbol map.
+ int diskPos = HEADER_SIZE; // walks the on-disk [len][utf8][crc] entries
+ int count = 0;
+ int wireLen = 0; // running size of the crc-stripped copy
+ while (diskPos < len) {
+ long[] vr = decodeVarint(buf, diskPos, len);
+ if (vr == null) {
+ break; // torn length varint
+ }
+ long symLen = vr[0];
+ int afterVar = (int) vr[1];
+ // [len varint][utf8] then a u32 CRC. symLen stays a long so a
+ // corrupt multi-gigabyte length cannot wrap an int back under the
+ // bound check. No fixed per-entry ceiling -- the write path applies
+ // none, so a legitimately large symbol must recover here.
+ long wireEnd = (long) afterVar + symLen; // end of [len][utf8]
+ if (wireEnd + CRC_SIZE > len) {
+ break; // torn/incomplete trailing entry (its CRC doesn't fit)
+ }
+ int wireEndI = (int) wireEnd;
+ int wireSpan = wireEndI - diskPos;
+ int crcStored = Unsafe.getUnsafe().getInt(buf + wireEndI);
+ int crcCalc = Crc32c.update(Crc32c.INIT, buf + diskPos, wireSpan);
+ if (crcCalc != crcStored) {
+ break; // corrupt/stale entry -- stop before it (fail-clean)
+ }
+ diskPos = wireEndI + CRC_SIZE;
+ wireLen += wireSpan;
+ count++;
+ }
+ int diskConsumed = diskPos - HEADER_SIZE; // valid entries incl. their CRCs
+ // Materialise the trusted entries as WIRE bytes ([len][utf8]..., no
+ // CRC) so loadedEntries*/readLoadedSymbols and the send-loop catch-up
+ // mirror stay wire-shaped -- the on-disk CRC is stripped here, once, at
+ // open. A second no-alloc walk over the already-validated region.
+ if (wireLen > 0) {
+ entriesAddr = Unsafe.malloc(wireLen, MemoryTag.NATIVE_DEFAULT);
+ // Record the length alongside the malloc so the catch below frees the
+ // right size (not 0) if this copy walk ever throws.
+ entriesLen = wireLen;
+ long dst = entriesAddr;
+ int p = HEADER_SIZE;
+ for (int i = 0; i < count; i++) {
+ int vp = p;
+ long symLen = 0;
+ int shift = 0;
+ while (true) {
+ byte b = Unsafe.getUnsafe().getByte(buf + vp++);
+ symLen |= (long) (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) {
+ break;
+ }
+ shift += 7;
+ if (shift > 35) {
+ break; // corrupt run; these entries were CRC-validated above
+ }
+ }
+ int wireSpan = (vp - p) + (int) symLen; // [len][utf8], no CRC
+ Unsafe.getUnsafe().copyMemory(buf + p, dst, wireSpan);
+ dst += wireSpan;
+ p += wireSpan + CRC_SIZE; // skip the entry's CRC
+ }
+ }
+ Unsafe.free(buf, len, MemoryTag.NATIVE_DEFAULT);
+ buf = 0L;
+ // Drop any torn/stale trailing bytes so a LATER, shorter append cannot
+ // leave residue past its own end. Unlike before, the truncate result IS
+ // checked: a file we cannot trim could still expose stale post-end bytes
+ // whose (self-consistent) per-entry CRC the parse would accept at a
+ // shifted position, so a failed truncate makes the file untrusted --
+ // open() then recreates it empty (fail-clean) rather than risk a silent
+ // misattribution.
+ long validLen = HEADER_SIZE + diskConsumed;
+ if (validLen < len && !ff.truncate(fd, validLen)) {
+ LOG.warn("symbol dict {} could not drop its torn/stale tail (truncate failed); recreating", filePath);
+ if (entriesAddr != 0L) {
+ Unsafe.free(entriesAddr, entriesLen, MemoryTag.NATIVE_DEFAULT);
+ }
+ ff.close(fd);
+ return null;
+ }
+ return new PersistedSymbolDict(ff, fd, validLen, count, entriesAddr, entriesLen);
+ } catch (Throwable t) {
+ if (buf != 0L) {
+ Unsafe.free(buf, (int) fileLen, MemoryTag.NATIVE_DEFAULT);
+ }
+ // entriesAddr is transferred to the returned dict on the success path,
+ // and the catch only runs before that return, so freeing it here cannot
+ // double-free. Unreachable today (nothing between its malloc and the
+ // return throws), but keeps the error path leak-free against a future edit.
+ if (entriesAddr != 0L) {
+ Unsafe.free(entriesAddr, entriesLen, MemoryTag.NATIVE_DEFAULT);
+ }
+ ff.close(fd);
+ LOG.warn("symbol dict {} recovery failed ({}); recreating", filePath, String.valueOf(t));
+ return null;
+ }
+ }
+
+ private static PersistedSymbolDict openFresh(FilesFacade ff, String filePath) {
+ int fd = ff.openCleanRW(filePath);
+ if (fd < 0) {
+ 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) {
+ ff.close(fd);
+ ff.remove(filePath);
+ LOG.warn("symbol dict {} header write failed; proceeding without it", filePath);
+ return null;
+ }
+ } catch (Throwable t) {
+ // Unreachable today (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 close the fd against a future
+ // edit so it cannot leak -- mirroring openExisting's error handling.
+ ff.close(fd);
+ LOG.warn("symbol dict {} creation failed ({}); proceeding without it", filePath, String.valueOf(t));
+ return null;
+ } finally {
+ if (hdr != 0L) {
+ Unsafe.free(hdr, HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+ return new PersistedSymbolDict(ff, fd, HEADER_SIZE, 0, 0L, 0);
+ }
+
+ private void ensureScratch(int required) {
+ if (scratchCap >= required) {
+ return;
+ }
+ // Double in long: scratchCap * 2 as an int overflows negative past ~1 GB and
+ // would make the realloc size negative. required is bounded by one frame's
+ // entries (the server batch cap), so this never actually caps -- it mirrors the
+ // long-math growth in CursorWebSocketSendLoop.ensureSentDictCapacity.
+ long newCap = Math.max(required, Math.max(256L, (long) scratchCap * 2));
+ if (newCap > Integer.MAX_VALUE - 8) {
+ newCap = Integer.MAX_VALUE - 8;
+ }
+ scratchAddr = Unsafe.realloc(scratchAddr, scratchCap, (int) newCap, MemoryTag.NATIVE_DEFAULT);
+ scratchCap = (int) newCap;
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCatchUpTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCatchUpTest.java
new file mode 100644
index 00000000..0f133443
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictCatchUpTest.java
@@ -0,0 +1,584 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client;
+
+import io.questdb.client.Sender;
+import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Verifies the delta symbol-dictionary catch-up on reconnect (memory-mode).
+ *
+ * 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 conn2 = handler.dictFor(2);
+ Assert.assertTrue("2nd connection saw a catch-up frame with 0 tables",
+ handler.sawZeroTableFrameOnConn2);
+ Assert.assertTrue("the catch-up frame carries no rows, so it must defer its "
+ + "(empty) commit -- FLAG_DEFER_COMMIT set",
+ handler.catchUpDeferredOnConn2);
+ Assert.assertEquals("2nd connection dictionary size", 2, conn2.size());
+ Assert.assertEquals("alpha", conn2.get(0));
+ Assert.assertEquals("beta", conn2.get(1));
+ }
+ });
+ }
+
+ @Test
+ public void testReconnectPreservesMonotonicDeltaBaseline() throws Exception {
+ // Regression: the producer's sent-symbol watermark (sentMaxSymbolId) must
+ // SURVIVE a reconnect. resetSymbolDictStateForNewConnection deliberately
+ // leaves it untouched -- the I/O thread re-registers the whole dictionary via
+ // a catch-up frame before replay, so the producer keeps shipping deltas ABOVE
+ // the baseline across the wire boundary. If a regression reset it on
+ // reconnect, the first post-reconnect data frame would re-ship the whole
+ // dictionary inline (deltaStart=0), pure wasted bandwidth. The sibling
+ // testReconnectCatchUpRebuildsDictionary asserts only that the final
+ // dictionary is complete -- which a reset-then-redefine ALSO satisfies (the
+ // server tolerates the redefinition) -- so it does NOT catch that regression.
+ // This pins the baseline survival directly: connection 2's data frame must
+ // carry a delta starting at id 1 (above alpha), not 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 + ";")) {
+ // Connection 1 registers alpha (id 0); the server ACKs and drops it.
+ sender.table("t").symbol("s", "alpha").longColumn("v", 1L).atNow();
+ sender.flush();
+ waitFor(() -> handler.dictFor(1).size() >= 1, 5_000);
+ waitFor(() -> handler.conn1Closed, 5_000);
+
+ // Connection 2 (fresh) registers beta (id 1). With the baseline
+ // preserved, beta ships as a delta ABOVE id 0 (deltaStart=1).
+ sender.table("t").symbol("s", "beta").longColumn("v", 2L).atNow();
+ sender.flush();
+ waitFor(() -> handler.connectionsAccepted.get() >= 2
+ && handler.dictFor(2).size() >= 2, 5_000);
+ }
+
+ Assert.assertTrue("connection 2 must re-register the dictionary via a catch-up first",
+ handler.sawZeroTableFrameOnConn2);
+ Assert.assertTrue("post-reconnect data frame must ship a delta ABOVE the surviving "
+ + "baseline (deltaStart >= 1); a reset baseline re-ships the whole "
+ + "dictionary from deltaStart 0",
+ handler.conn2SawDeltaAboveBaseline);
+ }
+ });
+ }
+
+ @Test
+ public void testFixedCapNearBoundarySymbolCatchesUpWithoutTerminal() throws Exception {
+ // Regression (homogeneous single cap): a symbol whose length sits just below
+ // the advertised cap is ACCEPTED into a data frame (messageSize <= cap) and
+ // enters the sent-dictionary mirror. On reconnect the catch-up must
+ // re-register it under the SAME cap -- the bare catch-up frame (header + two
+ // varints + the entry) is smaller than the data frame that already shipped
+ // it (which also carried the table schema + a row), so it fits.
+ //
+ // Pre-fix, the single-entry terminal used the conservative PACKING budget
+ // (cap - HEADER_SIZE - 16), which is stricter than the producer's publish
+ // gate (messageSize <= cap) by more than the minimal data-frame overhead. So
+ // a symbol accepted onto the wire under cap C could exceed that budget and
+ // trip a spurious "during catch-up" terminal, permanently hard-failing a
+ // running producer on its first transient reconnect. Concretely at cap=200:
+ // table("t").symbol("s", <173 chars>).atNow() encodes to 198 bytes (<=200,
+ // accepted), its dict entry is 2+173=175 bytes (> old budget 172 -> old
+ // terminal), while the real solo catch-up frame is 12+1+1+175=189 (<=200 ->
+ // fits). Unlike testCatchUpEntryTooLargeForCapFailsTerminally (a genuinely
+ // oversized entry on a shrunk cap, which MUST still terminate), this entry
+ // is legally shippable and must NOT terminate.
+ final int cap = 200;
+ final String nearCapSymbol = TestUtils.repeat("x", 173);
+ assertMemoryLeak(() -> {
+ CatchUpHandler handler = new CatchUpHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ server.setAdvertisedMaxBatchSize(cap); // same cap on every handshake (homogeneous)
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + port + ";")) {
+ // Symbol-only row so the near-cap symbol drives the frame size.
+ sender.table("t").symbol("s", nearCapSymbol).atNow();
+ sender.flush();
+ waitFor(() -> handler.dictFor(1).size() >= 1, 5_000);
+ waitFor(() -> handler.conn1Closed, 5_000);
+
+ // The reconnect runs the catch-up under the SAME cap. Pre-fix
+ // this latched a terminal (surfacing on this flush); post-fix the
+ // catch-up ships the near-cap symbol and the flush goes through.
+ sender.table("t").symbol("s", "beta").atNow();
+ sender.flush();
+ waitFor(() -> handler.connectionsAccepted.get() >= 2
+ && handler.dictFor(2).size() >= 2, 5_000);
+ }
+
+ // Connection 2's dictionary, rebuilt purely from the frames it
+ // received, must hold the near-cap symbol (re-registered by the
+ // catch-up) and beta, gap-free -- proving the catch-up SHIPPED rather
+ // than terminated.
+ List conn2 = handler.dictFor(2);
+ Assert.assertTrue("2nd connection saw a catch-up frame with 0 tables",
+ handler.sawZeroTableFrameOnConn2);
+ Assert.assertEquals("2nd connection dictionary size", 2, conn2.size());
+ Assert.assertEquals(nearCapSymbol, conn2.get(0));
+ Assert.assertEquals("beta", conn2.get(1));
+ }
+ });
+ }
+
+ @Test
+ public void testCatchUpEntryTooLargeForCapFailsTerminally() throws Exception {
+ // A dictionary entry that exceeds the reconnect server's per-chunk budget
+ // (cap - HEADER_SIZE - 16) cannot be shipped as a catch-up chunk.
+ // sendDictCatchUp must latch a clean terminal ("... during catch-up")
+ // rather than call fail(): pre-fix the oversized entry drove an endless
+ // reconnect loop (the entry never shrinks and the same cluster
+ // re-advertises the same cap) and re-entered connectLoop from the catch-up.
+ //
+ // Connection 1 advertises no cap, so the ~202-byte symbol registers and
+ // enters the sent-dictionary mirror. The handler then shrinks the
+ // advertised cap to 160 (catch-up budget 132) and drops the socket, so the
+ // reconnect's catch-up cannot re-ship the symbol. (One fixed cap can't do
+ // this: the client refuses to SEND a single-table frame over the cap, and
+ // that data frame is always larger than the bare catch-up entry.)
+ assertMemoryLeak(() -> {
+ CapShrinkHandler handler = new CapShrinkHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ handler.setServer(server);
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ String bigSymbol = TestUtils.repeat("x", 200); // ~202-byte dict entry
+ LineSenderException terminal = null;
+ Sender sender = Sender.fromConfig("ws::addr=localhost:" + port + ";");
+ try {
+ sender.table("t").symbol("s", bigSymbol).longColumn("v", 1L).atNow();
+ sender.flush();
+ // The terminal latches on the I/O thread once the reconnect's
+ // catch-up hits the oversized entry; it surfaces to the producer
+ // on a subsequent flush. Poll a bounded time for it. The polling
+ // rows use a small symbol that fits the shrunk cap, so the
+ // producer-side cap check never fires and flush() surfaces the
+ // I/O thread's catch-up terminal via checkError.
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (System.currentTimeMillis() < deadline && terminal == null) {
+ try {
+ sender.table("t").symbol("s", "y").longColumn("v", 2L).atNow();
+ sender.flush();
+ Thread.sleep(20);
+ } catch (LineSenderException e) {
+ terminal = e;
+ }
+ }
+ } finally {
+ try {
+ sender.close();
+ } catch (LineSenderException e) {
+ if (terminal == null) {
+ terminal = e;
+ }
+ }
+ }
+ Assert.assertNotNull("an oversized catch-up entry must surface a terminal", terminal);
+ Assert.assertTrue("terminal must come from the catch-up path, got: " + terminal.getMessage(),
+ terminal.getMessage().contains("during catch-up"));
+ }
+ });
+ }
+
+ @Test
+ public void testReconnectCatchUpSplitsLargeDictionaryAcrossFrames() throws Exception {
+ // Connection 1 registers 40 ten-character symbols (~440 dictionary bytes),
+ // then drops once the server has learned them all. On reconnect the fresh
+ // server has an empty dictionary, so the I/O thread must replay all 40 as a
+ // catch-up -- but the server advertises a 160-byte batch cap, so the whole
+ // dictionary cannot fit in a single frame. The catch-up therefore splits
+ // into several contiguous zero-table frames that the fresh server stitches
+ // back into a complete, gap-free dictionary.
+ final int symbolCount = 40;
+ assertMemoryLeak(() -> {
+ SplitCatchUpHandler handler = new SplitCatchUpHandler(symbolCount);
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ server.setAdvertisedMaxBatchSize(160); // small cap forces the catch-up to split
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + port + ";")) {
+ // One row per flush so each frame stays under the 160-byte cap; the
+ // sent dictionary still accumulates all 40 symbols on connection 1.
+ for (int i = 0; i < symbolCount; i++) {
+ sender.table("t").symbol("s", symbolName(i)).longColumn("v", i).atNow();
+ sender.flush();
+ }
+ // Wait until connection 1 has learned every symbol, so the sender's
+ // sent-dictionary mirror (the catch-up source) holds all of them.
+ waitFor(() -> handler.dictFor(1).size() >= symbolCount, 10_000);
+
+ // Wait until the server has actually closed connection 1 before
+ // sending batch 2, so batch 2 drives the reconnect + split catch-up.
+ waitFor(() -> handler.conn1Closed, 5_000);
+
+ sender.table("t").symbol("s", symbolName(symbolCount)).longColumn("v", symbolCount).atNow();
+ sender.flush();
+ waitFor(() -> handler.connectionsAccepted.get() >= 2
+ && handler.dictFor(2).size() >= symbolCount + 1, 10_000);
+ }
+
+ // Connection 2's dictionary, rebuilt purely from the frames it received,
+ // must hold every symbol contiguously with no null gap -- the split
+ // catch-up frames reassemble exactly.
+ List conn2 = handler.dictFor(2);
+ Assert.assertEquals("2nd connection dictionary size", symbolCount + 1, conn2.size());
+ for (int i = 0; i <= symbolCount; i++) {
+ Assert.assertEquals("symbol at id " + i, symbolName(i), conn2.get(i));
+ }
+ // The catch-up had to span more than one zero-table frame to stay under
+ // the advertised cap -- that split is the behaviour under test.
+ Assert.assertTrue("catch-up split into multiple frames (saw "
+ + handler.zeroTableFramesOnConn2 + ")",
+ handler.zeroTableFramesOnConn2 >= 2);
+ }
+ });
+ }
+
+ private static String symbolName(int i) {
+ // 10-char symbols so 40 of them clearly exceed the advertised 160-byte cap.
+ return "symbol" + (1000 + i);
+ }
+
+ private static int readVarint(byte[] buf, int[] pos) {
+ int result = 0;
+ int shift = 0;
+ while (pos[0] < buf.length) {
+ int b = buf[pos[0]++] & 0xFF;
+ result |= (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) return result;
+ shift += 7;
+ if (shift > 28) throw new IllegalStateException("varint too long");
+ }
+ throw new IllegalStateException("varint truncated");
+ }
+
+ private static void waitFor(BoolCondition cond, long timeoutMillis) {
+ long deadline = System.currentTimeMillis() + timeoutMillis;
+ while (System.currentTimeMillis() < deadline) {
+ if (cond.test()) return;
+ try {
+ Thread.sleep(20);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ Assert.fail("interrupted");
+ }
+ }
+ Assert.fail("waitFor timed out");
+ }
+
+ @FunctionalInterface
+ private interface BoolCondition {
+ boolean test();
+ }
+
+ /**
+ * Mirrors the server's per-connection delta-dictionary accumulation: the
+ * dictionary resets on every new connection, and each frame's delta section
+ * ({@code setQuick(deltaStart + i)}, null-padding to reach deltaStart) extends
+ * or overwrites it. A missing catch-up would show up here as a null gap.
+ */
+ private static class CatchUpHandler implements TestWebSocketServer.WebSocketServerHandler {
+ final AtomicInteger connectionsAccepted = new AtomicInteger();
+ // Set once the server has closed connection 1. A test waits on this
+ // (rather than a fixed sleep) before sending batch 2, so batch 2 cannot
+ // race into connection 1's pre-close window and must land on the reconnect.
+ volatile boolean conn1Closed;
+ // Set from the flags byte of the zero-table catch-up frame on connection 2:
+ // the catch-up carries no rows and must defer its (empty) commit.
+ volatile boolean catchUpDeferredOnConn2;
+ // Set when connection 2 receives a DATA frame (tableCount > 0) whose delta
+ // starts ABOVE id 0 (deltaStart >= 1). This can only happen if the producer's
+ // monotonic baseline SURVIVED the reconnect: a reset would re-ship the whole
+ // dictionary from deltaStart 0. Robust to replay -- a replayed pre-reconnect
+ // frame carries its original deltaStart 0, so only a genuinely-above-baseline
+ // post-reconnect frame trips it.
+ volatile boolean conn2SawDeltaAboveBaseline;
+ volatile boolean sawZeroTableFrameOnConn2;
+ private final List> dictsByConn = new CopyOnWriteArrayList<>();
+ private TestWebSocketServer.ClientHandler currentClient;
+ private final AtomicLong nextSeq = new AtomicLong(0);
+
+ synchronized List dictFor(int connNumber) {
+ return connNumber <= dictsByConn.size()
+ // Copy under the lock: the caller iterates it unlocked while the
+ // server thread may still be appending to the live inner list.
+ ? new ArrayList<>(dictsByConn.get(connNumber - 1))
+ : new ArrayList<>();
+ }
+
+ @Override
+ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ boolean newConnection = currentClient != client;
+ if (newConnection) {
+ currentClient = client;
+ connectionsAccepted.incrementAndGet();
+ dictsByConn.add(new ArrayList<>()); // fresh dictionary per connection
+ }
+ int connNumber = dictsByConn.size();
+ List dict = dictsByConn.get(connNumber - 1);
+ accumulate(data, dict);
+ if (connNumber == 2) {
+ if (tableCount(data) == 0) {
+ sawZeroTableFrameOnConn2 = true;
+ // FLAG_DEFER_COMMIT is bit 0x01 of the flags byte (offset 5).
+ catchUpDeferredOnConn2 = (data[5] & 0x01) != 0;
+ } else if (data.length >= 12 && (data[5] & 0x08) != 0) {
+ // A post-reconnect DATA frame carrying a delta section
+ // (FLAG_DELTA_SYMBOL_DICT = 0x08). A deltaStart >= 1 means the
+ // producer resumed the delta ABOVE the surviving baseline; a reset
+ // baseline would re-ship from deltaStart 0. Checking any frame (not
+ // just the first) keeps this robust to a replayed pre-reconnect
+ // frame arriving ahead of the new one -- that replay carries its
+ // original deltaStart 0 and does not trip the flag.
+ int[] pos = {12};
+ if (readVarint(data, pos) >= 1) {
+ conn2SawDeltaAboveBaseline = true;
+ }
+ }
+ }
+ try {
+ client.sendBinary(buildAck(nextSeq.getAndIncrement()));
+ // Drop the first connection right after ACKing its only frame,
+ // forcing the sender to reconnect onto a fresh dictionary.
+ if (connNumber == 1) {
+ Thread.sleep(50);
+ client.close();
+ conn1Closed = true;
+ }
+ } catch (IOException | InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static void accumulate(byte[] frame, List dict) {
+ final byte FLAG_DELTA_SYMBOL_DICT = 0x08;
+ if (frame.length < 12 || (frame[5] & FLAG_DELTA_SYMBOL_DICT) == 0) {
+ return;
+ }
+ int[] pos = {12}; // just past the 12-byte QWP header
+ int deltaStart = readVarint(frame, pos);
+ int deltaCount = readVarint(frame, pos);
+ while (dict.size() < deltaStart) {
+ dict.add(null); // null-pad, mirroring the server
+ }
+ for (int i = 0; i < deltaCount; i++) {
+ int len = readVarint(frame, pos);
+ String symbol = new String(frame, pos[0], len, StandardCharsets.UTF_8);
+ pos[0] += len;
+ int idx = deltaStart + i;
+ while (dict.size() <= idx) {
+ dict.add(null);
+ }
+ dict.set(idx, symbol);
+ }
+ }
+
+ private static byte[] buildAck(long seq) {
+ byte[] buf = new byte[1 + 8 + 2];
+ ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
+ bb.put((byte) 0x00);
+ bb.putLong(seq);
+ bb.putShort((short) 0);
+ return buf;
+ }
+
+ private static int tableCount(byte[] frame) {
+ return (frame[6] & 0xFF) | ((frame[7] & 0xFF) << 8);
+ }
+ }
+
+ /**
+ * ACKs connection 1's frames with no advertised cap (so an oversized symbol
+ * registers), then -- once connection 1 has sent something -- shrinks the
+ * advertised batch cap and drops the socket. The reconnect (connection 2)
+ * therefore advertises a cap whose catch-up budget is too small for the
+ * symbol, exercising the oversized-entry terminal in sendDictCatchUp.
+ */
+ private static class CapShrinkHandler implements TestWebSocketServer.WebSocketServerHandler {
+ final AtomicInteger connectionsAccepted = new AtomicInteger();
+ private final AtomicLong nextSeq = new AtomicLong(0);
+ private TestWebSocketServer.ClientHandler currentClient;
+ private volatile TestWebSocketServer server;
+
+ void setServer(TestWebSocketServer server) {
+ this.server = server;
+ }
+
+ @Override
+ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ if (currentClient != client) {
+ currentClient = client;
+ connectionsAccepted.incrementAndGet();
+ }
+ try {
+ client.sendBinary(CatchUpHandler.buildAck(nextSeq.getAndIncrement()));
+ if (connectionsAccepted.get() == 1) {
+ // Connection 1 registered the big symbol. Shrink the cap so the
+ // reconnect's catch-up budget can't fit it, then drop to force
+ // the reconnect. Setting the cap before the close (not from the
+ // test thread after it) removes the set-vs-reconnect race.
+ server.setAdvertisedMaxBatchSize(160); // catch-up budget = 132
+ Thread.sleep(50);
+ client.close();
+ }
+ } catch (IOException | InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /**
+ * Like {@link CatchUpHandler}, but drops connection 1 only after it has
+ * learned the whole batch, and counts the zero-table catch-up frames on
+ * connection 2 so a test can assert the dictionary replay split across
+ * several frames to respect the advertised batch cap.
+ */
+ private static class SplitCatchUpHandler implements TestWebSocketServer.WebSocketServerHandler {
+ final AtomicInteger connectionsAccepted = new AtomicInteger();
+ // Set once the server has closed connection 1 (see CatchUpHandler.conn1Closed).
+ volatile boolean conn1Closed;
+ volatile int zeroTableFramesOnConn2;
+ private final List> dictsByConn = new CopyOnWriteArrayList<>();
+ private final int dropConn1AtDictSize;
+ private final AtomicLong nextSeq = new AtomicLong(0);
+ private boolean conn1Dropped;
+ private TestWebSocketServer.ClientHandler currentClient;
+
+ SplitCatchUpHandler(int dropConn1AtDictSize) {
+ this.dropConn1AtDictSize = dropConn1AtDictSize;
+ }
+
+ synchronized List dictFor(int connNumber) {
+ return connNumber <= dictsByConn.size()
+ // Copy under the lock: the caller iterates it unlocked while the
+ // server thread may still be appending to the live inner list.
+ ? new ArrayList<>(dictsByConn.get(connNumber - 1))
+ : new ArrayList<>();
+ }
+
+ @Override
+ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ boolean newConnection = currentClient != client;
+ if (newConnection) {
+ currentClient = client;
+ connectionsAccepted.incrementAndGet();
+ dictsByConn.add(new ArrayList<>()); // fresh dictionary per connection
+ }
+ int connNumber = dictsByConn.size();
+ List dict = dictsByConn.get(connNumber - 1);
+ CatchUpHandler.accumulate(data, dict);
+ if (connNumber == 2 && CatchUpHandler.tableCount(data) == 0) {
+ zeroTableFramesOnConn2++;
+ }
+ try {
+ client.sendBinary(CatchUpHandler.buildAck(nextSeq.getAndIncrement()));
+ // Drop connection 1 only once it has learned the entire batch, so
+ // the sender's sent-dictionary mirror is complete and the reconnect
+ // catch-up must replay a dictionary larger than the batch cap.
+ if (connNumber == 1 && !conn1Dropped && dict.size() >= dropConn1AtDictSize) {
+ conn1Dropped = true;
+ Thread.sleep(50);
+ client.close();
+ conn1Closed = true;
+ }
+ } catch (IOException | InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java
new file mode 100644
index 00000000..e46d0148
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java
@@ -0,0 +1,944 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client;
+
+import io.questdb.client.Sender;
+import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
+import io.questdb.client.std.Files;
+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.lang.reflect.Field;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * End-to-end recovery for the delta symbol dictionary in store-and-forward mode.
+ *
+ * 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;
+ private String sfDir;
+
+ @Before
+ public void setUp() {
+ sfDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-delta-recov-" + System.nanoTime()).toString();
+ }
+
+ @After
+ public void tearDown() {
+ if (sfDir != null) {
+ rmDirRec(sfDir);
+ }
+ }
+
+ @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_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 dict = handler.dictSnapshot();
+ Assert.assertEquals("reconstructed dictionary size", DISTINCT_SYMBOLS, dict.size());
+ for (int i = 0; i < DISTINCT_SYMBOLS; i++) {
+ Assert.assertEquals("dictionary id " + i, "sym-" + i, dict.get(i));
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testTornDictionaryFailsCleanlyInsteadOfCorrupting() throws Exception {
+ assertMemoryLeak(() -> {
+ // Phase 1: each row introduces a new symbol, so frame i carries deltaStart=i.
+ // Silent server + close-fast leaves all frames unacked in the slot.
+ 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 < 6; i++) {
+ s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
+ s1.flush();
+ }
+ }
+ }
+
+ // Simulate a host/power crash: the segment frames survive but the persisted
+ // dictionary is lost, and the ack watermark was left mid-stream. Truncate
+ // .symbol-dict to its 8-byte header (0 symbols) and stamp the watermark at
+ // FSN 2, so recovery replays from FSN 3 -- a frame with deltaStart=3.
+ java.nio.file.Path slot = Paths.get(sfDir, "default");
+ java.nio.file.Path dict = slot.resolve(".symbol-dict");
+ byte[] header = Arrays.copyOf(java.nio.file.Files.readAllBytes(dict), 8);
+ java.nio.file.Files.write(dict, header);
+ writeAckWatermark(slot.resolve(".ack-watermark"), 2);
+
+ // Phase 2: recover against a fresh counting server. The replay guard must
+ // fire (frame deltaStart 3 > recovered dictionary size 0) and fail terminally
+ // rather than send a gapped frame that would corrupt the table.
+ CountingHandler handler = new CountingHandler();
+ 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 + ";";
+ LineSenderException terminal = null;
+ Sender s2 = Sender.fromConfig(cfg);
+ try {
+ // Poll for the replay guard to fire (it recordFatal's on the I/O
+ // thread); flush() surfaces the latched terminal to the producer.
+ // A bounded poll replaces a fixed sleep and captures it as soon as
+ // it fires; close() below is the fallback if it surfaces only there.
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (System.currentTimeMillis() < deadline && terminal == null) {
+ try {
+ s2.flush();
+ Thread.sleep(20);
+ } catch (LineSenderException e) {
+ terminal = e;
+ }
+ }
+ } finally {
+ try {
+ s2.close();
+ } catch (LineSenderException e) {
+ if (terminal == null) {
+ terminal = e;
+ }
+ }
+ }
+ Assert.assertEquals("no frame may be replayed to a fresh server with a torn dictionary",
+ 0, handler.frames.get());
+ Assert.assertNotNull("a torn dictionary must surface a terminal error", terminal);
+ Assert.assertTrue(terminal.getMessage(),
+ terminal.getMessage().contains("symbol dictionary is incomplete"));
+ }
+ });
+ }
+
+ @Test
+ public void testTornDictionaryOneIdGapFailsCleanly() throws Exception {
+ // Boundary variant of testTornDictionaryFailsCleanlyInsteadOfCorrupting: the
+ // recovered dictionary is short by EXACTLY ONE id -- the tightest gap the
+ // guard must still reject (deltaStart == recoveredSize + 1). A one-entry-short
+ // tail is the common host-crash outcome, so this pins the guard's
+ // false-negative edge: a "deltaStart > size + 1" mutation would let this
+ // single-id gap through and null-pad the missing symbol on the server.
+ assertMemoryLeak(() -> {
+ // Phase 1: each row introduces a new symbol (frame i carries deltaStart=i).
+ 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 < 6; i++) {
+ s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
+ s1.flush();
+ }
+ }
+ }
+
+ // Lose the whole dictionary (truncate to the 8-byte header, size 0) but
+ // stamp the watermark at FSN 0, so recovery replays from FSN 1 -- a frame
+ // with deltaStart=1. The dictionary covers no ids, so id 0 is the single
+ // missing entry: deltaStart(1) == recoveredSize(0) + 1. Because size is 0
+ // no catch-up is sent, so any counted frame would be the gapped data frame.
+ java.nio.file.Path slot = Paths.get(sfDir, "default");
+ java.nio.file.Path dict = slot.resolve(".symbol-dict");
+ byte[] header = Arrays.copyOf(java.nio.file.Files.readAllBytes(dict), 8);
+ java.nio.file.Files.write(dict, header);
+ writeAckWatermark(slot.resolve(".ack-watermark"), 0);
+
+ // Phase 2: recover against a fresh counting server. The guard must fire on
+ // the very first replay frame (deltaStart 1 > recovered size 0) and fail
+ // terminally rather than send a frame that null-pads id 0.
+ CountingHandler handler = new CountingHandler();
+ 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 + ";";
+ LineSenderException terminal = null;
+ Sender s2 = Sender.fromConfig(cfg);
+ try {
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (System.currentTimeMillis() < deadline && terminal == null) {
+ try {
+ s2.flush();
+ Thread.sleep(20);
+ } catch (LineSenderException e) {
+ terminal = e;
+ }
+ }
+ } finally {
+ try {
+ s2.close();
+ } catch (LineSenderException e) {
+ if (terminal == null) {
+ terminal = e;
+ }
+ }
+ }
+ Assert.assertEquals("a one-id gap must still block replay to a fresh server",
+ 0, handler.frames.get());
+ Assert.assertNotNull("a one-id-short dictionary must surface a terminal error", terminal);
+ Assert.assertTrue(terminal.getMessage(),
+ terminal.getMessage().contains("delta start 1 exceeds recovered dictionary size 0"));
+ }
+ });
+ }
+
+ @Test
+ public void testRecoveredSenderContinuesIngestingNewSymbols() throws Exception {
+ // M2 regression: seedGlobalDictionaryFromPersisted resumes the producer's
+ // dictionary and delta baseline from the persisted .symbol-dict, so a
+ // recovered sender that continues ingesting assigns the NEXT id, not a
+ // colliding low one. Without it the new symbol reuses a recovered id and the
+ // fresh server sees a redefinition -> silent misattribution. No prior test
+ // ingests on the recovered sender. Replay is from FSN 0 (no acks), so the
+ // recovered frames legitimately overlap the seeded dictionary -- this also
+ // pins that the redefinition guard does not false-positive on normal
+ // recovery.
+ assertMemoryLeak(() -> {
+ // Phase 1: ingest DISTINCT_SYMBOLS symbols, silent server, close-fast ->
+ // unacked frames + a full persisted dictionary.
+ 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_bytes=4096;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();
+ }
+ }
+ }
+
+ // Phase 2: recover against a fresh server, then ingest a genuinely NEW
+ // symbol. The producer must continue at id DISTINCT_SYMBOLS.
+ 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)) {
+ s2.table("m").symbol("s", "brand-new").longColumn("v", 99L).atNow();
+ s2.flush();
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (System.currentTimeMillis() < deadline
+ && handler.maxDictSize() < DISTINCT_SYMBOLS + 1) {
+ Thread.sleep(20);
+ }
+ }
+ List dict = handler.dictSnapshot();
+ Assert.assertEquals("recovered sender must continue the dictionary, not collide: " + dict,
+ DISTINCT_SYMBOLS + 1, dict.size());
+ for (int i = 0; i < DISTINCT_SYMBOLS; i++) {
+ Assert.assertEquals("sym-" + i, dict.get(i));
+ }
+ Assert.assertEquals("brand-new", dict.get(DISTINCT_SYMBOLS));
+ }
+ });
+ }
+
+ @Test
+ public void testUnopenablePersistedDictStillGuardsAgainstReplayingDeltaFrames() throws Exception {
+ // C1 regression: when a recovered disk slot's persisted dictionary cannot be
+ // OPENED (fd exhaustion, a read-only remount, ENOSPC -- simulated here by a
+ // .symbol-dict that is a DIRECTORY, so both openRW and openCleanRW fail),
+ // CursorSendEngine.isDeltaDictEnabled() returns false. The recorded frames
+ // are still DELTA frames, and replaying them against a fresh
+ // empty-dictionary server would null-pad the missing ids and SILENTLY
+ // corrupt the table. The torn-dictionary guard must fire regardless of
+ // deltaDictEnabled -- pre-fix it was gated on that very flag, so the
+ // corrupting frame sailed through unguarded. Unlike
+ // testTornDictionaryFailsCleanlyInsteadOfCorrupting (dict present but empty,
+ // deltaDictEnabled=true), here the dict is UNOPENABLE (deltaDictEnabled=false).
+ assertMemoryLeak(() -> {
+ // Phase 1: each row introduces a new symbol => frame i carries deltaStart=i.
+ 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 < 6; i++) {
+ s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
+ s1.flush();
+ }
+ }
+ }
+
+ // Make the persisted dictionary UNOPENABLE: replace the .symbol-dict file
+ // with a directory of the same name so PersistedSymbolDict.open() returns
+ // null (both openRW and openCleanRW fail) and the engine reports
+ // deltaDictEnabled=false. Stamp the watermark at FSN 2 so replay starts
+ // at FSN 3 -- a frame whose delta starts at id 3, with ids 0..2 living
+ // only in the now-unreadable dictionary.
+ java.nio.file.Path slot = Paths.get(sfDir, "default");
+ java.nio.file.Path dict = slot.resolve(".symbol-dict");
+ java.nio.file.Files.delete(dict);
+ java.nio.file.Files.createDirectory(dict);
+ writeAckWatermark(slot.resolve(".ack-watermark"), 2);
+
+ // Phase 2: recover against a fresh counting server. The guard must fire
+ // (frame deltaStart 3 > recovered dictionary size 0) and fail terminally
+ // rather than send a gapped frame that would corrupt the table.
+ CountingHandler handler = new CountingHandler();
+ 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 + ";";
+ LineSenderException terminal = null;
+ Sender s2 = Sender.fromConfig(cfg);
+ try {
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (System.currentTimeMillis() < deadline && terminal == null) {
+ try {
+ s2.flush();
+ Thread.sleep(20);
+ } catch (LineSenderException e) {
+ terminal = e;
+ }
+ }
+ } finally {
+ try {
+ s2.close();
+ } catch (LineSenderException e) {
+ if (terminal == null) {
+ terminal = e;
+ }
+ }
+ }
+ Assert.assertEquals("no delta frame may be replayed when the persisted dictionary is unopenable",
+ 0, handler.frames.get());
+ Assert.assertNotNull("an unopenable dictionary must surface a terminal error", terminal);
+ Assert.assertTrue(terminal.getMessage(),
+ terminal.getMessage().contains("symbol dictionary is incomplete"));
+ }
+ });
+ }
+
+ @Test
+ public void testCommitMessageDoesNotShipUnpersistedLeakedSymbol() throws Exception {
+ // C3 regression: sendCommitMessage does NOT write-ahead-persist the
+ // dictionary, so its frame must carry NO new symbol. A symbol left in the
+ // batch by a cancelled row -- cancelRow rolls back neither
+ // currentBatchMaxSymbolId nor the global-dictionary registration -- must not
+ // ride out on the commit frame: doing so puts an id on the wire that a
+ // recovered slot cannot rebuild from .symbol-dict, diverging the producer
+ // dictionary from the surviving frames and silently misattributing reused
+ // ids after a crash. The commit's delta must be bounded by sentMaxSymbolId
+ // (empty here), not currentBatchMaxSymbolId. Memory mode suffices to observe
+ // the wire behaviour; close() drains every frame to the server first.
+ assertMemoryLeak(() -> {
+ DictReconstructingHandler handler = new DictReconstructingHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ // Transactional + autoFlushRows=1: every completed row auto-flushes
+ // as a DEFERRED batch (setting hasDeferredMessages); an explicit
+ // flush() then emits the commit message.
+ Sender sender = Sender.builder("ws::addr=localhost:" + port + ";")
+ .transactional(true)
+ .autoFlushRows(1)
+ .build();
+ try {
+ // Row 1 registers "a"@0 and auto-flushes it deferred.
+ sender.table("m").symbol("s", "a").longColumn("v", 1L).atNow();
+ // Register "b"@1 on a row that is then cancelled: "b" stays in
+ // the global dictionary and currentBatchMaxSymbolId advances to
+ // 1, but nothing persists or sends it.
+ sender.table("m").symbol("s", "b");
+ sender.cancelRow();
+ // Commit the deferred batch. The commit frame must carry an
+ // EMPTY delta -- NOT "b"@1.
+ sender.flush();
+ } finally {
+ sender.close(); // drains every frame (incl. the commit) to the server
+ }
+
+ // The server's reconstructed dictionary must hold ONLY "a". Pre-fix
+ // the commit shipped "b"@1, so the server saw a second symbol.
+ List dict = handler.dictSnapshot();
+ Assert.assertEquals("commit frame must not ship the cancelled row's leaked symbol "
+ + "(recovery would then diverge from the persisted dictionary): " + dict,
+ 1, dict.size());
+ Assert.assertEquals("a", dict.get(0));
+ }
+ });
+ }
+
+ @Test
+ public void testFailedPublishDoesNotDuplicatePersistedSymbols() throws Exception {
+ // Regression: persistNewSymbolsBeforePublish is a write-ahead -- it runs
+ // BEFORE the frame is published (sealAndSwapBuffer -> appendBlocking). If
+ // publish fails (here PAYLOAD_TOO_LARGE, a frame bigger than the SF
+ // segment; a backpressure deadline in production), the frame's symbols are
+ // already on disk but sentMaxSymbolId is NOT advanced and the rows stay
+ // buffered -- so a retry re-runs the persist. Keying the persist range off
+ // pd.size() (not sentMaxSymbolId+1) makes it idempotent. Before that fix,
+ // the retry appended the symbol a SECOND time, breaking the dense
+ // id->position invariant; on recovery every later global id shifts by one
+ // and symbol column values are silently misattributed.
+ assertMemoryLeak(() -> {
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+
+ // Small segment; a heavily padded row's frame cannot fit, so
+ // appendBlocking throws PAYLOAD_TOO_LARGE deterministically -- no
+ // backpressure timing needed. The server never acks (SilentHandler).
+ String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";sf_max_bytes=1024;";
+ String pad = TestUtils.repeat("x", 2000); // frame >> 1024-byte segment
+ Sender sender = Sender.fromConfig(cfg);
+ try {
+ // Buffer ONE new-symbol row, then flush it TWICE. Each flush
+ // runs the write-ahead persist and then fails to publish; the
+ // failed flush leaves the row buffered, so the second flush is
+ // the retry that (pre-fix) duplicated the persisted symbol.
+ sender.table("m").symbol("s", "s0").stringColumn("p", pad).longColumn("v", 1L).atNow();
+ for (int attempt = 0; attempt < 2; attempt++) {
+ try {
+ sender.flush();
+ Assert.fail("oversized frame must fail to publish");
+ } catch (LineSenderException expected) {
+ // frame too large -- expected on every attempt
+ }
+ }
+
+ // The persisted dictionary must hold "s0" EXACTLY ONCE.
+ // Pre-fix, the retry duplicated it (size == 2).
+ PersistedSymbolDict pd = PersistedSymbolDict.open(Paths.get(sfDir, "default").toString());
+ Assert.assertNotNull(pd);
+ try {
+ Assert.assertEquals("failed-publish retry must not duplicate the persisted symbol",
+ 1, pd.size());
+ Assert.assertEquals("s0", pd.readLoadedSymbols().getQuick(0));
+ } finally {
+ pd.close();
+ }
+ } finally {
+ try {
+ sender.close();
+ } catch (LineSenderException ignored) {
+ // close() re-flushes the still-buffered oversized row and
+ // fails again (PAYLOAD_TOO_LARGE); expected here and not
+ // what we assert. close() still runs its resource cleanup,
+ // so no native memory leaks.
+ }
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testFailedPublishThenNewSymbolPersistsSuffixWithoutDuplicating() throws Exception {
+ // Regression for the re-encode (else) branch of persistNewSymbolsBeforePublish.
+ // After a failed publish leaves the durable dictionary size ahead of the wire
+ // baseline (pd.size() > sentMaxSymbolId+1), a later flush that introduces a NEW
+ // symbol cannot reuse the frame's already-encoded fast-path bytes -- it
+ // re-encodes just the [pd.size() .. currentBatchMaxSymbolId] suffix via
+ // appendSymbols. Keying that off pd.size() (not sentMaxSymbolId+1) keeps it
+ // idempotent: the already-persisted prefix is NOT re-appended.
+ assertMemoryLeak(() -> {
+ try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
+ int port = silent.getPort();
+ silent.start();
+ Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
+ // Small segment: any frame carrying the padded row fails to publish with
+ // PAYLOAD_TOO_LARGE deterministically (no backpressure timing).
+ String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";sf_max_bytes=1024;";
+ String pad = TestUtils.repeat("x", 2000); // frame >> 1024-byte segment
+ Sender sender = Sender.fromConfig(cfg);
+ try {
+ // Flush 1: s0 is persisted (write-ahead) before the oversized frame
+ // fails to publish. pd.size()=1, sentMaxSymbolId stays -1.
+ sender.table("m").symbol("s", "s0").stringColumn("p", pad).longColumn("v", 1L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("oversized frame must fail to publish");
+ } catch (LineSenderException expected) {
+ }
+
+ // Add a NEW symbol s1 (id 1). The failed s0 row is still buffered, so
+ // the batch is {s0, s1} and the durable size (1) has run ahead of the
+ // wire baseline (-1) -- the state that selects the appendSymbols branch.
+ sender.table("m").symbol("s", "s1").stringColumn("p", pad).longColumn("v", 2L).atNow();
+ try {
+ sender.flush();
+ Assert.fail("oversized frame must fail to publish");
+ } catch (LineSenderException expected) {
+ }
+
+ // The else branch persisted ONLY s1 (the suffix). The dictionary holds
+ // s0, s1 exactly once each. Pre-fix (appendSymbols from
+ // sentMaxSymbolId+1) re-appended s0, giving size 3.
+ PersistedSymbolDict pd = PersistedSymbolDict.open(Paths.get(sfDir, "default").toString());
+ Assert.assertNotNull(pd);
+ try {
+ Assert.assertEquals("re-encode suffix must not duplicate the persisted prefix",
+ 2, pd.size());
+ Assert.assertEquals("s0", pd.readLoadedSymbols().getQuick(0));
+ Assert.assertEquals("s1", pd.readLoadedSymbols().getQuick(1));
+ } finally {
+ pd.close();
+ }
+ } finally {
+ try {
+ sender.close();
+ } catch (LineSenderException ignored) {
+ // close() re-flushes the still-buffered oversized rows and fails
+ // again (PAYLOAD_TOO_LARGE); expected, resources still released.
+ }
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testRecoveryAfterFailedPublishReplaysGapFree() throws Exception {
+ // M3 end-to-end: chains a failed publish -> fresh-process recovery -> replay.
+ // A failed publish persists the frame's symbol (write-ahead) but does NOT
+ // record the frame, so the persisted dictionary becomes a strict SUPERSET of
+ // the recorded frames' references. A recovering sender must still replay
+ // gap-free: it re-registers the whole (superset) dictionary via the catch-up
+ // -- including the symbol whose frame never reached disk -- so the fresh
+ // server reconstructs a complete, gap-free dictionary. The sibling
+ // testFailedPublishDoesNotDuplicatePersistedSymbols proves the dict has no
+ // duplicate after the failed publish; this proves the resulting slot then
+ // recovers and replays end-to-end against a real server.
+ assertMemoryLeak(() -> {
+ // Phase 1: a silent server (no acks) + a small SF segment. Four small
+ // rows register sym-0..sym-3 and their frames are recorded; a fifth,
+ // oversized row registers (persists) sym-4 but its frame is too large for
+ // the segment, so appendBlocking throws and the frame is NOT recorded.
+ 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_bytes=4096"
+ + ";close_flush_timeout_millis=0;";
+ Sender s1 = Sender.fromConfig(cfg);
+ try {
+ for (int i = 0; i < 4; i++) {
+ s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
+ s1.flush();
+ }
+ // Oversized frame: sym-4 is persisted (write-ahead) before the
+ // publish fails, so the dictionary runs one ahead of the frames.
+ s1.table("m").symbol("s", "sym-4")
+ .stringColumn("p", TestUtils.repeat("x", 8000))
+ .longColumn("v", 4).atNow();
+ try {
+ s1.flush();
+ Assert.fail("oversized frame must fail to publish");
+ } catch (LineSenderException expected) {
+ // PAYLOAD_TOO_LARGE -- frame not recorded, sym-4 stays persisted
+ }
+ } finally {
+ try {
+ // Re-flushes the still-buffered oversized row and fails again
+ // (expected); resources are still released, and the idempotent
+ // write-ahead does not re-append sym-4.
+ s1.close();
+ } catch (LineSenderException ignored) {
+ }
+ }
+ }
+
+ // The persisted dictionary must hold the superset: sym-0..sym-4 (5 ids),
+ // one more than the four recorded frames reference, with no duplicate.
+ PersistedSymbolDict pd = PersistedSymbolDict.open(Paths.get(sfDir, "default").toString());
+ Assert.assertNotNull(pd);
+ try {
+ Assert.assertEquals("failed publish must leave the dict a superset (sym-0..sym-4)",
+ 5, pd.size());
+ } finally {
+ pd.close();
+ }
+
+ // Phase 2: recover against a fresh server that reconstructs its
+ // dictionary from the wire. The recovering sender must re-register all 5
+ // symbols via a catch-up (sym-4 exists ONLY in the dictionary -- no frame
+ // carries it) and replay the 4 recorded frames, leaving a complete,
+ // gap-free server dictionary.
+ 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() < 5) {
+ Thread.sleep(20);
+ }
+ }
+ Assert.assertTrue("recovery must send a full-dictionary catch-up frame",
+ handler.sawCatchUpFrame);
+ List dict = handler.dictSnapshot();
+ Assert.assertEquals("recovered dictionary must include the failed-publish symbol",
+ 5, dict.size());
+ for (int i = 0; i < 5; i++) {
+ Assert.assertEquals("dictionary id " + i + " must be gap-free",
+ "sym-" + i, dict.get(i));
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testRecoverySeedKeepsUtf8CollidingSymbolsInLockstep() throws Exception {
+ // M2 regression: two DISTINCT source symbols that collapse to the SAME UTF-8
+ // bytes -- lone UTF-16 surrogates, which the encoder maps to '?' -- get
+ // distinct producer ids and persist as separate entries. On recovery the
+ // producer must rebuild its id space to match the persisted entry count
+ // exactly. Pre-fix, seedGlobalDictionaryFromPersisted used getOrAddSymbol,
+ // which de-duped the two decoded "?" strings, leaving the producer
+ // dictionary (and sentMaxSymbolId) one short of pd.size() -- desyncing from
+ // the send-loop catch-up mirror (which uses pd.size()) and silently
+ // misattributing later symbols after a reconnect. addRecoveredSymbol appends
+ // without de-duping, keeping producer and mirror in lockstep.
+ assertMemoryLeak(() -> {
+ // Phase 1: ingest two lone-surrogate symbols in file mode, close-fast.
+ 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)) {
+ s1.table("m").symbol("s", "\uD800").longColumn("v", 1L).atNow(); // lone surrogate -> '?'
+ s1.flush();
+ s1.table("m").symbol("s", "\uD801").longColumn("v", 2L).atNow(); // a DIFFERENT one -> '?'
+ s1.flush();
+ }
+ }
+
+ // The persisted dictionary holds TWO entries (both encode to '?').
+ int persistedSize;
+ PersistedSymbolDict pd = PersistedSymbolDict.open(Paths.get(sfDir, "default").toString());
+ Assert.assertNotNull(pd);
+ try {
+ persistedSize = pd.size();
+ } finally {
+ pd.close();
+ }
+ Assert.assertEquals("two colliding symbols must persist as two entries", 2, persistedSize);
+
+ // Phase 2: recover. The seeded producer id space must match pd.size().
+ 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)) {
+ Assert.assertEquals("recovered producer dictionary must match the persisted "
+ + "entry count (not de-duped), else the delta baseline desyncs from the mirror",
+ persistedSize, globalDictSize(s2));
+ Assert.assertEquals("delta baseline must resume at the persisted tip",
+ persistedSize - 1, intField(s2, "sentMaxSymbolId"));
+ }
+ }
+ });
+ }
+
+ private static int globalDictSize(Sender sender) throws Exception {
+ Field f = sender.getClass().getDeclaredField("globalSymbolDictionary");
+ f.setAccessible(true);
+ Object dict = f.get(sender);
+ return (int) dict.getClass().getMethod("size").invoke(dict);
+ }
+
+ private static int intField(Sender sender, String name) throws Exception {
+ Field f = sender.getClass().getDeclaredField(name);
+ f.setAccessible(true);
+ return f.getInt(sender);
+ }
+
+ private static void writeAckWatermark(java.nio.file.Path path, long fsn) throws IOException {
+ byte[] buf = new byte[16];
+ ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
+ bb.putInt(0x31574B41); // 'AKW1'
+ bb.putInt(0); // reserved
+ bb.putLong(fsn);
+ java.nio.file.Files.write(path, buf);
+ }
+
+ private static int readVarint(byte[] buf, int[] pos) {
+ int result = 0;
+ int shift = 0;
+ while (pos[0] < buf.length) {
+ int b = buf[pos[0]++] & 0xFF;
+ result |= (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) {
+ return result;
+ }
+ shift += 7;
+ if (shift > 28) {
+ throw new IllegalStateException("varint too long");
+ }
+ }
+ throw new IllegalStateException("varint truncated");
+ }
+
+ 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);
+ }
+
+ /**
+ * Reconstructs the per-connection symbol dictionary from delta sections,
+ * mirroring the server's {@code setQuick(deltaStart + i)} + null-padding.
+ */
+ private static class DictReconstructingHandler implements TestWebSocketServer.WebSocketServerHandler {
+ volatile boolean sawCatchUpFrame;
+ private final List dict = new ArrayList<>();
+ private final AtomicLong nextSeq = new AtomicLong(0);
+ private TestWebSocketServer.ClientHandler currentClient;
+
+ synchronized List dictSnapshot() {
+ return new ArrayList<>(dict);
+ }
+
+ synchronized int maxDictSize() {
+ return dict.size();
+ }
+
+ @Override
+ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ if (currentClient != client) {
+ currentClient = client;
+ dict.clear(); // fresh server dictionary per connection
+ }
+ accumulate(data);
+ if (tableCount(data) == 0 && hasDelta(data)) {
+ sawCatchUpFrame = true;
+ }
+ try {
+ client.sendBinary(buildAck(nextSeq.getAndIncrement()));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static byte[] buildAck(long seq) {
+ byte[] buf = new byte[1 + 8 + 2];
+ ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
+ bb.put((byte) 0x00);
+ bb.putLong(seq);
+ bb.putShort((short) 0);
+ return buf;
+ }
+
+ private static boolean hasDelta(byte[] frame) {
+ return frame.length >= 12 && (frame[5] & 0x08) != 0;
+ }
+
+ private static int tableCount(byte[] frame) {
+ return (frame[6] & 0xFF) | ((frame[7] & 0xFF) << 8);
+ }
+
+ private void accumulate(byte[] frame) {
+ if (!hasDelta(frame)) {
+ return;
+ }
+ int[] pos = {12};
+ int deltaStart = readVarint(frame, pos);
+ int deltaCount = readVarint(frame, pos);
+ while (dict.size() < deltaStart) {
+ dict.add(null);
+ }
+ for (int i = 0; i < deltaCount; i++) {
+ int len = readVarint(frame, pos);
+ String sym = new String(frame, pos[0], len, StandardCharsets.UTF_8);
+ pos[0] += len;
+ int idx = deltaStart + i;
+ while (dict.size() <= idx) {
+ dict.add(null);
+ }
+ dict.set(idx, sym);
+ }
+ }
+ }
+
+ private static class SilentHandler implements TestWebSocketServer.WebSocketServerHandler {
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ // never acks -- sender leaves everything unacked in the slot
+ }
+ }
+
+ /** Counts every binary frame it receives and acks it. */
+ private static class CountingHandler implements TestWebSocketServer.WebSocketServerHandler {
+ final AtomicInteger frames = new AtomicInteger();
+ private final AtomicLong nextSeq = new AtomicLong(0);
+
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ frames.incrementAndGet();
+ try {
+ client.sendBinary(buildAck(nextSeq.getAndIncrement()));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static byte[] buildAck(long seq) {
+ byte[] buf = new byte[1 + 8 + 2];
+ ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
+ bb.put((byte) 0x00);
+ bb.putLong(seq);
+ bb.putShort((short) 0);
+ return buf;
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java
index b8945d40..2f38ab02 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java
@@ -31,6 +31,36 @@
public class GlobalSymbolDictionaryTest {
+ @Test
+ public void testAddRecoveredSymbol_appendsWithoutDeduplicating() {
+ // Recovery replays persisted entries in id order. Distinct source strings
+ // that decode to the same characters -- lone UTF-16 surrogates both
+ // UTF-8-encode to '?', so they read back as the string "?" -- must keep
+ // DISTINCT ids, so the producer id space matches the persisted entry count.
+ // getOrAddSymbol de-dups them; addRecoveredSymbol must not.
+ GlobalSymbolDictionary dedup = new GlobalSymbolDictionary();
+ dedup.getOrAddSymbol("?");
+ dedup.getOrAddSymbol("?");
+ assertEquals("getOrAddSymbol de-dups colliding strings", 1, dedup.size());
+
+ GlobalSymbolDictionary recovered = new GlobalSymbolDictionary();
+ assertEquals(0, recovered.addRecoveredSymbol("?"));
+ assertEquals(1, recovered.addRecoveredSymbol("?"));
+ assertEquals(2, recovered.addRecoveredSymbol("nvda"));
+ assertEquals("addRecoveredSymbol keeps colliding entries distinct", 3, recovered.size());
+
+ // Dense id -> symbol mapping is preserved position-for-position.
+ assertEquals("?", recovered.getSymbol(0));
+ assertEquals("?", recovered.getSymbol(1));
+ assertEquals("nvda", recovered.getSymbol(2));
+
+ // A later ingest of a colliding string reuses the highest recovered id
+ // (harmless -- both encode to identical bytes), and a genuinely new symbol
+ // continues past the recovered tip.
+ assertEquals(1, recovered.getOrAddSymbol("?"));
+ assertEquals(3, recovered.getOrAddSymbol("brand-new"));
+ }
+
@Test
public void testAddSymbol_assignsSequentialIds() {
GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/ReconnectTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/ReconnectTest.java
index 36553388..7ba7a1fe 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/ReconnectTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/ReconnectTest.java
@@ -52,9 +52,10 @@
* disconnect = sender broken, every subsequent batch threw. Reconnect
* machinery now handles transient drops: detect, build a fresh client
* via the registered factory, reset wire state, and reposition the replay
- * cursor at {@code engine.ackedFsn() + 1}. Cursor frames are self-sufficient
- * (every frame carries full schema + full symbol-dict delta), so post-reconnect
- * replay needs no producer-side schema-reset signal.
+ * cursor at {@code engine.ackedFsn() + 1}. Every frame carries its full schema
+ * inline; the symbol dictionary is either shipped in full per frame (file-mode
+ * store-and-forward) or re-registered by an I/O-thread catch-up frame before
+ * replay (memory-mode), so post-reconnect replay needs no producer-side reset.
*
* This commit covers the mechanics with a single-attempt retry; backoff,
* per-outage time cap, and auth-failure detection follow.
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SelfSufficientFramesTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SelfSufficientFramesTest.java
index 45275528..812a5317 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SelfSufficientFramesTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SelfSufficientFramesTest.java
@@ -25,6 +25,7 @@
package io.questdb.client.test.cutlass.qwp.client;
import io.questdb.client.Sender;
+import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
import org.junit.Assert;
import org.junit.Test;
@@ -32,23 +33,29 @@
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Stream;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
/**
- * Pins down the "every frame on disk is self-sufficient" rule for the symbol
- * dictionary.
+ * Pins down how the symbol dictionary is framed on the wire.
*
- * 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 {
@@ -56,58 +63,276 @@ public class SelfSufficientFramesTest {
private static final int DELTA_START_OFFSET = 12;
@Test
- public void testEverySymbolBatchIncludesFullDeltaFromZero() throws Exception {
- // Send two batches against the same connection, each with a
- // distinct symbol value. With the old schema-ref/delta encoding,
- // batch 2 would emit deltaStart=1, deltaCount=1 — only the new
- // symbol. With self-sufficient frames, batch 2 must emit
- // deltaStart=0 covering BOTH symbols.
- CapturingHandler handler = new CapturingHandler();
- 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("foo").symbol("s", "alpha").longColumn("v", 1L).atNow();
- sender.flush();
- waitFor(() -> handler.batches.size() >= 1, 5_000);
-
- sender.table("foo").symbol("s", "beta").longColumn("v", 2L).atNow();
- sender.flush();
- waitFor(() -> handler.batches.size() >= 2, 5_000);
- }
+ 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 = Files.createTempDirectory("qwp-sf-selfsufficient");
+ try {
+ 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/ (default "default").
+ Path dictFile = sfDir.resolve("default").resolve(".symbol-dict");
+ try (Sender sender = Sender.fromConfig(config)) {
+ sender.table("foo").symbol("s", "alpha").longColumn("v", 1L).atNow();
+ sender.flush();
+ waitFor(() -> handler.batches.size() >= 1, 5_000);
+
+ sender.table("foo").symbol("s", "beta").longColumn("v", 2L).atNow();
+ sender.flush();
+ waitFor(() -> handler.batches.size() >= 2, 5_000);
+
+ // Check the persisted dictionary while the sender is live: a
+ // fully-drained close intentionally unlinks it (slot cleanup).
+ Assert.assertTrue("persisted dictionary file exists", Files.exists(dictFile));
+ byte[] dict = Files.readAllBytes(dictFile);
+ Assert.assertTrue("dictionary retains alpha", containsUtf8(dict, "alpha"));
+ Assert.assertTrue("dictionary retains beta", containsUtf8(dict, "beta"));
+ }
+
+ Assert.assertEquals("expected 2 captured batches", 2, handler.batches.size());
+ byte[] b1 = handler.batches.get(0);
+ byte[] b2 = handler.batches.get(1);
- Assert.assertEquals("expected 2 captured batches", 2, handler.batches.size());
- byte[] b1 = handler.batches.get(0);
- byte[] b2 = handler.batches.get(1);
-
- // The deltaStart varint sits right after the 12-byte header.
- // For self-sufficient frames it must be 0 (single byte 0x00)
- // in BOTH batches — regardless of how many symbols the prior
- // batch already shipped.
- int deltaStart1 = readVarint(b1, DELTA_START_OFFSET);
- int deltaStart2 = readVarint(b2, DELTA_START_OFFSET);
- Assert.assertEquals("batch 1 deltaStart must be 0", 0, deltaStart1);
- Assert.assertEquals("batch 2 deltaStart must be 0 (self-sufficient)",
- 0, deltaStart2);
-
- // batch 2 must include >= 2 symbols in its delta dict (alpha
- // from the prior batch + beta from this one). The varint at
- // DELTA_START_OFFSET+1 is deltaCount.
- int deltaCount2 = readVarint(b2, DELTA_START_OFFSET + 1);
- Assert.assertTrue("batch 2 must redefine at least 2 symbols, got " + deltaCount2,
- deltaCount2 >= 2);
-
- // Sanity: batch 2 should NOT be much smaller than batch 1 —
- // with schema-ref/delta encoding it would have been; with
- // self-sufficient frames the size is in the same ballpark.
- Assert.assertTrue("batch 2 (" + b2.length + " bytes) must not be drastically smaller than batch 1 ("
- + b1.length + ")",
- b2.length >= b1.length / 2);
+ Assert.assertEquals("batch 1 deltaStart must be 0",
+ 0, readVarint(b1, DELTA_START_OFFSET));
+ Assert.assertEquals("batch 1 deltaCount must be 1", 1, readVarint(b1, DELTA_START_OFFSET + 1));
+ // batch 2 ships ONLY beta as a delta from id 1.
+ Assert.assertEquals("batch 2 deltaStart must be 1 (monotonic)",
+ 1, readVarint(b2, DELTA_START_OFFSET));
+ Assert.assertEquals("batch 2 deltaCount must be 1 (only the new symbol)",
+ 1, readVarint(b2, DELTA_START_OFFSET + 1));
+ }
+ });
+ } finally {
+ rmDir(sfDir);
}
}
+ @Test
+ public void testDiskModeFallsBackToFullDictWhenPersistedDictUnopenable() throws Exception {
+ // When the per-slot .symbol-dict cannot be opened in disk mode,
+ // isDeltaDictEnabled() is false and the sender must fall back to
+ // self-sufficient frames: every batch re-ships the WHOLE dictionary from
+ // id 0. A recovered / orphan-drained slot then has no dictionary to
+ // rebuild deltas from, so a monotonic delta would dangle ids on the fresh
+ // server -- the full-dict frame is the safe degradation. Force the open
+ // failure by planting a DIRECTORY where the dictionary file belongs:
+ // openRW / openCleanRW on a directory fails, so open() returns null.
+ Path sfDir = Files.createTempDirectory("qwp-sf-fallback");
+ Path dictPath = sfDir.resolve("default").resolve(".symbol-dict");
+ Files.createDirectories(dictPath); // a directory, not a file
+ Files.createFile(dictPath.resolve("blocker")); // non-empty: cannot be unlinked/rmdir'd
+ try {
+ 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 + ";";
+ try (Sender sender = Sender.fromConfig(config)) {
+ sender.table("foo").symbol("s", "alpha").longColumn("v", 1L).atNow();
+ sender.flush();
+ waitFor(() -> handler.batches.size() >= 1, 5_000);
+
+ sender.table("foo").symbol("s", "beta").longColumn("v", 2L).atNow();
+ sender.flush();
+ waitFor(() -> handler.batches.size() >= 2, 5_000);
+ }
+
+ // The planted directory is untouched -- the dictionary never
+ // opened, so delta encoding stayed disabled.
+ Assert.assertTrue("planted .symbol-dict directory must remain (open failed)",
+ Files.isDirectory(dictPath));
+
+ Assert.assertEquals("expected 2 captured batches", 2, handler.batches.size());
+ byte[] b1 = handler.batches.get(0);
+ byte[] b2 = handler.batches.get(1);
+
+ // Full-dict fallback: BOTH batches start at id 0, and batch 2
+ // re-ships the WHOLE dictionary (alpha + beta), NOT a monotonic
+ // delta (which would be deltaStart=1, deltaCount=1 as above).
+ Assert.assertEquals("batch 1 deltaStart must be 0",
+ 0, readVarint(b1, DELTA_START_OFFSET));
+ Assert.assertEquals("batch 1 deltaCount must be 1",
+ 1, readVarint(b1, DELTA_START_OFFSET + 1));
+ Assert.assertEquals("batch 2 deltaStart must be 0 (full-dict fallback, not monotonic)",
+ 0, readVarint(b2, DELTA_START_OFFSET));
+ Assert.assertEquals("batch 2 deltaCount must be 2 (whole dictionary re-shipped)",
+ 2, readVarint(b2, DELTA_START_OFFSET + 1));
+ }
+ });
+ } finally {
+ rmDir(sfDir);
+ }
+ }
+
+ private static boolean containsUtf8(byte[] haystack, String needle) {
+ byte[] n = needle.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ outer:
+ for (int i = 0; i + n.length <= haystack.length; i++) {
+ for (int j = 0; j < n.length; j++) {
+ if (haystack[i + j] != n[j]) {
+ continue outer;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Test
+ public void testMemoryModeShipsMonotonicDelta() throws Exception {
+ // Memory-mode (no sf_dir): each symbol id ships once. Batch 2 carries
+ // only "beta" as a delta starting at id 1, not the whole dictionary.
+ assertMemoryLeak(() -> {
+ CapturingHandler handler = new CapturingHandler();
+ 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("foo").symbol("s", "alpha").longColumn("v", 1L).atNow();
+ sender.flush();
+ waitFor(() -> handler.batches.size() >= 1, 5_000);
+
+ sender.table("foo").symbol("s", "beta").longColumn("v", 2L).atNow();
+ sender.flush();
+ waitFor(() -> handler.batches.size() >= 2, 5_000);
+ }
+
+ Assert.assertEquals("expected 2 captured batches", 2, handler.batches.size());
+ byte[] b1 = handler.batches.get(0);
+ byte[] b2 = handler.batches.get(1);
+
+ // Batch 1 introduces alpha at id 0.
+ Assert.assertEquals("batch 1 deltaStart must be 0",
+ 0, readVarint(b1, DELTA_START_OFFSET));
+ Assert.assertEquals("batch 1 deltaCount must be 1",
+ 1, readVarint(b1, DELTA_START_OFFSET + 1));
+
+ // Batch 2 ships ONLY beta as a delta from id 1.
+ Assert.assertEquals("batch 2 deltaStart must be 1 (monotonic)",
+ 1, readVarint(b2, DELTA_START_OFFSET));
+ Assert.assertEquals("batch 2 deltaCount must be 1 (only the new symbol)",
+ 1, readVarint(b2, DELTA_START_OFFSET + 1));
+ }
+ });
+ }
+
+ @Test
+ public void testSplitBatchShipsDeltaOnFirstFrameOnly() throws Exception {
+ // A single flush whose encoded size exceeds the server's batch cap is
+ // split into one frame per table (flushPendingRowsSplit). The FIRST split
+ // frame carries the whole batch's symbol-dict delta and advances the
+ // baseline; the remaining frames carry an EMPTY delta and only reference
+ // ids the first frame already registered. A regression that shipped each
+ // table's own symbols (wrong deltaStart) would dangle ids on the server.
+ assertMemoryLeak(() -> {
+ CapturingHandler handler = new CapturingHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ server.setAdvertisedMaxBatchSize(150); // forces the two-table batch to split
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ // Padding inflates each table past half the cap, so the combined
+ // two-table message exceeds it while each single-table frame fits.
+ String pad = new String(new char[60]).replace('\0', 'x');
+ try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + port + ";")) {
+ // Buffer TWO tables (symbols "a" id 0, "b" id 1), then ONE flush.
+ sender.table("t1").symbol("s", "a").stringColumn("p", pad).longColumn("v", 1L).atNow();
+ sender.table("t2").symbol("s", "b").stringColumn("p", pad).longColumn("v", 2L).atNow();
+ sender.flush();
+ waitFor(() -> handler.batches.size() >= 2, 5_000);
+ }
+
+ Assert.assertEquals("the oversized two-table batch must split into 2 frames",
+ 2, handler.batches.size());
+ byte[] f1 = handler.batches.get(0);
+ byte[] f2 = handler.batches.get(1);
+
+ // First split frame ships the whole batch's dictionary (a + b).
+ Assert.assertEquals("first split frame deltaStart must be 0",
+ 0, readVarint(f1, DELTA_START_OFFSET));
+ Assert.assertEquals("first split frame ships both new symbols",
+ 2, readVarint(f1, DELTA_START_OFFSET + 1));
+ // Second split frame carries an empty delta above the advanced baseline.
+ Assert.assertEquals("second split frame deltaStart must be 2 (baseline advanced)",
+ 2, readVarint(f2, DELTA_START_OFFSET));
+ Assert.assertEquals("second split frame carries no new symbols",
+ 0, readVarint(f2, DELTA_START_OFFSET + 1));
+ }
+ });
+ }
+
+ @Test
+ public void testOversizedTableSplitStrandsNothing() throws Exception {
+ // Regression: flushPendingRowsSplit publishes each table's frame one at a
+ // time (all but the last deferred, i.e. appended but uncommitted). If a LATER
+ // table's frame exceeds the cap, the split must not have already published an
+ // earlier table's frame -- otherwise that prefix strands on the ring, a later
+ // commit delivers it as a partial batch, and resetTableBuffersAfterFlush
+ // discards every source row, all while flush() reported failure to the
+ // caller. The pre-flight size pass makes the split all-or-nothing: an
+ // oversized table throws BEFORE any frame is published. Pre-fix, the "small"
+ // table's frame was published and committed on close, so the server saw it.
+ assertMemoryLeak(() -> {
+ CapturingHandler handler = new CapturingHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ server.setAdvertisedMaxBatchSize(200);
+ int port = server.getPort();
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ // auto_flush_bytes=off lets "big" accumulate PAST the cap (byte-based
+ // auto-flush is otherwise clamped to 90% of the cap and would flush
+ // first); the row/interval limits are set high so nothing auto-flushes
+ // during the test. Each row stays under the per-row guard (< cap), but
+ // 12 rows make "big"'s single frame exceed the cap, which no split can
+ // shrink. "small" (added first) fits; "big" (added second) does not, so
+ // the split hits it AFTER publishing "small" pre-fix.
+ String pad = new String(new char[40]).replace('\0', 'x');
+ try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + port
+ + ";auto_flush_bytes=off;auto_flush_rows=1000000;auto_flush_interval=60000;")) {
+ sender.table("small").symbol("s", "a").longColumn("v", 1L).atNow();
+ for (int i = 0; i < 12; i++) {
+ sender.table("big").stringColumn("p", pad).longColumn("v", (long) i).atNow();
+ }
+ try {
+ sender.flush();
+ Assert.fail("an oversized single-table split frame must throw");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage(),
+ e.getMessage().contains("too large for server batch cap"));
+ }
+ // close() drains the ring: pre-fix, the stranded "small" frame
+ // would be sent (and committed) here.
+ }
+
+ // No DATA frame (tableCount > 0) may have reached the server: the
+ // oversized-table split published nothing. Pre-fix, "small" arrived.
+ long dataFrames = 0;
+ for (byte[] frame : handler.batches) {
+ if (frame.length >= 8 && (((frame[6] & 0xFF) | ((frame[7] & 0xFF) << 8)) > 0)) {
+ dataFrames++;
+ }
+ }
+ Assert.assertEquals("an oversized-table split must publish NO data frame -- an "
+ + "earlier table's frame must not strand on the ring", 0, dataFrames);
+ }
+ });
+ }
+
private static int readVarint(byte[] buf, int offset) {
// Simple unsigned varint decode — sufficient for small values.
int result = 0;
@@ -122,6 +347,28 @@ private static int readVarint(byte[] buf, int offset) {
throw new IllegalStateException("varint truncated");
}
+ private static void rmDir(Path dir) {
+ try {
+ if (dir == null || !Files.exists(dir)) {
+ return;
+ }
+ // try-with-resources: Files.walk returns a Stream backed by an open
+ // directory handle that must be closed, or each rmDir leaks a descriptor.
+ try (Stream walk = Files.walk(dir)) {
+ walk.sorted(Comparator.reverseOrder())
+ .forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ } catch (IOException ignored) {
+ // best-effort
+ }
+ });
+ }
+ } catch (IOException ignored) {
+ // best-effort
+ }
+ }
+
private static void waitFor(BoolCondition cond, long timeoutMillis) {
long deadline = System.currentTimeMillis() + timeoutMillis;
while (System.currentTimeMillis() < deadline) {
@@ -157,6 +404,7 @@ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] dat
}
}
+ // Mirrors WebSocketResponse STATUS_OK layout: status u8 | sequence u64 | table_count u16
static byte[] buildAck(long seq) {
byte[] buf = new byte[1 + 8 + 2];
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java
new file mode 100644
index 00000000..3814d826
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java
@@ -0,0 +1,485 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.DefaultHttpClientConfiguration;
+import io.questdb.client.cutlass.http.client.WebSocketClient;
+import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
+import io.questdb.client.network.PlainSocketFactory;
+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.Before;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Guards the reconnect/failover symbol-dictionary catch-up ACK alignment in
+ * {@link CursorWebSocketSendLoop#setWireBaselineWithCatchUp}.
+ *
+ * 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, readLong(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, readLong(loop, "fsnAtZero"));
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testTransientCatchUpSendFailureIsRetriableNotTerminal() throws Exception {
+ // A transient wire failure WHILE shipping the catch-up (the fresh
+ // connection drops mid-handshake) must surface as a retriable
+ // CatchUpSendException for the reconnect loop to handle -- it must NOT
+ // call fail(). From inside the catch-up fail() re-enters connectLoop
+ // (corrupting the fsnAtZero/nextWireSeq mapping, or overflowing the stack
+ // on a flapping connection) or, with no reconnect attempt reachable,
+ // latches a terminal -- turning a transient outage into a hard failure and
+ // breaking store-and-forward. Only the oversized-entry (non-retriable)
+ // terminal was covered; this pins the retriable path.
+ TestUtils.assertMemoryLeak(() -> {
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0, true); // sendBinary throws
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 2);
+ engine.acknowledge(0); // ackedFsn=0 => a real unacked frame exists behind the catch-up
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ seedMirror(loop, "s0", "s1"); // non-empty dict => catch-up fires and hits the failing send
+ try {
+ invokeSetWireBaselineWithCatchUp(loop, engine.ackedFsn() + 1L);
+ fail("a transient catch-up send failure must raise a retriable "
+ + "CatchUpSendException, not be swallowed into fail()/a terminal");
+ } catch (InvocationTargetException e) {
+ assertEquals("transient catch-up send failure must surface as CatchUpSendException",
+ "CatchUpSendException", e.getCause().getClass().getSimpleName());
+ }
+ // Retriable, not terminal: the producer-facing error latch stays clear.
+ loop.checkError();
+ } 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 {
+ Method m = CursorWebSocketSendLoop.class.getDeclaredMethod(
+ "accumulateSentDict", long.class, int.class, int.class);
+ m.setAccessible(true);
+ m.invoke(loop, 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, readInt(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 testCatchUpChunkFrameSizeOverflowFailsLoud() throws Exception {
+ // M3: sendDictCatchUp caps each chunk under the budget, so the single-frame
+ // catch-up path cannot overflow its int frameLen at any real cardinality. The
+ // guard must still be LOCAL -- a future caller must not be able to feed a
+ // wrapped-negative frameLen to Unsafe.malloc. An oversized symbolsLen must
+ // fail loud (CatchUpSendException) BEFORE the malloc; the guard fires before
+ // symbolsAddr is read, so a dummy address is fine.
+ TestUtils.assertMemoryLeak(() -> {
+ CatchUpCapturingClient client = new CatchUpCapturingClient(0);
+ try (CursorSendEngine engine = newEngine()) {
+ CursorWebSocketSendLoop loop = newLoop(engine, client);
+ try {
+ Method m = CursorWebSocketSendLoop.class.getDeclaredMethod(
+ "sendCatchUpChunk", int.class, int.class, long.class, int.class);
+ m.setAccessible(true);
+ // symbolsLen past the mirror ceiling: HEADER + varints + symbolsLen
+ // overflows an int, so the guard must reject it before malloc.
+ m.invoke(loop, 0, 1, 0L, Integer.MAX_VALUE - 4);
+ fail("an overflowing catch-up frame size must fail loud, not malloc negative");
+ } catch (InvocationTargetException e) {
+ assertEquals("overflow must surface as CatchUpSendException",
+ "CatchUpSendException", e.getCause().getClass().getSimpleName());
+ assertTrue("message must name the frame-size guard: " + e.getCause().getMessage(),
+ e.getCause().getMessage().contains("catch-up frame exceeds the maximum size"));
+ } finally {
+ loop.close();
+ }
+ }
+ });
+ }
+
+ private static void appendFrames(CursorSendEngine engine, int count) {
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ byte[] payload = "frame-bytes-padd".getBytes(StandardCharsets.US_ASCII);
+ for (int i = 0; i < payload.length; i++) {
+ Unsafe.getUnsafe().putByte(buf + i, payload[i]);
+ }
+ for (int i = 0; i < count; i++) {
+ engine.appendBlocking(buf, 16);
+ }
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ // Builds a QWP delta frame [12-byte header][deltaStart varint][deltaCount
+ // varint][ [len varint][utf8] ... ] for the given symbols. accumulateSentDict
+ // skips the header, so its content is irrelevant; the caller frees the frame.
+ private static long buildDeltaFrame(int deltaStart, String[] symbols, int[] outLen) {
+ int deltaCount = symbols.length;
+ int size = 12 + varintSize(deltaStart) + varintSize(deltaCount);
+ for (String s : symbols) {
+ size += varintSize(s.getBytes(StandardCharsets.UTF_8).length)
+ + s.getBytes(StandardCharsets.UTF_8).length;
+ }
+ long addr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ long p = writeVarint(addr + 12, deltaStart);
+ p = writeVarint(p, deltaCount);
+ for (String s : symbols) {
+ byte[] b = s.getBytes(StandardCharsets.UTF_8);
+ p = writeVarint(p, b.length);
+ for (byte x : b) {
+ Unsafe.getUnsafe().putByte(p++, x);
+ }
+ }
+ outLen[0] = size;
+ return addr;
+ }
+
+ private static int readInt(CursorWebSocketSendLoop loop, String name) throws Exception {
+ Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
+ f.setAccessible(true);
+ return f.getInt(loop);
+ }
+
+ // Parses the loop's native sent-dictionary mirror ([len varint][utf8]...) back
+ // into the symbol strings a reconnect catch-up would re-register.
+ private static List readMirrorSymbols(CursorWebSocketSendLoop loop) throws Exception {
+ long addr = readLong(loop, "sentDictBytesAddr");
+ int len = readInt(loop, "sentDictBytesLen");
+ List out = new ArrayList<>();
+ long p = addr;
+ long limit = addr + len;
+ while (p < limit) {
+ long l = 0;
+ int shift = 0;
+ while (p < limit) {
+ byte b = Unsafe.getUnsafe().getByte(p++);
+ l |= (long) (b & 0x7F) << shift;
+ if ((b & 0x80) == 0) {
+ break;
+ }
+ shift += 7;
+ }
+ byte[] bytes = new byte[(int) l];
+ for (int i = 0; i < l; i++) {
+ bytes[i] = Unsafe.getUnsafe().getByte(p++);
+ }
+ out.add(new String(bytes, StandardCharsets.UTF_8));
+ }
+ return out;
+ }
+
+ // Delivers a 0-table STATUS_OK for {@code wireSeq} into the loop's response
+ // handler, mimicking the server acking a catch-up frame (which carries no tables).
+ private static void deliverOk(CursorWebSocketSendLoop loop, long wireSeq) throws Exception {
+ int size = 11; // status(1) + sequence(8) + tableCount(2)
+ long ptr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Unsafe.getUnsafe().putByte(ptr, WebSocketResponse.STATUS_OK);
+ Unsafe.getUnsafe().putLong(ptr + 1, wireSeq);
+ Unsafe.getUnsafe().putShort(ptr + 9, (short) 0);
+ Field f = CursorWebSocketSendLoop.class.getDeclaredField("responseHandler");
+ f.setAccessible(true);
+ Object handler = f.get(loop);
+ Method m = handler.getClass().getDeclaredMethod("onBinaryMessage", long.class, int.class);
+ m.setAccessible(true);
+ m.invoke(handler, ptr, size);
+ } finally {
+ Unsafe.free(ptr, size, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ private static void invokeSetWireBaselineWithCatchUp(CursorWebSocketSendLoop loop, long replayStart) throws Exception {
+ Method m = CursorWebSocketSendLoop.class.getDeclaredMethod("setWireBaselineWithCatchUp", long.class);
+ m.setAccessible(true);
+ m.invoke(loop, replayStart);
+ }
+
+ private CursorWebSocketSendLoop newLoop(CursorSendEngine engine, WebSocketClient client) {
+ return new CursorWebSocketSendLoop(
+ client, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
+ () -> {
+ throw new UnsupportedOperationException("test loop is never started");
+ },
+ 5_000L, 100L, 5_000L, false);
+ }
+
+ private CursorSendEngine newEngine() {
+ return new CursorSendEngine(tmpDir, 16384);
+ }
+
+ private static long readLong(CursorWebSocketSendLoop loop, String name) throws Exception {
+ Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
+ f.setAccessible(true);
+ return f.getLong(loop);
+ }
+
+ // Populates the loop's native sent-dictionary mirror with {@code symbols} in
+ // the on-wire [len varint][utf8] layout, so setWireBaselineWithCatchUp sees a
+ // non-empty dictionary to re-register. loop.close() frees it.
+ private static void seedMirror(CursorWebSocketSendLoop loop, String... symbols) throws Exception {
+ int total = 0;
+ for (String s : symbols) {
+ int len = s.getBytes(StandardCharsets.UTF_8).length;
+ total += varintSize(len) + len;
+ }
+ long addr = Unsafe.malloc(total, MemoryTag.NATIVE_DEFAULT);
+ long p = addr;
+ for (String s : symbols) {
+ byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
+ p = writeVarint(p, bytes.length);
+ for (byte b : bytes) {
+ Unsafe.getUnsafe().putByte(p++, b);
+ }
+ }
+ setField(loop, "sentDictBytesAddr", addr);
+ setIntField(loop, "sentDictBytesCapacity", total);
+ setIntField(loop, "sentDictBytesLen", total);
+ setIntField(loop, "sentDictCount", symbols.length);
+ }
+
+ private static void setField(Object target, String name, long value) throws Exception {
+ Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
+ f.setAccessible(true);
+ f.setLong(target, value);
+ }
+
+ private static void setIntField(Object target, String name, int value) throws Exception {
+ Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
+ f.setAccessible(true);
+ f.setInt(target, value);
+ }
+
+ private static int varintSize(long value) {
+ int n = 1;
+ while (value > 0x7F) {
+ value >>>= 7;
+ n++;
+ }
+ return n;
+ }
+
+ private static long writeVarint(long addr, long value) {
+ while (value > 0x7F) {
+ Unsafe.getUnsafe().putByte(addr++, (byte) ((value & 0x7F) | 0x80));
+ value >>>= 7;
+ }
+ Unsafe.getUnsafe().putByte(addr++, (byte) value);
+ return addr;
+ }
+
+ // Stub transport: completes no real I/O. getServerMaxBatchSize drives the
+ // catch-up split; sendBinary counts the frames the catch-up emitted, or --
+ // when throwOnSend is set -- raises a transient wire error to model the fresh
+ // connection dropping mid-catch-up.
+ private static final class CatchUpCapturingClient extends WebSocketClient {
+ private final int cap;
+ private final boolean throwOnSend;
+ private int framesSent;
+
+ CatchUpCapturingClient(int cap) {
+ this(cap, false);
+ }
+
+ CatchUpCapturingClient(int cap, boolean throwOnSend) {
+ super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE);
+ this.cap = cap;
+ this.throwOnSend = throwOnSend;
+ }
+
+ @Override
+ public int getServerMaxBatchSize() {
+ return cap;
+ }
+
+ @Override
+ public int getServerQwpVersion() {
+ return 1;
+ }
+
+ @Override
+ public void sendBinary(long dataPtr, int length) {
+ if (throwOnSend) {
+ throw new RuntimeException("transient wire failure during catch-up");
+ }
+ framesSent++;
+ }
+
+ @Override
+ protected void ioWait(int timeout, int op) {
+ }
+
+ @Override
+ protected void setupIoWait() {
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java
new file mode 100644
index 00000000..38741039
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java
@@ -0,0 +1,223 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.Sender;
+import io.questdb.client.cutlass.qwp.client.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.test.cutlass.qwp.websocket.TestWebSocketServer;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+/**
+ * Guards the recovery-seeded symbol-dictionary mirror against leaking when the
+ * I/O loop is constructed but never run.
+ *
+ * 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;
+
+ @Test
+ public void testSeededMirrorFreedWhenLoopClosedWithoutStart() throws Exception {
+ Path sfDir = Files.createTempDirectory("qwp-mirror-leak");
+ try {
+ // 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);
+
+ CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop(
+ null, engine, 0, 1_000_000L,
+ () -> {
+ throw new IOException("no reconnect in this test");
+ },
+ 0, 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",
+ readInt(loop, "sentDictCount") > 0);
+ loop.close();
+ // 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, readInt(loop, "sentDictCount"));
+ }
+ });
+ } finally {
+ rmDir(sfDir);
+ }
+ }
+
+ @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 CONSUMED
+ // 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. Copying the entries (leaving the
+ // dictionary intact for the engine's lifetime) lets every recycled loop
+ // re-seed. Pre-fix, loop2's sentDictCount is 0 and this assertion fails.
+ Path sfDir = Files.createTempDirectory("qwp-mirror-reseed");
+ try {
+ 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);
+
+ // 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, readInt(loop1, "sentDictCount"));
+ } finally {
+ loop1.close();
+ }
+
+ // 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, readInt(loop2, "sentDictCount"));
+ } finally {
+ loop2.close();
+ }
+ }
+ });
+ } finally {
+ rmDir(sfDir);
+ }
+ }
+
+ // 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, 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_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();
+ }
+ }
+ }
+ }
+
+ private static int readInt(CursorWebSocketSendLoop loop, String name) throws Exception {
+ Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
+ f.setAccessible(true);
+ return f.getInt(loop);
+ }
+
+ private static void rmDir(Path dir) throws IOException {
+ if (dir == null || !Files.exists(dir)) {
+ return;
+ }
+ // try-with-resources: Files.walk returns a Stream backed by an open
+ // directory handle that must be closed, or each rmDir leaks a descriptor.
+ try (Stream walk = Files.walk(dir)) {
+ walk.sorted(Comparator.reverseOrder())
+ .forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ } catch (IOException ignored) {
+ // best-effort
+ }
+ });
+ }
+ }
+
+ 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/CursorWebSocketSendLoopPoisonFrameTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java
index 34ae5518..d413d82e 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java
@@ -244,6 +244,77 @@ public void testNonOrderlyClosePoisonKeysOnOkLevelHeadOfLine() throws Exception
});
}
+ @Test
+ public void testNonOrderlyCloseAfterOnlyCatchUpDoesNotStrike() throws Exception {
+ // C2 regression: the dictionary catch-up advances nextWireSeq WITHOUT
+ // sending a data frame. A non-orderly close in that window -- a flapping
+ // LB/middlebox that completes the upgrade, accepts the catch-up, then drops
+ // before the first replay frame -- must be strike-EXEMPT. Keying the
+ // poison-strike gate off nextWireSeq > 0 (rather than
+ // dataFrameSentThisConnection) charges a strike on a frame that was never
+ // sent; MAX_REJECTIONS such closes then escalate a TRANSIENT outage to a
+ // PROTOCOL_VIOLATION terminal, hard-failing the producer and quarantining an
+ // orphan drainer -- exactly what store-and-forward's retry-forever contract
+ // forbids. Mirror image of testNonOrderlyClosePoisonKeysOnOkLevelHeadOfLine,
+ // which sends a real data frame (setSentCount) and DOES escalate.
+ TestUtils.assertMemoryLeak(() -> {
+ List clients = new ArrayList<>();
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 2);
+ CursorWebSocketSendLoop loop = newDurableLoop(engine, clients);
+ for (int i = 0; i < MAX_REJECTIONS + 2; i++) {
+ // Model the catch-up: nextWireSeq advanced, but NO data frame
+ // sent. swapClient resets both on every recycle, so re-apply
+ // before each close. Pre-fix, each of these lands a strike on the
+ // never-sent head frame and the loop terminals by now.
+ setCatchUpWireSeqOnly(loop, 2);
+ deliverNonOrderlyClose(loop);
+ }
+ // No strike was ever charged, so nothing escalated: the loop stays
+ // retriable and the producer-facing error latch is clear.
+ loop.checkError();
+ } finally {
+ closeAll(clients);
+ }
+ });
+ }
+
+ @Test
+ public void testNonOrderlyRejectionAfterOnlyCatchUpDoesNotStrike() throws Exception {
+ // C2 regression: the server-NACK twin of
+ // testNonOrderlyCloseAfterOnlyCatchUpDoesNotStrike. handleServerRejection's
+ // pre-send gate keys off dataFrameSentThisConnection, NOT nextWireSeq -- the
+ // dictionary catch-up advances nextWireSeq WITHOUT sending a data frame. A
+ // server NACK of a catch-up frame (nextWireSeq>0, no data frame sent) must
+ // take the pre-send path (surface + recycle, no strike), not the post-send
+ // poison-strike path. Pre-fix (gate on highestSent >= 0, i.e. nextWireSeq>0)
+ // each catch-up NACK strikes the never-sent head frame; MAX_REJECTIONS such
+ // strikes escalate a TRANSIENT outage to a producer-fatal PROTOCOL_VIOLATION
+ // terminal -- exactly what store-and-forward's retry-forever contract forbids.
+ // WRITE_ERROR (RETRIABLE) is the discriminating category: it DOES accrue
+ // strikes on the post-send path (see testNackRecycleIsPacedAgainstHealthyServer),
+ // so a regressed gate escalates here and checkError() throws.
+ TestUtils.assertMemoryLeak(() -> {
+ List clients = new ArrayList<>();
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 2);
+ CursorWebSocketSendLoop loop = newDurableLoop(engine, clients);
+ for (int i = 0; i < MAX_REJECTIONS + 2; i++) {
+ // Model the catch-up: nextWireSeq advanced, but NO data frame
+ // sent. The pre-send recycle (swapClient) resets nextWireSeq, so
+ // re-apply before each NACK (mirrors the onClose twin).
+ setCatchUpWireSeqOnly(loop, 2);
+ deliverRetriableNack(loop, 1, "disk full");
+ }
+ // No strike was ever charged, so nothing escalated: the loop stays
+ // retriable and the producer-facing error latch is clear.
+ loop.checkError();
+ } finally {
+ closeAll(clients);
+ }
+ });
+ }
+
@Test
public void testNackRecycleIsPacedAgainstHealthyServer() throws Exception {
// A reachable, healthy server that NACKs the head frame (RETRIABLE)
@@ -585,6 +656,46 @@ public void testPoisonDwellHoldsEscalationUntilWallClockWindowElapses() throws E
});
}
+ @Test
+ public void testPostSendCatchUpNackDoesNotStrikeOrLaunderPoisonState() throws Exception {
+ // Regression: after a reconnect the loop ships the head DATA frame
+ // (dataFrameSentThisConnection=true) BEFORE tryReceiveAcks reads the server's
+ // NACK of a dictionary CATCH-UP frame in the same loop iteration. That NACK
+ // names a wire seq below the replay head, so its fsn = fsnAtZero+cappedSeq is
+ // at or below ackedFsn -- negative when replayStart < catchUpFrames. It must
+ // NOT charge a poison strike: recordHeadRejectionStrike(fsn) would set
+ // poisonFsn to that value (the common shape yields -1, the "no suspect"
+ // sentinel), laundering any genuine in-progress poison run and reporting a
+ // bogus fsn. The fix routes an fsn <= ackedFsn rejection to the pre-send path
+ // (surface + recycle, no strike), symmetric with the success path's
+ // engine.acknowledge() no-op below ackedFsn.
+ TestUtils.assertMemoryLeak(() -> {
+ List clients = new ArrayList<>();
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 2);
+ CursorWebSocketSendLoop loop = newDurableLoop(engine, clients);
+ // Model: 2 catch-up frames (wire seq 0,1) + 1 data frame (wire seq 2)
+ // sent, nothing acked (ackedFsn = -1), so fsnAtZero = replayStart(0) -
+ // catchUpFrames(2) = -2. A NACK of catch-up wire seq 0 maps to fsn -2,
+ // strictly below the -1 sentinel so the assertion discriminates.
+ setPostCatchUpDataFrameBaseline(loop, -2L, 3L);
+ assertEquals("precondition: no poison suspect yet",
+ -1L, getLongField(loop, "poisonFsn"));
+
+ deliverRetriableNack(loop, 0, "disk full");
+
+ // The catch-up NACK was surfaced + recycled but charged NO strike, so
+ // the sentinel is intact. Pre-fix it was set to -2, laundering the
+ // poison detector's state.
+ assertEquals("catch-up NACK must not set/launder the poison sentinel",
+ -1L, getLongField(loop, "poisonFsn"));
+ loop.checkError();
+ } finally {
+ closeAll(clients);
+ }
+ });
+ }
+
@Test
public void testPostSendNotWritableNackNeverEscalatesToPoisonTerminal() throws Exception {
// RETRIABLE_OTHER (NOT_WRITABLE) is a node-state verdict, not a frame
@@ -969,6 +1080,44 @@ private static void setSentCount(CursorWebSocketSendLoop loop, long count) throw
Field f = CursorWebSocketSendLoop.class.getDeclaredField("nextWireSeq");
f.setAccessible(true);
f.setLong(loop, count);
+ // A non-zero sent count models DATA frames sent on this connection. The
+ // poison-strike / pre-send gates key off dataFrameSentThisConnection, not
+ // nextWireSeq (the dictionary catch-up advances nextWireSeq without sending
+ // a data frame), so keep the two in sync for these white-box scenarios.
+ Field d = CursorWebSocketSendLoop.class.getDeclaredField("dataFrameSentThisConnection");
+ d.setAccessible(true);
+ d.setBoolean(loop, count > 0);
+ }
+
+ // Sets ONLY nextWireSeq -- deliberately NOT dataFrameSentThisConnection -- to
+ // model the dictionary catch-up having advanced the wire sequence with no data
+ // frame sent yet. Contrast setSentCount, which sets both.
+ private static void setCatchUpWireSeqOnly(CursorWebSocketSendLoop loop, long count) throws Exception {
+ Field f = CursorWebSocketSendLoop.class.getDeclaredField("nextWireSeq");
+ f.setAccessible(true);
+ f.setLong(loop, count);
+ }
+
+ // Models a fresh connection that has shipped the dictionary catch-up (advancing
+ // fsnAtZero below the replay head) AND the head data frame: sets fsnAtZero,
+ // nextWireSeq, and dataFrameSentThisConnection=true together.
+ private static void setPostCatchUpDataFrameBaseline(CursorWebSocketSendLoop loop,
+ long fsnAtZero, long nextWireSeq) throws Exception {
+ Field z = CursorWebSocketSendLoop.class.getDeclaredField("fsnAtZero");
+ z.setAccessible(true);
+ z.setLong(loop, fsnAtZero);
+ Field w = CursorWebSocketSendLoop.class.getDeclaredField("nextWireSeq");
+ w.setAccessible(true);
+ w.setLong(loop, nextWireSeq);
+ Field d = CursorWebSocketSendLoop.class.getDeclaredField("dataFrameSentThisConnection");
+ d.setAccessible(true);
+ d.setBoolean(loop, true);
+ }
+
+ private static long getLongField(CursorWebSocketSendLoop loop, String name) throws Exception {
+ Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
+ f.setAccessible(true);
+ return f.getLong(loop);
}
private static long[] txns(long... v) {
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..2efee2e4 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,48 @@ 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.open(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.
+ PersistedSymbolDict reopened = PersistedSymbolDict.open(sfDir);
+ assertNotNull(reopened);
+ try {
+ assertEquals(0, reopened.size());
+ } finally {
+ reopened.close();
+ }
+ });
+ }
+
@Test
public void testNeverPublishedCloseLeavesNoSfaFiles() throws Exception {
TestUtils.assertMemoryLeak(() -> {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PersistedSymbolDictTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PersistedSymbolDictTest.java
new file mode 100644
index 00000000..966cdffd
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PersistedSymbolDictTest.java
@@ -0,0 +1,707 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.PersistedSymbolDict;
+import io.questdb.client.std.FilesFacade;
+import io.questdb.client.std.ObjList;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Comparator;
+import java.util.stream.Stream;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+public class PersistedSymbolDictTest {
+
+ @Test
+ public void testAppendPersistsAcrossReopen() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict");
+ try {
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ Assert.assertNotNull(d);
+ try {
+ Assert.assertEquals(0, d.size());
+ d.appendSymbol("AAPL");
+ d.appendSymbol("GOOG");
+ d.appendSymbol("MSFT");
+ Assert.assertEquals(3, d.size());
+ } finally {
+ d.close();
+ }
+
+ // Reopen: entries recovered in id order.
+ PersistedSymbolDict reopened = PersistedSymbolDict.open(dir.toString());
+ Assert.assertNotNull(reopened);
+ try {
+ Assert.assertEquals(3, reopened.size());
+ ObjList symbols = reopened.readLoadedSymbols();
+ Assert.assertEquals(3, symbols.size());
+ Assert.assertEquals("AAPL", symbols.getQuick(0));
+ Assert.assertEquals("GOOG", symbols.getQuick(1));
+ Assert.assertEquals("MSFT", symbols.getQuick(2));
+ Assert.assertTrue(reopened.loadedEntriesLen() > 0);
+
+ // Appending after recovery continues from the recovered tip.
+ reopened.appendSymbol("TSLA");
+ Assert.assertEquals(4, reopened.size());
+ } finally {
+ reopened.close();
+ }
+
+ PersistedSymbolDict third = PersistedSymbolDict.open(dir.toString());
+ try {
+ Assert.assertEquals(4, third.size());
+ Assert.assertEquals("TSLA", third.readLoadedSymbols().getQuick(3));
+ } finally {
+ third.close();
+ }
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @Test
+ public void testAppendRawEntriesMatchesAppendSymbols() throws Exception {
+ // M1: the producer persists the frame's already-encoded delta bytes via
+ // appendRawEntries instead of re-encoding the symbols. Those bytes are the
+ // same [len][utf8]... layout appendSymbols writes, so both must produce an
+ // identical, recoverable dictionary. Encode a range with appendSymbols,
+ // reopen to grab its on-disk entry bytes, replay them through
+ // appendRawEntries into a fresh dict, and assert the recovered symbols
+ // match -- including an empty entry mid-range.
+ assertMemoryLeak(() -> {
+ Path src = Files.createTempDirectory("qwp-symdict-src");
+ Path dst = Files.createTempDirectory("qwp-symdict-dst");
+ try {
+ GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
+ dict.getOrAddSymbol("AAPL"); // id 0
+ dict.getOrAddSymbol(""); // id 1 -- empty entry mid-range
+ dict.getOrAddSymbol("MSFT"); // id 2
+
+ PersistedSymbolDict encoded = PersistedSymbolDict.open(src.toString());
+ encoded.appendSymbols(dict, 0, 2);
+ encoded.close();
+
+ // Reopen to obtain the on-disk entry region [len][utf8]... verbatim,
+ // then replay it byte-for-byte into a fresh dict via appendRawEntries.
+ PersistedSymbolDict reopened = PersistedSymbolDict.open(src.toString());
+ try {
+ PersistedSymbolDict raw = PersistedSymbolDict.open(dst.toString());
+ try {
+ raw.appendRawEntries(reopened.loadedEntriesAddr(),
+ reopened.loadedEntriesLen(), reopened.size());
+ Assert.assertEquals(3, raw.size());
+ } finally {
+ raw.close();
+ }
+ } finally {
+ reopened.close();
+ }
+
+ // The raw-appended dict must recover the same dense symbols.
+ PersistedSymbolDict recovered = PersistedSymbolDict.open(dst.toString());
+ try {
+ Assert.assertEquals(3, recovered.size());
+ ObjList symbols = recovered.readLoadedSymbols();
+ Assert.assertEquals("AAPL", symbols.getQuick(0));
+ Assert.assertEquals("", symbols.getQuick(1));
+ Assert.assertEquals("MSFT", symbols.getQuick(2));
+ } finally {
+ recovered.close();
+ }
+ } finally {
+ rmDir(src);
+ rmDir(dst);
+ }
+ });
+ }
+
+ @Test
+ public void testAppendSymbolsBatchWritesDenseRange() throws Exception {
+ // appendSymbols persists a whole id range in one write (the hot-path
+ // syscall reduction). It must produce the same dense, id-ordered file a
+ // per-symbol loop would, including an empty symbol mid-range, and a second
+ // batched call keyed off size() must continue densely (the resume-from-
+ // durable-size contract the producer relies on).
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict");
+ try {
+ GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
+ dict.getOrAddSymbol("AAPL"); // id 0
+ dict.getOrAddSymbol(""); // id 1 -- empty symbol mid-range
+ dict.getOrAddSymbol("MSFT"); // id 2
+ dict.getOrAddSymbol("TSLA"); // id 3
+
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ try {
+ d.appendSymbols(dict, 0, 3); // one write for all four ids
+ Assert.assertEquals(4, d.size());
+ d.appendSymbols(dict, 4, 3); // empty range (to < from) is a no-op
+ Assert.assertEquals(4, d.size());
+ } finally {
+ d.close();
+ }
+
+ PersistedSymbolDict reopened = PersistedSymbolDict.open(dir.toString());
+ try {
+ Assert.assertEquals(4, reopened.size());
+ ObjList symbols = reopened.readLoadedSymbols();
+ Assert.assertEquals(4, symbols.size());
+ Assert.assertEquals("AAPL", symbols.getQuick(0));
+ Assert.assertEquals("", symbols.getQuick(1));
+ Assert.assertEquals("MSFT", symbols.getQuick(2));
+ Assert.assertEquals("TSLA", symbols.getQuick(3));
+
+ // A follow-on batch keyed off the recovered size continues
+ // the dense sequence without a gap or duplicate.
+ dict.getOrAddSymbol("NVDA"); // id 4
+ reopened.appendSymbols(dict, reopened.size(), 4);
+ Assert.assertEquals(5, reopened.size());
+ } finally {
+ reopened.close();
+ }
+
+ PersistedSymbolDict third = PersistedSymbolDict.open(dir.toString());
+ try {
+ Assert.assertEquals(5, third.size());
+ Assert.assertEquals("NVDA", third.readLoadedSymbols().getQuick(4));
+ } finally {
+ third.close();
+ }
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @Test
+ public void testBadMagicIsRecreatedEmpty() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict");
+ try {
+ // A file with the right size but garbage content (bad magic).
+ Path f = dir.resolve(".symbol-dict");
+ Files.write(f, new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ Assert.assertNotNull(d);
+ try {
+ Assert.assertEquals("bad-magic file recreated empty", 0, d.size());
+ d.appendSymbol("X");
+ Assert.assertEquals(1, d.size());
+ } finally {
+ d.close();
+ }
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @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 = Files.createTempDirectory("qwp-symdict");
+ try {
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ d.appendSymbol("AAPL");
+ d.close();
+
+ // Reopen so recovery loads the entries into native memory.
+ 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());
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @Test
+ public void testEmptySymbolRoundTrips() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict");
+ try {
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ try {
+ d.appendSymbol("");
+ d.appendSymbol("nonempty");
+ } finally {
+ d.close();
+ }
+ PersistedSymbolDict re = PersistedSymbolDict.open(dir.toString());
+ try {
+ Assert.assertEquals(2, re.size());
+ ObjList s = re.readLoadedSymbols();
+ Assert.assertEquals("", s.getQuick(0));
+ Assert.assertEquals("nonempty", s.getQuick(1));
+ } finally {
+ re.close();
+ }
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @Test
+ public void testInteriorCorruptionIsCaughtNotSilentlyMisattributed() throws Exception {
+ // A host-crash interior tear (a lost page reading back as zeroes) or a
+ // stale entry left past the end by a failed truncate can change the bytes
+ // of a NON-trailing entry. Without the per-entry CRC the parse would
+ // accept those bytes, shifting the dense id->symbol map and silently
+ // misattributing symbol-column values on replay. With the CRC the corrupt
+ // entry fails verification and the parse stops there, so recovery trusts
+ // only the intact prefix (fail-clean: the send loop's torn-dict guard then
+ // forces a resend of the rest).
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict");
+ try {
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ try {
+ d.appendSymbol("s0");
+ d.appendSymbol("s1");
+ d.appendSymbol("s2");
+ d.appendSymbol("s3");
+ d.appendSymbol("s4");
+ Assert.assertEquals(5, d.size());
+ } finally {
+ d.close();
+ }
+
+ // Corrupt one byte inside the 3rd entry's UTF-8 (id 2). On-disk
+ // entry layout is [len varint][utf8][crc32c u32]; a 2-byte ASCII
+ // symbol is 1 + 2 + 4 = 7 bytes, after the 8-byte header:
+ // header[0,8) e0[8,15) e1[15,22) e2[22,29) ...
+ // Offset 23 is "s2"'s first UTF-8 byte; flipping it leaves e2's
+ // stored CRC stale.
+ Path f = dir.resolve(".symbol-dict");
+ byte[] bytes = Files.readAllBytes(f);
+ bytes[23] ^= 0x7F;
+ Files.write(f, bytes);
+
+ PersistedSymbolDict re = PersistedSymbolDict.open(dir.toString());
+ Assert.assertNotNull(re);
+ try {
+ // Only the intact prefix [s0, s1] is trusted; the corrupt e2
+ // and everything after it are dropped. No recovered symbol is
+ // the corrupted string -- the tear is DETECTED, never silently
+ // misattributed.
+ Assert.assertEquals("parse must stop at the corrupt interior entry", 2, re.size());
+ ObjList s = re.readLoadedSymbols();
+ Assert.assertEquals("s0", s.getQuick(0));
+ Assert.assertEquals("s1", s.getQuick(1));
+ } finally {
+ re.close();
+ }
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @Test
+ public void testLargeSymbolRoundTripsAcrossReopen() throws Exception {
+ // C1 regression: the write path caps nothing, so a symbol larger than the
+ // old fixed 1 MB read ceiling must still recover intact. Before the fix,
+ // appendSymbol wrote the oversized entry but openExisting rejected it as
+ // "oversized", truncated the dictionary at that id (dropping it and every
+ // higher id), and a normal process-crash recovery then hard-failed with a
+ // spurious "host crash / resend required" terminal -- defeating store-and-
+ // forward's process-crash durability for large symbols. The file length is
+ // now the only bound, so the write and read paths agree.
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict");
+ try {
+ // Just over the old 1 << 20 (1 MB) ceiling.
+ String big = TestUtils.repeat("x", (1 << 20) + 17);
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ try {
+ d.appendSymbol("before");
+ d.appendSymbol(big);
+ d.appendSymbol("after");
+ Assert.assertEquals(3, d.size());
+ } finally {
+ d.close();
+ }
+
+ // Recovery must load ALL three; pre-fix the reopen truncated at the
+ // big entry and came back with size 1 (only "before" survived).
+ PersistedSymbolDict re = PersistedSymbolDict.open(dir.toString());
+ try {
+ Assert.assertEquals("large entry must survive recovery, not be truncated",
+ 3, re.size());
+ ObjList s = re.readLoadedSymbols();
+ Assert.assertEquals("before", s.getQuick(0));
+ Assert.assertEquals(big, s.getQuick(1));
+ Assert.assertEquals("after", s.getQuick(2));
+ } finally {
+ re.close();
+ }
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @Test
+ public void testOpenCleanDiscardsSurvivingDictionary() throws Exception {
+ // A fresh start must NOT inherit a dictionary left by a prior lifecycle:
+ // openClean() truncates any survivor to empty, where open() would recover
+ // (and TRUST) it. Trusting a survivor whose segments are gone -- the
+ // fresh-start producer is not seeded from it -- shifts the dense id->symbol
+ // mapping and misattributes symbols on the next reconnect.
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict-clean");
+ try {
+ PersistedSymbolDict stale = PersistedSymbolDict.open(dir.toString());
+ Assert.assertNotNull(stale);
+ try {
+ stale.appendSymbol("staleX");
+ stale.appendSymbol("staleY");
+ Assert.assertEquals(2, stale.size());
+ } finally {
+ stale.close();
+ }
+
+ // Fresh start: openClean yields an EMPTY dictionary regardless of
+ // the survivor, and appends from id 0 again.
+ PersistedSymbolDict fresh = PersistedSymbolDict.openClean(dir.toString());
+ Assert.assertNotNull(fresh);
+ try {
+ Assert.assertEquals(0, fresh.size());
+ Assert.assertEquals(0, fresh.readLoadedSymbols().size());
+ fresh.appendSymbol("freshA");
+ Assert.assertEquals(1, fresh.size());
+ } finally {
+ fresh.close();
+ }
+
+ // The survivor's bytes are physically gone, not just hidden: a
+ // subsequent recovery open() sees only the post-clean content.
+ PersistedSymbolDict reopened = PersistedSymbolDict.open(dir.toString());
+ Assert.assertNotNull(reopened);
+ try {
+ Assert.assertEquals(1, reopened.size());
+ Assert.assertEquals("freshA", reopened.readLoadedSymbols().getQuick(0));
+ } finally {
+ reopened.close();
+ }
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @Test
+ public void testRemoveOrphanDeletesFile() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict");
+ try {
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ d.appendSymbol("A");
+ d.close();
+ Path f = dir.resolve(".symbol-dict");
+ Assert.assertTrue(Files.exists(f));
+ PersistedSymbolDict.removeOrphan(dir.toString());
+ Assert.assertFalse(Files.exists(f));
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @Test
+ public void testTornTrailingEntrySelfHeals() throws Exception {
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict");
+ try {
+ // Write two complete entries, then a torn trailing record: a
+ // length prefix of 5 followed by only 2 bytes (crash mid-append).
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ d.appendSymbol("one");
+ d.appendSymbol("two");
+ d.close();
+
+ Path f = dir.resolve(".symbol-dict");
+ long cleanLen = Files.size(f); // header + "one" + "two", no tail
+ Files.write(f, new byte[]{(byte) 5, (byte) 'x', (byte) 'y'},
+ StandardOpenOption.APPEND);
+ Assert.assertEquals("torn tail present before reopen", cleanLen + 3, Files.size(f));
+
+ // Reopen: the torn tail is ignored; only the two complete entries load.
+ PersistedSymbolDict re = PersistedSymbolDict.open(dir.toString());
+ try {
+ // open() physically truncates the torn tail: the file returns
+ // to its clean length, so a later SHORTER append can never
+ // leave residue past its end that a future recovery mis-parses
+ // as a ghost symbol.
+ Assert.assertEquals("torn tail physically dropped by open", cleanLen, Files.size(f));
+ Assert.assertEquals(2, re.size());
+ ObjList s = re.readLoadedSymbols();
+ Assert.assertEquals("one", s.getQuick(0));
+ Assert.assertEquals("two", s.getQuick(1));
+ // The next append continues from the truncated tail cleanly.
+ re.appendSymbol("three");
+ Assert.assertEquals(3, re.size());
+ } finally {
+ re.close();
+ }
+
+ PersistedSymbolDict re2 = PersistedSymbolDict.open(dir.toString());
+ try {
+ Assert.assertEquals(3, re2.size());
+ Assert.assertEquals("three", re2.readLoadedSymbols().getQuick(2));
+ } finally {
+ re2.close();
+ }
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ @Test
+ public void testTruncateFailureRecreatesEmpty() throws Exception {
+ // A host crash can leave a torn/stale tail past the last complete entry.
+ // open() drops it with a truncate; if that truncate FAILS (a read-only
+ // remount, a Windows share lock), the file still exposes the stale bytes,
+ // whose self-consistent per-entry CRC a later shifted parse could accept as
+ // a real symbol. So a failed truncate must make the file UNTRUSTED --
+ // open() recreates it empty (fail-clean) rather than returning a dict laid
+ // over stale bytes. Drive the truncate failure with a facade and assert the
+ // reopened dictionary is empty, not the [one, two] prefix.
+ assertMemoryLeak(() -> {
+ Path dir = Files.createTempDirectory("qwp-symdict");
+ try {
+ PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
+ d.appendSymbol("one");
+ d.appendSymbol("two");
+ d.close();
+
+ // Append a torn trailing record (length prefix 5, only 2 bytes) so
+ // the reopen parses [one, two], then finds validLen < len and tries
+ // to truncate the tail -- the branch under test.
+ Path f = dir.resolve(".symbol-dict");
+ long cleanLen = Files.size(f); // header + "one" + "two", no tail
+ Files.write(f, new byte[]{(byte) 5, (byte) 'x', (byte) 'y'}, StandardOpenOption.APPEND);
+ Assert.assertEquals("torn tail present before reopen", cleanLen + 3, Files.size(f));
+
+ // Reopen through a facade whose truncate() fails.
+ PersistedSymbolDict re = PersistedSymbolDict.open(new FailingTruncateFacade(), dir.toString());
+ Assert.assertNotNull(re);
+ try {
+ // The failed truncate made the file untrusted, so open() recreated
+ // it empty rather than trusting the [one, two] prefix over a stale
+ // tail: size()==0, not 2, and no recovered symbols.
+ Assert.assertEquals("failed truncate must recreate the dictionary empty", 0, re.size());
+ Assert.assertEquals(0, re.readLoadedSymbols().size());
+ // openFresh rewrote a bare 8-byte header (magic + version), so both
+ // the two entries and the torn tail are gone.
+ Assert.assertEquals("recreated file is a bare header", 8L, Files.size(f));
+ } finally {
+ re.close();
+ }
+ } finally {
+ rmDir(dir);
+ }
+ });
+ }
+
+ private static void rmDir(Path dir) {
+ try {
+ if (dir == null || !Files.exists(dir)) {
+ return;
+ }
+ // try-with-resources: Files.walk returns a Stream backed by an open
+ // directory handle that must be closed, or each rmDir leaks a descriptor.
+ try (Stream walk = Files.walk(dir)) {
+ walk.sorted(Comparator.reverseOrder())
+ .forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ } catch (IOException ignored) {
+ }
+ });
+ }
+ } catch (IOException ignored) {
+ }
+ }
+
+ /**
+ * A {@link FilesFacade} that delegates every call to {@link FilesFacade#INSTANCE}
+ * except {@link #truncate(int, long)}, which always fails -- reproducing a
+ * host where the torn/stale-tail truncate cannot succeed (read-only remount,
+ * Windows share lock) so {@code open()}'s fail-clean recreate path runs.
+ */
+ private static final class FailingTruncateFacade 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 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 false; // the fault under test
+ }
+
+ @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/cutlass/qwp/websocket/TestWebSocketServer.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java
index 6d4c5ef0..5e009d67 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java
@@ -108,6 +108,11 @@ public class TestWebSocketServer implements Closeable {
// 401, 403, 404, 426, 503, etc. that the failover loop should
// classify per failover.md §6.
private volatile String rejectingStatusReason;
+ // When > 0, 101 upgrade responses advertise this value as the
+ // X-QWP-Max-Batch-Size header, capping the QWP message size the client
+ // builds. Lets a test force the delta-dictionary catch-up to split across
+ // several frames. Live-updatable via setAdvertisedMaxBatchSize().
+ private volatile int advertisedMaxBatchSize;
public TestWebSocketServer(WebSocketServerHandler handler) throws IOException {
this(handler, false);
@@ -221,6 +226,14 @@ public int liveConnectionCount() {
return liveConnections.get();
}
+ /**
+ * Advertises {@code X-QWP-Max-Batch-Size: } on subsequent
+ * handshakes (live update). Pass {@code 0} to stop advertising a cap.
+ */
+ public void setAdvertisedMaxBatchSize(int maxBatchSize) {
+ this.advertisedMaxBatchSize = maxBatchSize;
+ }
+
/**
* Replaces the advertised role for subsequent handshakes (live update).
*/
@@ -603,6 +616,10 @@ private boolean performHandshake() throws IOException {
if (role != null) {
sb.append("X-QuestDB-Role: ").append(role).append("\r\n");
}
+ int maxBatch = advertisedMaxBatchSize;
+ if (maxBatch > 0) {
+ sb.append("X-QWP-Max-Batch-Size: ").append(maxBatch).append("\r\n");
+ }
sb.append("\r\n");
out.write(sb.toString().getBytes(StandardCharsets.US_ASCII));
out.flush();