Fix LimitOffsetPagingSourceTest flaky test
Fix flaky test prepend_invalidResult and append_invalidResult.
The two tests are refactored into basic unit tests with the invalid flag
set to `true` directly instead of relying on refreshVersionSync.
Additional integration-tests that tests refreshVersionSync are added into Room's
PagingSourceTest.kt instead.
Bug: 193653151
Test: ./gradlew :room:integration-tests:room-testapp-kotlin:cC
Test: ./gradlew room:room-paging:cC
Change-Id: Id01bbbcefa0531ca47ea4be226818620c74f1111
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/PagingSourceTest.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/PagingSourceTest.kt
index d3f0f18..aec2c6e 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/PagingSourceTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/PagingSourceTest.kt
@@ -200,7 +200,103 @@
}
@Test
- fun dataChangesWithDelayedInvalidation() {
+ fun prependWithDelayedInvalidation() {
+ val items = createItems(startId = 0, count = 90)
+ db.dao.insert(items)
+
+ val pager = Pager(
+ config = CONFIG,
+ initialKey = 20,
+ pagingSourceFactory = { db.dao.loadItems().also { pagingSources.add(it) } }
+ )
+
+ runTest(pager) {
+ itemStore.awaitInitialLoad()
+ assertThat(
+ itemStore.peekItems()
+ ).containsExactlyElementsIn(
+ // should load starting from initial Key = 30
+ items.createExpected(
+ fromIndex = 20,
+ toIndex = 20 + CONFIG.initialLoadSize
+ )
+ )
+ assertThat(db.invalidationTracker.pendingRefresh).isFalse()
+ // now do some changes in the database but don't let change notifications go through
+ // to the data source. it should not crash :)
+ queryExecutor.filterFunction = { runnable ->
+ runnable !== db.invalidationTracker.refreshRunnable
+ }
+ db.dao.deleteItems(
+ items.subList(0, 60).map { it.id }
+ )
+ // make sure invalidation requests a refresh
+ db.invalidationTracker.awaitPendingRefresh()
+
+ // make sure we blocked the refresh runnable until after the exception generates a
+ // new paging source
+ queryExecutor.awaitDeferredSizeAtLeast(1)
+
+ // Now get more items. The pagingSource's load() will check for invalidation and then
+ // return LoadResult.Invalid, causing a second generation paging source to be generated.
+ itemStore.get(2)
+
+ itemStore.awaitGeneration(2)
+ assertTrue(pagingSources[0].invalid)
+ itemStore.awaitInitialLoad()
+
+ // the initial load triggers a call to refreshVersionsAsync which calls
+ // mRefreshRunnable. The runnable is getting filtered out but we need this one to
+ // complete, so we executed the latest queued mRefreshRunnable.
+ assertThat(queryExecutor.deferredSize()).isEqualTo(2)
+ queryExecutor.executeLatestDeferred()
+ assertThat(queryExecutor.deferredSize()).isEqualTo(1)
+
+ // it might be reloaded in any range so just make sure everything is there
+ // expects 30 items because items 60 - 89 left in database, so presenter should have
+ // items 60-68 from initialLoad + 21 null placeholders
+ assertThat(itemStore.peekItems()).hasSize(30)
+ withContext(Dispatchers.Main) {
+ (0 until 10).forEach {
+ itemStore.get(it)
+ }
+ }
+ // now ensure all of them are loaded
+ // only waiting for 9 items because because the 10th item and onwards are nulls from
+ // placeholders
+ (0 until 9).forEach {
+ assertThat(
+ itemStore.awaitItem(it)
+ ).isEqualTo(
+ items[60 + it]
+ )
+ }
+
+ // Runs the original invalidationTracker.refreshRunnable.
+ // Note that the second initial load's call to mRefreshRunnable resets the flag to
+ // false, so this mRefreshRunnable will not detect changes in the table anymore.
+ assertThat(db.invalidationTracker.pendingRefresh).isFalse()
+ queryExecutor.executeAll()
+
+ itemStore.awaitInitialLoad()
+
+ // make sure only two generations of paging sources have been created
+ assertTrue(!pagingSources[1].invalid)
+
+ // if a third generation is created, awaitGeneration(3) will return instead of timing
+ // out.
+ val expectError = assertFailsWith<AssertionError> {
+ itemStore.awaitGeneration(3)
+ }
+ assertThat(expectError.message).isEqualTo("didn't complete in expected time")
+
+ assertThat(itemStore.currentGenerationId).isEqualTo(2)
+ assertThat(pagingSources.size).isEqualTo(2)
+ }
+ }
+
+ @Test
+ fun appendWithDelayedInvalidation() {
val items = createItems(startId = 0, count = 90)
db.dao.insert(items)
runTest {
@@ -234,7 +330,19 @@
itemStore.awaitGeneration(2)
assertTrue(pagingSources[0].invalid)
+ // initial load is executed but refreshVersionsAsync's call to mRefreshRunnable is
+ // actually queued up here
itemStore.awaitInitialLoad()
+ // the initial load triggers a call to refreshVersionsAsync which calls
+ // mRefreshRunnable. The runnable is getting filtered out but we need this one to
+ // complete, so we executed the latest queued mRefreshRunnable.
+ assertThat(queryExecutor.deferredSize()).isEqualTo(2)
+ queryExecutor.executeLatestDeferred()
+ assertThat(queryExecutor.deferredSize()).isEqualTo(1)
+
+ // second paging source should be generated
+ assertThat(pagingSources.size).isEqualTo(2)
+
// it might be reloaded in any range so just make sure everything is there
assertThat(itemStore.peekItems()).hasSize(10)
withContext(Dispatchers.Main) {
@@ -250,11 +358,10 @@
items[80 + it]
)
}
- // Runs deferred invalidationTracker.refreshRunnable. Note that the step in
- // itemStore.get(70) includes checking the invalidation tables & resetting the tracker's
- // pendingRefresh flag to false.
- // Therefore, the mRefreshRunnable executed by executeAll() will not detect changes
- // in the table anymore.
+
+ // Runs the original invalidationTracker.refreshRunnable.
+ // Note that the second initial load's call to mRefreshRunnable resets the flag to
+ // false, so this mRefreshRunnable will not detect changes in the table anymore.
assertThat(db.invalidationTracker.pendingRefresh).isFalse()
queryExecutor.executeAll()
@@ -546,12 +653,20 @@
copy.forEach(this::execute)
}
+ fun deferredSize(): Int {
+ return deferred.size
+ }
+
fun executeAll() {
while (deferred.isNotEmpty()) {
deferred.removeFirst().run()
}
}
+ fun executeLatestDeferred() {
+ deferred.removeLast().run()
+ }
+
override fun execute(command: Runnable) {
lock.withLock {
if (filterFunction(command)) {
diff --git a/room/room-paging/src/androidTest/kotlin/androidx/room/paging/LimitOffsetPagingSourceTest.kt b/room/room-paging/src/androidTest/kotlin/androidx/room/paging/LimitOffsetPagingSourceTest.kt
index 0954498..abcf013 100644
--- a/room/room-paging/src/androidTest/kotlin/androidx/room/paging/LimitOffsetPagingSourceTest.kt
+++ b/room/room-paging/src/androidTest/kotlin/androidx/room/paging/LimitOffsetPagingSourceTest.kt
@@ -418,11 +418,11 @@
itemsList.subList(30, 35)
)
- // make changes to database
- dao.deleteTestItem(itemsList[42])
+ // invalidate pagingSource to imitate invalidation from running refreshVersionSync
+ pagingSource.invalidate()
- // this append should check invalidation tables, realize it has been updated,
- // and return a LoadResult.Invalid
+ // this append should check pagingSource's invalid status, realize it is invalid, and
+ // return a LoadResult.Invalid
val result2 = pagingSource.append(key = result.nextKey)
assertThat(result2).isInstanceOf(LoadResult.Invalid::class.java)
@@ -503,14 +503,13 @@
itemsList.subList(15, 20)
)
- // now write into database
- dao.deleteTestItem(itemsList[30])
+ // invalidate pagingSource to imitate invalidation from running refreshVersionSync
+ pagingSource.invalidate()
- // second prepend using prevKey from previous load
+ // this prepend should check pagingSource's invalid status, realize it is invalid, and
+ // return LoadResult.Invalid
val result2 = pagingSource.prepend(key = result.prevKey)
- // this prepend should check invalidation tables, realize it has been updated,
- // and return a LoadResult.Invalid
assertThat(result2).isInstanceOf(LoadResult.Invalid::class.java)
}
}