SwiftData does not work on a background Task even inside a custom ModelActor.

I have created an actor for the ModelContainer, in order to perform a data load when starting the app in the background. For this I have conformed to the ModelActor protocol and created the necessary elements, even preparing for test data.

Then I create a function of type async throws to perform the database loading processes and everything works fine, in that the data is loaded and when loaded it is displayed reactively.

actor Container: ModelActor {
    nonisolated let modelContainer: ModelContainer
    nonisolated let modelExecutor: ModelExecutor

    static let modelContainer: ModelContainer = {
        do {
            return try ModelContainer(for: Empleados.self)
        } catch {
            fatalError()
        }
    }()
    
    let context: ModelContext
    
    init(container: ModelContainer = Container.modelContainer) {
        self.modelContainer = container
        let context = ModelContext(modelContainer)
        self.modelExecutor = DefaultSerialModelExecutor(modelContext: context)
        self.context = context
        Task {
            do {
                try await loadData()
            } catch {
                print("Error en la carga \(error)")
            }
        }
    }
}

The problem is that, in spite of doing the load inside a Task and that there is no problem, when starting the app it stops responding the UI while loading to the user interactions. Which gives me to understand that actually the task that should be in a background thread is running somehow over the MainActor.

As I have my own API that will provide the information to my app and refresh it at each startup or even send them in Batch when the internet connection is lost and comes back, I don't want the user to be continuously noticing that the app stops because it is performing a heavy process that is not really running in the background.

Tested and compiled on Xcode 15 beta 7.

I made a Feedback for this: FB13038621.

Thanks Julio César

I try to save data off the main actor with the following actor

@ModelActor final actor DataActor {
    init(container: ModelContainer) {
        let context = ModelContext(container)
        context.autosaveEnabled = true
        modelContainer = container
        modelExecutor = DefaultSerialModelExecutor(modelContext: context)
    }
}

and fetch data with the main context, but no data are fetched. Data might not be saved by the above actor. After switching to data saving with the main context, it works.

Subject: Critical Issue with SwiftData in Xcode 15

Dear Apple Developer Support,

I hope this message finds you well. I am writing to address a significant concern with the SwiftData framework introduced at WWDC 2023.

Background: In June, during WWDC 2023, Apple unveiled SwiftData, a construct built upon the new Swift macros. This construct overlays a new API on top of Objective-C's Core Data, aiming to simplify and make data persistence more intuitive. The primary focus of SwiftData, as demonstrated in the WWDC code samples and videos, is to support apps that start from a data-empty state (akin to the Notes app) and gradually populate data, potentially integrating with a CloudKit-based backend. When integrated in this manner, predominantly through SwiftUI without background processes, the experience is seamless and indeed a significant improvement. However, there's a limitation: real-time access to the Predicate of a @Query is missing, requiring the query to be recreated.

The Issue: The challenge arises when attempting to utilize SwiftData for background operations, especially with concurrent threads. SwiftData provides developers with only the mainContext, which is explicitly tied to the @MainActor, restricting its use to the main thread. This becomes problematic when interfacing with a custom API, where data needs to be frequently sent or received from a server. To create background contexts, developers are forced to resort to the ModelActor protocol, which mandates the use of Actors, contexts, and executors to manage these contexts. Ideally, SwiftData should offer an out-of-the-box background context, rather than requiring developers to set it up manually.

Up until beta 6, using the background context to, for instance, send or fetch data from a server, especially during batch loads of over 1,000 records (essential for app initialization), worked flawlessly. However, once the background data load completed, the UI wouldn't refresh automatically, necessitating a restart. From beta 7 onwards, while the data does refresh, the background context ceased to function correctly. Even when encapsulated within a Task and ensuring operations are in the background, accessing the database context occurs on the main thread. This behavior stalls the UI during batch loads and results in a choppy UI experience during data send/receive operations with custom APIs.

From my analysis, it seems that to address the UI refresh issue, some processes were elevated to the main thread. However, this change severely compromises the efficiency of background operations.

Conclusion: We are now working with the final version of Xcode 15, and such a critical issue in a library as pivotal as SwiftData is concerning. I kindly urge the team to address this matter promptly.

Thank you for your attention to this matter.

Warm regards,

Julio César Fernández

Xcode 15.1 beta with iOS 17 beta 2, and still not working.

