Skip to content

Commit 521b48d

Browse files
glasstigerclaude
andcommitted
Close the untested gaps around the symbol-dict guards
Four behaviours the delta symbol-dictionary work relies on had no test that could fail if they regressed. Each mutation below leaves the whole suite green today. Pin the split path's write-ahead ordering. Moving the persist after the publish in flushPendingRowsSplit left every test passing, because the only split-path test checked the dictionary after a SUCCESSFUL flush -- where the symbols land either way. Only a publish that FAILS while the symbols are new can tell the orderings apart, so the new test caps the server batch size to force the split and shrinks the segment so the split frame cannot be published. With the ordering broken, a process crash in that window would leave a recorded frame whose deltaStart exceeds the recovered dictionary, and recovery would quarantine a slot that should have drained cleanly. Pin the persist-failure translation. A short write to .symbol-dict -- a full disk, an exhausted quota -- throws a low-level IllegalStateException that persistNewSymbolsBeforePublish wraps in a LineSenderException. Deleting the wrap left the suite green, yet the raw exception would sail past every caller's catch around flush(). Nothing could reach that path: PersistedSymbolDict has facade-aware overloads but CursorSendEngine called only the FilesFacade.INSTANCE ones, so the engine now takes a FilesFacade and the test drives a real short write through the real producer path. Pin the sealed-segment walk. Deleting the sealed half of SegmentRing.maxSymbolDeltaEnd left the suite green because every test fits its slot in one active segment. It is not a benign wrong answer: recoveredMaxSymbolId collapses to -1, and the seed guard fires on `>= pd.size()`, so a value that is too low never fires at all -- a torn dictionary is trusted and the producer silently reuses ids the surviving frames already define. The walk earns its keep exactly when the active segment holds nothing but an aborted deferred tail, which is the crash the guard was built for, so the test reproduces that shape. Hoist DelegatingFilesFacade into test/tools so a fault seam can be shared rather than stamped into each test that needs one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dd48d43 commit 521b48d

7 files changed

Lines changed: 424 additions & 142 deletions

File tree

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
2828
import io.questdb.client.std.Compat;
2929
import io.questdb.client.std.Files;
30+
import io.questdb.client.std.FilesFacade;
3031
import io.questdb.client.std.ObjList;
3132
import io.questdb.client.std.QuietCloseable;
33+
import org.jetbrains.annotations.TestOnly;
3234

3335
import java.util.concurrent.locks.LockSupport;
3436

@@ -154,9 +156,29 @@ public CursorSendEngine(String sfDir, long segmentSizeBytes) {
154156
*/
155157
public CursorSendEngine(String sfDir, long segmentSizeBytes,
156158
long maxTotalBytes, long appendDeadlineNanos) {
159+
this(sfDir, segmentSizeBytes, maxTotalBytes, appendDeadlineNanos, FilesFacade.INSTANCE);
160+
}
161+
162+
/**
163+
* As {@link #CursorSendEngine(String, long, long, long)}, but with an explicit
164+
* {@link FilesFacade} for the persisted symbol dictionary.
165+
* <p>
166+
* The seam exists so a test can drive a dictionary I/O fault -- a short write from
167+
* a full disk or an exhausted quota -- through the REAL producer path
168+
* ({@code flush()} -> the write-ahead persist), and assert it surfaces as a
169+
* {@code LineSenderException} like every other flush-path failure rather than as a
170+
* raw {@code IllegalStateException} that would sail past every caller's
171+
* {@code catch (LineSenderException)}. Nothing else could reach that translation:
172+
* {@code PersistedSymbolDict} has facade-aware overloads, but the engine used to
173+
* call only the {@code FilesFacade.INSTANCE} ones, so no fault could be injected
174+
* from outside.
175+
*/
176+
@TestOnly
177+
public CursorSendEngine(String sfDir, long segmentSizeBytes,
178+
long maxTotalBytes, long appendDeadlineNanos, FilesFacade dictFf) {
157179
this(sfDir, segmentSizeBytes,
158180
new SegmentManager(segmentSizeBytes, SegmentManager.DEFAULT_POLL_NANOS, maxTotalBytes),
159-
true, appendDeadlineNanos);
181+
true, appendDeadlineNanos, dictFf);
160182
}
161183

