546 lines
18 KiB
Swift
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)
|
|
}
|
|
}
|