Skip to content

Commit 519f5e4

Browse files
bluestreak01claude
andcommitted
fix(ilp): trim acked frames from active SF segment
Pre-fix behaviour: SegmentLog.trim only deleted sealed segments. Frames that the server had acknowledged but lived in the still-open active segment stayed on disk until the next natural rotation. On restart the new sender replayed those frames and the public Sender.storeAndForward contract — "trimmed when the server acknowledges it" — was load-bearing on server-side seqTxn dedup to avoid duplicate rows. Worst case at the default 64 MiB segment size: ~640 acked batches re-shipped per restart. Fix: when every frame in the active segment has been acked, force-rotate the active (sealing the file) and immediately remove the just-sealed segment. nextSeq is preserved across the auto-rotate so subsequent appends keep monotonic FSNs. The only safe-guard is the rotate-OOM recovery state from the M2 fix: when active.sealed is already true, the sealed pass above has already trimmed the file and force-rotate is skipped. Tests: - testTrimRotatesAndDropsFullyAckedActiveSegment (SegmentLogTest): unit-level proof that trim with full coverage drops the active contents to a fresh empty segment, with nextSeq preserved. - testTrimPartialAckOfActiveLeavesItIntact (SegmentLogTest, replaces testTrimNeverDeletesActive): proves partial ACKs do not seal a segment that still contains unacked data. - testRestartAfterAckedBatchesReplaysNothing (SfIntegrationTest): end-to-end. Send 5 batches, wait for trim, close, reopen with a fresh sender, send one more, assert server saw exactly 6 frames (5 originals + 1 new, no replays). - testCapturedBytesMatchWireBytes (SfIntegrationTest): updated to use a non-acking handler so the test thread's log.replay() doesn't race the I/O thread's trim. - testAutoReconnectAndReplay (SfIntegrationTest): expected frame count drops from 5 to 4 (msg1 trimmed before reconnect, no replay). - testMultiTableSurvivesReconnect (SfIntegrationTest): expected frame count drops from 6 to 5 (alpha-1 trimmed before reconnect). Public API: - Sender.storeAndForwardDir Javadoc rewritten to honestly describe the new contract: acked batches are reclaimed in real time; only batches whose ACK had not been received before sender shutdown are replayed on the next sender against the same directory. Full suite: 1975 tests pass, zero regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent acb32b9 commit 519f5e4

4 files changed

Lines changed: 289 additions & 30 deletions

