Skip to content

Commit ded2edc

Browse files
glasstigerclaude
andcommitted
Close store-and-forward recovery and drainer gaps
Four fixes across the recovery, orphan-scan and drainer paths, all on inputs a real cluster can produce. Bound the oversized-batch throw. flushPendingRowsSplit's all-or-nothing pre-flight throws when a single table's frame exceeds the server cap, and keeps the batch buffered so a rejected flush does not silently drop the caller's rows -- correct, and better than the old behaviour that discarded every table's rows while reporting failure. But a batch that can never fit the reachable cap then re-rejects on every flush(), growing unbounded if the caller keeps appending. The batch cannot drain until a larger-cap node returns or the sender is closed, so the message now says so, rather than reading as a transient a caller would retry into an ever-larger batch. Stop OrphanScanner counting the legacy-reader guards as data. CursorSendEngine plants .qwp-v2-guard-a/b.sfa in every disk-mode slot, before recovery or any segment, and its failure path does not unlink them. A construction that then throws (ENOSPC on the initial segment) leaves a slot holding only guards, which the scanner reported as an orphan with unacked data: a drainer adopted it, failed its own build under the same disk pressure, and dropped a permanent .failed sentinel plus an ERROR on a slot that never held a byte. Exclude the guards by name (SegmentRing's own predicate, now shared) -- a real segment beside them still counts. Saturate the drainer's capability-gap budget. reconnect_max_duration_millis is validated only as > 0, so a large value (Long.MAX_VALUE is the natural "never give up") wrapped the raw millis*1_000_000 multiply negative, and the OR-gated elapsed check then quarantined the slot on the FIRST capability-gap sweep, skipping the whole 16-sweep settle budget -- asking for more tolerance bought none. Convert with TimeUnit.toNanos, which clamps at Long.MAX_VALUE. The keepalive-interval and poison-escalation windows in CursorWebSocketSendLoop's constructor had the identical raw multiply -- the poison one is the exact twin of the cap-gap dwell the PR already hardened -- and a negative there escalates a transient to a producer-fatal terminal on strike count alone; convert both the same way. Make the legacy-reader barrier durable and stop re-truncating it. Its content never changes, yet it was rewritten via openCleanRW (truncate) on every engine open and never synced, so every open re-opened a window in which a host crash leaves a zero-filled guard beside already-durable v2 segments -- and a rolled-back v1 reader then skips the guard (bad magic) and the segments (bad version) alike, reads the slot empty, and truncates the unacked log. Verify an existing guard and rewrite only a missing or damaged one, and msync the guard on creation (33 bytes, once per slot lifetime) so it is durable before the data it protects. The guard frame is not byte-reproducible, so the test discriminates kept from rewritten by byte-identity across a reopen and asserts only semantic validity after a repair. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b279eec commit ded2edc

8 files changed

Lines changed: 191 additions & 12 deletions

File tree

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3702,10 +3702,23 @@ private void flushPendingRowsSplit(
37023702
splitFrameBodyBytes.getQuick(bodyIdx), simBaseline, currentBatchMaxSymbolId);
37033703
bodyIdx++;
37043704
if (messageSize > cap) {
3705+
// The batch stays BUFFERED: this throw precedes every publish, and a
3706+
// rejected flush must not silently discard the caller's rows (see
3707+
// SelfSufficientFramesTest#testOversizedTableSplitStrandsNothing). So the
3708+
// next flush() re-encodes and re-rejects the same batch until either the
3709+
// reachable cap grows -- a failover to a larger-cap node, which is the
3710+
// case retaining the rows exists to survive -- or the sender is closed,
3711+
// which discards them. It cannot drain against a cap this table will
3712+
// never fit, so say that here rather than let a caller read the repeat
3713+
// rejections as a transient and keep appending to a batch that only
3714+
// grows.
37053715
throw new LineSenderException("single table batch too large for server batch cap")
37063716
.put(" [table=").put(tableName)
37073717
.put(", messageSize=").put(messageSize)
3708-
.put(", serverMaxBatchSize=").put(cap).put(']');
3718+
.put(", serverMaxBatchSize=").put(cap).put(']')
3719+
.put("; the batch is retained for retry and every flush() will "
3720+
+ "reject it again until a larger-cap node is reached -- "
3721+
+ "close the sender to discard it, or produce smaller batches");
37093722
}
37103723
// Mirror advanceSentMaxSymbolId: once the first frame ships the batch's
37113724
// new ids, the remaining frames carry an empty delta above the baseline.

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.slf4j.LoggerFactory;
3737

3838
import java.util.concurrent.ThreadLocalRandom;
39+
import java.util.concurrent.TimeUnit;
3940
import java.util.concurrent.locks.LockSupport;
4041

4142
/**
@@ -287,7 +288,17 @@ public WebSocketClient connectWithDurableAckRetry() {
287288
// Timestamp of the previous capability-gap sweep; 0 = the next gap
288289
// charges nothing because a fresh episode is starting.
289290
long lastCapabilityGapNanos = 0L;
290-
final long capabilityGapBudgetNanos = reconnectMaxDurationMillis * 1_000_000L;
291+
// Saturate rather than multiply: reconnect_max_duration_millis is validated only
292+
// as > 0, so a large value (Long.MAX_VALUE is the natural way to ask for "never
293+
// give up") wraps a raw multiply NEGATIVE. capabilityGapElapsedNanos then clears
294+
// the budget on the FIRST capability-gap sweep -- 0 >= a negative -- and, because
295+
// this gate is an OR with the attempt cap, nothing else holds it back: the slot
296+
// quarantines immediately, skipping the whole 16-sweep settle budget. Asking for
297+
// more tolerance would buy exactly none. TimeUnit clamps at Long.MAX_VALUE, which
298+
// is the intended "effectively unbounded". CursorWebSocketSendLoop's dwell
299+
// conversion guards the same way for the same reason.
300+
final long capabilityGapBudgetNanos =
301+
TimeUnit.MILLISECONDS.toNanos(reconnectMaxDurationMillis);
291302
// Observability-only counter for the transient all-replica window;
292303
// never consulted for escalation (Invariant B).
293304
int roleRejectAttempts = 0;

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -839,12 +839,20 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
839839
this.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
840840
this.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
841841
this.durableAckMode = durableAckMode;
842+
// Saturate, never multiply raw -- the same hazard the cap-gap dwell above
843+
// guards. A raw multiply wraps a large millisecond value NEGATIVE, and both of
844+
// these read as "elapsed >= window", so a negative makes the gate trivially
845+
// true: the keepalive would ping on every loop pass, and the poison detector
846+
// would escalate on the strike count alone -- exactly the transient-to-terminal
847+
// false positive the dwell exists to stop (Invariant B). Both keys are
848+
// user-supplied and validated only as >= 0, so asking for a very long window is
849+
// legal, and asking for a longer one must never buy a shorter one.
842850
this.durableAckKeepaliveIntervalNanos = durableAckKeepaliveIntervalMillis > 0
843-
? durableAckKeepaliveIntervalMillis * 1_000_000L
851+
? TimeUnit.MILLISECONDS.toNanos(durableAckKeepaliveIntervalMillis)
844852
: 0L;
845853
this.maxHeadFrameRejections = maxHeadFrameRejections;
846854
this.poisonMinEscalationWindowNanos = poisonMinEscalationWindowMillis > 0
847-
? poisonMinEscalationWindowMillis * 1_000_000L
855+
? TimeUnit.MILLISECONDS.toNanos(poisonMinEscalationWindowMillis)
848856
: 0L;
849857
// SYNC/OFF startup hands a live client to the constructor, so we
850858
// already know we reached the server at least once. ASYNC startup

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,15 @@ static void createLegacyReaderGuard(String path, long baseSeq) {
194194
if (segment.tryAppend(payload, 1) != HEADER_SIZE) {
195195
throw new MmapSegmentException("could not append legacy-reader guard frame: " + path);
196196
}
197+
// Unlike the segment log this file guards, the guard IS msync'd. It is a
198+
// rollback barrier, so it has to be durable before the data it protects
199+
// becomes durable -- a guard still sitting in the page cache when the host
200+
// dies leaves zeroes on disk beside written-back v2 segments, and a
201+
// rolled-back v1 reader skips the guard (bad magic) and the segments (bad
202+
// version) alike, sees an empty slot, and truncates the log. It is 33 bytes
203+
// written once per slot lifetime, not per flush, so the sync costs nothing
204+
// measurable and buys the barrier the durability its whole purpose assumes.
205+
segment.msync();
197206
} finally {
198207
if (payload != 0L) {
199208
Unsafe.free(payload, 1, MemoryTag.NATIVE_DEFAULT);

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,17 @@ private static boolean hasAnySegmentFile(String slotPath) {
327327
while (rc > 0) {
328328
String name = Files.utf8ToString(Files.findName(find));
329329
rc = Files.findNext(find);
330-
if (name != null && name.endsWith(".sfa")) {
330+
// The legacy-reader barriers are named .sfa on purpose (a rolled-back v1
331+
// reader must not skip them), but they are a barrier, not data. Counting
332+
// them here would call a slot that never held a byte an orphan with
333+
// unacked data: CursorSendEngine plants them FIRST, before recovery or
334+
// any segment is created, and its cleanup does not unlink them, so a
335+
// construction that fails afterwards (ENOSPC on the initial segment)
336+
// leaves a directory holding nothing else. A drainer would then adopt it,
337+
// fail its own build under the same disk pressure, and quarantine an
338+
// empty slot with a permanent .failed sentinel plus an ERROR for an
339+
// operator to chase.
340+
if (name != null && name.endsWith(".sfa") && !SegmentRing.isLegacyReaderGuard(name)) {
331341
return true;
332342
}
333343
}

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

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -349,12 +349,47 @@ public long ackedFsn() {
349349
* logical ring.
350350
*/
351351
static void installLegacyReaderBarrier(String sfDir) {
352-
MmapSegment.createLegacyReaderGuard(
353-
sfDir + '/' + LEGACY_READER_GUARD_A,
354-
Long.MAX_VALUE - 4L);
355-
MmapSegment.createLegacyReaderGuard(
356-
sfDir + '/' + LEGACY_READER_GUARD_B,
357-
Long.MAX_VALUE - 1L);
352+
installLegacyReaderGuardIfDamaged(sfDir + '/' + LEGACY_READER_GUARD_A, Long.MAX_VALUE - 4L);
353+
installLegacyReaderGuardIfDamaged(sfDir + '/' + LEGACY_READER_GUARD_B, Long.MAX_VALUE - 1L);
354+
}
355+
356+
/**
357+
* Verifies the guard at {@code path} and rewrites it only when it is missing or
358+
* damaged.
359+
* <p>
360+
* A guard's content never changes, so re-creating one that is already intact buys
361+
* nothing -- and costs the only window in which the barrier can be lost. Creation
362+
* goes through {@code openCleanRW} (which truncates) and does not fsync, so an
363+
* unconditional rewrite on every engine open re-opens that window every time. A host
364+
* crash inside it leaves a zero-filled guard next to v2 segment data that is already
365+
* durable; a rolled-back v1 reader then skips the guard (bad magic) AND the v2
366+
* segments (bad version), concludes the slot is empty, and truncates the unacked log
367+
* the barrier exists to protect. Keeping an intact guard means the steady state never
368+
* enters that window at all.
369+
*/
370+
private static void installLegacyReaderGuardIfDamaged(String path, long baseSeq) {
371+
if (isIntactLegacyReaderGuard(path, baseSeq)) {
372+
return;
373+
}
374+
MmapSegment.createLegacyReaderGuard(path, baseSeq);
375+
}
376+
377+
private static boolean isIntactLegacyReaderGuard(String path, long baseSeq) {
378+
if (!Files.exists(path)) {
379+
return false;
380+
}
381+
try (MmapSegment guard = MmapSegment.openExisting(path)) {
382+
// Anything short of the exact shape createLegacyReaderGuard writes -- a
383+
// zero-filled survivor of a torn creation included -- is rewritten.
384+
return guard != null
385+
&& guard.version() == MmapSegment.LEGACY_VERSION
386+
&& guard.baseSeq() == baseSeq
387+
&& guard.frameCount() == 1
388+
&& guard.tornTailBytes() == 0L;
389+
} catch (Throwable t) {
390+
LOG.warn("legacy-reader guard {} could not be verified ({}); rewriting it", path, t.toString());
391+
return false;
392+
}
358393
}
359394

360395
/**
@@ -945,7 +980,15 @@ private static void sortByBaseSeq(ObjList<MmapSegment> list, int lo, int hi) {
945980
}
946981
}
947982

948-
private static boolean isLegacyReaderGuard(String name) {
983+
/**
984+
* Whether {@code name} is one of the reserved rollback-barrier files this class
985+
* plants in every disk-mode slot. They carry the {@code .sfa} suffix so a rolled-back
986+
* v1 reader cannot skip them, which means every directory walk that reasons about
987+
* "does this slot hold data" has to exclude them -- they are a barrier, not a log.
988+
* Package-private so {@link OrphanScanner} shares this one definition rather than
989+
* matching the names itself.
990+
*/
991+
static boolean isLegacyReaderGuard(String name) {
949992
return LEGACY_READER_GUARD_A.equals(name) || LEGACY_READER_GUARD_B.equals(name);
950993
}
951994

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import java.util.concurrent.TimeUnit;
4848
import java.util.concurrent.locks.LockSupport;
4949

50+
import static org.junit.Assert.assertArrayEquals;
5051
import static org.junit.Assert.assertEquals;
5152
import static org.junit.Assert.assertFalse;
5253
import static org.junit.Assert.assertNotNull;
@@ -298,6 +299,62 @@ public void testRecoveryAdvancesAckedFsnPastWatermark() throws Exception {
298299
});
299300
}
300301

