Skip to content

Commit c0c2ce2

Browse files
glasstigerclaude
andcommitted
Preserve symbol dict on a transient stat fault
PersistedSymbolDict.open() computed the routing length as ff.length(path) and sent anything below HEADER_SIZE to openFresh(), which O_TRUNCs the file. A stat that fails transiently (an EIO on a flaky disk) returns -1 for a fully populated dictionary, so that routing could truncate the only copy of load-bearing recovery state -- the exact destruction the class's "never recreate over an existing file" contract forbids, and which the openExisting read path already guards against but the routing check did not. A stat error reports -1, distinguishable from a genuine sub-header stub in [0, HEADER_SIZE), so open() now degrades to null when the file exists but its length reads negative: it falls back to full self-sufficient frames and leaves every byte on disk for a later attempt, once the transient clears. Also move appendRawEntries' per-entry (addr, len, count) consistency walk behind an assert. It re-reads every entry's length prefix on the common flush path, yet its sole caller derives count and len from one beginMessage, so it cannot fire today. The assert keeps the structural guard in the client's -ea test suite while eliding it from production, where client apps run without -ea. PersistedSymbolDictTest now covers a transient length fault leaving the file intact and recovering once it clears, and an inconsistent raw-entries triple being rejected under -ea. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7299925 commit c0c2ce2

2 files changed

Lines changed: 182 additions & 31 deletions

File tree

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

