AI checkpoint: the cache that races under loadswift-6.4/ios-26
Lesson 6 / 7
Unit 25 · Snapgram

AI checkpoint: the cache that races under load

Note

Unit 25 · Project 5, AI checkpoint. You asked Claude to prefetch the feed's posts concurrently into a cache so scrolling is instant. The code below compiles cleanly, passes your tests, and works every time you run it on the simulator. It also has a data race that will corrupt the cache in production. This is the review that matters most.

You prompted: "Prefetch a page of posts into a cache concurrently with a task group so the feed is instant. Make it a shared class I can inject." Here's the result. The compiler was complaining about Sendable, and the code "fixed" that. Read it as a reviewer.

The AI-generated code (review this)

import Foundation

// A shared cache the feed prefetches into.
final class FeedCache: @unchecked Sendable {
    private var store: [UUID: Post] = [:]

    func insert(_ post: Post) {
        store[post.id] = post
    }

    func post(for id: UUID) -> Post? {
        store[id]
    }

    var count: Int { store.count }
}

func prefetch(_ posts: [Post], into cache: FeedCache) async {
    await withTaskGroup(of: Void.self) { group in
        for post in posts {
            group.addTask {
                // Pretend each post needs some async prep before caching.
                let prepared = await prepare(post)
                cache.insert(prepared)     // ⚠️ concurrent writes to the same dictionary
            }
        }
    }
}

It reads fine. A cache, a prefetch that fans out over a task group, one line per post. The compiler accepted it. Tests pass. Ship it?

Guiding questions

Answer these before reading on.

  1. The compiler originally objected to passing FeedCache into the child tasks. What did @unchecked Sendable do to that objection — did it fix the problem or silence it?
  2. Inside the task group, many children call cache.insert(_:) at the same time. What is store[post.id] = post actually doing to the dictionary's internal storage, step by step?
  3. Why do your tests pass and the simulator run look fine, while this is genuinely broken? What would you have to do to make the bug show up reliably?
  4. What single change makes the concurrent writes safe and lets you delete @unchecked Sendable entirely? Why does that change also force the callers to await?

Explanation

The flaw is a data race, hidden behind @unchecked Sendable. Under strict concurrency, the compiler refused to let FeedCache — a class with mutable state and no synchronization — be shared across the task group's children, because that's unsafe. @unchecked Sendable is a promise to the compiler that "I've made this safe myself, trust me." Here that promise is false. Nothing synchronizes store. The annotation didn't fix the race; it disabled the check that was catching it.

Why it's a real race. store[post.id] = post is not atomic. A dictionary insert may read the current buffer, possibly reallocate and rehash it when it grows, and write back — several steps on shared memory. When two child tasks run that on the same dictionary at the same time, their steps interleave: lost writes, a torn buffer, or a crash (EXC_BAD_ACCESS) mid-reallocation. This is undefined behavior, not a Swift-level exception you can catch.

Why the tests pass anyway. This is the trap, and why it survived generation and a green suite. A data race is nondeterministic — it needs two threads to hit the same memory in the same tiny window. With a handful of posts on a fast simulator, the tasks often don't overlap, or overlap without landing on the exact interleaving that corrupts. So it "works" — until production, with more posts, slower devices, and thermal throttling, hits the window. The bug scales with load and luck, which is exactly the kind a unit test on ten items won't reproduce. The @unchecked should have been the reviewer's alarm bell: it's a claim that demands a written justification, and there isn't one.

The fix: make the cache an actor. An actor gives the cache its own isolation domain and serializes every access, so concurrent insert calls run one at a time — no interleaving, no torn storage. And an actor is Sendable by the compiler's own reasoning, so you delete @unchecked Sendable: the safety is now real and checked, not promised.

actor FeedCache {
    private var store: [UUID: Post] = [:]

    func insert(_ post: Post) {
        store[post.id] = post
    }

    func post(for id: UUID) -> Post? {
        store[id]
    }

    var count: Int { store.count }
}

func prefetch(_ posts: [Post], into cache: FeedCache) async {
    await withTaskGroup(of: Void.self) { group in
        for post in posts {
            group.addTask {
                let prepared = await prepare(post)
                await cache.insert(prepared)   // now serialized by the actor
            }
        }
    }
}

The only source change at the call site is await cache.insert(prepared) — because accessing an actor from outside is asynchronous. That await is the point: it's the compiler making the serialization visible. You didn't hand-write a lock; the actor is the mutual exclusion, and the race is gone by construction.

Note

@unchecked Sendable is a code-review red flag, not a fix. It tells the compiler to stop checking and trust you. There are legitimate uses (a class that synchronizes with its own lock, a type wrapping something you've proven immutable) — and every one should carry a comment explaining why it's actually safe, as the project conventions require. An @unchecked Sendable with mutable state and no synchronization and no justification is almost always a race the author silenced instead of solved. When you see it in an AI diff, that's where you look hardest.

Tip

Prefer making it safe over asserting it's safe. The instinct when the compiler complains about Sendable should be "what's the right isolation?" — an actor for shared mutable state, a struct for value data, @MainActor for UI state — not "how do I make the error go away?" @unchecked and nonisolated(unsafe) make errors go away without making the code correct. Reach for them last, with a reason, never first.

Your turn

The exercise below is the fix, made gradable: implement a Cache actor's set and get, then a hidden test hammers it with 1,000 concurrent writes from a task group and asserts every value landed. Against a naive unsynchronized version that test would race and drop writes; against the actor it passes every time — which is the whole point.

Make a shared cache race-free with an actor
Edit the code on the right, then run the hidden tests.

Knowledge check

Q: What did @unchecked Sendable actually do to the original FeedCache? It silenced the compiler's data-race check by promising the type is safe to share — a promise that was false, since nothing synchronized the dictionary. It didn't fix the race; it disabled the diagnostic that was catching it.

Q: Why does turning the cache into an actor let you delete @unchecked Sendable? An actor serializes all access to its state, so it is genuinely safe to share — and the compiler knows actors are Sendable on that basis. The safety becomes real and machine-checked, so the manual "trust me" annotation is no longer needed (and would be wrong to keep).

Solution.swiftSwift Testing
ConsoleReady · runs in a sandboxed Swift container
Press Run to compile against the hidden test suite.