Is there any deadline to fix this bug, is it feasible, would it arrive with iOS 17.1 and all other systems?

I promise that if I could I would give you a hand.

Thank you very much and best regards

Using Task.detached { ... } works for me. I'm seeing the model actor methods being run on a background thread:

@ModelActor
actor CustomModelActor {
  func updateObjects(...) {
    // This will run on a background thread
  }
}

struct MainView: View {
  @Environment(\.modelContext) var modelContext

  var body: some View {
    MyView()
      .task {
        let container = modelContext.container

        Task.detached {
          let modelActor = CustomModelActor(modelContainer: container)
          try await modelActor.updateObjects(...)
        }
      }
  }
}

Note that you need to create CustomModelActor in the detached Task as well, otherwise it will be bound to the thread it was created on (which in this case would be the main thread).

The bug is in SwiftData’s ModelContext initializer implementation. The initializer checks whether it is running on the main dispatch queue and if so, configures the context as a main context rather than a background context. More details in FB13399899 (Open Radar ID 5518888167014400).

Here is my workaround:

extension ModelContext {
   struct UncheckedSendableWrapper: @unchecked Sendable {
      let modelContext: ModelContext
   }

   /// Creates a background context.
   ///
   /// - Remark: This method works around FB13399899, which causes `init(_:)` to sometimes configure the instance
   /// as a main context. This method is marked `async` and not `@MainActor`, which guarantees that the method will be
   /// called off of the main thread, working around the bug in the initializer implementation.
   static func makeBackgroundContext(for container: ModelContainer) async -> UncheckedSendableWrapper {
      let modelContext = ModelContext(container)
      return UncheckedSendableWrapper(modelContext: modelContext)
   }
}

actor MyModelActor: ModelActor {
   …

   init(container: ModelContainer) async {
      let context = (await ModelContext.makeBackgroundContext(for: container)).modelContext
   }

   …
}

It turns out that it's not just the context that runs on the main thread, but the actor appears to be isolated to the main thread as well. If we create a normal actor, it runs on thread from the thread pool (not the main thread). However, if we create a ModelActor, it appears to inherit the thread from its parent. You can test this with Thread.isMainThread.

@ModelActor
final actor MyActor {
    var contextIsMainThread: Bool?
    var actorIsMainThread: Bool?
    
    func determineIfContextIsMainThread() {
        try? modelContext.transaction {
            self.contextIsMainThread = Thread.isMainThread
        }
    }
    
    func determineIfActorIsMainThread() {
        self.actorIsMainThread = Thread.isMainThread
    }
}

As has been discussed above, you get this behavior based on how your model actor is initiated.

content
.onAppear {
            actorCreatedOnAppear = MyActor(modelContainer: mainContext.container) // actor and modelContext are on main thread
            Task {
                self.actorCreatedOnAppearTask = MyActor(modelContainer: mainContext.container) // actor and modelContext are on main thread
            }
            Task.detached {
                self.actorCreatedOnAppearDetachedTask = MyActor(modelContainer: mainContext.container) // actor and modelContext are NOT on main thread. This is the only option which matches the behavior of other Swift actors.
            }
        }

I've submitted FB13450413 with a project demonstrating this.

I'm seeing similar behavior (ModelActor eagerly dispatching work to main) in Xcode_16_beta_3. Has anyone heard if this is supposed to be fixed… or should we plan to continue to ship with these workarounds going forward when the new OS is released?

There is a much simpler approach. You don't have to use @ModelActor macro or implement ModelActor protocol. Apple have got it wrong. Database code should never execute on the main thread. Instead I'm doing my own thing:

actor ProjectRepository: ProjectStorage {

    nonisolated let modelExecutor: any ModelExecutor
    nonisolated let modelContainer: ModelContainer

    private var modelContext: ModelContext { modelExecutor.modelContext }

    init(modelContainer: ModelContainer) {
        assertOnMainThread() // Custom function
        self.modelExecutor = DefaultSerialModelExecutor(modelContext: ModelContext(modelContainer))
        self.modelContainer = modelContainer
}

    func persist() throws {
        assertNotOnMainThread() // Custom function
        try modelContext.save()
    }
}

Works like a charm.

SwiftData does not work on a background Task even inside a custom ModelActor.
 
 
Q