Skip to content

Commit a341a9b

Browse files
committed
Optimize QWP symbol dictionary recovery
1 parent 350e4a3 commit a341a9b

11 files changed

Lines changed: 410 additions & 212 deletions

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

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3942,7 +3942,7 @@ private void resetSymbolDictStateForNewConnection() {
39423942
* <ol>
39433943
* <li>the slot's persisted {@code .symbol-dict} -- its intact prefix; then</li>
39443944
* <li>the surviving frames' OWN delta sections, for every id above that prefix
3945-
* ({@link CursorSendEngine#collectReplaySymbolsAbove}).</li>
3945+
* ({@link CursorSendEngine#addRecoveredSymbolsTo}).</li>
39463946
* </ol>
39473947
* Those are exactly the two sources, in exactly the order, that the send loop's mirror
39483948
* is built from: its constructor seeds {@code sentDictCount} from the same dictionary,
@@ -3976,7 +3976,7 @@ private void resetSymbolDictStateForNewConnection() {
39763976
* <b>What still fails clean.</b> A genuine GAP: the ids below a surviving frame's delta
39773977
* start were introduced by frames that were acked and TRIMMED away, so they lived only in
39783978
* the lost dictionary and nothing can rebuild them.
3979-
* {@code collectReplaySymbolsAbove} returns -1 for that and we throw. It is the same
3979+
* {@code addRecoveredSymbolsTo} returns -1 for that and we throw. It is the same
39803980
* condition the send loop's replay guard ({@code deltaStart > sentDictCount}) trips on, so
39813981
* producer and drainer now agree on exactly which slots are recoverable, instead of the
39823982
* producer rejecting slots the drainer drains.
@@ -3990,17 +3990,13 @@ private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
39903990
// mirror also seeds sentDictCount from.
39913991
int baseline = 0;
39923992
if (pd != null && pd.size() > 0) {
3993-
ObjList<String> persisted = pd.readLoadedSymbols();
3994-
for (int i = 0, n = persisted.size(); i < n; i++) {
3995-
globalSymbolDictionary.addRecoveredSymbol(persisted.getQuick(i));
3996-
}
3993+
pd.addLoadedSymbolsTo(globalSymbolDictionary);
39973994
baseline = globalSymbolDictionary.size();
39983995
}
39993996
// 2. Everything the surviving frames define above that prefix, straight out of their
40003997
// own delta sections -- the same bytes, in the same order, accumulateSentDict will
40013998
// feed the mirror as those frames go back on the wire.
4002-
ObjList<String> fromFrames = new ObjList<>();
4003-
long coverage = cursorEngine.collectReplaySymbolsAbove(baseline, fromFrames);
3999+
long coverage = cursorEngine.addRecoveredSymbolsTo(baseline, globalSymbolDictionary);
40044000
if (coverage < 0) {
40054001
// A gap: the surviving frames reference ids below their own delta start,
40064002
// introduced by frames since acked and trimmed away, and the persisted
@@ -4013,8 +4009,8 @@ private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
40134009
// "resend required" for delivered data AND -- because such a slot is fully
40144010
// drained -- let build()'s connect-path close unlink the (already-delivered)
40154011
// bytes the quarantine claims to preserve. So DON'T throw: seed the intact
4016-
// prefix only, leaving fromFrames unused exactly as the send loop's mirror
4017-
// does on a -1 (it adds nothing), so the producer baseline and the mirror's
4012+
// prefix only; addRecoveredSymbolsTo adds nothing on a -1 exactly as the
4013+
// send loop's mirror does, so the producer baseline and the mirror's
40184014
// sentDictCount still agree by construction. The producer resumes above the
40194015
// prefix and the fully-drained slot is cleaned up on close.
40204016
long ackedFsn = cursorEngine.ackedFsn();
@@ -4040,9 +4036,6 @@ private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
40404036
+ "holds them; the recovered dictionary holds only "
40414037
+ (pd == null ? 0 : pd.size()) + " id(s) -- resend the affected data");
40424038
}
4043-
for (int i = 0, n = fromFrames.size(); i < n; i++) {
4044-
globalSymbolDictionary.addRecoveredSymbol(fromFrames.getQuick(i));
4045-
}
40464039
// Producer baseline == the coverage the replay will establish == the mirror's
40474040
// sentDictCount once those frames have gone out. The first new frame therefore
40484041
// starts its delta exactly at the tip, and the replay guard passes.

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
package io.questdb.client.cutlass.qwp.client.sf.cursor;
2626

27+
import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary;
2728
import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
2829
import io.questdb.client.std.Compat;
2930
import io.questdb.client.std.Files;
@@ -760,6 +761,24 @@ public long collectReplaySymbolsAbove(int baseline, ObjList<String> out) {
760761
);
761762
}
762763

764+
/**
765+
* Decodes the cached recovery suffix directly into the producer's global
766+
* dictionary. Recovery always builds the analysis with the persisted
767+
* prefix size as its baseline, so no intermediate cardinality-sized list is
768+
* needed on the production path.
769+
*/
770+
public long addRecoveredSymbolsTo(int baseline, GlobalSymbolDictionary target) {
771+
if (recoveredFrameAnalysis == null) {
772+
return baseline;
773+
}
774+
RecoveredFrameAnalysis analysis = checkedRecoveryAnalysis(baseline);
775+
long coverage = analysis.coverage();
776+
if (coverage >= 0L) {
777+
analysis.addDecodedSymbolsTo(target);
778+
}
779+
return coverage;
780+
}
781+
763782
long recoveredSymbolCoverage(int baseline) {
764783
return checkedRecoveryAnalysis(baseline).coverage();
765784
}
@@ -785,6 +804,11 @@ public long recoveryFramesVisited() {
785804
return recoveredFrameAnalysis == null ? 0L : recoveredFrameAnalysis.framesVisited();
786805
}
787806

807+
@TestOnly
808+
public long recoverySymbolEntriesVisited() {
809+
return recoveredFrameAnalysis == null ? 0L : recoveredFrameAnalysis.symbolEntriesVisited();
810+
}
811+
788812
private RecoveredFrameAnalysis checkedRecoveryAnalysis(int baseline) {
789813
if (recoveredFrameAnalysis == null || recoveredFrameAnalysis.baseline() != baseline) {
790814
throw new IllegalStateException("recovery symbol baseline mismatch [expected="

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
299299
private long sentDictBytesAddr;
300300
private int sentDictBytesCapacity;
301301
private int sentDictBytesLen;
302+
// False only while an orphan-drainer loop borrows the persisted prefix.
303+
// Any growth first performs a copy-on-write; only owned buffers are freed.
304+
private boolean sentDictBytesOwned;
302305
private int sentDictCount;
303306
// Reusable staging frame for dictionary catch-up chunks. A reconnect may split a
304307
// large dictionary into many chunks, and every later reconnect repeats that split;
@@ -721,24 +724,17 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
721724
// tracks. When the dictionary could not be opened, pd is null, the mirror
722725
// simply starts empty and grows from the frames themselves.
723726
PersistedSymbolDict pd = engine.getPersistedSymbolDict();
727+
int persistedPrefixLen = 0;
724728
if (pd != null && pd.size() > 0) {
725729
int len = pd.loadedEntriesLen();
726730
if (len > 0) {
727-
// COPY the persisted dictionary's loaded-entries buffer into this
728-
// loop's own mirror rather than taking ownership of it. The engine
729-
// (and its PersistedSymbolDict) OUTLIVES this loop on the orphan
730-
// drainer path: BackgroundDrainer builds a fresh send loop per wire
731-
// session against the same engine on a durable-ack capability-gap
732-
// recycle. A one-shot ownership transfer would leave every loop
733-
// after the first with an EMPTY mirror -- it would then send no
734-
// reconnect catch-up, and the first replayed delta frame
735-
// (deltaStart > 0) would trip the torn-dict guard, falsely
736-
// quarantining a healthy slot. Copying keeps the dictionary's
737-
// loaded entries intact for the engine's lifetime so every
738-
// recycled loop re-seeds; pd.close() (at engine close) frees the
739-
// dictionary's copy, this loop frees its own copy on exit.
740-
sentDictBytesAddr = Unsafe.malloc(len, MemoryTag.NATIVE_DEFAULT);
741-
Unsafe.getUnsafe().copyMemory(pd.loadedEntriesAddr(), sentDictBytesAddr, len);
731+
// Seed by reference. The foreground loop takes ownership after
732+
// construction succeeds; orphan-drainer loops keep borrowing because
733+
// one engine can create several sessions during capability-gap
734+
// recycling. A borrowed mirror performs copy-on-write if a recovered
735+
// frame suffix must extend it.
736+
persistedPrefixLen = len;
737+
sentDictBytesAddr = pd.loadedEntriesAddr();
742738
sentDictBytesCapacity = len;
743739
sentDictBytesLen = len;
744740
// Set the count only alongside the bytes so sentDictCount can
@@ -786,16 +782,25 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
786782
}
787783
}
788784
} catch (Throwable t) {
789-
if (sentDictBytesAddr != 0) {
790-
Unsafe.free(sentDictBytesAddr, sentDictBytesCapacity, MemoryTag.NATIVE_DEFAULT);
791-
sentDictBytesAddr = 0;
792-
sentDictBytesCapacity = 0;
793-
sentDictBytesLen = 0;
794-
sentDictCount = 0;
795-
}
785+
releaseSentDictBytes();
796786
throw t;
797787
}
798788
}
789+
// QwpWebSocketSender seeds the producer dictionary before constructing
790+
// its one foreground loop, so that loop can own the recovered prefix and
791+
// eliminate the engine-side duplicate. BackgroundDrainer must retain the
792+
// prefix for later recycled loops and therefore keeps borrowing it.
793+
if (persistedPrefixLen > 0 && catchUpCapGapPolicy == CatchUpCapGapPolicy.RETRY_FOREVER) {
794+
long persistedPrefixAddr = pd.takeLoadedEntries();
795+
if (sentDictBytesOwned) {
796+
// Copy-on-grow already produced the combined mirror. Retire the
797+
// no-longer-needed persisted prefix after successful construction.
798+
Unsafe.free(persistedPrefixAddr, persistedPrefixLen, MemoryTag.NATIVE_DEFAULT);
799+
} else {
800+
assert persistedPrefixAddr == sentDictBytesAddr;
801+
sentDictBytesOwned = true;
802+
}
803+
}
799804
this.fsnAtZero = fsnAtZero;
800805
this.parkNanos = parkNanos;
801806
this.reconnectFactory = reconnectFactory;
@@ -1131,15 +1136,7 @@ public synchronized void close() {
11311136
// thread may still be mid-send, so touching the mirror here would race.
11321137
// A duplicate close observes sentDictBytesAddr == 0 and skips.
11331138
if (loopNeverRan && sentDictBytesAddr != 0) {
1134-
Unsafe.free(sentDictBytesAddr, sentDictBytesCapacity, MemoryTag.NATIVE_DEFAULT);
1135-
sentDictBytesAddr = 0;
1136-
sentDictBytesCapacity = 0;
1137-
sentDictBytesLen = 0;
1138-
// Reset the count alongside the buffer so the mirror stays all-or-
1139-
// nothing: a hypothetical close()-then-start() (start() has no closed
1140-
// guard) must not observe a non-zero sentDictCount against a freed
1141-
// buffer and drive setWireBaselineWithCatchUp into a null-mirror catch-up.
1142-
sentDictCount = 0;
1139+
releaseSentDictBytes();
11431140
}
11441141
if (loopNeverRan) {
11451142
freeCatchUpFrameBuffer();
@@ -2048,11 +2045,7 @@ private void ioLoop() {
20482045
// The symbol-dict mirror is I/O-thread-owned; free it here, on the
20492046
// owning thread's exit path, after the last send that could touch it.
20502047
if (sentDictBytesAddr != 0) {
2051-
Unsafe.free(sentDictBytesAddr, sentDictBytesCapacity, MemoryTag.NATIVE_DEFAULT);
2052-
sentDictBytesAddr = 0;
2053-
sentDictBytesCapacity = 0;
2054-
sentDictBytesLen = 0;
2055-
sentDictCount = 0; // keep the mirror all-or-nothing (see close())
2048+
releaseSentDictBytes();
20562049
}
20572050
freeCatchUpFrameBuffer();
20582051
shutdownLatch.countDown();
@@ -2438,10 +2431,34 @@ private void ensureSentDictCapacity(long required) {
24382431
if (newCap > MAX_SENT_DICT_BYTES) {
24392432
newCap = MAX_SENT_DICT_BYTES;
24402433
}
2441-
sentDictBytesAddr = Unsafe.realloc(sentDictBytesAddr, sentDictBytesCapacity, (int) newCap, MemoryTag.NATIVE_DEFAULT);
2434+
if (sentDictBytesOwned) {
2435+
sentDictBytesAddr = Unsafe.realloc(
2436+
sentDictBytesAddr,
2437+
sentDictBytesCapacity,
2438+
(int) newCap,
2439+
MemoryTag.NATIVE_DEFAULT);
2440+
} else {
2441+
long newAddr = Unsafe.malloc((int) newCap, MemoryTag.NATIVE_DEFAULT);
2442+
if (sentDictBytesLen > 0) {
2443+
Unsafe.getUnsafe().copyMemory(sentDictBytesAddr, newAddr, sentDictBytesLen);
2444+
}
2445+
sentDictBytesAddr = newAddr;
2446+
sentDictBytesOwned = true;
2447+
}
24422448
sentDictBytesCapacity = (int) newCap;
24432449
}
24442450

2451+
private void releaseSentDictBytes() {
2452+
if (sentDictBytesAddr != 0 && sentDictBytesOwned) {
2453+
Unsafe.free(sentDictBytesAddr, sentDictBytesCapacity, MemoryTag.NATIVE_DEFAULT);
2454+
}
2455+
sentDictBytesAddr = 0;
2456+
sentDictBytesCapacity = 0;
2457+
sentDictBytesLen = 0;
2458+
sentDictBytesOwned = false;
2459+
sentDictCount = 0;
2460+
}
2461+
24452462
private long readVarintAt(long p, long limit) {
24462463
long value = 0;
24472464
int shift = 0;

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -364,8 +364,9 @@ public static MmapSegment openExisting(FilesFacade ff, String path) {
364364
throw new MmapSegmentException(
365365
"bad baseSeq in " + path + ": " + baseSeq);
366366
}
367-
long lastGood = scanFrames(addr, fileSize);
368-
long count = countFrames(addr, lastGood);
367+
FrameScan scan = scanFrames(addr, fileSize);
368+
long lastGood = scan.lastGood;
369+
long count = scan.frameCount;
369370
long tornTail = detectTornTail(addr, lastGood, fileSize);
370371
if (tornTail > 0) {
371372
LOG.warn("SF segment {}: torn tail of {} bytes at offset {} "
@@ -907,22 +908,23 @@ private static boolean isMmapAccessFault(Throwable t) {
907908
}
908909

909910
/**
910-
* Forward scan that returns the offset just past the last frame whose
911-
* CRC verifies. A torn-tail frame (declared length runs past EOF, or
912-
* CRC mismatch) leaves both cursors at the start of that frame; the
913-
* next {@link #tryAppend} will overwrite it. The scan only reads from
914-
* the mapping — no syscalls.
911+
* Forward scan that returns the offset just past the last frame whose CRC
912+
* verifies and the number of verified frames. A torn-tail frame (declared
913+
* length runs past EOF, or CRC mismatch) leaves both cursors at the start of
914+
* that frame; the next {@link #tryAppend} will overwrite it. The scan only
915+
* reads from the mapping — no syscalls.
915916
*/
916-
private static long scanFrames(long addr, long fileSize) {
917+
private static FrameScan scanFrames(long addr, long fileSize) {
917918
long pos = HEADER_SIZE;
919+
long frameCount = 0L;
918920
try {
919921
while (pos + FRAME_HEADER_SIZE <= fileSize) {
920922
int crcRead = Unsafe.getUnsafe().getInt(addr + pos);
921923
int payloadLen = Unsafe.getUnsafe().getInt(addr + pos + 4);
922924
// Defensive: a corrupt length field could be enormous or negative,
923925
// both of which would otherwise overrun the mapping.
924926
if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize) {
925-
return pos;
927+
return new FrameScan(pos, frameCount);
926928
}
927929
// CRC over the contiguous (payloadLen, payload) pair, folded
928930
// via Unsafe reads rather than the native Crc32c.update.
@@ -945,9 +947,10 @@ private static long scanFrames(long addr, long fileSize) {
945947
// table CRC here is immaterial.
946948
int crcCalc = crc32cRecovery(addr + pos + 4, 4L + payloadLen);
947949
if (crcCalc != crcRead) {
948-
return pos;
950+
return new FrameScan(pos, frameCount);
949951
}
950952
pos += FRAME_HEADER_SIZE + payloadLen;
953+
frameCount++;
951954
}
952955
} catch (InternalError e) {
953956
// The read at `pos` hit a mapped page that is not backed by real
@@ -973,7 +976,7 @@ private static long scanFrames(long addr, long fileSize) {
973976
+ "if this recurs.",
974977
pos, fileSize);
975978
}
976-
return pos;
979+
return new FrameScan(pos, frameCount);
977980
}
978981

979982
/**
@@ -1049,19 +1052,13 @@ private static long detectTornTail(long addr, long lastGood, long fileSize) {
10491052
return 0L;
10501053
}
10511054

1052-
/**
1053-
* Counts frames in {@code [HEADER_SIZE, lastGood)}. Walks the framing in
1054-
* lockstep with {@link #scanFrames} (which already validated CRCs); so
1055-
* this is just length-driven traversal, no CRC re-check.
1056-
*/
1057-
private static long countFrames(long addr, long lastGood) {
1058-
long pos = HEADER_SIZE;
1059-
long count = 0;
1060-
while (pos < lastGood) {
1061-
int payloadLen = Unsafe.getUnsafe().getInt(addr + pos + 4);
1062-
pos += FRAME_HEADER_SIZE + payloadLen;
1063-
count++;
1055+
private static final class FrameScan {
1056+
private final long frameCount;
1057+
private final long lastGood;
1058+
1059+
private FrameScan(long lastGood, long frameCount) {
1060+
this.lastGood = lastGood;
1061+
this.frameCount = frameCount;
10641062
}
1065-
return count;
10661063
}
10671064
}

0 commit comments

Comments
 (0)