This is an automated email from the ASF dual-hosted git repository.

rmaucher pushed a commit to branch 9.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git

commit acbed351a291b09c4ff6a5a3eb1db10444ef4645
Author: opencode <[email protected]>
AuthorDate: Tue Oct 27 17:48:43 2026 +0100

    Reset the abandoned timer while a result set is being streamed in the JDBC 
pool
    
    The ResetAbandonedTimer interceptor only refreshed the abandoned timer
    while a statement call was executing, so a borrower that left a cursor
    open across a long pause - the usual reason for configuring a fetch size
    - looked idle, and a connection that was actively streaming rows could be
    reported as abandoned and reclaimed from underneath its borrower.
    
    The interceptor now also wraps the statements it proxies, so that the
    result sets those statements hand out (from executeQuery, getResultSet
    and getGeneratedKeys) reset the timer on every call made through them,
    whether the call succeeds or fails; each statement creation on the
    pooled connection resets the timer as well. As in the earlier fix for
    failed operations, a completed call is what counts as activity, so a
    single call that blocks on the database for longer than the abandoned
    timeout still gets the connection abandoned.
    
    The connection handle returned by the new proxies, and by cached result
    sets, is documented as borrow-scoped. Where the statement facade is
    switched off, the handle identity intentionally changes: a result set
    obtained through rs.getStatement() is now compared against the
    interceptor's own handle rather than the raw driver object, and result
    set handles are created per call rather than being the driver's cached
    object.
    
    Also correct the jdbcInterceptors documentation in PoolConfiguration,
    which listed only three of the bundled interceptors.
    
    Add TestResetAbandonedTimerStreaming covering streaming keep-alive,
    statement closure during iteration, failing reads and handle identity.
---
 .../apache/tomcat/jdbc/pool/PoolConfiguration.java |   8 +-
 .../jdbc/pool/interceptor/ResetAbandonedTimer.java | 219 +++++++++++++-
 .../TestResetAbandonedTimerStreaming.java          | 328 +++++++++++++++++++++
 webapps/docs/changelog.xml                         |  15 +
 4 files changed, 564 insertions(+), 6 deletions(-)

diff --git 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PoolConfiguration.java
 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PoolConfiguration.java
