I have a bug I've come across since I've upgraded Xcode (16.0) and macOS (15.0 Sequoia) and figured I'd create a minimal viable example in case someone else came across this and help. I've noticed this in both the macOS and iOS version of my app when I run it.
Essentially I have an area of my code that calls a class that has a step 1 and 2 process. The first step uses async let _ = await methodCall(...)
to create some SwiftData
items while the second step uses a TaskGroup
to go through the created elements, checks if an image is needed, and syncs it. Before this worked great as the first part finished and saved before the second part happened. Now it doesn't see the save and thus doesn't see the insertions/edits/etc in the first part so the second part just isn't done properly. Those step one changes are set and shown in the UI so, in this case, if I run it again the second part works just fine on those previous items while any newly created items are skipped.
I came across this issue when step one handled 74 inserts as each one didn't contain an image attached to it. When I switched the async let _ = await methodCall(...)
to a TaskGroup
, hoping that would wait better and work properly, I had the same issue but now only 10 to 30 items were created from the first step.
Minimal Viable Example: to reproduce something similar
In my minimal viable sample I simplified it way down so you can't run it twice and it's limited it creating 15 subitems. That said, I've hooked it up with both an async let _ = await methodCall(...)
dubbed AL
and a TaskGroup
dubbed TG
. With both types my second process (incrementing the number associated with the subissue/subitem) isn't run as it doesn't see the subitem as having been created and, when run with TaskGroup
, only 12 to 15 items are created rather that the always 15 of async let
. Here's my code:
import SwiftUI
import SwiftData
import Foundation
// MARK: Model with main `Issue` and `sub issues`.
@Model
class Issue {
var name: String
// Relatioship
@Relationship(deleteRule: .cascade, inverse: \Subissue.issue) var subissues: [Subissue]
init(name: String, subIssues: [Subissue] = [Subissue]()) {
self.name = name
self.subissues = subIssues
}
}
@Model
class Subissue {
var name: String
var numberEdited: Int = 0
// Relationship
var issue: Issue?
init(name: String, issue: Issue) {
self.name = name
self.numberEdited = 0
self.issue = issue
}
}
// MARK: Main entrance
@main
struct TestMultiplatformApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Issue.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
@State var refreshViewID: UUID = UUID()
var body: some Scene {
WindowGroup {
ContentView(refreshViewID: $refreshViewID)
.id(refreshViewID)
}
.modelContainer(sharedModelContainer)
}
}
// MARK: Class with the async methods that are called
final class BackgroundClass: Sendable {
public func asyncTaskGroup(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
let modelContext = ModelContext(container)
modelContext.autosaveEnabled = false
// Create new issue
let issue = Issue(name: "TG - Issue: \(Date.now.timeIntervalSince1970)")
modelContext.insert(issue)
do {
try modelContext.save()
} catch {
return (error: "Unable to save. Error: \(error.localizedDescription)", issueID: nil)
}
print("Main TG: Before creating the count is \(issue.subissues.count)")
let _ = await withTaskGroup(of: Void.self, body: { taskGroup in
let modelContainer = issue.modelContext!.container
let newID = issue.persistentModelID
print("TG create: Before creating the count is \(issue.subissues.count)")
for i in 1...15 {
// Add child task to task group
taskGroup.addTask {
let _ = await self.createSubIssue(in: modelContainer, issueID: newID, name: i)
// Return child task result
return
}
}
print("TG create: After creating the count is \(issue.subissues.count)")
})
print("Main TG: Between the count is \(issue.subissues.count)")
let errorReturned = await doSecondaryProcess(issue: issue)
if let error = errorReturned, error.count > 0 {
return (error: error, issueID: nil)
}
print("Main TG: After processing the count is \(issue.subissues.count)")
return (error: nil, issueID: issue.persistentModelID)
}
public func asyncLetTest(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
let modelContext = ModelContext(container)
modelContext.autosaveEnabled = false
// Create new issue
let issue = Issue(name: "AL - Issue: \(Date.now.timeIntervalSince1970)")
modelContext.insert(issue)
do {
try modelContext.save()
} catch {
return (error: "Unable to save. Error: \(error.localizedDescription)", issueID: nil)
}
print("Main AL: Before creating the count is \(issue.subissues.count)")
let _ = await createSubissues(issue: issue)
print("Main AL: Between the count is \(issue.subissues.count)")
let errorReturned = await doSecondaryProcess(issue: issue)
if let error = errorReturned, error.count > 0 {
return (error: error, issueID: nil)
}
print("Main AL: After processing the count is \(issue.subissues.count)")
return (error: nil, issueID: issue.persistentModelID)
}
// MARK: First set of items
internal final func createSubissues(issue: Issue) async {
let modelContainer = issue.modelContext!.container
let thisID = issue.persistentModelID
print("create: Before creating the count is \(issue.subissues.count)")
for i in 1...15 {
async let _ = createSubIssue(in: modelContainer, issueID: thisID, name: i)
}
print("create: After creating the count is \(issue.subissues.count)")
}
internal final func createSubIssue(in container: ModelContainer, issueID: PersistentIdentifier, name: Int) async -> String? {
let modelContext = ModelContext(container)
modelContext.autosaveEnabled = false
let thisIssue = modelContext.model(for: issueID) as? Issue
guard let thisIssue else { return "Unable to proceed as we're unable to find this issue." }
let newSubIssue = Subissue(name: "Subissue \(name) - \(Date.now.timeIntervalSince1970)", issue: thisIssue)
do {
try modelContext.save()
} catch {
return "Unable to save new subissue \(newSubIssue.name). Error: \(error)"
}
print("Created subissue: \(thisIssue.subissues.count) - \(newSubIssue.name)")
return nil
}
// MARK: Secondary set of processing after creation
internal final func doSecondaryProcess(issue: Issue) async -> String? {
let taskGroupResults = await withTaskGroup(
of: (myError: [String], myText: [String]).self,
returning: (myTexts: [String], myErrors: [String]).self,
body: { taskGroup in
// Loop through the issue's subissues. Which as above isn't saved is none
print("Going through increment. Have \(issue.subissues.count) subissue(s).")
for mySubissue in (issue.subissues) {
let modelContainer = mySubissue.modelContext!.container
let thisID = mySubissue.persistentModelID
// Add child task to task group
taskGroup.addTask {
// Execute slow operation
let value = await self.incrementNumberSlowly(in: modelContainer, myID: thisID)
// Return child task result -> same as 'of' above
return (myError: value.myError == nil ? [] : [value.myError!], myText: value.myText == nil ? [] : [value.myText!])
}
}
var errors = [String]()
var myTexts = [String]()
// Collect results of all child task in a dictionary
for await result in taskGroup {
// append to other arrays
errors = errors+result.myError
myTexts = myTexts+result.myText
}
// Task group finish running & return task group result
return (myTexts: myTexts, myErrors: errors) // same value as allResults and returning above.
})
print("After calling background class values returned for issue count of \(issue.subissues.count) is \(taskGroupResults.myTexts.count) and \(taskGroupResults.myErrors.count)")
if taskGroupResults.myErrors.count > 0 {
print("My Texts")
print("\t* \(taskGroupResults.myTexts.joined(separator: "\n\t* "))")
}
if taskGroupResults.myErrors.count > 0 {
print("My Errors")
print("\t* \(taskGroupResults.myErrors.joined(separator: "\n\t* "))")
}
return nil
}
private func incrementNumberSlowly(in container: ModelContainer, myID: PersistentIdentifier) async -> (myError: String?, myText: String?) {
let modelContext = ModelContext(container)
modelContext.autosaveEnabled = false
let mySubIssue = modelContext.model(for: myID) as? Subissue
guard let mySubIssue else {
return (myError: "Unable to find subissue.", myText: nil)
}
print("In increment before: \(mySubIssue.numberEdited)")
mySubIssue.numberEdited = mySubIssue.numberEdited + 1
print("In increment after: \(mySubIssue.numberEdited)")
return (myError: nil, myText: "Number incremented to \(mySubIssue.numberEdited)")
}
}
// MARK: Content view showing the information and calling the above methods
struct ContentView: View {
@Environment(\.modelContext) var modelContext
@Query var allIssues: [Issue]
@State var selectedIssue: Issue?
@Query var allSubissues: [Subissue]
@Binding var refreshViewID: UUID
var body: some View {
VStack {
Section("Data", content: {
HStack {
Spacer()
VStack {
Text("Issue Count: \(allIssues.count)")
Text("SubIssue Count: \(allSubissues.count)")
}
Spacer()
Button(action: {
refreshViewID = UUID()
}, label: {
Text("Refresh View")
})
Spacer()
}
})
Section("Actions", content: {
HStack {
Spacer()
Button(action: {
Task {
let result = await BackgroundClass().asyncLetTest(in: modelContext.container)
print("Result: \(result)")
}
}, label: {
Text("Run Async Let")
})
Spacer()
Button(action: {
Task {
let result = await BackgroundClass().asyncTaskGroup(in: modelContext.container)
print("Result: \(result)")
}
}, label: {
Text("Run Task Group")
})
Spacer()
Button(action: {
for issue in allIssues {
for subissue in issue.subissues {
modelContext.delete(subissue)
}
modelContext.delete(issue)
}
for subissue in allSubissues {
modelContext.delete(subissue)
}
// Saving breaks this: Thread 1: Fatal error: Context is missing for Optional(SwiftData.PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-coredata://5BA1C592-F9BF-45A0-9ED5-751B0A8AC593/Issue/p10), implementation: SwiftData.PersistentIdentifierImplementation))
// do {
// try modelContext.save()
// } catch {
// print("Model Context not saved! Error: \(error.localizedDescription)")
// }
}, label: {
Text("Delete All")
})
Spacer()
}
})
#if os(macOS)
Divider()
#endif
HStack {
List(selection: $selectedIssue, content: {
ForEach(allIssues) { issue in
Button(action: {
selectedIssue = issue
}) {
HStack {
Spacer()
Text(issue.name).bold()
Spacer()
Text("Subissues: \(issue.subissues.count)")
Spacer()
}
}
}
})
if let selectedIssue {
List(selectedIssue.subissues) { subIssue in
HStack {
Spacer()
Text(subIssue.name).bold()
Spacer()
if subIssue.numberEdited > 0 {
Text("Edited: \(subIssue.numberEdited)").bold()
} else {
Text("Edited: \(subIssue.numberEdited)").tint(.secondary)
}
Spacer()
}
}
} else {
Text("Please select an issue to see the subissues involved.")
.multilineTextAlignment(.center)
}
}
}
}
}
And this gives a macOS view of showing each time the button is pressed the sub issues created never increment to 1 while, when using TaskGroup, 15 isn't guaranteed to be created and remembered.
I'm essentially wondering if anyone else has this issue and if so have you figured out how to solve it? Thanks
UPDATE
Thank you so much for the help. In case people come across this in the future I will share the updated example from above. Here I added an actor class (in my actual code used it in a environmentObject so it's only created once) and updated the background class code. Here it is:
import SwiftData
import Foundation
@ModelActor public actor ExampleModelActor {
func incrementIssueNumbersSlowly(issueID: PersistentIdentifier) async -> String? {
let myIssue = modelContext.model(for: issueID) as? Issue
guard let myIssue else {
return "Unable to find issue."
}
let taskGroupResults = await withTaskGroup(
of: (myError: [String], myText: [String]).self,
returning: (myTexts: [String], myErrors: [String]).self,
body: { taskGroup in
// Loop through the issue's subissues. Which as above isn't saved is none
print("Going through increment. Have \(myIssue.subissues.count) subissue(s).")
for mySubissue in (myIssue.subissues) {
let thisID = mySubissue.persistentModelID
// Add child task to task group
taskGroup.addTask {
// Execute slow operation
let value = await self.incrementNumberSlowly(myID: thisID)
// let value = await self.incrementNumberSlowly(in: modelContainer, myID: thisID)
// Return child task result -> same as 'of' above
return (myError: value.myError == nil ? [] : [value.myError!], myText: value.myText == nil ? [] : [value.myText!])
}
}
var errors = [String]()
var myTexts = [String]()
// Collect results of all child task in a dictionary
for await result in taskGroup {
// append to other arrays
errors = errors+result.myError
myTexts = myTexts+result.myText
}
// Task group finish running & return task group result
return (myTexts: myTexts, myErrors: errors) // same value as allResults and returning above.
})
print("After calling background class values returned for issue count of \(myIssue.subissues.count) is \(taskGroupResults.myTexts.count) and \(taskGroupResults.myErrors.count)")
if taskGroupResults.myErrors.count > 0 {
print("My Texts")
print("\t* \(taskGroupResults.myTexts.joined(separator: "\n\t* "))")
}
if taskGroupResults.myErrors.count > 0 {
print("My Errors")
print("\t* \(taskGroupResults.myErrors.joined(separator: "\n\t* "))")
}
return nil
}
func incrementNumberSlowly(myID: PersistentIdentifier) async -> (myError: String?, myText: String?) {
let mySubIssue = modelContext.model(for: myID) as? Subissue
guard let mySubIssue else {
return (myError: "Unable to find subissue.", myText: nil)
}
print("In increment before: \(mySubIssue.numberEdited)")
mySubIssue.numberEdited = mySubIssue.numberEdited + 1
print("In increment after: \(mySubIssue.numberEdited)")
do {
try modelContext.save()
} catch {
return (myError: "Unable to increment the number. Error: \(error)", myText: nil)
}
return (myError: nil, myText: "Number incremented to \(mySubIssue.numberEdited)")
}
func createSubIssue(issueID: PersistentIdentifier, name: Int) async -> String? {
let thisIssue = modelContext.model(for: issueID) as? Issue
guard let thisIssue else { return "Unable to proceed as we're unable to find this issue." }
let newSubIssue = Subissue(name: "Subissue \(name) - \(Date.now.timeIntervalSince1970)", issue: thisIssue)
do {
try modelContext.save()
} catch {
return "Unable to save new subissue \(newSubIssue.name). Error: \(error)"
}
print("Created subissue: \(thisIssue.subissues.count) - \(newSubIssue.name)")
return nil
}
}
And updated BackgroundClass
:
import SwiftData
import Foundation
import os.log
final class BackgroundClass: Sendable {
let maxNumber = 100
let poi = OSSignposter(subsystem: "BackgroundClass", category: .pointsOfInterest)
public func asyncTaskGroup(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
let state = poi.beginInterval(#function, id: poi.makeSignpostID())
defer { poi.endInterval(#function, state) }
let modelContext = ModelContext(container)
modelContext.autosaveEnabled = false
// Create new issue
let issue = Issue(name: "TG - Issue: \(Date.now.timeIntervalSince1970)")
modelContext.insert(issue)
do {
try modelContext.save()
} catch {
return (error: "Unable to save. Error: \(error.localizedDescription)", issueID: nil)
}
// print("Main TG: Before creating the count is \(issue.subissues.count)")
poi.emitEvent(#function, "Main TG: Before creating the count is \(issue.subissues.count)")
let _ = await withTaskGroup(of: Void.self, body: { taskGroup in
let actor = ExampleModelActor(modelContainer: issue.modelContext!.container)
let newID = issue.persistentModelID
print("TG create: Before creating the count is \(issue.subissues.count)")
for i in 1...maxNumber {
// Add child task to task group
taskGroup.addTask {
async let _ = actor.createSubIssue(issueID: newID, name: i)
// let _ = await self.createSubIssue(in: modelContainer, issueID: newID, name: i)
// Return child task result
return
}
}
print("TG create: After creating the count is \(issue.subissues.count)")
})
poi.emitEvent(#function, "Main TG: Between the count is \(issue.subissues.count)")
let actor = ExampleModelActor(modelContainer: issue.modelContext!.container)
let errorReturned = await actor.incrementIssueNumbersSlowly(issueID: issue.persistentModelID)
if let error = errorReturned, error.count > 0 {
return (error: error, issueID: nil)
}
poi.emitEvent(#function, "Main TG: After processing the count is \(issue.subissues.count)")
return (error: nil, issueID: issue.persistentModelID)
}
public func asyncLetTest(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
let state = poi.beginInterval(#function, id: poi.makeSignpostID())
defer { poi.endInterval(#function, state) }
let modelContext = ModelContext(container)
modelContext.autosaveEnabled = false
// Create new issue
let issue = Issue(name: "AL - Issue: \(Date.now.timeIntervalSince1970)")
modelContext.insert(issue)
do {
try modelContext.save()
} catch {
return (error: "Unable to save. Error: \(error.localizedDescription)", issueID: nil)
}
poi.emitEvent(#function, "Main AL: Before creating the count is \(issue.subissues.count)")
let _ = await createSubissues(issue: issue)
poi.emitEvent(#function, "Main AL: Between the count is \(issue.subissues.count)")
let actor = ExampleModelActor(modelContainer: issue.modelContext!.container)
let errorReturned = await actor.incrementIssueNumbersSlowly(issueID: issue.persistentModelID)
if let error = errorReturned, error.count > 0 {
return (error: error, issueID: nil)
}
poi.emitEvent(#function, "Main AL: After processing the count is \(issue.subissues.count)")
return (error: nil, issueID: issue.persistentModelID)
}
// MARK: First set of items
internal final func createSubissues(issue: Issue) async {
let actor = ExampleModelActor(modelContainer: issue.modelContext!.container)
// let modelContainer = issue.modelContext!.container
let thisID = issue.persistentModelID
print("create: Before creating the count is \(issue.subissues.count)")
for i in 1...maxNumber {
async let _ = actor.createSubIssue(issueID: thisID, name: i)
}
print("create: After creating the count is \(issue.subissues.count)")
}
}
We are comparing apples-to-oranges. The task group implementation is performing createSubIssue
calls in parallel, but the async let
rendition, surprisingly, is not. This stems from a mistake async let
rendition (failing to ever await
) that is preventing parallel execution. And when we fix that in the async let
rendition (putting it on equal footing with the task group rendition), it (unfortunately) exhibits the exact same problem.
To illustrate this, let us add instrumentation to all the functions in BackgroundClass
, so we can profile the app with Instruments’ “Time Profiler” template and see what is going on in a timeline:
// BackgroundClass.swift
import Foundation
import SwiftData
import os.log
// MARK: Class with the async methods that are called
final class BackgroundClass: Sendable {
let poi = OSSignposter(subsystem: "BackgroundClass", category: .pointsOfInterest)
public func asyncTaskGroup(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
let state = poi.beginInterval(#function, id: poi.makeSignpostID())
defer { poi.endInterval(#function, state) }
…
// print("Main TG: Before creating the count is \(issue.subissues.count)")
poi.emitEvent(#function, "Main TG: Before creating the count is \(issue.subissues.count)")
…
}
public func asyncLetTest(in container: ModelContainer) async -> (error: String?, issueID: PersistentIdentifier?) {
let state = poi.beginInterval(#function, id: poi.makeSignpostID())
defer { poi.endInterval(#function, state) }
…
}
…
}
When we do that, and we profile the app and watch the task group rendition, we see it trying to add the subissues in parallel:
But if we look at the async let
rendition, they are not running in parallel:
The problem in the async let
rendition is that we never await
the results of the tasks. When you do async let
, you must always await
the result or else the task will be canceled and be implicitly awaited when the async let
falls out of scope. (The purpose of this is to ensure that, for example, if a routine has an early exit, that the now-unnecessary asynchronous work stops.) See the Implicit async let
awaiting discussion in SE-0317.
To fix the async let
rendition, let’s await
the tasks:
internal final func createSubissues(issue: Issue) async {
let state = poi.beginInterval(#function, id: poi.makeSignpostID())
defer { poi.endInterval(#function, state) }
let modelContainer = issue.modelContext!.container
let thisID = issue.persistentModelID
poi.emitEvent(#function, "create: Before creating the count is \(issue.subissues.count)")
// for i in 1...15 {
// async let _ = createSubIssue(in: modelContainer, issueID: thisID, name: i)
// }
async let task1 = createSubIssue(in: modelContainer, issueID: thisID, name: 1)
async let task2 = createSubIssue(in: modelContainer, issueID: thisID, name: 2)
async let task3 = createSubIssue(in: modelContainer, issueID: thisID, name: 3)
async let task4 = createSubIssue(in: modelContainer, issueID: thisID, name: 4)
async let task5 = createSubIssue(in: modelContainer, issueID: thisID, name: 5)
async let task6 = createSubIssue(in: modelContainer, issueID: thisID, name: 6)
async let task7 = createSubIssue(in: modelContainer, issueID: thisID, name: 7)
async let task8 = createSubIssue(in: modelContainer, issueID: thisID, name: 8)
async let task9 = createSubIssue(in: modelContainer, issueID: thisID, name: 9)
async let task10 = createSubIssue(in: modelContainer, issueID: thisID, name: 10)
async let task11 = createSubIssue(in: modelContainer, issueID: thisID, name: 11)
async let task12 = createSubIssue(in: modelContainer, issueID: thisID, name: 12)
async let task13 = createSubIssue(in: modelContainer, issueID: thisID, name: 13)
async let task14 = createSubIssue(in: modelContainer, issueID: thisID, name: 14)
async let task15 = createSubIssue(in: modelContainer, issueID: thisID, name: 15)
_ = await [task1, task2, task3, task4, task5, task6, task7, task8, task9, task10, task11, task12, task13, task14, task15]
poi.emitEvent(#function, "create: After creating the count is \(issue.subissues.count)")
}
When we profile it now, we do see parallel execution:
But unfortunately, we also now see it manifesting the same problem as the task group example. In the following screen snapshot, the first AL
is the original async let
rendition, and the following TG
entries are the task group rendition. The last AL
, though, is with my rendition of the async let
with the necessary await
calls. As you will notice, this will now also drop a record:
Now that we are comparing apples-to-apples, the question is how to fix this “parallel insert” process. I might advise cutting the Gordian knot, and not attempt to run these in parallel at all. For example, here I created a rendition that just inserts the records sequentially. (I also changed createSubIssue
not to be an async
function given that it does not await
anything and the only utility of a nonisolated
async
function is to get it off the current thread, which is not necessary if no longer trying to do this in parallel.)
When I did that, the serial rendition is actually a fraction faster. Compare the average time elapsed for the three renditions, the (corrected) async let
, the task group, and the non-parallel rendition, performing each four times:
And not only is the sequential rendition slightly faster, but it successfully inserts all fifteen subissues successfully:
Given that all of this “parallel” work is all within the same routine, the non-parallel rendition is fine. When I really have diverse asynchronous jobs trying to update the same database, I use ModelActor
to synchronize the interaction with this shared resource.