SlowQueryReport.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.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.ConcurrentHashMap;

import javax.management.openmbean.CompositeDataSupport;
import javax.management.openmbean.CompositeType;
import javax.management.openmbean.OpenDataException;
import javax.management.openmbean.OpenType;
import javax.management.openmbean.SimpleType;

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

/**
 * Slow query report interceptor. Tracks timing of query executions.
 * <p>
 * The statistics are published under the pool <em>name</em> (see {@link #getPoolStats(String)}), registered when a pool
 * starts and released when the last live pool registered under that name closes. Pools configured with the same name -
 * possible with {@code jmxEnabled=false}, which removes the JMX ObjectName clash that would otherwise catch it -
 * therefore share one statistics map; closing one of them never takes the shared statistics away from the others.
 * </p>
 */
public class SlowQueryReport extends AbstractQueryReport {

    private static final Log log = LogFactory.getLog(SlowQueryReport.class);

    /**
     * we will be keeping track of query stats on a per pool basis, keyed by pool name; pools registered under one name
     * share the statistics
     */
    protected static final ConcurrentHashMap<String, PoolStatistics> perPoolStats = new ConcurrentHashMap<>();
    /**
     * the queries that are used for this interceptor.
     */
    protected volatile ConcurrentHashMap<String, QueryStats> queries = null;
    /**
     * Maximum number of queries we will be storing statistics for. Eviction keeps the number of stored queries near
     * this amount; while queries are being executed concurrently it can briefly be exceeded. This sizes the statistics
     * store only: the slow and failed query logging is configured by {@link #setLogSlow(boolean)} and
     * {@link #setLogFailed(boolean)} independently of this value.
     */
    protected int maxQueries = 1000; // Don't store more than this amount of queries

    /**
     * Flag to enable disable logging of slow queries
     */
    protected boolean logSlow = true;

    /**
     * Flag to enable disable logging of failed queries
     */
    protected boolean logFailed = false;

    /**
     * Sort QueryStats by last invocation time
     */
    protected final Comparator<QueryStats> queryStatsComparator = new QueryStatsComparator();

    /**
     * Returns the query stats for a given pool
     *
     * @param poolname - the name of the pool we want to retrieve stats for
     *
     * @return a hash map containing the statistics of the monitored queries, kept near {@code maxQueries} entries by
     *             eviction, or {@code null} if no live pool is registered under that name. Pools registered under one
     *             name share the map, and it is published until the last of them has closed
     */
    public static ConcurrentHashMap<String, QueryStats> getPoolStats(String poolname) {
        PoolStatistics stats = perPoolStats.get(poolname);
        return stats == null ? null : stats.getQueryStats();
    }

    /**
     * Creates a slow query report interceptor
     */
    public SlowQueryReport() {
        super();
    }

    /**
     * Sets the maximum number of queries to track statistics for.
     *
     * @param maxQueries the maximum number of queries
     */
    public void setMaxQueries(int maxQueries) {
        this.maxQueries = maxQueries;
    }


    @Override
    protected String reportFailedQuery(String query, Object[] args, String name, long start, long delta,
            Throwable t) {
        String sql = super.reportFailedQuery(query, args, name, start, delta, t);
        // The wall clock is read for the timestamp the failure is recorded
        // against - a point in time, which is what the statistics keep the
        // last invocation as. The duration is the monotonic elapsed time the
        // statement proxy measured, so a step of the wall clock during the
        // execution cannot make the booked duration negative, as it could
        // while this hook recomputed it from start.
        long now = System.currentTimeMillis();
        if (this.maxQueries > 0) {
            QueryStats qs = this.getQueryStats(sql);
            if (qs != null) {
                qs.failure(delta, now);
            }
        }
        // The WARN reports the event, not the statistics booking: the
        // logFailed flag configures it, and maxQueries only sizes the
        // statistics store. Keeping the log inside the capacity guard
        // made maxQueries=0 - "store no per-query statistics" - silence
        // the log channel while SlowQueryReportJmx kept sending the
        // FAILED QUERY notification behind isLogFailed() alone, the two
        // channels disagreeing on the same event.
        if (isLogFailed() && log.isWarnEnabled()) {
            log.warn(MessageFactory.get("slowQueryReport.failedQueryReport", sql, delta));
        }
        return sql;
    }