index 345a5b0fd9..a7358d5863 100644
--- 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PoolConfiguration.java
+++ 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PoolConfiguration.java
@@ -624,8 +624,8 @@ public interface PoolConfiguration {
      * These interceptors will be inserted as an interceptor into the chain of 
operations on a java.sql.Connection object.
      * Example interceptors are {@link 
org.apache.tomcat.jdbc.pool.interceptor.StatementFinalizer StatementFinalizer} 
to close all
      * used statements during the session.
-     * {@link org.apache.tomcat.jdbc.pool.interceptor.ResetAbandonedTimer 
ResetAbandonedTimer} resets the timer upon every operation
-     * on the connection or a statement.
+     * {@link org.apache.tomcat.jdbc.pool.interceptor.ResetAbandonedTimer 
ResetAbandonedTimer} resets the timer upon any activity
+     * on the connection, upon any statement execution and upon any read of a 
result set, whether the operation succeeded or failed.
      * {@link org.apache.tomcat.jdbc.pool.interceptor.ConnectionState 
ConnectionState} caches the auto commit, read only and catalog settings to 
avoid round trips to the DB.
      * The default value is null.
      * @return the interceptors that are used for connections.
@@ -638,8 +638,8 @@ public interface PoolConfiguration {
      * These interceptors will be inserted as an interceptor into the chain of 
operations on a java.sql.Connection object.
      * Example interceptors are {@link 
org.apache.tomcat.jdbc.pool.interceptor.StatementFinalizer StatementFinalizer} 
to close all
      * used statements during the session.
-     * {@link org.apache.tomcat.jdbc.pool.interceptor.ResetAbandonedTimer 
ResetAbandonedTimer} resets the timer upon every operation
-     * on the connection or a statement.
+     * {@link org.apache.tomcat.jdbc.pool.interceptor.ResetAbandonedTimer 
ResetAbandonedTimer} resets the timer upon any activity
+     * on the connection, upon any statement execution and upon any read of a 
result set, whether the operation succeeded or failed.
      * {@link org.apache.tomcat.jdbc.pool.interceptor.ConnectionState 
ConnectionState} caches the auto commit, read only and catalog settings to 
avoid round trips to the DB.
      * The default value is null.
      * @param jdbcInterceptors the interceptors that are used for connections.
diff --git 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/ResetAbandonedTimer.java
 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/ResetAbandonedTimer.java
index 4be8bdd43b..9c0636b14f 100644
--- 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/ResetAbandonedTimer.java
+++ 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/ResetAbandonedTimer.java
@@ -16,18 +16,39 @@
  */
 package org.apache.tomcat.jdbc.pool.interceptor;
 
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.Statement;
 
 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.PooledConnection;
 import org.apache.tomcat.jdbc.pool.jmx.JmxUtil;
 
 /**
  * Class that resets the abandoned timer on any activity on the
- * Connection or any query execution, whether the operation completed
- * successfully or failed.
+ * Connection, on any query execution and on any read of a
+ * {@link java.sql.ResultSet} returned by a statement obtained from the
+ * pooled connection, whether the operation completed successfully or
+ * failed. Iterating a large result set therefore keeps the connection
+ * alive through the fetch phase, not only until the query returns.
+ * Each reset happens when an individual call completes, so a single call
+ * that itself blocks on the database for longer than
+ * {@link 
org.apache.tomcat.jdbc.pool.PoolConfiguration#setRemoveAbandonedTimeout(int)}
+ * still gets the connection abandoned. When the
+ * {@link 
org.apache.tomcat.jdbc.pool.PoolConfiguration#setUseStatementFacade(boolean)
+ * statement facade} is switched off, a result set handle that outlives
+ * its borrow keeps refreshing the abandoned timer of whoever holds that
+ * physical connection next; with the facade enabled, as by default, a
+ * returned borrow is sealed off before that can happen.
  * This interceptor is useful for when you have a {@link 
org.apache.tomcat.jdbc.pool.PoolConfiguration#setRemoveAbandonedTimeout(int)}
  * that is fairly low, and you want to reset the abandoned time each time any 
operation on the connection is performed
  * This is useful for batch processing programs that use connections for 
extensive amount of times.
@@ -35,6 +56,8 @@ import org.apache.tomcat.jdbc.pool.jmx.JmxUtil;
  */
 public class ResetAbandonedTimer extends AbstractQueryReport implements 
ResetAbandonedTimerMBean {
 
+    private static final Log log = 
LogFactory.getLog(ResetAbandonedTimer.class);
+
     private PooledConnection pcon;
 
     private ObjectName oname = null;
@@ -84,6 +107,40 @@ public class ResetAbandonedTimer extends 
AbstractQueryReport implements ResetAba
         }
     }
 
+    /**
+     * Wraps the query reporting statement proxy so that the result sets
+     * handed to the application reset the abandoned timer while they are
+     * read. Statements that this interceptor does not proxy are passed
+     * through unchanged.
+     */
+    @Override
+    public Object createStatement(Object proxy, Method method, Object[] args, 
Object statement, long time) {
+        Object result = super.createStatement(proxy, method, args, statement, 
time);
+        if (result == statement) {
+            //not one of the statement types we proxy, or the proxy
+            //creation above failed - nothing to wrap
+            return result;
+        }
+        try {
+            String name = method.getName();
+            Class<?> type = Statement.class;
+            int index = CREATE_STATEMENT_IDX;
+            if (compare(PREPARE_STATEMENT, name)) {
+                type = PreparedStatement.class;
+                index = PREPARE_STATEMENT_IDX;
+            } else if (compare(PREPARE_CALL, name)) {
+                type = CallableStatement.class;
+                index = PREPARE_CALL_IDX;
+            }
+            return getConstructor(index, type).newInstance(
+                    new Object[] { new TimerStatementProxy(result) });
+        } catch (Exception x) {
+            log.warn("Unable to create timer statement proxy for "
+                    + getClass().getName() + ".", x);
+        }
+        return result;
+    }
+
     @Override
     protected void prepareCall(String query, long time) {
         resetTimer();
@@ -117,4 +174,162 @@ public class ResetAbandonedTimer extends 
AbstractQueryReport implements ResetAba
         resetTimer();
         return super.reportSlowQuery(query, args, name, start, delta);
     }
+
+    /**
+     * Proxy for a statement that turns the result sets the statement hands
+     * out into {@link TimerResultSetProxy} instances. Every other call is
+     * passed straight through to the wrapped statement.
+     */
+    private final class TimerStatementProxy implements InvocationHandler {
+        /**
+         * The statement proxy created by {@code AbstractQueryReport}.
+         */
+        private final Object delegate;
+
+        TimerStatementProxy(Object delegate) {
+            this.delegate = delegate;
+        }
+
+        @Override
+        public Object invoke(Object proxy, Method method, Object[] args) 
throws Throwable {
+            //Handle identity on this proxy, like the statement facade
+            //does, rather than on the object below it: the application
+            //must be able to rely on its own handles
+            if (compare(TOSTRING_VAL, method)) {
+                return toString();
+            }
+            if (compare(EQUALS_VAL, method)) {
+                if (args[0] == null || 
!Proxy.isProxyClass(args[0].getClass())) {
+                    return Boolean.FALSE;
+                }
+                return 
Boolean.valueOf(this.equals(Proxy.getInvocationHandler(args[0])));
+            }
+            if (compare(HASHCODE_VAL, method)) {
+                return Integer.valueOf(this.hashCode());
+            }
+            Object result;
+            try {
+                result = method.invoke(delegate, args);
+            } catch (InvocationTargetException x) {
+                Throwable cause = x.getCause();
+                if (cause == null) {
+                    throw x;
+                }
+                throw cause;
+            }
+            //Wrap the same three result set producing calls that the
+            //statement facade wraps: executeQuery, getResultSet and
+            //getGeneratedKeys
+            if (result instanceof ResultSet) {
+                String name = method.getName();
+                if (compare(EXECUTE_QUERY, name) || compare(GET_RESULTSET, 
name)
+                        || compare(GET_GENERATED_KEYS, name)) {
+                    try {
+                        return getConstructor(RESULTSET_IDX, 
ResultSet.class).newInstance(
+                                new Object[] { new TimerResultSetProxy(result, 
proxy) });
+                    } catch (Exception x) {
+                        //the application just does not get the timer reset
+                        //while reading this result set
+                        log.warn("Unable to create timer result set proxy for "
+                                + 
ResetAbandonedTimer.this.getClass().getName() + ".", x);
+                    }
+                }
+            }
+            return result;
+        }
+
+        @Override
+        public int hashCode() {
+            return System.identityHashCode(this);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            return this == obj;
+        }
+
+        @Override
+        public String toString() {
+            return ResetAbandonedTimer.this.getClass().getName()
+                    + "[Proxy=" + hashCode() + "; Delegate=" + delegate + ']';
+        }
+    }
+
+    /**
+     * Proxy for a result set that resets the abandoned timer on every read,
+     * so that an application iterating a result set - or doing its per-row
+     * work between the reads - counts as active and is not abandoned
+     * mid-iteration.
+     */
+    private final class TimerResultSetProxy implements InvocationHandler {
+        /**
+         * The result set being read.
+         */
+        private final Object delegate;
+        /**
+         * The statement handle in use by this interceptor, returned by
+         * {@link java.sql.ResultSet#getStatement()}. It keeps the timer
+         * reset like the handle the application holds, but it is not
+         * necessarily that same outermost handle: the statement facade
+         * substitutes its own handle from outside this interceptor, and
+         * any interceptor configured around this one may too.
+         */
+        private final Object statement;
+
+        TimerResultSetProxy(Object delegate, Object statement) {
+            this.delegate = delegate;
+            this.statement = statement;
+        }
+
+        @Override
+        public Object invoke(Object proxy, Method method, Object[] args) 
throws Throwable {
+            //Handle identity on this proxy, like the statement facade
+            //does, rather than on the object below it: the application
+            //must be able to rely on its own handles
+            if (compare(TOSTRING_VAL, method)) {
+                return toString();
+            }
+            if (compare(EQUALS_VAL, method)) {
+                if (args[0] == null || 
!Proxy.isProxyClass(args[0].getClass())) {
+                    return Boolean.FALSE;
+                }
+                return 
Boolean.valueOf(this.equals(Proxy.getInvocationHandler(args[0])));
+            }
+            if (compare(HASHCODE_VAL, method)) {
+                return Integer.valueOf(this.hashCode());
+            }
+            if (compare(GET_STATEMENT, method)) {
+                return statement;
+            }
+            try {
+                return method.invoke(delegate, args);
+            } catch (InvocationTargetException x) {
+                Throwable cause = x.getCause();
+                if (cause == null) {
+                    throw x;
+                }
+                throw cause;
+            } finally {
+                //reading the result set is activity on the connection,
+                //even when the read failed
+                resetTimer();
+            }
+        }
+
+        @Override
+        public int hashCode() {
+            return System.identityHashCode(this);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            return this == obj;
+        }
+
+        @Override
+        public String toString() {
+            return ResetAbandonedTimer.this.getClass().getName()
+                    + "[Proxy=" + hashCode() + "; Delegate=" + delegate + ']';
+        }
+    }
 }
