Skip to content

Commit 809d453

Browse files
authored
Fix drain() skipping unacked frames after empty flush (#48)
1 parent cd99dd9 commit 809d453

3 files changed

Lines changed: 88 additions & 2 deletions

File tree

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,11 +469,21 @@ default Sender decimalColumn(CharSequence name, CharSequence value) {
469469
/**
470470
* Convenience: flush every buffered row and block until the server has
471471
* acknowledged the resulting frame, or until {@code timeoutMillis} elapses.
472-
* Equivalent to {@code awaitAckedFsn(flushAndGetSequence(), timeoutMillis)},
472+
* <br>
473+
* The default implementation is equivalent to
474+
* {@code awaitAckedFsn(flushAndGetSequence(), timeoutMillis)},
473475
* which is the same shape as the implicit drain {@link #close()} runs --
474476
* with the caller controlling the timeout per call-site rather than
475477
* relying on the builder-time {@code close_flush_timeout_millis}.
476478
* <br>
479+
* <b>Note:</b> Implementations that support frame-level acknowledgements
480+
* (e.g. WebSocket QWP) may override this method with <i>watermark
481+
* semantics</i>: flushing first, then waiting for the current global
482+
* published FSN rather than the per-call value from
483+
* {@code flushAndGetSequence()}. This ensures that {@code drain()} after
484+
* a previous publish with unacknowledged frames still blocks until those
485+
* frames are acknowledged, even when the current flush publishes nothing.
486+
* <br>
477487
* Returns immediately on transports that do not track frame sequence
478488
* numbers ({@code HTTP}, {@code TCP}, {@code UDP}): the flush still
479489
* happens, the wait is a no-op, and the return value is {@code true}.

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1341,6 +1341,8 @@ public long flushAndGetSequence() {
13411341
ensureNoInProgressRow();
13421342
ensureConnected();
13431343

1344+
long beforeFsn = cursorEngine != null ? cursorEngine.publishedFsn() : -1L;
1345+
13441346
// Cursor SF: SF.append happens on the user thread inside
13451347
// sealAndSwapBuffer, so by the time we reach here every encoded
13461348
// batch is durable on its mmap'd segment. No processingCount to
@@ -1354,7 +1356,40 @@ public long flushAndGetSequence() {
13541356
}
13551357
cursorSendLoop.checkError();
13561358
checkConnectionError();
1357-
return cursorEngine != null ? cursorEngine.publishedFsn() : -1L;
1359+
1360+
long afterFsn = cursorEngine != null ? cursorEngine.publishedFsn() : -1L;
1361+
return afterFsn > beforeFsn ? afterFsn : -1L;
1362+
}
1363+
1364+
/**
1365+
* Flushes pending rows and blocks until the server has acknowledged
1366+
* every frame published so far (the current published-FSN watermark),
1367+
* or until {@code timeoutMillis} elapses.
1368+
* <p>
1369+
* This override uses <b>watermark semantics</b> rather than per-call
1370+
* semantics: it waits for the global {@code publishedFsn()}, not just
1371+
* the FSN returned by the flush in this call. This is necessary because
1372+
* {@link #flushAndGetSequence()} now returns {@code -1} when no data
1373+
* was published by the call, and the default {@link Sender#drain}
1374+
* implementation ({@code awaitAckedFsn(flushAndGetSequence(), timeout)})
1375+
* would short-circuit immediately on an empty flush even when prior
1376+
* publishes remain unacknowledged.
1377+
* <p>
1378+
* Close-time drain ({@link #drainOnClose()}) already uses the same
1379+
* watermark approach directly.
1380+
*
1381+
* @param timeoutMillis upper bound on the wait; {@code <= 0} returns
1382+
* the current state without blocking (the flush
1383+
* still happens before the check)
1384+
* @return {@code true} if the server has acknowledged every published
1385+
* frame on return, {@code false} on timeout
1386+
* @throws LineSenderException if the transport has latched a terminal error
1387+
*/
1388+
@Override
1389+
public boolean drain(long timeoutMillis) {
1390+
flush();
1391+
long targetFsn = cursorEngine != null ? cursorEngine.publishedFsn() : -1L;
1392+
return awaitAckedFsn(targetFsn, timeoutMillis);
13581393
}
13591394

13601395
/**

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,47 @@ public void testDrainBlocksUntilAckArrivesAndReturnsTrue() throws Exception {
210210
}
211211
}
212212

213+
/**
214+
* Regression test for #7142: drain() after a prior flush() with unacked
215+
* frames must block for those frames, even though the inner
216+
* flushAndGetSequence() publishes nothing and returns -1.
217+
* <p>
218+
* On buggy code (no drain() override): drain() calls the default
219+
* Sender.drain() → flushAndGetSequence() returns -1 → awaitAckedFsn(-1, ...)
220+
* returns true immediately (ackedFsn >= -1 is always true) at elapsed≈0ms.
221+
* The elapsed >= 300 assertion fails deterministically.
222+
* <p>
223+
* On fixed code: drain() uses the watermark override → waits for the
224+
* delayed ACK (~600ms) → passes.
225+
*/
226+
@Test
227+
public void testDrainAfterFlushWaitsForPriorUnackedFrames() throws Exception {
228+
int port = TestPorts.findUnusedPort();
229+
long ackDelayMs = 600;
230+
DelayingAckHandler handler = new DelayingAckHandler(ackDelayMs);
231+
try (TestWebSocketServer server = new TestWebSocketServer(port, handler)) {
232+
server.start();
233+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
234+
235+
String cfg = "ws::addr=localhost:" + port + ";";
236+
try (Sender sender = Sender.fromConfig(cfg)) {
237+
sender.table("foo").longColumn("v", 1L).atNow();
238+
sender.flush(); // publish FSN 0; ACK delayed ~600ms
239+
240+
long t0 = System.nanoTime();
241+
boolean drained = sender.drain(5_000); // empty flush → -1 on buggy code
242+
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
243+
244+
Assert.assertTrue("drain() must return true when ACK arrives within budget",
245+
drained);
246+
Assert.assertTrue(
247+
"drain() must wait for prior unacked frame, but returned in only "
248+
+ elapsedMs + "ms (expected >= " + (ackDelayMs / 2) + "ms)",
249+
elapsedMs >= ackDelayMs / 2);
250+
}
251+
}
252+
}
253+
213254
@Test
214255
public void testDrainReturnsFalseOnTimeoutAndSenderStillUsable() throws Exception {
215256
// Server never ACKs. drain() with a small timeout must return false

0 commit comments

Comments
 (0)