Skip to content

Commit b6b288a

Browse files
committed
feat: Implement JobManager behavioral pattern
1 parent 22eeca4 commit b6b288a

1 file changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
//
2+
// JobManager.swift
3+
// DesignAlgorithmsKit
4+
//
5+
// Job Manager Pattern - Orchestration of asynchronous long-running tasks
6+
//
7+
8+
import Foundation
9+
10+
/// Status of a job managed by JobManager
11+
public enum JobStatus: String, Sendable, Codable {
12+
/// Job is queued but not started
13+
case pending
14+
/// Job is currently executing
15+
case running
16+
/// Job completed successfully
17+
case completed
18+
/// Job failed with an error
19+
case failed
20+
}
21+
22+
/// Generic container for job result data
23+
/// In a real app, you might use AnyCodable, but for this pattern we use Any & Sendable.
24+
public typealias JobOutput = Any & Sendable
25+
26+
/// Information about a specific job
27+
public struct JobSnapshot: Sendable {
28+
/// Unique identifier
29+
public let id: UUID
30+
/// Human-readable description
31+
public let description: String
32+
/// Current status
33+
public let status: JobStatus
34+
/// Progress (0.0 - 1.0)
35+
public let progress: Double
36+
/// Result if completed
37+
public let result: JobOutput?
38+
/// Error message if failed
39+
public let errorMessage: String?
40+
41+
public init(
42+
id: UUID,
43+
description: String,
44+
status: JobStatus,
45+
progress: Double,
46+
result: JobOutput? = nil,
47+
errorMessage: String? = nil
48+
) {
49+
self.id = id
50+
self.description = description
51+
self.status = status
52+
self.progress = progress
53+
self.result = result
54+
self.errorMessage = errorMessage
55+
}
56+
}
57+
58+
/// Delegate protocol for receiving job updates
59+
public protocol JobManagerDelegate: Sendable {
60+
func jobManager(_ manager: JobManager, didUpdateJob job: JobSnapshot)
61+
}
62+
63+
/// A manager that orchestrates the execution of asynchronous background jobs.
64+
///
65+
/// The JobManager provides a central place to submit, track, and retrieve results
66+
/// from long-running asynchronous tasks. It ensures thread safety via Actor isolation
67+
/// and prevents system overload by limiting concurrent execution.
68+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
69+
public actor JobManager {
70+
71+
// MARK: - State
72+
73+
private struct JobState {
74+
let id: UUID
75+
let description: String
76+
var status: JobStatus
77+
var progress: Double
78+
var result: JobOutput?
79+
var error: String?
80+
}
81+
82+
private var jobs: [UUID: JobState] = [:]
83+
private var delegate: JobManagerDelegate?
84+
85+
// MARK: - Configuration
86+
87+
/// Maximum number of concurrent jobs
88+
public let maxConcurrentJobs: Int
89+
90+
// Semaphores are not Sendable or actor-safe in the same way.
91+
// We should use a counter or a TaskGroup pattern.
92+
// For simplicity in this pattern, we will just run them as they come (unbounded)
93+
// OR we should implement a queue.
94+
// Given the previous Queue implementation, reusing that logic is complex here.
95+
// We'll trust the underlying Task scheduler for now or implement a simple active counter check.
96+
97+
private var activeJobCount = 0
98+
private var pendingJobs: [(UUID, @Sendable () async throws -> JobOutput)] = []
99+
100+
// MARK: - Init
101+
102+
public init(maxConcurrentJobs: Int = 4, delegate: JobManagerDelegate? = nil) {
103+
self.maxConcurrentJobs = max(1, maxConcurrentJobs)
104+
self.delegate = delegate
105+
}
106+
107+
// MARK: - Public API
108+
109+
/// Set a delegate to receive updates
110+
public func setDelegate(_ delegate: JobManagerDelegate?) {
111+
self.delegate = delegate
112+
}
113+
114+
/// Start a new job
115+
/// - Parameters:
116+
/// - description: Description of the job
117+
/// - operation: The async operation to perform
118+
/// - Returns: The UUID of the newly created job
119+
public func submit(
120+
description: String,
121+
operation: @escaping @Sendable () async throws -> JobOutput
122+
) -> UUID {
123+
let id = UUID()
124+
let state = JobState(
125+
id: id,
126+
description: description,
127+
status: .pending,
128+
progress: 0.0,
129+
result: nil,
130+
error: nil
131+
)
132+
jobs[id] = state
133+
notifyDelegate(for: id)
134+
135+
pendingJobs.append((id, operation))
136+
processNext()
137+
138+
return id
139+
}
140+
141+
/// Retrieve the current snapshot of a job
142+
public func getJob(id: UUID) -> JobSnapshot? {
143+
guard let state = jobs[id] else { return nil }
144+
return JobSnapshot(
145+
id: state.id,
146+
description: state.description,
147+
status: state.status,
148+
progress: state.progress,
149+
result: state.result,
150+
errorMessage: state.error
151+
)
152+
}
153+
154+
/// Cancel a job (Not fully implemented in this basic pattern without Task storage)
155+
public func cancel(id: UUID) {
156+
// In a full implementation, we would store the Task handle and cancel it.
157+
// For this pattern demo, we simply mark as failed if pending.
158+
if var state = jobs[id], state.status == .pending {
159+
state.status = .failed
160+
state.error = "Cancelled"
161+
jobs[id] = state
162+
notifyDelegate(for: id)
163+
}
164+
}
165+
166+
// MARK: - Validations
167+
168+
/// List all job IDs
169+
public var allJobIDs: [UUID] {
170+
return Array(jobs.keys)
171+
}
172+
173+
// MARK: - Internal Execution
174+
175+
private func processNext() {
176+
guard activeJobCount < maxConcurrentJobs else { return }
177+
guard !pendingJobs.isEmpty else { return }
178+
179+
let (id, operation) = pendingJobs.removeFirst()
180+
181+
// Start Job
182+
activeJobCount += 1
183+
updateJob(id: id, status: .running)
184+
185+
Task {
186+
await execute(id: id, operation: operation)
187+
}
188+
189+
// Try to start more if capacity allows
190+
processNext()
191+
}
192+
193+
private func execute(id: UUID, operation: @escaping @Sendable () async throws -> JobOutput) async {
194+
do {
195+
let result = try await operation()
196+
await complete(id: id, result: result)
197+
} catch {
198+
await fail(id: id, error: error)
199+
}
200+
}
201+
202+
private func complete(id: UUID, result: JobOutput) {
203+
activeJobCount -= 1
204+
205+
if var state = jobs[id] {
206+
state.status = .completed
207+
state.progress = 1.0
208+
state.result = result
209+
jobs[id] = state
210+
notifyDelegate(for: id)
211+
}
212+
213+
// Process next in queue
214+
processNext()
215+
}
216+
217+
private func fail(id: UUID, error: Error) {
218+
activeJobCount -= 1
219+
220+
if var state = jobs[id] {
221+
state.status = .failed
222+
state.error = String(describing: error)
223+
jobs[id] = state
224+
notifyDelegate(for: id)
225+
}
226+
227+
// Process next in queue
228+
processNext()
229+
}
230+
231+
private func updateJob(id: UUID, status: JobStatus) {
232+
if var state = jobs[id] {
233+
state.status = status
234+
jobs[id] = state
235+
notifyDelegate(for: id)
236+
}
237+
}
238+
239+
private func notifyDelegate(for id: UUID) {
240+
guard let delegate = delegate, let snapshot = getJob(id: id) else { return }
241+
// We are in an actor, calling a delegate which is Sendable.
242+
// The delegate method is synchronous but we can call it on a separated Task or assume it's fast.
243+
// However, if the delegate is an actor isolated instance, we generally should await.
244+
// But `JobManagerDelegate` defines a synchronous func.
245+
// Let's assume the delegate handles thread safety internally or is just a listener.
246+
delegate.jobManager(self, didUpdateJob: snapshot)
247+
}
248+
}

0 commit comments

Comments
 (0)