diff --git 
a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestResetAbandonedTimerStreaming.java
 
b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestResetAbandonedTimerStreaming.java
new file mode 100644
index 0000000000..e0df35edf2
--- /dev/null
+++ 
b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestResetAbandonedTimerStreaming.java
@@ -0,0 +1,328 @@
+/*
+ *  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.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import org.junit.jupiter.api.AfterEach;
+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;
+
+/**
+ * The {@code ResetAbandonedTimer} interceptor must treat reading a result
+ * set as activity: an application iterating a large result set - doing
+ * its per-row work between the reads - is active and must not have its
+ * connection abandoned underneath it, not even when the reads it makes
+ * fail, while a borrowed connection that really is idle must still be
+ * reclaimed. The keep-alive tests drive a streaming connection and an
+ * idle control connection against a one-second abandoned timeout and
+ * assert which of the two physical sessions the cleaner killed. The
+ * composition test pins that the result set handles the interceptor
+ * hands out stay usable, obey the identity contract of a handle, and
+ * compose with the statement facade on every access path that yields a
+ * result set.
+ */
+class TestResetAbandonedTimerStreaming extends JdbcPoolBaseTest {
+
+    /*
+     * Time the busy connection is kept streaming rows. It must stay well
+     * above removeAbandonedTimeout plus one sweep interval so that the
+     * idle control connection - and, before the fetch traffic reset the
+     * timer, the streaming one too - is certain to expire while the
+     * iteration is still running.
+     */
+    private static final long STREAM_MILLIS = 2500;
+
+    /*
+     * Lower bound on streamed rows. An app iterating rows between the
+     * per-row pauses makes far more than this; the bound only catches an
+     * iteration that never really ran.
+     */
+    private static final int MIN_STREAM_ROWS = 20;
+
+    /*
+     * Lower bound on failing result set reads. At a one-second abandoned
+     * timeout a genuine retry loop makes far more calls than this; the
+     * bound only catches a storm that never really ran.
+     */
+    private static final int MIN_FAILURE_CALLS = 5;
+
+    private DataSource streamingPool(boolean useStatementFacade) throws 
SQLException {
+        DataSource ds = newDataSource();
+        ds.getPoolProperties().setJdbcInterceptors(
+                "org.apache.tomcat.jdbc.pool.interceptor.ResetAbandonedTimer");
+        ds.getPoolProperties().setRemoveAbandoned(true);
+        ds.getPoolProperties().setRemoveAbandonedTimeout(1); // seconds
+        ds.getPoolProperties().setTimeBetweenEvictionRunsMillis(100);
+        ds.getPoolProperties().setUseStatementFacade(useStatementFacade);
+        ds.createPool();
+        pool = ds;
+        return ds;
+    }
+
+    /* The pool under test, for forced teardown. */
+    private DataSource pool;
+
+    /*
+     * These tests deliberately end with connections still borrowed. The
+     * regular data source teardown only disconnects idle connections, so
+     * the sessions of the borrowed ones would linger until the garbage
+     * collector finalizes them and leak their session counts into the
+     * starting point of the next test; force-close disconnects the busy
+     * ones as well, right away.
+     */
+    @AfterEach
+    void disconnectLeakedBusyConnections() {
+        if (pool != null) {
+            pool.close(true);
+        }
+    }
+
+    /*
+     * Borrows an idle control connection - left borrowed and untouched,
+     * it exists only to prove the abandoned sweep is running - and a
+     * busy connection, each identified by a session marker, and pins the
+     * session count to two while both are still live. The caller must
+     * have waited out any sessions dying from a previous test first:
+     * with a one-second abandoned timeout there is no time to absorb
+     * stale sessions once these connections have started their
+     * countdown. Returns the busy connection.
+     */
+    private Connection borrowBusyWithIdleControl(DataSource ds) throws 
SQLException {
+        Connection idle = ds.getConnection();
+        setSessionMarker(idle, 2);
+        Connection busy = ds.getConnection();
+        setSessionMarker(busy, 1);
+        Assertions.assertEquals(2, poolSessions(),
+                "both borrowed connections must start on live physical 
sessions");
+        return busy;
+    }
+
+    /*
+     * The busy connection ends with one live physical session carrying
+     * the marker it was given before the iteration, and the idle control
+     * connection has lost its session to the abandoned sweep.
+     */
+    private void assertBusySurvivedAndIdleAbandoned(Connection busy) throws 
SQLException {
+        Integer marker = sessionMarker(busy);
+        Assertions.assertNotNull(marker,
+                "the session of the busy connection must still be alive");
+        Assertions.assertEquals(1, marker.intValue(),
+                "the busy connection must still own its original session");
+        awaitSessions(1);
+    }
+
+    /*
+     * Iterates a generated multi-row result set with a pause between the
+     * reads - the batch processing between fetches that the abandoned
+     * timeout is meant to look over - for STREAM_MILLIS, and returns the
+     * number of rows read. The small fetch size keeps the driver pulling
+     * from the server during the iteration.
+     */
+    private int streamSlowly(Connection busy) throws SQLException {
+        int rows = 0;
+        try (Statement st = busy.createStatement()) {
+            st.setFetchSize(10);
+            try (ResultSet rs = st.executeQuery(
+                    "SELECT X FROM SYSTEM_RANGE(1,100000)")) {
+                long deadline = System.currentTimeMillis() + STREAM_MILLIS;
+                while (System.currentTimeMillis() < deadline && rs.next()) {
+                    rows++;
+                    sleep(10);
+                }
+            }
+        }
+        return rows;
+    }
+
+    private void assertStreamingKeepsConnectionAlive(boolean 
useStatementFacade) throws Exception {
+        awaitSessions(0);
+        DataSource ds = streamingPool(useStatementFacade);
+        Connection busy = borrowBusyWithIdleControl(ds);
+
+        int rows = streamSlowly(busy);
+        Assertions.assertTrue(rows >= MIN_STREAM_ROWS,
+                "the streaming loop must have run, only " + rows + " rows");
+
+        assertBusySurvivedAndIdleAbandoned(busy);
+    }
+
+    /*
+     * An application iterating a result set is active for as long as it
+     * reads, not only until the query returns the cursor position. The
+     * abandoned timer must be reset by the fetch traffic driven by
+     * ResultSet.next() - through the statement facade that wraps the
+     * interceptor's result set proxy by default.
+     */
+    @Test
+    void streamingAResultSetKeepsConnectionAlive() throws Exception {
+        assertStreamingKeepsConnectionAlive(true);
+    }
+
+    /*
+     * The result set keep-alive belongs to the interceptor itself, not
+     * to the statement facade layered around it: with the facade
+     * switched off, fetching rows must keep the connection alive just
+     * the same.
+     */
+    @Test
+    void streamingKeepsConnectionAliveWithoutStatementFacade() throws 
Exception {
+        assertStreamingKeepsConnectionAlive(false);
+    }
+
+    /*
+     * An application kept busy by result set reads that all fail - a bad
+     * column index in row-processing code, against a result set it still
+     * iterates - is active. A read attempt must reset the abandoned
+     * timer even when it failed, the same contract the connection-level
+     * calls and the statement executions have since the failed-operation
+     * fix: otherwise the pool cleaner kills the connection - and the
+     * open cursor with it - under the app mid-loop.
+     */
+    @Test
+    void failedResultSetReadsKeepConnectionAlive() throws Exception {
+        awaitSessions(0);
+        DataSource ds = streamingPool(true);
+        Connection busy = borrowBusyWithIdleControl(ds);
+
+        int failures = 0;
+        String failureState = null;
+        long deadline = System.currentTimeMillis() + STREAM_MILLIS;
+        try (Statement st = busy.createStatement();
+                ResultSet rs = st.executeQuery("SELECT X FROM 
SYSTEM_RANGE(1,2)")) {
+            Assertions.assertTrue(rs.next());
+            do {
+                SQLException failure = 
Assertions.assertThrows(SQLException.class,
+                        () -> rs.getBlob(999));
+                if (failureState == null) {
+                    failureState = failure.getSQLState();
+                } else {
+                    //Never fail on a session lost mid-storm - that would
+                    //look like a different failure, pin the identity
+                    Assertions.assertEquals(failureState, 
failure.getSQLState(),
+                            "the storm must fail on the bad column index: "
+                                    + failure.getMessage());
+                }
+                failures++;
+            } while (System.currentTimeMillis() < deadline);
+        }
+        Assertions.assertTrue(failures >= MIN_FAILURE_CALLS,
+                "the failure storm must have run, only " + failures + " 
failures");
+
+        assertBusySurvivedAndIdleAbandoned(busy);
+    }
+
+    /*
+     * The result set handle the interceptor inserts under (or, without
+     * the facade, in place of) the facade's own handle must behave: it
+     * must yield the rows, obey the identity contract of a handle (a
+     * handle is equal to itself and to nothing else), hand out a working
+     * statement through getStatement() - the handle in use by this
+     * interceptor, which an interceptor configured around it may have
+     * wrapped further - close cleanly, and do so on all three paths that
+     * produce a result set - executeQuery, getResultSet and
+     * getGeneratedKeys - with the facade composing around it.
+     */
+    @Test
+    void resultSetsOnEveryAccessPathStayUsableAndHandleStable() throws 
Exception {
+        awaitSessions(0);
+        assertResultSetHandlesCompose(true);
+        assertResultSetHandlesCompose(false);
+    }
+
+    private void assertResultSetHandlesCompose(boolean useStatementFacade) 
throws SQLException {
+        DataSource ds = streamingPool(useStatementFacade);
+        Connection con = ds.getConnection();
+        String table = uniqueTable();
+        try (Statement st = con.createStatement()) {
+            try (ResultSet rs = st.executeQuery("SELECT X FROM 
SYSTEM_RANGE(1,3)")) {
+                Assertions.assertSame(st, rs.getStatement(),
+                        "the statement behind the result set must be the 
handle in use");
+                //The identity contract of a handle, as the facade's own
+                //handles honour it (see TestProxyContract)
+                Assertions.assertTrue(rs.equals(rs),
+                        "a result set handle must be equal to itself");
+                Assertions.assertFalse(rs.equals(null),
+                        "a result set handle is not equal to null");
+                Assertions.assertFalse(rs.equals(st),
+                        "a result set handle is not equal to its statement");
+                Assertions.assertFalse(rs.equals(new Object()),
+                        "a result set handle is not equal to a foreign 
object");
+                Assertions.assertTrue(st.equals(st),
+                        "a statement handle must be equal to itself");
+                Assertions.assertFalse(st.equals(null),
+                        "a statement handle is not equal to null");
+                Assertions.assertFalse(st.equals(rs),
+                        "a statement handle is not equal to its result set");
+                int rows = 0;
+                while (rs.next()) {
+                    Assertions.assertEquals(rows + 1, rs.getInt(1));
+                    rows++;
+                }
+                Assertions.assertEquals(3, rows);
+            }
+
+            st.execute("CREATE TABLE " + table
+                    + "(X INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY)");
+            try (PreparedStatement ins = con.prepareStatement(
+                    "INSERT INTO " + table + "(X) VALUES (DEFAULT)",
+                    Statement.RETURN_GENERATED_KEYS)) {
+                Assertions.assertEquals(1, ins.executeUpdate());
+                try (ResultSet keys = ins.getGeneratedKeys()) {
+                    Assertions.assertSame(ins, keys.getStatement());
+                    Assertions.assertTrue(keys.next(),
+                            "the generated key must come through the proxy");
+                }
+            }
+
+            Assertions.assertTrue(st.execute("SELECT X FROM 
SYSTEM_RANGE(1,2)"));
+            try (ResultSet rs = st.getResultSet()) {
+                Assertions.assertNotNull(rs,
+                        "the query result must be reachable through 
getResultSet");
+                Assertions.assertSame(st, rs.getStatement());
+                int rows = 0;
+                while (rs.next()) {
+                    rows++;
+                }
+                Assertions.assertEquals(2, rows);
+            }
+        }
+
+        try (PreparedStatement ps = con.prepareStatement("SELECT X FROM 
SYSTEM_RANGE(4,6)")) {
+            try (ResultSet rs = ps.executeQuery()) {
+                Assertions.assertSame(ps, rs.getStatement());
+                int rows = 0;
+                while (rs.next()) {
+                    rows++;
+                }
+                Assertions.assertEquals(3, rows);
+            }
+        }
+
+        try (Statement st = con.createStatement()) {
+            st.execute("DROP TABLE " + table);
+        }
+        con.close();
+    }
+}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 8ec6b327aa..27e45e9d7f 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -882,6 +882,21 @@
         calls drove – the connection a later borrower was still using.
         (remm)
       </fix>
+      <fix>
+        Keep the abandoned timer alive while a pooled connection is
+        streaming a result set. The <code>ResetAbandonedTimer</code>
+        interceptor only reset the timer while a statement was executing,
+        so a borrower that left a cursor open across a long pause - the
+        usual reason for configuring a fetch size - appeared idle, and a
+        connection that was actively streaming could be reported as
+        abandoned and reclaimed from underneath its borrower. Statement
+        creation and result-set access through a pooled connection now
+        also reset the timer, so the abandoned check only reports a
+        connection that is genuinely making no progress. The
+        <code>jdbcInterceptors</code> documentation, which listed only
+        three of the interceptors bundled with the pool, has been
+        corrected. (remm)
+      </fix>
     </changelog>
   </subsection>
   <subsection name="Other">


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to