Memory Cache
Categories:
The cache system is a three-layer design: Memory Cache → Disk Cache → Source (RAW file). It’s built around four cooperating components.
Memory Cache Policy
Cost is calculated per cached image as:
$$\text{Cost} = (\text{width in pixels}) \times (\text{height in pixels}) \times \text{bytes per pixel} \times 1.1$$Where:
- Pixel dimensions: Actual pixel size from
image.representationsor logical image size fallback - Bytes per pixel: Default is 4 (RGBA: Red, Green, Blue, Alpha), but configured to 6 in this case
- 1.1 multiplier: 10% overhead buffer for NSImage wrapper and caching metadata
With 2048×1372 thumbnail size and 6 bytes/pixel:
$$\text{Cost per image} = 2048 \times 1372 \times 6 \text{ bytes/pixel} \times 1.1$$$$= 4,194,304 \times 6 \times 1.1 = 19.4 \text{ MB}$$Count Limit Calculation
Count limit is currently fixed at 10,000 as the cap, but it is controlled by maximum memory allocated for the app. Max memory allocated is 10,000 MB (10 GB).
$$\text{Count limit} = \frac{\text{Total RAM Cache}}{\text{Cost per image}}$$$$= \frac{10000 \text{ MB}}{19.4 \text{ MB}} \approx 515 \text{ images}$$Component 1: DiscardableThumbnail
This is the cache entry wrapper. Every thumbnail stored in memory is wrapped in this class.
Cost calculation is the most important part — it gives NSCache accurate RAM accounting:
cost = (sum of all representation pixel costs) × costPerPixel × 1.1 overhead
For a 2048×1365 RGBA thumbnail: 2048 × 1365 × 4 × 1.1 ≈ 12.3MB
The NSDiscardableContent implementation uses OSAllocatedUnfairLock for thread safety and tracks an accessCount — NSCache will only call discardContentIfPossible() when accessCount == 0, meaning the thumbnail isn’t actively being displayed. This is the correct and safe pattern.
One nuance: once isDiscarded = true, beginContentAccess() returns false. Your consuming code should handle this by falling back to disk or re-generating the thumbnail.
Component 2: CacheDelegate
A lightweight eviction monitor. It intercepts willEvictObject from NSCache and increments a thread-safe counter using NSLock. This feeds the cache statistics dashboard.
It does not influence eviction decisions — it only observes them. NSCache makes all eviction decisions autonomously based on cost limits and system memory pressure.
Component 3: SharedMemoryCache (actor)
The central coordinator. Key design decisions:
Actor isolation split:
memoryCache(NSCache) isnonisolated(unsafe)— allowing synchronous lookups from any thread without actor hops. This is correct because NSCache is internally thread-safe.- Configuration state (
_costPerPixel,isConfigured, limits) is actor-isolated — requiringawaitto access safely.
Configuration flow:
ensureReady() → SettingsViewModel → calculateConfig() → applyConfig()
Settings drive two key values: memoryCacheSizeMB (default 5000MB) and thumbnailCostPerPixel (default 4). The countLimit of 10,000 is intentionally high so memory, not item count, is the real constraint.
Memory pressure response (three levels):
| Level | Action |
|---|---|
.normal | Full cache capacity, notify UI |
.warning | Reduce totalCostLimit to 60% → NSCache evicts LRU entries |
.critical | removeAllObjects() + set 50MB floor |
Known issue: the .warning handler reduces from memoryCache.totalCostLimit (current value) rather than the original configured limit, causing compounding reductions on repeated warnings. The .normal handler also doesn’t restore the limit. Fix:
private var _configuredCostLimit: Int = 0
// in applyConfig():
_configuredCostLimit = config.totalCostLimit
// in handleMemoryPressureEvent():
case .normal:
memoryCache.totalCostLimit = _configuredCostLimit // restore
case .warning:
memoryCache.totalCostLimit = Int(Double(_configuredCostLimit) * 0.6)
Component 4: DiskCacheManager
Acts as the L2 cache between memory and the original RAW source. Thumbnails missed in RAM are looked up here before triggering an expensive RAW decode. pruneDiskCache(maxAgeInDays:) handles housekeeping.
Cache Lifecycle — End to End
Request thumbnail for URL
│
▼
SharedMemoryCache.object(forKey:) ←── synchronous, no await
│
Hit? ──Yes──► beginContentAccess() → display → endContentAccess()
│
No
▼
DiskCacheManager lookup
│
Hit? ──Yes──► decode → wrap in DiscardableThumbnail → setObject(cost:) → display
│
No
▼
Decode from RAW source → same as disk hit path above
Statistics Flow
Cache hits → updateCacheMemory() → cacheMemory counter
Cache misses → updateCacheDisk() → cacheDisk counter
Evictions → CacheDelegate → evictionCount
│
▼
getCacheStatistics() → CacheStatistics(hits, misses, evictions, hitRate)
Summary Assessment
The system is well-designed for a photo culling app with these strengths: pixel-accurate cost tracking, proper NSDiscardableContent semantics, actor-based configuration safety with synchronous cache access, and reactive memory pressure handling. The only concrete bug is the compounding warning reduction described above — everything else is solid production-quality code.
NSCache — Detailed Internals
What NSCache Is
NSCache is Apple’s thread-safe, in-memory key-value store designed specifically for caching expensive-to-recreate objects. It behaves like a dictionary but with automatic eviction — it will silently remove entries when it decides memory is needed. Unlike a dictionary, you never get a guarantee an object is still there when you look it up.
Memory Management Model
NSCache tracks memory usage through two independent constraints:
totalCostLimit — the primary constraint in your app
- You set this to
memoryCacheSizeMB × 1024 × 1024(e.g. 5000MB) - Every object is stored with an associated
costvalue - NSCache tracks a running total of all costs
- When the total exceeds the limit, it starts evicting
countLimit — secondary constraint
- You’ve set this to 10,000 (effectively disabled as a constraint)
- NSCache evicts if the number of objects exceeds this, regardless of cost
- By setting it high, you ensure only
totalCostLimitdrives eviction
Important nuance: Apple’s documentation explicitly states that cost limits are “not a strict limit” — NSCache may exceed the limit briefly and evict asynchronously, or evict proactively before the limit is reached under system pressure. You cannot rely on it as a hard memory cap.
Eviction Policy
NSCache uses an LRU-like (Least Recently Used) policy, but it’s not a strict LRU. The actual algorithm is private and undocumented, but empirically it:
- Prioritises evicting objects that haven’t been accessed recently
- Considers object cost — higher cost objects may be evicted preferentially
- Responds to both internal limits AND system-wide memory pressure signals from the OS
When the OS sends a memory warning (which your DispatchSource.makeMemoryPressureSource also receives), NSCache independently starts evicting entries — even before your handler fires. This means NSCache and your pressure handler are complementary, not redundant.
How NSDiscardableContent Changes Behaviour
When a cached object conforms to NSDiscardableContent, NSCache gets additional eviction control:
Normal object (no NSDiscardableContent):
NSCache decides to evict → object removed immediately → delegate notified
NSDiscardableContent object (your DiscardableThumbnail):
NSCache decides to evict
→ calls discardContentIfPossible()
→ if accessCount == 0: isDiscarded = true ✓ (eviction proceeds)
→ if accessCount > 0: cannot discard ✗ (object protected, stays in cache)
→ if discarded: calls delegate willEvictObject → object removed
This means a thumbnail currently being displayed or processed (accessCount > 0 via beginContentAccess()) is immune to eviction during that window. This is exactly the right behaviour — you never want NSCache to pull an image out from under an active view.
The access pattern your consumers should follow:
if let thumbnail = SharedMemoryCache.shared.object(forKey: url) {
guard thumbnail.beginContentAccess() else {
// Was discarded between lookup and access — treat as cache miss
return fallbackToDisk()
}
defer { thumbnail.endContentAccess() }
// Safe to use thumbnail.image here
display(thumbnail.image)
}
The setObject(forKey:cost:) Call
When you call:
memoryCache.setObject(obj, forKey: key, cost: obj.cost)
NSCache does the following internally:
- Stores the key-value pair
- Adds
costto its running total - Checks if
totalCostLimitorcountLimitis now exceeded - If exceeded, begins evicting LRU candidates until back under limit
- For
NSDiscardableContentobjects, callsbeginContentAccess()automatically on insertion — you must balance this withendContentAccess()after insertion if you don’t intend to immediately use it
That last point is subtle — NSCache calls beginContentAccess() on insert, so the object starts with accessCount = 1. NSCache itself calls endContentAccess() when it’s done with the insertion bookkeeping. This is handled internally and you don’t need to manage it manually on insert.
Thread Safety Internals
NSCache uses internal locking so all operations — object(forKey:), setObject(_:forKey:cost:), removeObject(forKey:), removeAllObjects() — are safe to call from any thread simultaneously. This is why your nonisolated(unsafe) access pattern is correct:
// These are all safe to call from any thread, no actor hop needed:
nonisolated func object(forKey key: NSURL) -> DiscardableThumbnail? {
memoryCache.object(forKey: key) // internally locked
}
The nonisolated(unsafe) annotation simply tells Swift’s concurrency checker “I know what I’m doing, don’t enforce actor isolation here” — the actual thread safety comes from NSCache itself.
What Happens During removeAllObjects()
Your critical memory pressure handler calls this:
memoryCache.removeAllObjects()
memoryCache.totalCostLimit = 50 * 1024 * 1024
Internally this:
- Acquires NSCache’s internal lock
- For each object: calls
discardContentIfPossible()— objects with activeaccessCountare marked but may not be immediately removed - Calls
willEvictObjecton yourCacheDelegatefor each evicted entry - Clears the internal storage
- Resets the running cost total to 0
- Releases the lock
After this, any in-flight object(forKey:) calls return nil, forcing a disk or RAW fallback — which is the correct graceful degradation under critical pressure.
Cost Accounting in Your System
With your default settings (5000MB limit, 4 bytes/pixel, 2048px thumbnails):
Per thumbnail cost: 2048 × 1365 × 4 × 1.1 ≈ 12.3MB
Max thumbnails: 5000MB ÷ 12.3MB ≈ 406 thumbnails
With a 20,000MB user-configured maximum:
Max thumbnails: 20,000MB ÷ 12.3MB ≈ 1,626 thumbnails
NSCache will enforce these limits automatically through LRU eviction, keeping the most recently accessed thumbnails resident and silently dropping older ones — which is exactly the right behaviour for a photo culling app where the user browses sequentially.