Skip to content

Commit fdd9a47

Browse files
committed
Add support for query timeouts
This change implements `Statement#setQueryTimeout()` method. It is implemented by scheduling a background task and calling `Statement#cancel()` when timeout expires. Timeouted statement has the same behaviour as it would be if cancelled manually - `SQLException` is thrown and the statement is closed. Timeout is applied for all `execute*` calls. For `executeBatch()` it is applied separately for every single query in a batch. Testing: new test added. Fixes: #212
1 parent 5f877ad commit fdd9a47

File tree

6 files changed

+80
-6
lines changed

6 files changed

+80
-6
lines changed

src/jni/duckdb_java.cpp

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,11 @@ jobject _duckdb_jdbc_execute(JNIEnv *env, jclass, jobject stmt_ref_buf, jobjectA
259259

260260
res_ref->res = stmt_ref->stmt->Execute(duckdb_params, stream_results);
261261
if (res_ref->res->HasError()) {
262-
string error_msg = string(res_ref->res->GetError());
262+
std::string error_msg = std::string(res_ref->res->GetError());
263+
duckdb::ExceptionType error_type = res_ref->res->GetErrorType();
263264
res_ref->res = nullptr;
264-
ThrowJNI(env, error_msg.c_str());
265+
jclass exc_type = duckdb::ExceptionType::INTERRUPT == error_type ? J_SQLTimeoutException : J_SQLException;
266+
env->ThrowNew(exc_type, error_msg.c_str());
265267
return nullptr;
266268
}
267269
return env->NewDirectByteBuffer(res_ref.release(), 0);

src/jni/refs.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jmethodID J_String_getBytes;
1818
jclass J_Throwable;
1919
jmethodID J_Throwable_getMessage;
2020
jclass J_SQLException;
21+
jclass J_SQLTimeoutException;
2122

2223
jclass J_Bool;
2324
jclass J_Byte;
@@ -178,6 +179,7 @@ void create_refs(JNIEnv *env) {
178179
J_Throwable = make_class_ref(env, "java/lang/Throwable");
179180
J_Throwable_getMessage = get_method_id(env, J_Throwable, "getMessage", "()Ljava/lang/String;");
180181
J_SQLException = make_class_ref(env, "java/sql/SQLException");
182+
J_SQLTimeoutException = make_class_ref(env, "java/sql/SQLTimeoutException");
181183

182184
J_Bool = make_class_ref(env, "java/lang/Boolean");
183185
J_Byte = make_class_ref(env, "java/lang/Byte");

src/jni/refs.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ extern jmethodID J_String_getBytes;
1515
extern jclass J_Throwable;
1616
extern jmethodID J_Throwable_getMessage;
1717
extern jclass J_SQLException;
18+
extern jclass J_SQLTimeoutException;
1819

1920
extern jclass J_Bool;
2021
extern jclass J_Byte;

src/main/java/org/duckdb/DuckDBDriver.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import java.nio.ByteBuffer;
66
import java.sql.*;
77
import java.util.*;
8+
import java.util.concurrent.ScheduledThreadPoolExecutor;
9+
import java.util.concurrent.ThreadFactory;
810
import java.util.concurrent.locks.ReentrantLock;
911
import java.util.logging.Logger;
1012
import java.util.regex.Pattern;
@@ -20,6 +22,8 @@ public class DuckDBDriver implements java.sql.Driver {
2022
static final String DUCKDB_URL_PREFIX = "jdbc:duckdb:";
2123
static final String MEMORY_DB = ":memory:";
2224

25+
static final ScheduledThreadPoolExecutor scheduler;
26+
2327
private static final String DUCKLAKE_OPTION = "ducklake";
2428
private static final String DUCKLAKE_ALIAS_OPTION = "ducklake_alias";
2529
private static final Pattern DUCKLAKE_ALIAS_OPTION_PATTERN = Pattern.compile("[a-zA-Z0-9_]+");
@@ -36,8 +40,11 @@ public class DuckDBDriver implements java.sql.Driver {
3640
static {
3741
try {
3842
DriverManager.registerDriver(new DuckDBDriver());
43+
ThreadFactory tf = r -> new Thread(r, "duckdb-query-cancel-scheduler-thread");
44+
scheduler = new ScheduledThreadPoolExecutor(1, tf);
45+
scheduler.setRemoveOnCancelPolicy(true);
3946
} catch (SQLException e) {
40-
e.printStackTrace();
47+
throw new RuntimeException(e);
4148
}
4249
}
4350

src/main/java/org/duckdb/DuckDBPreparedStatement.java

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static java.nio.charset.StandardCharsets.US_ASCII;
44
import static java.nio.charset.StandardCharsets.UTF_8;
5+
import static java.util.concurrent.TimeUnit.SECONDS;
56
import static org.duckdb.StatementReturnType.*;
67
import static org.duckdb.io.IOUtils.*;
78

@@ -37,6 +38,7 @@
3738
import java.util.ArrayList;
3839
import java.util.Calendar;
3940
import java.util.List;
41+
import java.util.concurrent.ScheduledFuture;
4042
import java.util.concurrent.locks.Lock;
4143
import java.util.concurrent.locks.ReentrantLock;
4244

@@ -59,6 +61,8 @@ public class DuckDBPreparedStatement implements PreparedStatement {
5961
private final List<String> batchedStatements = new ArrayList<>();
6062
private Boolean isBatch = false;
6163
private Boolean isPreparedStatement = false;
64+
private int queryTimeoutSeconds = 0;
65+
private ScheduledFuture<?> cancelQueryFuture = null;
6266

6367
public DuckDBPreparedStatement(DuckDBConnection conn) throws SQLException {
6468
if (conn == null) {
@@ -180,7 +184,14 @@ private boolean execute(boolean startTransaction) throws SQLException {
180184
startTransaction();
181185
}
182186

187+
if (queryTimeoutSeconds > 0) {
188+
cleanupCancelQueryTask();
189+
cancelQueryFuture =
190+
DuckDBDriver.scheduler.schedule(new CancelQueryTask(), queryTimeoutSeconds, SECONDS);
191+
}
192+
183193
resultRef = DuckDBNative.duckdb_jdbc_execute(stmtRef, params);
194+
cleanupCancelQueryTask();
184195
DuckDBResultSetMetaData resultMeta = DuckDBNative.duckdb_jdbc_query_result_meta(resultRef);
185196
selectResult = new DuckDBResultSet(conn, this, resultMeta, resultRef);
186197
returnsResultSet = resultMeta.return_type.equals(QUERY_RESULT);
@@ -356,6 +367,7 @@ public void close() throws SQLException {
356367
if (isClosed()) {
357368
return;
358369
}
370+
cleanupCancelQueryTask();
359371
if (selectResult != null) {
360372
selectResult.close();
361373
selectResult = null;
@@ -436,12 +448,16 @@ public void setEscapeProcessing(boolean enable) throws SQLException {
436448
@Override
437449
public int getQueryTimeout() throws SQLException {
438450
checkOpen();
439-
return 0;
451+
return queryTimeoutSeconds;
440452
}
441453

442454
@Override
443455
public void setQueryTimeout(int seconds) throws SQLException {
444456
checkOpen();
457+
if (seconds < 0) {
458+
throw new SQLException("Invalid negative timeout value: " + seconds);
459+
}
460+
this.queryTimeoutSeconds = seconds;
445461
}
446462

447463
@Override
@@ -1244,4 +1260,25 @@ private Lock getConnRefLock() throws SQLException {
12441260
throw new SQLException(e);
12451261
}
12461262
}
1263+
1264+
private void cleanupCancelQueryTask() {
1265+
if (cancelQueryFuture != null) {
1266+
cancelQueryFuture.cancel(false);
1267+
cancelQueryFuture = null;
1268+
}
1269+
}
1270+
1271+
private class CancelQueryTask implements Runnable {
1272+
@Override
1273+
public void run() {
1274+
try {
1275+
if (DuckDBPreparedStatement.this.isClosed()) {
1276+
return;
1277+
}
1278+
DuckDBPreparedStatement.this.cancel();
1279+
} catch (SQLException e) {
1280+
// suppress
1281+
}
1282+
}
1283+
}
12471284
}

src/test/java/org/duckdb/TestClosure.java

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ public static void test_statement_auto_closed_on_completion() throws Exception {
7777
public static void test_long_query_conn_close() throws Exception {
7878
Connection conn = DriverManager.getConnection(JDBC_URL);
7979
Statement stmt = conn.createStatement();
80-
stmt.execute("DROP TABLE IF EXISTS test_fib1");
8180
stmt.execute("CREATE TABLE test_fib1(i bigint, p double, f double)");
8281
stmt.execute("INSERT INTO test_fib1 values(1, 0, 1)");
8382
long start = System.currentTimeMillis();
@@ -108,7 +107,6 @@ public static void test_long_query_conn_close() throws Exception {
108107
public static void test_long_query_stmt_close() throws Exception {
109108
try (Connection conn = DriverManager.getConnection(JDBC_URL)) {
110109
Statement stmt = conn.createStatement();
111-
stmt.execute("DROP TABLE IF EXISTS test_fib1");
112110
stmt.execute("CREATE TABLE test_fib1(i bigint, p double, f double)");
113111
stmt.execute("INSERT INTO test_fib1 values(1, 0, 1)");
114112
long start = System.currentTimeMillis();
@@ -285,4 +283,31 @@ public static void test_stmt_can_only_cancel_self() throws Exception {
285283
assertFalse(stmt2.isClosed());
286284
}
287285
}
286+
287+
public static void test_stmt_query_timeout() throws Exception {
288+
try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) {
289+
stmt.setQueryTimeout(1);
290+
stmt.execute("CREATE TABLE test_fib1(i bigint, p double, f double)");
291+
stmt.execute("INSERT INTO test_fib1 values(1, 0, 1)");
292+
long start = System.currentTimeMillis();
293+
assertThrows(
294+
()
295+
-> stmt.executeQuery(
296+
"WITH RECURSIVE cte AS ("
297+
+
298+
"SELECT * from test_fib1 UNION ALL SELECT cte.i + 1, cte.f, cte.p + cte.f from cte WHERE cte.i < 150000) "
299+
+ "SELECT avg(f) FROM cte"),
300+
SQLTimeoutException.class);
301+
long elapsed = System.currentTimeMillis() - start;
302+
assertTrue(elapsed < 1500);
303+
assertFalse(conn.isClosed());
304+
assertTrue(stmt.isClosed());
305+
assertEquals(DuckDBDriver.scheduler.getQueue().size(), 0);
306+
}
307+
try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) {
308+
stmt.setQueryTimeout(1);
309+
assertThrows(() -> { stmt.execute("FAIL"); }, SQLException.class);
310+
assertEquals(DuckDBDriver.scheduler.getQueue().size(), 0);
311+
}
312+
}
288313
}

0 commit comments

Comments
 (0)