Skip to content

Commit 444199e

Browse files
glasstigerclaude
andcommitted
Fix symbol-dict durability doc; add recovery test
The PersistedSymbolDict class doc claimed a host-crash-torn dictionary is "caught at replay by the send loop's guard ... rather than corrupting the target table." The guard is tail-only: it fires only when a frame's delta start id exceeds the recovered dictionary size. It does NOT catch an interior page lost out of order (reads back as zeroes that parse as empty-string entries) or a failed best-effort truncate that leaves a stale trailing entry -- either shifts the dense id->symbol mapping without changing the entry count the guard checks, so a replay silently misattributes symbols. Correct the class doc and the truncate comment to describe the actual, tail-only protection and name the residual host-crash exposure (within the already-documented "not host-crash durable" boundary); note that a per-entry or running CRC would close it. Add testRecoveryAfterFailedPublishReplaysGapFree: a failed publish persists a frame's symbol without recording the frame, leaving the persisted dictionary a strict superset of the recorded frames. The test recovers the slot on a fresh sender against a dictionary-reconstructing server and asserts the catch-up re-registers the whole superset (the unrecorded symbol included) and the replay is gap-free -- the end-to-end fail -> recover -> replay chain the existing no-duplicate test omitted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fa50e81 commit 444199e

2 files changed

Lines changed: 112 additions & 5 deletions

