@@ -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 */
0 commit comments