There are two ways i have tried to iterate over large Room database:
1- Get Cursor and iterate over that:
import android.database.Cursor
...
@Query("SELECT * FROM Category")
fun getAllCategory(): Cursor<Category>
...
val success = cursor.moveToFirst()
if (success) {
     while (!cursor.isAfterLast) {
         // Process items
         cursor.moveToNext()
     }
} else {
    // Empty
}
cursor.close()
2- Use PagedList to get pageSize amount of items at once and process. Then query another page and process:
@Query("SELECT * FROM Category")
fun getAllCategory(): DataSource.Factory<Int, Category>
// Here i will return Flowable. You can return LiveData with 'LivePagedListBuilder'
fun getCategories(pageSize: Int): Flowable<PagedList<Category>> {
        val config = PagedList.Config.Builder()
                .setPageSize(pageSize)
                .setPrefetchDistance(pageSize / 4)
                .setEnablePlaceholders(true)
                .setInitialLoadSizeHint(pageSize)
                .build()
        return RxPagedListBuilder(categoryDao.getAllCategory(), config)
                .buildFlowable(BackpressureStrategy.BUFFER)
}
Now above getCategories() function will return pagedList inside Flowable or LiveData. Since we have set setEnablePlaceholders(true), pagedList.size will show the whole size even if it is not in memory. So, if pageSize is 50 and all data size is 1000, pagedList.size will return 1000, but most of them will be null. To query next page and process:
// Callback is triggered when next page is loaded
pagedList.addWeakCallback(pagedList.snapshot(), object : PagedList.Callback() {
    override fun onChanged(position: Int, count: Int) {
        for (index in position until (position + count)) {
            if (index == (position + count - 1)) {
                if (index < (pagedList.size - 1)) 
                    pagedList.loadAround(index + 1)
                else{ 
                    // Last item is processed.
                }
            } else
                processCurrentValue(index, pagedList[index]!!)
        }
    }
    override fun onInserted(position: Int, count: Int) {
        // You better not change database while iterating over it 
    }    
    override fun onRemoved(position: Int, count: Int) {
        // You better not change database while iterating over it
    }
})
// Start to iterate and query next page when item is null.
for (index in 0 until pagedList.size) {
     if (pagedList[index] != null) {
            processCurrentValue(index, pagedList[index]!!)
     } else {
            // Query next page
            pagedList.loadAround(index)
            break
     }
}
Conclusion: In PagedList approach, you can get thousands of rows at once and process, while in Cursor approach you iterate row by row. I found PagedList unstable when pageSize > 3000. It does not return the page sometimes. So i used Cursor. It takes roughly about 5 minutes to iterate over (and process) 900k rows on both approaches on Android 8 phone.