Skip to content

Commit 5ded812

Browse files
glasstigerclaude
andcommitted
Cut symbol-dict overhead and fix two blind guards
Five review findings on the delta symbol dictionary. The three performance ones all sit on paths that scale with symbol cardinality -- the workload this feature exists for introduces a new symbol per ROW -- and the two guards were pinning nothing. RecoveredFrameAnalysis.foldDelta issued one bounds check, one capacity check and a ~12-byte copyMemory per SYMBOL. runningCoverage is loop-invariant (it only advances after the walk) and id ascends, so "id >= runningCoverage" is a step predicate: the entries a frame contributes are always ONE contiguous run at the tail of its delta section. Note where the run starts and copy it once. Recovery walks the whole backlog, so this traded millions of stub-dispatched small copies for one bulk copy per frame. It also drops the partial prefix the per-entry version left behind on a markGap bail-out; that residue was already unreachable (a gap pins coverage() at -1, and a later self-sufficient frame resets the buffer), so not writing it is equivalent and leaves less state to reason about. accumulateSentDict already solved the same problem this way one file over. Crc32c.updateUnsafe read each 8-byte slice-by-8 block with eight getByte calls: 16 loads per block where 9 do. Consume it with two getInt loads. getInt is an Unsafe intrinsic exactly as getByte is, so the catchable InternalError this method exists for is unaffected; the loop becomes little-endian, which costs nothing because the formats it checksums are already little-endian throughout. This is the whole recovery CRC. The differential test against the native intrinsic (8 alignments x 28 lengths x 3 seeds, plus the chaining fuzz) proves it bit-identical. The sent-dict entry-ends index was maintained incrementally by accumulateSentDict and built eagerly by the constructor, yet sendDictCatchUp is its ONLY reader. That put a store per new symbol on the per-frame I/O path and retained 4 bytes per symbol for the connection's whole life, to serve a reconnect that may never come. Build it lazily instead: ensureSentDictEntryIndex() already notices sentDictIndexedCount falling behind and rebuilds, at most once per reconnect, dwarfed by shipping the same bytes over the wire. A connection that never reconnects no longer allocates the index at all. testCtorFreesSeededMirrorWhenFrameSeedThrows could not fail. The seam threw BEFORE ensureSentDictCapacity -- the call that copy-on-writes the borrowed prefix into a loop-OWNED allocation -- so the cleanup it guards, gated on sentDictBytesOwned, freed nothing. Deleting that cleanup left the test green. Move the seam past the grow: deleting the cleanup now fails with a 4096-byte NATIVE_DEFAULT leak. The test's premise comment described the old copy-based design and is corrected. testPersistFailureSurfacesAsLineSenderException drove code production never runs. FilesFacade.isMmapAllowed() defaults to (this == INSTANCE), so installing any fault facade silently swapped the dictionary onto the positioned-write fallback -- the act of injecting the fault replaced the code under test. Fault a refused ff.allocate on the mmap append window instead, with the facade opting into isMmapAllowed(): that is both the real shape of ENOSPC here and the path production takes. Reverting the wrap now escapes as "could not grow mmap append region", from the mapped path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 68aa31a commit 5ded812

7 files changed

Lines changed: 155 additions & 78 deletions

