|
38 | 38 | import java.nio.ByteBuffer; |
39 | 39 | import java.nio.ByteOrder; |
40 | 40 | import java.nio.charset.StandardCharsets; |
| 41 | +import java.util.Set; |
| 42 | +import java.util.concurrent.ConcurrentHashMap; |
41 | 43 | import java.util.concurrent.TimeUnit; |
42 | 44 | import java.util.concurrent.atomic.AtomicLong; |
43 | 45 | import java.util.concurrent.atomic.AtomicReference; |
@@ -279,6 +281,101 @@ public void testMaxFrameRejectionsConfigurableFromConnectString() throws Excepti |
279 | 281 | } |
280 | 282 | } |
281 | 283 |
|
| 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 | + |
282 | 379 | /** Server returns {@code STATUS_WRITE_ERROR} (RETRIABLE policy) for every received frame. */ |
283 | 380 | private static class WriteErrorAckHandler implements TestWebSocketServer.WebSocketServerHandler { |
284 | 381 | final AtomicLong totalBinaryReceived = new AtomicLong(); |
@@ -315,6 +412,16 @@ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] dat |
315 | 412 | } |
316 | 413 | } |
317 | 414 |
|
| 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 | + |
318 | 425 | // Mirrors WebSocketResponse error layout: status u8 | seq u64 | msgLen u16 | msg UTF-8 |
319 | 426 | private static byte[] buildErrorAck(long seq, byte status, String msg) { |
320 | 427 | byte[] msgBytes = msg.getBytes(StandardCharsets.UTF_8); |
|
0 commit comments