List Monitor and Cascade Delete #382

Closed
opened 2025-12-29 15:30:36 +01:00 by adam · 7 comments
Owner

Originally created by @spacedema on GitHub (Jan 21, 2022).

I have two entities with 1->M relationship. Note and Attachment (note can have many attachments and the attachment holds link to the note).
attachments
notes

I'm using NSManageObject and I have AttachmentsDbListener

// https://github.com/JohnEstropia/CoreStore/issues/362
class AttachesDbListener {
    typealias ListEntityType = Attachments

    private let monitor: ListMonitor<Attachments>

    var pendingObjects: [NSManagedObjectID: String] = [:]

    private let dispatchQueue = DispatchQueue(label: "com.test.AttachesDbListenerQueue")
    
    init(dataStack: DataStack) {
        monitor = dataStack.monitorList(
            From<Attachments>()
                .orderBy(.ascending(\.localPath))
        )
        monitor.addObserver(self)
    }
}

extension AttachesDbListener: ListObjectObserver {

    func listMonitorDidChange(_ monitor: ListMonitor<Attachments>) {
        pendingObjects = [:]
    }

    func listMonitorDidRefetch(_ monitor: ListMonitor<Attachments>) {

    }

    func listMonitorWillChange(_ monitor: ListMonitor<ListEntityType>) {
        pendingObjects = monitor.objectsInAllSections().reduce(into: [:]) { $0[$1.objectID] = $1.localPath }
    }

    func listMonitor(_ monitor: ListMonitor<ListEntityType>, didDeleteObject object: ListEntityType, fromIndexPath indexPath: IndexPath) {
        guard let path = pendingObjects[object.objectID] else { return }
        log.debug("Will delete attachment with local url \(String(describing: object.localPath))", tag: \.attachmentsRemover)
        if let url = URLPathHelper.tryFindCurrentPath(forPath: path)?.toFileURL() {
            dispatchQueue.async {
                do {
                    try FileManager.default.removeItem(at: url)
                    log.debug("Did delete attachment with local url \(path)", tag: \.attachmentsRemover)
                } catch let error {
                    log.error(error.localizedDescription)
                }
            }
        }
    }
}

Steps to reproduce the issue:

  1. Create Note And Attachment in it
  2. Close app
  3. Open app
  4. Delete Note
  5. Breakpoint in listMonitorWillChange
    Снимок экрана 2022-01-21 в 13 23 40
    All fields are nil, so I can't delete local attachments blobs
Originally created by @spacedema on GitHub (Jan 21, 2022). I have two entities with 1->M relationship. Note and Attachment (note can have many attachments and the attachment holds link to the note). <img width="1149" alt="attachments" src="https://user-images.githubusercontent.com/7204319/150509228-c12664ef-11e8-493b-955b-2901e164a151.png"> <img width="1146" alt="notes" src="https://user-images.githubusercontent.com/7204319/150509238-7abb2778-b1c9-4508-8086-0896673fdc5c.png"> I'm using `NSManageObject` and I have AttachmentsDbListener ``` // https://github.com/JohnEstropia/CoreStore/issues/362 class AttachesDbListener { typealias ListEntityType = Attachments private let monitor: ListMonitor<Attachments> var pendingObjects: [NSManagedObjectID: String] = [:] private let dispatchQueue = DispatchQueue(label: "com.test.AttachesDbListenerQueue") init(dataStack: DataStack) { monitor = dataStack.monitorList( From<Attachments>() .orderBy(.ascending(\.localPath)) ) monitor.addObserver(self) } } extension AttachesDbListener: ListObjectObserver { func listMonitorDidChange(_ monitor: ListMonitor<Attachments>) { pendingObjects = [:] } func listMonitorDidRefetch(_ monitor: ListMonitor<Attachments>) { } func listMonitorWillChange(_ monitor: ListMonitor<ListEntityType>) { pendingObjects = monitor.objectsInAllSections().reduce(into: [:]) { $0[$1.objectID] = $1.localPath } } func listMonitor(_ monitor: ListMonitor<ListEntityType>, didDeleteObject object: ListEntityType, fromIndexPath indexPath: IndexPath) { guard let path = pendingObjects[object.objectID] else { return } log.debug("Will delete attachment with local url \(String(describing: object.localPath))", tag: \.attachmentsRemover) if let url = URLPathHelper.tryFindCurrentPath(forPath: path)?.toFileURL() { dispatchQueue.async { do { try FileManager.default.removeItem(at: url) log.debug("Did delete attachment with local url \(path)", tag: \.attachmentsRemover) } catch let error { log.error(error.localizedDescription) } } } } } ``` Steps to reproduce the issue: 1. Create Note And Attachment in it 2. Close app 3. Open app 4. Delete Note 5. Breakpoint in `listMonitorWillChange` <img width="1201" alt="Снимок экрана 2022-01-21 в 13 23 40" src="https://user-images.githubusercontent.com/7204319/150511082-c9a81756-1fb6-4a14-89f3-2625c82bb390.png"> All fields are nil, so I can't delete local attachments blobs
adam closed this issue 2025-12-29 15:30:36 +01:00
Author
Owner