File tree

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,9 +1569,22 @@ public LineSenderBuilder storeAndForward(boolean enabled) {
15691569
/**
15701570
* Set the store-and-forward directory. Has effect only when SF is also
15711571
* enabled via {@link #storeAndForward(boolean)} (or {@code store_and_forward=on}
1572-
* in the connect string). Every batch is persisted before it leaves the
1573-
* wire and trimmed when the server acknowledges it; on restart the sender
1574-
* replays whatever is on disk. WebSocket transport only.
1572+
* in the connect string).
1573+
* <p>
1574+
* Every batch is persisted to disk before it leaves the wire and is
1575+
* reclaimed as soon as the server acknowledges it. On restart the
1576+
* sender replays only batches whose acknowledgement had not been
1577+
* received before the previous sender shut down — typically the last
1578+
* in-flight batches at close time. Acknowledged batches are not
1579+
* replayed: their disk space is freed during normal operation by an
1580+
* automatic per-frame trim that force-rotates the active segment
1581+
* once every frame in it has been acknowledged.
1582+
* <p>
1583+
* Note that {@link io.questdb.client.cutlass.qwp.client.QwpWebSocketSender#close()}
1584+
* under SF returns once data is on disk, not on server-ack, so a
1585+
* sender closed immediately after a flush may still have unacked
1586+
* batches in flight; those will be replayed by the next sender
1587+
* against the same directory. WebSocket transport only.
15751588
* <p>
15761589
* The sender takes ownership of the underlying SegmentLog and closes it
15771590
* when the sender itself is closed.

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

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,49 @@ public void replay(FrameVisitor visitor) {
256256
}
257257

258258
/**
259-
* Delete every sealed segment whose lastSeq is &lt;= ackedSeq. The active
260-
* segment is never trimmed, even if all of its frames are acked — it is only
261-
* deleted when sealed by a rotation.
259+
* Reclaim disk space for every frame whose seq is &lt;= ackedSeq.
260+
* <p>
261+
* Sealed segments whose {@code lastSeq <= ackedSeq} are deleted. If the
262+
* current active segment also has all of its frames acked (i.e. its
263+
* highest assigned seq &lt;= ackedSeq), it is force-rotated and the
264+
* just-sealed file is immediately removed. {@code nextSeq} is preserved
265+
* across the auto-rotate so future appends keep monotonic FSNs.
266+
* <p>
267+
* The force-rotate is what makes "trimmed when the server acknowledges
268+
* it" honest in the public API: a quiet sender whose batches all
269+
* acknowledge keeps disk at exactly one empty active segment, and on
270+
* restart no acked frames are replayed.
262271
*/
263272
public void trim(long ackedSeq) {
264273
ensureOpen();
274+
trimSealedSegments(ackedSeq);
275+
276+
// Force-rotate the active segment when every frame in it has been
277+
// acked. The just-sealed segment is then removed by a second pass
278+
// of trimSealedSegments. Cost is one extra rotation per fully-acked
279+
// burst (typically once per server cumulative ACK), which on a
280+
// low-rate sender is amortised by the natural-rotation cost it
281+
// displaces — the active will rotate anyway eventually.
282+
//
283+
// The {@code !active.sealed} guard handles the rotate-OOM recovery
284+
// state from the M2 fix: after an OOM mid-rename, {@code active}
285+
// points at the now-sealed segment with fd=-1 and pathPtrNative=0;
286+
// attempting to rotate it again would fail in fsync. The sealed
287+
// pass above already trimmed the file, so we just skip here.
288+
//
289+
// If rotate fails (e.g. fsync EIO), the SfException propagates to
290+
// the caller. ResponseHandler.onBinaryMessage runs trim() inline
291+
// with ACK processing, so a thrown SfException there will surface
292+
// as a connection-level error and the sender goes terminal — the
293+
// correct response to a broken disk.
294+
if (active != null && !active.sealed && active.frameCount > 0
295+
&& active.baseSeq + active.frameCount - 1 <= ackedSeq) {
296+
rotate();
297+
trimSealedSegments(ackedSeq);
298+
}
299+
}
300+
301+
private void trimSealedSegments(long ackedSeq) {
265302
int writeIdx = 0;
266303
for (int i = 0, n = segments.size(); i < n; i++) {
267304
Segment s = segments.getQuick(i);

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

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,14 @@ public void testTrimDeletesSealedFullyAcked() throws Exception {
246246
});
247247
}
248248

249+
/**
250+
* When ACK covers some-but-not-all of the active segment's frames, the
251+
* active segment must remain on disk (force-rotate only fires when
252+
* every frame is acked). Without this guard a partially-acked active
253+
* would be sealed and the unacked frames would be silently lost.
254+
*/
249255
@Test
250-
public void testTrimNeverDeletesActive() throws Exception {
256+
public void testTrimPartialAckOfActiveLeavesItIntact() throws Exception {
251257
TestUtils.assertMemoryLeak(() -> {
252258
byte[] payload = "x".getBytes();
253259
try (SegmentLog log = SegmentLog.open(tmpDir, 1L << 20)) {
@@ -259,15 +265,77 @@ public void testTrimNeverDeletesActive() throws Exception {
259265
Unsafe.free(buf, payload.length, MemoryTag.NATIVE_DEFAULT);
260266
}
261267
log.fsync();
262-
// ack way past everything; active is unsealed so must remain.
263-
log.trim(Long.MAX_VALUE / 2);
268+
// Ack only the first frame; second is still in flight. The
269+
// active must NOT be force-rotated yet — that would seal a
270+
// segment containing un-acked data.
271+
log.trim(0);
264272
assertEquals(1, log.segmentCount());
265273
int[] count = {0};
266274
log.replay((seq, addr, len) -> {
267275
count[0]++;
268276
return true;
269277
});
270-
assertEquals(2, count[0]);
278+
assertEquals("both frames must still be on disk", 2, count[0]);
279+
}
280+
});
281+
}
282+
283+
/**
284+
* When ACK covers every frame in the active segment, the active is
285+
* force-rotated and the just-sealed segment removed. nextSeq is
286+
* preserved across the auto-rotate so subsequent appends keep
287+
* monotonic FSNs. After reopen, replay yields zero frames — this is
288+
* what makes "trimmed when the server acknowledges it" honest in the
289+
* public Sender API.
290+
*/
291+
@Test
292+
public void testTrimRotatesAndDropsFullyAckedActiveSegment() throws Exception {
293+
TestUtils.assertMemoryLeak(() -> {
294+
byte[] payload = "x".getBytes();
295+
try (SegmentLog log = SegmentLog.open(tmpDir, 1L << 20)) {
296+
long buf = alloc(payload);
297+
try {
298+
for (int i = 0; i < 5; i++) {
299+
log.append(buf, payload.length);
300+
}
301+
} finally {
302+
Unsafe.free(buf, payload.length, MemoryTag.NATIVE_DEFAULT);
303+
}
304+
log.fsync();
305+
306+
long preTrimBytes = log.bytesOnDisk();
307+
assertTrue("data must be on disk before trim",
308+
preTrimBytes > SegmentLog.HEADER_SIZE);
309+
assertEquals(5L, log.nextSeq());
310+
311+
// Ack every frame; force-rotate kicks in, sealed segment
312+
// removed in the same trim() call.
313+
log.trim(4);
314+
315+
assertEquals("a fresh empty active must remain", 1, log.segmentCount());
316+
assertEquals("nextSeq must survive the auto-rotate", 5L, log.nextSeq());
317+
assertEquals("oldestSeq must report empty (no frames)", -1L, log.oldestSeq());
318+
assertEquals("only the new active's header should be on disk",
319+
(long) SegmentLog.HEADER_SIZE, log.bytesOnDisk());
320+
int[] count = {0};
321+
log.replay((seq, addr, len) -> {
322+
count[0]++;
323+
return true;
324+
});
325+
assertEquals("no frames should remain after force-rotate-trim",
326+
0, count[0]);
327+
}
328+
// Reopen with a fresh SegmentLog; replay must visit zero frames.
329+
try (SegmentLog log2 = SegmentLog.open(tmpDir, 1L << 20)) {
330+
int[] count = {0};
331+
log2.replay((seq, addr, len) -> {
332+
count[0]++;
333+
return true;
334+
});
335+
assertEquals(
336+
"acked-and-trimmed frames must not replay on restart",
337+
0, count[0]);
338+
assertEquals("nextSeq must round-trip", 5L, log2.nextSeq());
271339
}
272340
});
273341
}

0 commit comments

Comments
 (0)