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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
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:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)") | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
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