ContentCache.java revision fab77f147f85766d2f75d8aece0aaa4ffb3838e8
1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.provider;
18import com.android.email.Email;
19
20import android.content.ContentValues;
21import android.database.Cursor;
22import android.database.CursorWrapper;
23import android.database.MatrixCursor;
24import android.net.Uri;
25import android.util.Log;
26
27import java.util.ArrayList;
28import java.util.Arrays;
29import java.util.HashMap;
30import java.util.LinkedHashMap;
31import java.util.Map;
32
33/**
34 * An LRU cache for EmailContent (Account, HostAuth, Mailbox, and Message, thus far).  The intended
35 * user of this cache is EmailProvider itself; caching is entirely transparent to users of the
36 * provider.
37 *
38 * Usage examples; id is a String representation of a row id (_id), as it might be retrieved from
39 * a uri via getPathSegment
40 *
41 * To create a cache:
42 *    ContentCache cache = new ContentCache(name, projection, max);
43 *
44 * To (try to) get a cursor from a cache:
45 *    Cursor cursor = cache.getCursor(id, projection);
46 *
47 * To read from a table and cache the resulting cursor:
48 * 1. Get a CacheToken: CacheToken token = cache.getToken(id);
49 * 2. Get a cursor from the database: Cursor cursor = db.query(....);
50 * 3. Put the cursor in the cache: cache.putCursor(cursor, id, token);
51 * Only cursors with the projection given in the definition of the cache can be cached
52 *
53 * To delete one or more rows or update multiple rows from a table that uses cached data:
54 * 1. Lock the row in the cache: cache.lock(id);
55 * 2. Delete/update the row(s): db.delete(...);
56 * 3. Invalidate any other caches that might be affected by the delete/update:
57 *      The entire cache: affectedCache.invalidate()*
58 *      A specific row in a cache: affectedCache.invalidate(rowId)
59 * 4. Unlock the row in the cache: cache.unlock(id);
60 *
61 * To update a single row from a table that uses cached data:
62 * 1. Lock the row in the cache: cache.lock(id);
63 * 2. Update the row: db.update(...);
64 * 3. Unlock the row in the cache, passing in the new values: cache.unlock(id, values);
65 */
66public final class ContentCache extends LinkedHashMap<String, Cursor> {
67    private static final long serialVersionUID = 1L;
68
69    private static final boolean DEBUG_CACHE = false;
70    private static final boolean DEBUG_TOKENS = false;
71    private static final boolean DEBUG_NOT_CACHEABLE = false;
72
73    // Count of non-cacheable queries (debug only)
74    private static int sNotCacheable = 0;
75    // A map of queries that aren't cacheable (debug only)
76    private static final CounterMap<String> sNotCacheableMap = new CounterMap<String>();
77
78    // All defined caches
79    private static final ArrayList<ContentCache> sContentCaches = new ArrayList<ContentCache>();
80    // A set of all unclosed, cached cursors; this will typically be a very small set, as cursors
81    // tend to be closed quickly after use.  The value, for each cursor, is its reference count
82    /*package*/ static CounterMap<Cursor> sActiveCursors;
83
84    // A set of locked content id's
85    private final CounterMap<String> mLockMap = new CounterMap<String>(4);
86    // A set of active tokens
87    /*package*/ TokenList mTokenList;
88
89    // The name of the cache (used for logging)
90    private final String mName;
91    // The base projection (only queries in which all columns exist in this projection will be
92    // able to avoid a cache miss)
93    private final String[] mBaseProjection;
94    // The number of items (cursors) to cache
95    private final int mMaxSize;
96    // The tag used for logging
97    private final String mLogTag;
98    // Cache statistics
99    private final Statistics mStats;
100
101    /**
102     * A synchronized map used as a counter for arbitrary objects (e.g. a reference count)
103     */
104    /*package*/ static class CounterMap<T> extends HashMap<T, Integer> {
105        private static final long serialVersionUID = 1L;
106
107        /*package*/ CounterMap(int maxSize) {
108            super(maxSize);
109        }
110
111        /*package*/ CounterMap() {
112            super();
113        }
114
115        /*package*/ void remove(T object) {
116            synchronized(this) {
117                Integer refCount = get(object);
118                if (refCount == null || refCount.intValue() == 0) {
119                    throw new IllegalStateException();
120                }
121                if (refCount > 1) {
122                    put(object, refCount - 1);
123                } else {
124                    super.remove(object);
125                }
126            }
127        }
128
129        /*package*/ void add(T object) {
130            synchronized(this) {
131                Integer refCount = get(object);
132                if (refCount == null) {
133                    put(object, 1);
134                } else {
135                    put(object, refCount + 1);
136                }
137            }
138        }
139
140        /*package*/ boolean contains(T object) {
141            synchronized(this) {
142                Integer refCount = get(object);
143                return (refCount != null && refCount.intValue() > 0);
144            }
145        }
146
147        /*package*/ int getCount(T object) {
148            synchronized(this) {
149                Integer refCount = get(object);
150                return (refCount == null) ? 0 : refCount.intValue();
151            }
152        }
153    }
154
155    /**
156     * A list of tokens that are in use at any moment; there can be more than one token for an id
157     */
158    /*package*/ static class TokenList extends ArrayList<CacheToken> {
159        private static final long serialVersionUID = 1L;
160        private final String mLogTag;
161
162        /*package*/ TokenList(String name) {
163            mLogTag = "TokenList-" + name;
164        }
165
166        /*package*/ int invalidateTokens(String id) {
167            if (DEBUG_TOKENS) {
168                Log.d(mLogTag, "============ Invalidate tokens for: " + id);
169            }
170            ArrayList<CacheToken> removeList = new ArrayList<CacheToken>();
171            int count = 0;
172            for (CacheToken token: this) {
173                if (token.getId().equals(id)) {
174                    token.invalidate();
175                    removeList.add(token);
176                    count++;
177                }
178            }
179            for (CacheToken token: removeList) {
180                remove(token);
181            }
182            return count;
183        }
184
185        /*package*/ void invalidate() {
186            if (DEBUG_TOKENS) {
187                Log.d(mLogTag, "============ List invalidated");
188            }
189            for (CacheToken token: this) {
190                token.invalidate();
191            }
192            clear();
193        }
194
195        /*package*/ boolean remove(CacheToken token) {
196            boolean result = super.remove(token);
197            if (DEBUG_TOKENS) {
198                if (result) {
199                    Log.d(mLogTag, "============ Removing token for: " + token.mId);
200                } else {
201                    Log.d(mLogTag, "============ No token found for: " + token.mId);
202                }
203            }
204            return result;
205        }
206
207        public CacheToken add(String id) {
208            CacheToken token = new CacheToken(id);
209            super.add(token);
210            if (DEBUG_TOKENS) {
211                Log.d(mLogTag, "============ Taking token for: " + token.mId);
212            }
213            return token;
214        }
215    }
216
217    /**
218     * A CacheToken is an opaque object that must be passed into putCursor in order to attempt to
219     * write into the cache.  The token becomes invalidated by any intervening write to the cached
220     * record.
221     */
222    public static final class CacheToken {
223        private static final long serialVersionUID = 1L;
224        private final String mId;
225        private boolean mIsValid = true;
226
227        /*package*/ CacheToken(String id) {
228            mId = id;
229        }
230
231        /*package*/ String getId() {
232            return mId;
233        }
234
235        /*package*/ boolean isValid() {
236            return mIsValid;
237        }
238
239        /*package*/ void invalidate() {
240            mIsValid = false;
241        }
242
243        @Override
244        public boolean equals(Object token) {
245            return ((token instanceof CacheToken) && ((CacheToken)token).mId.equals(mId));
246        }
247
248        @Override
249        public int hashCode() {
250            return mId.hashCode();
251        }
252    }
253
254    /**
255     * The cached cursor is simply a CursorWrapper whose underlying cursor contains zero or one
256     * rows.  We handle simple movement (moveToFirst(), moveToNext(), etc.), and override close()
257     * to keep the underlying cursor alive (unless it's no longer cached due to an invalidation)
258     */
259    public static final class CachedCursor extends CursorWrapper {
260        // The cursor we're wrapping
261        private final Cursor mCursor;
262        // The cache which generated this cursor
263        private final ContentCache mCache;
264        // The current position of the cursor (can only be 0 or 1)
265        private int mPosition = -1;
266        // The number of rows in this cursor (-1 = not determined)
267        private int mCount = -1;
268        private boolean isClosed = false;
269
270        public CachedCursor(Cursor cursor, ContentCache cache, String name) {
271            super(cursor);
272            // The underlying cursor must always be at position 0
273            cursor.moveToPosition(0);
274            mCursor = cursor;
275            mCache = cache;
276            // Add this to our set of active cursors
277            sActiveCursors.add(cursor);
278        }
279
280        /**
281         * Close this cursor; if the cursor's cache no longer contains the cursor, we'll close the
282         * underlying cursor.  In any event we'll remove the cursor from our set of active cursors
283         */
284        @Override
285        public void close() {
286            if (!mCache.containsValue(mCursor)) {
287                super.close();
288            }
289            sActiveCursors.remove(mCursor);
290            isClosed = true;
291        }
292
293        @Override
294        public boolean isClosed() {
295            return isClosed;
296        }
297
298        @Override
299        public int getCount() {
300            if (mCount < 0) {
301                mCount = super.getCount();
302            }
303            return mCount;
304        }
305
306        /**
307         * We'll be happy to move to position 0 or -1; others are illegal
308         */
309        @Override
310        public boolean moveToPosition(int pos) {
311            if (pos > 0) {
312                throw new IllegalArgumentException();
313            }
314            if (pos >= getCount()) {
315                return false;
316            }
317            mPosition = pos;
318            return true;
319        }
320
321        @Override
322        public boolean moveToFirst() {
323            return moveToPosition(0);
324        }
325
326        @Override
327        public boolean moveToNext() {
328            return moveToPosition(mPosition + 1);
329        }
330
331        @Override
332        public boolean moveToPrevious() {
333            if (mPosition == 0) {
334                mPosition--;
335                return true;
336            }
337            return false;
338        }
339
340        @Override
341        public int getPosition() {
342            return mPosition;
343        }
344
345        @Override
346        public final boolean move(int offset) {
347            return moveToPosition(mPosition + offset);
348        }
349
350        @Override
351        public final boolean moveToLast() {
352            return moveToPosition(getCount() - 1);
353        }
354
355        @Override
356        public final boolean isLast() {
357            return mPosition == (getCount() - 1);
358        }
359
360        @Override
361        public final boolean isBeforeFirst() {
362            return mPosition == -1;
363        }
364
365        @Override
366        public final boolean isAfterLast() {
367            return mPosition == 1;
368        }
369    }
370
371    /**
372     * Public constructor
373     * @param name the name of the cache (used for logging)
374     * @param baseProjection the projection used for cached cursors; queries whose columns are not
375     *  included in baseProjection will always generate a cache miss
376     * @param maxSize the maximum number of content cursors to cache
377     */
378    public ContentCache(String name, String[] baseProjection, int maxSize) {
379        super();
380        mName = name;
381        mMaxSize = maxSize;
382        mBaseProjection = baseProjection;
383        mLogTag = "ContentCache-" + name;
384        sContentCaches.add(this);
385        mTokenList = new TokenList(mName);
386        sActiveCursors = new CounterMap<Cursor>(maxSize);
387        mStats = new Statistics(this);
388    }
389
390    /**
391     * Return the base projection for cached rows
392     * Get the projection used for cached rows (typically, the largest possible projection)
393     * @return
394     */
395    public String[] getProjection() {
396        return mBaseProjection;
397    }
398
399
400    /**
401     * Get a CacheToken for a row as specified by its id (_id column)
402     * @param id the id of the record
403     * @return a CacheToken needed in order to write data for the record back to the cache
404     */
405    public synchronized CacheToken getCacheToken(String id) {
406        // If another thread is already writing the data, return an invalid token
407        CacheToken token = mTokenList.add(id);
408        if (mLockMap.contains(id)) {
409            token.invalidate();
410        }
411        return token;
412    }
413
414    /* (non-Javadoc)
415     * @see java.util.LinkedHashMap#removeEldestEntry(java.util.Map.Entry)
416     */
417    @Override
418    public synchronized boolean removeEldestEntry(Map.Entry<String,Cursor> entry) {
419        // If we're above the maximum size for this cache, remove the LRU cache entry
420        if (size() > mMaxSize) {
421            Cursor cursor = entry.getValue();
422            // Close this cursor if it's no longer being used
423            if (!sActiveCursors.contains(cursor)) {
424                cursor.close();
425            }
426            return true;
427        }
428        return false;
429    }
430
431    /**
432     * Try to cache a cursor for the given id and projection; returns a valid cursor, either a
433     * cached cursor (if caching was successful) or the original cursor
434     *
435     * @param c the cursor to be cached
436     * @param id the record id (_id) of the content
437     * @param projection the projection represented by the cursor
438     * @return whether or not the cursor was cached
439     */
440    public synchronized Cursor putCursor(Cursor c, String id, String[] projection,
441            CacheToken token) {
442        try {
443            if (!token.isValid()) {
444                if (DEBUG_CACHE) {
445                    Log.d(mLogTag, "============ Stale token for " + id);
446                }
447                mStats.mStaleCount++;
448                return c;
449            }
450            if (c != null && projection == mBaseProjection) {
451                if (DEBUG_CACHE) {
452                    Log.d(mLogTag, "============ Caching cursor for: " + id);
453                }
454                // If we've already cached this cursor, invalidate the older one
455                Cursor existingCursor = get(id);
456                if (existingCursor != null) {
457                   unlockImpl(id, null, false);
458                }
459                put(id, c);
460                c.moveToFirst();
461                return new CachedCursor(c, this, id);
462            }
463            return c;
464        } finally {
465            mTokenList.remove(token);
466        }
467    }
468
469    /**
470     * Find and, if found, return a cursor, based on cached values, for the supplied id
471     * @param id the _id column of the desired row
472     * @param projection the requested projection for a query
473     * @return a cursor based on cached values, or null if the row is not cached
474     */
475    public synchronized Cursor getCachedCursor(String id, String[] projection) {
476        if (Email.DEBUG) {
477            // Every 200 calls to getCursor, report cache statistics
478            dumpOnCount(200);
479        }
480        if (projection == mBaseProjection) {
481            return getCachedCursorImpl(id);
482        } else {
483            return getMatrixCursor(id, projection);
484        }
485    }
486
487    private CachedCursor getCachedCursorImpl(String id) {
488        Cursor c = get(id);
489        if (c != null) {
490            mStats.mHitCount++;
491            return new CachedCursor(c, this, id);
492        }
493        mStats.mMissCount++;
494        return null;
495    }
496
497    private MatrixCursor getMatrixCursor(String id, String[] projection) {
498        return getMatrixCursor(id, projection, null);
499    }
500
501    private MatrixCursor getMatrixCursor(String id, String[] projection,
502            ContentValues values) {
503        Cursor c = get(id);
504        if (c != null) {
505            // Make a new MatrixCursor with the requested columns
506            MatrixCursor mc = new MatrixCursor(projection, 1);
507            Object[] row = new Object[projection.length];
508            if (values != null) {
509                // Make a copy; we don't want to change the original
510                values = new ContentValues(values);
511            }
512            int i = 0;
513            for (String column: projection) {
514                int columnIndex = c.getColumnIndex(column);
515                if (columnIndex < 0) {
516                    mStats.mProjectionMissCount++;
517                    return null;
518                } else {
519                    String value;
520                    if (values != null && values.containsKey(column)) {
521                        Object val = values.get(column);
522                        if (val instanceof Boolean) {
523                            value = (val == Boolean.TRUE) ? "1" : "0";
524                        } else {
525                            value = values.getAsString(column);
526                        }
527                        values.remove(column);
528                    } else {
529                        value = c.getString(columnIndex);
530                    }
531                    row[i++] = value;
532                }
533            }
534            if (values != null && values.size() != 0) {
535                return null;
536            }
537            mc.addRow(row);
538            mStats.mHitCount++;
539            return mc;
540        }
541        mStats.mMissCount++;
542        return null;
543    }
544
545    /**
546     * Lock a given row, such that no new valid CacheTokens can be created for the passed-in id.
547     * @param id the id of the row to lock
548     */
549    public synchronized void lock(String id) {
550        // Prevent new valid tokens from being created
551        mLockMap.add(id);
552        // Invalidate current tokens
553        int count = mTokenList.invalidateTokens(id);
554        if (DEBUG_TOKENS) {
555            Log.d(mTokenList.mLogTag, "============ Lock invalidated " + count +
556                    " tokens for: " + id);
557        }
558    }
559
560    /**
561     * Unlock a given row, allowing new valid CacheTokens to be created for the passed-in id.
562     * @param id the id of the item whose cursor is cached
563     */
564    public synchronized void unlock(String id) {
565        unlockImpl(id, null, true);
566    }
567
568    /**
569     * If the row with id is currently cached, replaces the cached values with the supplied
570     * ContentValues.  Then, unlock the row, so that new valid CacheTokens can be created.
571     *
572     * @param id the id of the item whose cursor is cached
573     * @param values updated values for this row
574     */
575    public synchronized void unlock(String id, ContentValues values) {
576        unlockImpl(id, values, true);
577    }
578
579    /**
580     * If values are passed in, replaces any cached cursor with one containing new values, and
581     * then closes the previously cached one (if any, and if not in use)
582     * If values are not passed in, removes the row from cache
583     * If the row was locked, unlock it
584     * @param id the id of the row
585     * @param values new ContentValues for the row (or null if row should simply be removed)
586     * @param wasLocked whether or not the row was locked; if so, the lock will be removed
587     */
588    public void unlockImpl(String id, ContentValues values, boolean wasLocked) {
589        Cursor c = get(id);
590        if (c != null) {
591            if (DEBUG_CACHE) {
592                Log.d(mLogTag, "=========== Unlocking cache for: " + id);
593            }
594            if (values != null) {
595                MatrixCursor cursor = getMatrixCursor(id, mBaseProjection, values);
596                if (cursor != null) {
597                    if (DEBUG_CACHE) {
598                        Log.d(mLogTag, "=========== Recaching with new values: " + id);
599                    }
600                    cursor.moveToFirst();
601                    put(id, cursor);
602                } else {
603                    remove(id);
604                }
605            } else {
606                remove(id);
607            }
608            // If there are no cursors using the old cached cursor, close it
609            if (!sActiveCursors.contains(c)) {
610                c.close();
611            }
612        }
613        if (wasLocked) {
614            mLockMap.remove(id);
615        }
616    }
617
618    /**
619     * Invalidate the entire cache, without logging
620     */
621    public synchronized void invalidate() {
622        invalidate(null, null, null);
623    }
624
625    /**
626     * Invalidate the entire cache; the arguments are used for logging only, and indicate the
627     * write operation that caused the invalidation
628     *
629     * @param operation a string describing the operation causing the invalidate (or null)
630     * @param uri the uri causing the invalidate (or null)
631     * @param selection the selection used with the uri (or null)
632     */
633    public synchronized void invalidate(String operation, Uri uri, String selection) {
634        if (DEBUG_CACHE && (operation != null)) {
635            Log.d(mLogTag, "============ INVALIDATED BY " + operation + ": " + uri +
636                    ", SELECTION: " + selection);
637        }
638        mStats.mInvalidateCount++;
639        // Close all cached cursors that are no longer in use
640        for (String id: keySet()) {
641            Cursor c = get(id);
642            if (!sActiveCursors.contains(c)) {
643                c.close();
644            }
645        }
646        clear();
647        // Invalidate all current tokens
648        mTokenList.invalidate();
649    }
650
651    // Debugging code below
652
653    private void dumpOnCount(int num) {
654        mStats.mOpCount++;
655        if ((mStats.mOpCount % num) == 0) {
656            dumpStats();
657        }
658    }
659
660    /*package*/ void recordQueryTime(Cursor c, long nanoTime) {
661        if (c instanceof CachedCursor) {
662            mStats.hitTimes += nanoTime;
663            mStats.hits++;
664        } else {
665            if (c.getCount() == 1) {
666                mStats.missTimes += nanoTime;
667                mStats.miss++;
668            }
669        }
670    }
671
672    public static synchronized void notCacheable(Uri uri, String selection) {
673        if (DEBUG_NOT_CACHEABLE) {
674            sNotCacheable++;
675            String str = uri.toString() + "$" + selection;
676            sNotCacheableMap.add(str);
677        }
678    }
679
680    private static class CacheCounter implements Comparable<Object> {
681        String uri;
682        Integer count;
683
684        CacheCounter(String _uri, Integer _count) {
685            uri = _uri;
686            count = _count;
687        }
688
689        @Override
690        public int compareTo(Object another) {
691            CacheCounter x = (CacheCounter)another;
692            return x.count > count ? 1 : x.count == count ? 0 : -1;
693        }
694    }
695
696    public static void dumpNotCacheableQueries() {
697        int size = sNotCacheableMap.size();
698        CacheCounter[] array = new CacheCounter[size];
699
700        int i = 0;
701        for (Entry<String, Integer> entry: sNotCacheableMap.entrySet()) {
702            array[i++] = new CacheCounter(entry.getKey(), entry.getValue());
703        }
704        Arrays.sort(array);
705        for (CacheCounter cc: array) {
706            Log.d("NotCacheable", cc.count + ": " + cc.uri);
707        }
708    }
709
710    static class Statistics {
711        private final ContentCache mCache;
712        private final String mName;
713
714        // Cache statistics
715        // The item is in the cache AND is used to create a cursor
716        private int mHitCount = 0;
717        // Basic cache miss (the item is not cached)
718        private int mMissCount = 0;
719        // Incremented when a cachePut is invalid due to an intervening write
720        private int mStaleCount = 0;
721        // A projection miss occurs when the item is cached, but not all requested columns are
722        // available in the base projection
723        private int mProjectionMissCount = 0;
724        // Incremented whenever the entire cache is invalidated
725        private int mInvalidateCount = 0;
726        // Count of operations put/get
727        private int mOpCount = 0;
728        // The following are for timing statistics
729        private long hits = 0;
730        private long hitTimes = 0;
731        private long miss = 0;
732        private long missTimes = 0;
733
734        // Used in toString() and addCacheStatistics()
735        private int mCursorCount = 0;
736        private int mTokenCount = 0;
737
738        Statistics(ContentCache cache) {
739            mCache = cache;
740            mName = mCache.mName;
741        }
742
743        Statistics(String name) {
744            mCache = null;
745            mName = name;
746        }
747
748        private void addCacheStatistics(ContentCache cache) {
749            if (cache != null) {
750                mHitCount += cache.mStats.mHitCount;
751                mMissCount += cache.mStats.mMissCount;
752                mProjectionMissCount += cache.mStats.mProjectionMissCount;
753                mStaleCount += cache.mStats.mStaleCount;
754                hitTimes += cache.mStats.hitTimes;
755                missTimes += cache.mStats.missTimes;
756                hits += cache.mStats.hits;
757                miss += cache.mStats.miss;
758                mCursorCount += cache.size();
759                mTokenCount += cache.mTokenList.size();
760            }
761        }
762
763        private void append(StringBuilder sb, String name, Object value) {
764            sb.append(", ");
765            sb.append(name);
766            sb.append(": ");
767            sb.append(value);
768        }
769
770        @Override
771        public String toString() {
772            if (mHitCount + mMissCount == 0) return "No cache";
773            int totalTries = mMissCount + mProjectionMissCount + mHitCount;
774            StringBuilder sb = new StringBuilder();
775            sb.append("Cache " + mName);
776            append(sb, "Cursors", mCache == null ? mCursorCount : mCache.size());
777            append(sb, "Hits", mHitCount);
778            append(sb, "Misses", mMissCount + mProjectionMissCount);
779            append(sb, "Inval", mInvalidateCount);
780            append(sb, "Tokens", mCache == null ? mTokenCount : mCache.mTokenList.size());
781            append(sb, "Hit%", mHitCount * 100 / totalTries);
782            append(sb, "\nHit time", hitTimes / 1000000.0 / hits);
783            append(sb, "Miss time", missTimes / 1000000.0 / miss);
784            return sb.toString();
785        }
786    }
787
788    public static void dumpStats() {
789        Statistics totals = new Statistics("Totals");
790
791        for (ContentCache cache: sContentCaches) {
792            if (cache != null) {
793                Log.d(cache.mName, cache.mStats.toString());
794                totals.addCacheStatistics(cache);
795            }
796        }
797        Log.d(totals.mName, totals.toString());
798    }
799}
800