Skip to content

Repository files navigation

QEngineKit

Tests

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).

Requirements

iOS 26+ / macOS 26+ / tvOS 26+ / watchOS 26+ / visionOS 26+. Build with Xcode 26 or later.

Installation

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).

Quick start

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 ... }

Setting up in your app

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.

Scheduling options

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.

Device-aware scheduling

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 changes

Built-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))

Observing progress

Task {
    for await event in await engine.events() {
        print("\(event.kind) [\(event.id)] → \(event.state)")
    }
}

App lifecycle & background execution

Stop on background, restart on foreground:

await engine.stop()                          // waits for in-flight tasks
await engine.stop(cancellingInFlight: true)  // cancels them cooperatively

Drain 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)
    }
}

Delivery semantics — read this

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.

Testing your code

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)

License

MIT — see LICENSE.

About

A persistent, crash-safe task queue for Swift, built on SwiftData and actors.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages