Skip to content

Commit 509b372

Browse files
glasstigerclaude
andcommitted
Cut three redundant walks out of recovery and the flush path
C9. The full-dict-fallback discard re-folded the whole recovered ring even when the dictionary it discarded was EMPTY -- and that is the ordinary client-upgrade path, not the rare one the comment described. Every frame the shipped client ever wrote passes confirmedMaxId = -1, so deltaStart is 0 and maxDeltaStart is 0; such a slot has no .symbol-dict, so recovery creates a fresh empty one. All three discard conditions hold on the first restart after upgrading, for every non-empty slot, and the first fold was already keyed to that size-0 baseline -- so the re-fold recomputed a bit-identical result over the entire backlog, with a byte-at-a-time varint decode over full-dict frames that carry the whole dictionary, inside build(). It now runs only when the discarded dictionary held entries, which is the only case that can desynchronise the fold from the baseline its consumers derive. C11. PersistedSymbolDict.openExisting walked the file twice: once to CRC every chunk and total the surviving entry bytes, then again -- re-decoding both header varints of every chunk -- purely to copy into an exact-sized allocation. The entry region is by construction a subset of the file, so the file length bounds it for free: one pass now validates and copies together into a length-sized buffer, then shrinks to the exact size. The trusted prefix is unchanged. The same pass gained an entryBytes <= 0 guard. Every entry costs at least its own length varint, so a positive entryCount inside a zero-byte region is self-contradictory -- but the CRC only proves the bytes are what was written, never that the header triple is consistent, so the scan accepted such a chunk and left decodeLoadedSymbols to discover it later, as a throw two layers up rather than a trimmed prefix. C12. Each pass of the flush path re-walked tableBuffers.keys() and re-probed the hash map per table: three probes per table on a plain flush, five on a split, on the producer's thread. The non-empty tables are now collected once into two reusable lists and every pass indexes them directly -- which also removes the bodyIdx bookkeeping that had to stay aligned with splitFrameBodyBytes by hand. Regression tests, each verified to fail with its fix reverted: testEmptyDictionaryDiscardDoesNotReFoldTheBacklog (expected:<1> but was:<2>) with testPopulatedDictionaryDiscardStillReFolds as its counterpart, and testChunkClaimingEntriesInAZeroByteRegionEndsTheTrustedPrefix (expected:<0> but was:<1>). Observing C9 needed a new engine-level recoveryFoldCount: recoveryFramesVisited() cannot see a second fold because the re-fold replaces the analysis instance. C12 has no new test. Its behaviour is unchanged by construction and its real risk -- index alignment across the collected lists and splitFrameBodyBytes -- is already exercised by the two split tests that drive two distinct tables (testSplitBatchShipsDeltaOnFirstFrameOnly, testOversizedTableSplitStrandsNothing). Full client suite: 2696 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3f98352 commit 509b372

5 files changed

Lines changed: 237 additions & 91 deletions

File tree

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

Lines changed: 40 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,14 @@ public class QwpWebSocketSender implements Sender {
175175
// encode. flushPendingRowsSplit uses them both for preflight sizing and to walk
176176
// the staged body slices without encoding the batch a second time. Cleared and
177177
// repopulated on every flush; reused to stay zero-GC.
178+
// The non-empty tables of the batch currently being flushed, collected ONCE per
179+
// flush and then iterated by every pass. Each pass used to re-walk tableBuffers.keys()
180+
// and re-probe the hash map per table -- 3 probes per table on a plain flush and 5 on
181+
// a split, on the producer's thread. Reused across flushes to stay zero-GC. Held in
182+
// lockstep so index i names the same table in both, and in the same order as
183+
// splitFrameBodyBytes, which is what lets the split passes index them directly.
184+
private final ObjList<QwpTableBuffer> flushTableBuffers = new ObjList<>();
185+
private final ObjList<CharSequence> flushTableNames = new ObjList<>();
178186
private final IntList splitFrameBodyBytes = new IntList();
179187
private final CharSequenceObjHashMap<QwpTableBuffer> tableBuffers;
180188
// null means plain text (no TLS)
@@ -3214,19 +3222,26 @@ private void checkTableSelected() {
32143222
}
32153223
}
32163224

