From 164366db8444d3050ef2800be2304693eb99e54b Mon Sep 17 00:00:00 2001
From: labkey-jeckels Calling this method with cache=false ensures that the JDBC driver will not cache the produced ResultSet in
* memory, which is useful when potentially working with very large (e.g., > 100MB) ResultSets. Calling it with
- * cache=true (the default setting) ensures the JDBC driver's default caching behavior.
By default, the PostgreSQL JDBC driver caches every ResultSet in its entirety. This can lead to * OutOfMemoryErrors when working with very large ResultSets. When the underlying database is PostgreSQL, calling * this method with false instructs this SqlExecutingSelector to use an unshared Connection and configure it with * special settings that disable the driver caching. The trade-off is that the underlying database query will not * use the shared Connection that other code on the thread (up or down the call stack) may be using, making - * Connection exhaustion more likely; that's why JDBC caching is on by default. Calling this method is not - * compatible with passing in an explicit Connection to the constructor.
+ * Connection exhaustion more likely. Calling this method is not compatible with passing in an explicit Connection to + * the constructor. + * + *Note that when neither this method nor an explicit Connection is supplied, JDBC caching is disabled by default + * whenever it's safe to do so (PostgreSQL, no active transaction, SELECT statement) — see + * {@link #getEffectiveConnectionFactory()}. Callers that require the driver's default caching behavior (e.g., to + * share the thread's Connection) must therefore opt in explicitly by calling this method with cache=true.
* *When the underlying database is not PostgreSQL, calling this method has no effect, other than validating that * the stashed Connection is null.
@@ -109,10 +144,32 @@ public SELECTOR setJdbcCaching(boolean cache) ConnectionFactory factory = getScope().getSqlDialect().getConnectionFactory(cache, getScope(), new SQLFragment("SELECT FakeColumn FROM FakeTable") /* SqlExecutingSelector always generates SELECT statements */); _connectionFactory = null != factory ? factory : super::getConnection; + _jdbcCachingExplicitlySet = true; return getThis(); } + /** + * Overridden to warn when a large number of rows is pulled into a Java collection. Loading many rows into memory + * (here plus, potentially, in the JDBC driver's buffer) is a common source of OutOfMemoryErrors; callers should + * generally prefer a streaming method — {@link #forEach}, {@link #forEachBatch}, or {@link #uncachedStream} — that + * processes rows without materializing them all at once. {@code getArray}, {@code getCollection}, + * {@code getMapArray}, and {@code getMapCollection} all delegate here, so they're covered as well. + */ + @Override + public @NotNull
+ * Callers that borrow the thread connection and temporarily modify its state (e.g., disabling JDBC caching for a
+ * streaming read) must do so only when this returns false, so that they are the outermost borrower and can safely
+ * restore the original state — via the connection's runOnClose, which {@link ConnectionType#Thread} fires when the
+ * last holder releases it (ref count returns to 0).
+ */
+ public boolean isThreadConnectionActive()
+ {
+ return getConnectionHolder().getRefCount() > 0;
+ }
+
/**
* Get a fresh connection directly from the pool... not part of the current transaction, not shared with the thread, etc.
*/
diff --git a/api/src/org/labkey/api/data/SqlExecutingSelector.java b/api/src/org/labkey/api/data/SqlExecutingSelector.java
index 622e6031f33..ed1bae67286 100644
--- a/api/src/org/labkey/api/data/SqlExecutingSelector.java
+++ b/api/src/org/labkey/api/data/SqlExecutingSelector.java
@@ -19,6 +19,8 @@
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import org.labkey.api.cache.Cache;
+import org.labkey.api.cache.CacheManager;
import org.labkey.api.data.dialect.SqlDialect;
import org.labkey.api.data.dialect.SqlDialect.ExecutionPlanType;
import org.labkey.api.data.dialect.StatementWrapper;
@@ -34,9 +36,11 @@
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
import static org.labkey.api.util.ExceptionUtil.CALCULATED_COLUMN_SQL_TAG;
@@ -50,6 +54,10 @@ public abstract class SqlExecutingSelector
+ * The {@code selfContained} flag reflects how the ResultSet is consumed. When true (e.g. {@link #getArrayList},
+ * {@link #forEach}, {@link #getRowCount}), the ResultSet is fully consumed and closed within this selector call, so
+ * the dialect may borrow the thread's shared, ref-counted connection — nested queries then reuse it (avoiding
+ * connection-pool exhaustion) and connection-local state (temp tables, search_path) stays visible — because its
+ * state can be restored before control returns to the caller. When false (e.g. {@code getResultSet(false)},
+ * {@link #uncachedStream}), a live ResultSet/Stream is handed back to the caller, so the dialect uses a dedicated,
+ * unshared connection whose lifetime the caller controls.
+ *
+ * The dialect returns null (meaning "use the shared Connection with the driver's default caching") when a
+ * transaction is active, the dialect is not PostgreSQL, or the statement is not a SELECT, so this default is safe by
+ * construction. Resolving lazily here (rather than at construction) ensures the transaction check reflects the state
+ * at execution time.
*/
- private ConnectionFactory getEffectiveConnectionFactory()
+ private ConnectionFactory getEffectiveConnectionFactory(boolean selfContained)
{
// Honor an explicit setJdbcCaching() call (which populated _connectionFactory)...
if (_jdbcCachingExplicitlySet)
@@ -106,7 +132,7 @@ private ConnectionFactory getEffectiveConnectionFactory()
if (null != _conn)
return super::getConnection;
- ConnectionFactory factory = getScope().getSqlDialect().getConnectionFactory(false, getScope(),
+ ConnectionFactory factory = getScope().getSqlDialect().getConnectionFactory(false, selfContained, getScope(),
new SQLFragment("SELECT FakeColumn FROM FakeTable") /* SqlExecutingSelector always generates SELECT statements */);
return null != factory ? factory : super::getConnection;
@@ -141,7 +167,8 @@ public SELECTOR setJdbcCaching(boolean cache)
if (null != _conn)
throw new IllegalStateException("Calling setJdbcCaching() is not valid when a Connection has already been provided");
- ConnectionFactory factory = getScope().getSqlDialect().getConnectionFactory(cache, getScope(),
+ // Explicitly disabling caching is documented to use an unshared Connection, so this path is never self-contained
+ ConnectionFactory factory = getScope().getSqlDialect().getConnectionFactory(cache, false, getScope(),
new SQLFragment("SELECT FakeColumn FROM FakeTable") /* SqlExecutingSelector always generates SELECT statements */);
_connectionFactory = null != factory ? factory : super::getConnection;
_jdbcCachingExplicitlySet = true;
@@ -163,13 +190,28 @@ public SELECTOR setJdbcCaching(boolean cache)
if (result.size() >= LARGE_RESULT_THRESHOLD)
{
- LOGGER.warn("{} rows loaded into a collection via {}. Consider switching to forEach(), forEachBatch(), or uncachedStream() to reduce memory usage. SQL: {}",
- result.size(), getClass().getSimpleName(), getSqlFactory(false).getSql(), new Throwable("Stack trace for large collection load"));
+ Throwable stackTrace = new Throwable("Stack trace for large collection load");
+ String stackKey = getStackKey(stackTrace);
+
+ // Warn at most once per day per unique call stack to avoid flooding the log. A benign race (two threads
+ // logging the same stack at once) is acceptable for a throttle.
+ if (null == LARGE_RESULT_WARNING_THROTTLE.get(stackKey))
+ {
+ LARGE_RESULT_WARNING_THROTTLE.put(stackKey, Boolean.TRUE);
+ LOGGER.warn("{} rows loaded into a collection via {}. Consider switching to forEach(), forEachBatch(), or uncachedStream() to reduce memory usage. SQL: {}",
+ result.size(), getClass().getSimpleName(), getSqlFactory(false).getSql(), stackTrace);
+ }
}
return result;
}
+ // Builds a stable key from a Throwable's stack trace so identical call stacks map to the same throttle entry
+ private static String getStackKey(Throwable t)
+ {
+ return Arrays.stream(t.getStackTrace()).map(StackTraceElement::toString).collect(Collectors.joining("\n"));
+ }
+
/**
* Set a ResultSet fetch size that differs from the default value (1,000 rows on PostgreSQL). This is normally a
* fine fetch size, but not when dealing with rows containing large TEXT or BYTEA columns.
@@ -453,7 +495,9 @@ public