Skip to content

Commit 164366d

Browse files
GitHub Issue 1187: Auto-disable PostgreSQL JDBC result set caching
1 parent 2c85948 commit 164366d

2 files changed

Lines changed: 86 additions & 8 deletions

File tree

api/src/org/labkey/api/data/SqlExecutingSelector.java

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import java.sql.ResultSet;
3434
import java.sql.SQLException;
3535
import java.sql.Statement;
36+
import java.util.ArrayList;
3637
import java.util.Collection;
3738
import java.util.List;
3839
import java.util.Map;
@@ -46,10 +47,14 @@ public abstract class SqlExecutingSelector<FACTORY extends SqlFactory, SELECTOR
4647
{
4748
private static final Logger LOGGER = LogHelper.getLogger(SqlExecutingSelector.class, "Log warnings about SQL exceptions");
4849

50+
// Warn when this many (or more) rows are pulled into a Java collection; suggests switching to a streaming method
51+
private static final int LARGE_RESULT_THRESHOLD = 10_000;
52+
4953
int _maxRows = Table.ALL_ROWS;
5054
protected long _offset = Table.NO_OFFSET;
5155
@Nullable Map<String, Object> _namedParameters = null;
52-
private ConnectionFactory _connectionFactory = super::getConnection;
56+
private @Nullable ConnectionFactory _connectionFactory = null; // null means "no explicit choice"; see getEffectiveConnectionFactory()
57+
private boolean _jdbcCachingExplicitlySet = false;
5358
private Integer _fetchSize = null; // By default, use the standard fetch size
5459

5560
private @Nullable AsyncQueryRequest<?> _asyncRequest = null;
@@ -79,21 +84,51 @@ public interface ConnectionFactory
7984
@Override
8085
public Connection getConnection() throws SQLException
8186
{
82-
return _connectionFactory.get();
87+
return getEffectiveConnectionFactory().get();
88+
}
89+
90+
/**
91+
* Determines which {@link ConnectionFactory} to use for this query. When a caller has explicitly chosen a caching
92+
* behavior via {@link #setJdbcCaching(boolean)} or supplied a Connection at construction time, that choice is
93+
* honored. Otherwise, JDBC caching is disabled by default: we ask the dialect for a streaming ConnectionFactory so
94+
* the driver won't buffer the entire ResultSet in memory. The dialect returns null (meaning "use the shared
95+
* Connection with the driver's default caching") when a transaction is active, the dialect is not PostgreSQL, or the
96+
* statement is not a SELECT, so this default is safe by construction. Resolving lazily here (rather than at
97+
* construction) ensures the transaction check reflects the state at execution time.
98+
*/
99+
private ConnectionFactory getEffectiveConnectionFactory()
100+
{
101+
// Honor an explicit setJdbcCaching() call (which populated _connectionFactory)...
102+
if (_jdbcCachingExplicitlySet)
103+
return _connectionFactory;
104+
105+
// ...or a Connection supplied at construction time (super::getConnection returns the stashed _conn)
106+
if (null != _conn)
107+
return super::getConnection;
108+
109+
ConnectionFactory factory = getScope().getSqlDialect().getConnectionFactory(false, getScope(),
110+
new SQLFragment("SELECT FakeColumn FROM FakeTable") /* SqlExecutingSelector always generates SELECT statements */);
111+
112+
return null != factory ? factory : super::getConnection;
83113
}
84114

85115
/**
86116
* <p>Calling this method with cache=false ensures that the JDBC driver will not cache the produced ResultSet in
87117
* memory, which is useful when potentially working with very large (e.g., > 100MB) ResultSets. Calling it with
88-
* cache=true (the default setting) ensures the JDBC driver's default caching behavior.</p>
118+
* cache=true ensures the JDBC driver's default caching behavior.</p>
89119
*
90120
* <p>By default, the PostgreSQL JDBC driver caches every ResultSet in its entirety. This can lead to
91121
* OutOfMemoryErrors when working with very large ResultSets. When the underlying database is PostgreSQL, calling
92122
* this method with false instructs this SqlExecutingSelector to use an unshared Connection and configure it with
93123
* special settings that disable the driver caching. The trade-off is that the underlying database query will not
94124
* use the shared Connection that other code on the thread (up or down the call stack) may be using, making
95-
* Connection exhaustion more likely; that's why JDBC caching is on by default. Calling this method is not
96-
* compatible with passing in an explicit Connection to the constructor.</p>
125+
* Connection exhaustion more likely. Calling this method is not compatible with passing in an explicit Connection to
126+
* the constructor.</p>
127+
*
128+
* <p>Note that when neither this method nor an explicit Connection is supplied, JDBC caching is disabled by default
129+
* whenever it's safe to do so (PostgreSQL, no active transaction, SELECT statement) — see
130+
* {@link #getEffectiveConnectionFactory()}. Callers that require the driver's default caching behavior (e.g., to
131+
* share the thread's Connection) must therefore opt in explicitly by calling this method with cache=true.</p>
97132
*
98133
* <p>When the underlying database is not PostgreSQL, calling this method has no effect, other than validating that
99134
* the stashed Connection is null.</p>
@@ -109,10 +144,32 @@ public SELECTOR setJdbcCaching(boolean cache)
109144
ConnectionFactory factory = getScope().getSqlDialect().getConnectionFactory(cache, getScope(),
110145
new SQLFragment("SELECT FakeColumn FROM FakeTable") /* SqlExecutingSelector always generates SELECT statements */);
111146
_connectionFactory = null != factory ? factory : super::getConnection;
147+
_jdbcCachingExplicitlySet = true;
112148

