This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 5b75e21feb GROOVY-12371: bound the Sql statement cache and make it
thread-safe
5b75e21feb is described below
commit 5b75e21feb99a5676f24c3dde932af1ffae9a578
Author: Paul King <[email protected]>
AuthorDate: Sun Sep 6 15:34:05 2026 +1000
GROOVY-12371: bound the Sql statement cache and make it thread-safe
The prepared-statement cache was an unbounded HashMap keyed on the SQL
text, mutated from getAbstractStatement without synchronization. Two
problems followed. It grew without limit whenever the text varied rather
than the parameters — an inList expands to a different placeholder count
per list size, so a caller sizing the list mints a distinct cached
statement each time, held open until the Sql is closed. And a shared Sql
using the cache from more than one thread could corrupt the map.
The cache is now a synchronized, access-ordered LinkedHashMap that evicts
the least recently used statement past a cap, closing it as it goes so a
bounded cache does not leak the cursor it drops. The cap is
statementCacheSize, default 256, settable and overridable with the
groovy.sql.statement.cache.size system property; 0 or less keeps the old
unbounded behaviour for anyone who relied on it.
Statement creation stays outside the lock — only the get-then-put is
synchronized — so preparing a statement does not serialize other callers,
and a create race keeps the first result and closes the loser rather than
leaking it.
One consequence to note: with a shared Sql caching across threads, a bound
below the working set can now evict and close a statement another thread is
about to reuse. A single Sql is typically used serially or per connection;
where it is shared, size the cache above the working set (or leave it
unbounded). Default 256 is generous enough that ordinary use never evicts.
---
.../groovy-sql/src/main/java/groovy/sql/Sql.java | 96 ++++++++++++++++++----
.../src/test/groovy/groovy/sql/SqlCacheTest.groovy | 37 +++++++++
2 files changed, 117 insertions(+), 16 deletions(-)
diff --git a/subprojects/groovy-sql/src/main/java/groovy/sql/Sql.java
b/subprojects/groovy-sql/src/main/java/groovy/sql/Sql.java
index 9e8bb03fc4..309e3e4ac5 100644
--- a/subprojects/groovy-sql/src/main/java/groovy/sql/Sql.java
+++ b/subprojects/groovy-sql/src/main/java/groovy/sql/Sql.java
@@ -44,6 +44,7 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
@@ -314,7 +315,28 @@ public class Sql implements AutoCloseable {
private boolean withinBatch;
- private final Map<String, Statement> statementCache = new HashMap<>();
+ /**
+ * Upper bound on the number of prepared statements kept in {@link
#statementCache}. A value
+ * of {@code 0} or less means unbounded, the historical behaviour.
Defaults to 256, overridable
+ * with the {@code groovy.sql.statement.cache.size} system property.
+ */
+ private int statementCacheSize =
Integer.getInteger("groovy.sql.statement.cache.size", 256);
+
+ // Access-ordered so eviction is LRU, and synchronized so a shared Sql
does not corrupt the map;
+ // the eldest entry is closed as it is evicted, otherwise a bounded cache
would leak the JDBC
+ // cursor it drops. SQL whose *text* varies — an inList expanding to a
different placeholder
+ // count per call — would otherwise grow this without limit.
+ private final Map<String, Statement> statementCache =
Collections.synchronizedMap(
+ new LinkedHashMap<String, Statement>(16, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<String,
Statement> eldest) {
+ if (statementCacheSize > 0 && size() > statementCacheSize)
{
+ closeStatementQuietly(eldest.getValue());
+ return true;
+ }
+ return false;
+ }
+ });
private final Map<String, String> namedParamSqlCache = new HashMap<>();
private final Map<String, List<Tuple<?>>> namedParamIndexPropCache = new
HashMap<>();
private List<String> keyColumnNames;
@@ -4047,6 +4069,33 @@ public class Sql implements AutoCloseable {
}
}
+ /**
+ * The maximum number of statements kept when {@code cacheStatements} is
on. A value of 0 or
+ * less means unbounded. Defaults to 256, or the {@code
groovy.sql.statement.cache.size} system
+ * property. A cache keyed on the SQL text grows once per distinct text,
so a query whose text
+ * varies — an {@code inList} expanding to a different placeholder count
per call — will fill
+ * an unbounded cache; the bound keeps that in check by evicting and
closing the least recently
+ * used statement.
+ *
+ * @return the cache size limit
+ * @since 6.0.0
+ */
+ public int getStatementCacheSize() {
+ return statementCacheSize;
+ }
+
+ /**
+ * Sets the maximum number of statements kept when {@code cacheStatements}
is on; see
+ * {@link #getStatementCacheSize()}. Lowering it below the current number
of cached statements
+ * takes effect as statements are next used and evicted, not immediately.
+ *
+ * @param statementCacheSize the new limit; 0 or less for unbounded
+ * @since 6.0.0
+ */
+ public void setStatementCacheSize(int statementCacheSize) {
+ this.statementCacheSize = statementCacheSize;
+ }
+
/**
* @return boolean true if cache is enabled (default is false)
*/
@@ -5035,20 +5084,25 @@ public class Sql implements AutoCloseable {
private void clearStatementCache() {
Statement[] statements;
- if (statementCache.isEmpty())
- return;
- statements = new Statement[statementCache.size()];
- statementCache.values().toArray(statements);
- statementCache.clear();
+ synchronized (statementCache) {
+ if (statementCache.isEmpty())
+ return;
+ statements = statementCache.values().toArray(new Statement[0]);
+ statementCache.clear();
+ }
for (Statement s : statements) {
- try {
- s.close();
- } catch (Exception e) {
- // It's normally safe to ignore exceptions during cleanup but
here if there is
- // a closed statement in the cache, the cache is possibly
corrupted, hence log
- // at slightly elevated level than similar cases.
- LOG.info("Failed to close statement. Already closed? Exception
message: " + e.getMessage());
- }
+ closeStatementQuietly(s);
+ }
+ }
+
+ private static void closeStatementQuietly(Statement s) {
+ try {
+ if (s != null) s.close();
+ } catch (Exception e) {
+ // It's normally safe to ignore exceptions during cleanup but here
if there is
+ // a closed statement in the cache, the cache is possibly
corrupted, hence log
+ // at slightly elevated level than similar cases.
+ LOG.info("Failed to close statement. Already closed? Exception
message: " + e.getMessage());
}
}
@@ -5057,8 +5111,18 @@ public class Sql implements AutoCloseable {
if (cacheStatements) {
stmt = statementCache.get(sql);
if (stmt == null) {
- stmt = cmd.execute(connection, sql);
- statementCache.put(sql, stmt);
+ Statement created = cmd.execute(connection, sql);
+ synchronized (statementCache) {
+ Statement raced = statementCache.get(sql);
+ if (raced != null) {
+ // another thread prepared the same SQL first; keep
theirs, drop ours
+ closeStatementQuietly(created);
+ stmt = raced;
+ } else {
+ statementCache.put(sql, created);
+ stmt = created;
+ }
+ }
}
} else {
stmt = cmd.execute(connection, sql);
diff --git
a/subprojects/groovy-sql/src/test/groovy/groovy/sql/SqlCacheTest.groovy
b/subprojects/groovy-sql/src/test/groovy/groovy/sql/SqlCacheTest.groovy
index 6b31f3d946..46e31df763 100644
--- a/subprojects/groovy-sql/src/test/groovy/groovy/sql/SqlCacheTest.groovy
+++ b/subprojects/groovy-sql/src/test/groovy/groovy/sql/SqlCacheTest.groovy
@@ -118,6 +118,43 @@ class SqlCacheTest extends GroovyTestCase {
}
}
+ // GROOVY-12371: the statement cache is keyed on SQL text and was
unbounded, so text that
+ // varies — an inList expanding to a different placeholder count, say —
grew it without limit.
+ // It is now capped, evicting (and closing) the least recently used
statement.
+ void testStatementCacheIsBoundedAndEvictsLeastRecentlyUsed() {
+ sql.cacheStatements = true
+ sql.statementCacheSize = 2
+ assert sql.statementCacheSize == 2
+
+ prepareStatementCallCounter = 0
+ sql.firstRow("select * from PERSON where id = ?", [1])
// prepare A, cache [A]
+ sql.firstRow("select * from PERSON where id = ? or id = ?", [1, 2])
// prepare B, cache [A,B]
+ sql.firstRow("select * from PERSON where id > ?", [0])
// prepare C -> evict A, cache [B,C]
+ int afterThreeDistinct = prepareStatementCallCounter
+ assert afterThreeDistinct == 3
+
+ // B and C are still cached: re-running them prepares nothing new
+ sql.firstRow("select * from PERSON where id = ? or id = ?", [1, 2])
+ sql.firstRow("select * from PERSON where id > ?", [0])
+ assert prepareStatementCallCounter == afterThreeDistinct
+
+ // A was evicted, so it must be prepared again
+ sql.firstRow("select * from PERSON where id = ?", [1])
+ assert prepareStatementCallCounter == afterThreeDistinct + 1
+ }
+
+ void testStatementCacheUnboundedWhenSizeNotPositive() {
+ sql.cacheStatements = true
+ sql.statementCacheSize = 0 // unbounded, the historical behaviour
+
+ prepareStatementCallCounter = 0
+ (1..20).each { n -> sql.firstRow("select * from PERSON where id = ? /*
${n} */".toString(), [1]) }
+ int prepared = prepareStatementCallCounter
+ // every one is retained, so re-running the first prepares nothing new
+ sql.firstRow("select * from PERSON where id = ? /* 1 */", [1])
+ assert prepareStatementCallCounter == prepared
+ }
+
void testCachePreparedStatements() {
prepareStatementCallCounter = 0
prepareStatementExpectedCall = 3