Skip to content

Commit cf3152d

Browse files
bluestreak01claude
andcommitted
fix(ilp): fail fast on fatal SF errors instead of looping
Three correctness fixes from a follow-up review of the QWiP store-and- forward client. Each is paired with a regression test that fails on the unfixed code and passes after the change. Critical: - WebSocketSendQueue.retryStalled split its catch ladder so a fatal SfException during stall-retry (corruption, oversized frame, fsync EIO) is classified the same as the main-loop sendBatch catch: failConnection(_, true) terminal, not (_, false) reconnect. The old behaviour silently reconnected and recycled the buffer as if sent, hiding storage failures and risking infinite loops on persistent errors. (C1) - SegmentLog.createActive registers the freshly-opened fd into the Segment before calling allocNativePath, and the try block now wraps the path-allocation call. The catch closes the fd and best-effort removes the orphan .sfa file. The previous order leaked one fd per failed rotation under OOM pressure. (C2) - ResponseHandler.onBinaryMessage error branch now fails the connection fatally. A server-side per-batch error (parse, schema mismatch, write, security, internal) is a protocol-level rejection of specific bytes; reconnecting and re-sending the same payload produces the same error. Under SF the rejected frame sits on disk and replay-on-reconnect shipped it again, so the previous transient classification turned any poisoned frame into an unbounded reconnect loop. (C4) Infrastructure: - FilesFacade gains allocNativePath / freeNativePath. SegmentLog now routes all path-pointer alloc/free through the facade so tests can inject OOM at the exact moment between openCleanRW and the try block in createActive. Required for the C2 regression test. Tests: - testCreateActiveDoesNotLeakFdOnAllocNativePathOom (SegmentLogTest) - testRetryStalledTreatsSfStorageErrorAsTerminal (SfIntegrationTest) - testPoisonedFrameInSfDoesNotLoopForever (SfIntegrationTest) - Full suite: 1971 tests pass (was 1968), zero regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent efbd8e1 commit cf3152d

6 files changed

Lines changed: 629 additions & 17 deletions

