Signal-iOS/SignalServiceKit/Backups/BackupExportJob/BackupExportJobRunner.swift
2025-09-23 09:16:12 -07:00

194 lines
6.5 KiB
Swift

//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public enum BackupExportJobRunnerUpdate {
case progress(OWSSequentialProgress<BackupExportJobStep>)
case completion(Result<Void, BackupExportJobError>)
}
/// A wrapper around ``BackupExportJob`` that prevents overlapping job runs and
/// tracks progress updates for the currently-running job.
public protocol BackupExportJobRunner {
/// An `AsyncStream` that yields updates on the status of the running Backup
/// export job, if one exists.
///
/// An update will be yielded once with the current status, and again any
/// time a new update is available. A `nil` update indicates that no export
/// job is running.
func updates() -> AsyncStream<BackupExportJobRunnerUpdate?>
/// Cooperatively cancel the running export job, if one exists.
func cancelIfRunning()
/// Run a ``BackupExportJob``, if one is not already running.
///
/// Only one export job is allowed to run at once, so calls to this method
/// will only start new async work if there is no job running. Callers who
/// wish to cancel a running job must use ``cancelIfRunning()``.
///
/// - Note
/// Callers should use ``updates()`` for status notifications about the
/// running job.
///
/// - SeeAlso ``BackupExportJob/exportAndUploadBackup(onProgressUpdate:)``
func startIfNecessary()
}
// MARK: -
class BackupExportJobRunnerImpl: BackupExportJobRunner {
private struct State {
struct UpdateObserver {
let id = UUID()
let block: (BackupExportJobRunnerUpdate?) -> Void
}
var updateObservers: [UpdateObserver] = []
var currentExportJobTask: Task<Void, Never>?
var progressUpdateDebounceUntil: [BackupExportJobStep: MonotonicDate] = [:]
var latestUpdate: BackupExportJobRunnerUpdate? {
didSet {
for observer in updateObservers {
observer.block(latestUpdate)
}
}
}
}
private let backupExportJob: BackupExportJob
private let state: SeriallyAccessedState<State>
init(backupExportJob: BackupExportJob) {
self.backupExportJob = backupExportJob
self.state = SeriallyAccessedState(State())
}
// MARK: -
func updates() -> AsyncStream<BackupExportJobRunnerUpdate?> {
return AsyncStream { continuation in
let observer = addUpdateObserver { update in
continuation.yield(update)
}
continuation.onTermination = { [weak self] reason in
guard let self else { return }
removeUpdateObserver(observer)
}
}
}
private func addUpdateObserver(
block: @escaping (BackupExportJobRunnerUpdate?) -> Void
) -> State.UpdateObserver {
let observer = State.UpdateObserver(block: block)
state.enqueueUpdate { _state in
observer.block(_state.latestUpdate)
_state.updateObservers.append(observer)
}
return observer
}
private func removeUpdateObserver(_ observer: State.UpdateObserver) {
state.enqueueUpdate { _state in
_state.updateObservers.removeAll { $0.id == observer.id }
}
}
// MARK: -
func cancelIfRunning() {
state.enqueueUpdate { _state in
if let currentExportJobTask = _state.currentExportJobTask {
currentExportJobTask.cancel()
}
}
}
// MARK: -
func startIfNecessary() {
state.enqueueUpdate { [self] _state in
if _state.currentExportJobTask != nil {
return
}
_state.currentExportJobTask = Task { () async -> Void in
let result: Result<Void, BackupExportJobError>
do throws(BackupExportJobError) {
try await backupExportJob.exportAndUploadBackup(
mode: .manual(OWSSequentialProgress<BackupExportJobStep>
.createSink { [weak self] exportJobProgress in
self?.exportJobDidUpdateProgress(exportJobProgress)
}
)
)
result = .success(())
} catch {
result = .failure(error)
}
exportJobDidComplete(result: result)
}
}
}
private func exportJobDidUpdateProgress(_ exportJobProgress: OWSSequentialProgress<BackupExportJobStep>) {
self.state.enqueueUpdate { _state in
guard _state.currentExportJobTask != nil else {
// Our running job completed before this progress update was
// emitted, so ignore this late update.
return
}
let currentStep = exportJobProgress.currentStep
let now = MonotonicDate()
let shortDebounceUntil = now.adding(0.1)
let longDebounceUntil = now.adding(0.5)
// If this is our first update, publish immediately.
if _state.progressUpdateDebounceUntil.isEmpty {
_state.progressUpdateDebounceUntil[currentStep] = shortDebounceUntil
_state.latestUpdate = .progress(exportJobProgress)
return
}
if let debounceUntil = _state.progressUpdateDebounceUntil[currentStep] {
if now > debounceUntil {
_state.progressUpdateDebounceUntil[currentStep] = shortDebounceUntil
_state.latestUpdate = .progress(exportJobProgress)
} else {
// We're debounced: ignore this update.
}
} else {
// Skip updates for a longer debounce the first time we start a
// new step. This helps prevent short-lived steps from flashing
// on and then off screen.
_state.progressUpdateDebounceUntil[currentStep] = longDebounceUntil
}
}
}
private func exportJobDidComplete(result: Result<Void, BackupExportJobError>) {
self.state.enqueueUpdate { _state in
_state.currentExportJobTask = nil
// Reset all debounces.
_state.progressUpdateDebounceUntil = [:]
// Push through the completion update...
_state.latestUpdate = .completion(result)
// ...then reset back to empty.
_state.latestUpdate = nil
}
}
}