Skip to content

Commit 042c123

Browse files
glasstigerclaude
andcommitted
Single-pass symbol-dict append; test window remap
PersistedSymbolDict.appendSymbols encoded the mapped chunk in two passes over the id range: a first loop called Utf8s.utf8Bytes on every symbol just to size the header varint and the mmap reserve, then a second loop re-walked each symbol's UTF-8 length to write it. It now stages the entry region in the scratch buffer with a single walk per symbol and bulk-copies it into the append window after the header, dropping the redundant walk. The chunk it writes is byte-identical, so the exact-file-size "one chunk, one CRC" test still holds. A direct in-window encode cannot avoid the double walk: the back-to-back chunk format leaves no room to reserve and back-fill the header in place. The path is cold -- only a retry after a failed publish re-encodes from the dictionary; the steady state persists a frame's pre-encoded bytes through appendRawEntries. Add testMappedAppendSpansMultipleWindowsAndRecoversDenseIds: it appends a dictionary larger than one 4 MiB append window, asserts the window remaps (appendMapGrowthCount >= 2) without a positioned write, then reopens and checks every id recovers to its own symbol across the boundary. This exercises the ensureAppendMap remap arithmetic and the single-pass encode as chunks straddle the boundary, both of which the single-window happy path left untested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 16be764 commit 042c123

2 files changed

Lines changed: 77 additions & 22 deletions