    @Override
    protected String reportQuery(String query, Object[] args, final String name, long start, long delta) {
        String sql = super.reportQuery(query, args, name, start, delta);
        if (this.maxQueries > 0) {
            QueryStats qs = this.getQueryStats(sql);
            if (qs != null) {
                qs.add(delta, start);
            }
        }
        return sql;
    }

    @Override
    protected String reportSlowQuery(String query, Object[] args, String name, long start, long delta) {
        String sql = super.reportSlowQuery(query, args, name, start, delta);
        if (this.maxQueries > 0) {
            QueryStats qs = this.getQueryStats(sql);
            if (qs != null) {
                qs.add(delta, start);
            }
        }
        // The WARN reports the event, not the statistics booking: the
        // logSlow flag configures it, and maxQueries only sizes the
        // statistics store. The log must therefore sit outside the
        // capacity guard, and outside the statistics lookup altogether:
        // getQueryStats legitimately hands back no entry - when the
        // store is disabled, when this instance's statistics reference
        // was released by a chain release, or when the entry was lost to
        // the post-putIfAbsent get race against capacity eviction - and
        // gating the WARN on it would let the log channel fall silent
        // while SlowQueryReportJmx keeps sending the SLOW QUERY
        // notification behind isLogSlow() alone, the two channels
        // disagreeing on the same event. This mirrors how
        // reportFailedQuery reports its event.
        if (isLogSlow() && log.isWarnEnabled()) {
            log.warn(MessageFactory.get("slowQueryReport.slowQueryReport", sql, delta));
        }
        return sql;
    }

    /**
     * invoked when the connection receives the close request Not used for now.
     */
    @Override
    public void closeInvoked() {
        // NOOP
    }

    /**
     * Tracks the preparation time for a prepared statement.
     *
     * @param sql  the SQL statement
     * @param time the time taken to prepare the statement
     */
    @Override
    public void prepareStatement(String sql, long time) {
        if (this.maxQueries > 0) {
            QueryStats qs = getQueryStats(sql);
            if (qs != null) {
                qs.prepare(time);
            }
        }
    }

    /**
     * Tracks the preparation time for a callable statement.
     *
     * @param sql  the SQL call
     * @param time the time taken to prepare the call
     */
    @Override
    public void prepareCall(String sql, long time) {
        if (this.maxQueries > 0) {
            QueryStats qs = getQueryStats(sql);
            if (qs != null) {
                qs.prepare(time);
            }
        }
    }

    /**
     * Initializes the query statistics map for the given connection pool.
     * <p>
     * The map is registered under the pool name together with the pool instance itself. If a live pool is already
     * registered under the name, both pools share one statistics map; that merging is the pre-existing behaviour, but
     * it is a configuration mistake worth a warning, so it is logged.
     * </p>
     *
     * @param pool the connection pool that has started
     */
    @Override
    public void poolStarted(ConnectionPool pool) {
        super.poolStarted(pool);
        // See if we already created a map for this pool - and register
        // this pool as one of the live pools the map is held for
        PoolStatistics stats = perPoolStats.compute(pool.getName(), (name, existing) -> {
            PoolStatistics registration = (existing == null) ? new PoolStatistics() : existing;
            registration.addLivePool(pool);
            return registration;
        });
        if (stats.hasOtherLivePools() && log.isWarnEnabled()) {
            log.warn(MessageFactory.get("slowQueryReport.poolNameInUse", pool.getName()));
        }
        queries = stats.getQueryStats();
    }

    /**
     * Releases the query statistics map for the given connection pool, unless another live pool is still registered
     * under its name.
     * <p>
     * The callback is delivered by {@link ConnectionPool} to a freshly instantiated interceptor (see
     * {@code ConnectionPool#close(boolean)}), which has never held a reference to the statistics, so the map to release
     * can only be identified through the closing pool passed here, not through this instance's own state. The
     * statistics stay published as long as one pool registered under the name is alive.
     * </p>
     *
     * @param pool the connection pool that has been closed
     */
    @Override
    public void poolClosed(ConnectionPool pool) {
        lastPoolOfNameClosed = releaseStatistics(pool);
        super.poolClosed(pool);
    }

