Skip to content

Commit 23a9824

Browse files
committed
feat(net): add application-level TCP connect timeout
Establish a real, cross-platform connect timeout for the HTTP and WebSocket (QWP) transports. Previously a connect to a black-holed or firewalled host blocked on the OS-level TCP connect timeout (often 60-120s) because the socket was created blocking and only switched to non-blocking *after* connect; the transport exposed no knob to clamp it. Approach: a new native primitive switches the socket to non-blocking *before* connect, so connect() returns EINPROGRESS immediately, then polls for writability bounded by the caller's budget and confirms the outcome via SO_ERROR. A distinct return code (CONNECT_TIMEOUT, -3) lets the Java layer raise a timeout-flagged exception rather than decode errno. Native: - share/net.c: connectAddrInfoTimeout + awaitConnectComplete (poll + getsockopt(SO_ERROR), monotonic-clock EINTR handling) - windows/net.c: Winsock equivalent (select write/except sets) - share/net.h: ECONNTIMEOUT (-3) sentinel Java: - Net / NetworkFacade(Impl): connectAddrInfoTimeout + CONNECT_TIMEOUT - HttpClientConfiguration.getConnectTimeout() (default 0 = OS fallback) - HttpClient.connect() / WebSocketClient.doConnect() honor it and throw a timeout-flagged HttpClientException on CONNECT_TIMEOUT - Sender builder: connectTimeoutMillis() + connect_timeout connect-string key (legacy http and ws/wss parsers) + ConfigSchema COMMON key - QwpWebSocketSender / QwpQueryClient: thread the value through to their WebSocketClient (adds QwpQueryClient.withConnectTimeout) Default is unset (0): behaviour is unchanged unless connect_timeout is configured. Tests: NetConnectTimeoutTest covers loopback success, refused-vs-timeout disambiguation, and a black-hole timeout that fires within budget; config-honored drift guards updated for the new COMMON key.
1 parent 51ed8ea commit 23a9824

16 files changed

Lines changed: 400 additions & 3 deletions

File tree

core/src/main/c/share/net.c

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
#include <stdlib.h>
3434
#include <stdint.h>
3535
#include <string.h>
36+
#include <poll.h>
37+
#include <time.h>
3638
#include "net.h"
3739
#include <netdb.h>
3840
#include "sysutil.h"
@@ -298,6 +300,85 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_connectAddrInfo
298300
return handleEintrInConnect(fd, result);
299301
}
300302

