// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import LibSignalClient import SignalCoreKit /// Manages durable jobs for deleting all ``CallRecord``s. /// /// This is a special action distinct from deleting a single ``CallRecord``, or /// even multiple discrete ``CallRecord``s, which is handled by the /// ``CallRecordDeleteManager``. This type is specifically for "delete them all, /// in bulk". /// /// - SeeAlso ``CallRecordDeleteManager`` public class CallRecordDeleteAllJobQueue { public enum DeleteAllBeforeOptions { case callRecord(CallRecord) case timestamp(UInt64) } private let jobRunnerFactory: CallRecordDeleteAllJobRunnerFactory private let jobQueueRunner: JobQueueRunner< JobRecordFinderImpl, CallRecordDeleteAllJobRunnerFactory > private let callRecordConversationIdAdapter: CallRecordSyncMessageConversationIdAdapter public init( callRecordConversationIdAdapter: CallRecordSyncMessageConversationIdAdapter, callRecordQuerier: CallRecordQuerier, db: DB, interactionDeleteManager: InteractionDeleteManager, messageSenderJobQueue: MessageSenderJobQueue ) { self.jobRunnerFactory = CallRecordDeleteAllJobRunnerFactory( callRecordConversationIdAdapter: callRecordConversationIdAdapter, callRecordQuerier: callRecordQuerier, db: db, interactionDeleteManager: interactionDeleteManager, messageSenderJobQueue: messageSenderJobQueue ) self.jobQueueRunner = JobQueueRunner( canExecuteJobsConcurrently: false, db: db, jobFinder: JobRecordFinderImpl(db: db), jobRunnerFactory: self.jobRunnerFactory ) self.callRecordConversationIdAdapter = callRecordConversationIdAdapter } func start(appContext: AppContext) { jobQueueRunner.start(shouldRestartExistingJobs: appContext.isMainApp) } /// Add a "delete all call records" job to the queue. /// /// - Parameter sendDeleteAllSyncMessage /// Whether we should send an ``OutgoingCallLogEventSyncMessage`` about this /// deletion. /// - Parameter deleteAllBeforeTimestamp /// The timestamp before which to delete all call records. public func addJob( sendDeleteAllSyncMessage: Bool, deleteAllBefore: DeleteAllBeforeOptions, tx: SDSAnyWriteTransaction ) { let jobRecord: CallRecordDeleteAllJobRecord switch deleteAllBefore { case .callRecord(let callRecord): guard let conversationId: CallRecordDeleteAllJobRecord.ConversationId = callRecordConversationIdAdapter .getConversationId(callRecord: callRecord, tx: tx.asV2Read) else { return } jobRecord = CallRecordDeleteAllJobRecord( sendDeleteAllSyncMessage: sendDeleteAllSyncMessage, deleteAllBeforeCallId: callRecord.callId, deleteAllBeforeConversationId: conversationId, deleteAllBeforeTimestamp: callRecord.callBeganTimestamp ) case .timestamp(let timestamp): jobRecord = CallRecordDeleteAllJobRecord( sendDeleteAllSyncMessage: sendDeleteAllSyncMessage, deleteAllBeforeCallId: nil, deleteAllBeforeConversationId: nil, deleteAllBeforeTimestamp: timestamp ) } jobRecord.anyInsert(transaction: tx) tx.addSyncCompletion { self.jobQueueRunner.addPersistedJob(jobRecord) } } } // MARK: - private class CallRecordDeleteAllJobRunner: JobRunner { typealias JobRecordType = CallRecordDeleteAllJobRecord private enum Constants { static let maxRetries: UInt = 110 static let deletionBatchSize: UInt = 500 } private var logger: CallRecordLogger { .shared } private let callRecordConversationIdAdapter: CallRecordSyncMessageConversationIdAdapter private let callRecordQuerier: CallRecordQuerier private let db: DB private let interactionDeleteManager: InteractionDeleteManager private let messageSenderJobQueue: MessageSenderJobQueue init( callRecordConversationIdAdapter: CallRecordSyncMessageConversationIdAdapter, callRecordQuerier: CallRecordQuerier, db: DB, interactionDeleteManager: InteractionDeleteManager, messageSenderJobQueue: MessageSenderJobQueue ) { self.callRecordConversationIdAdapter = callRecordConversationIdAdapter self.callRecordQuerier = callRecordQuerier self.db = db self.interactionDeleteManager = interactionDeleteManager self.messageSenderJobQueue = messageSenderJobQueue } // MARK: - func runJobAttempt( _ jobRecord: CallRecordDeleteAllJobRecord ) async -> JobAttemptResult { return await JobAttemptResult.executeBlockWithDefaultErrorHandler( jobRecord: jobRecord, retryLimit: Constants.maxRetries, db: db, block: { try await _runJobAttempt(jobRecord) } ) } func didFinishJob( _ jobRecordId: JobRecord.RowId, result: JobResult ) async { switch result.ranSuccessfullyOrError { case .success: break case .failure(let failure): logger.error("Failed to delete all call records! \(failure)") } } private func _runJobAttempt( _ jobRecord: CallRecordDeleteAllJobRecord ) async throws { let deleteBeforeTimestamp: UInt64 = { /// We'll prefer the timestamp on the call record if we have it. /// They should be identical in the 99.999% case, but there's a /// chance something updated the call's timestamp since this job /// record was created; and either way the goal is to use an actual /// call as the boundary for the delete-all rather than an arbitrary /// timestamp if possible. guard let callId = jobRecord.deleteAllBeforeCallId, let conversationId = jobRecord.deleteAllBeforeConversationId, let referencedCallRecord: CallRecord = db.read(block: { tx -> CallRecord? in return callRecordConversationIdAdapter.hydrate( callId: callId, conversationId: conversationId, tx: tx ) }) else { return jobRecord.deleteAllBeforeTimestamp } return referencedCallRecord.callBeganTimestamp }() logger.info("Attempting to delete all call records before \(deleteBeforeTimestamp).") let deletedCount = await TimeGatedBatch.processAllAsync(db: db) { tx in return self.deleteSomeCallRecords( beforeTimestamp: deleteBeforeTimestamp, tx: tx ) } logger.info("Deleted \(deletedCount) calls.") await db.awaitableWrite { tx in let sdsTx: SDSAnyWriteTransaction = SDSDB.shimOnlyBridge(tx) if jobRecord.sendDeleteAllSyncMessage { self.logger.info("Sending delete-all-calls sync message.") self.sendClearCallLogSyncMessage( callId: jobRecord.deleteAllBeforeCallId, conversationId: jobRecord.deleteAllBeforeConversationId, beforeTimestamp: deleteBeforeTimestamp, tx: sdsTx ) } jobRecord.anyRemove(transaction: sdsTx) } } /// Deletes a batch of call records with timestamps before the given value. /// /// - Returns /// The number of call records deleted. A return value of 0 indicates /// deletion has finished, either because there are no more records to /// delete or because this method ran into an unexpected, unrecoverable /// error. private func deleteSomeCallRecords( beforeTimestamp: UInt64, tx: DBWriteTransaction ) -> Int { /// The passed timestamp will be the timestamp of the most-recent call /// when the user initiated the delete-all action. So as to ensure we /// delete that most-recent call, we'll shim the timestamp forward. let beforeTimestamp = beforeTimestamp + 1 guard let cursor = callRecordQuerier.fetchCursor( ordering: .descendingBefore(timestamp: beforeTimestamp), tx: tx ) else { return 0 } do { let callRecordsToDelete = try cursor.drain( maxResults: Constants.deletionBatchSize ) if !callRecordsToDelete.isEmpty { /// Delete the call records and their associated interactions. /// Disable sending a sync message here, since we're instead /// going to send a different sync message when we're done /// deleting all the records. interactionDeleteManager.delete( alongsideAssociatedCallRecords: callRecordsToDelete, sideEffects: .custom(associatedCallDelete: .localDeleteOnly), tx: tx ) return callRecordsToDelete.count } } catch let error { owsFailBeta("Failed to get call records from cursor! \(error)") } return 0 } private func sendClearCallLogSyncMessage( callId: UInt64?, conversationId: CallRecordDeleteAllJobRecord.ConversationId?, beforeTimestamp: UInt64, tx: SDSAnyWriteTransaction ) { guard let localThread = TSContactThread.getOrCreateLocalThread( transaction: tx ) else { return } let outgoingCallLogEventSyncMessage = OutgoingCallLogEventSyncMessage( callLogEvent: OutgoingCallLogEventSyncMessage.CallLogEvent( eventType: .cleared, callId: callId, conversationId: conversationId, timestamp: beforeTimestamp ), thread: localThread, tx: tx ) let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: outgoingCallLogEventSyncMessage ) messageSenderJobQueue.add( message: preparedMessage, transaction: tx ) } } // MARK: - private class CallRecordDeleteAllJobRunnerFactory: JobRunnerFactory { typealias JobRunnerType = CallRecordDeleteAllJobRunner private let callRecordConversationIdAdapter: CallRecordSyncMessageConversationIdAdapter private let callRecordQuerier: CallRecordQuerier private let db: DB private let interactionDeleteManager: InteractionDeleteManager private let messageSenderJobQueue: MessageSenderJobQueue init( callRecordConversationIdAdapter: CallRecordSyncMessageConversationIdAdapter, callRecordQuerier: CallRecordQuerier, db: DB, interactionDeleteManager: InteractionDeleteManager, messageSenderJobQueue: MessageSenderJobQueue ) { self.callRecordConversationIdAdapter = callRecordConversationIdAdapter self.callRecordQuerier = callRecordQuerier self.db = db self.interactionDeleteManager = interactionDeleteManager self.messageSenderJobQueue = messageSenderJobQueue } func buildRunner() -> CallRecordDeleteAllJobRunner { return CallRecordDeleteAllJobRunner( callRecordConversationIdAdapter: callRecordConversationIdAdapter, callRecordQuerier: callRecordQuerier, db: db, interactionDeleteManager: interactionDeleteManager, messageSenderJobQueue: messageSenderJobQueue ) } }