113149
return getThis();
114150
}
115151

152+
/**
153+
* Overridden to warn when a large number of rows is pulled into a Java collection. Loading many rows into memory
154+
* (here plus, potentially, in the JDBC driver's buffer) is a common source of OutOfMemoryErrors; callers should
155+
* generally prefer a streaming method — {@link #forEach}, {@link #forEachBatch}, or {@link #uncachedStream} — that
156+
* processes rows without materializing them all at once. {@code getArray}, {@code getCollection},
157+
* {@code getMapArray}, and {@code getMapCollection} all delegate here, so they're covered as well.
158+
*/
159+
@Override
160+
public @NotNull <E> ArrayList<E> getArrayList(Class<E> clazz)
161+
{
162+
ArrayList<E> result = super.getArrayList(clazz);
163+
164+
if (result.size() >= LARGE_RESULT_THRESHOLD)
165+
{
166+
LOGGER.warn("{} rows loaded into a collection via {}. Consider switching to forEach(), forEachBatch(), or uncachedStream() to reduce memory usage. SQL: {}",
167+
result.size(), getClass().getSimpleName(), getSqlFactory(false).getSql(), new Throwable("Stack trace for large collection load"));
168+
}
169+
170+
return result;
171+
}
172+
116173
/**
117174
* Set a ResultSet fetch size that differs from the default value (1,000 rows on PostgreSQL). This is normally a
118175
* fine fetch size, but not when dealing with rows containing large TEXT or BYTEA columns.

api/src/org/labkey/api/data/SqlSelectorTestCase.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,23 @@ public void testJdbcUncached() throws SQLException
186186
DbScope scope = CoreSchema.getInstance().getScope();
187187
try (Connection conn = scope.getConnection())
188188
{
189-
// Default setting is to cache and share the connection
189+
// Default (no explicit setJdbcCaching() call) now auto-disables JDBC caching when it's safe: a separate,
190+
// uncached Connection on PostgreSQL (outside a transaction), but still the shared Connection on SQL Server.
190191
try (Connection conn2 = new SqlSelector(scope, "SELECT RowId, Body FROM comm.Announcements").getConnection())
191192
{
192-
assertEquals(conn, conn2);
193+
if (scope.getSqlDialect().isPostgreSQL())
194+
{
195+
assertNotEquals(conn, conn2);
196+
assertEquals(TRANSACTION_READ_UNCOMMITTED, conn2.getTransactionIsolation());
197+
assertFalse(conn2.getAutoCommit());
198+
}
199+
else
200+
{
201+
assertEquals(conn, conn2);
202+
}
193203
}
194204

195-
// Same as the default setting
205+
// Explicitly requesting caching shares the connection, even on PostgreSQL
196206
try (Connection conn2 = new SqlSelector(scope, "SELECT RowId, Body FROM comm.Announcements").setJdbcCaching(true).getConnection())
197207
{
198208
assertEquals(conn, conn2);
@@ -221,6 +231,17 @@ public void testJdbcUncached() throws SQLException
221231
}
222232
}
223233
}
234+
235+
// Inside a transaction, the default must NOT grab a separate Connection, even on PostgreSQL: the caller may be
236+
// relying on reading its own uncommitted writes, so we fall back to the shared, transactional Connection.
237+
try (DbScope.Transaction tx = scope.ensureTransaction())
238+
{
239+
try (Connection conn2 = new SqlSelector(scope, "SELECT RowId, Body FROM comm.Announcements").getConnection())
240+
{
241+
assertEquals(scope.getConnection(), conn2);
242+
}
243+
tx.commit();
244+
}
224245
}
225246

226247
// Passing in a Connection and calling setJdbcCaching() should throw

0 commit comments

Comments
 (0)