3217-
private int countNonEmptyTables(ObjList<CharSequence> keys) {
3218-
int tableCount = 0;
3225+
/**
3226+
* Collects the batch's non-empty tables into {@link #flushTableNames} /
3227+
* {@link #flushTableBuffers} and returns how many there are. One walk of the key
3228+
* list and one hash probe per table, for the whole flush.
3229+
*/
3230+
private int collectNonEmptyTables(ObjList<CharSequence> keys) {
3231+
flushTableNames.clear();
3232+
flushTableBuffers.clear();
32193233
for (int i = 0, n = keys.size(); i < n; i++) {
32203234
CharSequence tableName = keys.getQuick(i);
32213235
if (tableName == null) {
32223236
continue;
32233237
}
32243238
QwpTableBuffer tableBuffer = tableBuffers.get(tableName);
32253239
if (tableBuffer != null && tableBuffer.getRowCount() > 0) {
3226-
tableCount++;
3240+
flushTableNames.add(tableName);
3241+
flushTableBuffers.add(tableBuffer);
32273242
}
32283243
}
3229-
return tableCount;
3244+
return flushTableBuffers.size();
32303245
}
32313246

32323247
private Endpoint currentEndpoint() {
@@ -3552,7 +3567,7 @@ private void flushPendingRows(boolean deferCommit) {
35523567
cachedTimestampNanosColumn = null;
35533568

35543569
ObjList<CharSequence> keys = tableBuffers.keys();
3555-
int tableCount = countNonEmptyTables(keys);
3570+
int tableCount = collectNonEmptyTables(keys);
35563571
if (tableCount == 0) {
35573572
pendingBytes = 0;
35583573
currentTableBufferSnapshotBytes = currentTableBuffer == null
@@ -3584,19 +3599,12 @@ private void flushPendingRows(boolean deferCommit) {
35843599
splitFrameBodyBytes.clear();
35853600
int combinedBodyStart = encoder.getBuffer().getPosition();
35863601
int bodyStart = combinedBodyStart;
3587-
for (int i = 0, n = keys.size(); i < n; i++) {
3588-
CharSequence tableName = keys.getQuick(i);
3589-
if (tableName == null) {
3590-
continue;
3591-
}
3592-
QwpTableBuffer tableBuffer = tableBuffers.get(tableName);
3593-
if (tableBuffer == null || tableBuffer.getRowCount() == 0) {
3594-
continue;
3595-
}
3602+
for (int i = 0; i < tableCount; i++) {
3603+
QwpTableBuffer tableBuffer = flushTableBuffers.getQuick(i);
35963604

35973605
if (LOG.isDebugEnabled()) {
35983606
LOG.debug("Encoding table [name={}, rows={}, batchMaxId={}]",
3599-
tableName, tableBuffer.getRowCount(), currentBatchMaxSymbolId);
3607+
flushTableNames.getQuick(i), tableBuffer.getRowCount(), currentBatchMaxSymbolId);
36003608
}
36013609

36023610
encoder.addTable(tableBuffer);
@@ -3618,7 +3626,7 @@ private void flushPendingRows(boolean deferCommit) {
36183626
if (cap > 0 && messageSize > cap) {
36193627
// Keep the completed combined frame staged in the encoder while the
36203628
// split path copies its delta entries and table-body slices.
3621-
flushPendingRowsSplit(keys, deferCommit, combinedBodyStart, cap);
3629+
flushPendingRowsSplit(deferCommit, combinedBodyStart, cap);
36223630
return;
36233631
}
36243632

@@ -3640,7 +3648,7 @@ private void flushPendingRows(boolean deferCommit) {
36403648
lastCommitBoundaryFsn = cursorEngine.publishedFsn();
36413649
}
36423650

3643-
resetTableBuffersAfterFlush(keys);
3651+
resetTableBuffersAfterFlush();
36443652
}
36453653

36463654
/**
@@ -3671,7 +3679,6 @@ private void flushPendingRows(boolean deferCommit) {
36713679
* last message omits the flag.
36723680
*/
36733681
private void flushPendingRowsSplit(
3674-
ObjList<CharSequence> keys,
36753682
boolean deferCommit,
36763683
int combinedBodyStart,
36773684
int cap
@@ -3695,22 +3702,12 @@ private void flushPendingRowsSplit(
36953702
// already performed. simBaseline mirrors the publish loop's baseline advance
36963703
// (advanceSentMaxSymbolId), so each size equals the frame the staged-slice
36973704
// assembler will build; this pass mutates no delta/persist state.
3698-
int nonEmptyCount = 0;
3705+
int nonEmptyCount = flushTableBuffers.size();
36993706
int simBaseline = symbolDeltaBaseline();
3700-
int bodyIdx = 0;
3701-
for (int i = 0, n = keys.size(); i < n; i++) {
3702-
CharSequence tableName = keys.getQuick(i);
3703-
if (tableName == null) {
3704-
continue;
3705-
}
3706-
QwpTableBuffer tableBuffer = tableBuffers.get(tableName);
3707-
if (tableBuffer == null || tableBuffer.getRowCount() == 0) {
3708-
continue;
3709-
}
3710-
nonEmptyCount++;
3707+
for (int i = 0; i < nonEmptyCount; i++) {
3708+
CharSequence tableName = flushTableNames.getQuick(i);
37113709
int messageSize = encoder.getSplitMessageSize(
3712-
splitFrameBodyBytes.getQuick(bodyIdx), simBaseline, currentBatchMaxSymbolId);
3713-
bodyIdx++;
3710+
splitFrameBodyBytes.getQuick(i), simBaseline, currentBatchMaxSymbolId);
37143711
if (messageSize > cap) {
37153712
// The batch stays BUFFERED: this throw precedes every publish, and a
37163713
// rejected flush must not silently discard the caller's rows (see
@@ -3737,24 +3734,14 @@ private void flushPendingRowsSplit(
37373734
}
37383735
}
37393736

3740-
int sent = 0;
3741-
bodyIdx = 0;
37423737
int tableBodyOffset = combinedBodyStart;
3743-
for (int i = 0, n = keys.size(); i < n; i++) {
3744-
CharSequence tableName = keys.getQuick(i);
3745-
if (tableName == null) {
3746-
continue;
3747-
}
3748-
QwpTableBuffer tableBuffer = tableBuffers.get(tableName);
3749-
if (tableBuffer == null || tableBuffer.getRowCount() == 0) {
3750-
continue;
3751-
}
3738+
for (int i = 0; i < nonEmptyCount; i++) {
3739+
CharSequence tableName = flushTableNames.getQuick(i);
37523740

3753-
sent++;
3754-
boolean isLast = (sent == nonEmptyCount);
3741+
boolean isLast = (i == nonEmptyCount - 1);
37553742
boolean deferThis = deferCommit || !isLast;
37563743

3757-
int tableBodyLength = splitFrameBodyBytes.getQuick(bodyIdx++);
3744+
int tableBodyLength = splitFrameBodyBytes.getQuick(i);
37583745
// Persist before touching activeBuffer. If the write-ahead fails, the
37593746
// caller can retry with both the source rows and active microbatch
37603747
// unchanged. The first frame carries the batch's new symbols; later
@@ -3800,21 +3787,16 @@ private void flushPendingRowsSplit(
38003787
// committed the whole group.
38013788
lastCommitBoundaryFsn = cursorEngine.publishedFsn();
38023789
}
3803-
resetTableBuffersAfterFlush(keys);
3790+
resetTableBuffersAfterFlush();
38043791
}
38053792

3806-
private void resetTableBuffersAfterFlush(ObjList<CharSequence> keys) {
3807-
for (int i = 0, n = keys.size(); i < n; i++) {
3808-
CharSequence tableName = keys.getQuick(i);
3809-
if (tableName == null) {
3810-
continue;
3811-
}
3812-
QwpTableBuffer tableBuffer = tableBuffers.get(tableName);
3813-
if (tableBuffer == null || tableBuffer.getRowCount() == 0) {
3814-
continue;
3815-
}
3816-
tableBuffer.reset();
3793+
private void resetTableBuffersAfterFlush() {
3794+
for (int i = 0, n = flushTableBuffers.size(); i < n; i++) {
3795+
flushTableBuffers.getQuick(i).reset();
38173796
}
3797+
// Drop the references; the next flush re-collects.
3798+
flushTableNames.clear();
3799+
flushTableBuffers.clear();
38183800
currentBatchMaxSymbolId = -1;
38193801
pendingBytes = 0;
38203802
currentTableBufferSnapshotBytes = 0;

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

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ public final class CursorSendEngine implements QuietCloseable {
108108
// against the recovered dictionary size to fail clean instead. Computed once
109109
// in the constructor's recovery branch; -1 elsewhere.
110110
private long recoveredMaxSymbolId = -1L;
111+
// How many times the constructor folded the recovered ring. Observable because
112+
// recoveryFramesVisited() cannot be: the full-dict-fallback re-fold REPLACES the
113+
// analysis instance, resetting that counter, so a second fold is invisible through it.
114+
private int recoveryFoldCount;
111115
// Highest deltaStart across the recovered COMMITTED frames; 0 when none carries a symbol
112116
// dictionary. ZERO means every surviving frame is SELF-SUFFICIENT -- it re-registers its
113117
// dictionary from id 0 -- so the slot replays with no dictionary at all and the send loop
@@ -348,6 +352,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
348352
// without requiring a second bounded scan.
349353
recoveredFrameAnalysisInProgress = recovered.analyzeRecovery(
350354
persistedDictInProgress == null ? 0 : persistedDictInProgress.size());
355+
recoveryFoldCount++;
351356
// Locate the last commit-bearing frame below a potentially
352357
// orphaned FLAG_DEFER_COMMIT tail. A producer that crashed (or
353358
// closed) mid-transaction leaves deferred frames with no
@@ -401,6 +406,19 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
401406
if (persistedDictInProgress != null
402407
&& recoveredMaxSymbolId >= persistedDictInProgress.size()
403408
&& recoveredMaxSymbolDeltaStart == 0L) {
409+
// Only a NON-EMPTY discarded dictionary can desynchronise the fold.
410+
// The first fold was keyed to this very size, so when it is 0 the
411+
// re-fold below recomputes the identical baseline-0 result -- and
412+
// that is the OVERWHELMINGLY common case, not the rare one the
413+
// comment used to describe: every frame the shipped client ever
414+
// wrote passes confirmedMaxId = -1, hence deltaStart == 0, hence
415+
// maxDeltaStart == 0; and such a slot has no .symbol-dict, so
416+
// recovery creates a fresh EMPTY one. All three conditions therefore
417+
// hold on the FIRST restart after upgrading, for every non-empty
418+
// slot, and the re-fold was a second O(payload bytes) walk -- with a
419+
// byte-at-a-time varint decode over full-dict frames, which carry the
420+
// whole dictionary -- for no result change at all.
421+
int discardedSize = persistedDictInProgress.size();
404422
persistedDictInProgress.close();
405423
persistedDictInProgress = null;
406424
// Re-fold at baseline 0. The analysis above was keyed to the
@@ -427,11 +445,14 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
427445
// Re-folding rather than keeping the dictionary preserves the discard's
428446
// whole point -- the slot recovers in full-dict mode, exactly as it was
429447
// written, with producer, mirror and replay guard all anchored at 0.
430-
recoveredFrameAnalysisInProgress.close();
431-
recoveredFrameAnalysisInProgress = recovered.analyzeRecovery(0);
432-
this.recoveredCommitBoundaryFsn = recoveredFrameAnalysisInProgress.commitBoundaryFsn();
433-
this.recoveredMaxSymbolId = recoveredFrameAnalysisInProgress.maxDeltaEnd() - 1L;
434-
this.recoveredMaxSymbolDeltaStart = recoveredFrameAnalysisInProgress.maxDeltaStart();
448+
if (discardedSize > 0) {
449+
recoveredFrameAnalysisInProgress.close();
450+
recoveredFrameAnalysisInProgress = recovered.analyzeRecovery(0);
451+
recoveryFoldCount++;
452+
this.recoveredCommitBoundaryFsn = recoveredFrameAnalysisInProgress.commitBoundaryFsn();
453+
this.recoveredMaxSymbolId = recoveredFrameAnalysisInProgress.maxDeltaEnd() - 1L;
454+
this.recoveredMaxSymbolDeltaStart = recoveredFrameAnalysisInProgress.maxDeltaStart();
455+
}
435456
}
436457
} else {
437458
// Fresh start with no recovered segments. Any stale
@@ -809,6 +830,11 @@ void copyRecoveredSymbolSuffix(int baseline, long target) {
809830
}
810831
}
811832

833+
@TestOnly
834+
public int recoveryFoldCount() {
835+
return recoveryFoldCount;
836+
}
837+
812838
@TestOnly
813839
public long recoveryFramesVisited() {
814840
return recoveredFrameAnalysis == null ? 0L : recoveredFrameAnalysis.framesVisited();

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

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,7 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
670670
boolean mappedInput = ff.isMmapAllowed();
671671
long inputAddr = 0L;
672672
long entriesAddr = 0L;
673+
int entriesCapacity = 0;
673674
int entriesLen = 0;
674675
try {
675676
if (mappedInput) {
@@ -692,15 +693,25 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
692693
|| Unsafe.getUnsafe().getByte(inputAddr + 4) != VERSION) {
693694
throw new IllegalStateException("bad magic or unknown symbol dictionary version");
694695
}
695-
// First pass validates chunks and totals only the entry bytes that
696-
// survive. Production reads through a file-backed mapping, so this
697-
// pass does not allocate an anonymous whole-file copy. The second
698-
// pass copies directly into one exact-sized retained allocation.
699-
RecoveryScan scan = scanRecoveredChunks(inputAddr, len);
696+
// ONE pass: validate each chunk's CRC and, once proven good, copy its
697+
// entries straight out. The entry region is by construction a subset of
698+
// the file, so `len` is a safe upper bound to allocate against -- it
699+
// overshoots only by the chunk headers and CRCs -- and the exact size is
700+
// reclaimed by the shrink below. The previous shape walked the file
701+
// TWICE, re-decoding both header varints of every chunk on the second
702+
// pass purely to size one allocation it could have bounded for free.
703+
entriesCapacity = len;
704+
entriesAddr = Unsafe.malloc(entriesCapacity, MemoryTag.NATIVE_DEFAULT);
705+
RecoveryScan scan = scanAndCopyRecoveredChunks(inputAddr, len, entriesAddr);
700706
entriesLen = scan.entriesLen;
701-
if (entriesLen > 0) {
702-
entriesAddr = Unsafe.malloc(entriesLen, MemoryTag.NATIVE_DEFAULT);
703-
copyRecoveredEntries(inputAddr, scan.validLen, entriesAddr, entriesLen);
707+
if (entriesLen == 0) {
708+
Unsafe.free(entriesAddr, entriesCapacity, MemoryTag.NATIVE_DEFAULT);
709+
entriesAddr = 0L;
710+
entriesCapacity = 0;
711+
} else if (entriesLen < entriesCapacity) {
712+
entriesAddr = Unsafe.realloc(
713+
entriesAddr, entriesCapacity, entriesLen, MemoryTag.NATIVE_DEFAULT);
714+
entriesCapacity = entriesLen;
704715
}
705716
if (mappedInput) {
706717
ff.munmap(inputAddr, fileLen, MemoryTag.MMAP_DEFAULT);
@@ -729,7 +740,8 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
729740
}
730741
}
731742
if (entriesAddr != 0L) {
732-
Unsafe.free(entriesAddr, entriesLen, MemoryTag.NATIVE_DEFAULT);
743+
// capacity, not entriesLen: the shrink may not have happened yet.
744+
Unsafe.free(entriesAddr, entriesCapacity, MemoryTag.NATIVE_DEFAULT);
733745
}
734746
if (fd >= 0) {
735747
ff.close(fd);
@@ -743,25 +755,15 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
743755
}
744756
}
745757

746-
private static void copyRecoveredEntries(long inputAddr, int validLen, long entriesAddr, int entriesLen) {
747-
Varint v = new Varint();
748-
int diskPos = HEADER_SIZE;
749-
int dstPos = 0;
750-
while (diskPos < validLen) {
751-
if (!v.decode(inputAddr, diskPos, validLen)
752-
|| !v.decode(inputAddr, v.end, validLen)) {
753-
throw new IllegalStateException("validated symbol dictionary chunk could not be decoded");
754-
}
755-
int entryBytes = (int) v.value;
756-
int entriesStart = v.end;
757-
Unsafe.getUnsafe().copyMemory(inputAddr + entriesStart, entriesAddr + dstPos, entryBytes);
758-
dstPos += entryBytes;
759-
diskPos = entriesStart + entryBytes + CRC_SIZE;
760-
}
761-
assert dstPos == entriesLen;
762-
}
763-
764-
private static RecoveryScan scanRecoveredChunks(long inputAddr, int len) {
758+
/**
759+
* Validates every chunk and copies the entries of each proven-good one into
760+
* {@code dstAddr}, in a single pass. {@code dstAddr} must have room for {@code len}
761+
* bytes -- the entry region is a subset of the file, so that always suffices.
762+
* Stops at the first chunk that is torn, fails its CRC, or is internally
763+
* inconsistent, exactly as the two-pass version did, so the trusted prefix is
764+
* unchanged.
765+
*/
766+
private static RecoveryScan scanAndCopyRecoveredChunks(long inputAddr, int len, long dstAddr) {
765767
Varint v = new Varint();
766768
int count = 0;
767769
int diskPos = HEADER_SIZE;
@@ -788,10 +790,17 @@ private static RecoveryScan scanRecoveredChunks(long inputAddr, int len) {
788790
chunkEndI - diskPos);
789791
if (crcCalc != crcStored
790792
|| entryCount <= 0
793+
// Every entry costs at least its own length varint, so a positive
794+
// entryCount inside a zero-byte region is self-contradictory. The CRC
795+
// proves the bytes are what was WRITTEN, never that the triple is
796+
// consistent, and decodeLoadedSymbols would only discover it later --
797+
// as a throw two layers up rather than a trimmed trusted prefix.
798+
|| entryBytes <= 0
791799
|| (long) count + entryCount > Integer.MAX_VALUE
792800
|| entriesLen + entryBytes > Integer.MAX_VALUE) {
793801
break;
794802
}
803+
Unsafe.getUnsafe().copyMemory(inputAddr + entriesStart, dstAddr + entriesLen, entryBytes);
795804
entriesLen += entryBytes;
796805
diskPos = chunkEndI + CRC_SIZE;
797806
count += (int) entryCount;

0 commit comments

Comments
 (0)