001package org.apache.commons.jcs3.auxiliary.disk.jdbc;
002
003/*
004 * Licensed to the Apache Software Foundation (ASF) under one
005 * or more contributor license agreements.  See the NOTICE file
006 * distributed with this work for additional information
007 * regarding copyright ownership.  The ASF licenses this file
008 * to you under the Apache License, Version 2.0 (the
009 * "License"); you may not use this file except in compliance
010 * with the License.  You may obtain a copy of the License at
011 *
012 *   http://www.apache.org/licenses/LICENSE-2.0
013 *
014 * Unless required by applicable law or agreed to in writing,
015 * software distributed under the License is distributed on an
016 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
017 * KIND, either express or implied.  See the License for the
018 * specific language governing permissions and limitations
019 * under the License.
020 */
021
022import java.io.IOException;
023import java.sql.Connection;
024import java.sql.DatabaseMetaData;
025import java.sql.PreparedStatement;
026import java.sql.ResultSet;
027import java.sql.SQLException;
028import java.sql.Timestamp;
029import java.util.HashMap;
030import java.util.List;
031import java.util.Map;
032import java.util.Set;
033import java.util.concurrent.atomic.AtomicInteger;
034
035import javax.sql.DataSource;
036
037import org.apache.commons.jcs3.auxiliary.AuxiliaryCacheAttributes;
038import org.apache.commons.jcs3.auxiliary.disk.AbstractDiskCache;
039import org.apache.commons.jcs3.auxiliary.disk.jdbc.dsfactory.DataSourceFactory;
040import org.apache.commons.jcs3.engine.behavior.ICache;
041import org.apache.commons.jcs3.engine.behavior.ICacheElement;
042import org.apache.commons.jcs3.engine.logging.behavior.ICacheEvent;
043import org.apache.commons.jcs3.engine.logging.behavior.ICacheEventLogger;
044import org.apache.commons.jcs3.engine.stats.StatElement;
045import org.apache.commons.jcs3.engine.stats.behavior.IStatElement;
046import org.apache.commons.jcs3.engine.stats.behavior.IStats;
047import org.apache.commons.jcs3.log.Log;
048import org.apache.commons.jcs3.log.LogManager;
049
050/**
051 * This is the jdbc disk cache plugin.
052 * <p>
053 * It expects a table created by the following script. The table name is configurable.
054 * </p>
055 * <pre>
056 *                       drop TABLE JCS_STORE;
057 *                       CREATE TABLE JCS_STORE
058 *                       (
059 *                       CACHE_KEY                  VARCHAR(250)          NOT NULL,
060 *                       REGION                     VARCHAR(250)          NOT NULL,
061 *                       ELEMENT                    BLOB,
062 *                       CREATE_TIME                TIMESTAMP,
063 *                       UPDATE_TIME_SECONDS        BIGINT,
064 *                       MAX_LIFE_SECONDS           BIGINT,
065 *                       SYSTEM_EXPIRE_TIME_SECONDS BIGINT,
066 *                       IS_ETERNAL                 CHAR(1),
067 *                       PRIMARY KEY (CACHE_KEY, REGION)
068 *                       );
069 * </pre>
070 * <p>
071 * The cleanup thread will delete non eternal items where (now - create time) &gt; max life seconds *
072 * 1000
073 * </p>
074 * <p>
075 * To speed up the deletion the SYSTEM_EXPIRE_TIME_SECONDS is used instead. It is recommended that
076 * an index be created on this column is you will have over a million records.
077 * </p>
078 */
079public class JDBCDiskCache<K, V>
080    extends AbstractDiskCache<K, V>
081{
082    /** The local logger. */
083    private static final Log log = LogManager.getLog( JDBCDiskCache.class );
084
085    /** configuration */
086    private JDBCDiskCacheAttributes jdbcDiskCacheAttributes;
087
088    /** # of times update was called */
089    private final AtomicInteger updateCount = new AtomicInteger(0);
090
091    /** # of times get was called */
092    private final AtomicInteger getCount = new AtomicInteger(0);
093
094    /** # of times getMatching was called */
095    private final AtomicInteger getMatchingCount = new AtomicInteger(0);
096
097    /** db connection pool */
098    private final DataSourceFactory dsFactory;
099
100    /** tracks optimization */
101    private TableState tableState;
102
103    /**
104     * Constructs a JDBC Disk Cache for the provided cache attributes. The table state object is
105     * used to mark deletions.
106     * <p>
107     * @param cattr the configuration object for this cache
108     * @param dsFactory the DataSourceFactory for this cache
109     * @param tableState an object to track table operations
110     */
111    public JDBCDiskCache(final JDBCDiskCacheAttributes cattr, final DataSourceFactory dsFactory, final TableState tableState)
112    {
113        super( cattr );
114
115        setTableState( tableState );
116        setJdbcDiskCacheAttributes( cattr );
117
118        log.info( "jdbcDiskCacheAttributes = {0}", this::getJdbcDiskCacheAttributes);
119
120        // This initializes the pool access.
121        this.dsFactory = dsFactory;
122
123        // Initialization finished successfully, so set alive to true.
124        setAlive(true);
125    }
126
127    /**
128     * Inserts or updates. By default it will try to insert. If the item exists we will get an
129     * error. It will then update. This behavior is configurable. The cache can be configured to
130     * check before inserting.
131     * <p>
132     * @param ce
133     */
134    @Override
135    protected void processUpdate( final ICacheElement<K, V> ce )
136    {
137        updateCount.incrementAndGet();
138
139        log.debug( "updating, ce = {0}", ce );
140
141        try (Connection con = getDataSource().getConnection())
142        {
143            log.debug( "Putting [{0}] on disk.", ce::getKey);
144
145            try
146            {
147                final byte[] element = getElementSerializer().serialize( ce );
148                insertOrUpdate( ce, con, element );
149            }
150            catch ( final IOException e )
151            {
152                log.error( "Could not serialize element", e );
153            }
154        }
155        catch ( final SQLException e )
156        {
157            log.error( "Problem getting connection.", e );
158        }
159    }
160
161    /**
162     * If test before insert it true, we check to see if the element exists. If the element exists
163     * we will update. Otherwise, we try inserting.  If this fails because the item exists, we will
164     * update.
165     * <p>
166     * @param ce
167     * @param con
168     * @param element
169     */
170    private void insertOrUpdate( final ICacheElement<K, V> ce, final Connection con, final byte[] element )
171    {
172        boolean exists = false;
173
174        // First do a query to determine if the element already exists
175        if ( this.getJdbcDiskCacheAttributes().isTestBeforeInsert() )
176        {
177            exists = doesElementExist( ce, con );
178        }
179
180        // If it doesn't exist, insert it, otherwise update
181        if ( !exists )
182        {
183            exists = insertRow( ce, con, element );
184        }
185
186        // update if it exists.
187        if ( exists )
188        {
189            updateRow( ce, con, element );
190        }
191    }
192
193    /**
194     * This inserts a new row in the database.
195     * <p>
196     * @param ce
197     * @param con
198     * @param element
199     * @return true if the insertion fails because the record exists.
200     */
201    private boolean insertRow( final ICacheElement<K, V> ce, final Connection con, final byte[] element )
202    {
203        boolean exists = false;
204        final String sqlI = String.format("insert into %s"
205                + " (CACHE_KEY, REGION, ELEMENT, MAX_LIFE_SECONDS, IS_ETERNAL, CREATE_TIME, UPDATE_TIME_SECONDS,"
206                + " SYSTEM_EXPIRE_TIME_SECONDS) "
207                + " values (?, ?, ?, ?, ?, ?, ?, ?)", getJdbcDiskCacheAttributes().getTableName());
208
209        try (PreparedStatement psInsert = con.prepareStatement( sqlI ))
210        {
211            psInsert.setString( 1, ce.getKey().toString() );
212            psInsert.setString( 2, this.getCacheName() );
213            psInsert.setBytes( 3, element );
214            psInsert.setLong( 4, ce.getElementAttributes().getMaxLife() );
215            psInsert.setString( 5, ce.getElementAttributes().getIsEternal() ? "T" : "F" );
216
217            final Timestamp createTime = new Timestamp( ce.getElementAttributes().getCreateTime() );
218            psInsert.setTimestamp( 6, createTime );
219
220            final long now = System.currentTimeMillis() / 1000;
221            psInsert.setLong( 7, now );
222
223            final long expireTime = now + ce.getElementAttributes().getMaxLife();
224            psInsert.setLong( 8, expireTime );
225
226            psInsert.execute();
227        }
228        catch ( final SQLException e )
229        {
230            if ("23000".equals(e.getSQLState()))
231            {
232                exists = true;
233            }
234            else
235            {
236                log.error( "Could not insert element", e );
237            }
238
239            // see if it exists, if we didn't already
240            if ( !exists && !this.getJdbcDiskCacheAttributes().isTestBeforeInsert() )
241            {
242                exists = doesElementExist( ce, con );
243            }
244        }
245
246        return exists;
247    }
248
249    /**
250     * This updates a row in the database.
251     * <p>
252     * @param ce
253     * @param con
254     * @param element
255     */
256    private void updateRow( final ICacheElement<K, V> ce, final Connection con, final byte[] element )
257    {
258        final String sqlU = String.format("update %s"
259                + " set ELEMENT  = ?, CREATE_TIME = ?, UPDATE_TIME_SECONDS = ?, " + " SYSTEM_EXPIRE_TIME_SECONDS = ? "
260                + " where CACHE_KEY = ? and REGION = ?", getJdbcDiskCacheAttributes().getTableName());
261
262        try (PreparedStatement psUpdate = con.prepareStatement( sqlU ))
263        {
264            psUpdate.setBytes( 1, element );
265
266            final Timestamp createTime = new Timestamp( ce.getElementAttributes().getCreateTime() );
267            psUpdate.setTimestamp( 2, createTime );
268
269            final long now = System.currentTimeMillis() / 1000;
270            psUpdate.setLong( 3, now );
271
272            final long expireTime = now + ce.getElementAttributes().getMaxLife();
273            psUpdate.setLong( 4, expireTime );
274
275            psUpdate.setString( 5, (String) ce.getKey() );
276            psUpdate.setString( 6, this.getCacheName() );
277            psUpdate.execute();
278
279            log.debug( "ran update {0}", sqlU );
280        }
281        catch ( final SQLException e )
282        {
283            log.error( "Error executing update sql [{0}]", sqlU, e );
284        }
285    }
286
287    /**
288     * Does an element exist for this key?
289     * <p>
290     * @param ce the cache element
291     * @param con a database connection
292     * @return boolean
293     */
294    protected boolean doesElementExist( final ICacheElement<K, V> ce, final Connection con )
295    {
296        boolean exists = false;
297        // don't select the element, since we want this to be fast.
298        final String sqlS = String.format("select CACHE_KEY from %s where REGION = ? and CACHE_KEY = ?",
299                getJdbcDiskCacheAttributes().getTableName());
300
301        try (PreparedStatement psSelect = con.prepareStatement( sqlS ))
302        {
303            psSelect.setString( 1, this.getCacheName() );
304            psSelect.setString( 2, (String) ce.getKey() );
305
306            try (ResultSet rs = psSelect.executeQuery())
307            {
308                exists = rs.next();
309            }
310
311            log.debug( "[{0}] existing status is {1}", ce.getKey(), exists );
312        }
313        catch ( final SQLException e )
314        {
315            log.error( "Problem looking for item before insert.", e );
316        }
317
318        return exists;
319    }
320
321    /**
322     * Queries the database for the value. If it gets a result, the value is deserialized.
323     * <p>
324     * @param key
325     * @return ICacheElement
326     * @see org.apache.commons.jcs3.auxiliary.disk.AbstractDiskCache#get(Object)
327     */
328    @Override
329    protected ICacheElement<K, V> processGet( final K key )
330    {
331        getCount.incrementAndGet();
332
333        log.debug( "Getting [{0}] from disk", key );
334
335        if ( !isAlive() )
336        {
337            return null;
338        }
339
340        ICacheElement<K, V> obj = null;
341
342        // region, key
343        final String selectString = String.format("select ELEMENT from %s where REGION = ? and CACHE_KEY = ?",
344                getJdbcDiskCacheAttributes().getTableName());
345
346        try (Connection con = getDataSource().getConnection())
347        {
348            try (PreparedStatement psSelect = con.prepareStatement( selectString ))
349            {
350                psSelect.setString( 1, this.getCacheName() );
351                psSelect.setString( 2, key.toString() );
352
353                try (ResultSet rs = psSelect.executeQuery())
354                {
355                    byte[] data = null;
356
357                    if ( rs.next() )
358                    {
359                        data = rs.getBytes( 1 );
360                    }
361
362                    if ( data != null )
363                    {
364                        try
365                        {
366                            // USE THE SERIALIZER
367                            obj = getElementSerializer().deSerialize( data, null );
368                        }
369                        catch ( final IOException | ClassNotFoundException e )
370                        {
371                            log.error( "Problem getting item for key [{0}]", key, e );
372                        }
373                    }
374                }
375            }
376        }
377        catch ( final SQLException sqle )
378        {
379            log.error( "Caught a SQL exception trying to get the item for key [{0}]",
380                    key, sqle );
381        }
382
383        return obj;
384    }
385
386    /**
387     * This will run a like query. It will try to construct a usable query but different
388     * implementations will be needed to adjust the syntax.
389     * <p>
390     * @param pattern
391     * @return key,value map
392     */
393    @Override
394    protected Map<K, ICacheElement<K, V>> processGetMatching( final String pattern )
395    {
396        getMatchingCount.incrementAndGet();
397
398        log.debug( "Getting [{0}] from disk", pattern);
399
400        if ( !isAlive() )
401        {
402            return null;
403        }
404
405        final Map<K, ICacheElement<K, V>> results = new HashMap<>();
406
407        // region, key
408        final String selectString = String.format("select ELEMENT from %s where REGION = ? and CACHE_KEY like ?",
409                getJdbcDiskCacheAttributes().getTableName());
410
411        try (Connection con = getDataSource().getConnection())
412        {
413            try (PreparedStatement psSelect = con.prepareStatement( selectString ))
414            {
415                psSelect.setString( 1, this.getCacheName() );
416                psSelect.setString( 2, constructLikeParameterFromPattern( pattern ) );
417
418                try (ResultSet rs = psSelect.executeQuery())
419                {
420                    while ( rs.next() )
421                    {
422                        final byte[] data = rs.getBytes(1);
423                        if ( data != null )
424                        {
425                            try
426                            {
427                                // USE THE SERIALIZER
428                                final ICacheElement<K, V> value = getElementSerializer().deSerialize( data, null );
429                                results.put( value.getKey(), value );
430                            }
431                            catch ( final IOException | ClassNotFoundException e )
432                            {
433                                log.error( "Problem getting items for pattern [{0}]", pattern, e );
434                            }
435                        }
436                    }
437                }
438            }
439        }
440        catch ( final SQLException sqle )
441        {
442            log.error( "Caught a SQL exception trying to get items for pattern [{0}]",
443                    pattern, sqle );
444        }
445
446        return results;
447    }
448
449    /**
450     * @param pattern
451     * @return String to use in the like query.
452     */
453    public String constructLikeParameterFromPattern( final String pattern )
454    {
455        String likePattern = pattern.replace( ".+", "%" );
456        likePattern = likePattern.replace( ".", "_" );
457
458        log.debug( "pattern = [{0}]", likePattern );
459
460        return likePattern;
461    }
462
463    /**
464     * Returns true if the removal was successful; or false if there is nothing to remove. Current
465     * implementation always results in a disk orphan.
466     * <p>
467     * @param key
468     * @return boolean
469     */
470    @Override
471    protected boolean processRemove( final K key )
472    {
473        // remove single item.
474        final String sqlSingle = String.format("delete from %s where REGION = ? and CACHE_KEY = ?",
475                getJdbcDiskCacheAttributes().getTableName());
476        // remove all keys of the same name group.
477        final String sqlPartial = String.format("delete from %s where REGION = ? and CACHE_KEY like ?",
478                getJdbcDiskCacheAttributes().getTableName());
479
480        try (Connection con = getDataSource().getConnection())
481        {
482            boolean partial = key.toString().endsWith(ICache.NAME_COMPONENT_DELIMITER);
483            String sql = partial ? sqlPartial : sqlSingle;
484
485            try (PreparedStatement psSelect = con.prepareStatement(sql))
486            {
487                psSelect.setString( 1, this.getCacheName() );
488                if ( partial )
489                {
490                    psSelect.setString( 2, key.toString() + "%" );
491                }
492                else
493                {
494                    psSelect.setString( 2, key.toString() );
495                }
496
497                psSelect.executeUpdate();
498
499                setAlive(true);
500            }
501            catch ( final SQLException e )
502            {
503                log.error( "Problem creating statement. sql [{0}]", sql, e );
504                setAlive(false);
505            }
506        }
507        catch ( final SQLException e )
508        {
509            log.error( "Problem updating cache.", e );
510            reset();
511        }
512        return false;
513    }
514
515    /**
516     * This should remove all elements. The auxiliary can be configured to forbid this behavior. If
517     * remove all is not allowed, the method balks.
518     */
519    @Override
520    protected void processRemoveAll()
521    {
522        // it should never get here from the abstract disk cache.
523        if ( this.jdbcDiskCacheAttributes.isAllowRemoveAll() )
524        {
525            final String sql = String.format("delete from %s where REGION = ?",
526                    getJdbcDiskCacheAttributes().getTableName());
527
528            try (Connection con = getDataSource().getConnection())
529            {
530                try (PreparedStatement psDelete = con.prepareStatement( sql ))
531                {
532                    psDelete.setString( 1, this.getCacheName() );
533                    setAlive(true);
534                    psDelete.executeUpdate();
535                }
536                catch ( final SQLException e )
537                {
538                    log.error( "Problem creating statement.", e );
539                    setAlive(false);
540                }
541            }
542            catch ( final SQLException e )
543            {
544                log.error( "Problem removing all.", e );
545                reset();
546            }
547        }
548        else
549        {
550            log.info( "RemoveAll was requested but the request was not fulfilled: "
551                    + "allowRemoveAll is set to false." );
552        }
553    }
554
555    /**
556     * Removed the expired. (now - create time) &gt; max life seconds * 1000
557     * <p>
558     * @return the number deleted
559     */
560    protected int deleteExpired()
561    {
562        int deleted = 0;
563
564        try (Connection con = getDataSource().getConnection())
565        {
566            // The shrinker thread might kick in before the table is created
567            // So check if the table exists first
568            final DatabaseMetaData dmd = con.getMetaData();
569            final ResultSet result = dmd.getTables(null, null,
570                    getJdbcDiskCacheAttributes().getTableName(), null);
571
572            if (result.next())
573            {
574                getTableState().setState( TableState.DELETE_RUNNING );
575                final long now = System.currentTimeMillis() / 1000;
576
577                final String sql = String.format("delete from %s where IS_ETERNAL = ? and REGION = ?"
578                        + " and ? > SYSTEM_EXPIRE_TIME_SECONDS", getJdbcDiskCacheAttributes().getTableName());
579
580                try (PreparedStatement psDelete = con.prepareStatement( sql ))
581                {
582                    psDelete.setString( 1, "F" );
583                    psDelete.setString( 2, this.getCacheName() );
584                    psDelete.setLong( 3, now );
585
586                    setAlive(true);
587
588                    deleted = psDelete.executeUpdate();
589                }
590                catch ( final SQLException e )
591                {
592                    log.error( "Problem creating statement.", e );
593                    setAlive(false);
594                }
595
596                logApplicationEvent( getAuxiliaryCacheAttributes().getName(), "deleteExpired",
597                                     "Deleted expired elements.  URL: " + getDiskLocation() );
598            }
599            else
600            {
601                log.warn( "Trying to shrink non-existing table [{0}]",
602                        getJdbcDiskCacheAttributes().getTableName() );
603            }
604        }
605        catch ( final SQLException e )
606        {
607            logError( getAuxiliaryCacheAttributes().getName(), "deleteExpired",
608                    e.getMessage() + " URL: " + getDiskLocation() );
609            log.error( "Problem removing expired elements from the table.", e );
610            reset();
611        }
612        finally
613        {
614            getTableState().setState( TableState.FREE );
615        }
616
617        return deleted;
618    }
619
620    /**
621     * Typically this is used to handle errors by last resort, force content update, or removeall
622     */
623    public void reset()
624    {
625        // nothing
626    }
627
628    /** Shuts down the pool */
629    @Override
630    public void processDispose()
631    {
632        final ICacheEvent<K> cacheEvent = createICacheEvent( getCacheName(), null, ICacheEventLogger.DISPOSE_EVENT );
633
634        try
635        {
636                dsFactory.close();
637        }
638        catch ( final SQLException e )
639        {
640            log.error( "Problem shutting down.", e );
641        }
642        finally
643        {
644            logICacheEvent( cacheEvent );
645        }
646    }
647
648    /**
649     * Returns the current cache size. Just does a count(*) for the region.
650     * <p>
651     * @return The size value
652     */
653    @Override
654    public int getSize()
655    {
656        int size = 0;
657
658        // region, key
659        final String selectString = String.format("select count(*) from %s where REGION = ?",
660                getJdbcDiskCacheAttributes().getTableName());
661
662        try (Connection con = getDataSource().getConnection())
663        {
664            try (PreparedStatement psSelect = con.prepareStatement( selectString ))
665            {
666                psSelect.setString( 1, this.getCacheName() );
667
668                try (ResultSet rs = psSelect.executeQuery())
669                {
670                    if ( rs.next() )
671                    {
672                        size = rs.getInt( 1 );
673                    }
674                }
675            }
676        }
677        catch ( final SQLException e )
678        {
679            log.error( "Problem getting size.", e );
680        }
681
682        return size;
683    }
684
685    /**
686     * Return the keys in this cache.
687     * <p>
688     * @see org.apache.commons.jcs3.auxiliary.disk.AbstractDiskCache#getKeySet()
689     */
690    @Override
691    public Set<K> getKeySet() throws IOException
692    {
693        throw new UnsupportedOperationException( "Groups not implemented." );
694        // return null;
695    }
696
697    /**
698     * @param jdbcDiskCacheAttributes The jdbcDiskCacheAttributes to set.
699     */
700    protected void setJdbcDiskCacheAttributes( final JDBCDiskCacheAttributes jdbcDiskCacheAttributes )
701    {
702        this.jdbcDiskCacheAttributes = jdbcDiskCacheAttributes;
703    }
704
705    /**
706     * @return Returns the jdbcDiskCacheAttributes.
707     */
708    protected JDBCDiskCacheAttributes getJdbcDiskCacheAttributes()
709    {
710        return jdbcDiskCacheAttributes;
711    }
712
713    /**
714     * @return Returns the AuxiliaryCacheAttributes.
715     */
716    @Override
717    public AuxiliaryCacheAttributes getAuxiliaryCacheAttributes()
718    {
719        return this.getJdbcDiskCacheAttributes();
720    }
721
722    /**
723     * Extends the parent stats.
724     * <p>
725     * @return IStats
726     */
727    @Override
728    public IStats getStatistics()
729    {
730        final IStats stats = super.getStatistics();
731        stats.setTypeName( "JDBC/Abstract Disk Cache" );
732
733        final List<IStatElement<?>> elems = stats.getStatElements();
734
735        elems.add(new StatElement<>( "Update Count", updateCount ) );
736        elems.add(new StatElement<>( "Get Count", getCount ) );
737        elems.add(new StatElement<>( "Get Matching Count", getMatchingCount ) );
738        elems.add(new StatElement<>( "DB URL", getJdbcDiskCacheAttributes().getUrl()) );
739
740        stats.setStatElements( elems );
741
742        return stats;
743    }
744
745    /**
746     * Returns the name of the table.
747     * <p>
748     * @return the table name or UNDEFINED
749     */
750    protected String getTableName()
751    {
752        String name = "UNDEFINED";
753        if ( this.getJdbcDiskCacheAttributes() != null )
754        {
755            name = this.getJdbcDiskCacheAttributes().getTableName();
756        }
757        return name;
758    }
759
760    /**
761     * @param tableState The tableState to set.
762     */
763    public void setTableState( final TableState tableState )
764    {
765        this.tableState = tableState;
766    }
767
768    /**
769     * @return Returns the tableState.
770     */
771    public TableState getTableState()
772    {
773        return tableState;
774    }
775
776    /**
777     * This is used by the event logging.
778     * <p>
779     * @return the location of the disk, either path or ip.
780     */
781    @Override
782    protected String getDiskLocation()
783    {
784        return this.jdbcDiskCacheAttributes.getUrl();
785    }
786
787    /**
788     * Public so managers can access it.
789     * @return the dsFactory
790     * @throws SQLException if getting a data source fails
791     */
792    public DataSource getDataSource() throws SQLException
793    {
794        return dsFactory.getDataSource();
795    }
796
797    /**
798     * For debugging.
799     * <p>
800     * @return this.getStats();
801     */
802    @Override
803    public String toString()
804    {
805        return this.getStats();
806    }
807}