162184
/**
@@ -165,11 +187,12 @@ public CursorSendEngine(String sfDir, long segmentSizeBytes,
165187
* ownership of the manager. Uses the default append deadline.
166188
*/
167189
public CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager manager) {
168-
this(sfDir, segmentSizeBytes, manager, false, DEFAULT_APPEND_DEADLINE_NANOS);
190+
this(sfDir, segmentSizeBytes, manager, false, DEFAULT_APPEND_DEADLINE_NANOS,
191+
FilesFacade.INSTANCE);
169192
}
170193

171194
private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager manager,
172-
boolean ownsManager, long appendDeadlineNanos) {
195+
boolean ownsManager, long appendDeadlineNanos, FilesFacade dictFf) {
173196
// sfDir == null → memory-only mode (non-SF async ingest). Same
174197
// cursor architecture, no disk involvement; segments
175198
// live in malloc'd native memory.
@@ -278,7 +301,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
278301
// Load the persisted symbol dictionary so delta-encoded frames
279302
// in this recovered slot can be re-registered on the fresh
280303
// server before replay. Null on open failure -> delta disabled.
281-
persistedDictInProgress = PersistedSymbolDict.open(sfDir);
304+
persistedDictInProgress = PersistedSymbolDict.open(dictFf, sfDir);
282305
long baseSeed = lowestBase - 1;
283306
long watermarkFsn = watermarkInProgress != null
284307
? watermarkInProgress.read()
@@ -389,7 +412,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
389412
// Windows share lock); if the clean open itself fails,
390413
// persistedSymbolDict stays null and the sender falls back to full
391414
// self-sufficient frames, which is also safe.
392-
persistedDictInProgress = PersistedSymbolDict.openClean(sfDir);
415+
persistedDictInProgress = PersistedSymbolDict.openClean(dictFf, sfDir);
393416
}
394417
MmapSegment initial;
395418
String initialPath = null;

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
@@ -26,6 +26,9 @@
2626

2727
import io.questdb.client.Sender;
2828
import io.questdb.client.cutlass.line.LineSenderException;
29+
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
30+
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
31+
import io.questdb.client.test.tools.DelegatingFilesFacade;
2932
import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
3033
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
3134
import io.questdb.client.test.tools.TestUtils;
@@ -580,6 +583,56 @@ public void testCommitMessageDoesNotShipUnpersistedLeakedSymbol() throws Excepti
580583
});
581584
}
582585

