|
import Combine |
|
import Foundation |
|
import UIKit |
|
|
|
/// Serializes resource-heavy tasks with user-initiated priority tasks ahead of background tasks. |
|
/// Concurrency is limited to 1 execution at a time to prevent overlapping resource-heavy sessions. |
|
/// Isolated to @MainActor to guarantee thread safety of backlog state and sequence ordering. |
|
@MainActor |
|
public final class JobQueue { |
|
/// Represents a single unit of work in the queue. The typed continuation is captured inside `run`, |
|
/// keeping the queue heterogeneous without value-level type erasure. |
|
private struct Job { |
|
/// A unique identifier for the job. |
|
let id: String |
|
/// The execution priority level of the job. |
|
var priority: WorkPriority |
|
/// A monotonically increasing sequence number used for FIFO sorting within the same priority level. |
|
let sequence: UInt64 |
|
/// Executes the work and resumes the caller's typed continuation. |
|
let run: () async -> Void |
|
/// Resumes the caller's continuation with a cancellation error. Used when a duplicate job replaces this one. |
|
let cancel: () -> Void |
|
} |
|
|
|
private let policy: WorkloadPolicy |
|
private var backlog: [Job] = [] |
|
private var nextSequence: UInt64 = 0 |
|
private var drainTask: Task<Void, Never>? |
|
private var backgroundJobsScheduledThisSession = 0 |
|
private var cancellables = Set<AnyCancellable>() |
|
private let jobCompletedSubject = PassthroughSubject<Void, Never>() |
|
|
|
/// Fires after each job finishes, enabling callers to retry deferred background work. |
|
public var jobCompletedPublisher: AnyPublisher<Void, Never> { |
|
jobCompletedSubject.eraseToAnyPublisher() |
|
} |
|
|
|
/// Initializes a new job queue with a workload policy and hooks up foreground session observers. |
|
/// - Parameter policy: The workload policy enforcing thermal/power throttling rules. |
|
public init(policy: WorkloadPolicy) { |
|
self.policy = policy |
|
|
|
// Listen to willEnterForegroundNotification to reset session limits (iOS/iPadOS). |
|
// For macOS applications, adapt or omit this observer. |
|
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) |
|
.receive(on: DispatchQueue.main) |
|
.sink { [weak self] _ in |
|
self?.resetForegroundSession() |
|
} |
|
.store(in: &cancellables) |
|
} |
|
|
|
/// The number of background jobs currently waiting in the queue. |
|
public var pendingBackgroundJobCount: Int { |
|
backlog.filter { $0.priority == .background }.count |
|
} |
|
|
|
/// Checks if a new background task is allowed to be enqueued under current policy parameters. |
|
/// - Returns: `true` if background work is permitted; otherwise, `false`. |
|
public func canEnqueueBackgroundWork() -> Bool { |
|
policy.canEnqueueBackgroundWork( |
|
pendingBackgroundJobs: pendingBackgroundJobCount, |
|
backgroundJobsScheduledThisSession: backgroundJobsScheduledThisSession |
|
) |
|
} |
|
|
|
/// Resets the background session task limit counter. |
|
public func resetForegroundSession() { |
|
backgroundJobsScheduledThisSession = 0 |
|
} |
|
|
|
/// Enqueues a heavy task. Suspends the caller until the job's closure completes execution. |
|
/// This uses continuation closure capturing to support full type safety (`T`) without casting. |
|
/// - Parameters: |
|
/// - jobId: A unique identifier for the job. |
|
/// - priority: The execution priority for the job. |
|
/// - work: An escaping async closure that executes the actual work. |
|
/// - Returns: The resulting generic value `T`. |
|
/// - Throws: An error if the closure throws. |
|
public func enqueue<T>( |
|
jobId: String, |
|
priority: WorkPriority, |
|
work: @escaping () async throws -> T |
|
) async throws -> T { |
|
if priority == .background { |
|
backgroundJobsScheduledThisSession += 1 |
|
} |
|
|
|
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<T, Error>) in |
|
let seq = nextSequence |
|
nextSequence += 1 |
|
let job = Job( |
|
id: jobId, |
|
priority: priority, |
|
sequence: seq, |
|
run: { |
|
do { |
|
continuation.resume(returning: try await work()) |
|
} catch { |
|
continuation.resume(throwing: error) |
|
} |
|
}, |
|
cancel: { continuation.resume(throwing: CancellationError()) } |
|
) |
|
insert(job) |
|
scheduleDrain() |
|
} |
|
} |
|
|
|
/// Upgrades a pending background job's priority to user-initiated so that it jumps ahead of other background tasks. |
|
/// - Parameter jobId: The identifier of the job to promote. |
|
public func promote(jobId: String) { |
|
var changed = false |
|
for idx in backlog.indices where backlog[idx].id == jobId { |
|
if backlog[idx].priority != .userInitiated { |
|
backlog[idx].priority = .userInitiated |
|
changed = true |
|
} |
|
} |
|
if changed { |
|
sortBacklog() |
|
} |
|
} |
|
|
|
// MARK: - Core Queue Management |
|
|
|
private func insert(_ job: Job) { |
|
// Pending job with same id is cancelled and replaced to avoid redundant runs. |
|
if let existingIdx = backlog.firstIndex(where: { $0.id == job.id }) { |
|
let replaced = backlog.remove(at: existingIdx) |
|
replaced.cancel() |
|
} |
|
backlog.append(job) |
|
sortBacklog() |
|
} |
|
|
|
private func sortBacklog() { |
|
backlog.sort { (a, b) in |
|
if a.priority != b.priority { |
|
return a.priority > b.priority |
|
} |
|
return a.sequence < b.sequence |
|
} |
|
} |
|
|
|
private func scheduleDrain() { |
|
guard drainTask == nil else { return } |
|
drainTask = Task { |
|
await drainLoop() |
|
} |
|
} |
|
|
|
private func drainLoop() async { |
|
while !backlog.isEmpty { |
|
let job = backlog.removeFirst() |
|
|
|
// Apply delay based on the workload policy |
|
let delay = policy.interJobDelay( |
|
for: job.priority, |
|
pendingBackgroundJobs: pendingBackgroundJobCount |
|
) |
|
if delay > .zero { |
|
try? await Task.sleep(for: delay) |
|
} |
|
|
|
await job.run() |
|
jobCompletedSubject.send() |
|
} |
|
drainTask = nil |
|
} |
|
} |