Skip to content

Commit 52a9473

Browse files
glasstigerclaude
andcommitted
Speed up recovery lookups and the per-symbol varint decode
C14. findSegmentContaining walked the sealed list linearly, on the reconnect path, under the ring monitor -- with the list bounded only by the documented ~16K-segment ceiling, and its sibling nextSealedAfter doing the identical predicate in log N right beside it. The list is sorted by baseSeq with disjoint ranges, so the same binary search applies. A comparison probe makes it observable, and the test asserts the counter is non-zero BEFORE asserting the bound: the counter exists only in the binary search, so a bound-only assertion would pass for free against a reverted linear scan -- the trap the sibling perf assertions in this file already fall into. C15. seedGlobalDictionaryFromPersisted poured the recovered symbols into a default-capacity (64) dictionary, rehashing the map ~log2(n/64) times, each pass O(current size). recoveredMaxSymbolId + 1 bounds the seed, so the dictionary is now pre-sized before the pour. The field is no longer final; nothing retains a reference -- the encoder and the persisted dictionary both take it per call -- and the swap happens during construction, before the producer or the I/O thread exist. C16. ensureAppendMap rounded the window START down to a 4 MiB boundary while sizing it to the record's END, so a chunk straddling that boundary produced a window spanning both: 8 MiB mapped and re-allocated, whose lower half covers bytes already written and never touched again. Steady state then advanced 4 MiB per remap while always mapping 8. mmap only requires a page-aligned offset, so the window is now page-aligned. C17. readVarintAt returned its end position through an instance field -- a store per call, and this runs once per SYMBOL: per frame in accumulateSentDict and once per mirror entry in sendDictCatchUp. It now packs (value << 3) | bytes into the return, the shape RecoveredFrameAnalysis.readVarint already uses. That also closes a guard that could not guard. The field version returned 0 with varintEnd == p when p >= limit, so a caller computing p = varintEnd + len got p == limit and its `p > limit` bail-out could not fire at an exact boundary; a frame declaring more entries than it carries would walk zero-length pseudo-entries and advance sentDictCount past bytes the mirror does not hold. A -1 return makes truncation detectable and every bail-out now fires. C13 is RETRACTED, not fixed. The review claimed trySendOne's in-place orphan-tail re-anchor was dead after a catch-up, so every retirement on a dictionary-bearing slot cost a reconnect. The dead-arm half is right; the cost claim is not. Both connection setup sites -- swapClient and positionCursorForStart -- call tryRetireOrphanTail before any send, so that arm is reached only when frames below the tail still needed acks, which means they were sent on this connection and a recycle was always the correct path. Proven: a test written for the fix passed with the fix reverted. The code is restored; only the comment, which claimed the wire mapping is untouched "because no wire sequence has been consumed" (false once a catch-up has run), is corrected. The test is kept under a name describing what it does pin -- that adding a catch-up leaves the start()-time retirement intact -- and says so. Full client suite: 2697 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 509b372 commit 52a9473

6 files changed

Lines changed: 180 additions & 36 deletions