586+
@Test
587+
public void testPersistFailureSurfacesAsLineSenderException() throws Exception {
588+
// A dictionary write that fails mid-flush -- a full disk, an exhausted quota --
589+
// must reach the caller as a LineSenderException, like every other flush-path
590+
// failure. PersistedSymbolDict throws a low-level IllegalStateException on a short
591+
// write; persistNewSymbolsBeforePublish wraps it. Without the wrap the raw
592+
// IllegalStateException sails straight past every user's
593+
// `catch (LineSenderException)` around flush() and takes the application down.
594+
//
595+
// Nothing could reach that translation before: PersistedSymbolDict has
596+
// facade-aware overloads, but CursorSendEngine called only the
597+
// FilesFacade.INSTANCE ones, so no test could inject a dictionary I/O fault
598+
// through the real producer path. The engine now takes a FilesFacade for exactly
599+
// this, and the sender is built on that engine directly -- the same
600+
// QwpWebSocketSender.connect(..., CursorSendEngine) entry point Sender.build uses.
601+
assertMemoryLeak(() -> {
602+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
603+
int port = silent.getPort();
604+
silent.start();
605+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
606+
607+
String slot = Paths.get(sfDir, "default").toString();
608+
Assert.assertEquals(0, io.questdb.client.std.Files.mkdir(sfDir,
609+
io.questdb.client.std.Files.DIR_MODE_DEFAULT));
610+
ShortDictWriteFacade ff = new ShortDictWriteFacade();
611+
// The engine owns the dictionary; the fault facade reaches only it, so the
612+
// segment files still write normally and the ONLY failure is the persist.
613+
CursorSendEngine engine = new CursorSendEngine(
614+
slot, 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
615+
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff);
616+
try (Sender sender = QwpWebSocketSender.connect(
617+
"localhost", port, null, 0, 0, 0L, null, false, engine)) {
618+
ff.armed = true; // the next dictionary append short-writes
619+
sender.table("m").symbol("s", "boom").longColumn("v", 1L).atNow();
620+
try {
621+
sender.flush();
622+
Assert.fail("a short write to the symbol dictionary must fail the flush");
623+
} catch (LineSenderException expected) {
624+
Assert.assertTrue("the persist failure must be reported as a sender error, "
625+
+ "not leak a raw IllegalStateException: " + expected.getMessage(),
626+
expected.getMessage().contains("failed to persist symbol dictionary before publish"));
627+
}
628+
} catch (LineSenderException ignored) {
629+
// close() re-flushes the still-buffered row; the facade has disarmed, so
630+
// this normally succeeds. Either way it is not what we assert.
631+
}
632+
}
633+
});
634+
}
635+
583636
@Test
584637
public void testFailedPublishDoesNotDuplicatePersistedSymbols() throws Exception {
585638
// Regression: persistNewSymbolsBeforePublish is a write-ahead -- it runs
@@ -1256,4 +1309,22 @@ private static byte[] buildAck(long seq) {
12561309
return buf;
12571310
}
12581311
}
1312+
/**
1313+
* Short-writes the FIRST dictionary append after it is armed, modelling a disk that
1314+
* fills mid-flush. Offset 0 is left alone so the file header still writes -- only the
1315+
* entry append fails, which is the path under test.
1316+
*/
1317+
private static final class ShortDictWriteFacade extends DelegatingFilesFacade {
1318+
boolean armed;
1319+
1320+
@Override
1321+
public long write(int fd, long addr, long len, long offset) {
1322+
if (armed && offset > 0 && len > 1) {
1323+
armed = false;
1324+
return INSTANCE.write(fd, addr, len - 1, offset);
1325+
}
1326+
return INSTANCE.write(fd, addr, len, offset);
1327+
}
1328+
}
1329+
12591330
}

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626

2727
import io.questdb.client.Sender;
2828
import io.questdb.client.cutlass.line.LineSenderException;
29+
import io.questdb.client.std.ObjList;
30+
import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
2931
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
3032
import io.questdb.client.test.tools.TestUtils;
3133
import org.junit.Assert;
@@ -332,6 +334,89 @@ public void testOversizedTableSplitStrandsNothing() throws Exception {
332334
});
333335
}
334336

