Swift 6 Concurrency errors with ModelActor, or Core Data actors

In my app, I've been using ModelActors in SwiftData, and using actors with a custom executor in Core Data to create concurrency safe services.

I have multiple actor services that relate to different data model components or features, each that have their own internally managed state (DocumentService, ImportService, etc).

The problem I've ran into, is that I need to be able to use multiple of these services within another service, and those services need to share the same context. Swift 6 doesn't allow passing contexts across actors.

The specific problem I have is that I need a master service that makes multiple unrelated changes without saving them to the main context until approved by the user.

I've tried to find a solution in SwiftData and Core Data, but both have the same problem which is contexts are not sendable. Read the comments in the code to see the issue:

/// This actor does multiple things without saving, until committed in SwiftData.
@ModelActor
actor DatabaseHelper {
    func commitChange() throws {
        try modelContext.save()
    }
    
    func makeChanges() async throws {
        // Do unrelated expensive tasks on the child context...
        
        // Next, use our item service
        let service = ItemService(modelContainer: SwiftDataStack.shared.container)
        let id = try await service.expensiveBackgroundTask(saveChanges: false)
        
        // Now that we've used the service, we need to access something the service created.
        // However, because the service created its own context and it was never saved, we can't access it.
       let itemFromService = context.fetch(id) // fails
        
        // We need to be able to access changes made from the service within this service, 
        /// so instead I tried to create the service by passing the current service context, however that results in:
        // ERROR: Sending 'self.modelContext' risks causing data races
        let serviceFromContext = ItemService(context: modelContext)
        
        // Swift Data doesn't let you create child contexts, so the same context must be used in order to change data without saving. 
    }
}

@ModelActor
actor ItemService {
    init(context: ModelContext) {
        modelContainer = SwiftDataStack.shared.container
        modelExecutor = DefaultSerialModelExecutor(modelContext: context)
    }
    
    func expensiveBackgroundTask(saveChanges: Bool = true) async throws -> PersistentIdentifier? {
        // Do something expensive...
        return nil
    }
}

Core Data has the same problem:

/// This actor does multiple things without saving, until committed in Core Data.
actor CoreDataHelper {
    let parentContext: NSManagedObjectContext
    let context: NSManagedObjectContext
    
    /// In Core Data, I can create a child context from a background context.
    /// This lets you modify the context and save it without updating the main context.
    init(progress: Progress = Progress()) {
        parentContext = CoreDataStack.shared.newBackgroundContext()
        let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
        childContext.parent = parentContext
        self.context = childContext
    }
    
    /// To commit changes, save the parent context pushing them to the main context.
    func commitChange() async throws {
        // ERROR: Sending 'self.parentContext' risks causing data races
        try await parentContext.perform {
            try self.parentContext.save()
        }
    }

    func makeChanges() async throws {
        // Do unrelated expensive tasks on the child context...

        // As with the Swift Data example, I am unable to create a service that uses the current actors context from here.
        // ERROR: Sending 'self.context' risks causing data races
        let service = ItemService(context: self.context)
    }

}

Am I going about this wrong, or is there a solution to fix these errors?

Some services are very large and have their own internal state. So it would be very difficult to merge all of them into a single service. I also am using Core Data, and SwiftData extensively so I need a solution for both.

I seem to have trapped myself into a corner trying to make everything concurrency save, so any help would be appreciated!

Answered by DTS Engineer in 827503022

I'd like to make it clear that ModelContext and NSManagedObjectContext being un-sendable is as-designed, and so there is no way to pass a context across actors without triggering potentail data races.

In your situation, a common pattern, as @joadan said, is to wrap the tentative changes with some Senable types, which you can pass across actors, and convert the data to SwiftData models or Core Data managed objects when you are ready to save.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

One solution could be to actually call save() and then have a manual rollback function that when called would delete the object with a given id. That way you could hopefully avoid creating strong dependencies between your services (actors). Of course if there are cases that are more complicated than the one in your code that returns a single id a solution like this could easily become quite complex so it depends on your use case if this is doable.

Thanks! Unfortunately, the service makes so many different changes to items and their relationships that would be difficult to undo.

Another reason I can't save changes is that if the master service fails halfway though it could cause data corruption, which is why it only commits changes after it completes.

I believe you need to rethink your design then, you can't have a bunch of different operations that needs to be saved together spread over different actors. It's just not possible.

Maybe work with struct's instead so you can pass them between actors and have only one central actor that converts from struct objects to model objects and handles the storing and saving of objects in a single transaction.

I'd like to make it clear that ModelContext and NSManagedObjectContext being un-sendable is as-designed, and so there is no way to pass a context across actors without triggering potentail data races.

In your situation, a common pattern, as @joadan said, is to wrap the tentative changes with some Senable types, which you can pass across actors, and convert the data to SwiftData models or Core Data managed objects when you are ready to save.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Swift 6 Concurrency errors with ModelActor, or Core Data actors
 
 
Q