303+
// Waits up to timeout_millis for an in-progress non-blocking connect on fd to
304+
// finish. Returns 0 on success, -1 on connection failure (errno set so the
305+
// caller can read it via Os.errno()), or com_questdb_network_Net_ECONNTIMEOUT
306+
// on timeout.
307+
static jint awaitConnectComplete(int fd, jint timeout_millis) {
308+
struct timespec start;
309+
clock_gettime(CLOCK_MONOTONIC, &start);
310+
311+
for (;;) {
312+
struct pollfd pfd;
313+
pfd.fd = fd;
314+
pfd.events = POLLOUT;
315+
pfd.revents = 0;
316+
317+
int wait_millis = timeout_millis > 0 ? timeout_millis : 0;
318+
int rc = poll(&pfd, 1, wait_millis);
319+
if (rc > 0) {
320+
// The connect attempt has finished one way or another; the only
321+
// authoritative result is SO_ERROR (POLLOUT alone does not mean
322+
// success -- a refused connection is also reported as writable).
323+
int so_error = 0;
324+
socklen_t len = sizeof(so_error);
325+
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &so_error, &len) < 0) {
326+
return -1;
327+
}
328+
if (so_error != 0) {
329+
errno = so_error;
330+
return -1;
331+
}
332+
return 0;
333+
}
334+
if (rc == 0) {
335+
errno = ETIMEDOUT;
336+
return com_questdb_network_Net_ECONNTIMEOUT;
337+
}
338+
if (errno != EINTR) {
339+
return -1;
340+
}
341+
// Interrupted by a signal: recompute the remaining budget against a
342+
// monotonic clock so EINTR storms cannot extend the timeout, and retry.
343+
struct timespec now;
344+
clock_gettime(CLOCK_MONOTONIC, &now);
345+
long elapsed = (now.tv_sec - start.tv_sec) * 1000L
346+
+ (now.tv_nsec - start.tv_nsec) / 1000000L;
347+
start = now;
348+
timeout_millis = (jint) (timeout_millis - elapsed);
349+
if (timeout_millis <= 0) {
350+
errno = ETIMEDOUT;
351+
return com_questdb_network_Net_ECONNTIMEOUT;
352+
}
353+
}
354+
}
355+
356+
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_connectAddrInfoTimeout
357+
(JNIEnv *e, jclass cl, jint fd, jlong lpAddrInfo, jint timeoutMillis) {
358+
struct addrinfo *addr = (struct addrinfo *) lpAddrInfo;
359+
360+
// Switch to non-blocking BEFORE connect so connect() returns immediately
361+
// with EINPROGRESS instead of blocking on the OS connect timeout. The
362+
// socket is left non-blocking on success, matching the post-connect
363+
// configureNonBlocking() the callers already perform.
364+
int flags = fcntl((int) fd, F_GETFL, 0);
365+
if (flags < 0) {
366+
return -1;
367+
}
368+
if (fcntl((int) fd, F_SETFL, flags | O_NONBLOCK) < 0) {
369+
return -1;
370+
}
371+
372+
int result = connect((int) fd, addr->ai_addr, (int) addr->ai_addrlen);
373+
if (result == 0) {
374+
return 0; // connected immediately (e.g. loopback)
375+
}
376+
if (errno == EINPROGRESS || errno == EINTR || errno == EWOULDBLOCK) {
377+
return awaitConnectComplete((int) fd, timeoutMillis);
378+
}
379+
return -1; // immediate failure, errno set
380+
}
381+
301382
JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_freeAddrInfo0
302383
(JNIEnv *e, jclass cl, jlong address) {
303384
if (address != 0) {

core/src/main/c/share/net.h

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/src/main/c/windows/net.c

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,66 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_connectAddrInfo
160160
return res;
161161
}
162162

163+
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_connectAddrInfoTimeout
164+
(JNIEnv *e, jclass cl, jint fd, jlong lpAddrInfo, jint timeoutMillis) {
165+
struct addrinfo *addr = (struct addrinfo *) lpAddrInfo;
166+
SOCKET s = (SOCKET) fd;
167+
168+
// Switch to non-blocking BEFORE connect so it returns immediately with
169+
// WSAEWOULDBLOCK instead of blocking on the OS connect timeout.
170+
u_long mode = 1;
171+
if (ioctlsocket(s, FIONBIO, &mode) != 0) {
172+
SaveLastError();
173+
return -1;
174+
}
175+
176+
int res = connect(s, addr->ai_addr, (int) addr->ai_addrlen);
177+
if (res == 0) {
178+
return 0; // connected immediately (e.g. loopback)
179+
}
180+
if (WSAGetLastError() != WSAEWOULDBLOCK) {
181+
SaveLastError();
182+
return -1;
183+
}
184+
185+
fd_set writefds, exceptfds;
186+
FD_ZERO(&writefds);
187+
FD_ZERO(&exceptfds);
188+
FD_SET(s, &writefds);
189+
FD_SET(s, &exceptfds);
190+
191+
struct timeval tv;
192+
tv.tv_sec = timeoutMillis / 1000;
193+
tv.tv_usec = (timeoutMillis % 1000) * 1000;
194+
195+
// Winsock signals a failed non-blocking connect via the exception set.
196+
int sel = select(0, NULL, &writefds, &exceptfds, &tv);
197+
if (sel == 0) {
198+
WSASetLastError(WSAETIMEDOUT);
199+
SaveLastError();
200+
return com_questdb_network_Net_ECONNTIMEOUT;
201+
}
202+
if (sel == SOCKET_ERROR) {
203+
SaveLastError();
204+
return -1;
205+
}
206+
207+
int so_error = 0;
208+
int len = sizeof(so_error);
209+
if (FD_ISSET(s, &exceptfds) || !FD_ISSET(s, &writefds)) {
210+
getsockopt(s, SOL_SOCKET, SO_ERROR, (char *) &so_error, &len);
211+
WSASetLastError(so_error != 0 ? so_error : WSAECONNREFUSED);
212+
SaveLastError();
213+
return -1;
214+
}
215+
if (getsockopt(s, SOL_SOCKET, SO_ERROR, (char *) &so_error, &len) == 0 && so_error != 0) {
216+
WSASetLastError(so_error);
217+
SaveLastError();
218+
return -1;
219+
}
220+
return 0;
221+
}
222+
163223
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_configureNonBlocking
164224
(JNIEnv *e, jclass cl, jint fd) {
165225
u_long mode = 1;

core/src/main/java/io/questdb/client/HttpClientConfiguration.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ default boolean fixBrokenConnection() {
3838
return true;
3939
}
4040

41+
/**
42+
* Upper bound, in milliseconds, on establishing the TCP connection. When
43+
* {@code <= 0} (the default) no application-level connect timeout is applied
44+
* and the connect falls back to the OS-level TCP connect timeout.
45+
*/
46+
default int getConnectTimeout() {
47+
return 0;
48+
}
49+
4150
default EpollFacade getEpollFacade() {
4251
return EpollFacadeImpl.INSTANCE;
4352
}

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,9 @@ final class LineSenderBuilder {
10111011
private int autoFlushRows = PARAMETER_NOT_SET_EXPLICITLY;
10121012
private int bufferCapacity = PARAMETER_NOT_SET_EXPLICITLY;
10131013
private long closeFlushTimeoutMillis = CLOSE_FLUSH_TIMEOUT_NOT_SET;
1014+
// Upper bound (ms) on the TCP connect. PARAMETER_NOT_SET_EXPLICITLY ->
1015+
// 0 (no application-level connect timeout; OS connect timeout applies).
1016+
private int connectTimeoutMillis = PARAMETER_NOT_SET_EXPLICITLY;
10141017
// Optional user-supplied async connection-event listener. When null,
10151018
// the sender uses DefaultSenderConnectionListener.INSTANCE
10161019
// (loud-not-silent log of every transition).
@@ -1078,6 +1081,11 @@ public String getSettingsPath() {
10781081
public int getTimeout() {
10791082
return httpTimeout == PARAMETER_NOT_SET_EXPLICITLY ? DEFAULT_HTTP_TIMEOUT : httpTimeout;
10801083
}
1084+
1085+
@Override
1086+
public int getConnectTimeout() {
1087+
return connectTimeoutMillis == PARAMETER_NOT_SET_EXPLICITLY ? 0 : connectTimeoutMillis;
1088+
}
10811089
};
10821090
private long minRequestThroughput = PARAMETER_NOT_SET_EXPLICITLY;
10831091
private int multicastTtl = PARAMETER_NOT_SET_EXPLICITLY;
@@ -1199,6 +1207,28 @@ public AdvancedTlsSettings advancedTls() {
11991207
return new AdvancedTlsSettings();
12001208
}
12011209

1210+
/**
1211+
* Upper bound, in milliseconds, on establishing the TCP connection to a
1212+
* QuestDB endpoint. When set, a connect that does not complete within
1213+
* this budget is aborted (instead of riding the much longer OS-level
1214+
* connect timeout). Applies to both HTTP/WebSocket transports. Default
1215+
* is unset (0), which falls back to the OS connect timeout.
1216+
*
1217+
* @param millis connect timeout in milliseconds; must be &gt; 0
1218+
* @return this instance for method chaining
1219+
*/
1220+
public LineSenderBuilder connectTimeoutMillis(int millis) {
1221+
if (this.connectTimeoutMillis != PARAMETER_NOT_SET_EXPLICITLY) {
1222+
throw new LineSenderException("connect timeout was already configured ")
1223+
.put("[connect_timeout=").put(this.connectTimeoutMillis).put("]");
1224+
}
1225+
if (millis <= 0) {
1226+
throw new LineSenderException("connect_timeout must be > 0: ").put(millis);
1227+
}
1228+
this.connectTimeoutMillis = millis;
1229+
return this;
1230+
}
1231+
12021232
/**
12031233
* Per-endpoint timeout on the WebSocket upgrade response read. Default
12041234
* {@value QwpWebSocketSender#DEFAULT_AUTH_TIMEOUT_MS} ms.
@@ -1531,6 +1561,7 @@ public Sender build() {
15311561
actualErrorInboxCapacity,
15321562
actualDurableAckKeepaliveIntervalMillis,
15331563
authTimeoutMillis,
1564+
connectTimeoutMillis == PARAMETER_NOT_SET_EXPLICITLY ? 0 : connectTimeoutMillis,
15341565
connectionListener,
15351566
actualConnectionListenerInboxCapacity
15361567
);
@@ -3166,6 +3197,9 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
31663197
pos = getValue(configurationString, pos, sink, "request_timeout");
31673198
int requestTimeout = parseIntValue(sink, "request_timeout");
31683199
httpTimeoutMillis(requestTimeout);
3200+
} else if (Chars.equals("connect_timeout", sink)) {
3201+
pos = getValue(configurationString, pos, sink, "connect_timeout");
3202+
connectTimeoutMillis(parseIntValue(sink, "connect_timeout"));
31693203
} else if (Chars.equals("request_min_throughput", sink)) {
31703204
pos = getValue(configurationString, pos, sink, "request_min_throughput");
31713205
int requestMinThroughput = parseIntValue(sink, "request_min_throughput");
@@ -3446,6 +3480,9 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString)
34463480
if (view.has("auth_timeout_ms")) {
34473481
authTimeoutMillis(view.getLong("auth_timeout_ms", 0));
34483482
}
3483+
if (view.has("connect_timeout")) {
3484+
connectTimeoutMillis((int) view.getLong("connect_timeout", 0));
3485+
}
34493486

34503487
s = view.getStr("auto_flush_rows");
34513488
if (s != null) {
@@ -3701,6 +3738,7 @@ public java.util.Map<String, Object> wsConfigSnapshotForTest() {
37013738
m.put("connection_listener_inbox_capacity", connectionListenerInboxCapacity);
37023739
m.put("token", httpToken);
37033740
m.put("auth_timeout_ms", authTimeoutMillis);
3741+
m.put("connect_timeout", connectTimeoutMillis == PARAMETER_NOT_SET_EXPLICITLY ? 0 : connectTimeoutMillis);
37043742
m.put("username", username);
37053743
m.put("password", password);
37063744
m.put("tls_verify", tlsValidationMode == null ? null : tlsValidationMode.name());

core/src/main/java/io/questdb/client/cutlass/http/client/HttpClient.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public abstract class HttpClient implements QuietCloseable {
6666
protected final NetworkFacade nf;
6767
protected final Socket socket;
6868
private final ObjectPool<DirectUtf8String> csPool = new ObjectPool<>(DirectUtf8String.FACTORY, 64);
69+
private final int connectTimeout;
6970
private final int defaultTimeout;
7071
private final boolean fixBrokenConnection;
7172
private final int maxBufferSize;
@@ -84,6 +85,7 @@ public HttpClient(HttpClientConfiguration configuration, SocketFactory socketFac
8485
this.nf = configuration.getNetworkFacade();
8586
this.socket = socketFactory.newInstance(nf, LOG);
8687
this.defaultTimeout = configuration.getTimeout();
88+
this.connectTimeout = configuration.getConnectTimeout();
8789
this.bufferSize = configuration.getInitialRequestBufferSize();
8890
this.maxBufferSize = configuration.getMaximumRequestBufferSize();
8991
this.responseParserBufSize = configuration.getResponseBufferSize();
@@ -617,10 +619,16 @@ private void connect(CharSequence host, int port) {
617619
throw new HttpClientException("could not resolve host ").put("[host=").put(host).put("]");
618620
}
619621

620-
if (nf.connectAddrInfo(fd, addrInfo) != 0) {
622+
final int connectResult = connectTimeout > 0
623+
? nf.connectAddrInfoTimeout(fd, addrInfo, connectTimeout)
624+
: nf.connectAddrInfo(fd, addrInfo);
625+
if (connectResult != 0) {
621626
int errno = nf.errno();
622627
nf.freeAddrInfo(addrInfo);
623628
disconnect();
629+
if (connectResult == NetworkFacade.CONNECT_TIMEOUT) {
630+
throw new HttpClientException("connect timed out ").put("[host=").put(host).put(", port=").put(port).put(", timeout=").put(connectTimeout).put(']').flagAsTimeout();
631+
}
624632
throw new HttpClientException("could not connect to host ").put("[host=").put(host).put(", port=").put(port).put(", errno=").put(errno).put(']');
625633
}
626634
nf.freeAddrInfo(addrInfo);

core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ public abstract class WebSocketClient implements QuietCloseable {
101101
private final WebSocketSendBuffer sendBuffer;
102102
// volatile: written by user thread in close(), read by I/O thread in checkConnected()/sendFrame()/receiveFrame()
103103
private volatile boolean closed;
104+
// Upper bound (ms) on the TCP connect. <= 0 disables the application-level
105+
// timeout and falls back to the OS connect timeout. Seeded from the
106+
// configuration; the QWP sender may override it via setConnectTimeout().
107+
private int connectTimeoutMillis;
104108
private int fragmentBufPos;
105109
private long fragmentBufPtr; // native buffer for accumulating fragment payloads
106110
private int fragmentBufSize;
@@ -168,6 +172,7 @@ public WebSocketClient(HttpClientConfiguration configuration, SocketFactory sock
168172
this.nf = configuration.getNetworkFacade();
169173
this.socket = socketFactory.newInstance(nf, LOG);
170174
this.defaultTimeout = configuration.getTimeout();
175+
this.connectTimeoutMillis = configuration.getConnectTimeout();
171176

172177
int sendBufSize = Math.max(configuration.getInitialRequestBufferSize(), DEFAULT_SEND_BUFFER_SIZE);
173178
int maxSendBufSize = Math.max(configuration.getMaximumRequestBufferSize(), sendBufSize);
@@ -481,6 +486,16 @@ public void sendPing(int timeout) {
481486
}
482487
}
483488

489+
/**
490+
* Overrides the TCP connect timeout (milliseconds) for subsequent
491+
* {@link #connect} calls. {@code <= 0} disables the application-level
492+
* timeout and falls back to the OS connect timeout. Must be called before
493+
* {@link #connect}.
494+
*/
495+
public void setConnectTimeout(int connectTimeoutMillis) {
496+
this.connectTimeoutMillis = connectTimeoutMillis;
497+
}
498+
484499
/**
485500
* Sets the value sent as the {@code X-QWP-Accept-Encoding} upgrade header,
486501
* e.g. {@code "zstd;level=1,raw"}. Pass {@code null} to omit the header
@@ -922,10 +937,18 @@ private void doConnect(CharSequence host, int port) {
922937
throw new HttpClientException("could not resolve host [host=").put(host).put(']');
923938
}
924939

925-
if (nf.connectAddrInfo(fd, addrInfo) != 0) {
940+
final int connectResult = connectTimeoutMillis > 0
941+
? nf.connectAddrInfoTimeout(fd, addrInfo, connectTimeoutMillis)
942+
: nf.connectAddrInfo(fd, addrInfo);
943+
if (connectResult != 0) {
926944
int errno = nf.errno();
927945
nf.freeAddrInfo(addrInfo);
928946
disconnect();
947+
if (connectResult == NetworkFacade.CONNECT_TIMEOUT) {
948+
throw new HttpClientException("connect timed out [host=").put(host)
949+
.put(", port=").put(port)
950+
.put(", timeout=").put(connectTimeoutMillis).put(']').flagAsTimeout();
951+
}
929952
throw new HttpClientException("could not connect [host=").put(host)
930953
.put(", port=").put(port)
931954
.put(", errno=").put(errno).put(']');

0 commit comments

Comments
 (0)