337+
@Test
338+
public void testFileModeSplitPersistsDictBeforeAFAILEDPublish() throws Exception {
339+
// The SPLIT path's write-ahead ordering: persistNewSymbolsBeforePublish must run
340+
// BEFORE sealAndSwapBuffer publishes the frame to the ring, exactly as it does on
341+
// the non-split path.
342+
//
343+
// Its sibling below (testFileModeSplitPersistsDictBeforePublish) checks the dict
344+
// after a SUCCESSFUL flush, which proves nothing about ORDER -- the symbols land
345+
// in the file either way. Only a publish that FAILS while the symbols are new can
346+
// tell the two apart: with the write-ahead intact the symbols are already durable
347+
// when the publish throws; with the persist moved after the publish, the throw
348+
// beats it and the dictionary is still empty. (The non-split path is pinned this
349+
// way by DeltaDictRecoveryTest.testFailedPublishDoesNotDuplicatePersistedSymbols;
350+
// the split path had no equivalent.)
351+
//
352+
// Why it matters: store-and-forward is process-crash durable. If a split frame
353+
// reaches the ring before its symbols reach .symbol-dict, a JVM crash in that
354+
// window leaves a recorded frame whose deltaStart exceeds the recovered
355+
// dictionary -- so recovery declares the slot unreplayable and quarantines a slot
356+
// that should have drained cleanly.
357+
//
358+
// Setup: the server cap (150) splits the two-table batch, and each split frame
359+
// still FITS that cap, so the split pre-flight passes and the publish is actually
360+
// attempted. sf_max_bytes=64 then makes every frame larger than the segment, so
361+
// appendBlocking fails with PAYLOAD_TOO_LARGE -- deterministically, no
362+
// backpressure timing needed.
363+
Path sfDir = Files.createTempDirectory("qwp-sf-split-persist-fail");
364+
try {
365+
assertMemoryLeak(() -> {
366+
CapturingHandler handler = new CapturingHandler();
367+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
368+
server.setAdvertisedMaxBatchSize(150); // forces the two-table batch to split
369+
int port = server.getPort();
370+
server.start();
371+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
372+
373+
String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
374+
+ ";sf_max_bytes=64;";
375+
String pad = TestUtils.repeat("x", 60);
376+
Sender sender = Sender.fromConfig(config);
377+
try {
378+
sender.table("t1").symbol("s", "alpha").stringColumn("p", pad).longColumn("v", 1L).atNow();
379+
sender.table("t2").symbol("s", "bravo").stringColumn("p", pad).longColumn("v", 2L).atNow();
380+
try {
381+
sender.flush();
382+
Assert.fail("a split frame larger than the segment must fail to publish");
383+
} catch (LineSenderException expected) {
384+
// PAYLOAD_TOO_LARGE -- the publish, not the pre-flight.
385+
}
386+
Assert.assertEquals("the publish must fail, so no frame reaches the server",
387+
0, handler.batches.size());
388+
389+
// The write-ahead already ran: both of the batch's new symbols are
390+
// durable even though the frame that references them never
391+
// published. Move the persist after sealAndSwapBuffer and this is 0.
392+
PersistedSymbolDict pd = PersistedSymbolDict.open(
393+
sfDir.resolve("default").toString());
394+
Assert.assertNotNull(pd);
395+
try {
396+
Assert.assertEquals("the split path must persist its new symbols BEFORE "
397+
+ "publishing the frame that references them", 2, pd.size());
398+
ObjList<String> symbols = pd.readLoadedSymbols();
399+
Assert.assertEquals("alpha", symbols.getQuick(0));
400+
Assert.assertEquals("bravo", symbols.getQuick(1));
401+
} finally {
402+
pd.close();
403+
}
404+
} finally {
405+
try {
406+
sender.close();
407+
} catch (LineSenderException ignored) {
408+
// close() re-flushes the still-buffered oversized rows and fails
409+
// again; expected, and not what this test asserts. close() still
410+
// runs its resource cleanup, so no native memory leaks.
411+
}
412+
}
413+
}
414+
});
415+
} finally {
416+
rmDir(sfDir);
417+
}
418+
}
419+
335420
@Test
336421
public void testFileModeSplitPersistsDictBeforePublish() throws Exception {
337422
// File-mode store-and-forward + a SPLIT flush: a two-table batch whose combined

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager;
3232
import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
3333
import io.questdb.client.std.Files;
34+
import io.questdb.client.std.FilesFacade;
3435
import io.questdb.client.std.MemoryTag;
3536
import io.questdb.client.std.ObjList;
3637
import io.questdb.client.std.Unsafe;
@@ -608,11 +609,12 @@ private static void assertSlotCanBeReacquired(String sfDir) {
608609
private static Throwable invokeOwnedPrivateConstructorExpectingFailure(
609610
String sfDir, long segmentSizeBytes, SegmentManager manager) throws Exception {
610611
Constructor<CursorSendEngine> ctor = CursorSendEngine.class.getDeclaredConstructor(
611-
String.class, long.class, SegmentManager.class, boolean.class, long.class);
612+
String.class, long.class, SegmentManager.class, boolean.class, long.class,
613+
FilesFacade.class);
612614
ctor.setAccessible(true);
613615
try {
614616
ctor.newInstance(sfDir, segmentSizeBytes, manager, true,
615-
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS);
617+
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, FilesFacade.INSTANCE);
616618
fail("expected constructor failure");
617619
return null;
618620
} catch (InvocationTargetException e) {

0 commit comments

Comments
 (0)