AI checkpoint: the cache that races under load
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.
- The compiler originally objected to passing
FeedCacheinto the child tasks. What did@unchecked Sendabledo to that objection — did it fix the problem or silence it? - Inside the task group, many children call
cache.insert(_:)at the same time. What isstore[post.id] = postactually doing to the dictionary's internal storage, step by step? - 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?
- What single change makes the concurrent writes safe and lets you delete
@unchecked Sendableentirely? Why does that change also force the callers toawait?
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.
@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.
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.
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).