With Xcode Version 15.0 beta 6 (15A5219j) I try to insert Entities on a background thread using a ModelActor.
The example App crashes on the Simulator at different times with an EXC_BAD_ACCESS error on an Entity insert.
Switching context.autosaveEnabled to false still produces crashes, but at different intervals. With this setting, all Entities will be synchronized with the modelContext of the modelContainer only at the end of all inserts.
To me it seems that there is an internal pointer loss, maybe with SwiftData's _$backingData.
How can I do background inserts with SwiftData?
The source code being tested:
TestSwiftDataAsyncApp.swift
import SwiftUI
import SwiftData
@main
struct TestSwiftDataAsyncApp: App {
let modelContainer: ModelContainer
let backgroundActor: BackgroundActor
init() {
do {
self.modelContainer = try ModelContainer(for: Item.self, ModelConfiguration(for: Item.self))
backgroundActor = BackgroundActor(modelContainer: modelContainer)
} catch {
fatalError("\(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView(backgroundActor: backgroundActor)
}
.modelContainer(modelContainer)
}
}
ContentView.swift
import SwiftUI
import SwiftData
import OSLog
private let logger = Logger(subsystem: "TestSwiftDataAsync", category: "ContentView")
struct ContentView: View {
let backgroundActor: BackgroundActor
@State private var batchSize = "10"
@State private var timeDelay = "1000"
@State private var isLoading = false
@Environment(\.modelContext) private var modelContext
@Query(sort: \Item.timestamp, order: .forward)
private var items: [Item]
var body: some View {
NavigationSplitView {
List {
HStack {
Text("Size: ")
TextField("Size", text: $batchSize)
.textFieldStyle(.roundedBorder)
Text("Delay ms: ")
TextField("Time delay in milliseconds", text: $timeDelay)
.textFieldStyle(.roundedBorder)
Button("Load") {
addItems()
}
.buttonStyle(.borderedProminent)
}
ForEach(items) { item in
NavigationLink {
Text("(\(item.index)) at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text("(\(item.index)) at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
}
}
.onDelete(perform: deleteItems)
}
.overlay(alignment: .topLeading) {
if isLoading {
Text("Loading...")
.foregroundStyle(.red)
.padding()
}
}
.toolbar {
ToolbarItem {
Button(action: deleteAll) {
Label("Delete all items", systemImage: "x.circle")
}
}
}
} detail: {
Text("Select an item")
}
}
private func deleteAll() {
logger.info("deleteAll()")
Task {
await backgroundActor.deleteAllItems()
}
}
private func addItems() {
guard !isLoading else { return }
logger.info("addItems()")
isLoading = true
Task {
logger.info("start task addItems()")
let startIndex = (items.last?.index ?? -1) + 1
let size = Int(batchSize) ?? 10
let delay = Int(timeDelay) ?? 1
await backgroundActor.load(startIndex: startIndex, endIndex: startIndex + size, delay: delay)
await MainActor.run {
isLoading = false
}
logger.info("end task addItems()")
}
logger.info("end addItems()")
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
}
#Preview {
ContentView(backgroundActor: BackgroundActor(modelContainer: try! ModelContainer(for: Item.self, ModelConfiguration(for: Item.self, inMemory: true))))
.modelContainer(for: Item.self, inMemory: true)
}
Item.swift
import Foundation
import SwiftData
@Model
final class Item {
var index: Int
var timestamp: Date
init(index: Int, timestamp: Date) {
self.index = index
self.timestamp = timestamp
}
}
BackgroundActor.swift
import Foundation
import OSLog
import SwiftData
private let logger = Logger(subsystem: "TestSwiftDataAsync", category: "BackgroundActor")
actor BackgroundActor: ModelActor {
let executor: any ModelExecutor
let backgroundExecutor = BackgroundExecutor()
init(modelContainer: ModelContainer) {
self.executor = DefaultModelExecutor(context: ModelContext(modelContainer))
// self.executor.context.autosaveEnabled = false
}
func load(startIndex: Int, endIndex: Int, delay: Int = 1000) async {
logger.info("start load(startIndex: \(startIndex), endIndex: \(endIndex))")
logger.info("\(Thread.current.description)")
await backgroundExecutor.load(from: startIndex, to: endIndex, with: executor, delay: delay)
// save is not nescessary!
do {
try context.save()
} catch {
logger.error("\(error.localizedDescription)")
}
logger.info("end load(startIndex: \(startIndex), endIndex: \(endIndex)")
}
func deleteAllItems() {
do {
try executor.context.fetch(FetchDescriptor<Item>()).forEach { item in
executor.context.delete(item)
}
try executor.context.save()
} catch {
logger.error("\(error)")
}
}
}
BackgroundExecutor.swift
import Foundation
import OSLog
import SwiftData
private let logger = Logger(subsystem: "TestSwiftDataAsync", category: "BackgroundExecutor")
actor BackgroundExecutor {
/// use Sendable ModelExecutor
func load(from: Int, to: Int, with executor: any ModelExecutor, delay milliseconds: Int = 0) async {
logger.info("load(from: \(from), to: \(to), with executor: -, delay milliseconds: \(milliseconds))")
logger.info("\(Thread.current.description)")
for i in from..<to {
try? await Task.sleep(for: .milliseconds(milliseconds))
let newItem = Item(index: i, timestamp: .now)
executor.context.insert(newItem)
}
// do {
// try executor.context.save()
// } catch {
// logger.error("\(error)")
// }
}
}
A solution:
You have to create the ModelActor on a different thread than the main tread. You can do this by instantiating the ModelActor within a Task and with the ModelContainer as a parameter. The ModelContainer is Sendable.
actor BackgroundActor: ModelActor {
let executor: any ModelExecutor
init(container: ModelContainer) {
self.executor = DefaultModelExecutor(context: ModelContext(container))
// self.context.autosaveEnabled = false
}
func doStuff() {
for i in 0..<10000 {
let newItem = Item(timestamp: .now)
context.insert(newItem)
}
try? context.save()
}
}
Task {
await BackgroundActor(container: modelContext.container).doStuff()
}
I don't believe you need to reference the executor at all - since the ModelContainer is sendable, you simply capture a reference to that in the background actor (as you have done), and then use the context from the container. Without attempting to refactor your code, here is a simple example that is working for me: I define the ModelActor similarly, and add a trivial example to fetch data where the label's character count > 2, and then log the results
actor BackgroundActor: ModelActor {
let executor: any ModelExecutor
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PerformanceLogActor")
init(container: ModelContainer) {
executor = DefaultModelExecutor(context: ModelContext(container))
}
func printDataLabels(biggerThan maxSize: Int) {
let predicate = #Predicate<PerformanceLog>{ $0.dataLabel.count > 2 }
let fetchDescriptor: FetchDescriptor<PerformanceLog> = FetchDescriptor(predicate: predicate)
do {
let perfLogs = try context.fetch(fetchDescriptor)
Set(perfLogs).forEach { p in
logger.debug("\(p.dataLabel)")
}
} catch {
logger.log("Error fetching perf logs. \(error.localizedDescription)")
}
}
}
and then in the main view, I define this:
func printDataLabels() async {
var retValue: Int
let t = Task {
await BackgroundActor(container: context.container).printDataLabels(biggerThan: 2)
}
// Optionally; retValue = await t.value
}
which I call from;
.task { await printDataLabels() }
on the view.
Hope that helps.