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());
     }