File tree

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

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3905,15 +3905,16 @@ private void persistNewSymbolsBeforePublish() {
39053905
pd.appendSymbols(globalSymbolDictionary, from, currentBatchMaxSymbolId);
39063906
}
39073907
} catch (Throwable t) {
3908-
// A short write (disk full / quota) to the persisted dictionary throws
3909-
// a low-level IllegalStateException. Surface it as a LineSenderException
3910-
// -- like every other flush-path failure, e.g. the cursor append in
3911-
// sealAndSwapBuffer -- so a caller catching LineSenderException around
3912-
// flush() also catches a disk-full during the write-ahead persist. The
3913-
// persist ran before publish and pd.size() did not advance on the short
3914-
// write, so the still-buffered rows re-persist the same range
3915-
// idempotently on retry. A JVM Error is never a persist failure; let it
3916-
// propagate.
3908+
// A failed write to the persisted dictionary throws a low-level
3909+
// IllegalStateException: in production that is ff.allocate refusing to grow
3910+
// the mmap append window (how a full disk / exhausted quota surfaces there),
3911+
// and behind an injected facade a short positioned write. Surface it as a
3912+
// LineSenderException -- like every other flush-path failure, e.g. the cursor
3913+
// append in sealAndSwapBuffer -- so a caller catching LineSenderException
3914+
// around flush() also catches a disk-full during the write-ahead persist. The
3915+
// persist ran before publish and pd.size() did not advance on the failure, so
3916+
// the still-buffered rows re-persist the same range idempotently on retry.
3917+
// A JVM Error is never a persist failure; let it propagate.
39173918
if (t instanceof Error) {
39183919
throw (Error) t;
39193920
}

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

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,14 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
197197
* Throttle "reconnect attempt N failed" WARN logs to one per 5 s.
198198
*/
199199
private static final long RECONNECT_LOG_THROTTLE_NANOS = 5_000_000_000L;
200-
// Test seam: when true, recovery mirror seeding throws before growing,
201-
// simulating the native realloc OOM (or MAX_SENT_DICT_BYTES ceiling) that
202-
// ensureSentDictCapacity can raise while the constructor seeds the recovery
203-
// mirror. Lets a test prove the constructor frees the already-malloc'd mirror on
204-
// such a throw rather than leaking it. Production never sets it; volatile only so
205-
// a test thread's write is visible to the loop under test.
200+
// Test seam: when true, recovery mirror seeding throws immediately AFTER
201+
// ensureSentDictCapacity has grown (and therefore taken ownership of) the mirror,
202+
// standing in for the copyRecoveredSymbolSuffix-adjacent failure that leaves a
203+
// freshly malloc'd mirror with no owner. It sits after the grow deliberately: the
204+
// constructor's cleanup only frees an OWNED mirror, so a seam before the grow
205+
// would leave nothing to free and the leak guard would pass with the cleanup
206+
// deleted. Production never sets it; volatile only so a test thread's write is
207+
// visible to the loop under test.
206208
@TestOnly
207209
static volatile boolean forceMirrorSeedFailureForTest;
208210
// Pre-converted to nanos for the comparison in sendDurableAckKeepaliveIfDue.
@@ -784,21 +786,30 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
784786
int suffixLen = engine.recoveredSymbolSuffixLen(baseline);
785787
int suffixCount = engine.recoveredSymbolSuffixCount(baseline);
786788
if (suffixLen > 0) {
789+
ensureSentDictCapacity((long) sentDictBytesLen + suffixLen);
787790
if (forceMirrorSeedFailureForTest) {
791+
// Throw AFTER the grow, never before it. ensureSentDictCapacity
792+
// is what copy-on-writes a borrowed prefix into a loop-OWNED
793+
// allocation (sentDictBytesOwned = true), and the catch's
794+
// releaseSentDictBytes() is gated on exactly that flag. A seam
795+
// in front of the grow fires while the mirror is still borrowed
796+
// from PersistedSymbolDict, so the cleanup frees nothing and the
797+
// guard below passes even with the cleanup deleted -- it would
798+
// stop pinning the leak it exists for.
788799
throw new LineSenderException(
789800
"simulated mirror seed allocation failure (test only)");
790801
}
791-
ensureSentDictCapacity((long) sentDictBytesLen + suffixLen);
792802
engine.copyRecoveredSymbolSuffix(
793803
baseline, sentDictBytesAddr + sentDictBytesLen);
794804
sentDictBytesLen += suffixLen;
795805
sentDictCount += suffixCount;
796806
}
797807
}
798808
}
799-
if (sentDictCount > 0) {
800-
rebuildSentDictEntryIndex();
801-
}
809+
// The entry-ends index is NOT built here. sendDictCatchUp is its only
810+
// reader and builds it on demand, so a recovered slot that drains without
811+
// ever reconnecting -- the normal case -- never pays the O(n) walk nor
812+
// retains the 4-bytes-per-symbol index.
802813
} catch (Throwable t) {
803814
releaseSentDictBytes();
804815
throw t;
@@ -2446,7 +2457,16 @@ private void accumulateSentDict(long payloadAddr, int payloadLen, int deltaStart
24462457
if (deltaCount <= 0 || deltaStart > sentDictCount || deltaEnd <= sentDictCount) {
24472458
return;
24482459
}
2449-
ensureSentDictEntryIndex();
2460+
// Deliberately does NOT touch the entry-ends index. Only sendDictCatchUp reads
2461+
// it, so maintaining it here would put a store per new symbol on the per-frame
2462+
// I/O path -- and on the workload this feature targets (a new symbol per ROW)
2463+
// that is a store per row, plus 4 bytes per symbol retained for the connection's
2464+
// whole life, to serve a reconnect that may never happen. sendDictCatchUp calls
2465+
// ensureSentDictEntryIndex(), which notices sentDictIndexedCount has fallen
2466+
// behind sentDictCount and rebuilds; that O(n) walk runs at most once per
2467+
// reconnect and is dwarfed by shipping the same n bytes over the wire. A
2468+
// connection that never reconnects now never allocates the index at all.
2469+
//
24502470
// Walk past the already-held prefix [deltaStart, sentDictCount), then copy
24512471
// the new tail [sentDictCount, deltaEnd).
24522472
int skip = sentDictCount - deltaStart;
@@ -2459,8 +2479,8 @@ private void accumulateSentDict(long payloadAddr, int payloadLen, int deltaStart
24592479
}
24602480
long regionStart = p;
24612481
long newCount = deltaEnd - sentDictCount;
2462-
long requiredEntryCount = (long) sentDictCount + newCount;
2463-
ensureSentDictEntryEndsCapacity(requiredEntryCount);
2482+
// Walk the new tail only to find where it ends; the entry boundaries are not
2483+
// recorded here (see above -- the catch-up rebuilds them on demand).
24642484
for (long i = 0; i < newCount; i++) {
24652485
long len = readVarintAt(p, limit);
24662486
p = varintEnd + len;
@@ -2469,9 +2489,6 @@ private void accumulateSentDict(long payloadAddr, int payloadLen, int deltaStart
24692489
// than corrupt the mirror.
24702490
return;
24712491
}
2472-
Unsafe.getUnsafe().putInt(
2473-
sentDictEntryEndsAddr + ((long) sentDictCount + i) * Integer.BYTES,
2474-
sentDictBytesLen + (int) (p - regionStart));
24752492
}
24762493
int regionBytes = (int) (p - regionStart);
24772494
// long sum: sentDictBytesLen + regionBytes can exceed Integer.MAX_VALUE on
@@ -2482,7 +2499,6 @@ private void accumulateSentDict(long payloadAddr, int payloadLen, int deltaStart
24822499
Unsafe.getUnsafe().copyMemory(regionStart, sentDictBytesAddr + sentDictBytesLen, regionBytes);
24832500
sentDictBytesLen += regionBytes;
24842501
sentDictCount += (int) newCount;
2485-
sentDictIndexedCount = sentDictCount;
24862502
}
24872503

24882504
private void ensureSentDictCapacity(long required) {

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,14 @@ public void close() {
221221
releaseRawStorage();
222222
}
223223

224-
private void appendRaw(long entryStart, int entryLen) {
225-
long required = (long) runningRawLen + entryLen;
224+
/**
225+
* Appends one contiguous run of {@code count} wire entries -- {@code [len][utf8]}
226+
* repeated, exactly as a delta section carries them -- in a single copy. Callers
227+
* pass a whole frame's new-symbol suffix at once; see {@link #foldDelta} for why
228+
* that suffix is always contiguous.
229+
*/
230+
private void appendRaw(long addr, int len, int count) {
231+
long required = (long) runningRawLen + len;
226232
if (required > MAX_RAW_BYTES) {
227233
throw new IllegalStateException("recovered symbol dictionary suffix exceeds maximum size "
228234
+ "[required=" + required + ", max=" + MAX_RAW_BYTES + ']');
@@ -235,9 +241,9 @@ private void appendRaw(long entryStart, int entryLen) {
235241
rawAddr = Unsafe.realloc(rawAddr, rawCapacity, (int) newCapacity, MemoryTag.NATIVE_DEFAULT);
236242
rawCapacity = (int) newCapacity;
237243
}
238-
Unsafe.getUnsafe().copyMemory(entryStart, rawAddr + runningRawLen, entryLen);
239-
runningRawLen += entryLen;
240-
runningRawCount++;
244+
Unsafe.getUnsafe().copyMemory(addr, rawAddr + runningRawLen, len);
245+
runningRawLen += len;
246+
runningRawCount += count;
241247
}
242248

243249
private void foldDelta(long fsn, long p, long limit) {
@@ -292,7 +298,24 @@ private void foldDelta(long fsn, long p, long limit) {
292298
return;
293299
}
294300

301+
// runningCoverage is loop-invariant here -- it only advances after the walk --
302+
// and id ascends from deltaStart, so `id >= runningCoverage` is a step
303+
// predicate: the entries this frame contributes are always ONE contiguous run
304+
// at the tail of its delta section. Note where that run starts and copy it in a
305+
// single memcpy once the walk succeeds, rather than paying a bound check, a
306+
// capacity check and a ~12-byte copyMemory per symbol. Recovery walks the whole
307+
// backlog, and the workload this feature exists for introduces a new symbol per
308+
// ROW, so that is millions of stub-dispatched small copies where one bulk copy
309+
// per frame does the job.
310+
//
311+
// Deferring the copy past the markGap bail-outs also drops the partial prefix
312+
// the per-entry version used to leave behind. That residue was already
313+
// unreachable -- a gap pins coverage() at -1, and a later self-sufficient frame
314+
// resets runningRawLen/runningRawCount -- so not writing it is equivalent, and
315+
// leaves less state to reason about.
295316
long id = deltaStart;
317+
long suffixStart = 0L;
318+
int suffixCount = 0;
296319
for (long i = 0; i < deltaCount; i++, id++) {
297320
symbolEntriesVisited++;
298321
long entryStart = p;
@@ -310,9 +333,15 @@ private void foldDelta(long fsn, long p, long limit) {
310333
}
311334
p += symbolLen;
312335
if (id >= runningCoverage) {
313-
appendRaw(entryStart, (int) (p - entryStart));
336+
if (suffixCount == 0) {
337+
suffixStart = entryStart;
338+
}
339+
suffixCount++;
314340
}
315341
}
342+
if (suffixCount > 0) {
343+
appendRaw(suffixStart, (int) (p - suffixStart), suffixCount);
344+
}
316345
if (deltaEnd > runningCoverage) {
317346
runningCoverage = deltaEnd;
318347
}

core/src/main/java/io/questdb/client/std/Crc32c.java

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ private Crc32c() {
7373
* mmap access fault into a catchable {@link InternalError}; the native
7474
* {@link #update} path cannot provide that guarantee because a SIGBUS raised
7575
* inside JNI aborts the JVM.
76+
* <p>
77+
* The hot loop consumes each 8-byte block with two {@code getInt}s rather than
78+
* eight {@code getByte}s -- 9 loads per block instead of 16, for the same table
79+
* work. {@code getInt} is an {@link Unsafe} intrinsic exactly as {@code getByte}
80+
* is, so the fault-catchability above is unaffected. It does make the loop
81+
* little-endian, which costs nothing here: the segment and dictionary formats this
82+
* checksums are already little-endian throughout (their headers are read back with
83+
* {@code getInt}), and the native twin this must agree with asserts the same.
7684
*
7785
* @param seed previous CRC value, or {@link #INIT} to start fresh
7886
* @param addr off-heap address of at least {@code len} readable bytes
@@ -84,22 +92,18 @@ public static int updateUnsafe(int seed, long addr, long len) {
8492
int crc = ~seed;
8593
int[] table = CRC32C_TABLE;
8694
while (len >= Long.BYTES) {
87-
int b0 = Unsafe.getUnsafe().getByte(addr) & 0xFF;
88-
int b1 = Unsafe.getUnsafe().getByte(addr + 1) & 0xFF;
89-
int b2 = Unsafe.getUnsafe().getByte(addr + 2) & 0xFF;
90-
int b3 = Unsafe.getUnsafe().getByte(addr + 3) & 0xFF;
91-
int b4 = Unsafe.getUnsafe().getByte(addr + 4) & 0xFF;
92-
int b5 = Unsafe.getUnsafe().getByte(addr + 5) & 0xFF;
93-
int b6 = Unsafe.getUnsafe().getByte(addr + 6) & 0xFF;
94-
int b7 = Unsafe.getUnsafe().getByte(addr + 7) & 0xFF;
95-
crc = table[7 * 256 + ((crc ^ b0) & 0xFF)]
96-
^ table[6 * 256 + (((crc >>> 8) ^ b1) & 0xFF)]
97-
^ table[5 * 256 + (((crc >>> 16) ^ b2) & 0xFF)]
98-
^ table[4 * 256 + (((crc >>> 24) ^ b3) & 0xFF)]
99-
^ table[3 * 256 + b4]
100-
^ table[2 * 256 + b5]
101-
^ table[256 + b6]
102-
^ table[b7];
95+
// Little-endian: lo holds bytes 0..3 and hi bytes 4..7, ascending from the
96+
// low byte, so the per-byte table lookups below stay in wire order.
97+
int lo = Unsafe.getUnsafe().getInt(addr) ^ crc;
98+
int hi = Unsafe.getUnsafe().getInt(addr + Integer.BYTES);
99+
crc = table[7 * 256 + (lo & 0xFF)]
100+
^ table[6 * 256 + ((lo >>> 8) & 0xFF)]
101+
^ table[5 * 256 + ((lo >>> 16) & 0xFF)]
102+
^ table[4 * 256 + (lo >>> 24)]
103+
^ table[3 * 256 + (hi & 0xFF)]
104+
^ table[2 * 256 + ((hi >>> 8) & 0xFF)]
105+
^ table[256 + ((hi >>> 16) & 0xFF)]
106+
^ table[hi >>> 24];
103107
addr += Long.BYTES;
104108
len -= Long.BYTES;
105109
}

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -822,12 +822,20 @@ public void testCommitMessageDoesNotShipUnpersistedLeakedSymbol() throws Excepti
822822
public void testPersistFailureSurfacesAsLineSenderException() throws Exception {
823823
// A dictionary write that fails mid-flush -- a full disk, an exhausted quota --
824824
// must reach the caller as a LineSenderException, like every other flush-path
825-
// failure. PersistedSymbolDict throws a low-level IllegalStateException on a short
826-
// write; persistNewSymbolsBeforePublish wraps it. Without the wrap the raw
827-
// IllegalStateException sails straight past every user's
828-
// `catch (LineSenderException)` around flush() and takes the application down.
825+
// failure. PersistedSymbolDict throws a low-level IllegalStateException when it
826+
// cannot grow its append window; persistNewSymbolsBeforePublish wraps it.
827+
// Without the wrap the raw IllegalStateException sails straight past every
828+
// user's `catch (LineSenderException)` around flush() and takes the application
829+
// down.
829830
//
830-
// Nothing could reach that translation before: PersistedSymbolDict has
831+
// The fault is a REFUSED ff.allocate on the mmap append window, with the facade
832+
// opting into isMmapAllowed(): that is both the real shape of ENOSPC here and
833+
// the path production actually runs. A facade that inherits the default
834+
// isMmapAllowed() (this == INSTANCE -> false) silently swaps the dictionary onto
835+
// the positioned-write fallback, which production never executes -- so a
836+
// short-write fault would prove this translation on dead code.
837+
//
838+
// Nothing could reach the translation at all before: PersistedSymbolDict has
831839
// facade-aware overloads, but CursorSendEngine called only the
832840
// FilesFacade.INSTANCE ones, so no test could inject a dictionary I/O fault
833841
// through the real producer path. The engine now takes a FilesFacade for exactly
@@ -842,7 +850,7 @@ public void testPersistFailureSurfacesAsLineSenderException() throws Exception {
842850
String slot = Paths.get(sfDir, "default").toString();
843851
Assert.assertEquals(0, io.questdb.client.std.Files.mkdir(sfDir,
844852
io.questdb.client.std.Files.DIR_MODE_DEFAULT));
845-
ShortDictWriteFacade ff = new ShortDictWriteFacade();
853+
FullDiskDictFacade ff = new FullDiskDictFacade();
846854
// The engine owns the dictionary; the fault facade reaches only it, so the
847855
// segment files still write normally and the ONLY failure is the persist.
848856
CursorSendEngine engine = new CursorSendEngine(
@@ -851,7 +859,7 @@ public void testPersistFailureSurfacesAsLineSenderException() throws Exception {
851859
Sender sender = QwpWebSocketSender.connect(
852860
"localhost", port, null, 0, 0, 0L, null, false, engine);
853861
try {
854-
ff.armed = true; // the next dictionary append short-writes
862+
ff.armed = true; // the next dictionary append cannot grow its window
855863
sender.table("m").symbol("s", "boom").longColumn("v", 1L).atNow();
856864
try {
857865
sender.flush();
@@ -1553,16 +1561,27 @@ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] dat
15531561
* fills mid-flush. Offset 0 is left alone so the file header still writes -- only the
15541562
* entry append fails, which is the path under test.
15551563
*/
1556-
private static final class ShortDictWriteFacade extends DelegatingFilesFacade {
1564+
/**
1565+
* Refuses to grow the symbol dictionary's mmap append window -- the production
1566+
* shape of a full disk, since PersistedSymbolDict reserves that window through
1567+
* {@code ff.allocate} and ENOSPC surfaces there as a refusal.
1568+
* <p>
1569+
* {@code isMmapAllowed()} opts IN deliberately. The inherited default is
1570+
* {@code this == INSTANCE}, so any wrapping facade otherwise routes the dictionary
1571+
* down the positioned-write fallback that production never executes -- injecting a
1572+
* fault would then silently replace the code under test.
1573+
*/
1574+
private static final class FullDiskDictFacade extends DelegatingFilesFacade {
15571575
boolean armed;
15581576

15591577
@Override
1560-
public long write(int fd, long addr, long len, long offset) {
1561-
if (armed && offset > 0 && len > 1) {
1562-
armed = false;
1563-
return INSTANCE.write(fd, addr, len - 1, offset);
1564-
}
1565-
return INSTANCE.write(fd, addr, len, offset);
1578+
public boolean allocate(int fd, long size) {
1579+
return !armed && INSTANCE.allocate(fd, size);
1580+
}
1581+
1582+
@Override
1583+
public boolean isMmapAllowed() {
1584+
return true;
15661585
}
15671586
}
15681587

0 commit comments

Comments
 (0)