Просмотр исходного кода

Debounce Core Data remote-change merges and forecast recomputes

Marvin Polscheit 6 дней назад
Родитель
Сommit
7d8d2c4d2e

+ 17 - 4
Model/CoreDataStack.swift

@@ -10,6 +10,12 @@ class CoreDataStack: ObservableObject {
     private var notificationToken: NSObjectProtocol?
     private let inMemory: Bool
 
+    /// Debounces `NSPersistentStoreRemoteChange` bursts into a single history fetch + merge.
+    /// Lossless, since `fetchHistory(after: lastToken)` drains all accumulated transactions.
+    private let remoteChangeSubject = PassthroughSubject<Void, Never>()
+    private var remoteChangeCancellable: AnyCancellable?
+    private let historyQueue = DispatchQueue(label: "CoreDataStack.history", qos: .utility)
+
     let persistentContainer: NSPersistentContainer
 
     private let maxRetries = 3
@@ -210,12 +216,19 @@ class CoreDataStack: ObservableObject {
             forName: .NSPersistentStoreRemoteChange,
             object: nil,
             queue: nil
-        ) { _ in
-            Task {
-                await self.fetchPersistentHistory()
-            }
+        ) { [weak self] _ in
+            // Just signal here; fetching happens in the debounced pipeline below. Notifications
+            // arrive on arbitrary threads, so serialize the sends via `historyQueue`.
+            guard let self else { return }
+            self.historyQueue.async { self.remoteChangeSubject.send() }
         }
 
+        remoteChangeCancellable = remoteChangeSubject
+            .debounce(for: .milliseconds(200), scheduler: historyQueue)
+            .sink { [weak self] in
+                Task { await self?.fetchPersistentHistory() }
+            }
+
         debug(.coreData, "Set up persistent store change notifications")
     }
 

+ 13 - 3
Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift

@@ -7,15 +7,14 @@ extension Home.StateModel {
     @MainActor func setupEnactedDeterminationController() {
         enactedDeterminationControllerDelegate.onContentChange = { [weak self] in
             Task { @MainActor in
-                guard let self else { return }
-                self.updateEnactedDeterminationFromController()
-                await self.updateForecastData()
+                self?.scheduleForecastUpdate()
             }
         }
 
         do {
             try enactedDeterminationController.performFetch()
             updateEnactedDeterminationFromController()
+            // Initial population runs immediately — no burst to coalesce at startup.
             Task { @MainActor in
                 await self.updateForecastData()
             }
@@ -24,6 +23,17 @@ extension Home.StateModel {
         }
     }
 
+    /// Coalesces rapid determination changes — each cancels the previous pending recompute.
+    @MainActor func scheduleForecastUpdate() {
+        forecastUpdateTask?.cancel()
+        forecastUpdateTask = Task { @MainActor in
+            try? await Task.sleep(for: .milliseconds(150))
+            guard !Task.isCancelled else { return }
+            updateEnactedDeterminationFromController()
+            await updateForecastData()
+        }
+    }
+
     @MainActor private func updateEnactedDeterminationFromController() {
         guard let objects = enactedDeterminationController.fetchedObjects else { return }
         determinationsFromPersistence = objects

+ 4 - 0
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -389,6 +389,10 @@ extension Home {
 
         private var subscriptions = Set<AnyCancellable>()
 
+        /// Debounces the forecast recompute — the most expensive `onContentChange` callback,
+        /// which a manual re-determine fires twice in quick succession.
+        @ObservationIgnored var forecastUpdateTask: Task<Void, Never>?
+
         typealias PumpEvent = PumpEventStored.EventType
 
         override init() {