iosswiftcore-datacombineios-multithreading

How to use newBackgroundContext() with URLSession.shared.dataTaskPublisher?


I have created a simple Core Data project at Github to demonstrate my problem:

Xcode screenshot

My test app downloads a JSON list of objects, stores it in Core Data and displays in a SwiftUI List via @FetchRequest.

Because the list of objects has 1000+ elements in my real app, I would like to save the entities into the Core Data on a background thread and not on the main thread.

Preferably I would like to use the same default background thread, which is already used by the URLSession.shared.dataTaskPublisher.

So in the standard Persistence.swift generated by Xcode I have added only 2 lines:

container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
container.viewContext.automaticallyMergesChangesFromParent = true

and in my DownloadManager.swift singleton I call:

static let instance = DownloadManager()

var cancellables = Set<AnyCancellable>()

// How to run this line on the background thread of URLSession.shared.dataTaskPublisher?
let backgroundContext = PersistenceController.shared.container.newBackgroundContext()

private init() {
    getTops()
}

func getTops() {
    guard let url = URL(string: "https://slova.de/ws/top") else { return }
    
    URLSession.shared.dataTaskPublisher(for: url)
        .tryMap(handleOutput)
        .decode(type: TopResponse.self, decoder: JSONDecoder())
        .sink { completion in
            print(completion)
        } receiveValue: { [weak self] returnedTops in
            for top in returnedTops.data {
                // the next line fails with EXC_BAD_INSTRUCTION
                let topEntity = TopEntity(context: self!.backgroundContext)
                topEntity.uid = Int32(top.id)
                topEntity.elo = Int32(top.elo)
                topEntity.given = top.given
                topEntity.avg_score = top.avg_score ?? 0.0
            }
            self?.save()
        }
        .store(in: &cancellables)
}

As you can see in the above screenshot, this fails with

Thread 4: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)

because I have added the following "Arguments Passed on Launch" in Xcode:

-com.apple.CoreData.ConcurrencyDebug 1

Could anyone please advise me, how to call the newBackgroundContext() on the proper thread?

UPDATE:

I have tried to workaround my problem as in below code, but the error is the same:

URLSession.shared.dataTaskPublisher(for: url)
    .tryMap(handleOutput)
    .decode(type: TopResponse.self, decoder: JSONDecoder())
    .sink { completion in
        print(completion)
    } receiveValue: { returnedTops in
        let backgroundContext = PersistenceController.shared.container.newBackgroundContext()

        for top in returnedTops.data {
            // the next line fails with EXC_BAD_INSTRUCTION
            let topEntity = TopEntity(context: backgroundContext)
            topEntity.uid = Int32(top.id)
            topEntity.elo = Int32(top.elo)
            topEntity.given = top.given
            topEntity.avg_score = top.avg_score ?? 0.0
        }

        do {
            try backgroundContext.save()
        } catch {
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }

UPDATE 2:

I was assuming that when newBackgroundContext() is called, it takes the current thread and then you can use that context from the same thread...

This seems not to be the case and I have to call perform, performAndWait or performBackgroundTask (I have updated my code at Github to do just that).

Still I wonder, if the thread of newBackgroundContext can be the same as of the URLSession.shared.dataTaskPublisher...


Solution

  • It seems like you have figured most of this out yourself.

    This seems not to be the case and I have to call perform, performAndWait or performBackgroundTask (I have updated my code at Github to do just that).

    What's still not solved is here -

    Still I wonder, if the thread of newBackgroundContext can be the same as of the URLSession.shared.dataTaskPublisher...


    The URLSession API allows you to provide a custom response queue like following.

    URLSession.shared
        .dataTaskPublisher(for: URL(string: "https://google.com")!)
        .receive(on: DispatchQueue(label: "API.responseQueue", qos: .utility)) // <--- HERE
        .sink(receiveCompletion: {_ in }, receiveValue: { (output) in
              print(output)
         })
    

    OR traditionally like this -

    let queue = OperationQueue()
    queue.underlyingQueue = DispatchQueue(label: "API.responseQueue", qos: .utility)
    let session = URLSession(configuration: .default, delegate: self, delegateQueue: queue)
    

    So ideally, we should pass in the NSManagedObjectContext's DispatchQueue as the reponse queue for URLSession.


    The issue is with NSManagedObjectContext API that -

    1. neither allows you to supply a custom DispatchQueue instance from outside.
    2. nor exposes a read-only property for it's internally managed DispatchQueue instance.

    We can't access underlying DispatchQueue for NSManagedObjectContext instance. The ONLY EXCEPTION to this rule is .viewContext that uses DispatchQueue.main. Of course we don't want to handle network response decoding and thousands of records being created/persisted on/from main thread.