Caching toots (#809)

* Initial timeline cache implementation

* Fix build/DI errors for caching

* Rename timeline entities tables. Add migration. Add DB scheme file.

* Fix uniqueness problem, change offline strategy, improve mapping

* Try to merge in new statuses, fix bottom loading, fix saving spans.

* Fix reblogs IDs, fix inserting elements from top

* Send one more request to get latest timeline statuses

* Give Timeline placeholders string id. Rewrite Either in Kotlin

* Initial placeholder implementation for caching

* Fix crash on removing overlap statuses

* Migrate counters to long

* Remove unused counters. Add minimal TimelineDAOTest

* Fix bug with placeholder ID

* Update cache in response to events. Refactor TimelineCases

* Fix crash, reduce number of placeholders

* Fix crash, fix filtering, improve placeholder handling

* Fix migration, add 8-9 migration test

* Fix initial timeline update, remove more placeholders

* Add cleanup for old statuses

* Fix cleanup

* Delete ExampleInstrumentedTest

* Improve timeline UX regarding caching

* Fix typos

* Fix initial timeline update

* Cleanup/fix initial timeline update

* Workaround for weird behavior of first post on initial tl update.

* Change counter types back to int

* Clear timeline cache on logout

* Fix loading when timeline is completely empty

* Fix androidx migration issues

* Fix tests

* Apply caching feedback

* Save account emojis to cache

* Fix warnings and bugs
This commit is contained in:
Ivan Kupalov 2019-01-14 22:05:08 +01:00 committed by Konrad Pozniak
commit 3ab78a19bc
29 changed files with 1950 additions and 497 deletions

View file

@ -25,12 +25,15 @@ import androidx.annotation.NonNull;
* DB version & declare DAO
*/
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class}, version = 10)
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class,TimelineStatusEntity.class,
TimelineAccountEntity.class
}, version = 11)
public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao();
public abstract AccountDao accountDao();
public abstract InstanceDao instanceDao();
public abstract TimelineDao timelineDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@ -116,4 +119,51 @@ public abstract class AppDatabase extends RoomDatabase {
}
};
public static final Migration MIGRATION_10_11 = new Migration(10, 11) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" +
"`serverId` TEXT NOT NULL, " +
"`timelineUserId` INTEGER NOT NULL, " +
"`instance` TEXT NOT NULL, " +
"`localUsername` TEXT NOT NULL, " +
"`username` TEXT NOT NULL, " +
"`displayName` TEXT NOT NULL, " +
"`url` TEXT NOT NULL, " +
"`avatar` TEXT NOT NULL, " +
"`emojis` TEXT NOT NULL," +
"PRIMARY KEY(`serverId`, `timelineUserId`))");
database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" +
"`serverId` TEXT NOT NULL, " +
"`url` TEXT, " +
"`timelineUserId` INTEGER NOT NULL, " +
"`authorServerId` TEXT," +
"`instance` TEXT, " +
"`inReplyToId` TEXT, " +
"`inReplyToAccountId` TEXT, " +
"`content` TEXT, " +
"`createdAt` INTEGER NOT NULL, " +
"`emojis` TEXT, " +
"`reblogsCount` INTEGER NOT NULL, " +
"`favouritesCount` INTEGER NOT NULL, " +
"`reblogged` INTEGER NOT NULL, " +
"`favourited` INTEGER NOT NULL, " +
"`sensitive` INTEGER NOT NULL, " +
"`spoilerText` TEXT, " +
"`visibility` INTEGER, " +
"`attachments` TEXT, " +
"`mentions` TEXT, " +
"`application` TEXT, " +
"`reblogServerId` TEXT, " +
"`reblogAccountId` TEXT," +
" PRIMARY KEY(`serverId`, `timelineUserId`)," +
" FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " +
"ON UPDATE NO ACTION ON DELETE NO ACTION )");
database.execSQL("CREATE INDEX IF NOT EXISTS" +
"`index_TimelineStatusEntity_authorServerId_timelineUserId` " +
"ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)");
}
};
}

View file

