SwiftData rollback after a failed save
When we call save() on a SwiftData context and it throws, Apple's docs don't say what happens to the changes it tried to write. As far as I can tell, they don't go anywhere, they stay pending in the context, and on a context that autosaves, like Digest's main one, the next successful save anywhere in the app writes them after all.
That's what a code review found in Digest, a food journal I'm working on. A failed delete showed an alert saying "Nothing already logged is affected", and the next save, from any screen, would've deleted the meal anyway. A failed meal log, tapped again, would've written the meal twice.
So now Digest rolls the context back after a failed save, in one place. That rollback had a trap of its own: a rolled-back delete that crashed on the next read, which the tests found by making a save fail on purpose. I'm going into very edge case territory here, but it was still a fun thing to tackle.
I've never actually seen a save fail on a device; it's all from the tests.
One save for the whole app
Every write in Digest already went through one function, an extension on ModelContext called saveStamped(). It sets a modifiedAt date on every inserted and changed model, which is how the export notices an edit, then saves.
Undoing a failed save, on the other hand, was pretty much up to each call site: a handful had a hand-written undo, like deleting the insert that failed, and most had none.
I wanted one place instead of a fix at each of about 35 call sites. That would've been the same code 35 times, and the next call site would probably forget it.
saveStamped() was already that place, of course:
func saveStamped() throws {
// [...] stamps every inserted and changed model
do {
try save()
} catch {
revertToStore() // ❶
throw error // ❷
}
}We put the context back (❶) before the error leaves (❷), so whoever called it can trust "nothing was saved". A retry writes the meal once, and the next save elsewhere has nothing of this one left to commit.
What does the user see? The editor stays open with whatever they typed still there, under a "Couldn't save" alert: "Nothing already logged is affected. Try again in a moment." That line is actually true now.
revertToStore() ends in a rollback(), though (see below), which throws away everything pending in the context, not just this write. It's fine for Digest, because by convention every write saves right away, so when a save fails, nothing else should be pending. And if something was, it was part of the same failed save anyway. Where it would hurt is an app that leaves edits unsaved in a shared context for a while: one screen's failed save would throw away another screen's work.
The delete that crashed
Apple's documentation says rollback() "discards pending inserts and deletes, restores changed models to their most recent committed state, and empties the undo stack". Calling just that, though, crashed the test host on a delete: the meal was deleted, its save failed, the rollback ran, and the next read of the meal brought the whole host down.
If we roll back a delete the context hasn't processed yet, the object is still there, the editor still holds it, a list row might still show it, but reading it crashes. Processed first, it comes back whole:
private func revertToStore() {
processPendingChanges() // ❶
rollback() // ❷
Log.logging.notice("Pending changes put back after a failed save.")
}Apple documents processPendingChanges() (❶) as telling the undo manager to record the context's changes, and says you can call it yourself "to coalesce any pending changes". The docs don't say why that keeps a rolled-back delete readable, and I can't either. But with it, the read after rollback() (❷) works.
This one probably never reached the app, though. A real save() processes the pending changes on its way to the store (Apple's willSave notification posts when the context is "about to process" them), so by the time it throws, the delete is most likely processed already.
So where did the crash come from? The tests: there, the failure is thrown before save() runs, so nothing has processed the delete yet when the rollback comes. I kept the call anyway, so the rollback doesn't depend on where the failure came from, whether that's a real store, the test flag, or anything else that might throw before save() later on.
There's a thread on Apple's forums, "Fatal error on rollback after delete", about a crash inside rollback() after deleting a model that has children. An Apple engineer reproduced it and called it a framework issue, and the only workarounds in the thread are deleting the children first, or doing the work in a separate context. Mine crashed on the read after the rollback, not inside it, so it might just be a different bug, and I don't know if processing first helps there.
Making a save fail in a test
We can't ask a store to fail, so in debug builds saveStamped() checks a flag right before save(), after the stamps:
do {
#if DEBUG
if SaveFailureInjection.isFailing {
throw SaveFailureInjection.Failure()
}
#endif
try save()
} catch {
// [...]
}The flag is a task-local:
#if DEBUG
nonisolated enum SaveFailureInjection {
struct Failure: Error {}
@TaskLocal
static var isFailing = false
}
#endifA plain static var would be simpler, but Swift Testing runs tests in parallel, and a static flag would fail the saves of every test running beside the one that set it. @TaskLocal (a value bound to a task, and inherited by the tasks it starts) is only set for the length of a withValue call, so the failure stays inside one test:
private func failingSave(in context: ModelContext) {
#expect(throws: SaveFailureInjection.Failure.self) {
try SaveFailureInjection.$isFailing.withValue(true) {
try context.saveStamped()
}
}
}The delete test, with Entry being a meal and EntryItem the row joining it to a food:
@Test("Brings a deleted meal back whole, readable through the reference the screen holds")
func deleteIsPutBack() throws {
let container = try ModelContainer.inMemory() // ❶
let context = container.mainContext
let entry = Entry(timestamp: .now, items: [Item(name: "rye")])
context.insert(entry)
try context.saveStamped()
let timestamp = entry.timestamp
context.delete(entry)
failingSave(in: context) // ❷
try context.saveStamped() // ❸
#expect(entry.timestamp == timestamp) // ❹
#expect(entry.entryItems?.count == 1)
#expect(try context.fetchCount(FetchDescriptor<Entry>()) == 1)
#expect(try context.fetchCount(FetchDescriptor<EntryItem>()) == 1)
}We start from an in-memory container (❶; the CloudKit trap with those is in iOS testing with Claude Code). We fail the delete's save (❷), then save again, the way a new screen would (❸).
Reading entry after that (❹) is the read that used to crash, and the 2 counts check the store still holds the meal and its food row, so the second save didn't commit the delete either.
2 more tests do the same for an insert (a retry writes it once) and for a changed field (the screen's object gets its old value back). Before the flag, none of the old hand-written undos had a test.
One more way to slip out of the rollback, found along the way: inserting an object the context already holds isn't really the no-op it looks like.
In Digest, it cut the object off from the rollback. After a failed save, it kept the values the save tried to write while the store had the old ones, and a retried meal log counted a food's use twice. The screens that build a meal now insert only the foods that aren't in a context yet:
func insertUnstored<Model: PersistentModel>(_ models: [Model]) {
for model in models where model.modelContext == nil {
insert(model)
}
}If you know why an unprocessed delete can't be read after a rollback, or you've found what fixes the forum one, I'd like to hear it (@roland.leth.ro / @rolandleth)!