Implement the package-private AutoCloser which is responsible for reference counting, and scheduling auto-close operations.
Test: AutoCloserTest.java
RelNote: This is the first of a few cls to implement auto-closing for Room Databases.
Bug: 137223459
Change-Id: Ia83707a80cb67e9bd86c60e29178ed393c8ddcac
diff --git a/room/runtime/build.gradle b/room/runtime/build.gradle
index 0a0769d..fbce4b2 100644
--- a/room/runtime/build.gradle
+++ b/room/runtime/build.gradle
@@ -55,8 +55,12 @@
androidTestImplementation(ANDROIDX_TEST_CORE)
androidTestImplementation(ANDROIDX_TEST_RUNNER)
androidTestImplementation(ESPRESSO_CORE)
+ androidTestImplementation(KOTLIN_STDLIB)
androidTestImplementation(MOCKITO_CORE, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
androidTestImplementation(DEXMAKER_MOCKITO, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
+ androidTestImplementation(project(":internal-testutils-truth")) // for assertThrows
+ androidTestImplementation("androidx.arch.core:core-testing:2.0.1")
+
}
android.libraryVariants.all { variant ->
diff --git a/room/runtime/src/androidTest/java/androidx/room/AutoCloserTest.kt b/room/runtime/src/androidTest/java/androidx/room/AutoCloserTest.kt
new file mode 100644
index 0000000..87e9c17
--- /dev/null
+++ b/room/runtime/src/androidTest/java/androidx/room/AutoCloserTest.kt
@@ -0,0 +1,189 @@
+/*
+ * 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 androidx.arch.core.executor.ArchTaskExecutor
+import androidx.arch.core.executor.testing.CountingTaskExecutorRule
+import androidx.sqlite.db.SupportSQLiteDatabase
+import androidx.sqlite.db.SupportSQLiteOpenHelper
+import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.testutils.assertThrows
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+public class AutoCloserTest {
+
+ @get:Rule
+ public val countingTaskExecutorRule: CountingTaskExecutorRule = CountingTaskExecutorRule()
+
+ private lateinit var autoCloser: AutoCloser
+
+ private lateinit var callback: Callback
+
+ private class Callback(var throwOnOpen: Boolean = false) :
+ SupportSQLiteOpenHelper.Callback(1) {
+ override fun onCreate(db: SupportSQLiteDatabase) {}
+
+ override fun onOpen(db: SupportSQLiteDatabase) {
+ if (throwOnOpen) {
+ throw IOException()
+ }
+ }
+
+ override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {}
+ }
+
+ @Before
+ public fun setUp() {
+ callback = Callback()
+
+ val delegateOpenHelper = FrameworkSQLiteOpenHelperFactory()
+ .create(
+ SupportSQLiteOpenHelper.Configuration
+ .builder(ApplicationProvider.getApplicationContext())
+ .callback(callback)
+ .name("name")
+ .build()
+ )
+
+ val autoCloseExecutor = ArchTaskExecutor.getIOThreadExecutor()
+
+ autoCloser = AutoCloser(
+ 1,
+ TimeUnit.MILLISECONDS,
+ autoCloseExecutor
+ ).also {
+ it.init(delegateOpenHelper)
+ }
+ }
+
+ @After
+ public fun cleanUp() {
+ assertThat(countingTaskExecutorRule.isIdle).isTrue()
+ }
+
+ @Test
+ public fun refCountsCounted() {
+ autoCloser.incrementCountAndEnsureDbIsOpen()
+ assertThat(autoCloser.refCountForTest).isEqualTo(1)
+
+ autoCloser.incrementCountAndEnsureDbIsOpen()
+ assertThat(autoCloser.refCountForTest).isEqualTo(2)
+
+ autoCloser.decrementCountAndScheduleClose()
+ assertThat(autoCloser.refCountForTest).isEqualTo(1)
+
+ autoCloser.executeRefCountingFunction {
+ assertThat(autoCloser.refCountForTest).isEqualTo(2)
+ }
+ assertThat(autoCloser.refCountForTest).isEqualTo(1)
+
+ autoCloser.decrementCountAndScheduleClose()
+ assertThat(autoCloser.refCountForTest).isEqualTo(0)
+
+ // TODO(rohitsat): remove these sleeps and add a hook in AutoCloser to confirm that the
+ // scheduled tasks are done.
+ Thread.sleep(5)
+ countingTaskExecutorRule.drainTasks(10, TimeUnit.MILLISECONDS)
+ }
+
+ @Test
+ public fun executeRefCountingFunctionPropagatesFailure() {
+ assertThrows<IOException> {
+ autoCloser.executeRefCountingFunction {
+ throw IOException()
+ }
+ }
+
+ assertThat(autoCloser.refCountForTest).isEqualTo(0)
+
+ // TODO(rohitsat): remove these sleeps and add a hook in AutoCloser to confirm that the
+ // scheduled tasks are done.
+ Thread.sleep(5)
+ countingTaskExecutorRule.drainTasks(10, TimeUnit.MILLISECONDS)
+ }
+
+ @Test
+ public fun dbNotClosedWithRefCountIncremented() {
+ autoCloser.incrementCountAndEnsureDbIsOpen()
+
+ Thread.sleep(10)
+
+ assertThat(autoCloser.delegateDatabase!!.isOpen).isTrue()
+
+ autoCloser.decrementCountAndScheduleClose()
+
+ // TODO(rohitsat): remove these sleeps and add a hook in AutoCloser to confirm that the
+ // scheduled tasks are done.
+ Thread.sleep(10)
+ assertThat(autoCloser.delegateDatabase).isNull()
+ }
+
+ @Test
+ public fun getDelegatedDatabaseReturnsUnwrappedDatabase() {
+ assertThat(autoCloser.delegateDatabase).isNull()
+
+ val db = autoCloser.incrementCountAndEnsureDbIsOpen()
+ db.beginTransaction()
+ // Beginning a transaction on the unwrapped db shouldn't increment our ref count.
+ assertThat(autoCloser.refCountForTest).isEqualTo(1)
+ db.endTransaction()
+
+ autoCloser.delegateDatabase!!.beginTransaction()
+ assertThat(autoCloser.refCountForTest).isEqualTo(1)
+ autoCloser.delegateDatabase!!.endTransaction()
+ autoCloser.decrementCountAndScheduleClose()
+
+ autoCloser.executeRefCountingFunction {
+ assertThat(autoCloser.refCountForTest).isEqualTo(1)
+ }
+
+ // TODO(rohitsat): remove these sleeps and add a hook in AutoCloser to confirm that the
+ // scheduled tasks are done.
+ Thread.sleep(5)
+ countingTaskExecutorRule.drainTasks(10, TimeUnit.MILLISECONDS)
+ }
+
+ @Test
+ public fun refCountStaysIncrementedWhenErrorIsEncountered() {
+ callback.throwOnOpen = true
+ assertThrows<IOException> {
+ autoCloser.incrementCountAndEnsureDbIsOpen()
+ }
+
+ assertThat(autoCloser.refCountForTest).isEqualTo(1)
+
+ autoCloser.decrementCountAndScheduleClose()
+ callback.throwOnOpen = false
+
+ // TODO(rohitsat): remove these sleeps and add a hook in AutoCloser to confirm that the
+ // scheduled tasks are done.
+ Thread.sleep(5)
+ countingTaskExecutorRule.drainTasks(10, TimeUnit.MILLISECONDS)
+ }
+}
\ No newline at end of file
diff --git a/room/runtime/src/main/java/androidx/room/AutoCloser.java b/room/runtime/src/main/java/androidx/room/AutoCloser.java
new file mode 100644
index 0000000..eda2363
--- /dev/null
+++ b/room/runtime/src/main/java/androidx/room/AutoCloser.java
@@ -0,0 +1,256 @@
+/*
+ * 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
+ @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 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 (mDelegateDatabase != null) {
+ 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. This is probably a bug in"
+ + " the room code.");
+ 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 (mDelegateDatabase != null && mDelegateDatabase.isOpen()) {
+ return mDelegateDatabase;
+ } else if (mDelegateDatabase != null) {
+ // This shouldn't happen
+ throw new IllegalStateException("mDelegateDatabase is closed but non-null");
+ }
+
+ // 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 beeninitialized. This "
+ + "shouldn't happen, but if it does it means there's a bug in our code");
+ }
+
+ 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;
+ }
+ }
+
+ /**
+ * 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;
+ }
+ }
+}