    /**
     * Whether the {@link #poolClosed(ConnectionPool)} delivered to this instance deregistered the closing pool as the
     * last live pool under its name, meaning everything else registered for that name - the interceptor's MBean and
     * broadcaster, for instance - can and must now be released with it. The deciding
     * {@link #releaseStatistics(ConnectionPool)} call is atomic, so across concurrent closes of same-named pools and
     * across the interceptor definitions of one close exactly one caller ever sees {@code true}.
     */
    protected boolean lastPoolOfNameClosed = false;

    /**
     * Deregisters a closed pool from the live pools that the per-name statistics are held for, and releases the
     * statistics once no live pool is registered under the name any more.
     * <p>
     * The decision is taken inside a {@code compute} on the name, which atomises it against the concurrent removals of
     * other same-named pools: exactly one of any number of concurrent {@code poolClosed} deliveries sees {@code true}
     * returned, so the per-name entries are never released twice and never left behind.
     * </p>
     *
     * @param pool the connection pool that has been closed
     *
     * @return {@code true} if this call removed the last live pool registered under the name
     */
    protected static boolean releaseStatistics(ConnectionPool pool) {
        boolean[] last = new boolean[1];
        perPoolStats.computeIfPresent(pool.getName(), (name, existing) -> {
            last[0] = existing.removeLivePool(pool);
            return last[0] ? null : existing;
        });
        return last[0];
    }

    /**
     * Retrieves or creates a QueryStats entry for the given SQL string.
     *
     * @param sql the SQL query string
     *
     * @return the query statistics, or {@code null} if the pool has been closed
     */
    protected QueryStats getQueryStats(String sql) {
        if (sql == null) {
            sql = "";
        }
        ConcurrentHashMap<String, QueryStats> queries = SlowQueryReport.this.queries;
        if (queries == null) {
            if (log.isWarnEnabled()) {
                log.warn(MessageFactory.get("slowQueryReport.connectionClosedOrAbandoned"));
            }
            return null;
        }
        QueryStats qs = queries.get(sql);
        if (qs == null) {
            qs = new QueryStats(sql);
            if (queries.putIfAbsent(sql, qs) != null) {
                qs = queries.get(sql);
            } else {
                // We added a new element, see if we need to remove the oldest
                if (queries.size() > maxQueries) {
                    removeOldest(queries);
                }
            }
        }
        return qs;
    }

    /**
     * Evicts the least recently invoked query statistics until the map is back within {@code maxQueries} entries. The
     * map is shared by all the connections of the pool, so the eviction runs concurrently with the inserts and removals
     * of the other borrower threads and can only do as much work as its own snapshot allows; it then leaves the map
     * above capacity for the next new query to evict further.
     *
     * @param queries The queries map
     */
    protected void removeOldest(ConcurrentHashMap<String, QueryStats> queries) {
        // Sort a snapshot of the statistics rather than the live entries:
        // the invocation times are updated concurrently, and a comparator
        // that reads mutable state may violate its own contract while the
        // sort is in progress.
        ArrayList<QueryStats> snapshot = new ArrayList<>(queries.size());
        for (QueryStats qs : queries.values()) {
            snapshot.add(qs.snapshotForEviction());
        }
        Collections.sort(snapshot, queryStatsComparator);
        // The map is shared by all connections of the pool, so it may have
        // grown or shrunk since the snapshot was taken. Entries that another
        // thread removed already make our removal a no-op, so the snapshot
        // can run out while the map is still over capacity. Eviction then
        // gives up rather than running past the end of the list, leaving the
        // map over capacity by about as many queries as were being inserted
        // concurrently; the next new query evicts further.
        int removeIndex = 0;
        while (queries.size() > maxQueries && removeIndex < snapshot.size()) {
            String sql = snapshot.get(removeIndex).getQuery();
            removeIndex++;
            queries.remove(sql);
            if (log.isDebugEnabled()) {
                log.debug("Removing slow query, capacity reached:" + sql);
            }
        }
    }


    /**
     * Resets the query statistics reference when a connection is reset.
     *
     * @param parent the connection pool
     * @param con    the pooled connection being reset
     */
    @Override
    public void reset(ConnectionPool parent, PooledConnection con) {
        super.reset(parent, con);
        if (parent != null) {
            queries = getPoolStats(parent.getName());
        } else {
            queries = null;
        }
    }


    /**
     * Returns whether slow query logging is enabled.
     *
     * @return {@code true} if slow query logging is enabled
     */
    public boolean isLogSlow() {
        return logSlow;
    }

