All Downloads are FREE. Search and download functionalities are using the official Maven repository.

androidx.room.InvalidationTracker Maven / Gradle / Ivy

Go to download

A library jar that provides APIs for Applications written for the Google Android Platform.

There is a newer version: 14-robolectric-10818077
Show newest version
/*
 * Copyright (C) 2017 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 androidx.room;

import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.arch.core.internal.SafeIterableMap;
import androidx.collection.ArrayMap;
import androidx.collection.ArraySet;
import androidx.arch.core.executor.ArchTaskExecutor;
import androidx.sqlite.db.SupportSQLiteDatabase;
import androidx.sqlite.db.SupportSQLiteStatement;

import java.lang.ref.WeakReference;
import java.util.Arrays;
import java.util.Collections;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;

/**
 * InvalidationTracker keeps a list of tables modified by queries and notifies its callbacks about
 * these tables.
 */
// We create an in memory table with (version, table_id) where version is an auto-increment primary
// key and a table_id (hardcoded int from initialization).
// ObservedTableTracker tracks list of tables we should be watching (e.g. adding triggers for).
// Before each beginTransaction, RoomDatabase invokes InvalidationTracker to sync trigger states.
// After each endTransaction, RoomDatabase invokes InvalidationTracker to refresh invalidated
// tables.
// Each update on one of the observed tables triggers an insertion into this table, hence a
// new version.
// Unfortunately, we cannot override the previous row because sqlite uses the conflict resolution
// of the outer query (the thing that triggered us) so we do a cleanup as we sync instead of letting
// SQLite override the rows.
// https://sqlite.org/lang_createtrigger.html:  An ON CONFLICT clause may be specified as part of an
// UPDATE or INSERT action within the body of the trigger. However if an ON CONFLICT clause is
// specified as part of the statement causing the trigger to fire, then conflict handling policy of
// the outer statement is used instead.
public class InvalidationTracker {

    private static final String[] TRIGGERS = new String[]{"UPDATE", "DELETE", "INSERT"};

    private static final String UPDATE_TABLE_NAME = "room_table_modification_log";

    private static final String VERSION_COLUMN_NAME = "version";

    private static final String TABLE_ID_COLUMN_NAME = "table_id";

    private static final String CREATE_VERSION_TABLE_SQL = "CREATE TEMP TABLE " + UPDATE_TABLE_NAME
            + "(" + VERSION_COLUMN_NAME
            + " INTEGER PRIMARY KEY AUTOINCREMENT, "
            + TABLE_ID_COLUMN_NAME
            + " INTEGER)";

    @VisibleForTesting
    static final String CLEANUP_SQL = "DELETE FROM " + UPDATE_TABLE_NAME
            + " WHERE " + VERSION_COLUMN_NAME + " NOT IN( SELECT MAX("
            + VERSION_COLUMN_NAME + ") FROM " + UPDATE_TABLE_NAME
            + " GROUP BY " + TABLE_ID_COLUMN_NAME + ")";

    @VisibleForTesting
    // We always clean before selecting so it is unlikely to have the same row twice and if we
    // do, it is not a big deal, just more data in the cursor.
    static final String SELECT_UPDATED_TABLES_SQL = "SELECT * FROM " + UPDATE_TABLE_NAME
            + " WHERE " + VERSION_COLUMN_NAME
            + "  > ? ORDER BY " + VERSION_COLUMN_NAME + " ASC;";

    @NonNull
    @VisibleForTesting
    ArrayMap mTableIdLookup;
    private String[] mTableNames;

    @NonNull
    @VisibleForTesting
    long[] mTableVersions;

    private Object[] mQueryArgs = new Object[1];

    // max id in the last syc
    private long mMaxVersion = 0;

    private final RoomDatabase mDatabase;

    AtomicBoolean mPendingRefresh = new AtomicBoolean(false);

    private volatile boolean mInitialized = false;

    private volatile SupportSQLiteStatement mCleanupStatement;

    private ObservedTableTracker mObservedTableTracker;

    // should be accessed with synchronization only.
    @VisibleForTesting
    final SafeIterableMap mObserverMap = new SafeIterableMap<>();

    /**
     * Used by the generated code.
     *
     * @hide
     */
    @SuppressWarnings("WeakerAccess")
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public InvalidationTracker(RoomDatabase database, String... tableNames) {
        mDatabase = database;
        mObservedTableTracker = new ObservedTableTracker(tableNames.length);
        mTableIdLookup = new ArrayMap<>();
        final int size = tableNames.length;
        mTableNames = new String[size];
        for (int id = 0; id < size; id++) {
            final String tableName = tableNames[id].toLowerCase(Locale.US);
            mTableIdLookup.put(tableName, id);
            mTableNames[id] = tableName;
        }
        mTableVersions = new long[tableNames.length];
        Arrays.fill(mTableVersions, 0);
    }

