Concurrency model
Categories:
Concurrency Model — RawCull
Files covered:
RawCull/Model/ViewModels/RawCullViewModel.swiftRawCull/Views/RawCullSidebarMainView/extension+RawCullView.swiftRawCull/Actors/ScanFiles.swiftRawCull/Actors/ScanAndCreateThumbnails.swiftRawCull/Actors/ExtractAndSaveJPGs.swiftRawCull/Actors/ThumbnailLoader.swiftRawCull/Actors/RequestThumbnail.swiftRawCull/Actors/SharedMemoryCache.swiftRawCull/Actors/DiskCacheManager.swiftRawCull/Actors/SaveJPGImage.swiftRawCull/Enum/SonyThumbnailExtractor.swiftRawCull/Enum/JPGSonyARWExtractor.swift
Overview
RawCull uses Swift structured concurrency (async/await, Task, TaskGroup, and actor) across four primary flows:
| Flow | Entry point | Core actor(s) | Purpose |
|---|---|---|---|
| Catalog scan | RawCullViewModel.handleSourceChange(url:) | ScanFiles | Scan ARW files, extract metadata, load focus points |
| Thumbnail preload | RawCullViewModel.handleSourceChange(url:) | ScanAndCreateThumbnails | Bulk-populate the thumbnail cache for a selected catalog |
| JPG extraction | extension+RawCullView.extractAllJPGS() | ExtractAndSaveJPGs | Extract embedded JPEG previews and save to disk |
| On-demand thumbnails | UI grid + detail views | ThumbnailLoader, RequestThumbnail | Rate-limited, cached per-file thumbnail retrieval |
The two long-running operations (thumbnail preload and JPG extraction) share a two-level task pattern:
- An outer
Taskcreated from theViewModelorViewlayer. - An inner
Taskstored inside the actor, which owns the real work and cancellation handle.
This split keeps UI responsive: handleSourceChange is @MainActor but async — when it awaits the outer Task, the main actor is free to handle other work while the task’s body runs on the ScanAndCreateThumbnails actor. The inner task runs heavy I/O and image work on actor and cooperative thread-pool queues. Cancellation requires calling both levels.
1. Catalog Scan — ScanFiles
1.1 Entry point
RawCullViewModel.handleSourceChange(url:) is @MainActor and is called whenever the user selects a new catalog. It triggers the scan before any thumbnail work starts.
1.2 Scan flow
ScanFiles.scanFiles(url:onProgress:) runs on the ScanFiles actor:
- Opens the directory with security-scoped resource access.
- Uses
withTaskGroupto process all ARW files in parallel. - For each file, a task reads
URLResourceValues(name, size, content type, modification date) and callsextractExifData(from:). - After the group finishes, resolves focus points via a two-stage fallback:
- Native extraction first:
extractNativeFocusPoints(from:)runs awithTaskGroupover allFileItems, callingSonyMakerNoteParser.focusLocation(from:)on each ARW file. - JSON fallback: if native extraction yields no results,
decodeFocusPointsJSON(from:)readsfocuspoints.jsonsynchronously from the same directory.
- Native extraction first:
- Returns
[FileItem].
extractExifData(from:) reads EXIF data via CGImageSourceCopyPropertiesAtIndex and formats:
- Shutter speed (e.g.,
"1/1000"or"2.5s") - Focal length (e.g.,
"50.0mm") - Aperture (e.g.,
"ƒ/2.8") - ISO (e.g.,
"ISO 400") - Camera model (from TIFF dictionary)
- Lens model (from EXIF dictionary)
RawCullViewModel then calls ScanFiles.sortFiles(_:by:searchText:) (@concurrent nonisolated, runs on the cooperative thread pool), updates files and filteredFiles on the main actor, and maps decoded focus points to FocusPointsModel objects.
2. Thumbnail Preload — ScanAndCreateThumbnails
2.1 How the task starts
RawCullViewModel.handleSourceChange(url:) is the entry point (@MainActor).
Step-by-step:
- Skip duplicates:
processedURLs: Set<URL>prevents re-processing a catalog URL already handled in this session. - Fetch settings:
SettingsViewModel.shared.asyncgetsettings()providesthumbnailSizePreviewandthumbnailCostPerPixel. - Build handlers:
CreateFileHandlers().createFileHandlers(...)wires up four@MainActor @Sendableclosures:fileHandler(Int)— progress countmaxfilesHandler(Int)— total file countestimatedTimeHandler(Int)— ETA in secondsmemorypressurewarning(Bool)— memory pressure state for UI
- Create actor:
ScanAndCreateThumbnails()is instantiated and handlers injected. - Store actor reference:
currentScanAndCreateThumbnailsActoris set soabort()can reach it. - Create outer Task on the ViewModel:
preloadTask = Task {
await scanAndCreateThumbnails.preloadCatalog(
at: url,
targetSize: thumbnailSizePreview
)
}
await preloadTask?.value
The await suspends handleSourceChange (freeing the main actor while the preload runs on the ScanAndCreateThumbnails actor) until the preload finishes or is cancelled.
2.2 Inside the actor
preloadCatalog(at:targetSize:) runs on the ScanAndCreateThumbnails actor:
- One-time setup:
ensureReady()callsSharedMemoryCache.shared.ensureReady()and fetches settings via asetupTaskgate (preventing duplicate initialization from concurrent callers). - Cancel prior work:
cancelPreload()cancels and nils any existing inner task. - Discover files: Enumerate ARW files non-recursively via
DiscoverFiles. - Notify max:
fileHandlers?.maxfilesHandler(files.count)updates the progress bar maximum. - Create inner
Task<Int, Never>: stored aspreloadTaskon the actor. - Bounded
withTaskGroup: caps parallelism atProcessInfo.processInfo.activeProcessorCount * 2using index-based back-pressure and per-iteration cancellation checks:
for (index, url) in urls.enumerated() {
if Task.isCancelled {
group.cancelAll()
break
}
if index >= maxConcurrent {
await group.next()
}
group.addTask {
await self.processSingleFile(url, targetSize: targetSize, itemIndex: index)
}
}
await group.waitForAll()
2.3 Per-file processing and cancellation points
processSingleFile(_:targetSize:itemIndex:) follows the three-tier cache lookup and checks Task.isCancelled at every expensive step:
| Step | Cancellation check | Action on cancel |
|---|---|---|
| Before RAM lookup | Task.isCancelled | Return immediately |
| After RAM hit confirmed | Task.isCancelled | Skip remaining work |
| Before disk lookup | Task.isCancelled | Return immediately |
| Before source extraction | Task.isCancelled | Return immediately |
| After extraction completes | Task.isCancelled | Skip caching and disk write |
On extraction success:
- Call
cgImageToNormalizedNSImage(_:)— convertsCGImageto anNSImagebacked by a single JPEG representation (quality 0.7). This normalization ensures memory and disk representations are consistent. storeInMemoryCache(_:for:)— createsDiscardableThumbnailwith pixel-accurate cost and stores inSharedMemoryCache.- Encode
jpegDataand calldiskCache.save(_:for:)— this is a detached background task. The closure capturesdiskCachedirectly to avoid retaining the actor.
2.4 Request coalescing
ScanAndCreateThumbnails.thumbnail(for:targetSize:) exposes an async lookup for direct per-file requests. It calls resolveImage(for:targetSize:), which adds in-flight task coalescing via inflightTasks: [URL: Task<CGImage, Error>]:
- Check RAM cache.
- Check disk cache.
- If
inflightTasks[url]exists,awaitit — multiple callers share the same work. - Otherwise, create a new unstructured
Taskinside the actor, store it ininflightTasks, extract and cache the thumbnail, then remove the entry when done.
This prevents duplicate extraction work when multiple UI elements request the same file simultaneously.
3. JPG Extraction — ExtractAndSaveJPGs
3.1 How the task starts
extension+RawCullView.extractAllJPGS() creates an unstructured outer task from the View layer:
Task {
viewModel.creatingthumbnails = true
let handlers = CreateFileHandlers().createFileHandlers(
fileHandler: viewModel.fileHandler,
maxfilesHandler: viewModel.maxfilesHandler,
estimatedTimeHandler: viewModel.estimatedTimeHandler,
memorypressurewarning: { _ in },
)
let extract = ExtractAndSaveJPGs()
await extract.setFileHandlers(handlers)
viewModel.currentExtractAndSaveJPGsActor = extract
guard let url = viewModel.selectedSource?.url else { return }
await extract.extractAndSaveAlljpgs(from: url)
viewModel.currentExtractAndSaveJPGsActor = nil
viewModel.creatingthumbnails = false
}
Unlike the preload flow, the outer task is not stored on the ViewModel. Cancellation is driven entirely through the actor reference via viewModel.abort().
3.2 Inside the actor
extractAndSaveAlljpgs(from:) mirrors the preload pattern exactly:
- Cancel any previous inner task via
cancelExtractJPGSTask(). - Discover all ARW files (non-recursive).
- Create a
Task<Int, Never>stored asextractJPEGSTask. - Use
withThrowingTaskGroupwithactiveProcessorCount * 2concurrency cap and the same index-based back-pressure pattern asScanAndCreateThumbnails(cancellation check +group.cancelAll(), index guard beforegroup.next(),group.waitForAll()to drain). - Call
processSingleExtraction(_:itemIndex:)per file.
processSingleExtraction checks cancellation before and after JPGSonyARWExtractor.jpgSonyARWExtractor(from:fullSize:), then writes the result via SaveJPGImage().save(image:originalURL:).
SaveJPGImage.save is @concurrent nonisolated and runs on the cooperative thread pool. It:
- Replaces the
.arwextension with.jpg - Uses
CGImageDestinationCreateWithURLwith JPEG quality1.0 - Logs success/failure with image dimensions and file paths
4. Rate-Limited On-Demand Loading
4.1 ThumbnailLoader
ThumbnailLoader is a shared actor that enforces a maximum of 6 concurrent thumbnail loads. Excess requests suspend via CheckedContinuation and are queued:
actor ThumbnailLoader {
static let shared = ThumbnailLoader()
private let maxConcurrent = 6
private var activeTasks = 0
private var pendingContinuations: [(id: UUID, continuation: CheckedContinuation<Void, Never>)] = []
}
acquireSlot() flow:
- If
activeTasks < maxConcurrent: incrementactiveTasks, return immediately. - Otherwise: call
withCheckedContinuation { continuation in ... }— this suspends the caller. - A cancellation handler is registered to remove the pending continuation by ID so it is never resumed after cancellation.
releaseSlot() flow:
- Decrement
activeTasks. - If
pendingContinuationsis non-empty, pop the first andresume()it.
thumbnailLoader(file:) flow:
func thumbnailLoader(file: FileItem) async -> NSImage? {
await acquireSlot()
defer { releaseSlot() }
guard !Task.isCancelled else { return nil }
let settings = await getSettings()
let cgThumb = await RequestThumbnail().requestThumbnail(
for: file.url,
targetSize: settings.thumbnailSizePreview
)
guard !Task.isCancelled else { return nil }
if let cgThumb {
return NSImage(cgImage: cgThumb, size: .zero)
}
return nil
}
Settings are cached on the actor to avoid repeated SettingsViewModel calls. The result is wrapped as NSImage(cgImage:size:.zero) before returning to the caller.
4.2 RequestThumbnail
RequestThumbnail handles the per-file cache resolution path for the on-demand flow:
ensureReady()— samesetupTaskgate pattern asScanAndCreateThumbnails.- RAM cache lookup via
SharedMemoryCache.object(forKey:); on hit, callsSharedMemoryCache.updateCacheMemory()for statistics. - Disk cache lookup via
DiskCacheManager.load(for:); on hit, callsSharedMemoryCache.updateCacheDisk()for statistics. - Extraction fallback:
SonyThumbnailExtractor.extractSonyThumbnail(from:maxDimension:qualityCost:). - Store in RAM cache via
storeInMemory(_:for:). - Schedule disk save via a detached background task.
requestThumbnail(for:targetSize:) returns CGImage? for direct use by SwiftUI views. All errors are caught and logged; the method returns nil on failure.
nsImageToCGImage(_:) is async and tries cgImage(forProposedRect:) first; if that fails, it falls back to a TIFF round-trip on a Task.detached(priority: .utility) task to avoid blocking the actor with CPU-bound work.
5. Task Ownership and Handles
| Layer | Owner | Handle name | Type |
|---|---|---|---|
| Outer task (preload) | RawCullViewModel | preloadTask | Task<Void, Never>? |
| Inner task (preload) | ScanAndCreateThumbnails | preloadTask | Task<Int, Never>? |
| Outer task (extract) | View (extractAllJPGS) | not stored | Task<Void, Never> |
| Inner task (extract) | ExtractAndSaveJPGs | extractJPEGSTask | Task<Int, Never>? |
| Slot queue (on-demand) | ThumbnailLoader.shared | pendingContinuations | [(UUID, CheckedContinuation)] |
6. Cancellation
6.1 abort()
RawCullViewModel.abort() is the single cancellation entry point for user-initiated stops:
func abort() {
preloadTask?.cancel()
preloadTask = nil
if let actor = currentScanAndCreateThumbnailsActor {
Task { await actor.cancelPreload() }
}
currentScanAndCreateThumbnailsActor = nil
if let actor = currentExtractAndSaveJPGsActor {
Task { await actor.cancelExtractJPGSTask() }
}
currentExtractAndSaveJPGsActor = nil
creatingthumbnails = false
}
6.2 Why both levels matter
Cancelling the outer Task propagates into child structured tasks, but does not automatically cancel the actor’s inner Task. The inner task is unstructured (Task { ... } created inside the actor) — it is not a child of the outer task. The actor-specific cancel methods (cancelPreload, cancelExtractJPGSTask) must be explicitly called to cancel the inner task and allow the withTaskGroup to unwind.
6.3 ThumbnailLoader.cancelAll()
cancelAll() resumes all pending continuations immediately, unblocking any tasks waiting for a slot. This is called during teardown to prevent suspension leaks.
7. ETA Estimation
Both long-running actors compute a rolling ETA estimate:
Algorithm:
- Record a timestamp before each file starts processing.
- After completion, compute
delta = now - lastItemTime. - Append
deltatoprocessingTimesarray. - Keep only the last 10 items in the array.
- After collecting
minimumSamplesBeforeEstimation(10) items, calculate:
avgTime = sum(processingTimes) / processingTimes.count
remaining = (totalFiles - itemsProcessed) * avgTime
- Only update the displayed ETA if
remaining < lastEstimatedSeconds— this prevents the counter from jumping upward when a slow file takes longer than expected.
| Actor | Minimum samples threshold |
|---|---|
ScanAndCreateThumbnails | minimumSamplesBeforeEstimation = 10 |
ExtractAndSaveJPGs | estimationStartIndex = 10 |
8. Actor Isolation and Thread Safety
| Component | Isolation strategy |
|---|---|
ScanAndCreateThumbnails, ExtractAndSaveJPGs, ScanFiles | All mutable state is actor-isolated; mutations only through actor methods |
SharedMemoryCache | nonisolated(unsafe) for NSCache (thread-safe by design); all statistics and config remain actor-isolated |
DiskCacheManager | Actor-isolates path calculation and coordination; actual file I/O runs in detached tasks |
ThumbnailLoader | Actor-isolated slot counter and continuation queue |
DiscardableThumbnail | @unchecked Sendable with OSAllocatedUnfairLock protecting (isDiscarded, accessCount) |
CacheDelegate | @unchecked Sendable — willEvictObject is called synchronously by NSCache; increments are dispatched to an isolated EvictionCounter actor |
RawCullViewModel | @MainActor — all UI state updates serialized on the main thread |
SonyThumbnailExtractor, JPGSonyARWExtractor | nonisolated static methods dispatched to global GCD queues to prevent actor starvation |
SaveJPGImage | actor with a single @concurrent nonisolated method — runs on the cooperative thread pool, not the actor’s executor |
CPU-bound ImageIO and disk I/O work runs off-actor to keep the main thread and actor queues responsive.
9. Flow Diagrams
Thumbnail Preload — Two-Level Task Pattern
sequenceDiagram
participant VM as RawCullViewModel (MainActor)
participant A as ScanAndCreateThumbnails (actor)
participant MC as SharedMemoryCache
participant DC as DiskCacheManager
participant EX as SonyThumbnailExtractor
VM->>A: preloadCatalog(at:targetSize:)
Note over VM: outer Task stores preloadTask
A->>A: ensureReady() — setupTask gate
A->>A: cancelPreload() — cancel prior inner task
A->>A: create inner Task<Int,Never> — stored as preloadTask
loop withTaskGroup (capped at processorCount × 2)
A->>MC: object(forKey:) — RAM check
alt RAM hit
MC-->>A: DiscardableThumbnail
else RAM miss
A->>DC: load(for:) — disk check
alt Disk hit
DC-->>A: NSImage
A->>MC: setObject(...) — promote to RAM
else Disk miss
A->>EX: extractSonyThumbnail(from:maxDimension:qualityCost:)
EX-->>A: CGImage
A->>A: normalize to JPEG-backed NSImage
A->>MC: setObject(...)
A->>DC: save(jpegData:for:) — detached background
end
end
A->>VM: fileHandler(progress) via @MainActor closure
end
A-->>VM: return countOn-Demand Request
sequenceDiagram
participant UI
participant TL as ThumbnailLoader (actor)
participant RT as RequestThumbnail (actor)
participant MC as SharedMemoryCache
participant DC as DiskCacheManager
UI->>TL: thumbnailLoader(file:)
TL->>TL: acquireSlot() — suspend if activeTasks ≥ 6
TL->>RT: requestThumbnail(for:targetSize:)
RT->>MC: object(forKey:)
alt RAM hit
MC-->>RT: DiscardableThumbnail
RT-->>UI: CGImage
else RAM miss
RT->>DC: load(for:)
alt Disk hit
DC-->>RT: NSImage
RT->>MC: setObject(...)
RT-->>UI: CGImage
else Disk miss
RT->>RT: extractSonyThumbnail
RT->>MC: setObject(...)
RT->>DC: save — detached background
RT-->>UI: CGImage
end
end
TL->>TL: releaseSlot() — resume next pending continuation10. Settings Reference
| Setting | Default | Effect |
|---|---|---|
memoryCacheSizeMB | 5000 | Sets NSCache.totalCostLimit |
thumbnailCostPerPixel | 4 | Cost per pixel in DiscardableThumbnail |
thumbnailSizePreview | 1024 | Target size for bulk preload and on-demand loading via ThumbnailLoader |
thumbnailSizeGrid | 100 | Grid thumbnail size |
thumbnailSizeGridView | 400 | Grid View thumbnail size |
thumbnailSizeFullSize | 8700 | Full-size zoom path upper bound |
useThumbnailAsZoomPreview | false | Use cached thumbnail instead of re-extracting for zoom |