    /**
     * Enables or disables slow query logging.
     *
     * @param logSlow {@code true} to enable slow query logging
     */
    public void setLogSlow(boolean logSlow) {
        this.logSlow = logSlow;
    }

    /**
     * Returns whether failed query logging is enabled.
     *
     * @return {@code true} if failed query logging is enabled
     */
    public boolean isLogFailed() {
        return logFailed;
    }

    /**
     * Enables or disables failed query logging.
     *
     * @param logFailed {@code true} to enable failed query logging
     */
    public void setLogFailed(boolean logFailed) {
        this.logFailed = logFailed;
    }

    /**
     * Configures this interceptor from the given properties map, supporting threshold, maxQueries, logSlow, and
     * logFailed settings.
     *
     * @param properties the interceptor properties
     */
    @Override
    public void setProperties(Map<String, InterceptorProperty> properties) {
        super.setProperties(properties);
        final String threshold = "threshold";
        final String maxqueries = "maxQueries";
        final String logslow = "logSlow";
        final String logfailed = "logFailed";
        InterceptorProperty p1 = properties.get(threshold);
        InterceptorProperty p2 = properties.get(maxqueries);
        InterceptorProperty p3 = properties.get(logslow);
        InterceptorProperty p4 = properties.get(logfailed);
        if (p1 != null) {
            setThreshold(Long.parseLong(p1.getValue()));
        }
        if (p2 != null) {
            setMaxQueries(Integer.parseInt(p2.getValue()));
        }
        if (p3 != null) {
            setLogSlow(Boolean.parseBoolean(p3.getValue()));
        }
        if (p4 != null) {
            setLogFailed(Boolean.parseBoolean(p4.getValue()));
        }
    }


    /**
     * The query statistics published under one pool name, together with the live pools registered for that name.
     * ConnectionPool does not override {@code equals} or {@code hashCode}, so pool membership is by instance identity.
     * Instances are mutated only from within a {@code ConcurrentHashMap.compute} on their name key, which serialises
     * them against each other.
     */
    protected static class PoolStatistics {
        /**
         * the statistics map shared by all pools registered under the name, kept near {@code maxQueries} entries by
         * eviction
         */
        private final ConcurrentHashMap<String, QueryStats> stats = new ConcurrentHashMap<>();
        /**
         * the live pools registered under the name; the statistics are released once this set runs empty
         */
        private final Set<ConnectionPool> livePools = ConcurrentHashMap.newKeySet();

        /**
         * Registers a pool that started under this name.
         *
         * @param pool the connection pool that has started
         */
        protected void addLivePool(ConnectionPool pool) {
            livePools.add(pool);
        }

        /**
         * Deregisters a pool that closed.
         *
         * @param pool the connection pool that has been closed
         *
         * @return {@code true} if this was the last live pool registered under the name, so the statistics can be
         *             released
         */
        protected boolean removeLivePool(ConnectionPool pool) {
            livePools.remove(pool);
            return livePools.isEmpty();
        }

        /**
         * Returns whether pools other than the caller's own are registered under this name. The caller registers its
         * own pool before asking, so more than one registration means another live pool shares the name.
         *
         * @return {@code true} if another live pool shares this name
         */
        protected boolean hasOtherLivePools() {
            return livePools.size() > 1;
        }

        /**
         * Returns whether the given pool is one of the live pools registered under this name. The live-pool set is
         * concurrent, so this plain membership read is safe outside the {@code ConcurrentHashMap.compute} that mutates
         * it: it tells a caller, without making a concurrent start wait behind a lock, whether the name is still held
         * live by the given pool.
         *
         * @param pool the pool to look for
         *
         * @return {@code true} if the pool is registered as live under this name
         */
        protected boolean hasLivePool(ConnectionPool pool) {
            return livePools.contains(pool);
        }

        /**
         * Returns the statistics map published under this name.
         *
         * @return the shared query statistics map
         */
        protected ConcurrentHashMap<String, QueryStats> getQueryStats() {
            return stats;
        }
    }


    /**
     * Holds timing and invocation statistics for a single SQL query.
     */
    public static class QueryStats {
        static final String[] FIELD_NAMES = new String[] { "query", "nrOfInvocations", "maxInvocationTime",
                "maxInvocationDate", "minInvocationTime", "minInvocationDate", "totalInvocationTime", "failures",
                "prepareCount", "prepareTime", "lastInvocation" };