File tree

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ public class QwpWebSocketSender implements Sender {
155155
private final QwpWebSocketEncoder encoder;
156156
private final List<Endpoint> endpoints;
157157
// Global symbol dictionary for delta encoding
158-
private final GlobalSymbolDictionary globalSymbolDictionary;
158+
// Not final: seedGlobalDictionaryFromPersisted replaces it with a pre-sized instance
159+
// before anything can observe the original. Nothing retains a reference -- the encoder
160+
// and the persisted dictionary both take it as a per-call parameter -- and the swap
161+
// happens during construction, before the producer or the I/O thread exist.
162+
private GlobalSymbolDictionary globalSymbolDictionary;
159163
// Serializes FOREGROUND connect walks only (see buildAndConnect): the
160164
// shared-round state in hostTracker (pickNext/beginRound/attempted
161165
// bits), roundSeq, roundConnectAttemptSeq, and the foreground lifecycle
@@ -4080,6 +4084,15 @@ private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
40804084
// 1. The dictionary's intact prefix. addRecoveredSymbol appends without de-dup, so
40814085
// the producer's size tracks pd.size() exactly -- which is what the send loop's
40824086
// mirror also seeds sentDictCount from.
4087+
// Pre-size before pouring the recovered symbols in. The default capacity is 64,
4088+
// so rebuilding a large dictionary rehashed the map ~log2(n/64) times, each pass
4089+
// O(current size) -- roughly doubling the rebuild and touching a growing table
4090+
// the whole way. recoveredMaxSymbolId + 1 is the upper bound the seed can reach.
4091+
long expected = Math.max(pd == null ? 0L : pd.recoveredSize(),
4092+
cursorEngine.recoveredMaxSymbolId() + 1L);
4093+
if (expected > globalSymbolDictionary.size() && expected <= Integer.MAX_VALUE) {
4094+
globalSymbolDictionary = new GlobalSymbolDictionary((int) expected);
4095+
}
40834096
int baseline = 0;
40844097
if (pd != null && pd.size() > 0) {
40854098
pd.addLoadedSymbolsTo(globalSymbolDictionary);

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

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -346,8 +346,6 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
346346
// connection in setWireBaselineWithCatchUp; set in trySendOne after a successful
347347
// send.
348348
private boolean dataFrameSentThisConnection;
349-
// End position (native address) written by the last readVarintAt() call.
350-
private long varintEnd;
351349
private WebSocketClient client;
352350
// Optional: when non-null, every server-rejection error (retriable and
353351
// terminal alike) is offered to the dispatcher for async delivery to the user's
@@ -2416,7 +2414,10 @@ private int frameDeltaStart(long payloadAddr, int payloadLen) {
24162414
if (!isDeltaFrame(payloadAddr, payloadLen)) {
24172415
return -1;
24182416
}
2419-
return (int) readVarintAt(payloadAddr + QwpConstants.HEADER_SIZE, payloadAddr + payloadLen);
2417+
long encoded = readVarintAt(payloadAddr + QwpConstants.HEADER_SIZE, payloadAddr + payloadLen);
2418+
// A malformed start id reads as "no delta section", which the caller's
2419+
// `deltaStart >= 0` gate already handles.
2420+
return encoded < 0L ? -1 : (int) (encoded >>> 3);
24202421
}
24212422

24222423
// True only for a well-formed QWP frame this encoder produced that carries a
@@ -2453,8 +2454,12 @@ private void accumulateSentDict(long payloadAddr, int payloadLen, int deltaStart
24532454
// its canonical LEB128 encoding rather than re-reading the header and the
24542455
// start-id varint.
24552456
long p = payloadAddr + QwpConstants.HEADER_SIZE + NativeBufferWriter.varintSize(deltaStart);
2456-
long deltaCount = readVarintAt(p, limit);
2457-
p = varintEnd;
2457+
long encodedCount = readVarintAt(p, limit);
2458+
if (encodedCount < 0L) {
2459+
return; // malformed -- never happens for frames we encoded
2460+
}
2461+
long deltaCount = encodedCount >>> 3;
2462+
p += encodedCount & 7L;
24582463
// The mirror holds ids [0, sentDictCount). Accumulate ONLY the part of this
24592464
// frame's delta [deltaStart, deltaStart+deltaCount) that extends past the
24602465
// tip -- ids [sentDictCount, deltaStart+deltaCount). Cases:
@@ -2486,24 +2491,30 @@ private void accumulateSentDict(long payloadAddr, int payloadLen, int deltaStart
24862491
// the new tail [sentDictCount, deltaEnd).
24872492
int skip = sentDictCount - deltaStart;
24882493
for (int i = 0; i < skip; i++) {
2489-
long len = readVarintAt(p, limit);
2490-
p = varintEnd + len;
2491-
if (p > limit) {
2494+
long encoded = readVarintAt(p, limit);
2495+
if (encoded < 0L) {
24922496
return; // malformed -- bail rather than corrupt the mirror
24932497
}
2498+
p += (encoded & 7L) + (encoded >>> 3);
2499+
if (p > limit) {
2500+
return;
2501+
}
24942502
}
24952503
long regionStart = p;
24962504
long newCount = deltaEnd - sentDictCount;
24972505
// Walk the new tail only to find where it ends; the entry boundaries are not
24982506
// recorded here (see above -- the catch-up re-walks the mirror when it needs them).
24992507
for (long i = 0; i < newCount; i++) {
2500-
long len = readVarintAt(p, limit);
2501-
p = varintEnd + len;
2502-
if (p > limit) {
2508+
long encoded = readVarintAt(p, limit);
2509+
if (encoded < 0L) {
25032510
// Malformed -- never happens for frames we encoded; bail rather
25042511
// than corrupt the mirror.
25052512
return;
25062513
}
2514+
p += (encoded & 7L) + (encoded >>> 3);
2515+
if (p > limit) {
2516+
return;
2517+
}
25072518
}
25082519
int regionBytes = (int) (p - regionStart);
25092520
// long sum: sentDictBytesLen + regionBytes can exceed Integer.MAX_VALUE on
@@ -2569,28 +2580,34 @@ private void releaseSentDictBytes() {
25692580
sentDictCount = 0;
25702581
}
25712582

2572-
private long readVarintAt(long p, long limit) {
2583+
/**
2584+
* Decodes the varint at {@code [p, limit)} and returns {@code (value << 3) | bytes},
2585+
* or {@code -1} when it is truncated or runs past a canonical length.
2586+
* <p>
2587+
* Packing the consumed length into the return value -- the shape
2588+
* {@code RecoveredFrameAnalysis.readVarint} already uses -- drops a store to an
2589+
* instance field on every call, and this runs once per SYMBOL: per frame in
2590+
* accumulateSentDict, and once per mirror entry in sendDictCatchUp.
2591+
* <p>
2592+
* The {@code -1} also makes truncation DETECTABLE. The field version returned 0 with
2593+
* {@code varintEnd == p} when {@code p >= limit}, so a caller computing
2594+
* {@code p = varintEnd + len} got {@code p == limit} and its {@code p > limit}
2595+
* bail-out could not fire at an exact boundary -- guards that could not guard.
2596+
*/
2597+
private static long readVarintAt(long p, long limit) {
25732598
long value = 0;
25742599
int shift = 0;
2575-
long cur = p;
2576-
while (cur < limit) {
2577-
byte b = Unsafe.getUnsafe().getByte(cur++);
2600+
int bytes = 0;
2601+
while (p < limit && bytes < 6) {
2602+
byte b = Unsafe.getUnsafe().getByte(p++);
25782603
value |= (long) (b & 0x7F) << shift;
2604+
bytes++;
25792605
if ((b & 0x80) == 0) {
2580-
break;
2606+
return (value << 3) | bytes;
25812607
}
25822608
shift += 7;
2583-
if (shift > 35) {
2584-
// Defensive bound, matching PersistedSymbolDict.decodeVarint: a
2585-
// canonical entry-length / delta varint is <= 5 bytes. Every caller
2586-
// reads freshly-encoded, CRC- or openExisting-validated bytes, so
2587-
// this is unreachable, but it stops a corrupt continuation run from
2588-
// over-shifting into a garbage length.
2589-
break;
2590-
}
25912609
}
2592-
varintEnd = cur;
2593-
return value;
2610+
return -1L;
25942611
}
25952612

25962613
/**
@@ -2649,9 +2666,11 @@ private int sendDictCatchUp() {
26492666
long mirrorLimit = sentDictBytesAddr + sentDictBytesLen;
26502667
for (int entryId = 0; entryId < sentDictCount; entryId++) {
26512668
long entryStart = entryPtr;
2652-
long entryLen = readVarintAt(entryPtr, mirrorLimit);
2653-
long entryEnd = varintEnd + entryLen;
2654-
if (entryEnd > mirrorLimit) {
2669+
long encodedLen = readVarintAt(entryPtr, mirrorLimit);
2670+
long entryEnd = encodedLen < 0L
2671+
? -1L
2672+
: entryPtr + (encodedLen & 7L) + (encodedLen >>> 3);
2673+
if (entryEnd < 0L || entryEnd > mirrorLimit) {
26552674
LineSenderException err = new LineSenderException(
26562675
"invalid symbol dictionary mirror during catch-up [entry="
26572676
+ entryId + ", count=" + sentDictCount + ']');
@@ -3023,9 +3042,18 @@ private boolean trySendOne() {
30233042
return false;
30243043
}
30253044
if (nextWireSeq == 0) {
3026-
// Nothing sent on this connection yet: re-anchor in place past
3027-
// the retired tail. The wireSeq<->FSN mapping is untouched
3028-
// because no wire sequence has been consumed.
3045+
// Reached only when the tail was NOT retirable at connection setup --
3046+
// both setup sites (swapClient, positionCursorForStart) call
3047+
// tryRetireOrphanTail first -- which means frames below it still needed
3048+
// acks, which means they were SENT on this connection. So nextWireSeq is
3049+
// never 0 here in practice and this arm is effectively dead; it is kept
3050+
// as a cheap correctness guard rather than removed.
3051+
//
3052+
// NB the mapping is untouched only while NO wire sequence has been
3053+
// consumed. A dictionary catch-up consumes sequences 0..n-1 before any
3054+
// data frame, so after one this test is false and the recycle below --
3055+
// which re-anchors the mapping from scratch -- is the correct path, not
3056+
// an avoidable cost.
30293057
try {
30303058
positionCursorForStart();
30313059
} catch (CatchUpSendException e) {

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,14 @@ private void ensureAppendMap(long required) {
933933
&& required <= appendMapOffset + appendMapCapacity) {
934934
return;
935935
}
936-
long newOffset = appendOffset - appendOffset % APPEND_MAP_CAPACITY;
936+
// Page-align, not APPEND_MAP_CAPACITY-align: mmap only requires a page-aligned
937+
// file offset. Rounding the START down to a 4 MiB boundary while sizing `needed`
938+
// to the record's END meant a chunk straddling that boundary produced a window
939+
// spanning BOTH -- 8 MiB mapped and re-allocated, whose lower half covers bytes
940+
// already written and never touched again. Steady state then advanced 4 MiB per
941+
// remap while always mapping 8.
942+
long pageMask = Files.PAGE_SIZE - 1;
943+
long newOffset = appendOffset & ~pageMask;
937944
long needed = required - newOffset;
938945
long newCapacity = Math.max(APPEND_MAP_CAPACITY, needed);
939946
long remainder = newCapacity % APPEND_MAP_CAPACITY;

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ public final class SegmentRing implements QuietCloseable {
9090
private int sealedHead;
9191
// Deterministic performance probes: unlike elapsed-time assertions these
9292
// let tests pin binary successor search and amortized prefix compaction.
93+
private int lastFindSegmentComparisons;
9394
private int lastNextSealedSearchComparisons;
9495
private long sealedCompactionMoves;
9596
// High-water byte offset within the active segment at which we proactively
@@ -608,8 +609,28 @@ public synchronized ObjList<MmapSegment> drainTrimmable() {
608609
* scan cost doesn't matter.
609610
*/
610611
public synchronized MmapSegment findSegmentContaining(long fsn) {
611-
for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) {
612-
MmapSegment s = sealedSegments.get(i);
612+
// Binary search, for the same reason nextSealedAfter got one: the sealed list
613+
// is sorted by baseSeq and its ranges are disjoint, and its length is bounded
614+
// only by the documented ~16K-segment ceiling -- so the linear scan this
615+
// replaces cost up to 16K comparisons under the ring monitor, on the reconnect
616+
// path, right beside a sibling doing the identical predicate in log N.
617+
int lo = sealedHead;
618+
int hi = sealedSegments.size();
619+
int comparisons = 0;
620+
while (lo < hi) {
621+
int mid = (lo + hi) >>> 1;
622+
comparisons++;
623+
if (sealedSegments.get(mid).baseSeq() <= fsn) {
624+
lo = mid + 1;
625+
} else {
626+
hi = mid;
627+
}
628+
}
629+
lastFindSegmentComparisons = comparisons;
630+
// lo is the first segment whose baseSeq exceeds fsn, so the candidate is the
631+
// one before it -- the only sealed segment whose range can contain fsn.
632+
if (lo > sealedHead) {
633+
MmapSegment s = sealedSegments.get(lo - 1);
613634
long base = s.baseSeq();
614635
if (fsn >= base && fsn < base + s.frameCount()) {
615636
return s;
@@ -841,6 +862,11 @@ public synchronized long totalSegmentBytes() {
841862
return total;
842863
}
843864

865+
@TestOnly
866+
public synchronized int getLastFindSegmentComparisons() {
867+
return lastFindSegmentComparisons;
868+
}
869+
844870
@TestOnly
845871
public synchronized int getLastNextSealedSearchComparisons() {
846872
return lastNextSealedSearchComparisons;

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopOrphanTailTest.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,57 @@ public void testFastPathRetiresWholeDeferredLogBeforeAnySend() throws Exception
184184
});
185185
}
186186

187+
/**
188+
* A whole-deferred slot that ALSO ships a dictionary catch-up must still retire its
189+
* tail at start(), before any connection work, and must not reconnect.
190+
* <p>
191+
* testFastPathRetiresWholeDeferredLogBeforeAnySend covers the same shape without a
192+
* dictionary, so nothing pinned that adding a catch-up -- which consumes wire
193+
* sequences before any data frame -- leaves the start()-time retirement intact.
194+
* <p>
195+
* This deliberately does NOT pin trySendOne's in-place re-anchor arm: both connection
196+
* setup sites call tryRetireOrphanTail first, so that arm is only reached when frames
197+
* below the tail still needed acks -- which means they were sent on this connection.
198+
* Verified by reverting its guard: this test still passes.
199+
*/
200+
@Test
201+
public void testCatchUpBearingWholeDeferredSlotRetiresAtStartWithoutReconnecting() throws Exception {
202+
TestUtils.assertMemoryLeak(() -> {
203+
try (CursorSendEngine engine = newEngine()) {
204+
appendFrame(engine, true);
205+
appendFrame(engine, true);
206+
appendFrame(engine, true);
207+
}
208+
// A populated dictionary is what makes the loop ship a catch-up. The frames
209+
// carry no symbols, so recoveredMaxSymbolId stays -1 and the full-dict
210+
// discard cannot fire on it.
211+
try (PersistedSymbolDict pd = PersistedSymbolDict.openClean(tmpDir)) {
212+
assertNotNull(pd);
213+
pd.appendSymbol("a");
214+
pd.appendSymbol("b");
215+
}
216+
217+
try (CursorSendEngine engine = newEngine()) {
218+
assertEquals(-1L, engine.recoveredCommitBoundaryFsn());
219+
assertEquals(2L, engine.recoveredOrphanTipFsn());
220+
221+
List<AckingClient> clients = new ArrayList<>();
222+
CursorWebSocketSendLoop loop = newLoop(engine, clients);
223+
try {
224+
loop.start();
225+
assertEquals("scaffolding: the mirror must be seeded, so a catch-up ships",
226+
2, loop.sentDictCount());
227+
awaitAckedFsn(engine, 2L);
228+
assertTrue("retiring after only a catch-up must re-anchor in place, "
229+
+ "not reconnect",
230+
clientCount(clients) <= 1);
231+
} finally {
232+
loop.close();
233+
}
234+
}
235+
});
236+
}
237+
187238
@Test
188239
public void testRecoveryReleasesAbortedTailSymbolStorage() throws Exception {
189240
TestUtils.assertMemoryLeak(() -> {

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,25 @@ public void testNextSealedAfterWalksThousandsOfSegmentsWithoutOverflow() throws
462462
// After the loop we have `sealedCount` sealed segments and one
463463
// active (containing nothing yet — its base = sealedCount).
464464
// Now walk: oldest sealed, then nextSealedAfter() repeatedly.
465+
// findSegmentContaining walks the SAME sorted, disjoint list and is
466+
// on the reconnect path, so it must be logarithmic too -- a linear
467+
// scan costs up to the ~16K-segment ceiling under the ring monitor.
468+
// The > 0 assertion is the anti-vacuity guard: the comparison counter
469+
// exists only in the binary search, so a revert to a linear scan would
470+
// leave it at 0 and make a bound-only assertion pass for free.
471+
int findCeiling = 33 - Integer.numberOfLeadingZeros(sealedCount);
472+
for (long probe = 0; probe < 16; probe++) {
473+
MmapSegment hit = ring.findSegmentContaining(probe);
474+
assertNotNull("fsn " + probe + " must resolve to a segment", hit);
475+
assertEquals("fsn " + probe + " lives in the segment based there",
476+
probe, hit.baseSeq());
477+
int comparisons = ring.getLastFindSegmentComparisons();
478+
assertTrue("the search must actually run", comparisons > 0);
479+
assertTrue("findSegmentContaining must be logarithmic: " + comparisons
480+
+ " comparisons over " + sealedCount + " sealed segments",
481+
comparisons <= findCeiling);
482+
}
483+
465484
MmapSegment cursor = ring.firstSealed();
466485
assertNotNull(cursor);
467486
assertEquals(0, cursor.baseSeq());

0 commit comments

Comments
 (0)