pretty

Saturday, 5 November 2022

Accessing CoreData from a single background context

The usual scheme when working with Core Data is using one or more of the background contexts for modifying data, and one viewContext for reading and displaying it.

Utilizing many background contexts may seem like a good idea at first, because it allows to modify the data simultaneously from different threads. However, using many background CoreData contexts increases the complexity of a program. It can be tricky to properly set up the CoreData Merge Policy covering all the possible cases, and hence get into the concurrency related issues.

The following unit test recreates the basic concurrency issue on purpose, when the same data entity is being modified from those two background contexts on lines 59 and 75:

import XCTest
import CoreData
@testable import iosApp
class iosAppTests: XCTestCase {
/*
ChartState - is a CoreData entity with two fields: chartLen and seed:
<entity name="ChartState" representedClassName="ChartState" syncable="YES" codeGenerationType="class">
<attribute name="chartLen" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="seed" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
</entity>
*/
/*
CoreDataInventory - is a class defined in the app, it holds the NSPersistentContainer instance:
final class CoreDataInventory {
static let instance = CoreDataInventory()
let persistentContainer: NSPersistentContainer
private init() {
persistentContainer = NSPersistentContainer(name: "Data")
persistentContainer.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
}
}
*/
func testCoreDataMergeConflict() {
let coreData = CoreDataInventory.instance.persistentContainer
let expectation = XCTestExpectation(description: "")
expectation.expectedFulfillmentCount = 2
// 1. Launch new background task to create a new ChartState entity and save it
coreData.performBackgroundTask { (context) in
let newChartState = ChartState(context: context)
newChartState.chartLen = 100
newChartState.seed = 1
do {
try context.save()
} catch {
let nsError = error as NSError
fatalError("saveContext() error: \(nsError), \(nsError.userInfo)")
}
}
// 2. Launch two simultaneous background tasks
// 2.1 This task sleeps for 1 second, reads the written ChartLen from CoreData and modifies it, then saves it
coreData.performBackgroundTask { (context) in
sleep(1)
self.modifyChartStateSeed(context, 2)
sleep(1) // Wait 1 seconds before save for the next background task to read the same version of entity
do {
try context.save()
} catch {
let nsError = error as NSError
fatalError("saveContext() error: \(nsError), \(nsError.userInfo)")
}
expectation.fulfill()
}
// 2.2 This task also modifies the same entity, but waits another second before saving it
coreData.performBackgroundTask { (context) in
// Uncomment to set one of the Merge Policies, and bypass an error by prefering saved or in-memory data version
//context.mergePolicy = NSMergePolicy(merge: NSMergePolicyType.mergeByPropertyObjectTrumpMergePolicyType)
sleep(1)
self.modifyChartStateSeed(context, 3)
sleep(2) // Wait 2 seconds before save, so that the previous performBackgroundTask() would have already saved its ChartState version
do {
// This save would produce a merge conflict, because read entity was already modified and saved by the other context,
// so the entity saved version is already greater than the one that was read in this thread
try context.save()
} catch {
let nsError = error as NSError
// Would land here with the "Could not merge changes ... oldVersion = 1 and newVersion = 2 and
// old object snapshot = {chartLen = 100; seed = 1;} and new cached row = {chartLen = 100; seed = 2;}""
fatalError("saveContext() error: \(nsError), \(nsError.userInfo)")
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 6)
}
private func modifyChartStateSeed(_ context: NSManagedObjectContext, _ seed: Int32) {
let fetchRequest: NSFetchRequest<ChartState> = ChartState.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "chartLen = %i", 100)
let chartStateEntity = try? context.fetch(fetchRequest).first
chartStateEntity?.seed = seed
}
}
The test will pass if the line 78 would be uncommented. This line sets the merge policy that, instead of throwing error, prefers the persisted field version over the newer field change. But, instead of setting a specific merge policy, more viable option would be to have a single background context that executes all the modifications sequentially. It is much easier to manage and reason about.

To employ sequential CoreData writing scheme it's convenient to have a wrapper class that would hold the required single background context. The wrapper class in this sample is named CoreDataInventory and should be used for every CoreData interaction in the application. CoreDataInventory wrapper class in this design is a singleton:

import Foundation
import CoreData
final class CoreDataInventory {
static let instance = CoreDataInventory()
let persistentContainer: NSPersistentContainer
let viewContext: NSManagedObjectContext
private let backgroundContext: NSManagedObjectContext
private init() {
persistentContainer = NSPersistentContainer(name: "Data")
persistentContainer.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
viewContext = persistentContainer.viewContext
viewContext.automaticallyMergesChangesFromParent = true
backgroundContext = persistentContainer.newBackgroundContext()
}
/* Performs supplied block on a background managed object context
and saves possible changes */
func perform(block: @escaping (_ context: NSManagedObjectContext) -> Void) async {
await backgroundContext.perform {
do {
block(self.backgroundContext)
if (self.backgroundContext.hasChanges) {
try self.backgroundContext.save()
}
} catch {
let nsError = error as NSError
fatalError("saveContext() error: \(nsError), \(nsError.userInfo)")
}
}
}
}
Having this class let's change the unit test, and call the new method CoreDataInventory.instance.perform(...) instead of performBackgroundTask(...). The CoreData merge policy in the test is also the default one (used by CoreData when no specific policy is set), and should throw the error if there is a merge conflict.

import XCTest
import CoreData
@testable import iosApp
class iosAppTests: XCTestCase {
func testCoreDataMergeConflict() {
let expectation = XCTestExpectation(description: "")
expectation.expectedFulfillmentCount = 2
Task {
await CoreDataInventory.instance.perform { (context) in
let newChartState = ChartState(context: context)
newChartState.chartLen = 100
newChartState.seed = 1
}
}
Task {
await CoreDataInventory.instance.perform { (context) in
sleep(1)
self.modifyChartStateSeed(context, 2)
sleep(1) // Wait 1 seconds before save for the next background task to read the same version of entity
}
expectation.fulfill()
}
Task {
await CoreDataInventory.instance.perform { (context) in
sleep(1)
self.modifyChartStateSeed(context, 3)
sleep(2)
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 8)
}
private func modifyChartStateSeed(_ context: NSManagedObjectContext, _ seed: Int32) {
let fetchRequest: NSFetchRequest<ChartState> = ChartState.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "chartLen = %i", 100)
let chartStateEntity = try? context.fetch(fetchRequest).first
chartStateEntity?.seed = seed
}
}
This test should pass without merge conflicts:
	 Executed 1 test, with 0 failures (0 unexpected) in 5.023 (5.028) seconds

In conclusion, many background contexts may be used only when it is proven that concrete application performs a high amount of time-consuming data writes, and practically benefits from having multiple background contexts. Otherwise, more practical alternative is to use a single background context for writing to CoreData.

No comments :

Post a Comment