AutoCloser.java

/*
 * Copyright 2020 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.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.util.Log;

import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.arch.core.util.Function;
import androidx.room.util.SneakyThrow;
import androidx.sqlite.db.SupportSQLiteDatabase;
import androidx.sqlite.db.SupportSQLiteOpenHelper;

import java.io.IOException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;

/**
 * AutoCloser is responsible for automatically opening (using
 * delegateOpenHelper) and closing (on a timer started when there are no remaining references) a
 * SupportSqliteDatabase.
 *
 * It is important to ensure that the ref count is incremented when using a returned database.
 */
final class AutoCloser {

    @Nullable
    private SupportSQLiteOpenHelper mDelegateOpenHelper = null;

    @NonNull
    private final Handler mHandler = new Handler(Looper.getMainLooper());

    // Package private for access from mAutoCloser
    @Nullable
    Runnable mOnAutoCloseCallback = null;

    // Package private for access from mAutoCloser
    @NonNull
    final Object mLock = new Object();

    // Package private for access from mAutoCloser
    final long mAutoCloseTimeoutInMs;

    // Package private for access from mExecuteAutoCloser
    @NonNull
    final Executor mExecutor;

    // Package private for access from mAutoCloser
    @GuardedBy("mLock")
    int mRefCount = 0;

    // Package private for access from mAutoCloser
    @GuardedBy("mLock")
    long mLastDecrementRefCountTimeStamp = SystemClock.uptimeMillis();

    // The unwrapped SupportSqliteDatabase
    // Package private for access from mAutoCloser
    @GuardedBy("mLock")
    @Nullable
    SupportSQLiteDatabase mDelegateDatabase;

    private boolean mManuallyClosed = false;

    private final Runnable mExecuteAutoCloser = new Runnable() {
        @Override
        public void run() {
            mExecutor.execute(mAutoCloser);
        }
    };

    // Package private for access from mExecuteAutoCloser
    @NonNull
    final Runnable mAutoCloser = new Runnable() {
        @Override
        public void run() {
            synchronized (mLock) {
                if (SystemClock.uptimeMillis() - mLastDecrementRefCountTimeStamp
                        < mAutoCloseTimeoutInMs) {
                    // An increment + decrement beat us to closing the db. We
                    // will not close the database, and there should be at least
                    // one more auto-close scheduled.
                    return;
                }

                if (mRefCount != 0) {
                    // An increment beat us to closing the db. We don't close the
                    // db, and another closer will be scheduled once the ref
                    // count is decremented.
                    return;
                }

                if (mOnAutoCloseCallback != null) {
                    mOnAutoCloseCallback.run();
                } else {
                    throw new IllegalStateException("mOnAutoCloseCallback is null but it should"
                            + " have been set before use. Please file a bug "
                            + "against Room at: https://issuetracker.google"
                            + ".com/issues/new?component=413107&template=1096568");
                }

                if (mDelegateDatabase != null && mDelegateDatabase.isOpen()) {
                    try {
                        mDelegateDatabase.close();
                    } catch (IOException e) {
                        SneakyThrow.reThrow(e);
                    }
                    mDelegateDatabase = null;
                }
            }
        }
    };


    /**
     * Construct an AutoCloser.
     *
     * @param autoCloseTimeoutAmount time for auto close timer
     * @param autoCloseTimeUnit      time unit for autoCloseTimeoutAmount
     * @param autoCloseExecutor      the executor on which the auto close operation will happen
     */
    AutoCloser(long autoCloseTimeoutAmount,
            @NonNull TimeUnit autoCloseTimeUnit,
            @NonNull Executor autoCloseExecutor) {
        mAutoCloseTimeoutInMs = autoCloseTimeUnit.toMillis(autoCloseTimeoutAmount);
        mExecutor = autoCloseExecutor;
    }

    /**
     * Since we need to construct the AutoCloser in the RoomDatabase.Builder, we need to set the
     * delegateOpenHelper after construction.
     *
     * @param delegateOpenHelper the open helper that is used to create
     *                           new SupportSqliteDatabases
     */
    public void init(@NonNull SupportSQLiteOpenHelper delegateOpenHelper) {
        if (mDelegateOpenHelper != null) {
            Log.e(Room.LOG_TAG, "AutoCloser initialized multiple times. Please file a bug against"
                    + " room at: https://issuetracker.google"
                    + ".com/issues/new?component=413107&template=1096568");
            return;
        }
        this.mDelegateOpenHelper = delegateOpenHelper;
    }

