Skip to content

Commit a54d9f3

Browse files
committed
test(ServerErrorAckTerminal): add recovery test for retriable NACK with silent tail
1 parent bdd5ea5 commit a54d9f3

1 file changed

Lines changed: 107 additions & 0 deletions

File tree

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import java.nio.ByteBuffer;
3939
import java.nio.ByteOrder;
4040
import java.nio.charset.StandardCharsets;
41+
import java.util.Set;
42+
import java.util.concurrent.ConcurrentHashMap;
4143
import java.util.concurrent.TimeUnit;
4244
import java.util.concurrent.atomic.AtomicLong;
4345
import java.util.concurrent.atomic.AtomicReference;
@@ -279,6 +281,101 @@ public void testMaxFrameRejectionsConfigurableFromConnectString() throws Excepti
279281
}
280282
}
281283

284+
/**
285+
* Client half of the server stop-at-gap contract: the server acks the head,
286+
* RETRIABLE-NACKs the next frame, then goes silent for the frames the client
287+
* already pipelined behind it (never committed, never acked past the gap).
288+
* The client must recycle, replay its unacked tail from ackedFsn+1, and
289+
* deliver every row -- no hang on the unanswered tail, no data loss, no
290+
* poison escalation.
291+
*/
292+
@Test
293+
public void testRecoversFromRetriableNackWithSilentTail() throws Exception {
294+
StopAtGapHandler handler = new StopAtGapHandler();
295+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
296+
server.start();
297+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
298+
299+
int port = server.getPort();
300+
String cfg = "ws::addr=localhost:" + port
301+
+ ";reconnect_max_duration_millis=10000"
302+
+ ";reconnect_initial_backoff_millis=10"
303+
+ ";reconnect_max_backoff_millis=50"
304+
+ ";close_flush_timeout_millis=5000"
305+
+ ";";
306+
307+
Sender sender = Sender.fromConfig(cfg);
308+
try {
309+
// Pipeline four frames so the head NACK can leave a tail behind.
310+
for (int i = 0; i < 4; i++) {
311+
sender.table("foo").longColumn("v", i).atNow();
312+
sender.flush();
313+
}
314+
315+
// The RETRIABLE NACK must recycle and replay the unacked tail on
316+
// a fresh connection.
317+
waitFor(() -> handler.connections.size() >= 2, 10_000);
318+
319+
QwpWebSocketSender wss = (QwpWebSocketSender) sender;
320+
Assert.assertTrue("a RETRIABLE NACK must recycle the connection", wss.getTotalReconnectAttempts() >= 1);
321+
322+
// The replayed tail is OK-acked on the new connection, so nothing
323+
// escalates to a terminal.
324+
Thread.sleep(300);
325+
Assert.assertNull("retriable NACK + silent tail must recover, not latch a terminal", wss.getLastTerminalError());
326+
327+
// End to end: a further row flushes and drain-on-close completes
328+
// without throwing -- no stall, no loss.
329+
sender.table("foo").longColumn("v", 4).atNow();
330+
sender.flush();
331+
sender.close();
332+
} catch (LineSenderException e) {
333+
try {
334+
sender.close();
335+
} catch (LineSenderException ignored) {
336+
}
337+
throw new AssertionError("sender must recover from the retriable NACK, but flush/close threw", e);
338+
}
339+
}
340+
}
341+
342+
/**
343+
* First connection: ack the head, RETRIABLE-NACK the next frame, then stay
344+
* silent for the pipelined tail. Any later connection is the reconnect --
345+
* ack every replayed frame so the tail lands.
346+
*/
347+
private static class StopAtGapHandler implements TestWebSocketServer.WebSocketServerHandler {
348+
final Set<TestWebSocketServer.ClientHandler> connections = ConcurrentHashMap.newKeySet();
349+
final AtomicLong tailSilenced = new AtomicLong();
350+
private TestWebSocketServer.ClientHandler firstClient;
351+
private int firstConnFrameIdx;
352+
private long okAckSeq;
353+
354+
@Override
355+
public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
356+
connections.add(client);
357+
if (firstClient == null) {
358+
firstClient = client;
359+
}
360+
try {
361+
if (client == firstClient) {
362+
int idx = firstConnFrameIdx++;
363+
if (idx == 0) {
364+
client.sendBinary(buildAck(okAckSeq++));
365+
} else if (idx == 1) {
366+
client.sendBinary(buildErrorAck(1, WebSocketResponse.STATUS_WRITE_ERROR, "test: retriable"));
367+
} else {
368+
tailSilenced.incrementAndGet();
369+
}
370+
} else {
371+
client.sendBinary(buildAck(okAckSeq++));
372+
}
373+
} catch (IOException e) {
374+
throw new RuntimeException(e);
375+
}
376+
}
377+
}
378+
282379
/** Server returns {@code STATUS_WRITE_ERROR} (RETRIABLE policy) for every received frame. */
283380
private static class WriteErrorAckHandler implements TestWebSocketServer.WebSocketServerHandler {
284381
final AtomicLong totalBinaryReceived = new AtomicLong();
@@ -315,6 +412,16 @@ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] dat
315412
}
316413
}
317414

415+
// Mirrors WebSocketResponse STATUS_OK layout: status u8 | sequence u64 | table_count u16
416+
private static byte[] buildAck(long seq) {
417+
byte[] buf = new byte[1 + 8 + 2];
418+
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
419+
bb.put(WebSocketResponse.STATUS_OK);
420+
bb.putLong(seq);
421+
bb.putShort((short) 0);
422+
return buf;
423+
}
424+
318425
// Mirrors WebSocketResponse error layout: status u8 | seq u64 | msgLen u16 | msg UTF-8
319426
private static byte[] buildErrorAck(long seq, byte status, String msg) {
320427
byte[] msgBytes = msg.getBytes(StandardCharsets.UTF_8);

0 commit comments

Comments
 (0)