Signal-iOS/SignalServiceKit/src/Util/ModelReadCache.swift

438 lines
15 KiB
Swift

//
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
//
import Foundation
private class ModelReadCache<KeyType: AnyObject & Hashable, ValueType: AnyObject> {
// MARK: - Dependencies
var databaseStorage: SDSDatabaseStorage {
return SSKEnvironment.shared.databaseStorage
}
// MARK: -
enum Mode {
// * .uiRead caches are only accessed on the main thread.
// * They are evacuated whenever the ui database snapshot is updated.
case uiRead
// * .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 uncommited
// writes.
// * They need to be evacuated after cross-process writes.
case read
public var description: String {
switch self {
case .uiRead:
return ".uiRead"
case .read:
return ".read"
}
}
}
private let mode: Mode
private let cacheName: String
private var logName: String {
return "\(cacheName) \(mode)"
}
class ValueBox {
let value: ValueType?
init(value: ValueType?) {
self.value = value
}
}
// This property should only be accessed on serialQueue.
//
// TODO: We could tune the size of this cache.
// NSCache's default behavior is opaque.
// We're currently only using this to cache
// small models, but that could change.
private let nscache = NSCache<KeyType, ValueBox>()
typealias ReadBlock = (KeyType, SDSAnyReadTransaction) -> ValueType?
private let readBlock: ReadBlock
typealias KeyBlock = (ValueType) -> KeyType
private let keyBlock: KeyBlock
init(mode: Mode, cacheName: String, keyBlock: @escaping KeyBlock, readBlock: @escaping ReadBlock) {
self.mode = mode
self.cacheName = cacheName
self.keyBlock = keyBlock
self.readBlock = readBlock
switch mode {
case .read:
NotificationCenter.default.addObserver(self,
selector: #selector(didReceiveCrossProcessNotification),
name: SDSDatabaseStorage.didReceiveCrossProcessNotification,
object: nil)
case .uiRead:
NotificationCenter.default.addObserver(self,
selector: #selector(didUpdateUIDatabaseSnapshot),
name: UIDatabaseObserver.didUpdateUIDatabaseSnapshotNotification,
object: nil)
}
}
@objc
func didReceiveCrossProcessNotification(_ notification: Notification) {
AssertIsOnMainThread()
assert(mode == .read)
self.nscache.removeAllObjects()
DispatchQueue.global().async {
self.performSync {
self.nscache.removeAllObjects()
}
}
}
@objc
func didUpdateUIDatabaseSnapshot(_ notification: Notification) {
AssertIsOnMainThread()
assert(mode == .uiRead)
self.nscache.removeAllObjects()
}
// This method should only be called within performSync().
private func readValue(for key: KeyType, transaction: SDSAnyReadTransaction) -> ValueType? {
if let value = readBlock(key, transaction) {
if !isExcluded(key: key),
canUseCache(transaction: transaction) {
// Update cache.
nscache.setObject(ValueBox(value: value), forKey: key)
}
return value
}
if !isExcluded(key: key),
canUseCache(transaction: transaction) {
// Update cache.
nscache.setObject(ValueBox(value: nil), forKey: key)
}
return nil
}
// This method should only be called within performSync().
private func cachedValue(for key: KeyType) -> ValueBox? {
guard !isExcluded(key: key) else {
// Read excluded.
return nil
}
return nscache.object(forKey: key)
}
func getValue(for key: KeyType, transaction: SDSAnyReadTransaction) -> ValueType? {
return performSync {
if let cachedValue = self.cachedValue(for: key) {
return cachedValue.value
}
return self.readValue(for: key, transaction: transaction)
}
}
func didRemove(value: ValueType, transaction: SDSAnyWriteTransaction) {
assert(mode == .read)
let key = keyBlock(value)
updateCacheForWrite(key: key, value: nil, transaction: transaction)
}
func didInsertOrUpdate(value: ValueType, transaction: SDSAnyWriteTransaction) {
assert(mode == .read)
let key = keyBlock(value)
updateCacheForWrite(key: key, value: value, transaction: transaction)
}
private func updateCacheForWrite(key: KeyType, value: ValueType?, transaction: SDSAnyWriteTransaction) {
guard canUseCache(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.
if let value = value {
nscache.setObject(ValueBox(value: value), forKey: key)
} else {
nscache.setObject(ValueBox(value: nil), forKey: key)
}
if self.mode == .read {
// Protect the cache from being corrupted by reads
// by excluding the key until the write transaction
// commits.
addExclusion(for: key)
}
}
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: key)
}
}
}
}
private func canUseCache(transaction: SDSAnyReadTransaction) -> Bool {
switch transaction.readTransaction {
case .yapRead:
return false
case .grdbRead:
return true
}
}
// 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 refect 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 excludedKeyMap = [KeyType: UInt]()
// This method should only be called within performSync().
private func isExcluded(key: KeyType) -> Bool {
guard mode == .read else {
return false
}
return excludedKeyMap[key] != nil
}
// This method should only be called within performSync().
private func addExclusion(for key: KeyType) {
assert(mode == .read)
if let value = excludedKeyMap[key] {
excludedKeyMap[key] = value + 1
} else {
excludedKeyMap[key] = 1
}
}
// This method should only be called within performSync().
private func removeExclusion(for key: KeyType) {
assert(mode == .read)
guard let value = excludedKeyMap[key] else {
owsFailDebug("Missing exclusion key.")
return
}
guard value > 1 else {
excludedKeyMap.removeValue(forKey: key)
return
}
excludedKeyMap[key] = value - 1
}
// We can't use a serial queue due to GRDB's scheduling watchdog.
//
// Never open a transaction within performSync() to avoid deadlock.
@discardableResult
private func performSync<T>(_ block: () -> T) -> T {
switch mode {
case .uiRead:
AssertIsOnMainThread()
// We don't need to bother syncing .uiRead activity since
// it's all done on the main thread.
return block()
case .read:
objc_sync_enter(self)
let value = block()
objc_sync_exit(self)
return value
}
}
}
// MARK: -
private class ModelReadCacheWrapper<KeyType: AnyObject & Hashable, ValueType: AnyObject> {
private let uiReadCache: ModelReadCache<KeyType, ValueType>
private let readCache: ModelReadCache<KeyType, ValueType>
typealias ReadBlock = (KeyType, SDSAnyReadTransaction) -> ValueType?
typealias KeyBlock = (ValueType) -> KeyType
init(cacheName: String, keyBlock: @escaping KeyBlock, readBlock: @escaping ReadBlock) {
uiReadCache = ModelReadCache(mode: .uiRead, cacheName: cacheName, keyBlock: keyBlock, readBlock: readBlock)
readCache = ModelReadCache(mode: .read, cacheName: cacheName, keyBlock: keyBlock, readBlock: readBlock)
}
func getValue(for key: KeyType, transaction: SDSAnyReadTransaction) -> ValueType? {
if transaction.isUIRead {
assert(Thread.isMainThread)
}
let cache = (transaction.isUIRead ? uiReadCache : readCache)
return cache.getValue(for: key, transaction: transaction)
}
func didRemove(value: ValueType, transaction: SDSAnyWriteTransaction) {
// Only update readCache to reflect writes.
readCache.didRemove(value: value, transaction: transaction)
}
func didInsertOrUpdate(value: ValueType, transaction: SDSAnyWriteTransaction) {
// Only update readCache to reflect writes.
readCache.didInsertOrUpdate(value: value, transaction: transaction)
}
}
// MARK: -
@objc
public class UserProfileReadCache: NSObject {
private let cache: ModelReadCacheWrapper<SignalServiceAddress, OWSUserProfile>
@objc
public override init() {
cache = ModelReadCacheWrapper(cacheName: "UserProfile",
keyBlock: {
$0.address
},
readBlock: { (address, transaction) in
return OWSUserProfile.getFor(address, transaction: transaction)
})
}
@objc
public func getUserProfile(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> OWSUserProfile? {
return cache.getValue(for: address, transaction: transaction)
}
@objc(didRemoveUserProfile:transaction:)
public func didRemove(userProfile: OWSUserProfile, transaction: SDSAnyWriteTransaction) {
cache.didRemove(value: userProfile, transaction: transaction)
}
@objc(didInsertOrUpdateUserProfile:transaction:)
public func didInsertOrUpdate(userProfile: OWSUserProfile, transaction: SDSAnyWriteTransaction) {
cache.didInsertOrUpdate(value: userProfile, transaction: transaction)
}
}
// MARK: -
@objc
public class SignalAccountReadCache: NSObject {
private let cache: ModelReadCacheWrapper<SignalServiceAddress, SignalAccount>
@objc
public override init() {
let accountFinder = AnySignalAccountFinder()
cache = ModelReadCacheWrapper(cacheName: "SignalAccount",
keyBlock: {
$0.recipientAddress
},
readBlock: { (address, transaction) in
return accountFinder.signalAccount(for: address, transaction: transaction)
})
}
@objc
public func getSignalAccount(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> SignalAccount? {
return cache.getValue(for: address, transaction: transaction)
}
@objc(didRemoveSignalAccount:transaction:)
public func didRemove(signalAccount: SignalAccount, transaction: SDSAnyWriteTransaction) {
cache.didRemove(value: signalAccount, transaction: transaction)
}
@objc(didInsertOrUpdateSignalAccount:transaction:)
public func didInsertOrUpdate(signalAccount: SignalAccount, transaction: SDSAnyWriteTransaction) {
cache.didInsertOrUpdate(value: signalAccount, transaction: transaction)
}
}
// MARK: -
@objc
public class SignalRecipientReadCache: NSObject {
private let cache: ModelReadCacheWrapper<SignalServiceAddress, SignalRecipient>
@objc
public override init() {
let recipientFinder = AnySignalRecipientFinder()
cache = ModelReadCacheWrapper(cacheName: "SignalRecipient",
keyBlock: {
$0.address
},
readBlock: { (address, transaction) in
return recipientFinder.signalRecipient(for: address, transaction: transaction)
})
}
@objc(getSignalRecipientForAddress:transaction:)
public func getSignalRecipient(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> SignalRecipient? {
return cache.getValue(for: address, transaction: transaction)
}
@objc(didRemoveSignalRecipient:transaction:)
public func didRemove(signalRecipient: SignalRecipient, transaction: SDSAnyWriteTransaction) {
cache.didRemove(value: signalRecipient, transaction: transaction)
}
@objc(didInsertOrUpdateSignalRecipient:transaction:)
public func didInsertOrUpdate(signalRecipient: SignalRecipient, transaction: SDSAnyWriteTransaction) {
cache.didInsertOrUpdate(value: signalRecipient, transaction: transaction)
}
}
// MARK: -
@objc
public class ModelReadCaches: NSObject {
// UserProfileReadCache is only used within the profile manager.
@objc
public let signalAccountReadCache = SignalAccountReadCache()
@objc
public let signalRecipientReadCache = SignalRecipientReadCache()
}