|
26 | 26 |
|
27 | 27 | import io.questdb.client.Sender; |
28 | 28 | import io.questdb.client.cutlass.line.LineSenderException; |
| 29 | +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; |
29 | 30 | import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; |
30 | 31 | import org.junit.Assert; |
31 | 32 | import org.junit.Test; |
@@ -174,6 +175,99 @@ public void testCloseDrainTimesOutWhenAcksNeverArrive() throws Exception { |
174 | 175 | } |
175 | 176 | } |
176 | 177 |
|
| 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 | + |
177 | 271 | @Test |
178 | 272 | public void testDrainBlocksUntilAckArrivesAndReturnsTrue() throws Exception { |
179 | 273 | // Public drain(timeoutMillis): explicit pre-close drain that the |
@@ -411,6 +505,27 @@ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] dat |
411 | 505 | } |
412 | 506 | } |
413 | 507 |
|
| 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 | + |
414 | 529 | // Mirrors WebSocketResponse STATUS_OK layout: status u8 | sequence u64 | table_count u16 |
415 | 530 | static byte[] buildAck(long seq) { |
416 | 531 | byte[] buf = new byte[1 + 8 + 2]; |
|
0 commit comments