Skip to content

Commit 8cfd7bd

Browse files
glasstigerclaude
andcommitted
Fix recovery id desync on UTF-8-colliding symbols
Two DISTINCT source symbols that collapse to the same UTF-8 bytes -- lone UTF-16 surrogates, which the encoder maps to '?' -- get distinct producer ids and persist as separate .symbol-dict entries. On recovery, seedGlobalDictionaryFromPersisted replayed them through getOrAddSymbol, which de-dups the decoded "?" strings, leaving the producer dictionary (and sentMaxSymbolId) one short of pd.size(). That desynced the delta baseline from the send-loop catch-up mirror (which uses pd.size()) and, after a reconnect, silently misattributed later well-formed symbols. Add GlobalSymbolDictionary.addRecoveredSymbol, which appends at the next id WITHOUT de-duplicating, and seed recovery through it so the producer id space always matches the persisted entry count. The reverse lookup keeps the highest id for a colliding string, which is harmless -- both ids encode to identical bytes. A GlobalSymbolDictionary unit test pins the no-dedup contract, and a new recovery test seeds a slot with two lone-surrogate symbols and asserts the producer dictionary and sentMaxSymbolId match the persisted entry count. It fails before this change (dictionary size 1 vs the file's 2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2fdbbf9 commit 8cfd7bd

4 files changed

Lines changed: 143 additions & 1 deletion

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@ public GlobalSymbolDictionary(int initialCapacity) {
5050
this.idToSymbol = new ObjList<>(initialCapacity);
5151
}
5252

53+
/**
54+
* Appends {@code symbol} at the next sequential id, matching a recovered /
55+
* persisted dictionary's dense id order, WITHOUT de-duplicating.
56+
* <p>
57+
* Recovery ({@code QwpWebSocketSender.seedGlobalDictionaryFromPersisted})
58+
* replays the persisted entries in id order to rebuild this dictionary. It must
59+
* NOT collapse two source strings that decode to the same characters, because
60+
* the persisted {@code .symbol-dict}, the on-wire delta and the I/O-thread
61+
* catch-up mirror all key on the entry POSITION (id), not on the string. The
62+
* only strings that collide this way are malformed lone UTF-16 surrogates,
63+
* which the UTF-8 encoder maps to {@code '?'}: {@link #getOrAddSymbol} would
64+
* de-dup them and leave this dictionary SHORTER than the persisted entry count,
65+
* desyncing the producer's delta baseline from the catch-up mirror (which uses
66+
* {@code pd.size()}) and silently misattributing later symbols. Appending
67+
* unconditionally keeps {@link #size()} equal to that count. The reverse lookup
68+
* keeps the highest id for a colliding string, which is harmless: both ids
69+
* encode to the same bytes, so resolving either is equivalent.
70+
*
71+
* @param symbol the recovered symbol string (must not be null)
72+
* @return the id assigned (the previous {@link #size()})
73+
*/
74+
public int addRecoveredSymbol(String symbol) {
75+
if (symbol == null) {
76+
throw new IllegalArgumentException("symbol cannot be null");
77+
}
78+
int newId = idToSymbol.size();
79+
symbolToId.put(symbol, newId);
80+
idToSymbol.add(symbol);
81+
return newId;
82+
}
83+
5384
/**
5485
* Clears all symbols from the dictionary.
5586
* <p>

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3750,14 +3750,24 @@ private void resetSymbolDictStateForNewConnection() {
37503750
* the slot's persisted dictionary (ids assigned in the same ascending order,
37513751
* so they match the recovered frames) and resumes the delta baseline at the
37523752
* recovered tip, so newly ingested symbols continue above the recovered ids.
3753+
* <p>
3754+
* Uses {@link GlobalSymbolDictionary#addRecoveredSymbol} (append, NOT de-dup):
3755+
* the persisted dictionary, the on-wire delta and the send-loop catch-up mirror
3756+
* all key on the entry POSITION (id), so the producer id space must match the
3757+
* persisted entry count exactly. {@code getOrAddSymbol} would collapse two
3758+
* source strings that decode to the same characters -- only malformed lone
3759+
* UTF-16 surrogates, which UTF-8-encode to {@code '?'} -- leaving this
3760+
* dictionary shorter than {@code pd.size()} and desyncing
3761+
* {@code sentMaxSymbolId} from the mirror's {@code sentDictCount = pd.size()},
3762+
* which silently misattributes later symbols after a reconnect.
37533763
*/
37543764
private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
37553765
if (pd == null || pd.size() == 0) {
37563766
return;
37573767
}
37583768
ObjList<String> symbols = pd.readLoadedSymbols();
37593769
for (int i = 0, n = symbols.size(); i < n; i++) {
3760-
globalSymbolDictionary.getOrAddSymbol(symbols.getQuick(i));
3770+
globalSymbolDictionary.addRecoveredSymbol(symbols.getQuick(i));
37613771
}
37623772
sentMaxSymbolId = globalSymbolDictionary.size() - 1;
37633773
}

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.junit.Test;
3737

3838
import java.io.IOException;
39+
import java.lang.reflect.Field;
3940
import java.nio.ByteBuffer;
4041
import java.nio.ByteOrder;
4142
import java.nio.charset.StandardCharsets;
@@ -713,6 +714,76 @@ public void testRecoveryAfterFailedPublishReplaysGapFree() throws Exception {
713714
});
714715
}
715716

