A persistent, crash-safe task queue for Swift, built on SwiftData and actors.
Enqueue work that must survive app restarts — uploads, syncs, exports — and QEngineKit runs it with capped concurrency, priority ordering, exponential-backoff retries, per-task timeouts, deduplication, cancellation, and device-aware scheduling (network and power constraints).
iOS 26+ / macOS 26+ / tvOS 26+ / watchOS 26+ / visionOS 26+. Build with Xcode 26 or later.
Swift Package Manager:
.package(url: "https://github.com/ADKANK/QEngineKit.git", from: "1.0.0")Then add the QEngineKit product to your target. Note the import is QEngineKitCore (Swift doesn't allow a type to share its module's name, and the main actor is named QEngineKit).
import QEngineKitCore
import SwiftData
let container = try ModelContainer(for: PersistedTask.self)
let engine = QEngineKit(container: container, maxConcurrency: 3)
// Register a handler for each kind — every launch, before start().
await engine.register(kind: "upload") { payload in
try await uploadService.upload(payload)
}
await engine.start() // recovers persisted tasks, begins executing
let id = await engine.enqueue(kind: "upload", payload: data)Because closures can't be persisted, work is described by a kind string plus a Data payload. Codable payloads are encoded for you:
try await engine.enqueue(kind: "upload", payload: UploadJob(url: url, quality: .high))String-backed enums keep kinds typo-safe:
enum JobKind: String { case upload, sync }
await engine.register(kind: JobKind.upload) { payload in ... }Create one engine for the app's lifetime, register all handlers at launch, then start. Handlers are closures — they can't be persisted, so they must be re-registered on every launch before start(). If a persisted task is picked up and its kind has no handler, it fails.
import QEngineKitCore
import SwiftData
import SwiftUI
@main
struct MyApp: App {
static let container = try! ModelContainer(for: PersistedTask.self)
static let engine = QEngineKit(container: container)
@Environment(\.scenePhase) private var scenePhase
init() {
Task {
let engine = Self.engine
// 1. Register a handler for every kind the app has ever enqueued.
await engine.register(kind: "upload") { payload in
let job = try JSONDecoder().decode(UploadJob.self, from: payload)
try await UploadService.shared.upload(job)
}
await engine.register(kind: "sync", constraints: .network) { _ in
try await SyncService.shared.syncAll()
}
// 2. Optional: gate constrained kinds on real connectivity.
await engine.attachNetworkMonitor()
// 3. Start — recovers persisted tasks from previous launches.
await engine.start()
}
}
var body: some Scene {
WindowGroup { ContentView() }
.onChange(of: scenePhase) { _, phase in
Task {
switch phase {
case .active: await Self.engine.start()
case .background: await Self.engine.stop()
default: break
}
}
}
}
}Enqueue from anywhere in the app:
try await MyApp.engine.enqueue(kind: "upload", payload: UploadJob(url: url), dedupeKey: url.absoluteString)Renaming or removing a kind? Persisted tasks of the old kind may still exist on users' devices — keep a handler registered for the old name (even one that just succeeds as a no-op) for a few releases.
await engine.enqueue(
kind: "upload",
payload: data,
priority: 10, // higher runs first
maxAttempts: 5, // retries with backoff + jitter
runAt: .now.addingTimeInterval(3600), // delayed execution
timeout: 120, // per-attempt time limit
dedupeKey: "photo-\(photoID)" // coalesce duplicate enqueues
)Failures retry with exponential backoff (baseRetryDelay * 2^(attempt-1), capped at maxRetryDelay, ±20% jitter). Throw an error conforming to NonRetryableError to fail immediately — use it for permanent failures like invalid payloads or auth rejections.
Kinds can declare constraints; gated tasks wait as .pending without burning attempts:
await engine.register(kind: "bigUpload", constraints: .unmeteredNetwork) { payload in
try await upload(payload)
}
await engine.attachNetworkMonitor() // NWPathMonitor feeds connectivity changesBuilt-in constraints: .network, .unmeteredNetwork, .externalPower, or compose your own TaskConstraints. Charging state can't be observed portably from a framework — feed it from app code:
await engine.updateEnvironment(QueueEnvironment(isCharging: UIDevice.current.batteryState != .unplugged))Task {
for await event in await engine.events() {
print("\(event.kind) [\(event.id)] → \(event.state)")
}
}Stop on background, restart on foreground:
await engine.stop() // waits for in-flight tasks
await engine.stop(cancellingInFlight: true) // cancels them cooperativelyDrain the backlog inside a BGProcessingTask window:
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.sync", using: nil) { bgTask in
Task {
let finished = await engine.drain(timeout: 25)
bgTask.setTaskCompleted(success: finished)
}
}Execution is at-least-once. If the process dies after a handler succeeds but before the result is persisted, the task runs again on the next start(). Handlers must be idempotent — safe to execute more than once for the same payload. Use server-side idempotency keys (the task's dedupeKey or id is a good candidate) for network side effects.
Cancellation is cooperative: a cancelled running task only stops at its next suspension point (or explicit Task.isCancelled check).
Finished task records are kept until you call purgeFinished() — call it periodically or after draining.
Depend on any TaskQueue instead of the concrete engine and swap in a mock. The library's own suite (25 tests) runs against an in-memory ModelContainer:
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: PersistedTask.self, configurations: config)MIT — see LICENSE.