    /**
     * Execute a ref counting function. The function will receive an unwrapped open database and
     * this database will stay open until at least after function returns. If there are no more
     * references in use for the db once function completes, an auto close operation will be
     * scheduled.
     */
    @Nullable
    public <V> V executeRefCountingFunction(@NonNull Function<SupportSQLiteDatabase, V> function) {
        try {
            SupportSQLiteDatabase db = incrementCountAndEnsureDbIsOpen();
            return function.apply(db);
        } finally {
            decrementCountAndScheduleClose();
        }
    }

    /**
     * Confirms that autoCloser is no longer running and confirms that mDelegateDatabase is set
     * and open. mDelegateDatabase will not be auto closed until
     * decrementRefCountAndScheduleClose is called. decrementRefCountAndScheduleClose must be
     * called once for each call to incrementCountAndEnsureDbIsOpen.
     *
     * If this throws an exception, decrementCountAndScheduleClose must still be called!
     *
     * @return the *unwrapped* SupportSQLiteDatabase.
     */
    @NonNull
    public SupportSQLiteDatabase incrementCountAndEnsureDbIsOpen() {
        //TODO(rohitsat): avoid synchronized(mLock) when possible. We should be able to avoid it
        // when refCount is not hitting zero or if there is no auto close scheduled if we use
        // Atomics.
        synchronized (mLock) {
            // If there is a scheduled autoclose operation, we should remove it from the handler.
            mHandler.removeCallbacks(mExecuteAutoCloser);

            mRefCount++;

            if (mManuallyClosed) {
                throw new IllegalStateException("Attempting to open already closed database.");
            }

            if (mDelegateDatabase != null && mDelegateDatabase.isOpen()) {
                return mDelegateDatabase;
            }

            // Get the database while holding `mLock` so no other threads try to create it or
            // destroy it.
            if (mDelegateOpenHelper != null) {
                mDelegateDatabase = mDelegateOpenHelper.getWritableDatabase();
            } else {
                throw new IllegalStateException("AutoCloser has not been initialized. Please file "
                        + "a bug against Room at: "
                        + "https://issuetracker.google.com/issues/new?component=413107&template=1096568");
            }

            return mDelegateDatabase;
        }
    }

    /**
     * Decrements the ref count and schedules a close if there are no other references to the db.
     * This must only be called after a corresponding incrementCountAndEnsureDbIsOpen call.
     */
    public void decrementCountAndScheduleClose() {
        //TODO(rohitsat): avoid synchronized(mLock) when possible
        synchronized (mLock) {
            if (mRefCount <= 0) {
                throw new IllegalStateException("ref count is 0 or lower but we're supposed to "
                        + "decrement");
            }

            // decrement refCount
            mRefCount--;

            // if refcount is zero, schedule close operation
            if (mRefCount == 0) {
                if (mDelegateDatabase == null) {
                    // No db to close, this can happen due to exceptions when creating db...
                    return;
                }
                mHandler.postDelayed(mExecuteAutoCloser, mAutoCloseTimeoutInMs);
            }
        }
    }

    /**
     * Returns the underlying database. This does not ensure that the database is open; the
     * caller is responsible for ensuring that the database is open and the ref count is non-zero.
     *
     * This is primarily meant for use cases where we don't want to open the database (isOpen) or
     * we know that the database is already open (KeepAliveCursor).
     */
    @Nullable // Since the db might be closed
    public SupportSQLiteDatabase getDelegateDatabase() {
        synchronized (mLock) {
            return mDelegateDatabase;
        }
    }

    /**
     * Close the database if it is still active.
     *
     * @throws IOException if an exception is encountered when closing the underlying db.
     */
    public void closeDatabaseIfOpen() throws IOException {
        synchronized (mLock) {
            mManuallyClosed = true;

            if (mDelegateDatabase != null) {
                mDelegateDatabase.close();
            }
            mDelegateDatabase = null;
        }
    }

    /**
     * The auto closer is still active if the database has not been closed. This means that
     * whether or not the underlying database is closed, when active we will re-open it on the
     * next access.
     *
     * @return a boolean indicating whether the auto closer is still active
     */
    public boolean isActive() {
        return !mManuallyClosed;
    }

    /**
     * Returns the current ref count for this auto closer. This is only visible for testing.
     *
     * @return current ref count
     */
    @VisibleForTesting
    public int getRefCountForTest() {
        synchronized (mLock) {
            return mRefCount;
        }
    }

    /**
     * Sets a callback that will be run every time the database is auto-closed. This callback
     * needs to be lightweight since it is run while holding a lock.
     *
     * @param onAutoClose the callback to run
     */
    public void setAutoCloseCallback(Runnable onAutoClose) {
        mOnAutoCloseCallback = onAutoClose;
    }
}