StatementCache.java

/* 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.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;
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;

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;
import org.apache.tomcat.jdbc.pool.util.MessageFactory;

/**
 * Interceptor that caches {@code PreparedStatement} and/or {@code CallableStatement} instances on a connection.
 * <p>
 * A cache hit is answered by this interceptor alone: the rest of the interceptor chain is not invoked for it, and a
 * statement recycled into the cache is reset to driver defaults (its query timeout goes back to zero). Interceptors
 * 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: 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);
    /**
     * All statement types (prepared and callable).
     */
    protected static final String[] ALL_TYPES = new String[] { PREPARE_STATEMENT, PREPARE_CALL };
    /**
     * Callable statement type only.
     */
    protected static final String[] CALLABLE_TYPE = new String[] { PREPARE_CALL };
    /**
     * Prepared statement type only.
     */
    protected static final String[] PREPARED_TYPE = new String[] { PREPARE_STATEMENT };
    /**
     * No statement types.
     */
    protected static final String[] NO_TYPE = new String[] {};

    /**
     * Attribute key for storing the statement cache in connection attributes.
     */
    protected static final String STATEMENT_CACHE_ATTR = StatementCache.class.getName() + ".cache";

    private static final String SET_POOLABLE = "setPoolable";

    /**
     * Default constructor.
     */
    public StatementCache() {
    }

    private boolean cachePrepared = true;
    private boolean cacheCallable = false;
    private int maxCacheSize = 50;
    private PooledConnection pcon;
    private String[] types;

    private ObjectName oname = null;

    @Override
    public boolean isCachePrepared() {
        return cachePrepared;
    }

    @Override
    public boolean isCacheCallable() {
        return cacheCallable;
    }

    @Override
    public int getMaxCacheSize() {
        return maxCacheSize;
    }

    /**
     * Returns the statement types being cached.
     *
     * @return array of statement type strings
     */
    public String[] getTypes() {
        return types;
    }

    @Override
    public int getCacheSize() {
        AtomicInteger size = this.cacheSize;
        return size == null ? -1 : size.get();
    }

    @Override
    public void setProperties(Map<String, InterceptorProperty> properties) {
        super.setProperties(properties);
        InterceptorProperty p = properties.get("prepared");
        if (p != null) {
            cachePrepared = p.getValueAsBoolean(cachePrepared);
        }
        p = properties.get("callable");
        if (p != null) {
            cacheCallable = p.getValueAsBoolean(cacheCallable);
        }
        p = properties.get("max");
        if (p != null) {
            maxCacheSize = p.getValueAsInt(maxCacheSize);
        }
        if (cachePrepared && cacheCallable) {
            this.types = ALL_TYPES;
        } else if (cachePrepared) {
            this.types = PREPARED_TYPE;
        } else if (cacheCallable) {
            this.types = CALLABLE_TYPE;
        } else {
            this.types = NO_TYPE;
        }

    }

    private static ConcurrentHashMap<ConnectionPool, AtomicInteger> cacheSizeMap = new ConcurrentHashMap<>();

    private AtomicInteger cacheSize;

    @Override
    public void poolStarted(ConnectionPool pool) {
        cacheSizeMap.putIfAbsent(pool, new AtomicInteger(0));
        super.poolStarted(pool);
    }

    @Override
    public void poolClosed(ConnectionPool pool) {
        cacheSizeMap.remove(pool);
        super.poolClosed(pool);
    }

    @Override
    public void reset(ConnectionPool parent, PooledConnection con) {
        super.reset(parent, con);
        if (parent == null) {
            cacheSize = null;
            this.pcon = null;
            if (oname != null) {
                JmxUtil.unregisterJmx(oname);
                oname = null;
            }
        } else {
            cacheSize = cacheSizeMap.get(parent);
            this.pcon = con;
            if (!pcon.getAttributes().containsKey(STATEMENT_CACHE_ATTR)) {
                ConcurrentHashMap<CacheKey, CachedStatement> cache = new ConcurrentHashMap<>();
                pcon.getAttributes().put(STATEMENT_CACHE_ATTR, cache);
            }
            if (oname == null) {
                String keyprop = ",JdbcInterceptor=" + getClass().getSimpleName();
                oname = JmxUtil.registerJmx(pcon.getObjectName(), keyprop, this);
            }
        }
    }

    @Override
    public void disconnected(ConnectionPool parent, PooledConnection con, boolean finalizing) {
        @SuppressWarnings("unchecked")
        ConcurrentHashMap<CacheKey, CachedStatement> statements = (ConcurrentHashMap<CacheKey, CachedStatement>) con
                .getAttributes().get(STATEMENT_CACHE_ATTR);

        if (statements != null) {
            for (Map.Entry<CacheKey, CachedStatement> p : statements.entrySet()) {
                closeStatement(p.getValue());
            }
            statements.clear();
        }

        super.disconnected(parent, con, finalizing);
    }

    /**
     * Closes the given cached statement by forcing it to close.
     *
     * @param st the cached statement to close
     */
    public void closeStatement(CachedStatement st) {
        if (st == null) {
            return;
        }
        st.forceClose();
    }

    @Override
    protected Object createDecorator(Object proxy, Method method, Object[] args, Object statement,
            Constructor<?> constructor, String sql)
            throws InstantiationException, IllegalAccessException, InvocationTargetException {
        boolean process = process(this.types, method, false);
        if (process) {
            Object result = null;
            CachedStatement statementProxy = new CachedStatement((PreparedStatement) statement, sql);
            result = constructor.newInstance(new Object[] { statementProxy });
            statementProxy.setActualProxy(result);
            statementProxy.setConnection(proxy);
            statementProxy.setConstructor(constructor);
            statementProxy.setCacheKey(createCacheKey(method, args));
            return result;
        } else {
            return super.createDecorator(proxy, method, args, statement, constructor, sql);
        }
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        boolean process = process(this.types, method, false);
        if (process && args.length > 0 && args[0] instanceof String) {
            CachedStatement statement = isCached(method, args);
            if (statement != null) {
                // Remove it from the cache since it is used
                removeStatement(statement);
                if (statement.getAssociatedConnection() != currentPhysicalConnection()) {
                    // The entry belongs to a connection that has been
                    // replaced (a statement close that raced a reconnect
                    // parked it after the disconnect purge ran). It is a
                    // statement of a dead connection: close it and prepare
                    // again rather than hand it to this borrower.
                    closeStatement(statement);
                    return super.invoke(proxy, method, args);
                }
                // The cached proxy carries the connection handle of the
                // borrower that closed the statement, which by now is a
                // disposed facade. Re-bind it to the borrower the proxy is
                // handed to, so that getConnection() on the reused statement
                // reports a live handle instead of the previous borrower's.
                statement.setConnection(proxy);
                return statement.getActualProxy();
            } else {
                return super.invoke(proxy, method, args);
            }
        } else {
            return super.invoke(proxy, method, args);
        }
    }

    /**
     * Checks if a statement for the given method and arguments is currently cached.
     *
     * @param method the SQL method (prepareStatement or prepareCall)
     * @param args   the method arguments
     *
     * @return the cached statement, or null if not cached
     */
    public CachedStatement isCached(Method method, Object[] args) {
        ConcurrentHashMap<CacheKey, CachedStatement> cache = getCache();
        if (cache == null) {
            return null;
        }
        CacheKey key = createCacheKey(method, args);
        if (key == null) {
            throw new IllegalArgumentException(MessageFactory.get("statementCache.nullKey"));
        }
        return cache.get(key);
    }

    /**
     * Caches the given statement proxy if there is room in the cache.
     * <p>
     * The insertion is atomic: of two statements closed concurrently under one key exactly one is parked, and the other
     * is refused as a duplicate - a park can never overwrite, and so can never orphan, a statement another close has
     * already parked. A proxy that is refused was never in the map, and the caller that receives {@code false} must
     * close the statement it offered.
     *
     * @param proxy the statement proxy to cache
     *
     * @return true if the statement was cached, false otherwise
     */
    public boolean cacheStatement(CachedStatement proxy) {
        ConcurrentHashMap<CacheKey, CachedStatement> cache = getCache();
        if (cache == null) {
            return false;
        }
        if (proxy.getCacheKey() == null) {
            return false;
        } else if (proxy.getAssociatedConnection() != currentPhysicalConnection()) {
            // The physical connection the statement was prepared on has
            // been replaced (typically a close that raced a
            // 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;
        } else if (cacheSize.incrementAndGet() > maxCacheSize) {
            cacheSize.decrementAndGet();
            return false;
        }
        // Reserve the slot first and let the map itself arbitrate the
        // duplicate check: putIfAbsent makes the statement visible
        // atomically, so a second close racing in under the same key
        // either sees the parked statement and is refused, or parks its
        // own - it can no longer overwrite it. (The separate
        // containsKey/put pair this replaces let both racers pass the
        // check, counted both, and let the second put orphan the
        // statement the first one had parked.) Reserving before the
        // insert keeps the pool-wide count ahead of the map's visibility,
        // as increment-then-put already did, so retracting a reservation
        // never has to withdraw an entry that another thread can already
        // see, hand out, or close underneath.
        boolean parked = false;
        try {
            parked = cache.putIfAbsent(proxy.getCacheKey(), proxy) == null;
        } finally {
            if (!parked) {
                // The key was already taken, or the insert threw: this
                // statement is not in the map and never was. Undo exactly
                // the one increment this call made and let the caller
                // close it - nothing here can leak a slot or a statement.
                cacheSize.decrementAndGet();
            }
        }
        return parked;
    }

    /**
     * Removes the given statement from the cache. The removal is identity-aware: it only takes effect while the entry
     * under the statement's key still holds this very proxy, so a proxy that was never parked - or is no longer the
     * cached statement - cannot evict a different statement parked under an equal key.
     *
     * @param proxy the statement proxy to remove
     *
     * @return true if the statement was removed, false otherwise
     */
    public boolean removeStatement(CachedStatement proxy) {
        ConcurrentHashMap<CacheKey, CachedStatement> cache = getCache();
        if (cache == null) {
            return false;
        }
        if (cache.remove(proxy.getCacheKey(), proxy)) {
            cacheSize.decrementAndGet();
            return true;
        } else {
            return false;
        }
    }

    /**
     * Returns the physical connection behind the pooled connection this interceptor is bound to, or {@code null} if
     * there is none (yet).
     *
     * @return the current physical connection, or null
     */
    private Connection currentPhysicalConnection() {
        PooledConnection pCon = this.pcon;
        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.
     *
     * @return the cache map, or null if the connection has been closed
     */
    protected ConcurrentHashMap<CacheKey, CachedStatement> getCache() {
        PooledConnection pCon = this.pcon;
        if (pCon == null) {
            if (log.isWarnEnabled()) {
                log.warn(MessageFactory.get("statementCache.connectionClosedOrAbandoned"));
            }
            return null;
        }
        @SuppressWarnings("unchecked")
        ConcurrentHashMap<CacheKey, CachedStatement> cache = (ConcurrentHashMap<CacheKey, CachedStatement>) pCon
                .getAttributes().get(STATEMENT_CACHE_ATTR);
        return cache;
    }

    @Override
    public int getCacheSizePerConnection() {
        ConcurrentHashMap<CacheKey, CachedStatement> cache = getCache();
        if (cache == null) {
            return 0;
        }
        return cache.size();
    }

    /**
     * Proxy for a cached prepared statement.
     */
    protected class CachedStatement extends StatementDecoratorInterceptor.StatementProxy<PreparedStatement> {
        /**
         * Cache key for this statement.
         */
        CacheKey key;
        /**
         * The physical connection this statement was prepared on. Cached statements are bound to the connection they
         * were prepared on; if the pooled connection is reconnected, the entries become stale and must neither be
         * cached nor handed out.
         */
        private final Connection associatedConnection;
        /**
         * Poolable hint set by the application. Tracked here as some drivers (e.g. H2) always return false from
         * {@code isPoolable()}.
         */
        private boolean poolable = true;

        /**
         * 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
         */
        public CachedStatement(PreparedStatement parent, String sql) {
            super(parent, sql);
            associatedConnection = associatedConnectionOf(parent);
        }

        /**
         * 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
         *
         * @return the physical connection of the statement
         */
        private Connection associatedConnectionOf(PreparedStatement statement) {
            try {
                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();
            }
        }

        /**
         * Returns the physical connection this statement was prepared on.
         *
         * @return the connection associated with the statement
         */
        public Connection getAssociatedConnection() {
            return associatedConnection;
        }

        @Override
        public void closeInvoked() throws SQLException {
            // Should we cache it. Read the counter once, as getCacheSize()
            // does: a disposal (reset(null, null) from a finalize or from an
            // abandoned or closed session) nulls the field, and a handle the
            // application kept through that window must still close through
            // the normal path - the sibling entry points answer a disposed
            // cache quietly; this first read has to do the same rather than
            // dereference the field.
            AtomicInteger size = cacheSize;
            boolean shouldClose = true;
            if (size != null && size.get() < maxCacheSize) {
                // 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
                    if (poolable && !getDelegate().isCloseOnCompletion()) {
                        // Clear Resultset
                        ResultSet result = getDelegate().getResultSet();
                        if (result != null && !result.isClosed()) {
                            result.close();
                        }
                        // Clear parameter
                        getDelegate().clearParameters();
                        // Clear batch
                        getDelegate().clearBatch();
                        // Clear warnings
                        getDelegate().clearWarnings();
                        // Reset the timeout
                        getDelegate().setQueryTimeout(0);
                        // Reset fetch direction
                        getDelegate().setFetchDirection(ResultSet.FETCH_FORWARD);
                        // Reset max field size
                        getDelegate().setMaxFieldSize(0);
                        // Reset max rows
                        getDelegate().setMaxRows(0);
                        // Reset fetchsize
                        getDelegate().setFetchSize(0);
                        // Reset the escape processing
                        getDelegate().setEscapeProcessing(true);
                        // Create a new facade
                        Object actualProxy = getConstructor().newInstance(new Object[] { proxy });
                        proxy.setActualProxy(actualProxy);
                        proxy.setConnection(getConnection());
                        proxy.setConstructor(getConstructor());
                        if (cacheStatement(proxy)) {
                            shouldClose = false;
                        }
                    }
                } catch (RuntimeException | ReflectiveOperationException | SQLException x) {
                    // The throw always precedes the park attempt in the try
                    // above, so this proxy is not in the cache; the
                    // identity-aware removal is a no-op for it and leaves any
                    // other statement parked under the same key alone.
                    removeStatement(proxy);
                    if (log.isDebugEnabled()) {
                        log.debug("Unable to recycle statement: " + getSql(), x);
                    }
                }
            }
            if (shouldClose) {
                // The physical close belongs to the superclass, and so does the
                // retirement of this handle: a driver that refuses the close
                // throws from there and the application hears about it. The
                // statement never reached the cache in this branch, so nothing
                // here has to be unwound for it.
                super.closeInvoked();
            }
            closed = true;
            delegate = null;
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            Object result = super.invoke(proxy, method, args);
            if (compare(SET_POOLABLE, method)) {
                poolable = ((Boolean) args[0]).booleanValue();
            }
            return result;
        }

        /**
         * Forces immediate closure of this cached statement. No application waits on this close - the
         * pool is dropping the statement, from the purge that runs when a session is disconnected or
         * from a cache hit on an entry of a replaced session - so a driver that refuses it is logged
         * rather than thrown: the caller walks the cache, and one statement that will not close must
         * not keep the entries after it from being dropped as well.
         */
        public void forceClose() {
            removeStatement(this);
            try {
                super.closeInvoked();
            } catch (SQLException x) {
                log.warn(MessageFactory.get("statementCache.unableToCloseDiscardedStatement", getSql()), x);
            }
        }

        /**
         * Returns the cache key for this statement.
         *
         * @return the cache key
         */
        public CacheKey getCacheKey() {
            return key;
        }

        /**
         * Sets the cache key for this statement.
         *
         * @param cacheKey the cache key
         */
        public void setCacheKey(CacheKey cacheKey) {
            key = cacheKey;
        }

    }

    /**
     * Creates a cache key from the given method and arguments.
     *
     * @param method the SQL method
     * @param args   the method arguments
     *
     * @return the cache key
     */
    protected CacheKey createCacheKey(Method method, Object[] args) {
        return createCacheKey(method.getName(), args);
    }

    /**
     * Creates a cache key from the given method name and arguments.
     *
     * @param methodName the SQL method name
     * @param args       the method arguments
     *
     * @return the cache key, or null if the method name doesn't match a cached type
     */
    protected CacheKey createCacheKey(String methodName, Object[] args) {
        CacheKey key = null;
        if (compare(PREPARE_STATEMENT, methodName)) {
            key = new CacheKey(PREPARE_STATEMENT, args);
        } else if (compare(PREPARE_CALL, methodName)) {
            key = new CacheKey(PREPARE_CALL, args);
        }
        return key;
    }


    private static final class CacheKey {
        private final String stmtType;
        private final Object[] args;

        /*
         * The key must be an immutable snapshot of the call it was made for.
         * It is built from the live argument array of the prepare call - the
         * outer Object[] allocated by the connection proxy for that call,
         * whose int[]/String[] elements (prepareStatement(String, int[]) and
         * prepareStatement(String, String[])) are the application's own
         * arrays - and the key built at creation time is the one that parks
         * the statement, living as long as the physical session. Storing
         * those arrays by reference would let a caller that reuses and
         * mutates one of them after the prepare move the parked entry's hash
         * bucket under the map, since hashCode() below re-computes
         * Arrays.deepHashCode over the live data: the entry becomes
         * unreachable to lookups while its statement stays open on the
         * session and its slot stays counted, each further drifted key
         * parking another such entry until the cache is full. So snapshot
         * everything the hash and the comparison descend into: the outer
         * array - per-call on the proxy path, but caller-owned through the
         * createCacheKey entry point - and every array element, one level
         * deep, which is as far as deepHashCode/deepEquals reach for the
         * arrays the prepare methods can carry, whose own elements are
         * immutable Strings or primitives. A null element is no array,
         * instanceof answers false for it, and it is stored and hashed as
         * it stands.
         */
        private CacheKey(String type, Object[] methodArgs) {
            stmtType = type;
            args = new Object[methodArgs.length];
            for (int i = 0; i < methodArgs.length; i++) {
                Object arg = methodArgs[i];
                if (arg instanceof int[]) {
                    arg = ((int[]) arg).clone();
                } else if (arg instanceof Object[]) {
                    arg = ((Object[]) arg).clone();
                }
                args[i] = arg;
            }
        }

        @Override
        public int hashCode() {
            final int prime = 31;
            int result = 1;
            result = prime * result + Arrays.deepHashCode(args);
            result = prime * result + ((stmtType == null) ? 0 : stmtType.hashCode());
            return result;
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (obj == null) {
                return false;
            }
            if (getClass() != obj.getClass()) {
                return false;
            }
            CacheKey other = (CacheKey) obj;
            if (!Arrays.deepEquals(args, other.args)) {
                return false;
            }
            if (stmtType == null) {
                if (other.stmtType != null) {
                    return false;
                }
            } else if (!stmtType.equals(other.stmtType)) {
                return false;
            }
            return true;
        }
    }
}