File tree

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -970,8 +970,21 @@ private void retryStalled() {
970970
// wants to wake the I/O thread cooperatively.
971971
Thread.currentThread().interrupt();
972972
}
973+
} catch (SfException sfe) {
974+
// Fatal SF storage error during retry — same classification as
975+
// the main-loop sendBatch catch (corruption, oversized frame,
976+
// fsync EIO). Won't recover by reconnect; surface hard so the
977+
// user sees it instead of looping.
978+
LOG.error("Fatal SF storage error during retry [id={}]", batch.getBatchId(), sfe);
979+
failConnection(new LineSenderException(
980+
"SF storage error: " + sfe.getMessage(), sfe), true);
981+
if (batch.isSealed()) batch.markSending();
982+
if (batch.isSending()) batch.markRecycled();
983+
cleared = true;
973984
} catch (Throwable t) {
974-
// Non-disk-full failure during retry — recycle and surface.
985+
// Non-SF failure during retry (e.g. wire send error) — recycle
986+
// and surface as transient so SF auto-reconnect (when configured)
987+
// can absorb it.
975988
LOG.error("Error retrying stalled batch [id={}]", batch.getBatchId(), t);
976989
failConnection(new LineSenderException(
977990
"Error retrying stalled batch " + batch.getBatchId() + ": " + t.getMessage(), t), false);
@@ -1165,15 +1178,22 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) {
11651178
LOG.debug("Durable ACK received [tables={}]", response.getTableEntryCount());
11661179
}
11671180
} else {
1168-
// Error - fail the batch
1181+
// Server returned a per-batch error (parse, schema mismatch,
1182+
// write, security, internal). The bytes are the bytes —
1183+
// reconnecting and re-sending the same payload will produce
1184+
// the same error. Under SF, the rejected frame sits on disk
1185+
// and replay-on-reconnect would ship it again, so a
1186+
// transient classification turns into an unbounded
1187+
// reconnect loop. Treat as fatal so the user sees the
1188+
// failure instead of silent thrashing.
11691189
String errorMessage = response.getErrorMessage();
11701190
LOG.error("Error response [seq={}, status={}, error={}]", sequence, response.getStatusName(), errorMessage);
11711191

11721192
LineSenderException error = new LineSenderException(
11731193
"Server error for batch " + sequence + ": " +
11741194
response.getStatusName() + " - " + errorMessage);
11751195
totalErrors.incrementAndGet();
1176-
failConnection(error, false);
1196+
failConnection(error, true);
11771197
}
11781198
}
11791199

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

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ public void trim(long ackedSeq) {
275275
s.fd = -1;
276276
}
277277
ff.remove(s.pathPtrNative);
278-
Files.freeNativePath(s.pathPtrNative);
278+
ff.freeNativePath(s.pathPtrNative);
279279
s.pathPtrNative = 0;
280280
bytesOnDiskCache -= s.writePos;
281281
} else {
@@ -330,7 +330,7 @@ public void close() {
330330
s.fd = -1;
331331
}
332332
if (s.pathPtrNative != 0) {
333-
Files.freeNativePath(s.pathPtrNative);
333+
ff.freeNativePath(s.pathPtrNative);
334334
s.pathPtrNative = 0;
335335
}
336336
}
@@ -555,7 +555,7 @@ private void rotate() {
555555
if (old.frameCount == 0) {
556556
// empty segment shouldn't happen via rotate, but be defensive: drop it
557557
ff.remove(old.pathPtrNative);
558-
Files.freeNativePath(old.pathPtrNative);
558+
ff.freeNativePath(old.pathPtrNative);
559559
old.pathPtrNative = 0;
560560
bytesOnDiskCache -= old.writePos;
561561
segments.remove(segments.size() - 1);
@@ -567,9 +567,9 @@ private void rotate() {
567567
throw new SfException("failed to seal segment by rename " + old.path + " -> " + sealedPath);
568568
}
569569
// Path changed — free old native ptr and re-encode for the sealed name.
570-
Files.freeNativePath(old.pathPtrNative);
570+
ff.freeNativePath(old.pathPtrNative);
571571
old.path = sealedPath;
572-
old.pathPtrNative = Files.allocNativePath(sealedPath);
572+
old.pathPtrNative = ff.allocNativePath(sealedPath);
573573
old.sealed = true;
574574
old.lastSeqOnDisk = lastSeq;
575575
createActive(lastSeq + 1);
@@ -581,18 +581,19 @@ private void createActive(long baseSeq) {
581581
if (fd < 0) {
582582
throw new SfException("cannot create active segment: " + path);
583583
}
584+
// The fd and pathPtrNative are owned locally until segments.add(s)
585+
// below; close()'s cleanup loop only walks the segments list, so
586+
// anything that throws between the openCleanRW above and segments.add
587+
// must release them here or they leak. Note ff.allocNativePath can
588+
// throw CairoException on OOM — keep it inside the try.
584589
Segment s = new Segment();
585590
s.baseSeq = baseSeq;
586591
s.path = path;
587-
s.pathPtrNative = Files.allocNativePath(path);
588592
s.fd = fd;
589593
s.sealed = false;
590594
s.frameCount = 0;
591-
// The fd and pathPtrNative are owned locally until segments.add(s)
592-
// below; close()'s cleanup loop only walks the segments list, so
593-
// anything that throws between the openCleanRW above and segments.add
594-
// must release them here or they leak.
595595
try {
596+
s.pathPtrNative = ff.allocNativePath(path);
596597
writeHeader(s);
597598
s.writePos = HEADER_SIZE;
598599
if (ff.fsync(fd) != 0) {
@@ -601,8 +602,18 @@ private void createActive(long baseSeq) {
601602
} catch (Throwable t) {
602603
ff.close(fd);
603604
s.fd = -1;
604-
Files.freeNativePath(s.pathPtrNative);
605-
s.pathPtrNative = 0;
605+
if (s.pathPtrNative != 0) {
606+
ff.freeNativePath(s.pathPtrNative);
607+
s.pathPtrNative = 0;
608+
}
609+
// Best-effort cleanup of the orphan .sfa file. If this also
610+
// throws (e.g. another OOM during path encoding), let it
611+
// propagate — the original failure is already on the way out.
612+
try {
613+
ff.remove(path);
614+
} catch (Throwable ignored) {
615+
// best-effort
616+
}
606617
throw t;
607618
}
608619
segments.add(s);
@@ -698,7 +709,7 @@ private Segment parseFilename(String name) {
698709
Segment s = new Segment();
699710
s.baseSeq = Long.parseUnsignedLong(body, 16);
700711
s.path = dir + "/" + name;
701-
s.pathPtrNative = Files.allocNativePath(s.path);
712+
s.pathPtrNative = ff.allocNativePath(s.path);
702713
s.sealed = false;
703714
return s;
704715
}
@@ -712,7 +723,7 @@ private Segment parseFilename(String name) {
712723
s.baseSeq = Long.parseUnsignedLong(body.substring(0, 16), 16);
713724
s.lastSeqOnDisk = Long.parseUnsignedLong(body.substring(17), 16);
714725
s.path = dir + "/" + name;
715-
s.pathPtrNative = Files.allocNativePath(s.path);
726+
s.pathPtrNative = ff.allocNativePath(s.path);
716727
s.sealed = true;
717728
return s;
718729
}

core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
*/
3232
final class DefaultFilesFacade implements FilesFacade {
3333

34+
@Override
35+
public long allocNativePath(String path) {
36+
return Files.allocNativePath(path);
37+
}
38+
3439
@Override
3540
public int close(int fd) {
3641
return Files.close(fd);
@@ -66,6 +71,11 @@ public int findType(long findPtr) {
6671
return Files.findType(findPtr);
6772
}
6873

74+
@Override
75+
public void freeNativePath(long pathPtr) {
76+
Files.freeNativePath(pathPtr);
77+
}
78+
6979
@Override
7080
public int fsync(int fd) {
7181
return Files.fsync(fd);

core/src/main/java/io/questdb/client/std/FilesFacade.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@
3535
public interface FilesFacade {
3636
FilesFacade INSTANCE = new DefaultFilesFacade();
3737

38+
/**
39+
* Allocate a native UTF-8 path pointer. Test injection point: a wrapping
40+
* facade can throw to simulate OOM without depending on actual memory
41+
* pressure. Production callers must release the returned pointer via
42+
* {@link #freeNativePath(long)}. Default delegates to
43+
* {@link Files#allocNativePath(String)}.
44+
*/
45+
long allocNativePath(String path);
46+
3847
int close(int fd);
3948

4049
boolean exists(String path);
@@ -49,6 +58,12 @@ public interface FilesFacade {
4958

5059
int findType(long findPtr);
5160

61+
/**
62+
* Release a pointer returned by {@link #allocNativePath(String)}.
63+
* Default delegates to {@link Files#freeNativePath(long)}.
64+
*/
65+
void freeNativePath(long pathPtr);
66+
5267
int fsync(int fd);
5368

5469
long length(int fd);

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

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
package io.questdb.client.test.cutlass.qwp.client.sf;
2626

27+
import io.questdb.client.cairo.CairoException;
2728
import io.questdb.client.cutlass.qwp.client.sf.SegmentLog;
2829
import io.questdb.client.cutlass.qwp.client.sf.SfDiskFullException;
2930
import io.questdb.client.cutlass.qwp.client.sf.SfException;
@@ -564,6 +565,78 @@ public void testOldestSeqAfterTrim() throws Exception {
564565
* each opened fd was {@code close}d before the {@link SfException}
565566
* propagated out of {@code SegmentLog.open}.
566567
*/
568+
/**
569+
* Red test for the fd-leak gap between {@code openCleanRW} and the
570+
* {@code try} block in {@code SegmentLog.createActive}.
571+
* <p>
572+
* Production order at lines 580-595:
573+
* <pre>
574+
* int fd = ff.openCleanRW(path, 0); // fd opened
575+
* ...
576+
* s.pathPtrNative = ff.allocNativePath(path); // CAN throw OOM
577+
* s.fd = fd; // never reached on throw
578+
* try { ... } catch { ff.close(fd); ... } // try not entered
579+
* </pre>
580+
* If {@code allocNativePath} throws (the {@code Unsafe.malloc} inside
581+
* {@link io.questdb.client.std.Files#pathPtr(String)} wraps {@link OutOfMemoryError}
582+
* in {@link CairoException}), the local {@code fd} is leaked: {@code s} was
583+
* never added to {@code segments}, so {@code close()}'s cleanup loop never
584+
* sees it. The orphan {@code .sfa} file also remains on disk and trips the
585+
* "multiple active segments" guard on the next process restart that
586+
* legitimately rotates.
587+
* <p>
588+
* On a long-running spacecraft client under intermittent memory pressure,
589+
* each failed rotation leaks one fd; sustained loops will exhaust the
590+
* process fd table.
591+
* <p>
592+
* The fix: register {@code s.fd = fd} BEFORE the throwing call, and
593+
* extend the {@code try/catch} cleanup to cover the path allocation
594+
* (and {@code ff.remove(path)} the orphan file).
595+
*/
596+
@Test
597+
public void testCreateActiveDoesNotLeakFdOnAllocNativePathOom() throws Exception {
598+
TestUtils.assertMemoryLeak(() -> {
599+
FdTrackingFacade tracker = new FdTrackingFacade();
600+
tracker.failNextActiveAllocNativePath = true;
601+
try {
602+
SegmentLog.open(tmpDir, tracker, 4096, 4096, false);
603+
fail("expected open to fail because allocNativePath was forced to throw");
604+
} catch (Throwable expected) {
605+
String msg = expected.getMessage() == null ? "" : expected.getMessage();
606+
String causeMsg = expected.getCause() == null || expected.getCause().getMessage() == null
607+
? "" : expected.getCause().getMessage();
608+
assertTrue(
609+
"wrong failure surfaced: " + expected + " / cause=" + expected.getCause(),
610+
msg.contains("simulated") || msg.contains("OOM")
611+
|| causeMsg.contains("simulated") || causeMsg.contains("OOM"));
612+
}
613+
Set<Integer> leaked = new HashSet<>(tracker.opened);
614+
leaked.removeAll(tracker.closed);
615+
assertEquals(
616+
"createActive must close every fd it opened when allocNativePath throws "
617+
+ "between openCleanRW and the try-block; leaked=" + leaked,
618+
0, leaked.size());
619+
620+
// Also: no orphan .sfa file should remain on disk. The fix should
621+
// ff.remove the half-created file so the next open sees a clean dir.
622+
long find = Files.findFirst(tmpDir);
623+
if (find != 0) {
624+
try {
625+
int rc = 1;
626+
while (rc > 0) {
627+
String name = Files.utf8ToString(Files.findName(find));
628+
if (name != null && name.endsWith(".sfa")) {
629+
fail("orphan .sfa file remains after partial-init failure: " + name);
630+
}
631+
rc = Files.findNext(find);
632+
}
633+
} finally {
634+
Files.findClose(find);
635+
}
636+
}
637+
});
638+
}
639+
567640
@Test
568641
public void testCreateActiveDoesNotLeakFdOnFsyncFailure() throws Exception {
569642
TestUtils.assertMemoryLeak(() -> {
@@ -947,6 +1020,21 @@ private static class FdTrackingFacade implements FilesFacade {
9471020
// Set true to fault the NEXT fsync that targets a fd which was just
9481021
// opened (i.e., not yet closed). Auto-reset after firing once.
9491022
volatile boolean failNextFsyncOnNewFd;
1023+
// Set true to fault the NEXT allocNativePath whose path ends in
1024+
// ACTIVE_SUFFIX. Simulates an OOM at the exact moment between
1025+
// openCleanRW and the try-block in createActive. Auto-reset.
1026+
volatile boolean failNextActiveAllocNativePath;
1027+
1028+
@Override
1029+
public long allocNativePath(String path) {
1030+
// ".sfa" is SegmentLog.ACTIVE_SUFFIX (package-private, hardcoded here).
1031+
if (failNextActiveAllocNativePath && path.endsWith(".sfa")) {
1032+
failNextActiveAllocNativePath = false;
1033+
throw CairoException.nonCritical()
1034+
.put("simulated OOM in allocNativePath: ").put(path);
1035+
}
1036+
return FilesFacade.INSTANCE.allocNativePath(path);
1037+
}
9501038

9511039
@Override
9521040
public int close(int fd) {
@@ -987,6 +1075,11 @@ public int findType(long findPtr) {
9871075
return FilesFacade.INSTANCE.findType(findPtr);
9881076
}
9891077

1078+
@Override
1079+
public void freeNativePath(long pathPtr) {
1080+
FilesFacade.INSTANCE.freeNativePath(pathPtr);
1081+
}
1082+
9901083
@Override
9911084
public int fsync(int fd) {
9921085
if (failNextFsyncOnNewFd && opened.contains(fd) && !closed.contains(fd)) {
@@ -1069,6 +1162,11 @@ private static class FaultyLengthFacade implements FilesFacade {
10691162
int lengthFaultsTriggered;
10701163
private int lengthCalls;
10711164

1165+
@Override
1166+
public long allocNativePath(String path) {
1167+
return FilesFacade.INSTANCE.allocNativePath(path);
1168+
}
1169+
10721170
@Override
10731171
public int close(int fd) {
10741172
return FilesFacade.INSTANCE.close(fd);
@@ -1104,6 +1202,11 @@ public int findType(long findPtr) {
11041202
return FilesFacade.INSTANCE.findType(findPtr);
11051203
}
11061204

1205+
@Override
1206+
public void freeNativePath(long pathPtr) {
1207+
FilesFacade.INSTANCE.freeNativePath(pathPtr);
1208+
}
1209+
11071210
@Override
11081211
public int fsync(int fd) {
11091212
return FilesFacade.INSTANCE.fsync(fd);
@@ -1181,6 +1284,11 @@ private static class ShortPayloadWriteFacade implements FilesFacade {
11811284
// are larger. Use length to disambiguate without inspecting content.
11821285
volatile boolean failNextPayloadWrite;
11831286

1287+
@Override
1288+
public long allocNativePath(String path) {
1289+
return FilesFacade.INSTANCE.allocNativePath(path);
1290+
}
1291+
11841292
@Override
11851293
public int close(int fd) {
11861294
return FilesFacade.INSTANCE.close(fd);
@@ -1216,6 +1324,11 @@ public int findType(long findPtr) {
12161324
return FilesFacade.INSTANCE.findType(findPtr);
12171325
}
12181326

1327+
@Override
1328+
public void freeNativePath(long pathPtr) {
1329+
FilesFacade.INSTANCE.freeNativePath(pathPtr);
1330+
}
1331+
12191332
@Override
12201333
public int fsync(int fd) {
12211334
return FilesFacade.INSTANCE.fsync(fd);

0 commit comments

Comments
 (0)