@ -0,0 +1,87 @@
package com.keylesspalace.tusky.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.IGNORE
import androidx.room.OnConflictStrategy.REPLACE
import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.Single
@Dao
abstract class TimelineDao {
@Insert(onConflict = REPLACE)
abstract fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long
@Insert(onConflict = REPLACE)
abstract fun insertStatus(timelineAccountEntity: TimelineStatusEntity): Long
@Insert(onConflict = IGNORE)
abstract fun insertStatusIfNotThere(timelineAccountEntity: TimelineStatusEntity): Long
@Query("""
SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.instance, s.inReplyToId, s.inReplyToAccountId, s.createdAt,
s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.sensitive,
s.spoilerText, s.visibility, s.mentions, s.application, s.reblogServerId,s.reblogAccountId,
s.content, s.attachments,
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.instance as 'a_instance',
a.localUsername as 'a_localUsername', a.username as 'a_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', a.emojis as 'a_emojis',
rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', rb.instance as 'rb_instance',
rb.localUsername as 'rb_localUsername', rb.username as 'rb_username',
rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar',
rb.emojis as'rb_emojis'
FROM TimelineStatusEntity s
LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId)
LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId)
WHERE s.timelineUserId = :account
AND (CASE WHEN :maxId IS NOT NULL THEN s.serverId < :maxId ELSE 1 END)
AND (CASE WHEN :sinceId IS NOT NULL THEN s.serverId > :sinceId ELSE 1 END)
ORDER BY s.serverId DESC
LIMIT :limit""")
abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single<List<TimelineStatusWithAccount>>
@Transaction
open fun insertInTransaction(status: TimelineStatusEntity, account: TimelineAccountEntity,
reblogAccount: TimelineAccountEntity?) {
insertAccount(account)
reblogAccount?.let(this::insertAccount)
insertStatus(status)
}
@Query("""DELETE FROM TimelineStatusEntity WHERE authorServerId = null
AND timelineUserId = :acccount AND serverId > :sinceId AND serverId < :maxId""")
abstract fun removeAllPlaceholdersBetween(acccount: Long, maxId: String, sinceId: String)
@Query("""UPDATE TimelineStatusEntity SET favourited = :favourited
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId - :statusId)""")
abstract fun setFavourited(accountId: Long, statusId: String, favourited: Boolean)
@Query("""UPDATE TimelineStatusEntity SET reblogged = :reblogged
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId - :statusId)""")
abstract fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean)
@Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND
(authorServerId = :userId OR reblogAccountId = :userId)""")
abstract fun removeAllByUser(accountId: Long, userId: String)
@Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
abstract fun removeAllForAccount(accountId: Long)
@Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId")
abstract fun removeAllUsersForAccount(accountId: Long)
@Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId
AND serverId = :statusId""")
abstract fun delete(accountId: Long, statusId: String)
@Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId
AND authorServerId != :accountServerId AND createdAt < :olderThan""")
abstract fun cleanup(accountId: Long, accountServerId: String, olderThan: Long)
}

View file

@ -0,0 +1,79 @@
package com.keylesspalace.tusky.db
import androidx.room.*
import com.keylesspalace.tusky.entity.Status
/**
* We're trying to play smart here. Server sends us reblogs as two entities one embedded into
* another (reblogged status is a field inside of "reblog" status). But it's really inefficient from
* the DB perspective and doesn't matter much for the display/interaction purposes.
* What if when we store reblog we don't store almost empty "reblog status" but we store
* *reblogged* status and we embed "reblog status" into reblogged status. This reversed
* relationship takes much less space and is much faster to fetch (no N+1 type queries or JSON
* serialization).
* "Reblog status", if present, is marked by [reblogServerId], and [reblogAccountId]
* fields.
*/
@Entity(
primaryKeys = ["serverId", "timelineUserId"],
foreignKeys = ([
ForeignKey(
entity = TimelineAccountEntity::class,
parentColumns = ["serverId", "timelineUserId"],
childColumns = ["authorServerId", "timelineUserId"]
)
]),
// Avoiding rescanning status table when accounts table changes. Recommended by Room(c).
indices = [Index("authorServerId", "timelineUserId")]
)
@TypeConverters(TootEntity.Converters::class)
data class TimelineStatusEntity(
val serverId: String, // id never flips: we need it for sorting so it's a real id
val url: String?,
// our local id for the logged in user in case there are multiple accounts per instance
val timelineUserId: Long,
val authorServerId: String?,
val instance: String?,
val inReplyToId: String?,
val inReplyToAccountId: String?,
val content: String?,
val createdAt: Long,
val emojis: String?,
val reblogsCount: Int,
val favouritesCount: Int,
val reblogged: Boolean,
val favourited: Boolean,
val sensitive: Boolean,
val spoilerText: String?,
val visibility: Status.Visibility?,
val attachments: String?,
val mentions: String?,
val application: String?,
val reblogServerId: String?, // if it has a reblogged status, it's id is stored here
val reblogAccountId: String?
)
@Entity(
primaryKeys = ["serverId", "timelineUserId"]
)
data class TimelineAccountEntity(
val serverId: String,
val timelineUserId: Long,
val instance: String,
val localUsername: String,
val username: String,
val displayName: String,
val url: String,
val avatar: String,
val emojis: String
)
class TimelineStatusWithAccount {
@Embedded
lateinit var status: TimelineStatusEntity
@Embedded(prefix = "a_")
lateinit var account: TimelineAccountEntity
@Embedded(prefix = "rb_")
var reblogAccount: TimelineAccountEntity? = null
}

View file

@ -15,14 +15,14 @@
package com.keylesspalace.tusky.db;
import com.keylesspalace.tusky.entity.Status;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
import androidx.room.TypeConverter;
import androidx.room.TypeConverters;
import androidx.annotation.Nullable;
import com.keylesspalace.tusky.entity.Status;
/**
* Toot model.
@ -120,8 +120,8 @@ public class TootEntity {
}
@TypeConverter
public int intToVisibility(Status.Visibility visibility) {
return visibility.getNum();
public int intFromVisibility(Status.Visibility visibility) {
return visibility == null ? Status.Visibility.UNKNOWN.getNum() : visibility.getNum();
}
}
}