    /**
     * Internal method to initialize table tracking.
     * 

* You should never call this method, it is called by the generated code. */ void internalInit(SupportSQLiteDatabase database) { synchronized (this) { if (mInitialized) { Log.e(Room.LOG_TAG, "Invalidation tracker is initialized twice :/."); return; } database.beginTransaction(); try { database.execSQL("PRAGMA temp_store = MEMORY;"); database.execSQL("PRAGMA recursive_triggers='ON';"); database.execSQL(CREATE_VERSION_TABLE_SQL); database.setTransactionSuccessful(); } finally { database.endTransaction(); } syncTriggers(database); mCleanupStatement = database.compileStatement(CLEANUP_SQL); mInitialized = true; } } private static void appendTriggerName(StringBuilder builder, String tableName, String triggerType) { builder.append("`") .append("room_table_modification_trigger_") .append(tableName) .append("_") .append(triggerType) .append("`"); } private void stopTrackingTable(SupportSQLiteDatabase writableDb, int tableId) { final String tableName = mTableNames[tableId]; StringBuilder stringBuilder = new StringBuilder(); for (String trigger : TRIGGERS) { stringBuilder.setLength(0); stringBuilder.append("DROP TRIGGER IF EXISTS "); appendTriggerName(stringBuilder, tableName, trigger); writableDb.execSQL(stringBuilder.toString()); } } private void startTrackingTable(SupportSQLiteDatabase writableDb, int tableId) { final String tableName = mTableNames[tableId]; StringBuilder stringBuilder = new StringBuilder(); for (String trigger : TRIGGERS) { stringBuilder.setLength(0); stringBuilder.append("CREATE TEMP TRIGGER IF NOT EXISTS "); appendTriggerName(stringBuilder, tableName, trigger); stringBuilder.append(" AFTER ") .append(trigger) .append(" ON `") .append(tableName) .append("` BEGIN INSERT OR REPLACE INTO ") .append(UPDATE_TABLE_NAME) .append(" VALUES(null, ") .append(tableId) .append("); END"); writableDb.execSQL(stringBuilder.toString()); } } /** * Adds the given observer to the observers list and it will be notified if any table it * observes changes. *

* Database changes are pulled on another thread so in some race conditions, the observer might * be invoked for changes that were done before it is added. *

* If the observer already exists, this is a no-op call. *

* If one of the tables in the Observer does not exist in the database, this method throws an * {@link IllegalArgumentException}. * * @param observer The observer which listens the database for changes. */ @WorkerThread public void addObserver(@NonNull Observer observer) { final String[] tableNames = observer.mTables; int[] tableIds = new int[tableNames.length]; final int size = tableNames.length; long[] versions = new long[tableNames.length]; // TODO sync versions ? for (int i = 0; i < size; i++) { Integer tableId = mTableIdLookup.get(tableNames[i].toLowerCase(Locale.US)); if (tableId == null) { throw new IllegalArgumentException("There is no table with name " + tableNames[i]); } tableIds[i] = tableId; versions[i] = mMaxVersion; } ObserverWrapper wrapper = new ObserverWrapper(observer, tableIds, tableNames, versions); ObserverWrapper currentObserver; synchronized (mObserverMap) { currentObserver = mObserverMap.putIfAbsent(observer, wrapper); } if (currentObserver == null && mObservedTableTracker.onAdded(tableIds)) { syncTriggers(); } } /** * Adds an observer but keeps a weak reference back to it. *

* Note that you cannot remove this observer once added. It will be automatically removed * when the observer is GC'ed. * * @param observer The observer to which InvalidationTracker will keep a weak reference. * @hide */ @SuppressWarnings("unused") @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public void addWeakObserver(Observer observer) { addObserver(new WeakObserver(this, observer)); } /** * Removes the observer from the observers list. * * @param observer The observer to remove. */ @SuppressWarnings("WeakerAccess") @WorkerThread public void removeObserver(@NonNull final Observer observer) { ObserverWrapper wrapper; synchronized (mObserverMap) { wrapper = mObserverMap.remove(observer); } if (wrapper != null && mObservedTableTracker.onRemoved(wrapper.mTableIds)) { syncTriggers(); } } private boolean ensureInitialization() { if (!mDatabase.isOpen()) { return false; } if (!mInitialized) { // trigger initialization mDatabase.getOpenHelper().getWritableDatabase(); } if (!mInitialized) { Log.e(Room.LOG_TAG, "database is not initialized even though it is open"); return false; } return true; } @VisibleForTesting Runnable mRefreshRunnable = new Runnable() { @Override public void run() { final Lock closeLock = mDatabase.getCloseLock(); boolean hasUpdatedTable = false; try { closeLock.lock(); if (!ensureInitialization()) { return; } if (!mPendingRefresh.compareAndSet(true, false)) { // no pending refresh return; } if (mDatabase.inTransaction()) { // current thread is in a transaction. when it ends, it will invoke // refreshRunnable again. mPendingRefresh is left as false on purpose // so that the last transaction can flip it on again. return; } mCleanupStatement.executeUpdateDelete(); mQueryArgs[0] = mMaxVersion; if (mDatabase.mWriteAheadLoggingEnabled) { // This transaction has to be on the underlying DB rather than the RoomDatabase // in order to avoid a recursive loop after endTransaction. SupportSQLiteDatabase db = mDatabase.getOpenHelper().getWritableDatabase(); try { db.beginTransaction(); hasUpdatedTable = checkUpdatedTable(); db.setTransactionSuccessful(); } finally { db.endTransaction(); } } else { hasUpdatedTable = checkUpdatedTable(); } } catch (IllegalStateException | SQLiteException exception) { // may happen if db is closed. just log. Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?", exception); } finally { closeLock.unlock(); } if (hasUpdatedTable) { synchronized (mObserverMap) { for (Map.Entry entry : mObserverMap) { entry.getValue().checkForInvalidation(mTableVersions); } } } } private boolean checkUpdatedTable() { boolean hasUpdatedTable = false; Cursor cursor = mDatabase.query(SELECT_UPDATED_TABLES_SQL, mQueryArgs); //noinspection TryFinallyCanBeTryWithResources try { while (cursor.moveToNext()) { final long version = cursor.getLong(0); final int tableId = cursor.getInt(1); mTableVersions[tableId] = version; hasUpdatedTable = true; // result is ordered so we can safely do this assignment mMaxVersion = version; } } finally { cursor.close(); } return hasUpdatedTable; } }; /** * Enqueues a task to refresh the list of updated tables. *

* This method is automatically called when {@link RoomDatabase#endTransaction()} is called but * if you have another connection to the database or directly use {@link * SupportSQLiteDatabase}, you may need to call this manually. */ @SuppressWarnings("WeakerAccess") public void refreshVersionsAsync() { // TODO we should consider doing this sync instead of async. if (mPendingRefresh.compareAndSet(false, true)) { ArchTaskExecutor.getInstance().executeOnDiskIO(mRefreshRunnable); } } /** * Check versions for tables, and run observers synchronously if tables have been updated. * * @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @WorkerThread public void refreshVersionsSync() { syncTriggers(); mRefreshRunnable.run(); } void syncTriggers(SupportSQLiteDatabase database) { if (database.inTransaction()) { // we won't run this inside another transaction. return; } try { // This method runs in a while loop because while changes are synced to db, another // runnable may be skipped. If we cause it to skip, we need to do its work. while (true) { Lock closeLock = mDatabase.getCloseLock(); closeLock.lock(); try { // there is a potential race condition where another mSyncTriggers runnable // can start running right after we get the tables list to sync. final int[] tablesToSync = mObservedTableTracker.getTablesToSync(); if (tablesToSync == null) { return; } final int limit = tablesToSync.length; try { database.beginTransaction(); for (int tableId = 0; tableId < limit; tableId++) { switch (tablesToSync[tableId]) { case ObservedTableTracker.ADD: startTrackingTable(database, tableId); break; case ObservedTableTracker.REMOVE: stopTrackingTable(database, tableId); break; } } database.setTransactionSuccessful(); } finally { database.endTransaction(); } mObservedTableTracker.onSyncCompleted(); } finally { closeLock.unlock(); } } } catch (IllegalStateException | SQLiteException exception) { // may happen if db is closed. just log. Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?", exception); } } /** * Called by RoomDatabase before each beginTransaction call. *

* It is important that pending trigger changes are applied to the database before any query * runs. Otherwise, we may miss some changes. *

* This api should eventually be public. */ void syncTriggers() { if (!mDatabase.isOpen()) { return; } syncTriggers(mDatabase.getOpenHelper().getWritableDatabase()); } /** * Wraps an observer and keeps the table information. *

* Internally table ids are used which may change from database to database so the table * related information is kept here rather than in the Observer. */ @SuppressWarnings("WeakerAccess") static class ObserverWrapper { final int[] mTableIds; private final String[] mTableNames; private final long[] mVersions; final Observer mObserver; private final Set mSingleTableSet; ObserverWrapper(Observer observer, int[] tableIds, String[] tableNames, long[] versions) { mObserver = observer; mTableIds = tableIds; mTableNames = tableNames; mVersions = versions; if (tableIds.length == 1) { ArraySet set = new ArraySet<>(); set.add(mTableNames[0]); mSingleTableSet = Collections.unmodifiableSet(set); } else { mSingleTableSet = null; } } void checkForInvalidation(long[] versions) { Set invalidatedTables = null; final int size = mTableIds.length; for (int index = 0; index < size; index++) { final int tableId = mTableIds[index]; final long newVersion = versions[tableId]; final long currentVersion = mVersions[index]; if (currentVersion < newVersion) { mVersions[index] = newVersion; if (size == 1) { // Optimization for a single-table observer invalidatedTables = mSingleTableSet; } else { if (invalidatedTables == null) { invalidatedTables = new ArraySet<>(size); } invalidatedTables.add(mTableNames[index]); } } } if (invalidatedTables != null) { mObserver.onInvalidated(invalidatedTables); } } } /** * An observer that can listen for changes in the database. */ public abstract static class Observer { final String[] mTables; /** * Observes the given list of tables. * * @param firstTable The table name * @param rest More table names */ @SuppressWarnings("unused") protected Observer(@NonNull String firstTable, String... rest) { mTables = Arrays.copyOf(rest, rest.length + 1); mTables[rest.length] = firstTable; } /** * Observes the given list of tables. * * @param tables The list of tables to observe for changes. */ public Observer(@NonNull String[] tables) { // copy tables in case user modifies them afterwards mTables = Arrays.copyOf(tables, tables.length); } /** * Called when one of the observed tables is invalidated in the database. * * @param tables A set of invalidated tables. This is useful when the observer targets * multiple tables and want to know which table is invalidated. */ public abstract void onInvalidated(@NonNull Set tables); } /** * Keeps a list of tables we should observe. Invalidation tracker lazily syncs this list w/ * triggers in the database. *

* This class is thread safe */ static class ObservedTableTracker { static final int NO_OP = 0; // don't change trigger state for this table static final int ADD = 1; // add triggers for this table static final int REMOVE = 2; // remove triggers for this table // number of observers per table final long[] mTableObservers; // trigger state for each table at last sync // this field is updated when syncAndGet is called. final boolean[] mTriggerStates; // when sync is called, this field is returned. It includes actions as ADD, REMOVE, NO_OP final int[] mTriggerStateChanges; boolean mNeedsSync; /** * After we return non-null value from getTablesToSync, we expect a onSyncCompleted before * returning any non-null value from getTablesToSync. * This allows us to workaround any multi-threaded state syncing issues. */ boolean mPendingSync; ObservedTableTracker(int tableCount) { mTableObservers = new long[tableCount]; mTriggerStates = new boolean[tableCount]; mTriggerStateChanges = new int[tableCount]; Arrays.fill(mTableObservers, 0); Arrays.fill(mTriggerStates, false); } /** * @return true if # of triggers is affected. */ boolean onAdded(int... tableIds) { boolean needTriggerSync = false; synchronized (this) { for (int tableId : tableIds) { final long prevObserverCount = mTableObservers[tableId]; mTableObservers[tableId] = prevObserverCount + 1; if (prevObserverCount == 0) { mNeedsSync = true; needTriggerSync = true; } } } return needTriggerSync; } /** * @return true if # of triggers is affected. */ boolean onRemoved(int... tableIds) { boolean needTriggerSync = false; synchronized (this) { for (int tableId : tableIds) { final long prevObserverCount = mTableObservers[tableId]; mTableObservers[tableId] = prevObserverCount - 1; if (prevObserverCount == 1) { mNeedsSync = true; needTriggerSync = true; } } } return needTriggerSync; } /** * If this returns non-null, you must call onSyncCompleted. * * @return int[] An int array where the index for each tableId has the action for that * table. */ @Nullable int[] getTablesToSync() { synchronized (this) { if (!mNeedsSync || mPendingSync) { return null; } final int tableCount = mTableObservers.length; for (int i = 0; i < tableCount; i++) { final boolean newState = mTableObservers[i] > 0; if (newState != mTriggerStates[i]) { mTriggerStateChanges[i] = newState ? ADD : REMOVE; } else { mTriggerStateChanges[i] = NO_OP; } mTriggerStates[i] = newState; } mPendingSync = true; mNeedsSync = false; return mTriggerStateChanges; } } /** * if getTablesToSync returned non-null, the called should call onSyncCompleted once it * is done. */ void onSyncCompleted() { synchronized (this) { mPendingSync = false; } } } /** * An Observer wrapper that keeps a weak reference to the given object. *

* This class with automatically unsubscribe when the wrapped observer goes out of memory. */ static class WeakObserver extends Observer { final InvalidationTracker mTracker; final WeakReference mDelegateRef; WeakObserver(InvalidationTracker tracker, Observer delegate) { super(delegate.mTables); mTracker = tracker; mDelegateRef = new WeakReference<>(delegate); } @Override public void onInvalidated(@NonNull Set tables) { final Observer observer = mDelegateRef.get(); if (observer == null) { mTracker.removeObserver(this); } else { observer.onInvalidated(tables); } } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy