Skip to content

Commit b279eec

Browse files
glasstigerclaude
andcommitted
Re-fold recovery at baseline 0 when discarding the dictionary
The full-dict-fallback branch discards a persisted .symbol-dict whose frames carry their whole dictionary inline (maxSymbolDeltaStart == 0 and recoveredMaxSymbolId >= size). But the recovery analysis was folded with the dictionary's size as its baseline, and every consumer of it presents the baseline it derived the same way: once pd is null, seedGlobalDictionaryFromPersisted computes baseline 0, and checkedRecoveryAnalysis rejects a baseline that disagrees with the fold. Discarding a dictionary that held entries (size > 0) therefore threw IllegalStateException("recovery symbol baseline mismatch") out of build(). That is not an UnreplayableSlotException, so build()'s quarantine handler could not set the slot aside. With a stable senderId every restart re-recovered the same slot and threw again, so the application could never construct a Sender -- it could not even buffer new rows. Exactly the outage quarantineTornSlot exists to prevent, and on a slot that is fully recoverable, since its frames need no dictionary. It takes one transient plus one crash: a session whose .symbol-dict fails to open (EIO, fd exhaustion, a Windows share lock) runs full-dict fallback and, per the never-recreate contract, leaves the previous session's populated side-file intact; if that session then crashes, this recovery opens a dictionary with size > 0 beside self-sufficient frames that out-reach it. The prior regression test covered only the size == 0 case. Re-fold at baseline 0 after the discard so producer, mirror and replay guard all anchor at 0 -- the full-dict mode the slot was written in. The new test plants a populated survivor beside full-dict frames; reverting the re-fold throws "recovery symbol baseline mismatch [expected=3, actual=0]". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5ded812 commit b279eec

2 files changed

Lines changed: 125 additions & 0 deletions

File tree

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,36 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
404404
&& recoveredMaxSymbolDeltaStart == 0L) {
405405
persistedDictInProgress.close();
406406
persistedDictInProgress = null;
407+
// Re-fold at baseline 0. The analysis above was keyed to the
408+
// dictionary's size, and every consumer of it presents the baseline it
409+
// derived the same way -- seedGlobalDictionaryFromPersisted computes
410+
// baseline 0 once pd is gone, and checkedRecoveryAnalysis rejects a
411+
// baseline that disagrees with the fold. Discarding a dictionary that
412+
// held entries (size > 0) therefore desynchronised the two and threw
413+
// IllegalStateException("recovery symbol baseline mismatch") out of
414+
// build(). That is NOT an UnreplayableSlotException, so build()'s
415+
// quarantine handler could not catch it and set the slot aside: with a
416+
// stable senderId every restart re-recovered the same slot and threw
417+
// again, so the application could never construct a Sender -- it could
418+
// not even BUFFER new rows. Exactly the outage quarantineTornSlot
419+
// exists to prevent, on a slot that is fully recoverable (its frames
420+
// carry their whole dictionary inline).
421+
//
422+
// Reachable on one transient plus one crash: a session whose
423+
// .symbol-dict fails to open (EIO, fd exhaustion, a Windows share
424+
// lock) falls back to full-dict frames and, per the never-recreate
425+
// contract, leaves the previous session's populated side-file intact;
426+
// if that session then crashes, this recovery opens a dictionary with
427+
// size > 0 next to self-sufficient frames that out-reach it.
428+
//
429+
// Re-folding rather than keeping the dictionary preserves the discard's
430+
// whole point -- the slot recovers in full-dict mode, exactly as it was
431+
// written, with producer, mirror and replay guard all anchored at 0.
432+
recoveredFrameAnalysisInProgress.close();
433+
recoveredFrameAnalysisInProgress = recovered.analyzeRecovery(0);
434+
this.recoveredCommitBoundaryFsn = recoveredFrameAnalysisInProgress.commitBoundaryFsn();
435+
this.recoveredMaxSymbolId = recoveredFrameAnalysisInProgress.maxDeltaEnd() - 1L;
436+
this.recoveredMaxSymbolDeltaStart = recoveredFrameAnalysisInProgress.maxDeltaStart();
407437
}
408438
} else {
409439
// Fresh start with no recovered segments. Any stale

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,101 @@ public void testTornDictTotalLossRebuildsFromSurvivingFrames() throws Exception
13521352
});
13531353
}
13541354

