Search code examples
swiftmultithreadingcore-databackground-process

Multi-threaded core data sometimes returns nil properties


I am new to core data. I have an app that uses core data as local store. Writing to and reading from core data is done by background threads. While this works generally, in rare cases are the fetched data wrong, i.e. properties of a fetched entity are nil.
To check the situation, I wrote a unit test that starts 2 async threads: One fetches continuously from core data, and the other one overwrites continuously these data by first deleting all data, and then storing new data.
This test pretty quickly provokes the error, but I have no idea why. Of course I guess this is a multi-threading problem, but I don’t see why, because fetches and deletion+writes are done in separate managed contexts of a single persistentContainer.
I am sorry that the code below is pretty long, although shortened, but I think without it one cannot identify the problem.
Any help is highly welcome!

Here is my function to fetch data:

func fetchShoppingItems(completion: @escaping (Set<ShoppingItem>?, Error?) -> Void) {
    persistentContainer.performBackgroundTask { (managedContext) in 
        let fetchRequest: NSFetchRequest<CDShoppingItem> = CDShoppingItem.fetchRequest()
        do {
            let cdShoppingItems: [CDShoppingItem] = try managedContext.fetch(fetchRequest)
            for nextCdShoppingItem in cdShoppingItems {
                nextCdShoppingItem.managedObjectContext!.performAndWait {
                    let nextname = nextCdShoppingItem.name! // Here, sometimes name is nil
                } // performAndWait
            } // for all cdShoppingItems
            completion(nil, nil)
            return
        } catch let error as NSError {
            // error handling
            completion(nil, error)
            return
        } // fetch error
    } // performBackgroundTask
} // fetchShoppingItems

I have commented the line that sometimes crashes the test, since name is nil.

Here are my functions to store data:

func overwriteCD(shoppingItems: Set<ShoppingItem>,completion: @escaping () -> Void) {
    persistentContainer.performBackgroundTask { (managedContext) in 
        self.deleteAllCDRecords(managedContext: managedContext, in: "CDShoppingItem")
        let cdShoppingItemEntity = NSEntityDescription.entity(forEntityName: "CDShoppingItem",in: managedContext)!
        for nextShoppingItem in shoppingItems {
            let nextCdShoppingItem = CDShoppingItem(entity: cdShoppingItemEntity,insertInto: managedContext)
            nextCdShoppingItem.name = nextShoppingItem.name
        } // for all shopping items
        self.saveManagedContext(managedContext: managedContext)
        completion()
    } // performBackgroundTask
} // overwriteCD  

func deleteAllCDRecords(managedContext: NSManagedObjectContext, in entity: String) {
    let deleteFetch = NSFetchRequest<NSFetchRequestResult>(entityName: entity)
    let deleteRequest = NSBatchDeleteRequest(fetchRequest: deleteFetch)
    deleteRequest.resultType = .resultTypeObjectIDs
    do {
        let result = try managedContext.execute(deleteRequest) as? NSBatchDeleteResult
        let objectIDArray = result?.result as? [NSManagedObjectID]
        let changes = [NSDeletedObjectsKey: objectIDArray]
        NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes as [AnyHashable: Any], into: [managedContext])
    } catch let error as NSError {
        // error handling
    }
} // deleteAllCDRecords

func saveManagedContext(managedContext: NSManagedObjectContext) {
    if !managedContext.hasChanges { return }
    do {
        try managedContext.save()
    } catch let error as NSError {
        // error handling
    }
} // saveManagedContext

Solution

  • The problem with my code was apparently a race condition:
    While the „fetch“ thread fetched the core data records, and tried to assign the attributes to the properties, the „store“ thread deleted the records.
    This apparently released the attribute objects, so that nil was stored as property.
    I thought that the persistentContainer would automatically prevent this, but it does not.

    The solution is to execute both background threads of the persistentContainer in a concurrent serial queue, the „fetch“ thread synchronously, and the „store“ thread asynchronously with a barrier.
    So, concurrent fetches can be executed, while a store waits until all current fetches are finished.

    The concurrent serial queue is defined as

    let localStoreQueue = DispatchQueue(label: "com.xxx.yyy.LocalStore.localStoreQueue", 
        attributes: .concurrent)  
    

    EDIT:
    In the following fetch and store functions, I moved the core data function persistentContainer.performBackgroundTask inside the localStoreQueue. If it were outside as in my original answer, the store code in localStoreQueue.async(flags: .barrier) would setup a new thread and thus use managedContext in another thread that it was created in, which is a core data multi-threading error.

    The „fetch“ thread is modified as

    localStoreQueue.sync {
      self.persistentContainer.performBackgroundTask { (managedContext) in
        let fetchRequest: NSFetchRequest<CDShoppingItem> = CDShoppingItem.fetchRequest()
        //…
      } // performBackgroundTask  
    } // localStoreQueue.sync  
    

    and the „store“ thread as

    localStoreQueue.async(flags: .barrier) {
      self.persistentContainer.performBackgroundTask { (managedContext) in
        self.deleteAllCDRecords(managedContext: managedContext, in: "CDShoppingItem")
        //…
      } // performBackgroundTask
    } // localStoreQueue.async