This is an automated email from the ASF dual-hosted git repository. rmaucher pushed a commit to branch 10.1.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit ae3acadbbc30c2d17668e9b77bfe378b1ca52379 Author: opencode <[email protected]> AuthorDate: Fri Oct 30 23:31:47 2026 +0100 Bind a cached statement to the session the prepare ran on StatementCache records the physical connection each cached statement was prepared on so a statement whose session has since been replaced is never parked nor handed out. It recorded that binding by asking the statement itself, but an interceptor listed after the cache wraps statements beneath it and answers their getConnection() with the pool's handle for the borrow instead of forwarding, so the recorded value was a handle, never the session, every park was refused on the session check, every closed statement was closed instead of cached and the next prepare prepared again -- silently, while the class javadoc promised wrapping-only interceptors after the cache were unaffected. The binding now starts from the driver's own answer only where that answer is readable as the current physical connection; when it is one of the pool's own handles it resolves through the handle to the session sitting behind it, on which this very prepare ran; anything else stays trusted, so a driver honestly naming a replaced session still refuses the park. The park at close carries the prepare-time binding down to the cached proxy rather than asking the delegate again, so a lower wrapper cannot perturb it and a session replacement between prepare and park is still refused. A park refused for a lost association is now noted at debug level at most once per connection, so a cache configured but silently never caching costs one log line. --- .../jdbc/pool/interceptor/StatementCache.java | 113 ++++++++- .../TestStatementCacheInterceptorOrder.java | 257 +++++++++++++++++++++ webapps/docs/changelog.xml | 17 ++ 3 files changed, 378 insertions(+), 9 deletions(-) diff --git a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/StatementCache.java b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/StatementCache.java index 57473e6e2f..0009cc8a64 100644 --- a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/StatementCache.java +++ b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/StatementCache.java @@ -18,6 +18,7 @@ package org.apache.tomcat.jdbc.pool.interceptor; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -25,6 +26,7 @@ import java.sql.SQLException; import java.util.Arrays; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import javax.management.ObjectName; @@ -32,6 +34,7 @@ import javax.management.ObjectName; import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; import org.apache.tomcat.jdbc.pool.ConnectionPool; +import org.apache.tomcat.jdbc.pool.JdbcInterceptor; import org.apache.tomcat.jdbc.pool.PoolProperties.InterceptorProperty; import org.apache.tomcat.jdbc.pool.PooledConnection; import org.apache.tomcat.jdbc.pool.jmx.JmxUtil; @@ -44,8 +47,9 @@ import org.apache.tomcat.jdbc.pool.jmx.JmxUtil; * listed <em>after</em> this one in {@link org.apache.tomcat.jdbc.pool.PoolConfiguration#setJdbcInterceptors(String) * jdbcInterceptors} therefore do not see reused statements - relevant to any interceptor whose {@code createStatement} * hook performs an action rather than only wrapping the statement: {@link QueryTimeoutInterceptor} must be listed - * before this one to keep its timeout on cached statements, while wrapping-only interceptors are unaffected because - * their wrapper is carried inside the cached proxy. + * before this one to keep its timeout on cached statements, while wrapping-only interceptors are unaffected: their + * wrapper is carried inside the cached proxy, and the cache binds a cached statement to the physical session the + * prepare ran on rather than to whatever connection a wrapper beneath it reports for the statement. */ public class StatementCache extends StatementDecoratorInterceptor implements StatementCacheMBean { private static final Log log = LogFactory.getLog(StatementCache.class); @@ -308,6 +312,7 @@ public class StatementCache extends StatementDecoratorInterceptor implements Sta // disconnect/reconnect, landing after the disconnected() purge // had already run). The statement belongs to a dead connection // and must not be parked in the cache of the new one. + logLostAssociationOnce(proxy); return false; } else if (cacheSize.get() >= maxCacheSize) { return false; @@ -375,6 +380,54 @@ public class StatementCache extends StatementDecoratorInterceptor implements Sta return pCon == null ? null : pCon.getConnection(); } + /** + * Whether a reported connection is one of the handles the pool itself hands out - a proxy whose invocation + * handler is a pool interceptor - rather than a physical connection. Only the pool's own handles are read this + * way; an answer from anything else stays the statement's reported binding, so a driver that honestly names a + * replaced (dead) session keeps refusing the park. + * + * @param reported the value reported as a statement's connection + * + * @return true if the value is one of the pool's own connection handles + */ + private static boolean isPoolHandle(Connection reported) { + return reported != null && Proxy.isProxyClass(reported.getClass()) + && Proxy.getInvocationHandler(reported) instanceof JdbcInterceptor; + } + + /** + * Set once the refusal of a park for a lost association has been logged for the pooled connection this + * interceptor instance is bound to (the pool builds one chain, and so one interceptor instance, per pooled + * connection), so the note appears once per connection and not once per refused statement. The claim is a + * compare-and-set run under the debug guard, so of two refusals on concurrent closes exactly one logs, and + * a refusal met while debug is off leaves the claim unspent for a later refusal to make after debug is + * enabled. + */ + private final AtomicBoolean associationLossLogged = new AtomicBoolean(); + + /** + * Logs, at most once per connection, that a park was refused because the connection recorded for the statement + * is not the current physical one. The usual cause is benign - a close racing a session replacement - but a + * wrapper beneath this cache that answers the statement's {@code getConnection()} with a value this cache + * cannot resolve to a physical session reaches this refusal for every statement it closes, and a cache that + * never caches is worth one line of log. + * + * @param proxy the statement whose park was refused + */ + private void logLostAssociationOnce(CachedStatement proxy) { + // The debug guard runs first, so a refusal met while debug is off does + // not spend the claim, and the compare-and-set runs second, so of the + // refusals met while debug is on exactly one logs. + if (log.isDebugEnabled() && associationLossLogged.compareAndSet(false, true)) { + log.debug("Not caching statement [" + proxy.getSql() + "]: the connection recorded for it is " + + "not the current physical connection. Either the session was replaced while the " + + "statement was being closed, or an interceptor listed below " + + getClass().getSimpleName() + " answers the statement's getConnection() with a value " + + "that cannot be resolved to the physical session, which keeps every statement out " + + "of the cache."); + } + } + /** * Returns the statement cache for the current pooled connection. * @@ -424,7 +477,9 @@ public class StatementCache extends StatementDecoratorInterceptor implements Sta private boolean poolable = true; /** - * Constructs a CachedStatement. + * Constructs a CachedStatement, recording the physical connection the statement was prepared on as the + * answer of the statement itself (or, when that answer is not a physical connection the cache can read, of + * the pool's own record of the session the prepare ran on). * * @param parent the parent prepared statement * @param sql the SQL text @@ -435,9 +490,29 @@ public class StatementCache extends StatementDecoratorInterceptor implements Sta } /** - * Resolves the physical connection a statement belongs to. The connection the driver reports for the statement - * itself is authoritative; only when the driver will not answer does the pool's own record of the current - * connection serve as the binding. + * Constructs a CachedStatement with an already determined binding to the physical connection the statement + * was prepared on. {@link #closeInvoked()} parks through this constructor, carrying the binding that was + * made when the statement was prepared down to the proxy that enters the cache: asking the delegate again + * at park time would hand a wrapper beneath this cache another chance to answer with something that is not + * the physical session, while the binding made at prepare time names the session the statement was really + * prepared on and still lets a session replacement between the prepare and the park refuse it. + * + * @param parent the parent prepared statement + * @param sql the SQL text + * @param associatedConnection the physical connection the statement was prepared on + */ + CachedStatement(PreparedStatement parent, String sql, Connection associatedConnection) { + super(parent, sql); + this.associatedConnection = associatedConnection; + } + + /** + * Resolves the physical connection a statement belongs to. The connection the driver reports for the + * statement itself is authoritative; a wrapper listed beneath this cache may however intercept the call and + * answer it with a pool handle rather than forward it to the driver, and a handle names no session of its + * own - the physical session that sits behind the handle now, and behind the prepare of this very + * statement, serves as the binding for that answer. Only when the driver will not answer does the pool's + * own record of the current connection serve as the binding. * * @param statement the statement whose associated connection to determine * @@ -445,7 +520,21 @@ public class StatementCache extends StatementDecoratorInterceptor implements Sta */ private Connection associatedConnectionOf(PreparedStatement statement) { try { - return statement.getConnection(); + Connection reported = statement.getConnection(); + if (reported == currentPhysicalConnection()) { + return reported; + } + if (isPoolHandle(reported)) { + // A wrapper beneath the cache intercepted the call (the + // StatementDecoratorInterceptor answers it with the + // handle of the borrow, among others). The handle is + // this pooled connection's own, and the current borrow + // - on whose session this prepare ran - sits on its + // current physical connection: that session is the + // statement's binding. + return currentPhysicalConnection(); + } + return reported; } catch (SQLException x) { return currentPhysicalConnection(); } @@ -472,8 +561,14 @@ public class StatementCache extends StatementDecoratorInterceptor implements Sta AtomicInteger size = cacheSize; boolean shouldClose = true; if (size != null && size.get() < maxCacheSize) { - // Cache a proxy so that we don't reuse the facade - CachedStatement proxy = new CachedStatement(getDelegate(), getSql()); + // Cache a proxy so that we don't reuse the facade. The + // binding to the physical session made at the prepare is + // carried over to the parked proxy rather than the delegate + // asked again: a wrapper beneath this cache would intercept + // that second question too, while the prepare-time binding + // still names the session the statement was prepared on for + // the session check in cacheStatement. + CachedStatement proxy = new CachedStatement(getDelegate(), getSql(), associatedConnection); proxy.setCacheKey(getCacheKey()); try { // Only cache if statement has been marked as poolable and not close on completion diff --git a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestStatementCacheInterceptorOrder.java b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestStatementCacheInterceptorOrder.java new file mode 100644 index 0000000000..62cd4868ea --- /dev/null +++ b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestStatementCacheInterceptorOrder.java @@ -0,0 +1,257 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tomcat.jdbc.pool.interceptor; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.Statement; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.apache.tomcat.jdbc.JdbcPoolBaseTest; +import org.apache.tomcat.jdbc.pool.DataSource; +import org.apache.tomcat.jdbc.pool.PooledConnection; + +/** + * The documented order independence of the statement cache: a wrapping + * interceptor listed <em>after</em> the cache sits beneath it in the chain, + * and the cache then prepares its entry on that interceptor's proxy rather + * than on the driver's statement. The proxy answers {@code getConnection()} + * with the pooled handle of the borrow instead of forwarding the call to the + * driver, so if the cache took that answer as the statement's physical + * connection it would never match the session and every park would be + * refused - the cache silently degrades to closing every closed statement, + * exactly what {@code StatementCache}'s class documentation says does not + * happen ("wrapping-only interceptors are unaffected"). These tests pin both + * orderings: the cache must park and reuse through a wrapper beneath it, a + * wrapper above it must keep working as before, and the protection against + * parking a statement of a replaced session (which the driver's own answer + * carries) must stay intact on both routes. + */ +class TestStatementCacheInterceptorOrder extends JdbcPoolBaseTest { + + private static final String CACHE_CONFIG = + StatementCache.class.getName() + "(prepared=true,max=5)"; + private static final String WRAPPER_CONFIG = + StatementDecoratorInterceptor.class.getName(); + private static final String WRAPPER_BEFORE_CACHE = + WRAPPER_CONFIG + ";" + CACHE_CONFIG; + + /** The statement cache as the given borrower sees it. */ + private static StatementCache cacheOf(Connection pooled) { + return interceptorOf(pooled, StatementCache.class); + } + + /** + * The defect: with the wrapper configured beneath the cache, closing one + * prepared statement must still park it - the same park, the same reuse + * and the same driver statement as with the cache alone (compare + * {@link TestStatementCacheReuse}). Pre-fix the park was refused because + * the connection recorded for the statement was the decorator's pooled + * handle, never the physical session, so the cache size stayed at + * {@code 0}, the driver statement came back closed, and the next prepare + * of the same SQL was a real second prepare. + */ + @Test + void aWrappingInterceptorListedAfterTheCacheDoesNotStopTheCaching() throws Exception { + DataSource ds = newDataSource(); + ds.getPoolProperties().setJdbcInterceptors(CACHE_CONFIG + ";" + WRAPPER_CONFIG); + ds.getPoolProperties().setMaxActive(1); + String table = uniqueTable(); + ds.createPool(); + try (Connection admin = adminConnection(); Statement st = admin.createStatement()) { + st.execute("CREATE TABLE " + table + " (v INT)"); + } + String insert = "INSERT INTO " + table + " VALUES (?)"; + + Connection first = ds.getConnection(); + // The configuration order must really put the wrapper beneath the + // cache, otherwise this test pins nothing about the ordering. + StatementCache cache = cacheOf(first); + Assertions.assertTrue(cache.getNext() instanceof StatementDecoratorInterceptor, + "an interceptor listed after the cache must sit beneath it in the chain"); + setSessionMarker(first, 41); + PreparedStatement prepared = first.prepareStatement(insert); + Statement physical = driverStatement(prepared); + prepared.setInt(1, 1); + Assertions.assertEquals(1, prepared.executeUpdate()); + prepared.close(); + Assertions.assertEquals(1, cache.getCacheSizePerConnection(), + "a wrapper beneath the cache must not stop the statement from " + + "being parked (pre-fix the park was refused and the " + + "cache stayed empty)"); + Assertions.assertFalse(physical.isClosed(), + "the parked statement must stay open - that is what saves the prepare"); + first.close(); + + Connection second = ds.getConnection(); + try { + Assertions.assertEquals(Integer.valueOf(41), sessionMarker(second), + "the same physical session must have been reused"); + Assertions.assertEquals(1, cacheOf(second).getCacheSizePerConnection(), + "the cache must survive the return to the pool"); + + PreparedStatement reused = second.prepareStatement(insert); + Assertions.assertSame(physical, driverStatement(reused), + "the next borrower must get the same physical statement, not a " + + "second prepare on the session"); + Assertions.assertSame(second, reused.getConnection(), + "the wrapper's own handle must not leak out as the statement's " + + "connection on reuse"); + reused.setInt(1, 2); + Assertions.assertEquals(1, reused.executeUpdate()); + reused.close(); + Assertions.assertEquals(1, cacheOf(second).getCacheSizePerConnection(), + "the statement must go back into the cache when closed again"); + } finally { + second.close(); + } + Assertions.assertEquals(2, adminRowCount(table), + "both executes must have reached the database"); + } + + /** + * The reverse order, which worked before the defect was addressed: the + * wrapper above the cache sees every prepare (its proxy wraps even a + * statement served from the cache) and the cache prepares directly on + * the driver's statement, whose connection answer is the physical + * session. This must keep park-and-reusing, so a fix aimed at the other + * ordering cannot quietly change this one. + */ + @Test + void aWrappingInterceptorListedBeforeTheCacheKeepsWorkingAsBefore() throws Exception { + DataSource ds = newDataSource(); + ds.getPoolProperties().setJdbcInterceptors(WRAPPER_BEFORE_CACHE); + ds.getPoolProperties().setMaxActive(1); + String table = uniqueTable(); + ds.createPool(); + try (Connection admin = adminConnection(); Statement st = admin.createStatement()) { + st.execute("CREATE TABLE " + table + " (v INT)"); + } + String insert = "INSERT INTO " + table + " VALUES (?)"; + + Connection first = ds.getConnection(); + StatementCache cache = cacheOf(first); + Assertions.assertFalse(cache.getNext() instanceof StatementDecoratorInterceptor, + "an interceptor listed before the cache must not sit beneath it"); + setSessionMarker(first, 42); + PreparedStatement prepared = first.prepareStatement(insert); + Statement physical = driverStatement(prepared); + prepared.setInt(1, 1); + Assertions.assertEquals(1, prepared.executeUpdate()); + prepared.close(); + Assertions.assertEquals(1, cache.getCacheSizePerConnection(), + "the wrapper above the cache must not keep the statement out of the cache"); + Assertions.assertFalse(physical.isClosed(), + "the parked statement must stay open"); + first.close(); + + Connection second = ds.getConnection(); + try { + Assertions.assertEquals(Integer.valueOf(42), sessionMarker(second), + "the same physical session must have been reused"); + PreparedStatement reused = second.prepareStatement(insert); + Assertions.assertSame(physical, driverStatement(reused), + "the next borrower must get the same physical statement"); + reused.setInt(1, 2); + Assertions.assertEquals(1, reused.executeUpdate()); + reused.close(); + Assertions.assertEquals(1, cacheOf(second).getCacheSizePerConnection(), + "the statement must go back into the cache when closed again"); + } finally { + second.close(); + } + Assertions.assertEquals(2, adminRowCount(table), + "both executes must have reached the database"); + } + + /** + * The protection the driver's answer carries must survive the fix on the + * route where the answer is intercepted: a statement close racing a + * session replacement must still not be parked in the new session's + * cache. Same deterministic race as + * {@link TestStatementCacheRacingClose} (the reconnect runs inside + * {@code cacheStatement}), but with the wrapping interceptor beneath the + * cache, so the statement's own {@code getConnection()} can never answer + * the old session - a fix that resolved the intercepted answer from the + * session current at park time would park the dead statement here. + */ + @Test + void aStatementClosedWhileTheSessionIsReplacedIsStillNotParkedWithAWrapperBelow() + throws Exception { + DataSource ds = newDataSource(); + String racingCache = + TestStatementCacheRacingClose.RacingCloseStatementCache.class.getName(); + ds.getPoolProperties().setJdbcInterceptors( + racingCache + "(prepared=true,max=5);" + WRAPPER_CONFIG); + ds.getPoolProperties().setMaxActive(1); + // keep the background sweeper out of the way: the racing close + // performs the reconnect itself here + ds.getPoolProperties().setTimeBetweenEvictionRunsMillis(60000); + String racingTable = uniqueTable(); + ds.createPool(); + try (Connection admin = adminConnection(); Statement st = admin.createStatement()) { + st.execute("CREATE TABLE " + racingTable + " (v INT)"); + } + String racingSql = "INSERT INTO " + racingTable + " VALUES (?)"; + + Connection first = ds.getConnection(); + PooledConnection pcon = (PooledConnection) first.unwrap(PooledConnection.class); + TestStatementCacheRacingClose.RacingCloseStatementCache cache = + interceptorOf(first, TestStatementCacheRacingClose.RacingCloseStatementCache.class); + setSessionMarker(first, 43); + + Connection oldPhysical = pcon.getConnection(); + PreparedStatement racing = first.prepareStatement(racingSql); + Statement racingDriverStatement = driverStatement(racing); + racing.setInt(1, 2); + Assertions.assertEquals(1, racing.executeUpdate()); + + cache.raceNextParkWithReconnect(pcon); + racing.close(); + + Assertions.assertTrue(oldPhysical.isClosed(), + "the racing reconnect must have replaced the physical session"); + awaitSessions(1); + Assertions.assertEquals(0, cache.getCacheSizePerConnection(), + "the statement of the replaced session must not be parked through " + + "the intercepted connection answer"); + Assertions.assertTrue(racingDriverStatement.isClosed(), + "the refused statement must have been closed by its close"); + + first.close(); // back to the pool on the new session + Connection second = ds.getConnection(); + try { + Assertions.assertNull(sessionMarker(second), + "the next borrower must sit on the new physical session"); + PreparedStatement fresh = second.prepareStatement(racingSql); + Assertions.assertNotSame(racingDriverStatement, driverStatement(fresh), + "the statement of the replaced session must not be served"); + fresh.setInt(1, 3); + Assertions.assertEquals(1, fresh.executeUpdate(), + "the next borrower must get a statement that works on its session"); + fresh.close(); + Assertions.assertEquals(1, cache.getCacheSizePerConnection(), + "the new session must be able to park its own statement"); + } finally { + second.close(); + } + Assertions.assertEquals(2, adminRowCount(racingTable), + "both executes of the racing SQL must have reached the database"); + } +} diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index 1b51946a84..5c5aa7f836 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -1174,6 +1174,23 @@ closed, <code>abort()</code> is the no-op the specification prescribes. Based on code submitted by Arturo Bernal. (remm) </fix> + <fix> + Correct a defect in the <code>StatementCache</code> interceptor + where an interceptor listed after the cache that wraps statements + beneath it - <code>StatementDecoratorInterceptor</code>, for + example - silently stopped the cache from caching. The cached + statement recorded as its connection whatever that lower wrapper + answered for the statement's <code>getConnection()</code> - the + pool's handle for the borrow, not the physical connection - so no + closed statement ever passed the session check on the way into the + cache, every one was closed instead, and the next prepare prepared + again, at any log level. The cache now binds a cached statement to + the physical session the prepare ran on, using the driver's own + answer where it is readable, while a statement prepared on a + session that has since been replaced still refuses both caching + and reuse, and a park refused for a lost association is now noted + once per connection at debug level. (remm) + </fix> </changelog> </subsection> <subsection name="Other"> --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