File tree

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,18 @@
6767
* dictionary is a superset of every recoverable frame's references. It is NOT
6868
* sufficient for a <b>host/power crash</b>, where unflushed pages can be lost out
6969
* of order and the dictionary may end up torn relative to the frames it serves --
70-
* exactly as the segment frames themselves may be lost on a host crash. A torn
71-
* dictionary is caught at replay by the send loop's guard, which fails loudly
72-
* (the unreplayable data must be resent) rather than corrupting the target table.
70+
* exactly as the segment frames themselves may be lost on a host crash. The send
71+
* loop's replay guard catches the COMMON host-crash outcome -- a truncated tail,
72+
* where a frame's delta start id exceeds the recovered dictionary size -- and
73+
* fails loudly (the unreplayable data must be resent) rather than sending a
74+
* gapped frame. It does NOT catch every host-crash tear: an interior page lost
75+
* out of order reads back as zeroes that parse as empty-string entries, and a
76+
* failed best-effort truncate (see {@link #open}) can leave a stale trailing
77+
* entry -- either SHIFTS the dense id->symbol mapping without changing the entry
78+
* count the guard checks, so a replay silently misattributes symbols. That
79+
* residual exposure sits within the "not host-crash durable" boundary above; a
80+
* per-entry or running CRC would be needed to close it (the 3 reserved header
81+
* bytes leave room for a future integrity field).
7382
* <p>
7483
* A torn trailing entry from a crash mid-append is self-healing: {@link #open}
7584
* stops parsing at the first incomplete entry and the next append overwrites it.
@@ -367,8 +376,12 @@ private static PersistedSymbolDict openExisting(String filePath, long fileLen) {
367376
// shift every subsequent dense id. Without this, appendOffset already
368377
// lands just past the last complete entry, so the next append
369378
// overwrites FROM there, but only truncation guarantees nothing
370-
// survives BEYOND it. Best-effort: a failed truncate just forgoes the
371-
// hardening and falls back to the overwrite-from-appendOffset behaviour.
379+
// survives BEYOND it. Best-effort: a failed truncate (rare -- an I/O
380+
// error) leaves a latent silent-corruption path, not merely a lost
381+
// optimization: if a later, shorter append then leaves stale bytes past
382+
// its end, a subsequent recovery mis-parses them as a ghost symbol and
383+
// shifts the dense ids, and the count-based replay guard does not catch
384+
// it (see the class-level durability note).
372385
long validLen = HEADER_SIZE + entriesLen;
373386
if (validLen < len) {
374387
Files.truncate(fd, validLen);

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,100 @@ public void testFailedPublishDoesNotDuplicatePersistedSymbols() throws Exception
480480
});
481481
}
482482

483+
@Test
484+
public void testRecoveryAfterFailedPublishReplaysGapFree() throws Exception {
485+
// M3 end-to-end: chains a failed publish -> fresh-process recovery -> replay.
486+
// A failed publish persists the frame's symbol (write-ahead) but does NOT
487+
// record the frame, so the persisted dictionary becomes a strict SUPERSET of
488+
// the recorded frames' references. A recovering sender must still replay
489+
// gap-free: it re-registers the whole (superset) dictionary via the catch-up
490+
// -- including the symbol whose frame never reached disk -- so the fresh
491+
// server reconstructs a complete, gap-free dictionary. The sibling
492+
// testFailedPublishDoesNotDuplicatePersistedSymbols proves the dict has no
493+
// duplicate after the failed publish; this proves the resulting slot then
494+
// recovers and replays end-to-end against a real server.
495+
assertMemoryLeak(() -> {
496+
// Phase 1: a silent server (no acks) + a small SF segment. Four small
497+
// rows register sym-0..sym-3 and their frames are recorded; a fifth,
498+
// oversized row registers (persists) sym-4 but its frame is too large for
499+
// the segment, so appendBlocking throws and the frame is NOT recorded.
500+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
501+
int port = silent.getPort();
502+
silent.start();
503+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
504+
String cfg = "ws::addr=localhost:" + port
505+
+ ";sf_dir=" + sfDir
506+
+ ";sf_max_bytes=4096"
507+
+ ";close_flush_timeout_millis=0;";
508+
Sender s1 = Sender.fromConfig(cfg);
509+
try {
510+
for (int i = 0; i < 4; i++) {
511+
s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
512+
s1.flush();
513+
}
514+
// Oversized frame: sym-4 is persisted (write-ahead) before the
515+
// publish fails, so the dictionary runs one ahead of the frames.
516+
s1.table("m").symbol("s", "sym-4")
517+
.stringColumn("p", TestUtils.repeat("x", 8000))
518+
.longColumn("v", 4).atNow();
519+
try {
520+
s1.flush();
521+
Assert.fail("oversized frame must fail to publish");
522+
} catch (LineSenderException expected) {
523+
// PAYLOAD_TOO_LARGE -- frame not recorded, sym-4 stays persisted
524+
}
525+
} finally {
526+
try {
527+
// Re-flushes the still-buffered oversized row and fails again
528+
// (expected); resources are still released, and the idempotent
529+
// write-ahead does not re-append sym-4.
530+
s1.close();
531+
} catch (LineSenderException ignored) {
532+
}
533+
}
534+
}
535+
536+
// The persisted dictionary must hold the superset: sym-0..sym-4 (5 ids),
537+
// one more than the four recorded frames reference, with no duplicate.
538+
PersistedSymbolDict pd = PersistedSymbolDict.open(Paths.get(sfDir, "default").toString());
539+
Assert.assertNotNull(pd);
540+
try {
541+
Assert.assertEquals("failed publish must leave the dict a superset (sym-0..sym-4)",
542+
5, pd.size());
543+
} finally {
544+
pd.close();
545+
}
546+
547+
// Phase 2: recover against a fresh server that reconstructs its
548+
// dictionary from the wire. The recovering sender must re-register all 5
549+
// symbols via a catch-up (sym-4 exists ONLY in the dictionary -- no frame
550+
// carries it) and replay the 4 recorded frames, leaving a complete,
551+
// gap-free server dictionary.
552+
DictReconstructingHandler handler = new DictReconstructingHandler();
553+
try (TestWebSocketServer good = new TestWebSocketServer(handler)) {
554+
int port = good.getPort();
555+
good.start();
556+
Assert.assertTrue(good.awaitStart(5, TimeUnit.SECONDS));
557+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";";
558+
try (Sender ignored = Sender.fromConfig(cfg)) {
559+
long deadline = System.currentTimeMillis() + 5_000;
560+
while (System.currentTimeMillis() < deadline && handler.maxDictSize() < 5) {
561+
Thread.sleep(20);
562+
}
563+
}
564+
Assert.assertTrue("recovery must send a full-dictionary catch-up frame",
565+
handler.sawCatchUpFrame);
566+
List<String> dict = handler.dictSnapshot();
567+
Assert.assertEquals("recovered dictionary must include the failed-publish symbol",
568+
5, dict.size());
569+
for (int i = 0; i < 5; i++) {
570+
Assert.assertEquals("dictionary id " + i + " must be gap-free",
571+
"sym-" + i, dict.get(i));
572+
}
573+
}
574+
});
575+
}
576+
483577
private static void writeAckWatermark(java.nio.file.Path path, long fsn) throws IOException {
484578
byte[] buf = new byte[16];
485579
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);

0 commit comments

Comments
 (0)