1355+
@Test
1356+
public void testFullDictFramesRecoverBesideASurvivingPopulatedDictionary() throws Exception {
1357+
// The populated-side-file twin of testFullDictFramesRecoverInFullDictModeInsteadOf-
1358+
// Bricking. Self-sufficient frames can also be recovered next to a .symbol-dict
1359+
// that HOLDS entries, and that slot must recover exactly as the empty-side-file
1360+
// one does.
1361+
//
1362+
// It takes one transient plus one crash. A delta session leaves a populated
1363+
// .symbol-dict. The next session's open of it fails transiently (EIO, fd
1364+
// exhaustion, a Windows share lock), so that session runs full-dict fallback --
1365+
// and PersistedSymbolDict's never-recreate contract deliberately leaves the
1366+
// older side-file intact rather than truncating the only copy of load-bearing
1367+
// state. That session writes self-sufficient frames out-reaching the stale
1368+
// dictionary, then crashes. This recovery opens the survivor: size() > 0,
1369+
// maxSymbolDeltaStart == 0, recoveredMaxSymbolId >= size().
1370+
//
1371+
// CursorSendEngine discards the dictionary here (the frames carry their own, so
1372+
// it is not needed) -- but the recovery analysis was folded with the dictionary's
1373+
// size as its baseline, while seedGlobalDictionaryFromPersisted computes baseline
1374+
// 0 once pd is gone. checkedRecoveryAnalysis then threw
1375+
// IllegalStateException("recovery symbol baseline mismatch"), which is NOT an
1376+
// UnreplayableSlotException, so build()'s quarantine handler could not set the
1377+
// slot aside. With a stable senderId every restart re-recovered the same slot and
1378+
// threw again: the application could never construct a Sender, so it could not
1379+
// even buffer new rows -- on a slot that is perfectly recoverable. The engine now
1380+
// re-folds at baseline 0 when it discards.
1381+
assertMemoryLeak(() -> {
1382+
java.nio.file.Path slot = Paths.get(sfDir, "default");
1383+
java.nio.file.Path dict = slot.resolve(".symbol-dict");
1384+
// Phase 1: force full-dict fallback with a planted directory, so the frames
1385+
// land self-sufficient (deltaStart=0) and no side-file is written.
1386+
java.nio.file.Files.createDirectories(dict);
1387+
java.nio.file.Path blocker = dict.resolve("blocker");
1388+
java.nio.file.Files.createFile(blocker);
1389+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
1390+
int port = silent.getPort();
1391+
silent.start();
1392+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
1393+
String cfg = "ws::addr=localhost:" + port
1394+
+ ";sf_dir=" + sfDir
1395+
+ ";close_flush_timeout_millis=0;";
1396+
try (Sender s1 = Sender.fromConfig(cfg)) {
1397+
for (int i = 0; i < DISTINCT_SYMBOLS; i++) {
1398+
s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
1399+
s1.flush();
1400+
}
1401+
}
1402+
Assert.assertTrue("full-dict fallback: .symbol-dict must stay a directory",
1403+
java.nio.file.Files.isDirectory(dict));
1404+
}
1405+
1406+
// Swap the planted directory for the POPULATED side-file the earlier delta
1407+
// session left behind. Fewer entries than the frames reference, so the
1408+
// discard's recoveredMaxSymbolId >= size() guard fires with size() > 0 --
1409+
// the case that bricked build().
1410+
java.nio.file.Files.delete(blocker);
1411+
java.nio.file.Files.delete(dict);
1412+
final int survivingEntries = 3;
1413+
Assert.assertTrue(survivingEntries < DISTINCT_SYMBOLS);
1414+
try (PersistedSymbolDict survivor = PersistedSymbolDict.openClean(slot.toString())) {
1415+
Assert.assertNotNull("the survivor dictionary must open", survivor);
1416+
for (int i = 0; i < survivingEntries; i++) {
1417+
survivor.appendSymbol("sym-" + i);
1418+
}
1419+
Assert.assertEquals(survivingEntries, survivor.size());
1420+
}
1421+
1422+
// Phase 2: recover. build() must SUCCEED, and the self-sufficient frames must
1423+
// replay gap-free in full-dict mode -- the stale dictionary is discarded, not
1424+
// trusted, so it cannot misattribute an id.
1425+
DictReconstructingHandler handler = new DictReconstructingHandler();
1426+
try (TestWebSocketServer good = new TestWebSocketServer(handler)) {
1427+
int port = good.getPort();
1428+
good.start();
1429+
Assert.assertTrue(good.awaitStart(5, TimeUnit.SECONDS));
1430+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";";
1431+
try (Sender ignored = Sender.fromConfig(cfg)) { // must NOT throw
1432+
long deadline = System.currentTimeMillis() + 5_000;
1433+
while (System.currentTimeMillis() < deadline
1434+
&& handler.maxDictSize() < DISTINCT_SYMBOLS) {
1435+
Thread.sleep(20);
1436+
}
1437+
}
1438+
Assert.assertFalse("a discarded dictionary leaves full-dict mode, which is "
1439+
+ "catch-up-free", handler.sawCatchUpFrame);
1440+
List<String> reconstructed = handler.dictSnapshot();
1441+
Assert.assertEquals("reconstructed dictionary size",
1442+
DISTINCT_SYMBOLS, reconstructed.size());
1443+
for (int i = 0; i < DISTINCT_SYMBOLS; i++) {
1444+
Assert.assertEquals("dictionary id " + i, "sym-" + i, reconstructed.get(i));
1445+
}
1446+
}
1447+
});
1448+
}
1449+
13551450
@Test
13561451
public void testFullDictFramesRecoverInFullDictModeInsteadOfBricking() throws Exception {
13571452
// M1 regression (counterpoint to testTornDictTotalLossFailsCleanOnResume): a

0 commit comments

Comments
 (0)