') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); GitHub - Inventas/DurableJobs: A typed, durable job layer on top of Queuer and GRDB for iOS and macOS. · GitHub
Skip to content

Repository files navigation

DurableJobs

DurableJobs adds a typed, durable job layer on top of Queuer and GRDB. Jobs are encoded as Codable payloads in SQLite, claimed with a lease, and then run through Queuer's in-process executor. The package targets iOS 15 and later and macOS 13 and later.

The delivery contract is at least once. A crash after a handler performs a side effect but before the success transaction commits can run that job again. Handlers must therefore be idempotent, or use the stable JobContext.idempotencyKey with the downstream service. This package does not provide exactly-once side effects.

A typed job

DurableJob contains only the data needed to rebuild a job. Register the handler separately. This keeps closures and service instances out of the persisted payload.

import Foundation
import DurableJobs
import GRDB
structSendInvoice:DurableJob{staticlettypeIdentifier="billing.send-invoice"staticletpayloadVersion=1staticletdefaults=JobDefaults(
queue:"billing",
maxAttempts:4,
retryPolicy:.default,
lane:.processingNetwork
)letinvoiceID:UUID}varregistry=JobRegistry()try registry.register(SendInvoice.self){ job, context in
// Make the service call idempotent with context.idempotencyKey.
tryawait invoiceService.send(
invoiceID: job.invoiceID,
idempotencyKey: context.idempotencyKey
)}letdatabaseURL= appSupportURL.appendingPathComponent("app.sqlite")letdatabase=tryDatabasePool(path: databaseURL.path)letqueue=tryDurableQueue(database: database, registry: registry)letreceipt=tryawait queue.dispatch(SendInvoice(invoiceID: invoiceID),
options:DispatchOptions(idempotencyKey:"invoice:\(invoiceID.uuidString)"))tryawait queue.runDueJobs()letstatus=tryawait queue.status(receipt.id)

DispatchOptions can override queue, priority, delay, availability, deadline, timeout, attempts, retry policy, scheduling requirements, execution lane, a unique active job key, an idempotency key, and inspection tags. uniqueKey suppresses a second queued or running job. It is not a replacement for an idempotency key. Completed rows can be pruned, and a retried side effect still needs an idempotency boundary.

Unique jobs use UniqueJobPolicy.keep by default. Use .replace when the new payload must cancel and supersede the active job. Replacement is durable and fences a running job before the new row is inserted. Handler cancellation is still cooperative, so both payloads must remain idempotent.

Use .append to create a durable dependency on the current unique-work tail. Use .appendOrReplace to append to active work or start a new sequence after a failed or cancelled sequence. dispatchDebounced is delayed .replace work.

Payload changes use payloadVersion and one-step JobPayloadMigration values passed to JobRegistry.register. A stored version newer than the registered handler is rejected. JobMiddleware composes around a handler; the supplied WithoutOverlapping middleware uses a lease-renewed durable lock and releases the job when the lock is busy. RateLimited and ThrottleExceptions keep their counters in SQLite.

The queue exposes status(_:), bounded process-local events, database-backed observe(_:) and observe(matching:), cancel(_:), pause(queue:), resume(queue:), full and lane-specific drains, and retention helpers. A handler can call context.heartbeat(), report progress from 0 through 1, ask for cancellation, release itself for a later attempt, or fail permanently. Drain methods throw when storage, lease maintenance, or cancellation prevents a reliable drain. A recorded handler failure does not make the drain throw because the failure or retry state is already durable.

Shared GRDB database

Use one DatabasePool for the app's records and the durable queue. If the app uses Point-Free SQLiteData, assign this same writer as SQLiteData's default database, then pass it to DurableQueue. DurableJobs creates only its own durable_queue_* tables and runs its migration on that writer.

letdatabase=tryDatabasePool(path: databaseURL.path)prepareDependencies{
$0.defaultDatabase = database // SQLiteData
}letqueue=tryDurableQueue(database: database, registry: registry)

When an application record and its job must be atomic, use the transactional dispatch overload from the application's GRDB write closure:

letreceipt=tryawait database.write{ db intry order.insert(db)returntry queue.dispatch(SendInvoice(invoiceID: order.invoiceID),
in: db
)}

Both writes commit or roll back together. This overload does not emit the process-local dispatched event because the outer transaction can still roll back after the method returns. Persisted status is authoritative.

Inspect and operate jobs

Use a bounded JobQuery to inspect local work by state, queue, job type, or tag. Each field accepts a set. The default result limit is 100 and the maximum is 1,000. Use the last JobInfo.cursor for stable pagination.

letfailedSyncJobs=tryawait queue.jobs(
matching:JobQuery(state:.failed, tag:"sync"))tryawait queue.retry(failedSyncJobs[0].snapshot.id)tryawait queue.forget(oldCompletedJobID)

