Signal-iOS/SignalServiceKit/Util/ModelReadCache.swift
2026-03-05 21:54:46 -06:00

546 lines
18 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
// MARK: -
class ModelCacheValueBox<ValueType> {
let value: ValueType?
init(value: ValueType?) {
self.value = value
}
}
// MARK: -
struct ModelCacheKey<KeyType: Hashable & Equatable> {
let key: KeyType
}
// MARK: -
class ModelCacheAdapter<KeyType: Hashable & Equatable, ValueType> {
func read(key: KeyType, transaction: DBReadTransaction) -> ValueType? {
fatalError("Unimplemented")
}
final func cacheKey(forValue value: ValueType) -> ModelCacheKey<KeyType> {
cacheKey(forKey: key(forValue: value))
}
func key(forValue value: ValueType) -> KeyType {
fatalError("Unimplemented")
}
func cacheKey(forKey key: KeyType) -> ModelCacheKey<KeyType> {
fatalError("Unimplemented")
}
func copy(value: ValueType) throws -> ValueType {
fatalError("Unimplemented")
}
let cacheName: String
let cacheCountLimit: Int
init(cacheName: String, cacheCountLimit: Int) {
self.cacheName = cacheName
self.cacheCountLimit = cacheCountLimit
}
}
// MARK: -
/// * Read caches can be accessed from any thread.
///
/// * They are eagerly updated to reflect db writes using the
/// didInsertOrUpdate() and didRemove() hooks.
///
/// * They use "exclusion" to avoid races between reads and uncommitted
/// writes.
///
/// * They need to be evacuated after cross-process writes.
class ModelReadCache<KeyType: Hashable & Equatable, ValueType> {
private let appReadiness: AppReadiness
private var cacheName: String {
adapter.cacheName
}
var logName: String {
return "\(cacheName)"
}
fileprivate let cache: LRUCache<KeyType, ModelCacheValueBox<ValueType>>
private let adapter: ModelCacheAdapter<KeyType, ValueType>
init(
adapter: ModelCacheAdapter<KeyType, ValueType>,
appReadiness: AppReadiness,
) {
self.appReadiness = appReadiness
self.adapter = adapter
self.cache = LRUCache(maxSize: adapter.cacheCountLimit, nseMaxSize: 0)
NotificationCenter.default.addObserver(
self,
selector: #selector(didReceiveCrossProcessNotification),
name: SDSDatabaseStorage.didReceiveCrossProcessNotificationAlwaysSync,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(didReceiveEvacuateCacheNotification),
name: ModelReadCaches.evacuateAllModelCaches,
object: nil,
)
}
private func evacuateCache() {
// Right now, we call `cache.removeAllObjects()` on background threads. For
// now, this is OK because LRUCache is thread safe, but if we ever do more
// work here we should re-evaluate.
cache.removeAllObjects()
DispatchQueue.global().async {
self.performSync {
self.cache.removeAllObjects()
}
}
}
@objc
private func didReceiveEvacuateCacheNotification(_ notification: Notification) {
evacuateCache()
}
@objc
private func didReceiveCrossProcessNotification(_ notification: Notification) {
AssertIsOnMainThread()
evacuateCache()
}
// This method should only be called within performSync().
private func readValue(for cacheKey: ModelCacheKey<KeyType>, transaction: DBReadTransaction) -> ValueType? {
let maybeValue = adapter.read(key: cacheKey.key, transaction: transaction)
if let value = maybeValue {
return value
}
if !isExcluded(cacheKey: cacheKey, transaction: transaction), canUseCache() {
// Update cache.
writeToCache(cacheKey: cacheKey, value: nil)
}
return nil
}
func didRead(value: ValueType, transaction: DBReadTransaction) {
let cacheKey = adapter.cacheKey(forValue: value)
guard canUseCache() else {
return
}
performSync {
if !isExcluded(cacheKey: cacheKey, transaction: transaction), canUseCache() {
writeToCache(cacheKey: cacheKey, value: value)
}
}
}
fileprivate func getValue(for cacheKey: ModelCacheKey<KeyType>, transaction: DBReadTransaction, returnNilOnCacheMiss: Bool = false) -> ValueType? {
return getValues(for: [cacheKey], transaction: transaction, returnNilOnCacheMiss: returnNilOnCacheMiss)[0]
}
func getValues(
for cacheKeys: [ModelCacheKey<KeyType>],
transaction: DBReadTransaction,
returnNilOnCacheMiss: Bool = false,
) -> [ValueType?] {
return performSync {
return cacheKeys.map { cacheKey in
if
!isExcluded(cacheKey: cacheKey, transaction: transaction),
let cachedValue = readFromCache(cacheKey: cacheKey)
{
return cachedValue.value.flatMap { self.copyValue($0) }
}
if returnNilOnCacheMiss {
return nil
}
return self.readValue(for: cacheKey, transaction: transaction)
}
}
}
func getValuesIfInCache(for keys: [KeyType], transaction: DBReadTransaction) -> [KeyType: ValueType] {
var result = [KeyType: ValueType]()
for key in keys {
let cacheKey = adapter.cacheKey(forKey: key)
if let value = getValue(for: cacheKey, transaction: transaction, returnNilOnCacheMiss: true) {
result[key] = value
}
}
return result
}
private func copyValue(_ value: ValueType) -> ValueType? {
do {
return try adapter.copy(value: value)
} catch {
owsFailDebug("Error: \(error)")
return nil
}
}
func didRemove(value: ValueType, transaction: DBWriteTransaction) {
let cacheKey = adapter.cacheKey(forValue: value)
updateCacheForWrite(cacheKey: cacheKey, value: nil, transaction: transaction)
}
func didInsertOrUpdate(value: ValueType, transaction: DBWriteTransaction) {
let cacheKey = adapter.cacheKey(forValue: value)
updateCacheForWrite(cacheKey: cacheKey, value: value, transaction: transaction)
}
private func updateCacheForWrite(cacheKey: ModelCacheKey<KeyType>, value: ValueType?, transaction: DBWriteTransaction) {
guard canUseCache() else {
return
}
// Exclude this key from being used in the cache until the write
// transaction has committed.
performSync {
// Update the cache to reflect the new value. The cache won't be used
// during the exclusion, so we could also update this when we remove the
// exclusion.
writeToCache(cacheKey: cacheKey, value: value)
// Protect the cache from being corrupted by reads by excluding the key
// until the write transaction commits.
addExclusion(for: cacheKey)
}
// Once the write transaction has completed, it is safe to use the cache
// for this key again for .read caches.
transaction.addSyncCompletion {
self.performSync {
self.removeExclusion(for: cacheKey)
}
}
}
private var isAppReady: Bool {
return appReadiness.isAppReady
}
private func canUseCache() -> Bool { isAppReady }
// MARK: -
func writeToCache(cacheKey: ModelCacheKey<KeyType>, value: ValueType?) {
cache.setObject(ModelCacheValueBox(value: value), forKey: cacheKey.key)
}
func readFromCache(cacheKey: ModelCacheKey<KeyType>) -> ModelCacheValueBox<ValueType>? {
cache.object(forKey: cacheKey.key)
}
// MARK: - Exclusion
/// Races between reads and writes can corrupt the cache. Therefore gets
/// _for a given key_ should not read from or write to the cache during
/// database writes which affect that key, specifically during the
/// "exclusion period" that begins when the first write query affecting that
/// key completes and that ends when the write transaction has committed.
///
/// The desired behavior:
///
/// * During the "exclusion period" (e.g. write query completed but write
/// transaction hasn't committed) we want all gets to reflect the current
/// state _for their transaction_.
///
/// * We might "get" from within the same write transaction that caused the
/// "exclusion". That should reflect the _new_ state.
///
/// * Concurrent gets from read transactions or without a transaction during
/// the "exclusion period" should reflect the old state.
///
/// We achieve this by having all gets ignore the cache during the
/// "exclusion period."
///
/// Bear in mind that:
///
/// * Values might be evacuated from the cache between the write query and
/// the write transaction being committed.
///
/// Note that we use a map with counters so that the async completion of one
/// write doesn't interfere with exclusion from a subsequent write to the
/// same entity.
private var exclusionCountMap = [KeyType: Int]()
private var exclusionDateMap = [KeyType: MonotonicDate]()
// This method should only be called within performSync().
private func isExcluded(cacheKey: ModelCacheKey<KeyType>, transaction: DBReadTransaction) -> Bool {
if let exclusionDate = exclusionDateMap[cacheKey.key] {
if exclusionDate > transaction.startDate {
return true
}
}
if exclusionCountMap[cacheKey.key] != nil {
return true
}
return false
}
// This method should only be called within performSync().
func addExclusion(for cacheKey: ModelCacheKey<KeyType>) {
let key = cacheKey.key
if let value = self.exclusionCountMap[key] {
self.exclusionCountMap[key] = value + 1
} else {
self.exclusionCountMap[key] = 1
}
}
// This method should only be called within performSync().
private func removeExclusion(for cacheKey: ModelCacheKey<KeyType>) {
let key = cacheKey.key
self.exclusionDateMap[key] = MonotonicDate()
guard let value = self.exclusionCountMap[key] else {
owsFailDebug("Missing exclusion key.")
return
}
guard value > 1 else {
self.exclusionCountMap.removeValue(forKey: key)
return
}
self.exclusionCountMap[key] = value - 1
}
// Never open a transaction within performSync() to avoid deadlock.
@discardableResult
func performSync<T>(_ block: () -> T) -> T {
// We can't use a serial queue due to GRDB's scheduling watchdog.
// Additionally, our locking mechanism needs to be re-entrant.
objc_sync_enter(self)
let value = block()
objc_sync_exit(self)
return value
}
}
// MARK: -
@objc
public class ThreadReadCache: NSObject {
private class Adapter: ModelCacheAdapter<String, TSThread> {
override func read(key: String, transaction: DBReadTransaction) -> TSThread? {
return TSThread.anyFetch(uniqueId: key, transaction: transaction)
}
override func key(forValue value: TSThread) -> String {
value.uniqueId
}
override func cacheKey(forKey key: String) -> ModelCacheKey<String> {
return ModelCacheKey(key: key)
}
override func copy(value: TSThread) throws -> TSThread {
return value.deepCopy()
}
}
private let cache: ModelReadCache<String, TSThread>
private let adapter = Adapter(cacheName: "TSThread", cacheCountLimit: 32)
@objc
public init(_ factory: ModelReadCacheFactory) {
cache = factory.create(adapter: adapter)
}
@objc(getThreadForUniqueId:transaction:)
public func getThread(uniqueId: String, transaction: DBReadTransaction) -> TSThread? {
let cacheKey = adapter.cacheKey(forKey: uniqueId)
return cache.getValue(for: cacheKey, transaction: transaction)
}
@objc(didRemoveThread:transaction:)
public func didRemove(thread: TSThread, transaction: DBWriteTransaction) {
cache.didRemove(value: thread, transaction: transaction)
}
@objc(didInsertOrUpdateThread:transaction:)
public func didInsertOrUpdate(thread: TSThread, transaction: DBWriteTransaction) {
cache.didInsertOrUpdate(value: thread, transaction: transaction)
}
@objc
public func didReadThread(_ thread: TSThread, transaction: DBReadTransaction) {
cache.didRead(value: thread, transaction: transaction)
}
}
// MARK: -
@objc
public class InteractionReadCache: NSObject {
private class Adapter: ModelCacheAdapter<String, TSInteraction> {
override func read(key: String, transaction: DBReadTransaction) -> TSInteraction? {
return TSInteraction.anyFetch(uniqueId: key, transaction: transaction)
}
override func key(forValue value: TSInteraction) -> String {
value.uniqueId
}
override func cacheKey(forKey key: String) -> ModelCacheKey<String> {
return ModelCacheKey(key: key)
}
override func copy(value: TSInteraction) throws -> TSInteraction {
return try DeepCopies.deepCopy(value)
}
}
private let cache: ModelReadCache<String, TSInteraction>
private let adapter = Adapter(cacheName: "TSInteraction", cacheCountLimit: 1024)
@objc
public init(_ factory: ModelReadCacheFactory) {
cache = factory.create(adapter: adapter)
}
@objc(getInteractionForUniqueId:transaction:)
public func getInteraction(uniqueId: String, transaction: DBReadTransaction) -> TSInteraction? {
let cacheKey = adapter.cacheKey(forKey: uniqueId)
return cache.getValue(for: cacheKey, transaction: transaction)
}
public func getInteractionsIfInCache(for uniqueIds: [String], transaction: DBReadTransaction) -> [String: TSInteraction] {
return cache.getValuesIfInCache(for: uniqueIds, transaction: transaction)
}
@objc(didRemoveInteraction:transaction:)
public func didRemove(interaction: TSInteraction, transaction: DBWriteTransaction) {
cache.didRemove(value: interaction, transaction: transaction)
}
@objc(didUpdateInteraction:transaction:)
public func didUpdate(interaction: TSInteraction, transaction: DBWriteTransaction) {
guard interaction.sortId > 0 else {
// Only cache interactions that have been read from the database.
return
}
cache.didInsertOrUpdate(value: interaction, transaction: transaction)
}
@objc
public func didReadInteraction(_ interaction: TSInteraction, transaction: DBReadTransaction) {
cache.didRead(value: interaction, transaction: transaction)
}
}
// MARK: -
public class InstalledStickerCache {
private class Adapter: ModelCacheAdapter<String, InstalledStickerRecord> {
override func read(key: String, transaction: DBReadTransaction) -> InstalledStickerRecord? {
return InstalledStickerRecord.anyFetch(uniqueId: key, transaction: transaction)
}
override func key(forValue value: InstalledStickerRecord) -> String {
value.uniqueId
}
override func cacheKey(forKey key: String) -> ModelCacheKey<String> {
return ModelCacheKey(key: key)
}
override func copy(value: InstalledStickerRecord) throws -> InstalledStickerRecord {
return value.deepCopy()
}
}
private let cache: ModelReadCache<String, InstalledStickerRecord>
private static var cacheCountLimit: Int {
if CurrentAppContext().isMainApp {
// Large enough to hold three pages of max-size stickers.
return 600
} else {
// Large enough to hold the current default 49 stickers with a little room to grow.
return 64
}
}
private let adapter = Adapter(cacheName: "InstalledStickerRecord", cacheCountLimit: InstalledStickerCache.cacheCountLimit)
public init(_ factory: ModelReadCacheFactory) {
cache = factory.create(adapter: adapter)
}
public func getInstalledSticker(uniqueId: String, transaction: DBReadTransaction) -> InstalledStickerRecord? {
let cacheKey = adapter.cacheKey(forKey: uniqueId)
return cache.getValue(for: cacheKey, transaction: transaction)
}
public func didRemove(installedSticker: InstalledStickerRecord, transaction: DBWriteTransaction) {
cache.didRemove(value: installedSticker, transaction: transaction)
}
public func didInsertOrUpdate(installedSticker: InstalledStickerRecord, transaction: DBWriteTransaction) {
cache.didInsertOrUpdate(value: installedSticker, transaction: transaction)
}
public func didReadInstalledSticker(_ installedSticker: InstalledStickerRecord, transaction: DBReadTransaction) {
cache.didRead(value: installedSticker, transaction: transaction)
}
}
// MARK: -
@objc
public class ModelReadCaches: NSObject {
@objc(initWithModelReadCacheFactory:)
public init(factory: ModelReadCacheFactory) {
threadReadCache = ThreadReadCache(factory)
interactionReadCache = InteractionReadCache(factory)
installedStickerCache = InstalledStickerCache(factory)
}
@objc
public let threadReadCache: ThreadReadCache
public let interactionReadCache: InteractionReadCache
public let installedStickerCache: InstalledStickerCache
fileprivate static let evacuateAllModelCaches = Notification.Name("EvacuateAllModelCaches")
public func evacuateAllCaches() {
NotificationCenter.default.post(name: Self.evacuateAllModelCaches, object: nil)
}
}
public class ModelReadCacheFactory: NSObject {
fileprivate let appReadiness: AppReadiness
public init(appReadiness: AppReadiness) {
self.appReadiness = appReadiness
}
func create<KeyType: Hashable & Equatable, ValueType>(
adapter: ModelCacheAdapter<KeyType, ValueType>,
) -> ModelReadCache<KeyType, ValueType> {
return ModelReadCache(adapter: adapter, appReadiness: appReadiness)
}
}