        static final String[] FIELD_DESCRIPTIONS = new String[] { "The SQL query",
                "The number of query invocations, a call to executeXXX",
                "The longest time for this query in milliseconds",
                "The time and date for when the longest query took place",
                "The shortest time for this query in milliseconds",
                "The time and date for when the shortest query took place",
                "The total amount of milliseconds spent executing this query", "The number of failures for this query",
                "The number of times this query was prepared (prepareStatement/prepareCall)",
                "The total number of milliseconds spent preparing this query",
                "The date and time of the last invocation or preparation" };

        static final OpenType<?>[] FIELD_TYPES = new OpenType[] { SimpleType.STRING, SimpleType.INTEGER,
                SimpleType.LONG, SimpleType.LONG, SimpleType.LONG, SimpleType.LONG, SimpleType.LONG, SimpleType.LONG,
                SimpleType.INTEGER, SimpleType.LONG, SimpleType.LONG };

        private final String query;
        private volatile int nrOfInvocations;
        private volatile long maxInvocationTime = Long.MIN_VALUE;
        private volatile long maxInvocationDate;
        private volatile long minInvocationTime = Long.MAX_VALUE;
        private volatile long minInvocationDate;
        private volatile long totalInvocationTime;
        private volatile long failures;
        private volatile int prepareCount;
        private volatile long prepareTime;
        private volatile long lastInvocation = 0;

        /**
         * Returns the names of the fields in the composite data representation.
         *
         * @return the field names
         */
        public static String[] getFieldNames() {
            return FIELD_NAMES;
        }

        /**
         * Returns the descriptions of the fields in the composite data representation.
         *
         * @return the field descriptions
         */
        public static String[] getFieldDescriptions() {
            return FIELD_DESCRIPTIONS;
        }

        /**
         * Returns the JMX OpenType of each field in the composite data representation.
         *
         * @return the field types
         */
        public static OpenType<?>[] getFieldTypes() {
            return FIELD_TYPES;
        }

        @Override
        public String toString() {
            SimpleDateFormat sdf = new SimpleDateFormat("d MMM yyyy HH:mm:ss z", Locale.US);
            sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
            StringBuilder buf = new StringBuilder("QueryStats[query:");
            buf.append(query);
            buf.append(", nrOfInvocations:");
            buf.append(nrOfInvocations);
            buf.append(", maxInvocationTime:");
            buf.append(maxInvocationTime);
            buf.append(", maxInvocationDate:");
            buf.append(sdf.format(new java.util.Date(maxInvocationDate)));
            buf.append(", minInvocationTime:");
            buf.append(minInvocationTime);
            buf.append(", minInvocationDate:");
            buf.append(sdf.format(new java.util.Date(minInvocationDate)));
            buf.append(", totalInvocationTime:");
            buf.append(totalInvocationTime);
            buf.append(", averageInvocationTime:");
            buf.append((float) totalInvocationTime / (float) nrOfInvocations);
            buf.append(", failures:");
            buf.append(failures);
            buf.append(", prepareCount:");
            buf.append(prepareCount);
            buf.append(", prepareTime:");
            buf.append(prepareTime);
            buf.append(']');
            return buf.toString();
        }

        /**
         * Returns the query statistics as JMX CompositeData.
         *
         * @param type the composite type definition
         *
         * @return the composite data representation of this query's statistics
         *
         * @throws OpenDataException if the data cannot be converted
         */
        public CompositeDataSupport getCompositeData(final CompositeType type) throws OpenDataException {
            Object[] values = new Object[] { query, Integer.valueOf(nrOfInvocations), Long.valueOf(maxInvocationTime),
                    Long.valueOf(maxInvocationDate), Long.valueOf(minInvocationTime), Long.valueOf(minInvocationDate),
                    Long.valueOf(totalInvocationTime), Long.valueOf(failures), Integer.valueOf(prepareCount),
                    Long.valueOf(prepareTime), Long.valueOf(lastInvocation) };
            return new CompositeDataSupport(type, FIELD_NAMES, values);
        }

        /**
         * Creates a new QueryStats instance for the given SQL query.
         *
         * @param query the SQL query string
         */
        public QueryStats(String query) {
            this.query = query;
        }

