Added throtting of invalidation events
TODO:
- Cursor based query invalidation
Bug: 143216096
Bug: 152802019
Bug: 152803443
Bug: 152790431
Test: ./gradlew :sqlite:sqlite-inspection:cC
Change-Id: Ib885ee68e3bec474d18df5a55702f859c7878662
diff --git a/sqlite/sqlite-inspection/src/androidTest/java/androidx/sqlite/inspection/test/InvalidationTest.kt b/sqlite/sqlite-inspection/src/androidTest/java/androidx/sqlite/inspection/test/InvalidationTest.kt
index dfe073f..6d4ef16 100644
--- a/sqlite/sqlite-inspection/src/androidTest/java/androidx/sqlite/inspection/test/InvalidationTest.kt
+++ b/sqlite/sqlite-inspection/src/androidTest/java/androidx/sqlite/inspection/test/InvalidationTest.kt
@@ -18,9 +18,9 @@
import android.database.sqlite.SQLiteStatement
import androidx.sqlite.inspection.SqliteInspectorProtocol.DatabasePossiblyChangedEvent
-import androidx.sqlite.inspection.SqliteInspectorProtocol.Event
+import androidx.sqlite.inspection.SqliteInspectorProtocol.Event.OneOfCase.DATABASE_POSSIBLY_CHANGED
import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.MediumTest
+import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
@@ -29,7 +29,10 @@
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
-@MediumTest
+private val sqliteStatementInvalidationTriggeringMethods =
+ listOf("execute()V", "executeInsert()J", "executeUpdateDelete()I")
+
+@LargeTest
@RunWith(AndroidJUnit4::class)
class InvalidationTest {
@get:Rule
@@ -40,12 +43,12 @@
@Test
fun test_exec_methods(): Unit = runBlocking {
- // Starting to track databases registers hooks
+ // Starting to track databases makes the inspector register hooks
testEnvironment.sendCommand(MessageFactory.createTrackDatabasesCommand())
// Verification of hooks being registered and triggering the DatabasePossiblyChangedEvent
testEnvironment.consumeRegisteredHooks().let { hooks ->
- listOf("execute()V", "executeInsert()J", "executeUpdateDelete()I")
+ sqliteStatementInvalidationTriggeringMethods
.forEach { method ->
val hook = hooks.filter { hook ->
hook.originMethod == method &&
@@ -56,7 +59,7 @@
testEnvironment.assertNoQueuedEvents()
hook.first().asExitHook.onExit(null)
testEnvironment.receiveEvent().let { event ->
- assertThat(event.oneOfCase == Event.OneOfCase.DATABASE_POSSIBLY_CHANGED)
+ assertThat(event.oneOfCase == DATABASE_POSSIBLY_CHANGED)
assertThat(event.databasePossiblyChanged).isEqualTo(
DatabasePossiblyChangedEvent.getDefaultInstance()
)
@@ -65,4 +68,33 @@
}
}
}
+
+ @Test
+ fun test_throttling(): Unit = runBlocking {
+ // Starting to track databases makes the inspector register hooks
+ testEnvironment.sendCommand(MessageFactory.createTrackDatabasesCommand())
+
+ // Any hook that triggers invalidation
+ val hook = testEnvironment.consumeRegisteredHooks()
+ .first { it.originMethod == sqliteStatementInvalidationTriggeringMethods.first() }
+ .asExitHook
+
+ testEnvironment.assertNoQueuedEvents()
+
+ // First invalidation triggering event
+ hook.onExit(null)
+ val event1 = testEnvironment.receiveEvent()
+
+ // Shortly followed by many invalidation triggering events
+ repeat(50) { hook.onExit(null) }
+ val event2 = testEnvironment.receiveEvent()
+
+ // Event validation
+ listOf(event1, event2).forEach {
+ assertThat(it.oneOfCase).isEqualTo(DATABASE_POSSIBLY_CHANGED)
+ }
+
+ // Only two invalidation events received
+ testEnvironment.assertNoQueuedEvents()
+ }
}
diff --git a/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/RequestCollapsingThrottler.java b/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/RequestCollapsingThrottler.java
new file mode 100644
index 0000000..a1d5d4b
--- /dev/null
+++ b/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/RequestCollapsingThrottler.java
@@ -0,0 +1,92 @@
+/*
+ * 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.sqlite.inspection;
+
+import android.annotation.SuppressLint;
+
+import androidx.annotation.GuardedBy;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Throttler implementation ensuring that events are run not more frequently that specified
+ * interval. Events submitted during the interval period are collapsed into one (i.e. only one is
+ * executed).
+ *
+ * Thread safe.
+ */
+@SuppressLint("SyntheticAccessor")
+final class RequestCollapsingThrottler {
+ private static final long NEVER = -1;
+
+ private final Runnable mAction;
+ private final long mMinIntervalMs;
+ private final ScheduledExecutorService mExecutor;
+ private final Object mLock = new Object();
+
+ @GuardedBy("mLock") private boolean mPendingDispatch = false;
+ @GuardedBy("mLock") private long mLastSubmitted = NEVER;
+
+ RequestCollapsingThrottler(long minIntervalMs, Runnable action) {
+ // TODO: ensure Thread names meet Android Studio requirements
+ mExecutor = Executors.newSingleThreadScheduledExecutor();
+ mAction = action;
+ mMinIntervalMs = minIntervalMs;
+ }
+
+ public void submitRequest() {
+ synchronized (mLock) {
+ if (mPendingDispatch) {
+ return;
+ } else {
+ mPendingDispatch = true; // about to schedule
+ }
+ }
+ long delay = mMinIntervalMs - sinceLast(); // delay < 0 is OK
+ scheduleDispatch(delay);
+ }
+
+ // TODO: switch to ListenableFuture to react on failures
+ @SuppressWarnings("FutureReturnValueIgnored")
+ private void scheduleDispatch(long delay) {
+ mExecutor.schedule(new Runnable() {
+ @Override
+ public void run() {
+ mAction.run();
+ synchronized (mLock) {
+ mLastSubmitted = now();
+ mPendingDispatch = false;
+ }
+ }
+ }, delay, TimeUnit.MILLISECONDS);
+ }
+
+ private static long now() {
+ return System.currentTimeMillis();
+ }
+
+ private long sinceLast() {
+ synchronized (mLock) {
+ final long lastSubmitted = mLastSubmitted;
+ return lastSubmitted == NEVER
+ ? (mMinIntervalMs + 1) // more than mMinIntervalMs
+ : (now() - lastSubmitted);
+ }
+ }
+}
diff --git a/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java b/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java
index 35817e1..23be820 100644
--- a/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java
+++ b/sqlite/sqlite-inspection/src/main/java/androidx/sqlite/inspection/SqliteInspector.java
@@ -89,6 +89,8 @@
"executeInsert()J",
"executeUpdateDelete()I");
+ private static final int INVALIDATION_MIN_INTERVAL_MS = 1000;
+
// Note: this only works on API26+ because of pragma_* functions
// TODO: replace with a resource file
// language=SQLite
@@ -205,12 +207,21 @@
}
private void registerInvalidationHooks() {
+ final RequestCollapsingThrottler throttler = new RequestCollapsingThrottler(
+ INVALIDATION_MIN_INTERVAL_MS,
+ new Runnable() {
+ @Override
+ public void run() {
+ sendDatabasePossiblyChangedEvent();
+ }
+ });
+
for (String method : sSqliteStatementExecuteMethodsSignatures) {
mEnvironment.registerExitHook(SQLiteStatement.class, method,
new InspectorEnvironment.ExitHook<Object>() {
@Override
public Object onExit(Object result) {
- sendDatabasePossiblyChangedEvent();
+ throttler.submitRequest();
return result;
}
});
@@ -218,7 +229,6 @@
}
private void sendDatabasePossiblyChangedEvent() {
- // TODO: add throttling
getConnection().sendEvent(Event.newBuilder().setDatabasePossiblyChanged(
DatabasePossiblyChangedEvent.getDefaultInstance()).build().toByteArray());
}