DEV Community

GoyesDev
GoyesDev

Posted on

[SC] Usando el executor de un actor para acceder a una base de datos

Comprensión durante la lectura

¿Cuándo tiene sentido usar un actor personalizado versus el viewContext directamente?

actor CoreDataStore {

  static let shared = CoreDataStore()

  let persistentContainer: NSPersistentContainer

  nonisolated let modelExecutor: NSModelObjectContextExecutor

  nonisolated var unownedExecutor: UnownedSerialExecutor {
    modelExecutor.asUnownedSerialExecutor()
  }

  private var context: NSManagedObjectContext { modelExecutor.context }

  private init() {
    persistentContainer = NSPersistentContainer(name: "CoreDataConcurrency")
    persistentContainer.viewContext.automaticallyMergesChangesFromParent = true

    modelExecutor = NSModelObjectContextExecutor(context: persistentContainer.newBackgroundContext())

    Task {
      do {
        try await persistentContainer.loadPersistentStores()
      } catch {
        print("Failed to load persistent store: \(error)")
      }
    }
  }

  func deleteAllAndSave<T: NSManagedObject>(using fetchRequest: NSFetchRequest<T>) throws {
    let objects = try context.fetch(fetchRequest)
    for object in objects {
      context.delete(object)
    }
    try context.save()
  }
}
Enter fullscreen mode Exit fullscreen mode
extension NSPersistentContainer {
  func loadPersistentStores() async throws {
    try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
      self.loadPersistentStores { storeDescription, error in
        if let error {
          continuation.resume(throwing: error)
        } else {
          continuation.resume(returning: ())
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

La mayoría de aplicaciones que usan Core Data trabajan directamente con viewContext, así que introducir el actor CoreDataStore va a forzar usar concurrencia en lugares donde no es realmente necesaria (porque se puede usar viewContext desde @MainActor directamente).

Se podría decir que tal vez en segundo plano se puede hacer, sin embargo, puede ser más fácil usar:

try await backgroundContext.perform {
  /// Perform heavy query & work...
  try backgroundContext.save()
}
Enter fullscreen mode Exit fullscreen mode

¿Qué diferencia hay entre un actor regular y un global actor para este caso?

@globalActor
actor CoreDataBackgroundContext {
  static let shared = CoreDataBackgroundContext()

  nonisolated let modelExecutor: NSModelObjectContextExecutor

  nonisolated var unownedExecutor: UnownedSerialExecutor {
    modelExecutor.asUnownedSerialExecutor()
  }

  init() {
    let backgroundContext = CoreDataStore.shared.persistentContainer.newBackgroundContext()
    modelExecutor = NSModelObjectContextExecutor(context: backgroundContext)
  }
}
Enter fullscreen mode Exit fullscreen mode
nonisolated struct CoreDataStoreStructure {

  let viewContext: NSManagedObjectContext
  let backgroundContext: NSManagedObjectContext

  @CoreDataBackgroundContext 
  func performHeavyQuery(closure: (NSManagedObjectContext) throws -> Void) rethrows {
    try closure(backgroundContext)
  }

  @MainActor
  func performWorkOnViewContext(closure: @escaping (NSManagedObjectContext) throws -> Void) async rethrows {
    try closure(viewContext)
  }
}
Enter fullscreen mode Exit fullscreen mode

Usar el actor normal podría obligar a que todos los llamados usen concurrencia. Sin embargo, al usar el actor global, se podría crear un par de métodos (performHeavyQuery y performWorkOnViewContext) para envolver el llamado a los contextos respectivos, en lugar de forzar al desarrollar a hacer await siempre. Sin embargo, esta implementación depende de que el desarrollar llame correctamente el método que es (por ejemplo: llamar el método performWorkOnViewContext desde un hilo de segundo plano).

¿Qué rol cumple SerialExecutor en la implementación de NSModelObjectContextExecutor?

SerialExecutor me permite serializar el acceso al NSManagedObjectContext dentro de NSModelObjectContextExecutor. Notar que dentro de enqueue se ejecuta context.perform, lo que implica que cada tarea se va a ejecutar de forma serial.

import CoreData

final class NSModelObjectContextExecutor: @unchecked Sendable, SerialExecutor {

  private let context: NSManagedObjectContext

  init(context: NSManagedObjectContext) {
    self.context = context
  }

  func enqueue(_ job: consuming ExecutorJob) {
    let unownedJob = UnownedJob(job)
    let unownedExecutor = asUnownedSerialExecutor()

    /// Execute the enqueued job on the configured managed object context.
    context.perform {
      unownedJob.runSynchronously(on: unownedExecutor)
    }
  }

  func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    UnownedSerialExecutor(ordinary: self)
  }
}
Enter fullscreen mode Exit fullscreen mode

¿Por qué se usa @unchecked Sendable en el executor?

El executor va a tener una referencia a NSManagedObjectContext que, a pesar de ser let, no es Sendable. Por esto, es @unchecked Sendable.

¿Qué hace el método enqueue(_:) y por qué es necesario?

enqueue(_:) ejecuta las tareas que hayan sido encoladas en el executor.

Implementación final

nonisolated struct CoreDataStore {

  static let shared = CoreDataStore()

  let persistentContainer: NSPersistentContainer
  private var viewContext: NSManagedObjectContext { persistentContainer.viewContext }

  private init() {
    persistentContainer = NSPersistentContainer(name: "CoreDataConcurrency")
    persistentContainer.viewContext.automaticallyMergesChangesFromParent = true

    Task { [persistentContainer] in
      do {
        try await persistentContainer.loadPersistentStores()
      } catch {
        print("Failed to load persistent store: \(error)")
      }
    }
  }

  @MainActor
  func perform(_ block: (NSManagedObjectContext) throws -> Void) rethrows {
    try block(viewContext)
  }

  @concurrent func deleteAllAndSave<T: NSManagedObject>(using fetchRequest: NSFetchRequest<T>) async throws {
    let backgroundContext = persistentContainer.newBackgroundContext()
    try await backgroundContext.perform {
      let objects = try backgroundContext.fetch(fetchRequest)
      for object in objects {
        backgroundContext.delete(object)
      }
      try backgroundContext.save()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Recordar sin releer

¿Puedes explicar con tus propias palabras por qué el autor termina descartando el custom executor?

¿Qué problema resuelve el atributo @concurrent en deleteAllAndSave?

¿Por qué el perform del CoreDataStore final debe ejecutarse en @MainActor?


Revisión y síntesis

¿Cuál es la solución final propuesta y qué ventajas ofrece sobre las alternativas exploradas?

¿Qué principio de diseño guía la decisión final del autor y cómo se refleja en el código?

¿En qué escenarios reales seguirías usando backgroundContext.perform {} en lugar de un actor personalizado?


Bibliografía

Top comments (0)