@JohnEstropia commented on GitHub (Jan 24, 2022):

@spacedema This seems to be the expected behavior if the objects you access in listMonitorWillChange are in faulted state. At that state, accessing the object (firing its fault) would attempt to fetch values from the persistent store, where it obviously had already been deleted.

When objects had been deleted, consider those objects as tombstones and the only reliable info would be its NSManagedObjectID.

Looking at your use case, I would suggest to keep a cache of object.localPaths and use the NSManagedObjectID as keys. You can then execute your cleanup code anytime later on using the deleted object's objectID.

@JohnEstropia commented on GitHub (Jan 24, 2022): @spacedema This seems to be the expected behavior if the objects you access in `listMonitorWillChange` are in faulted state. At that state, accessing the object (firing its fault) would attempt to fetch values from the persistent store, where it obviously had already been deleted. When objects had been deleted, consider those objects as tombstones and the only reliable info would be its `NSManagedObjectID`. Looking at your use case, I would suggest to keep a cache of `object.localPath`s and use the `NSManagedObjectID` as keys. You can then execute your cleanup code anytime later on using the deleted object's `objectID`.
Author
Owner

@spacedema commented on GitHub (Jan 24, 2022):

Looking at your use case, I would suggest to keep a cache of object.localPaths and use the NSManagedObjectID as keys. You can then execute your cleanup code anytime later on using the deleted object's objectID.

