Skip to content

Commit 8853d8f

Browse files
glasstigerclaude
andcommitted
Persist symbol delta from frame, skip re-encode
M1: persistNewSymbolsBeforePublish re-encoded each new symbol into a scratch buffer even though beginMessage had just written the identical [len][utf8] bytes into the frame's symbol-dict delta section. The encoder now records that entry region's offset and length; the producer writes those bytes straight to the slot .symbol-dict via a new PersistedSymbolDict.appendRawEntries, so a high-cardinality batch no longer re-encodes what the wire frame already carries. The common path (durable size == the frame's delta start id) takes the direct copy; a retry after a failed publish, where the durable size has run ahead of the wire baseline, falls back to re-encoding just the remaining suffix. The persisted bytes are byte-identical either way. M2: document, on the sendDictCatchUp oversized-entry terminal, that in a heterogeneous or rolling-cap cluster a symbol accepted under a larger cap can hit this hard stop on failover to a smaller-cap node and will not self-recover if a later node advertises a larger cap. Behavior is unchanged -- the terminal keeps the homogeneous common case livelock-free; this only records the tradeoff and the two ways to relax it (an ingest-side symbol-size bound, or a settle budget across reconnects before latching). Adds testAppendRawEntriesMatchesAppendSymbols; the file-mode persist, recovery, and failed-publish suites cover both the direct-copy and re-encode branches end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8dd7723 commit 8853d8f

5 files changed

Lines changed: 146 additions & 14 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ public class QwpWebSocketEncoder implements QuietCloseable {
3939

4040
private final QwpColumnWriter columnWriter = new QwpColumnWriter();
4141
private NativeBufferWriter buffer;
42+
// Byte offsets, within the buffer, of the symbol-dict delta ENTRY region
43+
// ([len][utf8]... only, without the two section varints) that beginMessage
44+
// last wrote. Let the producer persist those bytes straight to the slot's
45+
// .symbol-dict instead of re-encoding the same symbols (see
46+
// QwpWebSocketSender.persistNewSymbolsBeforePublish). Valid until the next
47+
// beginMessage; stored as offsets so they survive a buffer realloc.
48+
private int deltaEntriesEnd;
49+
private int deltaEntriesStart;
4250
// QWP ingress always advertises Gorilla timestamp encoding. The column
4351
// writer still emits a per-column encoding byte and falls back to raw
4452
// values when delta-of-delta overflows int32.
@@ -75,10 +83,12 @@ public void beginMessage(
7583
payloadStart = buffer.getPosition();
7684
buffer.putVarint(deltaStart);
7785
buffer.putVarint(deltaCount);
86+
deltaEntriesStart = buffer.getPosition();
7887
for (int id = deltaStart; id < deltaStart + deltaCount; id++) {
7988
String symbol = globalDict.getSymbol(id);
8089
buffer.putString(symbol);
8190
}
91+
deltaEntriesEnd = buffer.getPosition();
8292
columnWriter.setBuffer(buffer);
8393
}
8494

@@ -122,6 +132,22 @@ public QwpBufferWriter getBuffer() {
122132
return buffer;
123133
}
124134

135+
/**
136+
* Byte length of the symbol-dict delta ENTRY region ({@code [len][utf8]...},
137+
* excluding the two section varints) that {@link #beginMessage} last wrote.
138+
*/
139+
public int getDeltaEntriesLen() {
140+
return deltaEntriesEnd - deltaEntriesStart;
141+
}
142+
143+
/**
144+
* Byte offset, within {@link #getBuffer()}, of the symbol-dict delta ENTRY
145+
* region {@link #beginMessage} last wrote.
146+
*/
147+
public int getDeltaEntriesStart() {
148+
return deltaEntriesStart;
149+
}
150+
125151
public void setDeferCommit(boolean defer) {
126152
if (defer) {
127153
flags |= FLAG_DEFER_COMMIT;

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

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3681,24 +3681,41 @@ private void persistNewSymbolsBeforePublish() {
36813681
if (pd == null) {
36823682
return;
36833683
}
3684-
// Persist [pd.size() .. currentBatchMaxSymbolId] as ONE write.
3684+
// Persist [pd.size() .. currentBatchMaxSymbolId] as ONE write, BEFORE the
3685+
// frame is published.
36853686
//
36863687
// Resume from the dictionary's own durable size, NOT sentMaxSymbolId+1:
3687-
// appendSymbols advances pd.size() only after a full write, whereas
3688+
// the persist advances pd.size() only after a full write, whereas
36883689
// sentMaxSymbolId only advances after the WHOLE frame is published (via
36893690
// advanceSentMaxSymbolId, after activeBuffer.write). If a prior persist
3690-
// threw (a short write -- disk full/quota), the frame was not published
3691-
// and sentMaxSymbolId stayed put, while the symbols before the failing
3692-
// one are already on disk. Keying the resume point off sentMaxSymbolId+1
3693-
// would then re-append that persisted prefix on the retry, duplicating
3694-
// entries and corrupting the dense id->symbol mapping recovery relies on
3695-
// (position i must be symbol id i). pd.size() resumes exactly past what is
3696-
// already durable (the write overwrites any torn trailing bytes at
3697-
// appendOffset), so the write-ahead is idempotent under retry. In the
3698-
// happy path pd.size() equals sentMaxSymbolId+1, so the range -- and the
3699-
// behaviour -- are unchanged; the single positioned write just replaces
3700-
// the previous one-syscall-per-new-symbol loop.
3701-
pd.appendSymbols(globalSymbolDictionary, pd.size(), currentBatchMaxSymbolId);
3691+
// threw (short write -- disk full/quota) or the publish threw, the frame
3692+
// was not published and sentMaxSymbolId stayed put, while the symbols
3693+
// before the failure are already on disk. Keying the resume point off
3694+
// sentMaxSymbolId+1 would re-append that persisted prefix on the retry,
3695+
// duplicating entries and corrupting the dense id->symbol mapping recovery
3696+
// relies on (position i must be symbol id i). pd.size() resumes exactly
3697+
// past what is already durable, so the write-ahead is idempotent.
3698+
int from = pd.size();
3699+
if (currentBatchMaxSymbolId < from) {
3700+
return; // nothing new to persist (warm batch, or an idempotent retry)
3701+
}
3702+
// Fast path: the frame the encoder just built already holds these symbols
3703+
// in its delta section as [len][utf8]... -- byte-identical to what
3704+
// PersistedSymbolDict stores. In the common case pd.size() equals the
3705+
// frame's delta start id (sentMaxSymbolId+1), so persist those bytes
3706+
// straight from the frame instead of re-encoding the symbols. After a
3707+
// failed publish the durable size has run ahead of the wire baseline, so
3708+
// the frame's delta covers MORE than remains to persist; then re-encode
3709+
// just the [from .. currentBatchMaxSymbolId] suffix.
3710+
if (from == sentMaxSymbolId + 1) {
3711+
QwpBufferWriter buffer = encoder.getBuffer();
3712+
pd.appendRawEntries(
3713+
buffer.getBufferPtr() + encoder.getDeltaEntriesStart(),
3714+
encoder.getDeltaEntriesLen(),
3715+
currentBatchMaxSymbolId - from + 1);
3716+
} else {
3717+
pd.appendSymbols(globalSymbolDictionary, from, currentBatchMaxSymbolId);
3718+
}
37023719
}
37033720

37043721
private void resetSymbolDictStateForNewConnection() {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2192,6 +2192,15 @@ private int sendDictCatchUp() {
21922192
// Latch a terminal (the data must be resent after the cap is
21932193
// raised) rather than calling fail() -- which, from inside the
21942194
// catch-up, would re-enter connectLoop (see CatchUpSendException).
2195+
//
2196+
// Tradeoff (heterogeneous / rolling-cap clusters): a symbol
2197+
// accepted under a larger/absent cap can hit this on failover to a
2198+
// smaller-cap node, and the hard terminal does NOT self-recover if
2199+
// a later node advertises a larger cap -- the producer must be
2200+
// resumed after the data is resent (or the cap raised). Bounding
2201+
// symbol size at ingest, or a settle budget across reconnects
2202+
// before latching, would relax this, but both are larger changes;
2203+
// the terminal keeps the homogeneous common case livelock-free.
21952204
LineSenderException err = new LineSenderException(
21962205
"symbol dictionary entry too large for the server batch cap during catch-up ["
21972206
+ "entryBytes=" + entryBytes + ", budget=" + budget + ']');

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,30 @@ public static void removeOrphan(String slotDir) {
148148
Files.remove(slotDir + "/" + FILE_NAME);
149149
}
150150

151+
/**
152+
* Appends {@code count} already-encoded entries -- {@code [len varint][utf8]...},
153+
* the exact layout {@link #appendSymbols} produces -- verbatim from {@code addr}
154+
* in a single write. The producer uses this when it already holds those bytes
155+
* (the symbol-dict delta section the frame encoder just wrote), so it does not
156+
* re-encode the same symbols. Writes {@code len} bytes and advances {@code size}
157+
* by {@code count}. Same durability/idempotency contract as {@link #appendSymbols}:
158+
* no fsync, and a short write throws WITHOUT advancing {@code size}/{@code
159+
* appendOffset}, so a retry keyed off {@link #size()} re-persists the same range
160+
* at the same offset. No-op when the range is empty or the dictionary is closed.
161+
*/
162+
public void appendRawEntries(long addr, int len, int count) {
163+
if (closed || count <= 0 || len <= 0) {
164+
return;
165+
}
166+
long written = Files.write(fd, addr, len, appendOffset);
167+
if (written != len) {
168+
throw new IllegalStateException("short write to " + FILE_NAME
169+
+ " [expected=" + len + ", actual=" + written + ']');
170+
}
171+
appendOffset += len;
172+
size += count;
173+
}
174+
151175
/**
152176
* Appends one symbol, extending the on-disk dictionary. The caller appends a
153177
* frame's new symbols BEFORE publishing that frame, so the write ordering

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,62 @@ public void testAppendPersistsAcrossReopen() throws Exception {
9191
});
9292
}
9393

94+
@Test
95+
public void testAppendRawEntriesMatchesAppendSymbols() throws Exception {
96+
// M1: the producer persists the frame's already-encoded delta bytes via
97+
// appendRawEntries instead of re-encoding the symbols. Those bytes are the
98+
// same [len][utf8]... layout appendSymbols writes, so both must produce an
99+
// identical, recoverable dictionary. Encode a range with appendSymbols,
100+
// reopen to grab its on-disk entry bytes, replay them through
101+
// appendRawEntries into a fresh dict, and assert the recovered symbols
102+
// match -- including an empty entry mid-range.
103+
assertMemoryLeak(() -> {
104+
Path src = Files.createTempDirectory("qwp-symdict-src");
105+
Path dst = Files.createTempDirectory("qwp-symdict-dst");
106+
try {
107+
GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
108+
dict.getOrAddSymbol("AAPL"); // id 0
109+
dict.getOrAddSymbol(""); // id 1 -- empty entry mid-range
110+
dict.getOrAddSymbol("MSFT"); // id 2
111+
112+
PersistedSymbolDict encoded = PersistedSymbolDict.open(src.toString());
113+
encoded.appendSymbols(dict, 0, 2);
114+
encoded.close();
115+
116+
// Reopen to obtain the on-disk entry region [len][utf8]... verbatim,
117+
// then replay it byte-for-byte into a fresh dict via appendRawEntries.
118+
PersistedSymbolDict reopened = PersistedSymbolDict.open(src.toString());
119+
try {
120+
PersistedSymbolDict raw = PersistedSymbolDict.open(dst.toString());
121+
try {
122+
raw.appendRawEntries(reopened.loadedEntriesAddr(),
123+
reopened.loadedEntriesLen(), reopened.size());
124+
Assert.assertEquals(3, raw.size());
125+
} finally {
126+
raw.close();
127+
}
128+
} finally {
129+
reopened.close();
130+
}
131+
132+
// The raw-appended dict must recover the same dense symbols.
133+
PersistedSymbolDict recovered = PersistedSymbolDict.open(dst.toString());
134+
try {
135+
Assert.assertEquals(3, recovered.size());
136+
ObjList<String> symbols = recovered.readLoadedSymbols();
137+
Assert.assertEquals("AAPL", symbols.getQuick(0));
138+
Assert.assertEquals("", symbols.getQuick(1));
139+
Assert.assertEquals("MSFT", symbols.getQuick(2));
140+
} finally {
141+
recovered.close();
142+
}
143+
} finally {
144+
rmDir(src);
145+
rmDir(dst);
146+
}
147+
});
148+
}
149+
94150
@Test
95151
public void testAppendSymbolsBatchWritesDenseRange() throws Exception {
96152
// appendSymbols persists a whole id range in one write (the hot-path

0 commit comments

Comments
 (0)