Memory Cache

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.representations or 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) is nonisolated(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 — requiring await to 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):

LevelAction
.normalFull cache capacity, notify UI
.warningReduce totalCostLimit to 60% → NSCache evicts LRU entries
.criticalremoveAllObjects() + 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 cost value
  • 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 totalCostLimit drives 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:

  1. Prioritises evicting objects that haven’t been accessed recently
  2. Considers object cost — higher cost objects may be evicted preferentially
  3. 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:

  1. Stores the key-value pair
  2. Adds cost to its running total
  3. Checks if totalCostLimit or countLimit is now exceeded
  4. If exceeded, begins evicting LRU candidates until back under limit
  5. For NSDiscardableContent objects, calls beginContentAccess() automatically on insertion — you must balance this with endContentAccess() 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:

  1. Acquires NSCache’s internal lock
  2. For each object: calls discardContentIfPossible() — objects with active accessCount are marked but may not be immediately removed
  3. Calls willEvictObject on your CacheDelegate for each evicted entry
  4. Clears the internal storage
  5. Resets the running cost total to 0
  6. 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.

Last modified February 27, 2026: update (75e722b)