File tree

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

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -415,11 +415,18 @@ public synchronized void appendSymbol(CharSequence symbol) {
415415

416416
/**
417417
* Appends the dense id range {@code [from .. to]} as one chunk with one
418-
* checksum. Production encodes directly into a segmented append mapping, avoiding
419-
* both the staging copy and a positioned-write syscall on every flush. The
420-
* scratch/positioned-write fallback exists only behind an injected filesystem
421-
* facade so short-write recovery tests retain their fault seam. Callers pass the
422-
* dictionary and the range so the ids resolve to their symbol strings.
418+
* checksum. This is the RE-ENCODE path: the steady-state persist ships a frame's
419+
* pre-encoded delta bytes through {@link #appendRawEntries}, and only a retry
420+
* after a failed publish rebuilds a range straight from the dictionary. The
421+
* mapped path stages the entry region in the scratch buffer with a single UTF-8
422+
* walk per symbol, then bulk-copies it into the append window after the header,
423+
* and still commits WITHOUT a positioned-write syscall. A direct encode into the
424+
* window would walk each symbol's UTF-8 length twice -- the exact entriesLen sizes
425+
* both the header varint and the mmap reserve -- and the back-to-back chunk format
426+
* leaves no room to reserve-and-back-fill the header in place. The
427+
* positioned-write fallback runs only behind an injected filesystem facade so
428+
* short-write recovery tests retain their fault seam. Callers pass the dictionary
429+
* and the range so the ids resolve to their symbol strings.
423430
* <p>
424431
* Same durability and idempotency contract as {@link #appendSymbol}: no fsync,
425432
* and a short write throws WITHOUT advancing {@code size}/{@code appendOffset},
@@ -432,32 +439,32 @@ public synchronized void appendSymbols(GlobalSymbolDictionary dict, int from, in
432439
}
433440
int count = to - from + 1;
434441
if (mappedAppend) {
435-
long entriesLenLong = 0L;
442+
// Stage the entry region in scratch with ONE UTF-8 walk per symbol, then
443+
// bulk-copy it into the append window after the header (see the method
444+
// javadoc for why a direct in-window encode would have to walk each symbol
445+
// twice). ensureScratch enforces the same MAX_SCRATCH_BYTES ceiling the old
446+
// sizing pass did, throwing before size/appendOffset advance.
447+
int entriesLen = 0;
436448
for (int id = from; id <= to; id++) {
437-
int utf8Len = Utf8s.utf8Bytes(dict.getSymbol(id));
438-
entriesLenLong += NativeBufferWriter.varintSize(utf8Len) + (long) utf8Len;
439-
if (entriesLenLong > MAX_SCRATCH_BYTES) {
440-
throw new IllegalStateException("symbol dict chunk exceeds the maximum size to "
441-
+ FILE_NAME + " [required=" + entriesLenLong + ", max="
442-
+ MAX_SCRATCH_BYTES + ']');
449+
CharSequence symbol = dict.getSymbol(id);
450+
int utf8Len = Utf8s.utf8Bytes(symbol);
451+
int wireLen = NativeBufferWriter.varintSize(utf8Len) + utf8Len; // [len][utf8]
452+
ensureScratch((long) entriesLen + wireLen);
453+
long q = NativeBufferWriter.writeVarint(scratchAddr + entriesLen, utf8Len);
454+
if (utf8Len > 0) {
455+
Utf8s.strCpyUtf8(symbol, q, utf8Len);
443456
}
457+
entriesLen += wireLen;
444458
}
445-
int entriesLen = (int) entriesLenLong;
446459
int hdrLen = NativeBufferWriter.varintSize(count)
447460
+ NativeBufferWriter.varintSize(entriesLen);
448461
long recLen = (long) hdrLen + entriesLen + CRC_SIZE;
449462
ensureAppendMap(checkedRequiredOffset(recLen));
450463
long recStart = appendMapAddr + appendOffset - appendMapOffset;
451464
long p = NativeBufferWriter.writeVarint(recStart, count);
452-
p = NativeBufferWriter.writeVarint(p, entriesLen);
453-
for (int id = from; id <= to; id++) {
454-
CharSequence symbol = dict.getSymbol(id);
455-
int utf8Len = Utf8s.utf8Bytes(symbol);
456-
p = NativeBufferWriter.writeVarint(p, utf8Len);
457-
if (utf8Len > 0) {
458-
Utf8s.strCpyUtf8(symbol, p, utf8Len);
459-
p += utf8Len;
460-
}
465+
NativeBufferWriter.writeVarint(p, entriesLen);
466+
if (entriesLen > 0) {
467+
Unsafe.getUnsafe().copyMemory(scratchAddr, recStart + hdrLen, entriesLen);
461468
}
462469
commitMappedChunk(recStart, hdrLen, entriesLen, count);
463470
return;

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,54 @@ public void testMappedAppendUsesMultiMegabyteWindowsWithoutPositionedWrites() th
452452
});
453453
}
454454

455+
@Test
456+
public void testMappedAppendSpansMultipleWindowsAndRecoversDenseIds() throws Exception {
457+
// A dictionary larger than one 4 MiB append window must remap the window
458+
// mid-append (appendMapGrowthCount >= 2) and still recover every symbol in
459+
// dense id order across the boundary. Guards two paths the single-window
460+
// happy path leaves uncovered: the remap arithmetic in ensureAppendMap, and
461+
// the single-pass appendSymbols encode as its chunks straddle the boundary.
462+
assertMemoryLeak(() -> {
463+
final int n = 640;
464+
final String pad = TestUtils.repeat("x", 8192); // ~8 KiB per symbol -> >4 MiB total
465+
GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
466+
for (int i = 0; i < n; i++) {
467+
dict.getOrAddSymbol("sym-" + i + "-" + pad); // distinct, id == i
468+
}
469+
Assert.assertEquals(n, dict.size());
470+
471+
Path dir = newFolder("qwp-symdict");
472+
try (PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString())) {
473+
Assert.assertNotNull(d);
474+
// Multi-symbol batches so the appendSymbols encode loop is exercised,
475+
// not just the single-symbol path.
476+
for (int from = 0; from < n; from += 8) {
477+
int to = Math.min(from + 8, n) - 1;
478+
d.appendSymbols(dict, from, to);
479+
}
480+
Assert.assertEquals(n, d.size());
481+
Assert.assertEquals("production mmap appends must not use positioned writes",
482+
0L, d.appendWriteCount());
483+
Assert.assertTrue(
484+
"a dictionary larger than the 4 MiB window must remap at least once (saw "
485+
+ d.appendMapGrowthCount() + ")",
486+
d.appendMapGrowthCount() >= 2);
487+
}
488+
489+
// Fresh open: every id must resolve to its own symbol, including the ids
490+
// whose chunks straddled the 4 MiB window boundary.
491+
try (PersistedSymbolDict re = PersistedSymbolDict.open(dir.toString())) {
492+
Assert.assertNotNull(re);
493+
Assert.assertEquals(n, re.size());
494+
ObjList<String> got = re.readLoadedSymbols();
495+
for (int i = 0; i < n; i++) {
496+
Assert.assertEquals("symbol at id " + i + " must survive the window boundary",
497+
dict.getSymbol(i), got.getQuick(i));
498+
}
499+
}
500+
});
501+
}
502+
455503
private static int varintSize(int v) {
456504
int n = 1;
457505
while ((v >>>= 7) != 0) {

0 commit comments

Comments
 (0)