Skip to content

Commit 07c20e0

Browse files
bluestreak01claude
andcommitted
fix(ilp): five SF correctness fixes from PR-17 review
Each fix is paired with a red-then-green regression test. C1 — SegmentLog.rotate() partial failure deadlock When rotate's allocNativePath OOMs or createActive fails after the rename succeeds, `active` is left pointing at a sealed segment with fd=-1. A subsequent small append that fits under the cap bypasses the rotate trigger and falls through to ff.write(fd=-1, ...) which returns -1 and is wrapped as the recoverable SfDiskFullException. The I/O thread retries forever (disk-full backpressure path) and the user thread blocks in flush() — silent deadlock. Guard added at the top of append() that throws a fatal SfException when active is in the post-rotate sealed/fd=-1 state. C2 — Symbol-delta watermark not reset on reconnect resetSchemaStateForNewConnection cleared maxSentSchemaId and per-table schema ids but left maxSentSymbolId and currentBatchMaxSymbolId untouched. The encoder's first post-reconnect batch then shipped a delta dictionary that excluded every symbol id <= the old server's high-water mark; column refs into the new server's empty dictionary decoded as garbage (or were rejected). Both watermarks are now reset alongside the schema state. C3 — trim() forgets sealed segments when remove() fails trimSealedSegments discarded the boolean return of ff.remove(). On Windows sharing-violation under antivirus, transient NFS errors, ESTALE, etc., the file stayed on disk while bytesOnDiskCache was decremented and the segment was dropped from the in-memory list. Failure modes: (a) bytesOnDisk underreports reality, so sf_max_total_bytes stops being an enforceable cap; (b) on next process start scanDirectory rediscovers the orphan .sfs and re-ships its already-acked frames to the new server. Failed removes now keep the segment in the list with a removePending flag — bytesOnDiskCache stays honest, replay() skips removePending segments (so already-acked frames don't re-ship), the next trim() retries naturally, and close() does a last-chance retry too. C4 — Future server ACK can delete unsent SF data InFlightWindow.acknowledgeUpTo clamps incoming server sequence at highestSent; ResponseHandler.onBinaryMessage was passing the raw uncapped sequence into segmentLog.trim(fsnAtZero + sequence) with no symmetric clamp. A buggy/replayed/malformed server ACK with a sequence beyond what the client had sent drove SegmentLog.trim past every real lastSeq, force-rotating the active segment and unlinking every sealed segment whose lastSeq <= the bogus value — including frames mid-replay the new server had never seen. Permanent silent data loss. The trim path now clamps the sequence at nextBatchSequence-1 (mirroring the InFlightWindow cap) and emits a WARN when the cap fires. C5 — Replay window-wait spin hangs after mid-replay socket drop replayPersistedFrames's window-wait spin only called tryReceiveAcks while client.isConnected() returned true. When the server dropped mid-replay, isConnected went false, no ACKs could arrive, hasWindowSpace stayed false, and the spin ran forever — preventing the outer state machine from running another doReconnectCycle and blocking flush()/close() until the user signalled shutdown. Compounding: replayPersistedFrames swallowed internal failures via failConnection(non-fatal) and returned normally, so doReconnectCycle returned true and ioLoop cleared reconnectRequested — losing the failure. The spin now exits on !isConnected or reconnectRequested; doReconnectCycle clears the stale reconnectRequested before replay (so the freshly-reconnected spin doesn't bail) and re-checks it after replay (so internal failures propagate to the outer loop's backoff/retry). Tests: SegmentLogTest: + testRotateOomThenSmallAppendThrowsFatalNotDiskFull (C1) + testTrimRemoveFailureMustNotForgetSealedSegment (C3) SfIntegrationTest: + testReconnectResetsSymbolWatermark (C2) + testFutureAckMustNotTrimUnsentSfData (C4) + testReplayMustNotHangWhenConnectionDropsMidReplay (C5) All 1988 client tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0ad83b9 commit 07c20e0

5 files changed

Lines changed: 1135 additions & 11 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1968,6 +1968,16 @@ private int countNonEmptyTables(ObjList<CharSequence> keys) {
19681968
private void resetSchemaStateForNewConnection() {
19691969
maxSentSchemaId = -1;
19701970
nextSchemaId = 0;
1971+
// The new server has an empty symbol dictionary. The encoder's
1972+
// delta-dictionary range is computed as
1973+
// deltaStart = maxSentSymbolId + 1
1974+
// deltaCount = max(0, currentBatchMaxSymbolId - maxSentSymbolId)
1975+
// so a non-reset watermark would skip every symbol id <= the old
1976+
// server's confirmed max, leaving column refs into a dictionary the
1977+
// new server has never seen. Reset both so the next batch ships a
1978+
// delta starting at id 0 covering every referenced symbol.
1979+
maxSentSymbolId = -1;
1980+
currentBatchMaxSymbolId = -1;
19711981

19721982
ObjList<CharSequence> keys = tableBuffers.keys();
19731983
for (int i = 0, n = keys.size(); i < n; i++) {

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

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -924,12 +924,29 @@ private boolean doReconnectCycle(long sleepMs) {
924924
}
925925
long oldest = segmentLog.oldestSeq();
926926
fsnAtZero = oldest >= 0 ? oldest : segmentLog.nextSeq();
927+
// Clear the stale reconnectRequested flag BEFORE replay, otherwise
928+
// replay's window-wait spin would exit immediately on the new
929+
// connection (it checks !reconnectRequested as a bail-out signal).
930+
// failConnection in onClose / onError will set it again if the new
931+
// connection also fails.
932+
reconnectRequested = false;
927933
try {
928934
replayPersistedFrames();
929935
} catch (Throwable t) {
930936
LOG.warn("SF replay after reconnect failed: {}", t.getMessage());
931937
return false;
932938
}
939+
// replayPersistedFrames swallows internal failures by calling
940+
// failConnection(non-fatal), which sets reconnectRequested=true and
941+
// returns normally. Without this check the caller would clear
942+
// reconnectRequested and proceed as if replay succeeded — the
943+
// failure would be silently lost. Surface it so the I/O loop backs
944+
// off and retries.
945+
if (reconnectRequested) {
946+
LOG.warn("SF replay aborted mid-stream (reconnect requested) — "
947+
+ "treating reconnect cycle as failed");
948+
return false;
949+
}
933950
LOG.info("SF reconnect complete");
934951
return true;
935952
}
@@ -959,13 +976,35 @@ private void replayPersistedFrames() {
959976
"SF replay FSN drift: fsn=" + fsn + " expected=" + (fsnAtZero + wireSeq));
960977
}
961978
if (inFlightWindow != null) {
962-
while (running && !inFlightWindow.hasWindowSpace()) {
963-
if (client.isConnected()) {
964-
tryReceiveAcks();
965-
}
979+
// Wait for window space, but bail out as soon as the
980+
// connection is gone or the I/O thread has been told to
981+
// reconnect. Without these guards the loop would spin
982+
// forever once the server drops mid-replay: a closed
983+
// socket can never produce ACKs, so hasWindowSpace stays
984+
// false; the outer state machine never gets to call
985+
// doReconnectCycle because we're stuck in this lambda;
986+
// and flush()/close() block on the I/O thread that will
987+
// never make progress until the user signals shutdown.
988+
while (running
989+
&& !inFlightWindow.hasWindowSpace()
990+
&& !reconnectRequested
991+
&& client.isConnected()) {
992+
tryReceiveAcks();
966993
Thread.onSpinWait();
967994
}
968-
if (!running) {
995+
if (!running || reconnectRequested || !client.isConnected()) {
996+
// Either shutdown was requested, the connection died
997+
// (so any further sends will fail anyway), or the
998+
// failure handler has already requested a reconnect.
999+
// Stop replaying and let the outer state machine
1000+
// drive the next attempt. Mark reconnectRequested so
1001+
// doReconnectCycle's post-replay check returns false
1002+
// and the caller backs off and retries.
1003+
if (running && !reconnectRequested && !client.isConnected()) {
1004+
failConnection(new LineSenderException(
1005+
"Connection lost mid-replay (window full, no ACKs possible)"),
1006+
false);
1007+
}
9691008
return false;
9701009
}
9711010
if (!inFlightWindow.tryAddInFlight(wireSeq)) {
@@ -1249,7 +1288,29 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) {
12491288
if (segmentLog != null) {
12501289
// Translate wire seq → FSN. Cumulative ack of wire seq N means
12511290
// every FSN up to fsnAtZero+N has been applied server-side.
1252-
segmentLog.trim(fsnAtZero + sequence);
1291+
//
1292+
// Clamp sequence at the highest wire seq the client has actually
1293+
// sent on this connection (= nextBatchSequence - 1). Without this,
1294+
// a misbehaving / replayed / malformed server ACK with a sequence
1295+
// beyond what we sent would feed a fictitious lastSeq into
1296+
// SegmentLog.trim, which would then force-rotate the active
1297+
// segment and unlink every sealed segment whose lastSeq <= the
1298+
// bogus value — including frames mid-replay that the new server
1299+
// has never seen. Permanent silent data loss.
1300+
//
1301+
// This mirrors the cap that InFlightWindow.acknowledgeUpTo
1302+
// already applies at line 144; the SF trim path was missing the
1303+
// symmetric guard.
1304+
long highestSent = nextBatchSequence - 1;
1305+
if (highestSent >= 0) {
1306+
long capped = Math.min(sequence, highestSent);
1307+
if (capped < sequence) {
1308+
LOG.warn("server ACK sequence {} exceeds highest sent "
1309+
+ "wire seq {} — clamping SF trim to prevent "
1310+
+ "silent data loss", sequence, highestSent);
1311+
}
1312+
segmentLog.trim(fsnAtZero + capped);
1313+
}
12531314
}
12541315
for (int i = 0, n = response.getTableEntryCount(); i < n; i++) {
12551316
advanceSeqTxn(committedSeqTxns, response.getTableName(i), response.getTableSeqTxn(i));

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

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
import io.questdb.client.std.ObjList;
3232
import io.questdb.client.std.QuietCloseable;
3333
import io.questdb.client.std.Unsafe;
34+
import org.slf4j.Logger;
35+
import org.slf4j.LoggerFactory;
3436

3537
/**
3638
* Segmented append-only log of opaque byte frames keyed by a monotonic 64-bit sequence number.
@@ -60,6 +62,9 @@
6062
*/
6163
public final class SegmentLog implements QuietCloseable {
6264

65+
private static final Logger LOG = LoggerFactory.getLogger(SegmentLog.class);
66+
67+
6368
public static final long DEFAULT_MAX_BYTES_PER_SEGMENT = 64L * 1024 * 1024;
6469
public static final long DEFAULT_MAX_TOTAL_BYTES = Long.MAX_VALUE;
6570
public static final long FIRST_SEQ = 0L;
@@ -173,6 +178,21 @@ public static SegmentLog open(String dir, FilesFacade ff, long maxBytesPerSegmen
173178
*/
174179
public long append(long payloadAddr, int payloadLen) {
175180
ensureOpen();
181+
// Guard against the partial-rotate failure state (bug C1). When
182+
// rotate() fails between rename and createActive (e.g. allocNativePath
183+
// OOMs at the second alloc, or createActive's openCleanRW/fsync fails
184+
// for the new segment), `active` is left pointing at the now-sealed
185+
// segment with sealed=true and fd=-1. Without this guard, a small
186+
// subsequent append that fits under the cap would bypass the rotate
187+
// trigger below and fall through to ff.write(fd=-1) — which returns
188+
// -1 and is wrapped as SfDiskFullException (a recoverable backpressure
189+
// signal) by the short-write branch. The I/O thread would then retry
190+
// forever and the user thread would deadlock in flush(). Surface a
191+
// fatal SfException instead so the connection terminates cleanly.
192+
if (active.sealed || active.fd < 0) {
193+
throw new SfException("SegmentLog is unusable after a prior rotate failure: "
194+
+ active.path + " (sealed=" + active.sealed + ", fd=" + active.fd + ")");
195+
}
176196
if (payloadLen <= 0) {
177197
throw new SfException("payloadLen must be > 0, got " + payloadLen);
178198
}
@@ -249,7 +269,15 @@ public void fsync() {
249269
public void replay(FrameVisitor visitor) {
250270
ensureOpen();
251271
for (int i = 0, n = segments.size(); i < n; i++) {
252-
if (!replaySegment(segments.getQuick(i), visitor)) {
272+
Segment s = segments.getQuick(i);
273+
// Skip segments whose disk file we tried (and failed) to remove
274+
// on a previous trim. Their frames were acked by the server —
275+
// re-shipping them on the new connection would produce silent
276+
// duplicate writes.
277+
if (s.removePending) {
278+
continue;
279+
}
280+
if (!replaySegment(s, visitor)) {
253281
return;
254282
}
255283
}
@@ -307,19 +335,54 @@ private void trimSealedSegments(long ackedSeq) {
307335
continue;
308336
}
309337
if (s.lastSeq() <= ackedSeq) {
338+
// Close the fd up front: even if remove fails and the segment
339+
// stays in the list, we won't read from it again — replay()
340+
// skips removePending segments and append() never targets a
341+
// sealed one. Holding the fd would just leak a descriptor.
310342
if (s.fd != -1) {
311343
ff.close(s.fd);
312344
s.fd = -1;
313345
}
346+
boolean removed;
314347
if (s.pathPtrNative != 0) {
315-
ff.remove(s.pathPtrNative);
316-
ff.freeNativePath(s.pathPtrNative);
317-
s.pathPtrNative = 0;
348+
removed = ff.remove(s.pathPtrNative);
318349
} else {
319350
// Recovery case: rotate's allocNativePath OOMed and left
320351
// pathPtrNative=0. Fall back to the String form, which
321352
// does its own one-shot encode/free internally.
322-
ff.remove(s.path);
353+
removed = ff.remove(s.path);
354+
}
355+
if (!removed) {
356+
// remove() failed (Windows sharing-violation under AV,
357+
// transient NFS error, ESTALE, etc.). DO NOT decrement
358+
// bytesOnDiskCache or free pathPtrNative — the file is
359+
// still on disk. Keep the segment in the in-memory list
360+
// so:
361+
// (a) bytesOnDisk() keeps reporting the truth and the
362+
// sf_max_total_bytes cap stays enforceable;
363+
// (b) the next trim() retries the remove (the
364+
// lastSeq() <= ackedSeq predicate still holds for
365+
// cumulative ACKs);
366+
// (c) replay() skips it via the removePending flag so
367+
// already-acked frames don't re-ship to the new
368+
// server on reconnect.
369+
if (!s.removePending) {
370+
LOG.warn("trim: remove() failed for sealed segment, "
371+
+ "will retry on next trim [path={}, baseSeq={}, "
372+
+ "lastSeq={}, writePos={}]",
373+
s.path, s.baseSeq, s.lastSeq(), s.writePos);
374+
}
375+
s.removePending = true;
376+
segments.setQuick(writeIdx++, s);
377+
continue;
378+
}
379+
if (s.removePending) {
380+
LOG.info("trim: retry succeeded for previously-failed "
381+
+ "remove [path={}, baseSeq={}]", s.path, s.baseSeq);
382+
}
383+
if (s.pathPtrNative != 0) {
384+
ff.freeNativePath(s.pathPtrNative);
385+
s.pathPtrNative = 0;
323386
}
324387
bytesOnDiskCache -= s.writePos;
325388
} else {
@@ -373,6 +436,25 @@ public void close() {
373436
ff.close(s.fd);
374437
s.fd = -1;
375438
}
439+
// Last-chance retry for segments whose mid-session remove() failed
440+
// (e.g. Windows sharing-violation that has since cleared, transient
441+
// NFS error that has resolved). Without this, an orphan .sfs file
442+
// would persist on disk and the next process start would
443+
// rediscover it via scanDirectory and replay its already-acked
444+
// frames to the new server — silent duplicate writes.
445+
if (s.removePending) {
446+
boolean removed = s.pathPtrNative != 0
447+
? ff.remove(s.pathPtrNative)
448+
: ff.remove(s.path);
449+
if (removed) {
450+
s.removePending = false;
451+
} else {
452+
LOG.warn("close: remove() still failing for orphaned segment "
453+
+ "[path={}, baseSeq={}] — file will be rediscovered "
454+
+ "on next start and re-replay its already-acked "
455+
+ "frames to the new server", s.path, s.baseSeq);
456+
}
457+
}
376458
if (s.pathPtrNative != 0) {
377459
ff.freeNativePath(s.pathPtrNative);
378460
s.pathPtrNative = 0;
@@ -850,6 +932,15 @@ static final class Segment {
850932
long pathPtrNative;
851933
int fd = -1;
852934
boolean sealed;
935+
// Trim attempted to delete this segment but ff.remove returned false
936+
// (e.g. Windows sharing-violation, transient NFS error). The .sfs
937+
// file is still on disk; the next trim() will retry the remove.
938+
// While true, the segment stays in the in-memory list so:
939+
// (a) bytesOnDisk() keeps counting it (sf_max_total_bytes stays
940+
// enforceable),
941+
// (b) replay() skips it (its frames were already acked — must not
942+
// re-ship to the new server).
943+
boolean removePending;
853944

854945
long lastSeq() {
855946
return sealed ? lastSeqOnDisk : (baseSeq + frameCount - 1);

0 commit comments

Comments
 (0)