Version: duckdb_jdbc 1.5.5.0 (also present in this repo's current source). Tested on linux/aarch64, Temurin 25.
Summary
DuckDBPreparedStatement.getResultSet() nulls its own selectResult field before returning:
// getResultSet can only be called once per result
ResultSet to_return = selectResult;
this.selectResult = null;
return to_return;
(https://github.com/duckdb/duckdb-java/blob/main/src/main/java/org/duckdb/DuckDBPreparedStatement.java#L605-L618)
close() calls clearResults() (https://github.com/duckdb/duckdb-java/blob/main/src/main/java/org/duckdb/DuckDBPreparedStatement.java#L1373-L1382), which only frees selectResult when it's non-null. So once getResultSet() detaches the result, it can only ever be freed if the caller explicitly closes the ResultSet.
That means e.g. this standard JDBC 4.3 compliant code leaks the native result per query:
Connection c = DriverManager.getConnection("jdbc:duckdb:");
PreparedStatement s = c.prepareStatement("SELECT 1");
for (int i = 0; i < 100_000; i++) {
s.execute();
ResultSet rs = s.getResultSet(); // ownership detached from s
// rs never closed — closing a Statement must close its ResultSets (JDBC 4.3)
}
s.close();
// each iteration orphans a MaterializedQueryResult; native memory grows linearly
executeQuery() is unaffected — it returns selectResult without nulling it (https://github.com/duckdb/duckdb-java/blob/main/src/main/java/org/duckdb/DuckDBPreparedStatement.java#L313-L320) — which is also how we know the nulling itself is the trigger.
Impact
Any JDBC wrapper that uses execute() + getResultSet() and relies on statement close to clean up results leaks on every query — next.jdbc and Spring's JdbcTemplate both do this. In our production service this retained a MaterializedQueryResult per query, growing native memory (invisible to the JVM heap and to duckdb_memory()) until OOM.
Note
I spent many many hours trying to figure out a memory leak issue for my OSS tool: https://github.com/o11ylite/o11ylite.
I initially thought it was upstream DuckDB issue, which I did discover one/two leaks and @dentiny helped fixing them but it turned out leak still happens after v1.5.5. And that's when I start suspecting my Clojure/Java harness is the culprit.
Version:
duckdb_jdbc1.5.5.0 (also present in this repo's current source). Tested on linux/aarch64, Temurin 25.Summary
DuckDBPreparedStatement.getResultSet()nulls its ownselectResultfield before returning:(https://github.com/duckdb/duckdb-java/blob/main/src/main/java/org/duckdb/DuckDBPreparedStatement.java#L605-L618)
close()callsclearResults()(https://github.com/duckdb/duckdb-java/blob/main/src/main/java/org/duckdb/DuckDBPreparedStatement.java#L1373-L1382), which only freesselectResultwhen it's non-null. So oncegetResultSet()detaches the result, it can only ever be freed if the caller explicitly closes theResultSet.That means e.g. this standard JDBC 4.3 compliant code leaks the native result per query:
executeQuery()is unaffected — it returnsselectResultwithout nulling it (https://github.com/duckdb/duckdb-java/blob/main/src/main/java/org/duckdb/DuckDBPreparedStatement.java#L313-L320) — which is also how we know the nulling itself is the trigger.Impact
Any JDBC wrapper that uses
execute()+getResultSet()and relies on statement close to clean up results leaks on every query —next.jdbcand Spring'sJdbcTemplateboth do this. In our production service this retained aMaterializedQueryResultper query, growing native memory (invisible to the JVM heap and toduckdb_memory()) until OOM.Note
I spent many many hours trying to figure out a memory leak issue for my OSS tool: https://github.com/o11ylite/o11ylite.
I initially thought it was upstream DuckDB issue, which I did discover one/two leaks and @dentiny helped fixing them but it turned out leak still happens after v1.5.5. And that's when I start suspecting my Clojure/Java harness is the culprit.