|
21 | 21 | import com.clickhouse.client.api.insert.InsertResponse; |
22 | 22 | import com.clickhouse.client.api.insert.InsertSettings; |
23 | 23 | import com.clickhouse.client.api.internal.DataTypeConverter; |
| 24 | +import com.clickhouse.client.api.internal.HttpAPIClientHelper; |
24 | 25 | import com.clickhouse.client.api.internal.ServerSettings; |
25 | 26 | import com.clickhouse.client.api.internal.ValidationUtils; |
26 | 27 | import com.clickhouse.client.api.query.GenericRecord; |
27 | 28 | import com.clickhouse.client.api.query.QueryResponse; |
28 | 29 | import com.clickhouse.client.api.query.QuerySettings; |
| 30 | +import com.clickhouse.client.api.transport.Endpoint; |
| 31 | +import com.clickhouse.client.api.transport.HttpEndpoint; |
| 32 | +import com.clickhouse.client.api.transport.internal.TransportRequest; |
| 33 | +import com.clickhouse.client.api.transport.internal.TransportResponse; |
29 | 34 | import com.clickhouse.client.config.ClickHouseClientOption; |
30 | 35 | import com.clickhouse.data.ClickHouseFormat; |
| 36 | +import net.jpountz.lz4.LZ4Factory; |
31 | 37 | import com.github.tomakehurst.wiremock.WireMockServer; |
32 | 38 | import com.github.tomakehurst.wiremock.client.WireMock; |
33 | 39 | import com.github.tomakehurst.wiremock.common.ConsoleNotifier; |
|
64 | 70 | import javax.net.ssl.SSLHandshakeException; |
65 | 71 | import java.io.ByteArrayInputStream; |
66 | 72 | import java.io.ByteArrayOutputStream; |
| 73 | +import java.io.InputStream; |
67 | 74 | import java.io.StringWriter; |
68 | 75 | import java.math.BigInteger; |
69 | 76 | import java.net.InetAddress; |
|
102 | 109 | import java.util.concurrent.Executors; |
103 | 110 | import java.util.concurrent.ThreadLocalRandom; |
104 | 111 | import java.util.concurrent.TimeUnit; |
| 112 | +import java.util.concurrent.atomic.AtomicBoolean; |
105 | 113 | import java.util.concurrent.atomic.AtomicInteger; |
| 114 | +import java.util.concurrent.atomic.AtomicLong; |
| 115 | +import java.util.concurrent.atomic.AtomicReference; |
| 116 | +import java.util.function.Supplier; |
106 | 117 | import java.util.regex.Pattern; |
107 | 118 | import java.util.zip.GZIPOutputStream; |
108 | 119 |
|
109 | 120 | import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED; |
| 121 | +import static org.testng.Assert.assertTrue; |
110 | 122 | import static org.testng.Assert.fail; |
111 | 123 |
|
112 | 124 | @Test(groups = {"integration"}) |
@@ -2532,4 +2544,173 @@ private static String toPem(X509Certificate certificate) throws Exception { |
2532 | 2544 | } |
2533 | 2545 | return stringWriter.toString(); |
2534 | 2546 | } |
| 2547 | + |
| 2548 | + /** |
| 2549 | + * Exercises {@code HttpAPIClientHelper.TransportRequestImpl#cancel()}, which is not reachable through the |
| 2550 | + * high-level {@link Client} API. A {@link HttpAPIClientHelper} is created directly and used to start a request |
| 2551 | + * for an effectively endless stream (reading from {@code system.numbers}) to emulate a long-running query. |
| 2552 | + * The test verifies the whole life cycle against the server: |
| 2553 | + * <ol> |
| 2554 | + * <li>the query is observed running on the server ({@code system.processes});</li> |
| 2555 | + * <li>the in-flight request is cancelled from another thread and the reader stops with an error;</li> |
| 2556 | + * <li>the query is no longer running on the server;</li> |
| 2557 | + * <li>{@code system.query_log} contains a record proving the query was interrupted (not finished).</li> |
| 2558 | + * </ol> |
| 2559 | + */ |
| 2560 | + @Test(groups = {"integration"}) |
| 2561 | + @SuppressWarnings("java:S2925") |
| 2562 | + public void testTransportRequestCancel() throws Exception { |
| 2563 | + if (isCloud()) { |
| 2564 | + return; // relies on direct transport access and local system tables (processes / query_log) |
| 2565 | + } |
| 2566 | + |
| 2567 | + ClickHouseNode server = getServer(ClickHouseProtocol.HTTP); |
| 2568 | + String queryId = "transport-cancel-" + UUID.randomUUID(); |
| 2569 | + |
| 2570 | + Map<String, Object> configuration = new HashMap<>(); |
| 2571 | + configuration.put(ClientConfigProperties.USER.getKey(), "default"); |
| 2572 | + configuration.put(ClientConfigProperties.PASSWORD.getKey(), ClickHouseServerForTest.getPassword()); |
| 2573 | + configuration.put(ClientConfigProperties.DATABASE.getKey(), ClickHouseServerForTest.getDatabase()); |
| 2574 | + configuration.put(ClientConfigProperties.COMPRESS_SERVER_RESPONSE.getKey(), Boolean.FALSE); |
| 2575 | + configuration.put(ClientConfigProperties.COMPRESS_CLIENT_REQUEST.getKey(), Boolean.FALSE); |
| 2576 | + |
| 2577 | + HttpAPIClientHelper helper = new HttpAPIClientHelper(new HashMap<>(configuration), null, false, |
| 2578 | + LZ4Factory.fastestInstance()); |
| 2579 | + |
| 2580 | + try (Client verifyClient = new Client.Builder() |
| 2581 | + .addEndpoint(Protocol.HTTP, server.getHost(), server.getPort(), false) |
| 2582 | + .setUsername("default") |
| 2583 | + .setPassword(ClickHouseServerForTest.getPassword()) |
| 2584 | + .setDefaultDatabase(ClickHouseServerForTest.getDatabase()) |
| 2585 | + .compressClientRequest(false) |
| 2586 | + .compressServerResponse(false) |
| 2587 | + .build()) { |
| 2588 | + |
| 2589 | + Endpoint endpoint = new HttpEndpoint(server.getHost(), server.getPort(), false, "/"); |
| 2590 | + |
| 2591 | + Map<String, Object> requestConfig = new HashMap<>(configuration); |
| 2592 | + requestConfig.put(ClientConfigProperties.INPUT_OUTPUT_FORMAT.getKey(), ClickHouseFormat.TSV); |
| 2593 | + requestConfig.put(ClientConfigProperties.QUERY_ID.getKey(), queryId); |
| 2594 | + |
| 2595 | + // Endless result set so the query stays active on the server until the request is cancelled. |
| 2596 | + TransportRequest request = helper.createRequest(endpoint, requestConfig, |
| 2597 | + "SELECT number FROM system.numbers"); |
| 2598 | + |
| 2599 | + AtomicBoolean readStarted = new AtomicBoolean(false); |
| 2600 | + AtomicLong bytesRead = new AtomicLong(0); |
| 2601 | + AtomicReference<Throwable> readError = new AtomicReference<>(); |
| 2602 | + |
| 2603 | + Thread reader = new Thread(() -> { |
| 2604 | + long start = System.currentTimeMillis(); |
| 2605 | + try (TransportResponse response = helper.executeRequest(request); |
| 2606 | + InputStream in = response.createDataInputStream()) { |
| 2607 | + byte[] buffer = new byte[8192]; |
| 2608 | + int read; |
| 2609 | + while ((read = in.read(buffer)) != -1) { |
| 2610 | + readStarted.set(true); |
| 2611 | + bytesRead.addAndGet(read); |
| 2612 | + // Safety valve so the test can never hang on the endless stream if cancel() fails. |
| 2613 | + if (System.currentTimeMillis() - start > 30_000) { |
| 2614 | + break; |
| 2615 | + } |
| 2616 | + } |
| 2617 | + } catch (Throwable t) { |
| 2618 | + readStarted.set(true); |
| 2619 | + readError.set(t); |
| 2620 | + } |
| 2621 | + }, "transport-request-reader"); |
| 2622 | + reader.start(); |
| 2623 | + |
| 2624 | + // 1. The query must actually be running on the server. |
| 2625 | + Assert.assertTrue(waitForCondition(() -> isQueryRunning(verifyClient, queryId), 20_000), |
| 2626 | + "query was not observed running on the server (query_id=" + queryId + ")"); |
| 2627 | + Assert.assertNull(readError.get(), "reading should still be in progress while the query runs"); |
| 2628 | + |
| 2629 | + // 2. Cancel the in-flight request from a separate thread. |
| 2630 | + AtomicBoolean cancelled = new AtomicBoolean(false); |
| 2631 | + AtomicReference<Throwable> cancelError = new AtomicReference<>(); |
| 2632 | + Thread canceller = new Thread(() -> { |
| 2633 | + try { |
| 2634 | + cancelled.set(request.cancel()); |
| 2635 | + } catch (Throwable t) { |
| 2636 | + cancelError.set(t); |
| 2637 | + } |
| 2638 | + }, "transport-request-canceller"); |
| 2639 | + canceller.start(); |
| 2640 | + canceller.join(5_000); |
| 2641 | + |
| 2642 | + Assert.assertNull(cancelError.get(), "cancel() must not throw"); |
| 2643 | + Assert.assertTrue(cancelled.get(), "cancel() should report the request as cancelled"); |
| 2644 | + |
| 2645 | + // The reader must stop with an error once the underlying connection is aborted. |
| 2646 | + reader.join(15_000); |
| 2647 | + Assert.assertFalse(reader.isAlive(), "reading must stop after the request was cancelled"); |
| 2648 | + Assert.assertNotNull(readError.get(), |
| 2649 | + "reading a cancelled request must fail, but read " + bytesRead.get() + " bytes"); |
| 2650 | + |
| 2651 | + // 3. The server must stop running the query shortly after the client disconnects. |
| 2652 | + Assert.assertTrue(waitForCondition(() -> !isQueryRunning(verifyClient, queryId), 20_000), |
| 2653 | + "query is still running on the server after cancellation (query_id=" + queryId + ")"); |
| 2654 | + |
| 2655 | + // 4. The query_log must show the query was interrupted instead of finishing successfully. |
| 2656 | + assertQueryWasInterrupted(verifyClient, queryId); |
| 2657 | + assertTrue(request.cancel()); |
| 2658 | + } finally { |
| 2659 | + helper.close(); |
| 2660 | + } |
| 2661 | + } |
| 2662 | + |
| 2663 | + private static boolean isQueryRunning(Client client, String queryId) { |
| 2664 | + List<GenericRecord> rows = client.queryAll( |
| 2665 | + "SELECT count() AS c FROM system.processes WHERE query_id = '" + queryId + "'"); |
| 2666 | + return !rows.isEmpty() && rows.get(0).getLong("c") > 0; |
| 2667 | + } |
| 2668 | + |
| 2669 | + private static boolean waitForCondition(Supplier<Boolean> condition, long timeoutMillis) |
| 2670 | + throws InterruptedException { |
| 2671 | + long deadline = System.currentTimeMillis() + timeoutMillis; |
| 2672 | + while (System.currentTimeMillis() < deadline) { |
| 2673 | + try { |
| 2674 | + if (Boolean.TRUE.equals(condition.get())) { |
| 2675 | + return true; |
| 2676 | + } |
| 2677 | + } catch (Exception ignore) { |
| 2678 | + // transient query failures (e.g. server busy) are retried until the timeout elapses |
| 2679 | + } |
| 2680 | + Thread.sleep(100); |
| 2681 | + } |
| 2682 | + return false; |
| 2683 | + } |
| 2684 | + |
| 2685 | + private static void assertQueryWasInterrupted(Client client, String queryId) throws InterruptedException { |
| 2686 | + long deadline = System.currentTimeMillis() + 30_000; |
| 2687 | + String seenTypes = ""; |
| 2688 | + while (System.currentTimeMillis() < deadline) { |
| 2689 | + client.queryAll("SYSTEM FLUSH LOGS"); |
| 2690 | + List<GenericRecord> rows = client.queryAll( |
| 2691 | + "SELECT toString(type) AS type, exception_code FROM system.query_log " + |
| 2692 | + "WHERE query_id = '" + queryId + "' AND event_date >= today() - 1"); |
| 2693 | + |
| 2694 | + StringBuilder types = new StringBuilder(); |
| 2695 | + for (GenericRecord row : rows) { |
| 2696 | + String type = row.getString("type"); |
| 2697 | + int exceptionCode = row.getInteger("exception_code"); |
| 2698 | + types.append(type).append('(').append(exceptionCode).append(") "); |
| 2699 | + |
| 2700 | + Assert.assertNotEquals(type, "QueryFinish", |
| 2701 | + "query unexpectedly finished successfully instead of being interrupted (query_id=" |
| 2702 | + + queryId + ")"); |
| 2703 | + if ("ExceptionWhileProcessing".equals(type)) { |
| 2704 | + Assert.assertNotEquals(exceptionCode, 0, |
| 2705 | + "an interrupted query must be logged with a non-zero exception code (query_id=" |
| 2706 | + + queryId + ")"); |
| 2707 | + return; // found the proof the query was interrupted on the server |
| 2708 | + } |
| 2709 | + } |
| 2710 | + seenTypes = types.toString(); |
| 2711 | + Thread.sleep(250); |
| 2712 | + } |
| 2713 | + Assert.fail("no query_log record proving the query was interrupted (query_id=" + queryId |
| 2714 | + + ", seen types: [" + seenTypes + "])"); |
| 2715 | + } |
2535 | 2716 | } |
0 commit comments