717+
@Test
718+
public void testRecoverySeedKeepsUtf8CollidingSymbolsInLockstep() throws Exception {
719+
// M2 regression: two DISTINCT source symbols that collapse to the SAME UTF-8
720+
// bytes -- lone UTF-16 surrogates, which the encoder maps to '?' -- get
721+
// distinct producer ids and persist as separate entries. On recovery the
722+
// producer must rebuild its id space to match the persisted entry count
723+
// exactly. Pre-fix, seedGlobalDictionaryFromPersisted used getOrAddSymbol,
724+
// which de-duped the two decoded "?" strings, leaving the producer
725+
// dictionary (and sentMaxSymbolId) one short of pd.size() -- desyncing from
726+
// the send-loop catch-up mirror (which uses pd.size()) and silently
727+
// misattributing later symbols after a reconnect. addRecoveredSymbol appends
728+
// without de-duping, keeping producer and mirror in lockstep.
729+
assertMemoryLeak(() -> {
730+
// Phase 1: ingest two lone-surrogate symbols in file mode, close-fast.
731+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
732+
int port = silent.getPort();
733+
silent.start();
734+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
735+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
736+
+ ";close_flush_timeout_millis=0;";
737+
try (Sender s1 = Sender.fromConfig(cfg)) {
738+
s1.table("m").symbol("s", "\uD800").longColumn("v", 1L).atNow(); // lone surrogate -> '?'
739+
s1.flush();
740+
s1.table("m").symbol("s", "\uD801").longColumn("v", 2L).atNow(); // a DIFFERENT one -> '?'
741+
s1.flush();
742+
}
743+
}
744+
745+
// The persisted dictionary holds TWO entries (both encode to '?').
746+
int persistedSize;
747+
PersistedSymbolDict pd = PersistedSymbolDict.open(Paths.get(sfDir, "default").toString());
748+
Assert.assertNotNull(pd);
749+
try {
750+
persistedSize = pd.size();
751+
} finally {
752+
pd.close();
753+
}
754+
Assert.assertEquals("two colliding symbols must persist as two entries", 2, persistedSize);
755+
756+
// Phase 2: recover. The seeded producer id space must match pd.size().
757+
DictReconstructingHandler handler = new DictReconstructingHandler();
758+
try (TestWebSocketServer good = new TestWebSocketServer(handler)) {
759+
int port = good.getPort();
760+
good.start();
761+
Assert.assertTrue(good.awaitStart(5, TimeUnit.SECONDS));
762+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";";
763+
try (Sender s2 = Sender.fromConfig(cfg)) {
764+
Assert.assertEquals("recovered producer dictionary must match the persisted "
765+
+ "entry count (not de-duped), else the delta baseline desyncs from the mirror",
766+
persistedSize, globalDictSize(s2));
767+
Assert.assertEquals("delta baseline must resume at the persisted tip",
768+
persistedSize - 1, intField(s2, "sentMaxSymbolId"));
769+
}
770+
}
771+
});
772+
}
773+
774+
private static int globalDictSize(Sender sender) throws Exception {
775+
Field f = sender.getClass().getDeclaredField("globalSymbolDictionary");
776+
f.setAccessible(true);
777+
Object dict = f.get(sender);
778+
return (int) dict.getClass().getMethod("size").invoke(dict);
779+
}
780+
781+
private static int intField(Sender sender, String name) throws Exception {
782+
Field f = sender.getClass().getDeclaredField(name);
783+
f.setAccessible(true);
784+
return f.getInt(sender);
785+
}
786+
716787
private static void writeAckWatermark(java.nio.file.Path path, long fsn) throws IOException {
717788
byte[] buf = new byte[16];
718789
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,36 @@
3131

3232
public class GlobalSymbolDictionaryTest {
3333

34+
@Test
35+
public void testAddRecoveredSymbol_appendsWithoutDeduplicating() {
36+
// Recovery replays persisted entries in id order. Distinct source strings
37+
// that decode to the same characters -- lone UTF-16 surrogates both
38+
// UTF-8-encode to '?', so they read back as the string "?" -- must keep
39+
// DISTINCT ids, so the producer id space matches the persisted entry count.
40+
// getOrAddSymbol de-dups them; addRecoveredSymbol must not.
41+
GlobalSymbolDictionary dedup = new GlobalSymbolDictionary();
42+
dedup.getOrAddSymbol("?");
43+
dedup.getOrAddSymbol("?");
44+
assertEquals("getOrAddSymbol de-dups colliding strings", 1, dedup.size());
45+
46+
GlobalSymbolDictionary recovered = new GlobalSymbolDictionary();
47+
assertEquals(0, recovered.addRecoveredSymbol("?"));
48+
assertEquals(1, recovered.addRecoveredSymbol("?"));
49+
assertEquals(2, recovered.addRecoveredSymbol("nvda"));
50+
assertEquals("addRecoveredSymbol keeps colliding entries distinct", 3, recovered.size());
51+
52+
// Dense id -> symbol mapping is preserved position-for-position.
53+
assertEquals("?", recovered.getSymbol(0));
54+
assertEquals("?", recovered.getSymbol(1));
55+
assertEquals("nvda", recovered.getSymbol(2));
56+
57+
// A later ingest of a colliding string reuses the highest recovered id
58+
// (harmless -- both encode to identical bytes), and a genuinely new symbol
59+
// continues past the recovered tip.
60+
assertEquals(1, recovered.getOrAddSymbol("?"));
61+
assertEquals(3, recovered.getOrAddSymbol("brand-new"));
62+
}
63+
3464
@Test
3565
public void testAddSymbol_assignsSequentialIds() {
3666
GlobalSymbolDictionary dict = new GlobalSymbolDictionary();

0 commit comments

Comments
 (0)