Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core Data Stack updates #14013

Merged
merged 30 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0c344f2
Create background context separate from view context
itsmeichigo Sep 23, 2024
bac61d6
Keep product date and dateCreated optional
itsmeichigo Sep 23, 2024
613d8c0
Add comment for automaticallyMergesChangesFromParent
itsmeichigo Sep 23, 2024
e2243ec
Update saveDerivedType to remove saving view context
itsmeichigo Sep 23, 2024
cff18db
Fix failed unit tests
itsmeichigo Sep 23, 2024
0bb7f56
Add new helper method performAndSave for handling write operations
itsmeichigo Sep 24, 2024
37c983f
Update ProductStore to use the new performAndSave method
itsmeichigo Sep 24, 2024
a0c81a9
Assert thread check for write operations
itsmeichigo Sep 24, 2024
bd1b060
Update MockStorageManager
itsmeichigo Sep 24, 2024
8f2b794
Enforce correct context usage only if enabled
itsmeichigo Sep 24, 2024
5ec6e5d
Remove stack trace for less noise in warnings
itsmeichigo Sep 24, 2024
9112815
Disable -enforce-core-data-write-in-background by default
itsmeichigo Sep 24, 2024
f9a5aeb
Remove UITest checking when checking write context
itsmeichigo Sep 24, 2024
eedbdff
Revert "Update ProductStore to use the new performAndSave method"
itsmeichigo Sep 24, 2024
0a51538
Merge branch 'trunk' into task/core-data-update
itsmeichigo Sep 24, 2024
885f94e
Revert changes to MockStorageManager
itsmeichigo Sep 24, 2024
7dc1a26
Wrap context check in debug build only
itsmeichigo Sep 27, 2024
7856d0a
Remove unused method performBackgroundTask
itsmeichigo Sep 27, 2024
f40f843
Replace block with closure for more "swifty" naming
itsmeichigo Sep 27, 2024
1df2c0f
Merge branch 'trunk' into task/core-data-update
itsmeichigo Sep 30, 2024
47fd79f
Initialize storage manager with app delegate
itsmeichigo Sep 30, 2024
5bcc48a
Fix assert failure when marking order as read
itsmeichigo Sep 30, 2024
794709d
Update merge policy for main context
itsmeichigo Oct 1, 2024
78386e7
Add writer queue to handle write operations one-by-one following WP/JP
itsmeichigo Oct 1, 2024
8ebe336
Add warning log after checking thread
itsmeichigo Oct 1, 2024
6d902d8
Reduce nested blocks for thread check
itsmeichigo Oct 1, 2024
95135b9
Update logging to log warnings for saving in main context
itsmeichigo Oct 3, 2024
e6e644d
Remove `saveIfNeeded` in AppDelegate
itsmeichigo Oct 14, 2024
4910fe4
Merge branch 'trunk' into task/core-data-update
itsmeichigo Oct 14, 2024
64c71ce
Update release notes
itsmeichigo Oct 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 101 additions & 17 deletions Storage/Storage/CoreData/CoreDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public final class CoreDataManager: StorageManagerType {

private let modelsInventory: ManagedObjectModelsInventory

/// A serial queue used to ensure there is only one writing operation at a time.
private let writerQueue: OperationQueue

/// Module-private designated Initializer.
///
/// - Parameter name: Identifier to be used for: [database, data model, container].
Expand All @@ -30,6 +33,9 @@ public final class CoreDataManager: StorageManagerType {
modelsInventory: ManagedObjectModelsInventory?) {
self.name = name
self.crashLogger = crashLogger
self.writerQueue = OperationQueue()
self.writerQueue.name = "com.automattic.woocommerce.CoreDataManager.writer"
self.writerQueue.maxConcurrentOperationCount = 1

do {
if let modelsInventory = modelsInventory {
Expand Down Expand Up @@ -59,16 +65,20 @@ public final class CoreDataManager: StorageManagerType {
/// Returns the Storage associated with the View Thread.
///
public var viewStorage: StorageType {
return persistentContainer.viewContext
let context = persistentContainer.viewContext
/// This simplifies the process of merging updates from persistent container to view context.
/// When disable auto merge, we need to handle merging manually using `NSManagedObjectContextDidSave` notifications.
context.automaticallyMergesChangesFromParent = true
selanthiraiyan marked this conversation as resolved.
Show resolved Hide resolved
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return context
}

/// Returns a shared derived storage instance dedicated for write operations.
///
public lazy var writerDerivedStorage: StorageType = {
let childManagedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
childManagedObjectContext.parent = persistentContainer.viewContext
childManagedObjectContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return childManagedObjectContext
let backgroundContext = persistentContainer.newBackgroundContext()
backgroundContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return backgroundContext
}()

/// Persistent Container: Holds the full CoreData Stack
Expand Down Expand Up @@ -130,25 +140,36 @@ public final class CoreDataManager: StorageManagerType {
return container
}()

/// Performs the received closure in Background. Note that you should use the received Storage instance (BG friendly!).
///
public func performBackgroundTask(_ closure: @escaping (StorageType) -> Void) {
persistentContainer.performBackgroundTask { context in
closure(context as StorageType)
}
}

/// Saves the derived storage. Note: the closure may be called on a different thread
///
public func saveDerivedType(derivedStorage: StorageType, _ closure: @escaping () -> Void) {
derivedStorage.perform {
derivedStorage.saveIfNeeded()
closure()
}
}

self.viewStorage.perform {
self.viewStorage.saveIfNeeded()
closure()
/// Execute the given block with a background context and save the changes.
///
/// This function _does not block_ its running thread. The block is executed in background and its return value
/// is passed onto the `completion` block which is executed on the given `queue`.
///
/// - Parameters:
/// - closure: A closure which uses the given `NSManagedObjectContext` to make Core Data model changes.
/// - completion: A closure which is called with the return value of the `block`, after the changed made
/// by the `block` is saved.
/// - queue: A queue on which to execute the completion block.
public func performAndSave(_ closure: @escaping (StorageType) -> Void, completion: (() -> Void)?, on queue: DispatchQueue) {
let derivedStorage = writerDerivedStorage
self.writerQueue.addOperation(AsyncBlockOperation { done in
derivedStorage.perform {
closure(derivedStorage)

derivedStorage.saveIfNeeded()
queue.async { completion?() }
selanthiraiyan marked this conversation as resolved.
Show resolved Hide resolved
done()
}
}
})
}

/// This method effectively destroys all of the stored data, and generates a blank Persistent Store from scratch.
Expand Down Expand Up @@ -323,3 +344,66 @@ extension CoreDataManagerError: CustomStringConvertible {
}
}
}

/// Helper types to support writing operations to be handled one by one.
/// This implementation follows WP/JP's work at
/// WordPress/Classes/Utility/ContextManager.swift#L131
///
extension CoreDataManager {
/// Helper type to support handling async operations by keeping track of their states
class AsyncOperation: Operation {
selanthiraiyan marked this conversation as resolved.
Show resolved Hide resolved
enum State: String {
case isReady, isExecuting, isFinished
}

override var isAsynchronous: Bool {
return true
}

var state = State.isReady {
willSet {
willChangeValue(forKey: state.rawValue)
willChangeValue(forKey: newValue.rawValue)
}
didSet {
didChangeValue(forKey: oldValue.rawValue)
didChangeValue(forKey: state.rawValue)
}
}

override var isExecuting: Bool {
return state == .isExecuting
}

override var isFinished: Bool {
return state == .isFinished
}

override func start() {
if isCancelled {
state = .isFinished
return
}

state = .isExecuting
main()
}
}

/// Helper type to handle async operations given a closure of code to be executed.
final class AsyncBlockOperation: AsyncOperation {

private let block: (@escaping () -> Void) -> Void

init(block: @escaping (@escaping () -> Void) -> Void) {
self.block = block
}

override func main() {
self.block { [weak self] in
self?.state = .isFinished
}
}

}
}
26 changes: 26 additions & 0 deletions Storage/Storage/Extensions/NSManagedObjectContext+Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ extension NSManagedObjectContext: StorageType {
/// Deletes the specified Object Instance
///
public func deleteObject<T: Object>(_ object: T) {
Self.ensureCorrectContextUsageIfNeeded(for: T.entityName)
guard let object = object as? NSManagedObject else {
logErrorAndExit("Invalid Object Kind")
}
Expand All @@ -70,6 +71,7 @@ extension NSManagedObjectContext: StorageType {
/// Deletes all of the NSMO instances associated to the specified kind
///
public func deleteAllObjects<T: Object>(ofType type: T.Type) {
Self.ensureCorrectContextUsageIfNeeded(for: T.entityName)
let request = fetchRequest(forType: type)
request.includesPropertyValues = false
request.includesSubentities = false
Expand Down Expand Up @@ -104,6 +106,7 @@ extension NSManagedObjectContext: StorageType {
/// Inserts a new Entity. For performance reasons, this helper *DOES NOT* persists the context.
///
public func insertNewObject<T: Object>(ofType type: T.Type) -> T {
Self.ensureCorrectContextUsageIfNeeded(for: T.entityName)
return NSEntityDescription.insertNewObject(forEntityName: T.entityName, into: self) as! T
}

Expand Down Expand Up @@ -134,6 +137,9 @@ extension NSManagedObjectContext: StorageType {
return
}

/// cannot infer the entity name here, so leaving it empty
Self.ensureCorrectContextUsageIfNeeded(for: "")

do {
try save()
} catch {
Expand Down Expand Up @@ -175,4 +181,24 @@ extension NSManagedObjectContext: StorageType {
private func fetchRequest<T: Object>(forType type: T.Type) -> NSFetchRequest<NSFetchRequestResult> {
return NSFetchRequest<NSFetchRequestResult>(entityName: type.entityName)
}

/// Helper to trigger failure in debug mode or print warning when write operations are called from the view context (main thread).
shiki marked this conversation as resolved.
Show resolved Hide resolved
///
private static func ensureCorrectContextUsageIfNeeded(for entityName: String) {
#if DEBUG
guard Thread.isMainThread else {
return
}
let message: String = {
if entityName.isEmpty {
"⚠️ Saving data should only be done on a background context"
} else {
"⚠️ Write operations for \(entityName) should only be done on a background context"
}
}()

let enforceWriteInBackground = ProcessInfo.processInfo.arguments.contains("-enforce-core-data-write-in-background")
enforceWriteInBackground ? assertionFailure(message) : DDLogWarn(message)
#endif
}
}
4 changes: 2 additions & 2 deletions Storage/Storage/Model/Product+CoreDataProperties.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ extension Product {
@NSManaged public var name: String
@NSManaged public var slug: String
@NSManaged public var permalink: String
@NSManaged public var date: Date
@NSManaged public var dateCreated: Date
@NSManaged public var date: Date?
@NSManaged public var dateCreated: Date?
@NSManaged public var dateModified: Date?
@NSManaged public var dateOnSaleStart: Date?
@NSManaged public var dateOnSaleEnd: Date?
Expand Down
14 changes: 14 additions & 0 deletions Storage/Storage/Protocols/StorageManagerType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public protocol StorageManagerType {

/// Returns a shared derived storage instance dedicated for write operations.
///
@available(*, deprecated, message: "Use `performAndSave` to handle write operations instead.")
var writerDerivedStorage: StorageType { get }

/// Performs a task in Background: a special `Storage` instance will be provided (which is expected to be used within the closure!).
Expand All @@ -30,8 +31,21 @@ public protocol StorageManagerType {
/// - derivedStorageType: a derived StorageType constructed with `newDerivedStorage`
/// - closure: Callback to be executed on completion
///
@available(*, deprecated, message: "Use `performAndSave` to handle write operations instead.")
func saveDerivedType(derivedStorage: StorageType, _ closure: @escaping () -> Void)

/// Execute the given block with a background context and save the changes.
///
/// This function _does not block_ its running thread. The block is executed in background and its return value
/// is passed onto the `completion` block which is executed on the given `queue`.
///
/// - Parameters:
/// - closure: A closure which uses the given `NSManagedObjectContext` to make Core Data model changes.
/// - completion: A closure which is called with the return value of the `block`, after the changed made
/// by the `block` is saved.
/// - queue: A queue on which to execute the completion block.
func performAndSave(_ closure: @escaping (StorageType) -> Void, completion: (() -> Void)?, on queue: DispatchQueue)

/// This method is expected to destroy all persisted data. A notification of type `StorageManagerDidResetStorage` should get
/// posted.
///
Expand Down
21 changes: 4 additions & 17 deletions Storage/StorageTests/CoreData/CoreDataManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,6 @@ final class CoreDataManagerTests: XCTestCase {
XCTAssertEqual(manager.viewStorage as? NSManagedObjectContext, manager.persistentContainer.viewContext)
}

/// Verifies that performBackgroundTask effectively runs received closure in BG.
///
func test_performBackgroundTask_effectively_runs_received_closure_in_background_thread() {
let manager = CoreDataManager(name: storageIdentifier, crashLogger: MockCrashLogger())
let expectation = self.expectation(description: "Background")

manager.performBackgroundTask { (_) in
XCTAssertFalse(Thread.isMainThread)
expectation.fulfill()
}

wait(for: [expectation], timeout: Constants.expectationTimeout)
}

/// Verifies that derived context is instantiated correctly.
///
func test_derived_storage_is_instantiated_correctly() {
Expand All @@ -65,7 +51,7 @@ final class CoreDataManagerTests: XCTestCase {
XCTAssertNotNil(viewContext)
XCTAssertNotNil(derivedContext)
XCTAssertNotEqual(derivedContext, viewContext)
XCTAssertEqual(derivedContext?.parent, viewContext)
XCTAssertNil(derivedContext?.parent)
}

func test_resetting_CoreData_deletes_preexisting_objects() throws {
Expand All @@ -84,7 +70,7 @@ final class CoreDataManagerTests: XCTestCase {
XCTAssertEqual(viewContext.countObjects(ofType: ShippingLine.self), 0)
}

func test_saving_derived_storage_while_resetting_CoreData_still_saves_data() throws {
func test_saving_derived_storage_while_resetting_CoreData_does_not_save_data() throws {
// Arrange
let manager = CoreDataManager(name: storageIdentifier, crashLogger: MockCrashLogger())
manager.reset()
Expand All @@ -97,14 +83,15 @@ final class CoreDataManagerTests: XCTestCase {
_ = derivedContext.insertNewObject(ofType: ShippingLine.self)
}
manager.saveDerivedType(derivedStorage: derivedContext, {
XCTAssertEqual(viewContext.countObjects(ofType: ShippingLine.self), 1)
expectation.fulfill()
})
manager.reset()
expectation.fulfill()
}

// Assert
XCTAssertEqual(viewContext.countObjects(ofType: ShippingLine.self), 1)
XCTAssertEqual(viewContext.countObjects(ofType: ShippingLine.self), 0)
}

func test_when_the_model_is_incompatible_then_it_recovers_and_recreates_the_database() throws {
Expand Down
11 changes: 7 additions & 4 deletions WooCommerce/Classes/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
///
private var appCoordinator: AppCoordinator?

/// Initializes storage manager along with AppDelegate
private let storageManager = ServiceLocator.storageManager

/// Tab Bar Controller
///
var tabBarController: MainTabBarController? {
Expand Down Expand Up @@ -191,7 +194,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

// When the app is put into an incative state, it's important to ensure that any pending changes to
// Core Data context are saved to avoid data loss.
ServiceLocator.storageManager.viewStorage.saveIfNeeded()
storageManager.viewStorage.saveIfNeeded()
selanthiraiyan marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down Expand Up @@ -231,7 +234,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

// When the app is put into the background, it's important to ensure that any pending changes to
// Core Data context are saved to avoid data loss.
ServiceLocator.storageManager.viewStorage.saveIfNeeded()
storageManager.viewStorage.saveIfNeeded()
}

func applicationWillEnterForeground(_ application: UIApplication) {
Expand All @@ -253,7 +256,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
NotificationCenter.default.post(name: .applicationTerminating, object: nil)

// Save changes in the application's managed object context before the application terminates.
ServiceLocator.storageManager.viewStorage.saveIfNeeded()
storageManager.viewStorage.saveIfNeeded()
}

func application(_ application: UIApplication,
Expand Down Expand Up @@ -459,7 +462,7 @@ private extension AppDelegate {
}

if ProcessConfiguration.shouldUseScreenshotsNetworkLayer {
ServiceLocator.setStores(ScreenshotStoresManager(storageManager: ServiceLocator.storageManager))
ServiceLocator.setStores(ScreenshotStoresManager(storageManager: storageManager))
}

if ProcessConfiguration.shouldSimulatePushNotification {
Expand Down
1 change: 1 addition & 0 deletions WooCommerce/Classes/Model/MarkOrderAsReadUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct MarkOrderAsReadUseCase {
/// and then we compare local `orderID` with the one from remote `Note`.
/// If they match we mark it as read.
/// Returns syncronized note if marking was successful and error if some error happened
@MainActor
static func markOrderNoteAsReadIfNeeded(stores: StoresManager, noteID: Int64, orderID: Int) async -> Result<Note, Error> {
let syncronizedNoteResult: Result<Note, Error> = await withCheckedContinuation { continuation in
let action = NotificationAction.synchronizeNotification(noteID: noteID) { syncronizedNote, error in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@
argument = "mocked-network-layer"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-enforce-core-data-write-in-background"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ extension Storage.Product: ReadOnlyConvertible {
name: name,
slug: slug,
permalink: permalink,
date: date,
dateCreated: dateCreated,
date: date ?? Date(),
dateCreated: dateCreated ?? Date(),
dateModified: dateModified,
dateOnSaleStart: dateOnSaleStart,
dateOnSaleEnd: dateOnSaleEnd,
Expand Down
Loading