swiftswiftdata

SwiftData insert crashes with EXC_BAD_ACCESS using background thread from ModelActor


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()
}


Solution

  • 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.