Lines changed: 64 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,22 @@ public static PersistedSymbolDict open(String slotDir) {
215215
*/
216216
public static PersistedSymbolDict open(FilesFacade ff, String slotDir) {
217217
String filePath = slotDir + "/" + FILE_NAME;
218-
long existing = ff.exists(filePath) ? ff.length(filePath) : -1L;
218+
boolean exists = ff.exists(filePath);
219+
long existing = exists ? ff.length(filePath) : -1L;
220+
if (exists && existing < 0) {
221+
// The file is present but its length could not be stat'd (a transient EIO
222+
// on a flaky disk). Do NOT fall through to openFresh below, which O_TRUNCs:
223+
// truncating the only copy of load-bearing state on a TRANSIENT fault is the
224+
// exact destruction the class-level "Never recreate over an existing file"
225+
// note forbids -- and unlike the openExisting read path, this routing check
226+
// otherwise has no guard. A genuine sub-header stub reports a length in
227+
// [0, HEADER_SIZE); only a stat error reports < 0, so the two are
228+
// distinguishable. Degrade to full self-sufficient frames and leave every
229+
// byte on disk for a later attempt, once the transient clears.
230+
LOG.warn("symbol dict {} exists but its length could not be read; "
231+
+ "falling back to full-dictionary frames (file left intact)", filePath);
232+
return null;
233+
}
219234
if (existing >= HEADER_SIZE) {
220235
// A dictionary at or past Integer.MAX_VALUE cannot be read back:
221236
// openExisting reads it into ONE int-sized native buffer, and the (int)
@@ -310,36 +325,12 @@ public synchronized void appendRawEntries(long addr, int len, int count) {
310325
// with the entries it holds, shifting the dense id->symbol map on recovery.
311326
// The sole caller derives count and len from one beginMessage, so this cannot
312327
// fire today -- but the file this guards is the one the "resend required"
313-
// contract rests on, so it stays.
314-
long src = addr;
315-
long srcLimit = addr + len;
316-
for (int i = 0; i < count; i++) {
317-
long symLen = 0;
318-
int shift = 0;
319-
while (src < srcLimit) {
320-
byte b = Unsafe.getUnsafe().getByte(src++);
321-
symLen |= (long) (b & 0x7F) << shift;
322-
if ((b & 0x80) == 0) {
323-
break;
324-
}
325-
shift += 7;
326-
if (shift > 35) {
327-
// A canonical entry-length varint is <= 5 bytes; a longer
328-
// continuation run is corrupt. The bound check below rejects it.
329-
break;
330-
}
331-
}
332-
src += symLen; // src was just past the len varint
333-
if (src > srcLimit) {
334-
throw new IllegalStateException("malformed raw symbol-dict entries to " + FILE_NAME
335-
+ " [entry=" + i + ", count=" + count + ']');
336-
}
337-
}
338-
if (src != srcLimit) {
339-
throw new IllegalStateException("raw symbol-dict entries under-filled the buffer to "
340-
+ FILE_NAME + " [count=" + count + ", len=" + len
341-
+ ", consumed=" + (int) (src - addr) + ']');
342-
}
328+
// contract rests on, so keep the structural guard. Gated behind assert: it
329+
// re-walks every entry's length prefix on the common flush path, and the client
330+
// library runs without -ea in production (embedded in user apps), so this holds
331+
// the guard in the client's own -ea test suite without the per-flush cost in
332+
// production.
333+
assert validateRawEntries(addr, len, count);
343334
int hdrLen = NativeBufferWriter.varintSize(count) + NativeBufferWriter.varintSize(len);
344335
ensureScratch((long) hdrLen + len + CRC_SIZE);
345336
long p = NativeBufferWriter.writeVarint(scratchAddr, count);
@@ -693,6 +684,48 @@ private static PersistedSymbolDict openFresh(FilesFacade ff, String filePath) {
693684
return new PersistedSymbolDict(ff, fd, HEADER_SIZE, 0, 0L, 0);
694685
}
695686

687+
/**
688+
* Verifies that {@code count} wire entries ({@code [len varint][utf8]}) occupy
689+
* exactly {@code len} bytes from {@code addr}. Returns {@code true} when the triple
690+
* is consistent; throws {@link IllegalStateException} (naming the offending entry /
691+
* count / consumed bytes) otherwise. Called only from an {@code assert} in
692+
* {@link #appendRawEntries}: it guards an internal invariant the sole caller cannot
693+
* violate today, so it runs under the test suite's {@code -ea} but is elided from
694+
* the per-flush production path (client apps run without {@code -ea}).
695+
*/
696+
private static boolean validateRawEntries(long addr, int len, int count) {
697+
long src = addr;
698+
long srcLimit = addr + len;
699+
for (int i = 0; i < count; i++) {
700+
long symLen = 0;
701+
int shift = 0;
702+
while (src < srcLimit) {
703+
byte b = Unsafe.getUnsafe().getByte(src++);
704+
symLen |= (long) (b & 0x7F) << shift;
705+
if ((b & 0x80) == 0) {
706+
break;
707+
}
708+
shift += 7;
709+
if (shift > 35) {
710+
// A canonical entry-length varint is <= 5 bytes; a longer
711+
// continuation run is corrupt. The bound check below rejects it.
712+
break;
713+
}
714+
}
715+
src += symLen; // src was just past the len varint
716+
if (src > srcLimit) {
717+
throw new IllegalStateException("malformed raw symbol-dict entries to " + FILE_NAME
718+
+ " [entry=" + i + ", count=" + count + ']');
719+
}
720+
}
721+
if (src != srcLimit) {
722+
throw new IllegalStateException("raw symbol-dict entries under-filled the buffer to "
723+
+ FILE_NAME + " [count=" + count + ", len=" + len
724+
+ ", consumed=" + (int) (src - addr) + ']');
725+
}
726+
return true;
727+
}
728+
696729
/**
697730
* Grows the append scratch buffer to hold at least {@code required} bytes.
698731
* <p>

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

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import io.questdb.client.test.tools.DelegatingFilesFacade;
3232
import io.questdb.client.test.tools.TestUtils;
3333
import org.junit.Assert;
34+
import org.junit.Assume;
3435
import org.junit.Test;
3536

3637
import java.nio.charset.StandardCharsets;
@@ -152,6 +153,56 @@ public void testAppendRawEntriesMatchesAppendSymbols() throws Exception {
152153
});
153154
}
154155

156+
@Test
157+
public void testAppendRawEntriesRejectsInconsistentTripleUnderAssertions() throws Exception {
158+
// appendRawEntries validates the (addr,len,count) triple before writing: an
159+
// inconsistent triple would record a chunk whose stored entryCount disagreed
160+
// with its entries and shift the dense id->symbol map on recovery, so it must
161+
// fail loudly rather than corrupt the file. That check is an assert -- gated on
162+
// -ea to keep the per-entry walk off the per-flush production path (client apps
163+
// run without -ea) -- so observe it under the test suite's -ea, and skip if
164+
// assertions happen to be disabled.
165+
boolean assertionsEnabled = false;
166+
assert assertionsEnabled = true;
167+
Assume.assumeTrue("the guard is assert-gated; only observable under -ea", assertionsEnabled);
168+
assertMemoryLeak(() -> {
169+
Path src = Files.createTempDirectory("qwp-symdict-src");
170+
Path dst = Files.createTempDirectory("qwp-symdict-dst");
171+
try {
172+
GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
173+
dict.getOrAddSymbol("AAPL"); // id 0
174+
dict.getOrAddSymbol("MSFT"); // id 1
175+
dict.getOrAddSymbol("GOOG"); // id 2
176+
PersistedSymbolDict encoded = PersistedSymbolDict.open(src.toString());
177+
encoded.appendSymbols(dict, 0, 2);
178+
encoded.close();
179+
180+
// Grab the on-disk entry region (3 entries) verbatim, then feed it back
181+
// with a count of 1: validateRawEntries stops one entry in with
182+
// src < srcLimit and throws "under-filled", before any bytes are written.
183+
PersistedSymbolDict reopened = PersistedSymbolDict.open(src.toString());
184+
try {
185+
PersistedSymbolDict raw = PersistedSymbolDict.open(dst.toString());
186+
try {
187+
raw.appendRawEntries(reopened.loadedEntriesAddr(), reopened.loadedEntriesLen(), 1);
188+
Assert.fail("an inconsistent (len,count) triple must be rejected");
189+
} catch (IllegalStateException expected) {
190+
Assert.assertTrue("message names the under-filled buffer: " + expected.getMessage(),
191+
expected.getMessage().contains("under-filled"));
192+
Assert.assertEquals("a rejected triple must write nothing", 0, raw.size());
193+
} finally {
194+
raw.close();
195+
}
196+
} finally {
197+
reopened.close();
198+
}
199+
} finally {
200+
rmDir(src);
201+
rmDir(dst);
202+
}
203+
});
204+
}
205+
155206
@Test
156207
public void testAppendRawEntriesShortWriteThrowsWithoutAdvancingIsIdempotentOnRetry() throws Exception {
157208
// The producer's FAST path persists a frame's already-encoded delta bytes
@@ -774,6 +825,60 @@ public void testTornTrailingEntrySelfHeals() throws Exception {
774825
});
775826
}
776827

828+
@Test
829+
public void testTransientLengthFaultDegradesWithoutDestroyingTheFile() throws Exception {
830+
// open() routes on ff.length(): a value < HEADER_SIZE falls through to the
831+
// truncating openFresh(). A genuine sub-header stub reports a length in
832+
// [0, HEADER_SIZE), but a TRANSIENT stat failure (an EIO on a flaky disk)
833+
// reports the -1 error sentinel for a fully populated file -- and routing that
834+
// to openFresh would O_TRUNC the only copy of load-bearing state, the exact
835+
// destruction the "Never recreate over an existing file" contract forbids. A
836+
// negative length is distinguishable from a stub, so open() must degrade to
837+
// null (fall back to full self-sufficient frames) and leave every byte on disk
838+
// for a later attempt, once the transient clears.
839+
assertMemoryLeak(() -> {
840+
Path dir = Files.createTempDirectory("qwp-symdict");
841+
try {
842+
PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
843+
d.appendSymbol("one");
844+
d.appendSymbol("two");
845+
d.close();
846+
847+
Path f = dir.resolve(".symbol-dict");
848+
byte[] before = Files.readAllBytes(f);
849+
Assert.assertTrue("a populated dict must exceed the header", before.length > HEADER_SIZE);
850+
851+
// Reopen through a facade whose length() reports the -1 stat-error
852+
// sentinel for the (present) file -- the branch under test.
853+
PersistedSymbolDict reopened = PersistedSymbolDict.open(new StatFailsLengthFacade(), dir.toString());
854+
if (reopened != null) {
855+
// Pre-fix: open() fell through to openFresh() and handed back a fresh
856+
// empty dict over the now-truncated file. Close its fd so only the
857+
// assertion below reports the failure, not a leaked descriptor.
858+
reopened.close();
859+
}
860+
Assert.assertNull("a populated dict whose length cannot be stat'd must degrade to null, not truncate",
861+
reopened);
862+
Assert.assertArrayEquals("a transient length-stat fault must NOT destroy the dictionary",
863+
before, Files.readAllBytes(f));
864+
865+
// Once the filesystem recovers, the SAME file reopens its intact content.
866+
PersistedSymbolDict re = PersistedSymbolDict.open(dir.toString());
867+
Assert.assertNotNull(re);
868+
try {
869+
Assert.assertEquals("the intact content survives the transient", 2, re.size());
870+
ObjList<String> s = re.readLoadedSymbols();
871+
Assert.assertEquals("one", s.getQuick(0));
872+
Assert.assertEquals("two", s.getQuick(1));
873+
} finally {
874+
re.close();
875+
}
876+
} finally {
877+
rmDir(dir);
878+
}
879+
});
880+
}
881+
777882
@Test
778883
public void testTruncateFailureDegradesWithoutDestroyingTheFile() throws Exception {
779884
// A host crash can leave a torn/stale tail past the last complete chunk.
@@ -894,4 +999,17 @@ public long write(int fd, long addr, long len, long offset) {
894999
return INSTANCE.write(fd, addr, len, offset);
8951000
}
8961001
}
1002+
1003+
/**
1004+
* Reports a length of -1 -- the stat-error sentinel -- for the dictionary file,
1005+
* reproducing a transient stat failure (an EIO on a flaky disk) where the file is
1006+
* present but its size cannot be read. open() must treat this as "present but
1007+
* unreadable" and degrade to null, NOT route it to the truncating fresh-open path.
1008+
*/
1009+
private static final class StatFailsLengthFacade extends DelegatingFilesFacade {
1010+
@Override
1011+
public long length(String path) {
1012+
return -1L;
1013+
}
1014+
}
8971015
}

0 commit comments

Comments
 (0)