/*
* @copyright 2012 Philip Warner
* @license GNU General Public License
*
* This file is part of Book Catalogue.
*
* Book Catalogue is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Book Catalogue is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Book Catalogue. If not, see <http://www.gnu.org/licenses/>.
*/
package com.eleybourn.bookcatalogue.database;
import java.lang.reflect.Field;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteClosable;
import android.database.sqlite.SQLiteCursor;
import android.database.sqlite.SQLiteCursorDriver;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteDatabase.CursorFactory;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteQuery;
import android.database.sqlite.SQLiteStatement;
import com.eleybourn.bookcatalogue.CatalogueDBAdapter;
import com.eleybourn.bookcatalogue.database.DbSync.Synchronizer.LockTypes;
import com.eleybourn.bookcatalogue.database.DbSync.Synchronizer.SyncLock;
import com.eleybourn.bookcatalogue.utils.Logger;
/**
* Classes used to help synchronize database access across threads.
*
* @author Philip Warner
*/
public class DbSync {
/**
* Implementation of a Readers/Writer lock that is fully reentrant.
*
* Because SQLite throws exception on locking conflicts, this class can be used to serialize WRITE
* access while allowing concurrent read access.
*
* Each logical database should have its own 'Synchronizer' and before any read, or group or reads, a call
* to getSharedLock() should be made. A call to getExclusiveLock() should be made before any update. Multiple
* calls can be made as necessary so long as an unlock() is called for all get*() calls by using the
* SyncLock object returned from the get*() call.
*
* These can be called in any order and locks in the current thread never block requests.
*
* Deadlocks are not possible because the implementation involves a single lock object.
*
* NOTE: This lock can cause writer starvation since it does not introduce pending locks.
*
* @author Philip Warner
*/
public static class Synchronizer {
/** Main lock for synchronization */
private final ReentrantLock mLock = new ReentrantLock();
/** Condition fired when a reader releases a lock */
private final Condition mReleased = mLock.newCondition();
/** Collection of threads that have shared locks */
private final Hashtable<Thread,Integer> mSharedOwners = new Hashtable<Thread,Integer>();
/** Lock used to pass back to consumers of shared locks */
private final SharedLock mSharedLock = new SharedLock();
/** Lock used to pass back to consumers of exclusive locks */
private final ExclusiveLock mExclusiveLock = new ExclusiveLock();
/** Enum of lock types supported */
public enum LockTypes { shared, exclusive };
/**
* Interface common to all lock types.
*
* @author Philip Warner
*/
public interface SyncLock {
void unlock();
LockTypes getType();
}
/**
* Internal implementation of a Shared Lock.
*
* @author Philip Warner
*/
private class SharedLock implements SyncLock {
@Override
public void unlock() {
releaseSharedLock();
}
@Override
public LockTypes getType() {
return LockTypes.shared;
}
}
/**
* Internal implementation of an Exclusive Lock.
*
* @author Philip Warner
*/
private class ExclusiveLock implements SyncLock {
@Override
public void unlock() {
releaseExclusiveLock();
}
@Override
public LockTypes getType() {
return LockTypes.exclusive;
}
}
/**
* Routine to purge shared locks held by dead threads. Can only be called
* while mLock is held.
*/
private void purgeOldLocks() {
if (!mLock.isHeldByCurrentThread())
throw new RuntimeException("Can not cleanup old locks if not locked");
Enumeration<Thread> it = mSharedOwners.keys();
while( it.hasMoreElements() ) {
Thread t = it.nextElement();
if (!t.isAlive())
mSharedOwners.remove(t);
}
}
/**
* Add a new SharedLock to the collection and return it.
*
* @return
*/
public SyncLock getSharedLock() {
final Thread t = Thread.currentThread();
//System.out.println(t.getName() + " requesting SHARED lock");
mLock.lock();
//System.out.println(t.getName() + " locked lock held by " + mLock.getHoldCount());
purgeOldLocks();
try {
Integer count;
if (mSharedOwners.containsKey(t)) {
count = mSharedOwners.get(t) + 1;
} else {
count = 1;
}
mSharedOwners.put(t,count);
//System.out.println(t.getName() + " " + count + " SHARED threads");
return mSharedLock;
} finally {
mLock.unlock();
//System.out.println(t.getName() + " unlocked lock held by " + mLock.getHoldCount());
}
}
/**
* Release a shared lock. If no more locks in thread, remove from list.
*/
public void releaseSharedLock() {
final Thread t = Thread.currentThread();
//System.out.println(t.getName() + " releasing SHARED lock");
mLock.lock();
//System.out.println(t.getName() + " locked lock held by " + mLock.getHoldCount());
try {
if (mSharedOwners.containsKey(t)) {
Integer count = mSharedOwners.get(t) - 1;
//System.out.println(t.getName() + " now has " + count + " SHARED locks");
if (count < 0)
throw new RuntimeException("Release a lock count already zero");
if (count != 0) {
mSharedOwners.put(t,count);
} else {
mSharedOwners.remove(t);
mReleased.signal();
}
} else {
throw new RuntimeException("Release a lock when not held");
}
} finally {
mLock.unlock();
//System.out.println(t.getName() + " unlocked lock held by " + mLock.getHoldCount());
}
}
/**
* Return when exclusive access is available.
*
* - take a lock on the collection
* - see if there are any other locks
* - if not, return with the lock still held -- this prevents more EX or SH locks.
* - if there are other SH locks, wait for one to be release and loop.
*
* @return
*/
public SyncLock getExclusiveLock() {
final Thread t = Thread.currentThread();
//long t0 = System.currentTimeMillis();
// Synchronize with other code
mLock.lock();
try {
while (true) {
// Cleanup any old threads that are dead.
purgeOldLocks();
//System.out.println(t.getName() + " requesting EXCLUSIVE lock with " + mSharedOwners.size() + " shared locks (attempt #" + i + ")");
//System.out.println("Lock held by " + mLock.getHoldCount());
try {
// Simple case -- no locks held, just return and keep the lock
if (mSharedOwners.size() == 0)
return mExclusiveLock;
// Check for one lock, and it being this thread.
if (mSharedOwners.size() == 1 && mSharedOwners.containsValue(t)) {
// One locker, and it is us...so upgrade is OK.
return mExclusiveLock;
}
// Someone else has it. Wait.
//System.out.println("Thread " + t.getName() + " waiting for DB access");
mReleased.await();
} catch (Exception e) {
// Probably happens because thread was interrupted. Just die.
try { mLock.unlock(); } catch(Exception e2) {};
throw new RuntimeException("Unable to get exclusive lock", e);
}
}
} finally {
//long t1 = System.currentTimeMillis();
//if (mLock.isHeldByCurrentThread())
// System.out.println(t.getName() + " waited " + (t1 - t0) + "ms for EXCLUSIVE access");
//else
// System.out.println(t.getName() + " waited " + (t1 - t0) + "ms AND FAILED TO GET EXCLUSIVE access");
}
}
/**
* Release the lock previously taken
*/
public void releaseExclusiveLock() {
//final Thread t = Thread.currentThread();
//System.out.println(t.getName() + " releasing EXCLUSIVE lock");
if (!mLock.isHeldByCurrentThread())
throw new RuntimeException("Exclusive Lock is not held by this thread");
mLock.unlock();
//System.out.println("Release lock held by " + mLock.getHoldCount());
//System.out.println(t.getName() + " released EXCLUSIVE lock");
}
}
/**
* Database wrapper class that performs thread synchronization on all operations.
*
* @author Philip Warner
*/
public static class SynchronizedDb {
/** Underlying database */
final SQLiteDatabase mDb;
/** Sync object to use */
final Synchronizer mSync;
/** Currently held transaction lock, if any */
private SyncLock mTxLock = null;
/**
* Constructor. Use of this method is not recommended. It is better to use
* the methods that take a DBHelper object since opening the database may block
* another thread, or vice versa.
*
* @param db Underlying database
* @param sync Synchronizer to use
*/
public SynchronizedDb(SQLiteDatabase db, Synchronizer sync) {
mDb = db;
mSync = sync;
}
/**
* Interface to an object that can return an open SQLite database object
*
* @author pjw
*/
private interface DbOpener {
SQLiteDatabase open();
}
/**
* Call the passed database opener with retries to reduce risks of access conflicts
* causing crashes.
*
* @param opener DbOpener interface
*
* @return The opened database
*/
private SQLiteDatabase openWithRetries(DbOpener opener) {
int wait = 10; // 10ms
//int retriesLeft = 5; // up to 320ms
int retriesLeft = 10; // 2^10 * 10ms = 10.24sec (actually 2x that due to total wait time)
SQLiteDatabase db = null;
do {
SyncLock l = mSync.getExclusiveLock();
try {
db = opener.open();
return db;
} catch (Exception e) {
if (l != null) {
l.unlock();
l = null;
}
if (retriesLeft == 0) {
throw new RuntimeException("Unable to open database, retries exhausted", e);
}
try {
Thread.sleep(wait);
// Decrement tries
retriesLeft--;
// Wait longer next time
wait *= 2;
} catch (InterruptedException e1) {
throw new RuntimeException("Unable to open database, interrupted", e1);
}
} finally {
if (l != null) {
l.unlock();
l = null;
}
}
} while (true);
}
/**
* Constructor.
*
* @param helper DBHelper to open underlying database
* @param sync Synchronizer to use
*/
public SynchronizedDb(final SQLiteOpenHelper helper, Synchronizer sync) {
mSync = sync;
mDb = openWithRetries(new DbOpener() {
@Override
public SQLiteDatabase open() {
return helper.getWritableDatabase();
}});
}
/**
* Constructor.
*
* @param helper DBHelper to open underlying database
* @param sync Synchronizer to use
*/
public SynchronizedDb(final GenericOpenHelper helper, Synchronizer sync) {
mSync = sync;
mDb = openWithRetries(new DbOpener() {
@Override
public SQLiteDatabase open() {
return helper.getWritableDatabase();
}});
}
/**
* Factory for Synchronized Cursor objects. This can be subclassed by other
* Cursor implementations.
*
* @author Philip Warner
*/
public class SynchronizedCursorFactory implements CursorFactory {
@Override
public SynchronizedCursor newCursor(SQLiteDatabase db,
SQLiteCursorDriver masterQuery, String editTable,
SQLiteQuery query) {
return new SynchronizedCursor(db, masterQuery, editTable, query, mSync);
}
}
/** Factory object to create the custom cursor. Can not be static because it needs mSync */
public final SynchronizedCursorFactory mCursorFactory = new SynchronizedCursorFactory();
/**
* Locking-aware wrapper for underlying database method.
*
* @param sql
* @param selectionArgs
* @return
*/
public SynchronizedCursor rawQuery(String sql, String [] selectionArgs) {
return rawQueryWithFactory(mCursorFactory, sql, selectionArgs,"");
}
/**
* Locking-aware wrapper for underlying database method.
*
* @param sql
* @return
*/
public SynchronizedCursor rawQuery(String sql) {
return rawQuery(sql, CatalogueDBAdapter.EMPTY_STRING_ARRAY);
}
/**
* Locking-aware wrapper for underlying database method.
*
* @param factory
* @param sql
* @param selectionArgs
* @param editTable
* @return
*/
public SynchronizedCursor rawQueryWithFactory(SynchronizedCursorFactory factory, String sql, String [] selectionArgs, String editTable) {
SyncLock l = null;
if (mTxLock == null)
l = mSync.getSharedLock();
try {
return (SynchronizedCursor)mDb.rawQueryWithFactory(factory, sql, selectionArgs, editTable);
} finally {
if (l != null)
l.unlock();
}
}
/**
* Locking-aware wrapper for underlying database method.
*
* @param sql
* @param selectionArgs
* @return
*/
public void execSQL(String sql) {
if (mTxLock != null) {
if (mTxLock.getType() != LockTypes.exclusive)
throw new RuntimeException("Update inside shared TX");
mDb.execSQL(sql);
} else {
SyncLock l = mSync.getExclusiveLock();
try {
mDb.execSQL(sql);
} finally {
l.unlock();
}
}
}
/**
* Locking-aware wrapper for underlying database method.
*
* @param table
* @param columns
* @param selection
* @param selectionArgs
* @param groupBy
* @param having
* @param orderBy
* @return
*/
public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy) {
SyncLock l = null;
if (mTxLock == null)
l = mSync.getSharedLock();
try {
return mDb.query(table, columns, selection, selectionArgs, groupBy, having, orderBy);
} finally {
if (l != null)
l.unlock();
}
}
/**
* Locking-aware wrapper for underlying database method; actually
* calls insertOrThrow since this method also throws exceptions
*
* @param sql
* @param selectionArgs
* @return
*/
public long insert(String table, String nullColumnHack, ContentValues values) {
SyncLock l = null;
if (mTxLock != null) {
if (mTxLock.getType() != LockTypes.exclusive)
throw new RuntimeException("Update inside shared TX");
} else
l = mSync.getExclusiveLock();
try {
return mDb.insertOrThrow(table, nullColumnHack, values);
} finally {
if (l != null)
l.unlock();
}
}
/**
* Locking-aware wrapper for underlying database method.
*
* @param table
* @param values
* @param whereClause
* @param whereArgs
* @return
*/
public int update(String table, ContentValues values, String whereClause, String[] whereArgs) {
SyncLock l = null;
if (mTxLock != null) {
if (mTxLock.getType() != LockTypes.exclusive)
throw new RuntimeException("Update inside shared TX");
} else
l = mSync.getExclusiveLock();
try {
return mDb.update(table, values, whereClause, whereArgs);
} finally {
if (l != null)
l.unlock();
}
}
/**
* Locking-aware wrapper for underlying database method.
*
* @param sql
* @param selectionArgs
* @return
*/
public int delete(String table, String whereClause, String[] whereArgs) {
SyncLock l = null;
if (mTxLock != null) {
if (mTxLock.getType() != LockTypes.exclusive)
throw new RuntimeException("Update inside shared TX");
} else
l = mSync.getExclusiveLock();
try {
return mDb.delete(table, whereClause, whereArgs);
} finally {
if (l != null)
l.unlock();
}
}
/**
* Wrapper for underlying database method. It is recommended that custom cursors subclass SynchronizedCursor.
*
* @param cursorFactory
* @param sql
* @param selectionArgs
* @param editTable
* @return
*/
public Cursor rawQueryWithFactory(SQLiteDatabase.CursorFactory cursorFactory, String sql, String[] selectionArgs, String editTable) {
SyncLock l = null;
if (mTxLock == null)
l = mSync.getSharedLock();
try {
return mDb.rawQueryWithFactory(cursorFactory, sql, selectionArgs, editTable);
} finally {
if (l != null)
l.unlock();
}
}
/**
* Locking-aware wrapper for underlying database method.
*
* @param sql
* @return
*/
public SynchronizedStatement compileStatement(String sql) {
SyncLock l = null;
if (mTxLock != null) {
if (mTxLock.getType() != LockTypes.exclusive)
throw new RuntimeException("Compile inside shared TX");
} else
l = mSync.getExclusiveLock();
try {
return new SynchronizedStatement(this, sql);
} finally {
if (l != null)
l.unlock();
}
}
/**
* Return the underlying SQLiteDatabase object.
*
* @return
*/
public SQLiteDatabase getUnderlyingDatabase() {
return mDb;
}
/**
* Wrapper.
*
* @return
*/
public boolean inTransaction() {
return mDb.inTransaction();
}
/**
* Locking-aware wrapper for underlying database method.
*
* @param isUpdate Indicates if updates will be done in TX
*
* @return
*/
public SyncLock beginTransaction(boolean isUpdate) {
SyncLock l;
if (isUpdate) {
l = mSync.getExclusiveLock();
} else {
l = mSync.getSharedLock();
}
// We have the lock, but if the real beginTransaction() throws an exception, we need to release the lock
try {
// If we have a lock, and there is currently a TX active...die
// Note: because we get a lock, two 'isUpdate' transactions will
// block, this is only likely to happen with two TXs on the current thread
// or two non-update TXs on different thread.
// ENHANCE: Consider allowing nested TXs
// ENHANCE: Consider returning NULL if TX active and handle null locks...
if (mTxLock != null)
throw new RuntimeException("Starting a transaction when one is already started");
mDb.beginTransaction();
} catch (Exception e) {
l.unlock();
throw new RuntimeException("Unable to start database transaction: " + e.getMessage(), e);
}
mTxLock = l;
return l;
}
/**
* Locking-aware wrapper for underlying database method.
*
* @param l Lock returned from BeginTransaction().
*/
public void endTransaction(SyncLock l) {
if (mTxLock == null)
throw new RuntimeException("Ending a transaction when none is started");
if (!mTxLock.equals(l))
throw new RuntimeException("Ending a transaction with wrong transaction lock");
try {
mDb.endTransaction();
} finally {
// Clear mTxLock before unlocking so another thread does not
// see the old lock when it gets the lock
mTxLock = null;
l.unlock();
}
}
/**
* Wrapper for underlying database method.
*
*/
public void setTransactionSuccessful() {
mDb.setTransactionSuccessful();
}
/**
* Wrapper for underlying database method.
*
*/
public boolean isOpen() {
return mDb.isOpen();
}
/**
* Return the underlying synchronizer object.
*
* @return
*/
public Synchronizer getSynchronizer() {
return mSync;
}
/**
* Utility routine, purely for debugging ref count issues (mainly Android 2.1)
*
* @param msg Message to display (relating to context)
* @param db Database object
*
* @return Number of current references
*/
public static int printRefCount(String msg, SQLiteDatabase db) {
System.gc();
Field f;
try {
f = SQLiteClosable.class.getDeclaredField("mReferenceCount");
f.setAccessible(true);
int refs = (Integer) f.get(db); //IllegalAccessException
if (msg != null) {
System.out.println("DBRefs (" + msg + "): " + refs);
//if (refs < 100) {
// System.out.println("DBRefs (" + msg + "): " + refs + " <-- TOO LOW (< 100)!");
//} else if (refs < 1001) {
// System.out.println("DBRefs (" + msg + "): " + refs + " <-- TOO LOW (< 1000)!");
//} else {
// System.out.println("DBRefs (" + msg + "): " + refs);
//}
}
return refs;
} catch (NoSuchFieldException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IllegalArgumentException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IllegalAccessException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return 0;
}
}
/**
* Wrapper for statements that ensures locking is used.
*
* @author Philip Warner
*/
public static class SynchronizedStatement {
/** Synchronizer from database */
final Synchronizer mSync;
/** Underlying statement */
final SQLiteStatement mStatement;
/** Indicates this is a 'read-only' statement */
final boolean mIsReadOnly;
/** Indicates close() has been called */
private boolean mIsClosed = false;
/** Copy of SQL used for debugging */
private final String mSql;
private SynchronizedStatement (final SynchronizedDb db, final String sql) {
mSync = db.getSynchronizer();
mSql = sql;
if (sql.trim().toLowerCase().startsWith("select"))
mIsReadOnly = true;
else
mIsReadOnly = false;
mStatement = db.getUnderlyingDatabase().compileStatement(sql);
}
/**
* Wrapper for underlying method on SQLiteStatement.
*/
public void bindDouble(final int index, final double value) {
mStatement.bindDouble(index, value);
}
/**
* Wrapper for underlying method on SQLiteStatement.
*/
public void bindLong(final int index, final long value) {
mStatement.bindLong(index, value);
}
/**
* Wrapper for underlying method on SQLiteStatement.
*/
public void bindString(final int index, final String value) {
mStatement.bindString(index, value);
}
/**
* Wrapper for underlying method on SQLiteStatement.
*/
public void bindBlob(final int index, final byte[] value) {
mStatement.bindBlob(index, value);
}
/**
* Wrapper for underlying method on SQLiteStatement.
*/
public void bindNull(final int index) {
mStatement.bindNull(index);
}
/**
* Wrapper for underlying method on SQLiteStatement.
*/
public void clearBindings() {
mStatement.clearBindings();
}
/**
* Wrapper for underlying method on SQLiteStatement.
*/
public void close() {
mIsClosed = true;
mStatement.close();
}
/**
* Wrapper that uses a lock before calling underlying method on SQLiteStatement.
*/
public long simpleQueryForLong() {
SyncLock l = mSync.getSharedLock();
try {
return mStatement.simpleQueryForLong();
} finally {
l.unlock();
}
}
/**
* Wrapper that uses a lock before calling underlying method on SQLiteStatement.
*/
public String simpleQueryForString() {
SyncLock l = mSync.getSharedLock();
try {
return mStatement.simpleQueryForString();
} finally {
l.unlock();
}
}
/**
* Wrapper that uses a lock before calling underlying method on SQLiteStatement.
*/
public void execute() {
SyncLock l;
if (mIsReadOnly)
l = mSync.getSharedLock();
else
l = mSync.getExclusiveLock();
try {
mStatement.execute();
} finally {
l.unlock();
}
}
/**
* Wrapper that uses a lock before calling underlying method on SQLiteStatement.
*/
public long executeInsert() {
SyncLock l = mSync.getExclusiveLock();
try {
return mStatement.executeInsert();
} finally {
l.unlock();
}
}
public void finalize() {
if (!mIsClosed)
Logger.logError(new RuntimeException("Finalizing non-closed statement")); // + mSql));
// Try to close the underlying statement.
try {
mStatement.close();
} catch (Exception e) {
// Ignore; may have been finalized
}
}
}
/**
* Cursor wrapper that tries to apply locks as necessary. Unfortunately, most cursor
* movement methods are final and, if they involve any database locking, could theoretically
* still result in 'database is locked' exceptions. So far in testing, none have occurred.
*/
public static class SynchronizedCursor extends SQLiteCursor {
private final Synchronizer mSync;
public SynchronizedCursor(SQLiteDatabase db, SQLiteCursorDriver driver,
String editTable, SQLiteQuery query, Synchronizer sync) {
super(db, driver, editTable, query);
mSync = sync;
}
private int mCount = -1;
/**
* Wrapper that uses a lock before calling underlying method.
*/
@Override
public int getCount() {
// Cache the count (it's what SQLiteCursor does), and we avoid locking
if (mCount == -1) {
SyncLock l = mSync.getSharedLock();
try {
mCount = super.getCount();
} finally {
l.unlock();
}
}
return mCount;
}
/**
* Wrapper that uses a lock before calling underlying method.
*/
@Override
public boolean requery() {
SyncLock l = mSync.getSharedLock();
try {
return super.requery();
} finally {
l.unlock();
}
}
}
}