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

229 lines
8.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import GRDB
import Foundation
/// Responsible for cleaning up expired ``DeletedCallRecord``s.
///
/// ``DeletedCallRecord``s are only intended to exist on-disk for as long as is
/// necessary to silently swallow events related to a call the user deleted.
/// Once that period has concluded i.e., the ``DeletedCallRecord`` has
/// "expired" this manager is responsible for deleting the
/// ``DeletedCallRecord``.
///
/// This manager works by finding all expired ``DeletedCallRecord``s and
/// deleting them immediately, then scheduling another cleanup (deletion) pass
/// for the expiration time of the next-expiring record, if there is one.
///
/// The manager will add a minimum delay between deletion passes to accommodate
/// multiple ``DeletedCallRecord``s with very close deletion times. For example,
/// if we have records with deletion times 10ms, 20ms, 30ms, and 40ms from now,
/// we don't want to schedule a pass for each one. Rather, if our minimum delay
/// is 1s, we'll schedule a single pass for `max(10ms, 1s)` from now, which will
/// then delete all the records.
///
/// - Note
/// "Expiration time" for a ``DeletedCallRecord`` is a function of its
/// ``DeletedCallRecord/deletedAtTimestamp`` property. Consequently, the phrase
/// "multiple records with the same expiration time" is equivalent to "multiple
/// records with the same `deletedAtTimestamp`.
public protocol DeletedCallRecordCleanupManager {
/// Start cleaning up deleted call records, as necessary.
///
/// - Important
/// This method must be safe to call anytime, including while asynchronous
/// cleanup is already scheduled.
func startCleanupIfNecessary()
}
// MARK: -
final class DeletedCallRecordCleanupManagerImpl: DeletedCallRecordCleanupManager {
typealias TimeIntervalProvider = () -> TimeInterval
private struct CleanupLock {
private let lock = AtomicBool(false, lock: .init())
func get() -> Bool {
return lock.get()
}
func tryTake() -> Bool {
return lock.tryToSetFlag()
}
func release() {
let isUnlocked = lock.tryToClearFlag()
owsPrecondition(isUnlocked)
}
}
private let minimumSecondsBetweenCleanupPasses: TimeIntervalProvider
private let callLinkStore: any CallLinkRecordStore
private let dateProvider: DateProvider
private let db: any DB
private let deletedCallRecordStore: DeletedCallRecordStore
private let schedulers: Schedulers
private let cleanupLock = CleanupLock()
/// Creates a cleanup manager.
///
/// - Parameter minimumSecondsBetweenCleanupPasses
/// Returns the minimum time interval between cleanup passes, in seconds.
init(
minimumSecondsBetweenCleanupPasses: @escaping TimeIntervalProvider = { 1 },
callLinkStore: any CallLinkRecordStore,
dateProvider: @escaping DateProvider,
db: any DB,
deletedCallRecordStore: DeletedCallRecordStore,
schedulers: Schedulers
) {
self.minimumSecondsBetweenCleanupPasses = minimumSecondsBetweenCleanupPasses
self.callLinkStore = callLinkStore
self.dateProvider = dateProvider
self.db = db
self.deletedCallRecordStore = deletedCallRecordStore
self.schedulers = schedulers
}
func startCleanupIfNecessary() {
guard cleanupLock.tryTake() else {
return
}
schedulers.global().async {
guard let notYetExpiredRecord = self.cleanUpAlreadyExpiredRecords() else {
self.cleanupLock.release()
return
}
self.scheduleCleanup(beginningWith: notYetExpiredRecord)
}
}
/// Cleans up any deleted call records that have already expired.
///
/// - Returns
/// The not-yet-expired deleted call record that will next expire.
private func cleanUpAlreadyExpiredRecords() -> DeletedCallRecord? {
var firstRecordNotDeleted: DeletedCallRecord?
_ = TimeGatedBatch.processAll(db: db) { tx in
if
let nextExpiringRecord = deletedCallRecordStore
.nextDeletedRecord(tx: tx)
{
if nextExpiringRecord.isExpired(dateProvider: dateProvider) {
/// If the next-expiring record should be deleted in this
/// batch, delete it and report to ``TimeGatedBatch`` that
/// we did. This method will be called by ``TimeGatedBatch``
/// repeatedly in the same transaction (time allowing), so
/// only deleting a single element per call is fine.
deletedCallRecordStore.delete(
expiredDeletedCallRecord: nextExpiringRecord,
tx: tx
)
do {
try deleteCallLinkIfNeeded(conversationId: nextExpiringRecord.conversationId, tx: tx)
} catch {
owsFailDebug("\(error)")
}
return 1
}
/// If the next expiring record shouldn't be deleted in this
/// batch, cache it and bail out so we can return it to the
/// caller.
firstRecordNotDeleted = nextExpiringRecord
}
return 0
}
return firstRecordNotDeleted
}
/// Removes the ``CallLinkRecord`` if there are no more references.
private func deleteCallLinkIfNeeded(conversationId: CallRecord.ConversationID, tx: any DBWriteTransaction) throws {
let callLinkRowId: Int64
switch conversationId {
case .thread:
return
case .callLink(let callLinkRowId2):
callLinkRowId = callLinkRowId2
}
let callLinkRecord = try callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
throw OWSAssertionError("Must be able to find call link.")
}()
if callLinkRecord.isDeleted {
// We can't delete this until Storage Service is done with it.
return
}
do {
try callLinkStore.delete(callLinkRecord, tx: tx)
} catch DatabaseError.SQLITE_CONSTRAINT {
// We'll delete it later -- something else is still using it.
}
}
/// Schedules a cleanup pass for the expiration time of the given record.
///
/// If there are any not-yet-expired records after this cleanup pass
/// finishes, this this method recursively calls itself with the *next*
/// next-expiring record.
///
/// - Important
/// The caller must have taken `cleanupLock` before calling this method.
/// method. This method releases `cleanupLock` when there are no more
/// records scheduled for cleanup.
private func scheduleCleanup(
beginningWith recordToScheduleExpiration: DeletedCallRecord
) {
owsPrecondition(cleanupLock.get())
let secondsUntilNextCleanupPass: TimeInterval = max(
recordToScheduleExpiration.secondsUntilExpiration(
dateProvider: dateProvider
),
minimumSecondsBetweenCleanupPasses()
)
schedulers.global().asyncAfter(deadline: .now() + secondsUntilNextCleanupPass) {
let nextRecordToSchedule = self.cleanUpAlreadyExpiredRecords()
if let nextRecordToSchedule {
self.scheduleCleanup(beginningWith: nextRecordToSchedule)
} else {
self.cleanupLock.release()
}
}
}
}
private extension DeletedCallRecord {
private enum Constants {
static let deletedRecordLifetime: TimeInterval = 8 * kHourInterval
}
private var expirationDate: Date {
return Date(millisecondsSince1970: deletedAtTimestamp)
.addingTimeInterval(Constants.deletedRecordLifetime)
}
/// The number of seconds until this record expires.
func secondsUntilExpiration(dateProvider: DateProvider) -> TimeInterval {
return max(0, dateProvider().distance(to: expirationDate))
}
/// Whether this record is already expired.
func isExpired(dateProvider: DateProvider) -> Bool {
return secondsUntilExpiration(dateProvider: dateProvider) == 0
}
}