Skip to content

Commit 2a9b4cd

Browse files
committed
test(qwp): pin close()'s drain-to-commit-boundary for uncommitted deferred tails
Regression tests for c6eadf0 (stop close() waiting on acks of uncommitted deferred frames), which shipped with the fix in 4 production files and no client-side test -- the only coverage was testDeferredCommitConnectionDropRollsBack in the parent server repo, where the pre-fix symptom was a 300s close() hang. Two tests in CloseDrainTest, both verified red on the pre-fix behavior (target = publishedFsn) and green on the fix: - testCloseSkipsDrainForUncommittedDeferredTail: defer-commit mode, publish rows, never commit, close() against a server that never acks (what the real server does to a deferred tail by design). Fixed close() drains to min(publishedFsn, commitBoundary) = -1 and returns immediately with the abandonment WARN; broken close() throws 'drain timed out' after the full close_flush_timeout. - testCloseDrainsToCommitBoundaryAndAbandonsDeferredTail: one committed frame below an uncommitted deferred tail, server acks only the commit-bearing frame (AckFirstFrameOnlyHandler). Pins that the boundary drain is not an opt-out of draining altogether: close() still waits for the committed frame's ack, then abandons the tail instead of timing out on it.
1 parent 0955443 commit 2a9b4cd

1 file changed

Lines changed: 115 additions & 0 deletions

File tree

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import io.questdb.client.Sender;
2828
import io.questdb.client.cutlass.line.LineSenderException;
29+
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
2930
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
3031
import org.junit.Assert;
3132
import org.junit.Test;
@@ -174,6 +175,99 @@ public void testCloseDrainTimesOutWhenAcksNeverArrive() throws Exception {
174175
}
175176
}
176177

178+
@Test
179+
public void testCloseSkipsDrainForUncommittedDeferredTail() throws Exception {
180+
// Regression test for the close()-hang on abandoned deferred
181+
// transactions: the server withholds acks for FLAG_DEFER_COMMIT
182+
// frames until their group-closing commit lands, so a close()-time
183+
// drain that targets publishedFsn (instead of the last commit
184+
// boundary) can only ever time out -- 300s hangs in the e2e suite
185+
// (testDeferredCommitConnectionDropRollsBack).
186+
//
187+
// Same producer sequence as that e2e test, against a server that
188+
// never acks (which is exactly what the real server does to an
189+
// uncommitted deferred tail): defer-commit mode, publish rows, no
190+
// commit, close(). Fixed close() drains to
191+
// min(publishedFsn, commitBoundary) = -1, abandons the tail with a
192+
// WARN, and returns immediately. Broken close() targets the deferred
193+
// frame and throws "drain timed out" after the full timeout.
194+
long timeoutMs = 2000;
195+
SilentHandler handler = new SilentHandler();
196+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
197+
server.start();
198+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
199+
200+
int port = server.getPort();
201+
String cfg = "ws::addr=localhost:" + port
202+
+ ";close_flush_timeout_millis=" + timeoutMs + ";";
203+
try (Sender sender = Sender.fromConfig(cfg)) {
204+
((QwpWebSocketSender) sender).setDeferCommit(true);
205+
sender.table("foo").longColumn("v", 1L).atNow();
206+
sender.flush(); // publishes a deferred frame; commit never sent
207+
208+
long t0 = System.nanoTime();
209+
try {
210+
sender.close();
211+
} catch (LineSenderException e) {
212+
Assert.fail("close() must not wait for acks of an uncommitted deferred "
213+
+ "tail (the server withholds them by design), but threw: "
214+
+ e.getMessage());
215+
}
216+
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
217+
Assert.assertTrue("close() took " + elapsedMs + "ms -- it drained toward the "
218+
+ "uncommitted deferred tail instead of stopping at the commit "
219+
+ "boundary",
220+
elapsedMs < timeoutMs / 2);
221+
}
222+
}
223+
}
224+
225+
@Test
226+
public void testCloseDrainsToCommitBoundaryAndAbandonsDeferredTail() throws Exception {
227+
// Mixed case: one committed frame followed by an uncommitted deferred
228+
// tail. close() must still wait for the committed frame's ack (the
229+
// commit-boundary drain is not an opt-out of draining altogether) but
230+
// must not wait for the deferred tail above it.
231+
//
232+
// The handler acks only the first data frame (the committed one) and
233+
// stays silent above it -- mirroring the real server, which acks
234+
// commit-bearing frames and withholds acks for deferred ones. Broken
235+
// close() targets publishedFsn (the deferred tail) and throws "drain
236+
// timed out"; fixed close() returns once the boundary frame is acked.
237+
long timeoutMs = 2000;
238+
AckFirstFrameOnlyHandler handler = new AckFirstFrameOnlyHandler();
239+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
240+
server.start();
241+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
242+
243+
int port = server.getPort();
244+
String cfg = "ws::addr=localhost:" + port
245+
+ ";close_flush_timeout_millis=" + timeoutMs + ";";
246+
try (Sender sender = Sender.fromConfig(cfg)) {
247+
sender.table("foo").longColumn("v", 1L).atNow();
248+
sender.flush(); // commit-bearing frame FSN 0 -- boundary = 0, acked
249+
250+
((QwpWebSocketSender) sender).setDeferCommit(true);
251+
sender.table("foo").longColumn("v", 2L).atNow();
252+
sender.flush(); // deferred frame FSN 1 -- never acked, never committed
253+
254+
long t0 = System.nanoTime();
255+
try {
256+
sender.close();
257+
} catch (LineSenderException e) {
258+
Assert.fail("close() must drain to the commit boundary (FSN 0) and abandon "
259+
+ "the uncommitted deferred tail above it, but threw: "
260+
+ e.getMessage());
261+
}
262+
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
263+
Assert.assertTrue("close() took " + elapsedMs + "ms -- it drained toward the "
264+
+ "uncommitted deferred tail instead of stopping at the acked "
265+
+ "commit boundary",
266+
elapsedMs < timeoutMs / 2);
267+
}
268+
}
269+
}
270+
177271
@Test
178272
public void testDrainBlocksUntilAckArrivesAndReturnsTrue() throws Exception {
179273
// Public drain(timeoutMillis): explicit pre-close drain that the
@@ -411,6 +505,27 @@ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] dat
411505
}
412506
}
413507

508+
/**
509+
* Acks only the first data frame, then goes silent — models a server that
510+
* acks the commit-bearing frame and withholds acks for the uncommitted
511+
* deferred tail above it (the FLAG_DEFER_COMMIT ack contract).
512+
*/
513+
private static class AckFirstFrameOnlyHandler implements TestWebSocketServer.WebSocketServerHandler {
514+
private final AtomicLong received = new AtomicLong(0);
515+
516+
@Override
517+
public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
518+
if (received.getAndIncrement() == 0) {
519+
try {
520+
client.sendBinary(buildAck(0));
521+
} catch (IOException e) {
522+
throw new RuntimeException(e);
523+
}
524+
}
525+
// frames past the first are deferred: withhold their acks
526+
}
527+
}
528+
414529
// Mirrors WebSocketResponse STATUS_OK layout: status u8 | sequence u64 | table_count u16
415530
static byte[] buildAck(long seq) {
416531
byte[] buf = new byte[1 + 8 + 2];

0 commit comments

Comments
 (0)