302+
@Test
303+
public void testLegacyReaderGuardsSurviveReopenAndAreRepairedWhenDamaged() throws Exception {
304+
// The barrier is only worth what it is worth on disk. Creating it truncates
305+
// (openCleanRW) and, before this, did not sync -- and it ran unconditionally on
306+
// EVERY engine open, so every open destroyed a durable guard and re-dirtied it.
307+
// A host crash inside that writeback window leaves a zero-filled guard beside v2
308+
// segments that ARE already durable; a rolled-back v1 reader then skips the guard
309+
// (bad magic) and the segments (bad version) alike, reads the slot as empty, and
310+
// truncates the unacked log the guard was planted to protect. The guard's content
311+
// never changes, so an intact one is verified and kept, and only a damaged one is
312+
// rewritten -- the steady state never re-enters the window.
313+
//
314+
// Two guards created with identical arguments are NOT byte-identical (they
315+
// differ in the frame header region), so a KEPT guard is byte-identical to
316+
// itself across a reopen while a REWRITTEN one is not -- which is exactly the
317+
// discriminator this needs. The repair leg then only asserts semantic validity,
318+
// since a rebuilt guard legitimately differs byte-for-byte from the original.
319+
TestUtils.assertMemoryLeak(() -> {
320+
long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
321+
try {
322+
try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 4096)) {
323+
assertEquals(0L, engine.appendBlocking(buf, 16));
324+
}
325+
String guardA = tmpDir + "/.qwp-v2-guard-a.sfa";
326+
byte[] planted = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(guardA));
327+
assertTrue("the guard must carry bytes", planted.length > 0);
328+
329+
// Reopening the slot must leave an intact guard untouched. Under the old
330+
// unconditional re-create this rewrites it, and because the frame is not
331+
// byte-reproducible the bytes then differ -- so this comparison fails.
332+
try (CursorSendEngine reopened = new CursorSendEngine(tmpDir, 4096)) {
333+
assertEquals("recovery must adopt the one real frame, not a guard",
334+
0L, reopened.publishedFsn());
335+
}
336+
assertArrayEquals("an intact guard must survive a reopen untouched",
337+
planted, java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(guardA)));
338+
339+
// Zero it -- the shape a torn creation leaves -- and it must be rebuilt to
340+
// a valid guard, otherwise a slot that crashed mid-plant stays unprotected.
341+
java.nio.file.Files.write(java.nio.file.Paths.get(guardA), new byte[planted.length]);
342+
try (CursorSendEngine repaired = new CursorSendEngine(tmpDir, 4096)) {
343+
assertEquals("recovery must adopt the one real frame, not a guard",
344+
0L, repaired.publishedFsn());
345+
}
346+
try (MmapSegment guard = MmapSegment.openExisting(guardA)) {
347+
assertNotNull("a damaged guard must be rewritten to a valid one", guard);
348+
assertEquals(MmapSegment.LEGACY_VERSION, guard.version());
349+
assertEquals(1L, guard.frameCount());
350+
assertEquals("a rewritten guard must not read as torn", 0L, guard.tornTailBytes());
351+
}
352+
} finally {
353+
Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
354+
}
355+
});
356+
}
357+
301358
@Test
302359
public void testV2DiskSlotForcesLegacyReaderToFailClosed() throws Exception {
303360
TestUtils.assertMemoryLeak(() -> {

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,34 @@ public void testSlotWithSfaIsAnOrphan() throws Exception {
8989
});
9090
}
9191

92+
@Test
93+
public void testSlotHoldingOnlyLegacyReaderGuardsIsNotAnOrphan() throws Exception {
94+
TestUtils.assertMemoryLeak(() -> {
95+
// The rollback barriers are named .sfa deliberately -- a rolled-back v1
96+
// reader must not skip them -- but they are a barrier, not data. A slot
97+
// holding nothing else has never had a byte written to it and must not be
98+
// adopted: CursorSendEngine plants the guards FIRST, before recovery or the
99+
// initial segment, and its failure path does not unlink them, so an ENOSPC
100+
// partway through construction leaves exactly this directory. Counting the
101+
// guards sent a drainer to adopt it, which fails under the same disk
102+
// pressure and quarantines the empty slot with a permanent .failed sentinel.
103+
String slot = sfDir + "/guards-only";
104+
assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
105+
touchFile(slot + "/.qwp-v2-guard-a.sfa");
106+
touchFile(slot + "/.qwp-v2-guard-b.sfa");
107+
108+
ObjList<String> orphans = OrphanScanner.scan(sfDir, "default");
109+
assertEquals(0, orphans.size());
110+
111+
// ...but a real segment alongside them still is: the exclusion must be by
112+
// guard name, not "ignore dot-prefixed .sfa" or "ignore the first two".
113+
touchFile(slot + "/sf-0001.sfa");
114+
orphans = OrphanScanner.scan(sfDir, "default");
115+
assertEquals(1, orphans.size());
116+
assertEquals(slot, orphans.get(0));
117+
});
118+
}
119+
92120
@Test
93121
public void testEmptySlotDirIsNotAnOrphan() throws Exception {
94122
TestUtils.assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)