retry(_:) accepts only failed jobs. It resets execution state but keeps the same job ID, payload, idempotency key, unique key, and tags. forget(_:) accepts only terminal jobs and removes their tags with them. The matching overloads cancel, retry, or forget a full query. dispatchAll inserts independent jobs atomically. health() reports state and queue counts, oldest eligible work, active leases, failure hooks, and next eligible dates.

Recurring work and workflows

Recurring definitions have a stable RecurringScheduleID, an interval, an optional flexible execution window, and either bounded .all catch-up or the default .latest missed-run policy. Scheduling is inexact. Each occurrence is an ordinary job with the normal lease, retry, progress, and inspection rules.

dispatchChain inserts sequential jobs atomically. Later steps start in blocked. Success releases the next step. Failure and cancellation cascade, unless a step uses .runRegardless for cleanup. dispatchBatch inserts parallel jobs, reports aggregate progress, supports group cancellation, and can release one completion job after all members finish. Jobs exchange record IDs; the queue does not pass arbitrary output data between steps.

In-app dashboard

DurableJobsDashboard is an optional SwiftUI product for development and support builds. The core DurableJobs product does not link SwiftUI and does not require the dashboard. Add the dashboard product only to targets that show the tool, then present its root view with the same queue instance used by the application:

#if DEBUGimport DurableJobsDashboard
DurableQueueDashboard(queue: queue)#endif

The dashboard shows current counts, per-queue state, paused queues, hourly completed and failed activity for the last 24 hours, bounded job lists, failure details, and job progress. It refreshes from process-local events and polls the durable database once per second while visible, so it also sees changes made by another DurableQueue instance.

On iPhone, the dashboard uses compact Overview, Jobs, and Queues tabs. On iPad and macOS, it uses an adaptive split view with a sidebar and a wide detail area.

The dashboard can retry failed jobs, cancel queued or running jobs, forget terminal jobs, and pause or resume queues. It asks for confirmation before each operation. It never starts a queue drain by itself.

Stored payload data is not fetched or shown by default. An application can provide an asynchronous formatter for selected jobs. The formatter must decode only known job types and return a redacted summary that is safe to display:

letconfiguration=DurableQueueDashboardConfiguration(
payloadFormatter:{ payload inguard payload.typeIdentifier ==SendInvoice.typeIdentifier else{returnnil}letjob=tryJSONDecoder().decode(SendInvoice.self, from: payload.data)return"Invoice \(job.invoiceID.uuidString)"})DurableQueueDashboard(queue: queue, configuration: configuration)

The raw payload is passed only to this host-supplied formatter after a job is selected. The dashboard does not render, cache, persist, or log the raw bytes. The host application is responsible for removing credentials, personal data, and other secrets from the returned string.

For runnable iOS and macOS examples with completed, failed, delayed, and long-running jobs, see the dashboard sample app.

The SQLiteData integration is an application fixture, not a dependency of this package. For an app whose deployment target is iOS 15, pin SQLiteData to 1.8.2 until a newer release is verified against the app's deployment target and Swift toolchain. Do not add SQLiteData to this package's Package.swift. See the integration fixture.

BackgroundTasks

DurableJobsBackgroundTasks maps the five JobExecutionLane values to five identifiers. Create identifiers from the app's reverse-DNS bundle identifier:

letbridge=BackgroundTaskBridge(
queue: queue,
prefix:Bundle.main.bundleIdentifier!
)letregistered= bridge.registerLaunchHandlers()Task{tryawait queue.runDueJobs()if registered {await bridge.attach(to: queue)}}

Keep the bridge alive for the lifetime of the process. Add all five generated strings to BGTaskSchedulerPermittedIdentifiers in Info.plist, and enable the Background Modes capability needed by the app (Background fetch for the refresh lane and Background processing for processing lanes). See BackgroundTasks setup for the exact identifiers and launch lifecycle.

The iOS 26 continued-processing request is optional and must be guarded with if #available(iOS 26.0, *). The package remains usable on iOS 15; an iOS 27 build must still keep iOS 26 calls availability-gated and should be tested with the iOS 27 SDK. Background scheduling is best effort. It is not a guarantee of immediate execution or a replacement for a background URLSession transfer.

Scope

DurableJobs does not provide a remote broker, distributed workers, exactly-once side effects, arbitrary closure serialization, continuous network reachability, cross-device synchronization, or automatic URLSession transfer management. It also cannot stop a non-cooperative synchronous handler. Those concerns belong to the application or a separate integration.

Further details are in package architecture, typed jobs, reliability semantics, GRDB/SQLiteData setup, and BackgroundTasks, and recurring work and workflows.

About

A typed, durable job layer on top of Queuer and GRDB for iOS and macOS.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages