Concurrency model
Categories:
Concurrency Model — RawCull
Branch:
version-1.1.0
Files covered:
RawCull/Model/ViewModels/RawCullViewModel.swiftRawCull/Actors/ScanAndCreateThumbnails.swiftRawCull/Actors/ExtractAndSaveJPGs.swiftRawCull/Views/RawCullView/extension+RawCullView.swift
Overview
See the Synchronous Code post for handling heavy synchronous work in Swift Concurrency.
RawCull uses Swift Structured Concurrency (async/await, Task, TaskGroup, and actor) throughout its two main background operations:
| Operation | Actor | Triggered from |
|---|---|---|
| Scan & create thumbnails | ScanAndCreateThumbnails | RawCullViewModel.handleSourceChange(url:) |
| Extract & save JPGs | ExtractAndSaveJPGs | extension+RawCullView.extractAllJPGS() |
Both operations follow the same two-level task pattern: an outer Task owned by the ViewModel/View layer, and an inner Task owned by the actor itself. Cancellation is explicit and must be propagated through both levels.
1. ScanAndCreateThumbnails
1.1 How the task is started
handleSourceChange(url:) in RawCullViewModel is the entry point. It is an async function that runs on @MainActor.
RawCullViewModel.handleSourceChange(url:) ← @MainActor async
Step-by-step flow:
Guard against duplicate processing — A
processedURLs: Set<URL>set prevents re-scanning a catalog URL that has already been processed in the current session. If the URL is already in the set, the thumbnail creation block is skipped entirely.Settings fetch — Before creating the actor, settings are fetched via
await SettingsViewModel.shared.asyncgetsettings(). This provides thethumbnailSizePreviewvalue used as the rendering target size.FileHandlers are built —
CreateFileHandlers().createFileHandlers(...)bundles three@MainActor-bound closures:fileHandler(_:)— updatesprogressmaxfilesHandler(_:)— setsmaxestimatedTimeHandler(_:)— setsestimatedSeconds
Actor instantiation — A fresh
ScanAndCreateThumbnails()actor is created and the handlers are injected viaawait actor.setFileHandlers(handlers).Actor reference is stored —
currentPreloadActor = actoris assigned on@MainActorbefore the outer Task is launched. This is the handle used later byabort().Outer Task is created and stored:
preloadTask = Task { await actor.preloadCatalog(at: url, targetSize: thumbnailSizePreview) }This is an unstructured
Taskwith no explicit actor context, created while on@MainActor. It immediately hops to theScanAndCreateThumbnailsactor when it callsawait actor.preloadCatalog(...).ViewModel awaits completion:
await preloadTask?.value creatingthumbnails = falseThe
handleSourceChangefunction suspends here. When the outer Task finishes — either by completing normally or by being cancelled — execution resumes andcreatingthumbnailsis set tofalse.
1.2 Inside the actor — preloadCatalog
preloadCatalog(at:targetSize:) runs on the ScanAndCreateThumbnails actor.
Step-by-step:
Ensure setup is complete —
await ensureReady()is called first. This uses asetupTask: Task<Void, Never>?pattern to guarantee thatSharedMemoryCache.shared.ensureReady()andgetSettings()are run exactly once, even ifpreloadCatalogis called concurrently.Cancel any prior inner task —
cancelPreload()is called immediately, which cancels and nils out any previously storedpreloadTaskon the actor.Create the inner Task:
let task = Task<Int, Never> { // reset counters successCount = 0 processingTimes = [] lastItemTime = nil lastEstimatedSeconds = nil let urls = await DiscoverFiles().discoverFiles(at: catalogURL, recursive: false) totalFilesToProcess = urls.count await fileHandlers?.maxfilesHandler(urls.count) return await withTaskGroup(of: Void.self) { group in ... } } preloadTask = task // stored as actor-isolated state return await task.value // actor suspends hereThis inner
Task<Int, Never>runs on the actor’s context. All mutations to actor-isolated state (successCount,processingTimes,cacheMemory, etc.) happen safely because every child task calls back into the actor viaawait self.processSingleFile(...).Controlled concurrency with TaskGroup:
let maxConcurrent = ProcessInfo.processInfo.activeProcessorCount * 2 for (index, url) in urls.enumerated() { if Task.isCancelled { group.cancelAll() break } if index >= maxConcurrent { await group.next() // back-pressure: wait for a slot } group.addTask { await self.processSingleFile(url, targetSize: targetSize, itemIndex: index) } } await group.waitForAll() return successCountThe loop checks
Task.isCancelledat the start of every iteration. If cancelled,group.cancelAll()stops any in-flight child tasks and the loop breaks.await group.next()provides back-pressure so no more thanactiveProcessorCount * 2tasks are in flight at once.Per-file processing —
processSingleFile(_:targetSize:itemIndex:)performs multipleTask.isCancelledchecks at key suspension points:- At function entry
- After the RAM cache check
- Before the Sony thumbnail extraction
- After the expensive
SonyThumbnailExtractor.extractSonyThumbnail(...)call (the most critical check — prevents writing stale data after cancellation)
Cache resolution follows a three-tier lookup:
- A. RAM cache (
SharedMemoryCache.shared) — synchronous, thread-safe viaNSCacheinternal locking - B. Disk cache (
DiskCacheManager.load(for:)) — async - C. Extract from source file — calls
SonyThumbnailExtractor.extractSonyThumbnail(...), then normalises to JPEG-backedNSImage, stores in RAM, and fires aTask.detached(priority: .background)to persist to disk
2. ExtractAndSaveJPGs
2.1 How the task is started
ExtractAndSaveJPGs is triggered from extractAllJPGS() in extension+RawCullView.swift. This function is called from the View layer and is not an async function itself — it creates an unstructured Task to bridge into async code.
View (extension+RawCullView) — extractAllJPGS()
└─ Task { ← outer Task, unstructured, inherits @MainActor
viewModel.creatingthumbnails = true
...
viewModel.currentExtractActor = extract ← stored on ViewModel for cancellation
await extract.extractAndSaveAlljpgs(from: url)
viewModel.currentExtractActor = nil ← cleaned up after completion
viewModel.creatingthumbnails = false
}
Step-by-step flow:
Set UI state —
viewModel.creatingthumbnails = trueis set immediately (on@MainActor).FileHandlers are built — Same pattern as
ScanAndCreateThumbnails: closures forfileHandler,maxfilesHandler, andestimatedTimeHandlerare assembled viaCreateFileHandlers().createFileHandlers(...).Actor instantiation — A fresh
ExtractAndSaveJPGs()actor is created and handlers are injected viaawait extract.setFileHandlers(handlers).Actor reference is stored —
viewModel.currentExtractActor = extractis assigned before the work begins. This is the handle required forabort()to cancel the operation.Outer Task awaits the actor:
await extract.extractAndSaveAlljpgs(from: url)The outer Task suspends here until the extraction completes or is cancelled.
Cleanup — After the call returns (normally or via cancellation),
viewModel.currentExtractActor = nilandviewModel.creatingthumbnails = falseare set.
Note: Unlike
ScanAndCreateThumbnails, the outerTaskhandle forExtractAndSaveJPGsis not stored on the ViewModel (preloadTaskis only used for thumbnails). Cancellation of the outer task therefore relies solely onabort()callingactor.cancelExtractJPGSTask().
2.2 Inside the actor — extractAndSaveAlljpgs
extractAndSaveAlljpgs(from:) runs on the ExtractAndSaveJPGs actor.
Step-by-step:
Cancel any prior inner task —
cancelExtractJPGSTask()is called first, which cancels and nils out any existingextractJPEGSTask. This is the same defensive pattern asScanAndCreateThumbnails.Create the inner Task:
let task = Task { successCount = 0 processingTimes = [] let urls = await DiscoverFiles().discoverFiles(at: catalogURL, recursive: false) totalFilesToProcess = urls.count await fileHandlers?.maxfilesHandler(urls.count) return await withThrowingTaskGroup(of: Void.self) { group in ... } } extractJPEGSTask = task // stored as actor-isolated state return await task.value // actor suspends hereNote:
ExtractAndSaveJPGsuseswithThrowingTaskGroup(vs.withTaskGroupinScanAndCreateThumbnails). Errors from child tasks are silently consumed viatry?.Controlled concurrency with ThrowingTaskGroup:
let maxConcurrent = ProcessInfo.processInfo.activeProcessorCount * 2 for (index, url) in urls.enumerated() { if Task.isCancelled { group.cancelAll() break } if index >= maxConcurrent { try? await group.next() // back-pressure } group.addTask { await self.processSingleExtraction(url, itemIndex: index) } } try? await group.waitForAll() return successCountIdentical back-pressure and cancellation-check pattern to
ScanAndCreateThumbnails.Per-file processing —
processSingleExtraction(_:itemIndex:)checksTask.isCancelledat two points:- At function entry
- After
EmbeddedPreviewExtractor.extractEmbeddedPreview(from:)returns (the critical check — prevents writing a JPG to disk after cancellation)
If not cancelled, it calls
await SaveJPGImage().save(image:originalURL:)and then updates progress and ETA.
3. Task Ownership and Lifecycle Summary
┌─────────────────────────────────────────┐
│ RawCullViewModel (@MainActor) │
│ │
│ currentPreloadActor: ScanAnd...? │
│ currentExtractActor: ExtractAnd...? │
│ preloadTask: Task<Void, Never>? │
└─────────────────────────────────────────┘
│ │
┌────────────────────┘ └────────────────────┐
▼ ▼
┌─────────────────────────┐ ┌──────────────────────────┐
│ ScanAndCreateThumbnails│ │ ExtractAndSaveJPGs │
│ (actor) │ │ (actor) │
│ │ │ │
│ preloadTask: │ │ extractJPEGSTask: │
│ Task<Int, Never>? │ │ Task<Int, Never>? │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ withTaskGroup { │ │ withThrowingTaskGroup { │
│ processSingleFile() │ │ processSingleExtract() │
│ processSingleFile() │ │ processSingleExtract() │
│ ... │ │ ... │
│ } │ │ } │
└─────────────────────────┘ └──────────────────────────┘
| Layer | Owner | Handle name | Type |
|---|---|---|---|
| Outer Task (thumbnails) | RawCullViewModel | preloadTask | Task<Void, Never>? |
| Inner Task (thumbnails) | ScanAndCreateThumbnails | preloadTask | Task<Int, Never>? |
| Outer Task (JPG extract) | View (extractAllJPGS) | (not stored) | Task<Void, Never> (fire-and-store pattern) |
| Inner Task (JPG extract) | ExtractAndSaveJPGs | extractJPEGSTask | Task<Int, Never>? |
4. Cancellation
4.1 abort() — the single cancellation entry point
abort() is a synchronous function on RawCullViewModel (@MainActor). It is the single point for cancelling both operations simultaneously.
func abort() {
// --- ScanAndCreateThumbnails ---
preloadTask?.cancel() // (1) cancel the outer Task
preloadTask = nil
if let actor = currentPreloadActor {
Task { await actor.cancelPreload() } // (2) cancel the inner Task
}
currentPreloadActor = nil
// --- ExtractAndSaveJPGs ---
if let actor = currentExtractActor {
Task { await actor.cancelExtractJPGSTask() } // (3) cancel the inner Task
}
currentExtractActor = nil
creatingthumbnails = false // (4) reset UI state
}
4.2 Cancellation of ScanAndCreateThumbnails — detailed propagation
abort()
│
├─ (1) preloadTask?.cancel()
│ └─ The outer Task<Void, Never> created in handleSourceChange is marked cancelled.
│ Because handleSourceChange is awaiting preloadTask?.value, it unblocks
│ and execution resumes — but the outer task's closure body does NOT re-run;
│ the await returns with cancellation.
│
└─ (2) Task { await actor.cancelPreload() }
└─ cancelPreload() runs on the ScanAndCreateThumbnails actor:
preloadTask?.cancel() ← cancels the INNER Task<Int, Never>
preloadTask = nil
└─ The inner Task's isCancelled flag becomes true.
└─ withTaskGroup sees isCancelled == true on next loop iteration:
group.cancelAll() ← propagates to all child tasks
break ← stops adding new tasks
└─ In-flight processSingleFile() calls check Task.isCancelled
at multiple suspension points and return early.
└─ group.waitForAll() completes once all children exit.
└─ inner Task returns (with partial successCount).
└─ preloadCatalog returns.
└─ outer Task body completes (returns Void).
Key detail: Calling preloadTask?.cancel() on the ViewModel’s outer Task<Void, Never> does not automatically cancel the inner Task<Int, Never> inside the actor. The outer task wraps a call to actor.preloadCatalog(...) — cancelling the outer task sets its isCancelled flag but the actor’s inner task is completely separate and continues running unless explicitly cancelled. This is why actor.cancelPreload() must also be called.
4.3 Cancellation of ExtractAndSaveJPGs — detailed propagation
abort()
│
└─ (3) Task { await actor.cancelExtractJPGSTask() }
└─ cancelExtractJPGSTask() runs on the ExtractAndSaveJPGs actor:
extractJPEGSTask?.cancel() ← cancels the INNER Task<Int, Never>
extractJPEGSTask = nil
└─ The inner Task's isCancelled flag becomes true.
└─ withThrowingTaskGroup sees isCancelled == true on next loop iteration:
group.cancelAll() ← propagates to all child tasks
break ← stops adding new tasks
└─ In-flight processSingleExtraction() calls check Task.isCancelled
at two suspension points and return early.
└─ try? await group.waitForAll() completes.
└─ inner Task returns (with partial successCount).
└─ outer Task in extractAllJPGS() unblocks:
viewModel.currentExtractActor = nil
viewModel.creatingthumbnails = false
Key detail: Because ExtractAndSaveJPGs does not have an outer Task handle stored on the ViewModel (unlike ScanAndCreateThumbnails which stores preloadTask), there is no outer task to .cancel() for this flow. Only the inner task cancel path applies. The outer Task in extractAllJPGS() will naturally complete once extractAndSaveAlljpgs returns after the inner task is cancelled.
4.4 What happens at each isCancelled check point
ScanAndCreateThumbnails — processSingleFile
| Check point | What happens on cancellation |
|---|---|
Entry to processSingleFile | Returns immediately — skips all cache lookups and I/O |
| After RAM cache lookup (before disk check) | Returns immediately — skips disk and extract |
Before SonyThumbnailExtractor.extractSonyThumbnail(...) | Returns immediately — skips the expensive extraction |
After extractSonyThumbnail returns | Returns immediately — discards the just-extracted image, does not store in cache or write to disk |
ExtractAndSaveJPGs — processSingleExtraction
| Check point | What happens on cancellation |
|---|---|
Entry to processSingleExtraction | Returns immediately — skips the embedded preview extraction |
After EmbeddedPreviewExtractor.extractEmbeddedPreview(...) returns | Returns immediately — discards the extracted image, does not call SaveJPGImage().save(...) |
4.5 State reset after cancellation
After abort() completes:
| ViewModel property | State |
|---|---|
preloadTask | nil |
currentPreloadActor | nil |
currentExtractActor | nil |
creatingthumbnails | false |
progress | unchanged (retains last value) |
max | unchanged (retains last value) |
estimatedSeconds | unchanged (retains last value) |
The processedURLs set is not cleared by abort(). A URL that was partially processed will not be re-scanned if the user selects the same source again. This is intentional — partial thumbnails generated before cancellation remain in the memory and disk caches.
5. ETA Estimation
Both actors implement a rolling ETA calculation based on recent per-item processing times.
- Estimation begins after a minimum number of items are processed:
ScanAndCreateThumbnails: afterminimumSamplesBeforeEstimation = 10itemsExtractAndSaveJPGs: afterestimationStartIndex = 10items
- The ETA uses the average of the most recent 10 inter-item intervals.
- The ETA is only updated downward — if the new estimate is higher than the previous one, it is discarded. This prevents the ETA counter from jumping upward mid-operation.
- The ETA is reported to the ViewModel via
fileHandlers?.estimatedTimeHandler(_:), which setsviewModel.estimatedSeconds.
6. Actor Isolation Guarantees
All mutable state in both actors is actor-isolated. Child tasks spawned inside withTaskGroup / withThrowingTaskGroup call back into the actor via await self.processSingleFile(...) / await self.processSingleExtraction(...), serialising all mutations (successCount, processingTimes, cacheMemory, etc.) through the actor.
SharedMemoryCache (an NSCache wrapper) is accessed synchronously from within both actors. This is safe because NSCache is internally thread-safe, and the access is documented accordingly in the code.
Background disk writes in ScanAndCreateThumbnails use Task.detached(priority: .background) with only value types (cgImage, dcache) captured — this avoids retaining the actor in the detached task and prevents actor isolation violations.