Skip to content

Commit 84820eb

Browse files
committed
sync api: cancelled queries when the calling thread is interrupted
fixes elastic#1195
1 parent 056ed9c commit 84820eb

2 files changed

Lines changed: 149 additions & 1 deletion

File tree

java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
import java.util.Map;
6363
import java.util.Set;
6464
import java.util.concurrent.CompletableFuture;
65+
import java.util.concurrent.ExecutionException;
6566

6667
public abstract class ElasticsearchTransportBase implements ElasticsearchTransport {
6768

@@ -150,7 +151,21 @@ public final <RequestT, ResponseT, ErrorT> ResponseT performRequest(
150151
TransportHttpClient.Request req = prepareTransportRequest(request, endpoint);
151152
ctx.beforeSendingHttpRequest(req, options);
152153

153-
TransportHttpClient.Response resp = httpClient.performRequest(endpoint.id(), null, req, opts);
154+
CompletableFuture<TransportHttpClient.Response> future =
155+
httpClient.performRequestAsync(endpoint.id(), null, req, opts);
156+
TransportHttpClient.Response resp;
157+
try {
158+
resp = future.get();
159+
} catch (InterruptedException e) {
160+
future.cancel(true);
161+
Thread.currentThread().interrupt();
162+
throw new IOException("request was interrupted", e);
163+
} catch (ExecutionException e) {
164+
Throwable cause = e.getCause();
165+
if (cause instanceof IOException ioe) throw ioe;
166+
if (cause instanceof RuntimeException re) throw re;
167+
throw new IOException(cause);
168+
}
154169
ctx.afterReceivingHttpResponse(resp);
155170

156171
ResponseT apiResponse = getApiResponse(resp, endpoint);
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package co.elastic.clients.transport;
21+
22+
import co.elastic.clients.elasticsearch.ElasticsearchAsyncClient;
23+
import co.elastic.clients.elasticsearch.ElasticsearchClient;
24+
import co.elastic.clients.elasticsearch.ElasticsearchTestClient;
25+
import com.sun.net.httpserver.HttpServer;
26+
import org.junit.jupiter.api.Assertions;
27+
import org.junit.jupiter.api.Test;
28+
import org.junit.jupiter.api.Timeout;
29+
30+
import java.io.IOException;
31+
import java.io.OutputStream;
32+
import java.net.InetAddress;
33+
import java.net.InetSocketAddress;
34+
import java.util.Collections;
35+
import java.util.concurrent.CompletableFuture;
36+
import java.util.concurrent.CountDownLatch;
37+
import java.util.concurrent.TimeUnit;
38+
import java.util.concurrent.locks.LockSupport;
39+
40+
/**
41+
* Verifies that cancelling an in-flight ES client request closes the underlying HTTP connection.
42+
*/
43+
public class InterruptSyncRequestTest extends Assertions {
44+
45+
/**
46+
* Creates an HttpServer whose {@code /_count} handler streams bytes until
47+
* the client disconnects (causing a write failure) or 30 seconds elapse.
48+
* If the client disconnects, the resulting {@code IOException} is delivered
49+
* via {@code serverException}.
50+
*/
51+
private static HttpServer createSlowServer(
52+
CountDownLatch requestArrived, CompletableFuture<Exception> serverException
53+
) throws Exception {
54+
HttpServer httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
55+
56+
httpServer.createContext("/_count", exchange -> {
57+
try (exchange; OutputStream out = exchange.getResponseBody()) {
58+
exchange.getResponseHeaders().put("X-Elastic-Product", Collections.singletonList("Elasticsearch"));
59+
exchange.getResponseHeaders().put("Content-Type", Collections.singletonList("application/json"));
60+
exchange.sendResponseHeaders(200, 0);
61+
62+
requestArrived.countDown();
63+
64+
// Stream data slowly for 30 seconds. Throws IOException on client disconnect.
65+
for (int i = 0; i < 30_000; i++) {
66+
out.write(' ');
67+
out.flush();
68+
LockSupport.parkNanos(1_000_000L);
69+
}
70+
} catch (Exception e) {
71+
serverException.complete(e);
72+
} finally {
73+
serverException.complete(null);
74+
}
75+
});
76+
77+
return httpServer;
78+
}
79+
80+
@Test
81+
@Timeout(10)
82+
public void syncRequestShouldStopWhenCallingThreadIsInterrupted() throws Exception {
83+
CountDownLatch requestArrived = new CountDownLatch(1);
84+
CompletableFuture<Exception> serverException = new CompletableFuture<>();
85+
86+
HttpServer httpServer = createSlowServer(requestArrived, serverException);
87+
httpServer.start();
88+
try {
89+
ElasticsearchClient client = ElasticsearchTestClient.createClient(httpServer, null);
90+
91+
Thread clientThread = new Thread(() -> {
92+
try { client.count(); } catch (Exception e) { }
93+
});
94+
clientThread.start();
95+
96+
assertTrue(requestArrived.await(5, TimeUnit.SECONDS));
97+
clientThread.interrupt();
98+
99+
// The server should get an IOException from writing to the closed connection.
100+
// If the connection leaked, the future completes with null after the handler finishes.
101+
assertInstanceOf(IOException.class, serverException.join(),
102+
"HTTP connection should have been closed after thread interruption");
103+
} finally {
104+
httpServer.stop(0);
105+
}
106+
}
107+
108+
@Test
109+
@Timeout(10)
110+
public void asyncRequestShouldStopWhenFutureIsCancelled() throws Exception {
111+
CountDownLatch requestArrived = new CountDownLatch(1);
112+
CompletableFuture<Exception> serverException = new CompletableFuture<>();
113+
114+
HttpServer httpServer = createSlowServer(requestArrived, serverException);
115+
httpServer.start();
116+
try {
117+
ElasticsearchClient syncClient = ElasticsearchTestClient.createClient(httpServer, null);
118+
ElasticsearchAsyncClient client = new ElasticsearchAsyncClient(syncClient._transport());
119+
120+
CompletableFuture<?> future = client.count();
121+
122+
assertTrue(requestArrived.await(5, TimeUnit.SECONDS));
123+
future.cancel(true);
124+
125+
// The server should get an IOException from writing to the closed connection.
126+
// If the connection leaked, the future completes with null after the handler finishes.
127+
assertInstanceOf(IOException.class, serverException.join(),
128+
"HTTP connection should have been closed after future cancellation");
129+
} finally {
130+
httpServer.stop(0);
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)