/* * Copyright (C) 2010 The Android Open Source Project * * Licensed 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 com.android.email.provider; import android.content.ContentValues; import android.database.CrossProcessCursor; import android.database.Cursor; import android.database.CursorWindow; import android.database.CursorWrapper; import android.database.MatrixCursor; import android.net.Uri; import android.util.Log; import android.util.LruCache; import com.android.email.Email; import com.google.common.annotations.VisibleForTesting; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Set; /** * An LRU cache for EmailContent (Account, HostAuth, Mailbox, and Message, thus far). The intended * user of this cache is EmailProvider itself; caching is entirely transparent to users of the * provider. * * Usage examples; id is a String representation of a row id (_id), as it might be retrieved from * a uri via getPathSegment * * To create a cache: * ContentCache cache = new ContentCache(name, projection, max); * * To (try to) get a cursor from a cache: * Cursor cursor = cache.getCursor(id, projection); * * To read from a table and cache the resulting cursor: * 1. Get a CacheToken: CacheToken token = cache.getToken(id); * 2. Get a cursor from the database: Cursor cursor = db.query(....); * 3. Put the cursor in the cache: cache.putCursor(cursor, id, token); * Only cursors with the projection given in the definition of the cache can be cached * * To delete one or more rows or update multiple rows from a table that uses cached data: * 1. Lock the row in the cache: cache.lock(id); * 2. Delete/update the row(s): db.delete(...); * 3. Invalidate any other caches that might be affected by the delete/update: * The entire cache: affectedCache.invalidate()* * A specific row in a cache: affectedCache.invalidate(rowId) * 4. Unlock the row in the cache: cache.unlock(id); * * To update a single row from a table that uses cached data: * 1. Lock the row in the cache: cache.lock(id); * 2. Update the row: db.update(...); * 3. Unlock the row in the cache, passing in the new values: cache.unlock(id, values); * * Synchronization note: All of the public methods in ContentCache are synchronized (i.e. on the * cache itself) except for methods that are solely used for debugging and do not modify the cache. * All references to ContentCache that are external to the ContentCache class MUST synchronize on * the ContentCache instance (e.g. CachedCursor.close()) */ public final class ContentCache { private static final boolean DEBUG_CACHE = false; // DO NOT CHECK IN TRUE private static final boolean DEBUG_TOKENS = false; // DO NOT CHECK IN TRUE private static final boolean DEBUG_NOT_CACHEABLE = false; // DO NOT CHECK IN TRUE private static final boolean DEBUG_STATISTICS = false; // DO NOT CHECK THIS IN TRUE // If false, reads will not use the cache; this is intended for debugging only private static final boolean READ_CACHE_ENABLED = true; // DO NOT CHECK IN FALSE // Count of non-cacheable queries (debug only) private static int sNotCacheable = 0; // A map of queries that aren't cacheable (debug only) private static final CounterMap<String> sNotCacheableMap = new CounterMap<String>(); private final LruCache<String, Cursor> mLruCache; // All defined caches private static final ArrayList<ContentCache> sContentCaches = new ArrayList<ContentCache>(); // A set of all unclosed, cached cursors; this will typically be a very small set, as cursors // tend to be closed quickly after use. The value, for each cursor, is its reference count /*package*/ static final CounterMap<Cursor> sActiveCursors = new CounterMap<Cursor>(24); // A set of locked content id's private final CounterMap<String> mLockMap = new CounterMap<String>(4); // A set of active tokens /*package*/ TokenList mTokenList; // The name of the cache (used for logging) private final String mName; // The base projection (only queries in which all columns exist in this projection will be // able to avoid a cache miss) private final String[] mBaseProjection; // The tag used for logging private final String mLogTag; // Cache statistics private final Statistics mStats; /** If {@code true}, lock the cache for all writes */ private static boolean sLockCache; /** * A synchronized reference counter for arbitrary objects */ /*package*/ static class CounterMap<T> { private HashMap<T, Integer> mMap; /*package*/ CounterMap(int maxSize) { mMap = new HashMap<T, Integer>(maxSize); } /*package*/ CounterMap() { mMap = new HashMap<T, Integer>(); } /*package*/ synchronized int subtract(T object) { Integer refCount = mMap.get(object); int newCount; if (refCount == null || refCount.intValue() == 0) { throw new IllegalStateException(); } if (refCount > 1) { newCount = refCount - 1; mMap.put(object, newCount); } else { newCount = 0; mMap.remove(object); } return newCount; } /*package*/ synchronized void add(T object) { Integer refCount = mMap.get(object); if (refCount == null) { mMap.put(object, 1); } else { mMap.put(object, refCount + 1); } } /*package*/ synchronized boolean contains(T object) { return mMap.containsKey(object); } /*package*/ synchronized int getCount(T object) { Integer refCount = mMap.get(object); return (refCount == null) ? 0 : refCount.intValue(); } synchronized int size() { return mMap.size(); } /** * For Debugging Only - not efficient */ synchronized Set<HashMap.Entry<T, Integer>> entrySet() { return mMap.entrySet(); } } /** * A list of tokens that are in use at any moment; there can be more than one token for an id */ /*package*/ static class TokenList extends ArrayList<CacheToken> { private static final long serialVersionUID = 1L; private final String mLogTag; /*package*/ TokenList(String name) { mLogTag = "TokenList-" + name; } /*package*/ int invalidateTokens(String id) { if (Email.DEBUG && DEBUG_TOKENS) { Log.d(mLogTag, "============ Invalidate tokens for: " + id); } ArrayList<CacheToken> removeList = new ArrayList<CacheToken>(); int count = 0; for (CacheToken token: this) { if (token.getId().equals(id)) { token.invalidate(); removeList.add(token); count++; } } for (CacheToken token: removeList) { remove(token); } return count; } /*package*/ void invalidate() { if (Email.DEBUG && DEBUG_TOKENS) { Log.d(mLogTag, "============ List invalidated"); } for (CacheToken token: this) { token.invalidate(); } clear(); } /*package*/ boolean remove(CacheToken token) { boolean result = super.remove(token); if (Email.DEBUG && DEBUG_TOKENS) { if (result) { Log.d(mLogTag, "============ Removing token for: " + token.mId); } else { Log.d(mLogTag, "============ No token found for: " + token.mId); } } return result; } public CacheToken add(String id) { CacheToken token = new CacheToken(id); super.add(token); if (Email.DEBUG && DEBUG_TOKENS) { Log.d(mLogTag, "============ Taking token for: " + token.mId); } return token; } } /** * A CacheToken is an opaque object that must be passed into putCursor in order to attempt to * write into the cache. The token becomes invalidated by any intervening write to the cached * record. */ public static final class CacheToken { private final String mId; private boolean mIsValid = READ_CACHE_ENABLED; /*package*/ CacheToken(String id) { mId = id; } /*package*/ String getId() { return mId; } /*package*/ boolean isValid() { return mIsValid; } /*package*/ void invalidate() { mIsValid = false; } @Override public boolean equals(Object token) { return ((token instanceof CacheToken) && ((CacheToken)token).mId.equals(mId)); } @Override public int hashCode() { return mId.hashCode(); } } /** * The cached cursor is simply a CursorWrapper whose underlying cursor contains zero or one * rows. We handle simple movement (moveToFirst(), moveToNext(), etc.), and override close() * to keep the underlying cursor alive (unless it's no longer cached due to an invalidation). * Multiple CachedCursor's can use the same underlying cursor, so we override the various * moveX methods such that each CachedCursor can have its own position information */ public static final class CachedCursor extends CursorWrapper implements CrossProcessCursor { // The cursor we're wrapping private final Cursor mCursor; // The cache which generated this cursor private final ContentCache mCache; private final String mId; // The current position of the cursor (can only be 0 or 1) private int mPosition = -1; // The number of rows in this cursor (-1 = not determined) private int mCount = -1; private boolean isClosed = false; public CachedCursor(Cursor cursor, ContentCache cache, String id) { super(cursor); mCursor = cursor; mCache = cache; mId = id; // Add this to our set of active cursors sActiveCursors.add(cursor); } /** * Close this cursor; if the cursor's cache no longer contains the underlying cursor, and * there are no other users of that cursor, we'll close it here. In any event, * we'll remove the cursor from our set of active cursors. */ @Override public void close() { synchronized(mCache) { int count = sActiveCursors.subtract(mCursor); if ((count == 0) && mCache.mLruCache.get(mId) != (mCursor)) { super.close(); } } isClosed = true; } @Override public boolean isClosed() { return isClosed; } @Override public int getCount() { if (mCount < 0) { mCount = super.getCount(); } return mCount; } /** * We'll be happy to move to position 0 or -1 */ @Override public boolean moveToPosition(int pos) { if (pos >= getCount() || pos < -1) { return false; } mPosition = pos; return true; } @Override public boolean moveToFirst() { return moveToPosition(0); } @Override public boolean moveToNext() { return moveToPosition(mPosition + 1); } @Override public boolean moveToPrevious() { return moveToPosition(mPosition - 1); } @Override public int getPosition() { return mPosition; } @Override public final boolean move(int offset) { return moveToPosition(mPosition + offset); } @Override public final boolean moveToLast() { return moveToPosition(getCount() - 1); } @Override public final boolean isLast() { return mPosition == (getCount() - 1); } @Override public final boolean isBeforeFirst() { return mPosition == -1; } @Override public final boolean isAfterLast() { return mPosition == 1; } @Override public CursorWindow getWindow() { return ((CrossProcessCursor)mCursor).getWindow(); } @Override public void fillWindow(int pos, CursorWindow window) { ((CrossProcessCursor)mCursor).fillWindow(pos, window); } @Override public boolean onMove(int oldPosition, int newPosition) { return true; } } /** * Public constructor * @param name the name of the cache (used for logging) * @param baseProjection the projection used for cached cursors; queries whose columns are not * included in baseProjection will always generate a cache miss * @param maxSize the maximum number of content cursors to cache */ public ContentCache(String name, String[] baseProjection, int maxSize) { mName = name; mLruCache = new LruCache<String, Cursor>(maxSize) { @Override protected void entryRemoved( boolean evicted, String key, Cursor oldValue, Cursor newValue) { // Close this cursor if it's no longer being used if (evicted && !sActiveCursors.contains(oldValue)) { oldValue.close(); } } }; mBaseProjection = baseProjection; mLogTag = "ContentCache-" + name; sContentCaches.add(this); mTokenList = new TokenList(mName); mStats = new Statistics(this); } /** * Return the base projection for cached rows * Get the projection used for cached rows (typically, the largest possible projection) * @return */ public String[] getProjection() { return mBaseProjection; } /** * Get a CacheToken for a row as specified by its id (_id column) * @param id the id of the record * @return a CacheToken needed in order to write data for the record back to the cache */ public synchronized CacheToken getCacheToken(String id) { // If another thread is already writing the data, return an invalid token CacheToken token = mTokenList.add(id); if (mLockMap.contains(id)) { token.invalidate(); } return token; } public int size() { return mLruCache.size(); } @VisibleForTesting Cursor get(String id) { return mLruCache.get(id); } protected Map<String, Cursor> getSnapshot() { return mLruCache.snapshot(); } /** * Try to cache a cursor for the given id and projection; returns a valid cursor, either a * cached cursor (if caching was successful) or the original cursor * * @param c the cursor to be cached * @param id the record id (_id) of the content * @param projection the projection represented by the cursor * @return whether or not the cursor was cached */ public Cursor putCursor(Cursor c, String id, String[] projection, CacheToken token) { // Make sure the underlying cursor is at the first row, and do this without synchronizing, // to prevent deadlock with a writing thread (which might, for example, be calling into // CachedCursor.invalidate) c.moveToPosition(0); return putCursorImpl(c, id, projection, token); } public synchronized Cursor putCursorImpl(Cursor c, String id, String[] projection, CacheToken token) { try { if (!token.isValid()) { if (Email.DEBUG && DEBUG_CACHE) { Log.d(mLogTag, "============ Stale token for " + id); } mStats.mStaleCount++; return c; } if (c != null && Arrays.equals(projection, mBaseProjection) && !sLockCache) { if (Email.DEBUG && DEBUG_CACHE) { Log.d(mLogTag, "============ Caching cursor for: " + id); } // If we've already cached this cursor, invalidate the older one Cursor existingCursor = get(id); if (existingCursor != null) { unlockImpl(id, null, false); } mLruCache.put(id, c); return new CachedCursor(c, this, id); } return c; } finally { mTokenList.remove(token); } } /** * Find and, if found, return a cursor, based on cached values, for the supplied id * @param id the _id column of the desired row * @param projection the requested projection for a query * @return a cursor based on cached values, or null if the row is not cached */ public synchronized Cursor getCachedCursor(String id, String[] projection) { if (Email.DEBUG && DEBUG_STATISTICS) { // Every 200 calls to getCursor, report cache statistics dumpOnCount(200); } if (projection == mBaseProjection) { return getCachedCursorImpl(id); } else { return getMatrixCursor(id, projection); } } private CachedCursor getCachedCursorImpl(String id) { Cursor c = get(id); if (c != null) { mStats.mHitCount++; return new CachedCursor(c, this, id); } mStats.mMissCount++; return null; } private MatrixCursor getMatrixCursor(String id, String[] projection) { return getMatrixCursor(id, projection, null); } private MatrixCursor getMatrixCursor(String id, String[] projection, ContentValues values) { Cursor c = get(id); if (c != null) { // Make a new MatrixCursor with the requested columns MatrixCursor mc = new MatrixCursor(projection, 1); if (c.getCount() == 0) { return mc; } Object[] row = new Object[projection.length]; if (values != null) { // Make a copy; we don't want to change the original values = new ContentValues(values); } int i = 0; for (String column: projection) { int columnIndex = c.getColumnIndex(column); if (columnIndex < 0) { mStats.mProjectionMissCount++; return null; } else { String value; if (values != null && values.containsKey(column)) { Object val = values.get(column); if (val instanceof Boolean) { value = (val == Boolean.TRUE) ? "1" : "0"; } else { value = values.getAsString(column); } values.remove(column); } else { value = c.getString(columnIndex); } row[i++] = value; } } if (values != null && values.size() != 0) { return null; } mc.addRow(row); mStats.mHitCount++; return mc; } mStats.mMissCount++; return null; } /** * Lock a given row, such that no new valid CacheTokens can be created for the passed-in id. * @param id the id of the row to lock */ public synchronized void lock(String id) { // Prevent new valid tokens from being created mLockMap.add(id); // Invalidate current tokens int count = mTokenList.invalidateTokens(id); if (Email.DEBUG && DEBUG_TOKENS) { Log.d(mTokenList.mLogTag, "============ Lock invalidated " + count + " tokens for: " + id); } } /** * Unlock a given row, allowing new valid CacheTokens to be created for the passed-in id. * @param id the id of the item whose cursor is cached */ public synchronized void unlock(String id) { unlockImpl(id, null, true); } /** * If the row with id is currently cached, replaces the cached values with the supplied * ContentValues. Then, unlock the row, so that new valid CacheTokens can be created. * * @param id the id of the item whose cursor is cached * @param values updated values for this row */ public synchronized void unlock(String id, ContentValues values) { unlockImpl(id, values, true); } /** * If values are passed in, replaces any cached cursor with one containing new values, and * then closes the previously cached one (if any, and if not in use) * If values are not passed in, removes the row from cache * If the row was locked, unlock it * @param id the id of the row * @param values new ContentValues for the row (or null if row should simply be removed) * @param wasLocked whether or not the row was locked; if so, the lock will be removed */ private void unlockImpl(String id, ContentValues values, boolean wasLocked) { Cursor c = get(id); if (c != null) { if (Email.DEBUG && DEBUG_CACHE) { Log.d(mLogTag, "=========== Unlocking cache for: " + id); } if (values != null && !sLockCache) { MatrixCursor cursor = getMatrixCursor(id, mBaseProjection, values); if (cursor != null) { if (Email.DEBUG && DEBUG_CACHE) { Log.d(mLogTag, "=========== Recaching with new values: " + id); } cursor.moveToFirst(); mLruCache.put(id, cursor); } else { mLruCache.remove(id); } } else { mLruCache.remove(id); } // If there are no cursors using the old cached cursor, close it if (!sActiveCursors.contains(c)) { c.close(); } } if (wasLocked) { mLockMap.subtract(id); } } /** * Invalidate the entire cache, without logging */ public synchronized void invalidate() { invalidate(null, null, null); } /** * Invalidate the entire cache; the arguments are used for logging only, and indicate the * write operation that caused the invalidation * * @param operation a string describing the operation causing the invalidate (or null) * @param uri the uri causing the invalidate (or null) * @param selection the selection used with the uri (or null) */ public synchronized void invalidate(String operation, Uri uri, String selection) { if (DEBUG_CACHE && (operation != null)) { Log.d(mLogTag, "============ INVALIDATED BY " + operation + ": " + uri + ", SELECTION: " + selection); } mStats.mInvalidateCount++; // Close all cached cursors that are no longer in use mLruCache.evictAll(); // Invalidate all current tokens mTokenList.invalidate(); } // Debugging code below private void dumpOnCount(int num) { mStats.mOpCount++; if ((mStats.mOpCount % num) == 0) { dumpStats(); } } /*package*/ void recordQueryTime(Cursor c, long nanoTime) { if (c instanceof CachedCursor) { mStats.hitTimes += nanoTime; mStats.hits++; } else { if (c.getCount() == 1) { mStats.missTimes += nanoTime; mStats.miss++; } } } public static synchronized void notCacheable(Uri uri, String selection) { if (DEBUG_NOT_CACHEABLE) { sNotCacheable++; String str = uri.toString() + "$" + selection; sNotCacheableMap.add(str); } } private static class CacheCounter implements Comparable<CacheCounter> { String uri; Integer count; CacheCounter(String _uri, Integer _count) { uri = _uri; count = _count; } @Override public int compareTo(CacheCounter another) { return another.count > count ? 1 : another.count == count ? 0 : -1; } } private static void dumpNotCacheableQueries() { int size = sNotCacheableMap.size(); CacheCounter[] array = new CacheCounter[size]; int i = 0; for (Map.Entry<String, Integer> entry: sNotCacheableMap.entrySet()) { array[i++] = new CacheCounter(entry.getKey(), entry.getValue()); } Arrays.sort(array); for (CacheCounter cc: array) { Log.d("NotCacheable", cc.count + ": " + cc.uri); } } // For use with unit tests public static void invalidateAllCaches() { for (ContentCache cache: sContentCaches) { cache.invalidate(); } } /** Sets the cache lock. If the lock is {@code true}, also invalidates all cached items. */ public static void setLockCacheForTest(boolean lock) { sLockCache = lock; if (sLockCache) { invalidateAllCaches(); } } static class Statistics { private final ContentCache mCache; private final String mName; // Cache statistics // The item is in the cache AND is used to create a cursor private int mHitCount = 0; // Basic cache miss (the item is not cached) private int mMissCount = 0; // Incremented when a cachePut is invalid due to an intervening write private int mStaleCount = 0; // A projection miss occurs when the item is cached, but not all requested columns are // available in the base projection private int mProjectionMissCount = 0; // Incremented whenever the entire cache is invalidated private int mInvalidateCount = 0; // Count of operations put/get private int mOpCount = 0; // The following are for timing statistics private long hits = 0; private long hitTimes = 0; private long miss = 0; private long missTimes = 0; // Used in toString() and addCacheStatistics() private int mCursorCount = 0; private int mTokenCount = 0; Statistics(ContentCache cache) { mCache = cache; mName = mCache.mName; } Statistics(String name) { mCache = null; mName = name; } private void addCacheStatistics(ContentCache cache) { if (cache != null) { mHitCount += cache.mStats.mHitCount; mMissCount += cache.mStats.mMissCount; mProjectionMissCount += cache.mStats.mProjectionMissCount; mStaleCount += cache.mStats.mStaleCount; hitTimes += cache.mStats.hitTimes; missTimes += cache.mStats.missTimes; hits += cache.mStats.hits; miss += cache.mStats.miss; mCursorCount += cache.size(); mTokenCount += cache.mTokenList.size(); } } private void append(StringBuilder sb, String name, Object value) { sb.append(", "); sb.append(name); sb.append(": "); sb.append(value); } @Override public String toString() { if (mHitCount + mMissCount == 0) return "No cache"; int totalTries = mMissCount + mProjectionMissCount + mHitCount; StringBuilder sb = new StringBuilder(); sb.append("Cache " + mName); append(sb, "Cursors", mCache == null ? mCursorCount : mCache.size()); append(sb, "Hits", mHitCount); append(sb, "Misses", mMissCount + mProjectionMissCount); append(sb, "Inval", mInvalidateCount); append(sb, "Tokens", mCache == null ? mTokenCount : mCache.mTokenList.size()); append(sb, "Hit%", mHitCount * 100 / totalTries); append(sb, "\nHit time", hitTimes / 1000000.0 / hits); append(sb, "Miss time", missTimes / 1000000.0 / miss); return sb.toString(); } } public static void dumpStats() { Statistics totals = new Statistics("Totals"); for (ContentCache cache: sContentCaches) { if (cache != null) { Log.d(cache.mName, cache.mStats.toString()); totals.addCacheStatistics(cache); } } Log.d(totals.mName, totals.toString()); } }