TM-SGNL-iOS/SignalServiceKit/Util/ModelReadCache.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

755 lines
26 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: SDSAnyReadTransaction) -> ValueType? {
fatalError("Unimplemented")
}
func read(keys: [KeyType], transaction: SDSAnyReadTransaction) -> [ValueType?] {
return keys.map {
read(key: $0, transaction: transaction)
}
}
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
let cacheCountLimitNSE: Int
init(cacheName: String, cacheCountLimit: Int, cacheCountLimitNSE: Int) {
self.cacheName = cacheName
self.cacheCountLimit = cacheCountLimit
self.cacheCountLimitNSE = cacheCountLimitNSE
}
}
// MARK: -
class ModelReadCache<KeyType: Hashable & Equatable, ValueType> {
enum Mode {
// * .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.
case read
public var description: String {
switch self {
case .read:
return ".read"
}
}
}
private let appReadiness: AppReadiness
private let mode: Mode
private var cacheName: String {
adapter.cacheName
}
var logName: String {
return "\(cacheName) \(mode)"
}
fileprivate let cache: LRUCache<KeyType, ModelCacheValueBox<ValueType>>
private let adapter: ModelCacheAdapter<KeyType, ValueType>
private var isCacheReady: Bool {
switch mode {
case .read:
return true
}
}
private let disableCachesInNSE = true
init(
mode: Mode,
adapter: ModelCacheAdapter<KeyType, ValueType>,
appReadiness: AppReadiness
) {
self.appReadiness = appReadiness
self.mode = mode
self.adapter = adapter
self.cache = LRUCache(maxSize: adapter.cacheCountLimit,
nseMaxSize: disableCachesInNSE ? 0 : adapter.cacheCountLimitNSE)
switch mode {
case .read:
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()
assert(mode == .read)
evacuateCache()
}
#if TESTABLE_BUILD
// This method should only be called within performSync().
private func readValue(for cacheKey: ModelCacheKey<KeyType>, transaction: SDSAnyReadTransaction) -> ValueType? {
return readValues(for: AnySequence([cacheKey]), transaction: transaction)[0]
}
#endif
// This method should only be called within performSync().
func readValues(for cacheKeys: AnySequence<ModelCacheKey<KeyType>>,
transaction: SDSAnyReadTransaction) -> [ValueType?] {
let maybeValues = adapter.read(keys: cacheKeys.map { $0.key },
transaction: transaction)
return zip(cacheKeys, maybeValues).map { tuple in
let (cacheKey, maybeValue) = tuple
if let value = maybeValue {
return value
}
if !isExcluded(cacheKey: cacheKey, transaction: transaction),
canUseCache(cacheKey: cacheKey, transaction: transaction) {
// Update cache.
writeToCache(cacheKey: cacheKey, value: nil)
}
return nil
}
}
func didRead(value: ValueType, transaction: SDSAnyReadTransaction) {
let cacheKey = adapter.cacheKey(forValue: value)
guard canUseCache(cacheKey: cacheKey, transaction: transaction) else {
return
}
performSync {
if !isExcluded(cacheKey: cacheKey, transaction: transaction),
canUseCache(cacheKey: cacheKey, transaction: transaction) {
writeToCache(cacheKey: cacheKey, value: value)
}
}
}
// This method should only be called within performSync().
private func cachedValue(for cacheKey: ModelCacheKey<KeyType>, transaction: SDSAnyReadTransaction) -> ModelCacheValueBox<ValueType>? {
return cachedValues(for: [cacheKey], transaction: transaction)[0]
}
// This method should only be called within performSync().
private func cachedValues(for cacheKeys: [ModelCacheKey<KeyType>], transaction: SDSAnyReadTransaction) -> [ModelCacheValueBox<ValueType>?] {
guard isCacheReady else {
return Array(repeating: nil, count: cacheKeys.count)
}
return Refinery<ModelCacheKey<KeyType>, ModelCacheValueBox<ValueType>>(cacheKeys).refine { cacheKey in
return !isExcluded(cacheKey: cacheKey, transaction: transaction)
} then: { cacheKeys -> [ModelCacheValueBox<ValueType>?] in
return readFromCache(cacheKeys: AnySequence(cacheKeys))
} otherwise: { cacheKeys -> [ModelCacheValueBox<ValueType>?] in
// Read excluded.
return cacheKeys.lazy.map { _ in nil }
}.values
}
func getValue(for cacheKey: ModelCacheKey<KeyType>, transaction: SDSAnyReadTransaction, returnNilOnCacheMiss: Bool = false) -> ValueType? {
return getValues(for: [cacheKey], transaction: transaction, returnNilOnCacheMiss: returnNilOnCacheMiss)[0]
}
func getValues(for cacheKeys: [ModelCacheKey<KeyType>],
transaction: SDSAnyReadTransaction,
returnNilOnCacheMiss: Bool = false) -> [ValueType?] {
// This can be used to verify that cached values exactly
// align with database contents.
#if TESTABLE_BUILD
let shouldCheckValues = false
let checkValues = { (cacheKey: ModelCacheKey<KeyType>, cachedValue: ValueType?) in
guard !returnNilOnCacheMiss else {
return
}
_ = self.readValue(for: cacheKey, transaction: transaction)
}
#endif
return performSync {
let maybeValues = self.cachedValues(for: cacheKeys, transaction: transaction)
let keyValueTuples = Array(zip(cacheKeys, maybeValues))
typealias KeyValuePair = (ModelCacheKey<KeyType>, ModelCacheValueBox<ValueType>?)
return Refinery<KeyValuePair, ValueType>(keyValueTuples).refine { (entry: KeyValuePair) -> Bool in
return entry.1 != nil
} then: { (entries: AnySequence<KeyValuePair>) -> [ValueType?] in
// Have an entry in maybeValues, although it could be nil.
return entries.map { tuple in
let (key, cachedValue) = (tuple.0, tuple.1!)
if let value = cachedValue.value {
// Return a copy of the model.
let cachedValue = self.copyValue(value)
#if TESTABLE_BUILD
if shouldCheckValues {
checkValues(key, cachedValue)
}
#endif
return cachedValue
}
#if TESTABLE_BUILD
if shouldCheckValues {
checkValues(key, nil)
}
#endif
return nil
}
} otherwise: { tuples -> [ValueType?] in
// Have no cache entry.
guard !returnNilOnCacheMiss else {
return tuples.lazy.map { _ in nil }
}
let keys = tuples.lazy.map { $0.0 }
return self.readValues(for: AnySequence(keys), transaction: transaction)
}.values
}
}
func getValuesIfInCache(for keys: [KeyType], transaction: SDSAnyReadTransaction) -> [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: SDSAnyWriteTransaction) {
assert(mode == .read)
let cacheKey = adapter.cacheKey(forValue: value)
updateCacheForWrite(cacheKey: cacheKey, value: nil, transaction: transaction)
}
func didInsertOrUpdate(value: ValueType, transaction: SDSAnyWriteTransaction) {
assert(mode == .read)
let cacheKey = adapter.cacheKey(forValue: value)
updateCacheForWrite(cacheKey: cacheKey, value: value, transaction: transaction)
}
private func updateCacheForWrite(cacheKey: ModelCacheKey<KeyType>, value: ValueType?, transaction: SDSAnyWriteTransaction) {
guard canUseCache(cacheKey: cacheKey, transaction: transaction) 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)
if self.mode == .read {
// Protect the cache from being corrupted by reads
// by excluding the key until the write transaction
// commits.
addExclusion(for: cacheKey)
}
}
if mode == .read {
// 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 func isCachable(cacheKey: ModelCacheKey<KeyType>) -> Bool {
guard let address = cacheKey.key as? SignalServiceAddress else {
return true
}
if address.serviceId != nil {
return true
}
if address.phoneNumber == OWSUserProfile.Constants.localProfilePhoneNumber {
owsAssertDebug(address.serviceId == nil)
return true
}
return false
}
var isAppReady: Bool {
return appReadiness.isAppReady
}
private func canUseCache(cacheKey: ModelCacheKey<KeyType>,
transaction: SDSAnyReadTransaction) -> Bool {
guard isCachable(cacheKey: cacheKey) else {
return false
}
guard isCacheReady else {
return false
}
guard isAppReady else {
return false
}
switch transaction.readTransaction {
case .grdbRead:
return true
}
}
// 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)
}
private func readFromCache(cacheKeys: AnySequence<ModelCacheKey<KeyType>>) -> [ModelCacheValueBox<ValueType>?] {
return cacheKeys.map { cache.object(forKey: $0.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: Date]()
// This method should only be called within performSync().
private func isExcluded(cacheKey: ModelCacheKey<KeyType>, transaction: SDSAnyReadTransaction) -> Bool {
guard mode == .read else {
return false
}
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>) {
assert(mode == .read)
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>) {
assert(mode == .read)
let key = cacheKey.key
self.exclusionDateMap[key] = Date()
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 {
switch mode {
case .read:
// 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 {
typealias KeyType = String
typealias ValueType = TSThread
private class Adapter: ModelCacheAdapter<KeyType, ValueType> {
override func read(key: KeyType, transaction: SDSAnyReadTransaction) -> ValueType? {
return TSThread.anyFetch(uniqueId: key,
transaction: transaction,
ignoreCache: true)
}
override func key(forValue value: ValueType) -> KeyType {
value.uniqueId
}
override func cacheKey(forKey key: KeyType) -> ModelCacheKey<KeyType> {
return ModelCacheKey(key: key)
}
override func copy(value: ValueType) throws -> ValueType {
return try DeepCopies.deepCopy(value)
}
}
private let cache: ModelReadCache<KeyType, ValueType>
private let adapter = Adapter(cacheName: "TSThread", cacheCountLimit: 32, cacheCountLimitNSE: 8)
@objc
public init(_ factory: ModelReadCacheFactory) {
cache = factory.create(mode: .read, adapter: adapter)
}
@objc(getThreadForUniqueId:transaction:)
public func getThread(uniqueId: String, transaction: SDSAnyReadTransaction) -> TSThread? {
let cacheKey = adapter.cacheKey(forKey: uniqueId)
return cache.getValue(for: cacheKey, transaction: transaction)
}
@objc(getThreadsIfInCacheForUniqueIds:transaction:)
public func getThreadsIfInCache(forUniqueIds uniqueIds: [String], transaction: SDSAnyReadTransaction) -> [String: TSThread] {
let keys: [String] = uniqueIds.map { $0 }
let result: [String: TSThread] = cache.getValuesIfInCache(for: keys, transaction: transaction)
return Dictionary(uniqueKeysWithValues: result.map({ (key, value) in
return (key, value)
}))
}
@objc(didRemoveThread:transaction:)
public func didRemove(thread: TSThread, transaction: SDSAnyWriteTransaction) {
cache.didRemove(value: thread, transaction: transaction)
}
@objc(didInsertOrUpdateThread:transaction:)
public func didInsertOrUpdate(thread: TSThread, transaction: SDSAnyWriteTransaction) {
cache.didInsertOrUpdate(value: thread, transaction: transaction)
}
@objc
public func didReadThread(_ thread: TSThread, transaction: SDSAnyReadTransaction) {
cache.didRead(value: thread, transaction: transaction)
}
}
// MARK: -
@objc
public class InteractionReadCache: NSObject {
typealias KeyType = String
typealias ValueType = TSInteraction
private class Adapter: ModelCacheAdapter<KeyType, ValueType> {
override func read(key: KeyType, transaction: SDSAnyReadTransaction) -> ValueType? {
return TSInteraction.anyFetch(uniqueId: key,
transaction: transaction,
ignoreCache: true)
}
override func key(forValue value: ValueType) -> KeyType {
value.uniqueId
}
override func cacheKey(forKey key: KeyType) -> ModelCacheKey<KeyType> {
return ModelCacheKey(key: key)
}
override func copy(value: ValueType) throws -> ValueType {
return try DeepCopies.deepCopy(value)
}
}
private let cache: ModelReadCache<KeyType, ValueType>
private let adapter = Adapter(cacheName: "TSInteraction", cacheCountLimit: 1024, cacheCountLimitNSE: 32)
@objc
public init(_ factory: ModelReadCacheFactory) {
cache = factory.create(mode: .read, adapter: adapter)
}
@objc(getInteractionForUniqueId:transaction:)
public func getInteraction(uniqueId: String, transaction: SDSAnyReadTransaction) -> TSInteraction? {
let cacheKey = adapter.cacheKey(forKey: uniqueId)
return cache.getValue(for: cacheKey, transaction: transaction)
}
public func getInteractionsIfInCache(for uniqueIds: [String], transaction: SDSAnyReadTransaction) -> [String: TSInteraction] {
return cache.getValuesIfInCache(for: uniqueIds, transaction: transaction)
}
@objc(didRemoveInteraction:transaction:)
public func didRemove(interaction: TSInteraction, transaction: SDSAnyWriteTransaction) {
cache.didRemove(value: interaction, transaction: transaction)
}
@objc(didUpdateInteraction:transaction:)
public func didUpdate(interaction: TSInteraction, transaction: SDSAnyWriteTransaction) {
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: SDSAnyReadTransaction) {
cache.didRead(value: interaction, transaction: transaction)
}
}
// MARK: -
@objc
public class InstalledStickerCache: NSObject {
typealias KeyType = String
typealias ValueType = InstalledSticker
private class Adapter: ModelCacheAdapter<KeyType, ValueType> {
override func read(key: KeyType, transaction: SDSAnyReadTransaction) -> ValueType? {
return InstalledSticker.anyFetch(uniqueId: key,
transaction: transaction,
ignoreCache: true)
}
override func key(forValue value: ValueType) -> KeyType {
value.uniqueId
}
override func cacheKey(forKey key: KeyType) -> ModelCacheKey<KeyType> {
return ModelCacheKey(key: key)
}
override func copy(value: ValueType) throws -> ValueType {
return try DeepCopies.deepCopy(value)
}
}
private let cache: ModelReadCache<KeyType, ValueType>
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: "InstalledSticker",
cacheCountLimit: InstalledStickerCache.cacheCountLimit,
cacheCountLimitNSE: 8)
@objc
public init(_ factory: ModelReadCacheFactory) {
cache = factory.create(mode: .read, adapter: adapter)
}
@objc(getInstalledStickerForUniqueId:transaction:)
public func getInstalledSticker(uniqueId: String, transaction: SDSAnyReadTransaction) -> InstalledSticker? {
let cacheKey = adapter.cacheKey(forKey: uniqueId)
return cache.getValue(for: cacheKey, transaction: transaction)
}
@objc(didRemoveInstalledSticker:transaction:)
public func didRemove(installedSticker: InstalledSticker, transaction: SDSAnyWriteTransaction) {
cache.didRemove(value: installedSticker, transaction: transaction)
}
@objc(didInsertOrUpdateInstalledSticker:transaction:)
public func didInsertOrUpdate(installedSticker: InstalledSticker, transaction: SDSAnyWriteTransaction) {
cache.didInsertOrUpdate(value: installedSticker, transaction: transaction)
}
@objc
public func didReadInstalledSticker(_ installedSticker: InstalledSticker, transaction: SDSAnyReadTransaction) {
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
@objc
public let interactionReadCache: InteractionReadCache
@objc
public let installedStickerCache: InstalledStickerCache
@objc
fileprivate static let evacuateAllModelCaches = Notification.Name("EvacuateAllModelCaches")
@objc
public func evacuateAllCaches() {
NotificationCenter.default.post(name: Self.evacuateAllModelCaches, object: nil)
}
}
class TestableModelReadCache<KeyType: Hashable & Equatable, ValueType>: ModelReadCache<KeyType, ValueType> {
override var isAppReady: Bool {
return true
}
}
public class ModelReadCacheFactory: NSObject {
fileprivate let appReadiness: AppReadiness
public init(appReadiness: AppReadiness) {
self.appReadiness = appReadiness
}
func create<KeyType: Hashable & Equatable, ValueType>(
mode: ModelReadCache<KeyType, ValueType>.Mode,
adapter: ModelCacheAdapter<KeyType, ValueType>
) -> ModelReadCache<KeyType, ValueType> {
return ModelReadCache(mode: mode, adapter: adapter, appReadiness: appReadiness)
}
}
@objc
class TestableModelReadCacheFactory: ModelReadCacheFactory {
override func create<KeyType: Hashable & Equatable, ValueType>(
mode: ModelReadCache<KeyType, ValueType>.Mode,
adapter: ModelCacheAdapter<KeyType, ValueType>
) -> ModelReadCache<KeyType, ValueType> {
return TestableModelReadCache(mode: mode, adapter: adapter, appReadiness: appReadiness)
}
}