        /**
         * Returns a snapshot of the data that orders this query for eviction. Only the query and the time of its last
         * invocation are carried over: the snapshot exists to be sorted, and unlike the live statistics its ordering
         * does not change while a sort is running.
         *
         * @return a snapshot of this query and its last invocation time
         */
        private QueryStats snapshotForEviction() {
            QueryStats result = new QueryStats(query);
            result.lastInvocation = lastInvocation;
            return result;
        }

        /**
         * Records the time taken to prepare this query. Preparation counts as activity on the entry, so the last
         * invocation time that orders the statistics for eviction is updated as well: a statement that is prepared but
         * never executed must age like any other query instead of occupying a statistics slot for ever.
         *
         * @param invocationTime the time in milliseconds to prepare the query
         */
        public synchronized void prepare(long invocationTime) {
            prepareCount++;
            prepareTime += invocationTime;
            lastInvocation = System.currentTimeMillis();
        }

        /**
         * Records a successful query invocation, updating min/max/total timing statistics.
         *
         * @param invocationTime the time in milliseconds the query took
         * @param now            the current timestamp in milliseconds
         */
        public synchronized void add(long invocationTime, long now) {
            maxInvocationTime = Math.max(invocationTime, maxInvocationTime);
            if (maxInvocationTime == invocationTime) {
                maxInvocationDate = now;
            }
            minInvocationTime = Math.min(invocationTime, minInvocationTime);
            if (minInvocationTime == invocationTime) {
                minInvocationDate = now;
            }
            nrOfInvocations++;
            totalInvocationTime += invocationTime;
            lastInvocation = now;
        }

        /**
         * Records a failed query invocation, updating timing statistics and failure count.
         *
         * @param invocationTime the time in milliseconds the query took before failing
         * @param now            the current timestamp in milliseconds
         */
        public synchronized void failure(long invocationTime, long now) {
            add(invocationTime, now);
            failures++;

        }

        /**
         * Returns the SQL query string for these statistics.
         *
         * @return the SQL query string
         */
        public String getQuery() {
            return query;
        }

        /**
         * Returns the total number of times this query has been invoked.
         *
         * @return the number of invocations
         */
        public int getNrOfInvocations() {
            return nrOfInvocations;
        }

        /**
         * Returns the longest time this query took to execute.
         *
         * @return the maximum invocation time in milliseconds
         */
        public long getMaxInvocationTime() {
            return maxInvocationTime;
        }

        /**
         * Returns the timestamp when the longest query execution occurred.
         *
         * @return the timestamp in milliseconds of the maximum invocation
         */
        public long getMaxInvocationDate() {
            return maxInvocationDate;
        }

        /**
         * Returns the shortest time this query took to execute.
         *
         * @return the minimum invocation time in milliseconds
         */
        public long getMinInvocationTime() {
            return minInvocationTime;
        }

        /**
         * Returns the timestamp when the shortest query execution occurred.
         *
         * @return the timestamp in milliseconds of the minimum invocation
         */
        public long getMinInvocationDate() {
            return minInvocationDate;
        }

        /**
         * Returns the total time spent executing this query across all invocations.
         *
         * @return the total invocation time in milliseconds
         */
        public long getTotalInvocationTime() {
            return totalInvocationTime;
        }

        @Override
        public int hashCode() {
            return query.hashCode();
        }

        @Override
        public boolean equals(Object other) {
            if (other instanceof QueryStats) {
                QueryStats qs = (QueryStats) other;
                return qs.query.equals(this.query);
            }
            return false;
        }

        /**
         * Returns whether this query was last invoked before the given query stats.
         *
         * @param other the other query statistics to compare against
         *
         * @return {@code true} if this query's last invocation is older
         */
        public boolean isOlderThan(QueryStats other) {
            return this.lastInvocation < other.lastInvocation;
        }
    }

    /**
     * Compares QueryStats by their lastInvocation value. QueryStats that have never been updated have a lastInvocation
     * value of {@code 0} which is treated as the newest possible invocation.
     */
    // Public for unit tests
    public static class QueryStatsComparator implements Comparator<QueryStats> {

        /**
         * Creates a new QueryStatsComparator.
         */
        public QueryStatsComparator() {
        }

        @Override
        public int compare(QueryStats stats1, QueryStats stats2) {
            return Long.compare(handleZero(stats1.lastInvocation), handleZero(stats2.lastInvocation));
        }

        private static long handleZero(long value) {
            return value == 0 ? Long.MAX_VALUE : value;
        }

    }

}