Skip to content

Commit 5617d79

Browse files
committed
Optimize symbol dictionary recovery and catch-up
1 parent 6328467 commit 5617d79

9 files changed

Lines changed: 716 additions & 39 deletions

File tree

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

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ public final class CursorSendEngine implements QuietCloseable {
137137
// constructor, closed by {@link #close()}. When null in disk mode the engine
138138
// reports delta encoding as unavailable and the sender keeps full-dict frames.
139139
private final PersistedSymbolDict persistedSymbolDict;
140+
// Engine-owned output of the single ordered recovery walk. It is retained
141+
// because both producer seeding and every recycled send loop need the same
142+
// frame-rebuilt symbol suffix. Null for fresh and memory-only engines.
143+
private final RecoveredFrameAnalysis recoveredFrameAnalysis;
140144
// close() is publicly callable from any thread (Sender.close from a user
141145
// thread, JVM shutdown hooks, test cleanup). volatile + synchronized
142146
// close() makes the check-and-set atomic and gives readers a fence.
@@ -248,6 +252,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
248252
SegmentRing ringInProgress = null;
249253
AckWatermark watermarkInProgress = null;
250254
PersistedSymbolDict persistedDictInProgress = null;
255+
RecoveredFrameAnalysis recoveredFrameAnalysisInProgress = null;
251256
try {
252257
// Disk mode: try to recover any *.sfa files left behind by a prior
253258
// session before deciding to start fresh. Without this the engine
@@ -329,6 +334,12 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
329334
if (seed >= 0) {
330335
recovered.acknowledge(seed);
331336
}
337+
// Fold the whole recovered ring once. The result checkpoints all
338+
// running metadata and raw symbol bytes at each commit-bearing
339+
// frame, so its final snapshot excludes an orphan deferred tail
340+
// without requiring a second bounded scan.
341+
recoveredFrameAnalysisInProgress = recovered.analyzeRecovery(
342+
persistedDictInProgress == null ? 0 : persistedDictInProgress.size());
332343
// Locate the last commit-bearing frame below a potentially
333344
// orphaned FLAG_DEFER_COMMIT tail. A producer that crashed (or
334345
// closed) mid-transaction leaves deferred frames with no
@@ -338,12 +349,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
338349
// drainOnClose), and (b) replaying them into a NEW session's
339350
// commit would resurrect half a transaction -- see the WARN
340351
// below. Computed before the I/O loop or producer append.
341-
this.recoveredCommitBoundaryFsn = recovered.findLastFsnWithoutPayloadFlag(
342-
QwpConstants.HEADER_OFFSET_FLAGS,
343-
QwpConstants.FLAG_DEFER_COMMIT,
344-
QwpConstants.MAGIC_MESSAGE,
345-
QwpConstants.HEADER_SIZE
346-
);
352+
this.recoveredCommitBoundaryFsn = recoveredFrameAnalysisInProgress.commitBoundaryFsn();
347353
if (publishedFsn >= 0 && recoveredCommitBoundaryFsn < publishedFsn) {
348354
this.recoveredOrphanTipFsn = publishedFsn;
349355
LOG.warn("recovered SF log ends with {} deferred frame(s) whose transaction was never "
@@ -363,12 +369,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
363369
// this and over-reject an otherwise-recoverable slot. maxSymbolDeltaEnd
364370
// returns 0 when no such frame carries a symbol, yielding -1 here.
365371
// Computed before the I/O loop or producer append; single-threaded.
366-
this.recoveredMaxSymbolId = recovered.maxSymbolDeltaEnd(
367-
QwpConstants.MAGIC_MESSAGE,
368-
QwpConstants.HEADER_OFFSET_FLAGS,
369-
QwpConstants.FLAG_DELTA_SYMBOL_DICT,
370-
QwpConstants.HEADER_SIZE,
371-
recoveredCommitBoundaryFsn) - 1L;
372+
this.recoveredMaxSymbolId = recoveredFrameAnalysisInProgress.maxDeltaEnd() - 1L;
372373
// Full-dict-fallback recovery. When the persisted .symbol-dict is a
373374
// SUBSET of the ids the surviving frames reference
374375
// (recoveredMaxSymbolId >= its size) YET every such frame is
@@ -388,12 +389,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
388389
// dictionary. The recoveredMaxSymbolId >= size guard means this never
389390
// fires for a slot whose dictionary is intact, nor for an empty slot
390391
// (recoveredMaxSymbolId == -1). Single-threaded; before the I/O loop.
391-
this.recoveredMaxSymbolDeltaStart = recovered.maxSymbolDeltaStart(
392-
QwpConstants.MAGIC_MESSAGE,
393-
QwpConstants.HEADER_OFFSET_FLAGS,
394-
QwpConstants.FLAG_DELTA_SYMBOL_DICT,
395-
QwpConstants.HEADER_SIZE,
396-
recoveredCommitBoundaryFsn);
392+
this.recoveredMaxSymbolDeltaStart = recoveredFrameAnalysisInProgress.maxDeltaStart();
397393
if (persistedDictInProgress != null
398394
&& recoveredMaxSymbolId >= persistedDictInProgress.size()
399395
&& recoveredMaxSymbolDeltaStart == 0L) {
@@ -451,6 +447,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
451447
this.ring = ringInProgress;
452448
this.watermark = watermarkInProgress;
453449
this.persistedSymbolDict = persistedDictInProgress;
450+
this.recoveredFrameAnalysis = recoveredFrameAnalysisInProgress;
454451
} catch (Throwable t) {
455452
// Stop an owned manager before freeing the ring and watermark it may
456453
// touch, then release the slot lock. Each cleanup is in its own
@@ -482,6 +479,12 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
482479
} catch (Throwable ignored) {
483480
}
484481
}
482+
if (recoveredFrameAnalysisInProgress != null) {
483+
try {
484+
recoveredFrameAnalysisInProgress.close();
485+
} catch (Throwable ignored) {
486+
}
487+
}
485488
if (acquiredLock != null) {
486489
try {
487490
acquiredLock.close();
@@ -667,6 +670,12 @@ public synchronized void close() {
667670
} catch (Throwable ignored) {
668671
}
669672
}
673+
if (recoveredFrameAnalysis != null) {
674+
try {
675+
recoveredFrameAnalysis.close();
676+
} catch (Throwable ignored) {
677+
}
678+
}
670679
if (fullyDrained) {
671680
try {
672681
unlinkAllSegmentFiles(sfDir);
@@ -716,6 +725,11 @@ public synchronized void close() {
716725
* transmitted, so their ids never reach a server and must not inflate the baseline.
717726
*/
718727
public long collectReplaySymbolsAbove(int baseline, ObjList<String> out) {
728+
if (recoveredFrameAnalysis != null
729+
&& recoveredFrameAnalysis.baseline() == baseline) {
730+
recoveredFrameAnalysis.appendDecodedSymbols(out);
731+
return recoveredFrameAnalysis.coverage();
732+
}
719733
if (ring == null) {
720734
return baseline;
721735
}
@@ -738,6 +752,40 @@ public long collectReplaySymbolsAbove(int baseline, ObjList<String> out) {
738752
);
739753
}
740754

755+
long recoveredSymbolCoverage(int baseline) {
756+
return checkedRecoveryAnalysis(baseline).coverage();
757+
}
758+
759+
int recoveredSymbolSuffixCount(int baseline) {
760+
return checkedRecoveryAnalysis(baseline).rawCount();
761+
}
762+
763+
int recoveredSymbolSuffixLen(int baseline) {
764+
return checkedRecoveryAnalysis(baseline).rawLen();
765+
}
766+
767+
void copyRecoveredSymbolSuffix(int baseline, long target) {
768+
RecoveredFrameAnalysis analysis = checkedRecoveryAnalysis(baseline);
769+
int len = analysis.rawLen();
770+
if (len > 0) {
771+
io.questdb.client.std.Unsafe.getUnsafe().copyMemory(analysis.rawAddr(), target, len);
772+
}
773+
}
774+
775+
@TestOnly
776+
public long recoveryFramesVisited() {
777+
return recoveredFrameAnalysis == null ? 0L : recoveredFrameAnalysis.framesVisited();
778+
}
779+
780+
private RecoveredFrameAnalysis checkedRecoveryAnalysis(int baseline) {
781+
if (recoveredFrameAnalysis == null || recoveredFrameAnalysis.baseline() != baseline) {
782+
throw new IllegalStateException("recovery symbol baseline mismatch [expected="
783+
+ (recoveredFrameAnalysis == null ? "none" : recoveredFrameAnalysis.baseline())
784+
+ ", actual=" + baseline + ']');
785+
}
786+
return recoveredFrameAnalysis;
787+
}
788+
741789
/**
742790
* Pass-through to {@link SegmentRing#findSegmentContaining(long)}.
743791
*/

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

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
import io.questdb.client.std.MemoryTag;
4545
import io.questdb.client.std.QuietCloseable;
4646
import io.questdb.client.std.str.Utf8s;
47-
import io.questdb.client.std.ObjList;
4847
import io.questdb.client.std.Unsafe;
4948
import org.jetbrains.annotations.TestOnly;
5049
import org.slf4j.Logger;
@@ -195,7 +194,7 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
195194
* Throttle "reconnect attempt N failed" WARN logs to one per 5 s.
196195
*/
197196
private static final long RECONNECT_LOG_THROTTLE_NANOS = 5_000_000_000L;
198-
// Test seam: when true, appendSymbolToMirror throws instead of appending,
197+
// Test seam: when true, recovery mirror seeding throws before growing,
199198
// simulating the native realloc OOM (or MAX_SENT_DICT_BYTES ceiling) that
200199
// ensureSentDictCapacity can raise while the constructor seeds the recovery
201200
// mirror. Lets a test prove the constructor frees the already-malloc'd mirror on
@@ -300,6 +299,13 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
300299
private int sentDictBytesCapacity;
301300
private int sentDictBytesLen;
302301
private int sentDictCount;
302+
// Reusable staging frame for dictionary catch-up chunks. A reconnect may split a
303+
// large dictionary into many chunks, and every later reconnect repeats that split;
304+
// retaining one growable loop-owned buffer turns the old malloc/free-per-chunk path
305+
// into amortized growth only. I/O-thread-owned, freed beside sentDictBytesAddr.
306+
private long catchUpFrameAddr;
307+
private int catchUpFrameCapacity;
308+
private int catchUpFrameGrowthCount;
303309
// Orphan-policy cap-gap attempts -- catch-ups that reached a node and found an entry
304310
// too large for its batch cap -- with no intervening successful catch-up or unrelated
305311
// reconnect state (see MAX_CATCHUP_CAP_GAP_ATTEMPTS for the full budget accounting).
@@ -750,18 +756,28 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
750756
// catch-up-free.
751757
if (engine.recoveredMaxSymbolDeltaStart() > 0L) {
752758
// The prefix seed above may already have malloc'd the mirror, and
753-
// appendSymbolToMirror -> ensureSentDictCapacity can still throw here (a
754-
// native realloc OOM, or the MAX_SENT_DICT_BYTES ceiling). Such a throw
759+
// ensureSentDictCapacity can still throw here (a native realloc OOM, or
760+
// the MAX_SENT_DICT_BYTES ceiling). Such a throw
755761
// propagates out of the constructor, so the half-built loop is never
756762
// assigned to a reference -- neither ensureConnected's catch nor
757763
// BackgroundDrainer's finally can close() it -- and the mirror would leak.
758764
// Free it on any throw so the constructor leaves nothing behind, mirroring
759765
// the loopNeverRan free in close() / ioLoop's exit.
760766
try {
761-
ObjList<String> fromFrames = new ObjList<>();
762-
if (engine.collectReplaySymbolsAbove(sentDictCount, fromFrames) >= 0) {
763-
for (int i = 0, n = fromFrames.size(); i < n; i++) {
764-
appendSymbolToMirror(fromFrames.getQuick(i));
767+
int baseline = sentDictCount;
768+
if (engine.recoveredSymbolCoverage(baseline) >= 0L) {
769+
int suffixLen = engine.recoveredSymbolSuffixLen(baseline);
770+
int suffixCount = engine.recoveredSymbolSuffixCount(baseline);
771+
if (suffixLen > 0) {
772+
if (forceMirrorSeedFailureForTest) {
773+
throw new LineSenderException(
774+
"simulated mirror seed allocation failure (test only)");
775+
}
776+
ensureSentDictCapacity((long) sentDictBytesLen + suffixLen);
777+
engine.copyRecoveredSymbolSuffix(
778+
baseline, sentDictBytesAddr + sentDictBytesLen);
779+
sentDictBytesLen += suffixLen;
780+
sentDictCount += suffixCount;
765781
}
766782
}
767783
} catch (Throwable t) {
@@ -1120,6 +1136,9 @@ public synchronized void close() {
11201136
// buffer and drive setWireBaselineWithCatchUp into a null-mirror catch-up.
11211137
sentDictCount = 0;
11221138
}
1139+
if (loopNeverRan) {
1140+
freeCatchUpFrameBuffer();
1141+
}
11231142
}
11241143

11251144
/**
@@ -2030,6 +2049,7 @@ private void ioLoop() {
20302049
sentDictBytesLen = 0;
20312050
sentDictCount = 0; // keep the mirror all-or-nothing (see close())
20322051
}
2052+
freeCatchUpFrameBuffer();
20332053
shutdownLatch.countDown();
20342054
// Failed-stop hand-off (see delegateEngineClose): the owner could
20352055
// not free the engine safely while this thread was alive, so the
@@ -2613,7 +2633,8 @@ private void sendCatchUpChunk(int deltaStart, int deltaCount, long symbolsAddr,
26132633
}
26142634
int payloadLen = (int) payloadLenL;
26152635
int frameLen = (int) frameLenL;
2616-
long frame = Unsafe.malloc(frameLen, MemoryTag.NATIVE_DEFAULT);
2636+
ensureCatchUpFrameCapacity(frameLen);
2637+
long frame = catchUpFrameAddr;
26172638
try {
26182639
Unsafe.getUnsafe().putByte(frame, (byte) 'Q');
26192640
Unsafe.getUnsafe().putByte(frame + 1, (byte) 'W');
@@ -2647,14 +2668,42 @@ private void sendCatchUpChunk(int deltaStart, int deltaCount, long symbolsAddr,
26472668
throw (Error) t;
26482669
}
26492670
throw new CatchUpSendException(t);
2650-
} finally {
2651-
Unsafe.free(frame, frameLen, MemoryTag.NATIVE_DEFAULT);
26522671
}
26532672
nextWireSeq++; // this catch-up chunk consumed a wire sequence
26542673
lastFrameOrPingNanos = System.nanoTime();
26552674
totalFramesSent.incrementAndGet();
26562675
}
26572676

2677+
@TestOnly
2678+
public int catchUpFrameGrowthCount() {
2679+
return catchUpFrameGrowthCount;
2680+
}
2681+
2682+
private void ensureCatchUpFrameCapacity(int required) {
2683+
if (catchUpFrameCapacity >= required) {
2684+
return;
2685+
}
2686+
long newCapacity = Math.max(required, Math.max(4096L, (long) catchUpFrameCapacity * 2L));
2687+
if (newCapacity > MAX_SENT_DICT_BYTES) {
2688+
newCapacity = MAX_SENT_DICT_BYTES;
2689+
}
2690+
catchUpFrameAddr = Unsafe.realloc(
2691+
catchUpFrameAddr,
2692+
catchUpFrameCapacity,
2693+
(int) newCapacity,
2694+
MemoryTag.NATIVE_DEFAULT);
2695+
catchUpFrameCapacity = (int) newCapacity;
2696+
catchUpFrameGrowthCount++;
2697+
}
2698+
2699+
private void freeCatchUpFrameBuffer() {
2700+
if (catchUpFrameAddr != 0L) {
2701+
Unsafe.free(catchUpFrameAddr, catchUpFrameCapacity, MemoryTag.NATIVE_DEFAULT);
2702+
catchUpFrameAddr = 0L;
2703+
catchUpFrameCapacity = 0;
2704+
}
2705+
}
2706+
26582707
private boolean tryReceiveAcks() {
26592708
boolean any = false;
26602709
try {

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,20 @@ public long collectReplaySymbolsAbove(
639639
return coverage;
640640
}
641641

642+
/**
643+
* Feeds every recovered frame in this segment to the engine's single-pass
644+
* recovery fold. Segments are visited oldest-first by {@link SegmentRing}.
645+
*/
646+
void scanRecovery(RecoveredFrameAnalysis analysis) {
647+
long off = HEADER_SIZE;
648+
long frames = frameCount;
649+
for (long i = 0; i < frames; i++) {
650+
int payloadLen = Unsafe.getUnsafe().getInt(mmapAddress + off + 4);
651+
analysis.accept(baseSeq + i, mmapAddress + off + FRAME_HEADER_SIZE, payloadLen);
652+
off += FRAME_HEADER_SIZE + payloadLen;
653+
}
654+
}
655+
642656
/**
643657
* Highest {@code deltaStart + deltaCount} (one past the highest symbol id) any
644658
* delta-flagged frame in this segment carries, or {@code 0} only when NO

0 commit comments

Comments
 (0)