It looks too complicated. It would be easier if Persistent History Tracking were implemented (https://developer.apple.com/documentation/coredata/consuming_relevant_store_changes). Preserve after deletion would solve this problem, wouldn't it?

@spacedema commented on GitHub (Jan 24, 2022): > Looking at your use case, I would suggest to keep a cache of object.localPaths and use the NSManagedObjectID as keys. You can then execute your cleanup code anytime later on using the deleted object's objectID. It looks too complicated. It would be easier if Persistent History Tracking were implemented ([https://developer.apple.com/documentation/coredata/consuming_relevant_store_changes](https://developer.apple.com/documentation/coredata/consuming_relevant_store_changes)). `Preserve after deletion` would solve this problem, wouldn't it?
Author
Owner

@JohnEstropia commented on GitHub (Jan 24, 2022):

@spacedema I don't think the Preserve after deletion feature works how you imagine it would. Yes, the attribute value will be saved even for deleted records, but that data belongs to the NSPersistentHistoryTransaction. It will not be propagated to NSManagedObject instances. History transaction records are only used to notify NSManagedObjectContexts of external changes, but faulted objects still fetch data from the store.

  • Rather than containing full objects under keys like NSUpdatedObjectsKey, it contains object IDs under keys like updated_objectIDs. This is a bit unexpected, because NSManagedObjectContext is already documented to support NSManagedObjectID or NSURL objects under the NSUpdatedObjectsKey key.
  • In any case, you get IDs because it isn’t storing the changed values. Instead, when merging, it fetches the latest values from the store.

Even when Core Store eventually implements Persistent History Tracking, I don't intend to open up an API to access the raw history transactions as shown in the sample codes. That's for merging diffs into the relevant contexts. Of course you're free to observe relevant NSNotifications at that point, but I doubt it would be less complicated that just managing cleanup tables on your own:

class AttachesDbListener: ListObjectObserver {

    let monitor: ListMonitor<Attachments> // ...
    lazy var localPathsByID: [NSManagedObjectID: String] = self.allPaths()

    private func allPaths()  -> [NSManagedObjectID: String] {
         return monitor.objectsInAllSections().reduce(into: [:]) { $0[$1.objectID] = $1.localPath }
    }

    func listMonitorDidChange(_ monitor: ListMonitor<Attachments>) {
         localPathsByID = self.allPaths()
    }

    func listMonitorDidRefetch(_ monitor: ListMonitor<Attachments>) {
         localPathsByID = self.allPaths()
    }

    func listMonitor(_ monitor: ListMonitor<ListEntityType>, didDeleteObject object: ListEntityType, fromIndexPath indexPath: IndexPath) {
        guard let path = localPathsByID.removeValue(forKey: object.objectID) else { return }
        log.debug("Will delete attachment with local url \(String(describing: path))", tag: \.attachmentsRemover)
        if let url = URLPathHelper.tryFindCurrentPath(forPath: path)?.toFileURL() {
            dispatchQueue.async {
                do {
                    try FileManager.default.removeItem(at: url)
                    log.debug("Did delete attachment with local url \(path)", tag: \.attachmentsRemover)
                } catch let error {
                    log.error(error.localizedDescription)
                }
            }
        }
    }
}
@JohnEstropia commented on GitHub (Jan 24, 2022): @spacedema I don't think the `Preserve after deletion` feature works how you imagine it would. Yes, the attribute value will be saved even for deleted records, but that data belongs to the `NSPersistentHistoryTransaction`. It will not be propagated to `NSManagedObject` instances. History transaction records are only used to notify `NSManagedObjectContexts` of external changes, but [faulted objects still fetch data from the store](https://mjtsai.com/blog/2019/08/21/persistent-history-tracking-in-core-data/). > - Rather than containing full objects under keys like NSUpdatedObjectsKey, it contains object IDs under keys like updated_objectIDs. This is a bit unexpected, because NSManagedObjectContext is already documented to support NSManagedObjectID or NSURL objects under the NSUpdatedObjectsKey key. > - In any case, you get IDs because it isn’t storing the changed values. Instead, when merging, it fetches the latest values from the store. Even when Core Store eventually implements Persistent History Tracking, I don't intend to open up an API to access the raw history transactions [as shown in the sample codes](https://developer.apple.com/documentation/coredata/consuming_relevant_store_changes). That's for merging diffs into the relevant contexts. Of course you're free to observe relevant `NSNotification`s at that point, but I doubt it would be less complicated that just managing cleanup tables on your own: ```swift class AttachesDbListener: ListObjectObserver { let monitor: ListMonitor<Attachments> // ... lazy var localPathsByID: [NSManagedObjectID: String] = self.allPaths() private func allPaths() -> [NSManagedObjectID: String] { return monitor.objectsInAllSections().reduce(into: [:]) { $0[$1.objectID] = $1.localPath } } func listMonitorDidChange(_ monitor: ListMonitor<Attachments>) { localPathsByID = self.allPaths() } func listMonitorDidRefetch(_ monitor: ListMonitor<Attachments>) { localPathsByID = self.allPaths() } func listMonitor(_ monitor: ListMonitor<ListEntityType>, didDeleteObject object: ListEntityType, fromIndexPath indexPath: IndexPath) { guard let path = localPathsByID.removeValue(forKey: object.objectID) else { return } log.debug("Will delete attachment with local url \(String(describing: path))", tag: \.attachmentsRemover) if let url = URLPathHelper.tryFindCurrentPath(forPath: path)?.toFileURL() { dispatchQueue.async { do { try FileManager.default.removeItem(at: url) log.debug("Did delete attachment with local url \(path)", tag: \.attachmentsRemover) } catch let error { log.error(error.localizedDescription) } } } } } ```
Author
Owner

@spacedema commented on GitHub (Jan 24, 2022):

Ok, got it, thanks for detailed answer.
But I'll still wait for the implementation of Persistent History Tracking in order to use Spotlight with NSCoreDataCoreSpotlightDelegate :)

@spacedema commented on GitHub (Jan 24, 2022): Ok, got it, thanks for detailed answer. But I'll still wait for the implementation of Persistent History Tracking in order to use Spotlight with NSCoreDataCoreSpotlightDelegate :)
Author
Owner

@spacedema commented on GitHub (Jan 24, 2022):

@JohnEstropia the code above don't work as expected after reopen app, i.e.

  1. open app
  2. create root object and attachment in it
  3. close app
  4. open app
  5. delete root object, listMonitorDidChange and listMonitorDidRefetch not called, localPathsByID is Empty

But if i wrote

init(dataStack: DataStack) {
        monitor = dataStack.monitorList(
            From<Attachments>()
                .orderBy(.ascending(\. localPath))
        )
        monitor.addObserver(self)
        pendingObjects = allPaths()
    }

it's working as expected. Is it expected behaviour?

@spacedema commented on GitHub (Jan 24, 2022): @JohnEstropia the code above don't work as expected after reopen app, i.e. 1. open app 2. create root object and attachment in it 3. close app 4. open app 5. delete root object, `listMonitorDidChange` and `listMonitorDidRefetch` not called, `localPathsByID` is Empty But if i wrote ``` init(dataStack: DataStack) { monitor = dataStack.monitorList( From<Attachments>() .orderBy(.ascending(\. localPath)) ) monitor.addObserver(self) pendingObjects = allPaths() } ``` it's working as expected. Is it expected behaviour?
Author
Owner

@JohnEstropia commented on GitHub (Jan 24, 2022):

Yes, you'd want to get the latest values right after you call addObserver(). My code was just pseudo code 👍

@JohnEstropia commented on GitHub (Jan 24, 2022): Yes, you'd want to get the latest values right after you call `addObserver()`. My code was just pseudo code 👍
Author
Owner

@spacedema commented on GitHub (Jan 24, 2022):

Got it, thanks

@spacedema commented on GitHub (Jan 24, 2022): Got it, thanks
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/CoreStore#382