Przeglądaj źródła

Merge pull request #1107 from nightscout/core-data-fixes

Core Data Performance Improvements, Fixes and Adjustments
Deniz Cengiz 1 dzień temu
rodzic
commit
48cfdc1f67
65 zmienionych plików z 29141 dodań i 2448 usunięć
  1. 6 82
      Model/CoreDataObserver.swift
  2. 41 14
      Model/CoreDataStack.swift
  3. 6 0
      Model/Helper/NSPredicates.swift
  4. 9 0
      Model/NSFetchedResultsControllerDelegate.swift
  5. 1 1
      Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents
  6. 4 0
      Trio.xcodeproj/project.pbxproj
  7. 100 61
      Trio/Sources/APS/APSManager.swift
  8. 13 10
      Trio/Sources/APS/DeviceDataManager.swift
  9. 6 4
      Trio/Sources/APS/FetchGlucoseManager.swift
  10. 0 1
      Trio/Sources/APS/FetchTreatmentsManager.swift
  11. 58 37
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  12. 29 20
      Trio/Sources/APS/Storage/CarbsStorage.swift
  13. 19 12
      Trio/Sources/APS/Storage/ContactImageStorage.swift
  14. 30 27
      Trio/Sources/APS/Storage/DeterminationStorage.swift
  15. 74 37
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  16. 35 12
      Trio/Sources/APS/Storage/OverrideStorage.swift
  17. 103 131
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  18. 54 28
      Trio/Sources/APS/Storage/TDDStorage.swift
  19. 39 16
      Trio/Sources/APS/Storage/TempTargetsStorage.swift
  20. 26986 692
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  21. 0 1
      Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift
  22. 3 2
      Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift
  23. 0 2
      Trio/Sources/Modules/History/HistoryStateModel.swift
  24. 18 28
      Trio/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift
  25. 26 53
      Trio/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift
  26. 47 92
      Trio/Sources/Modules/Home/HomeStateModel+Setup/ChartAxisSetup.swift
  27. 13 41
      Trio/Sources/Modules/Home/HomeStateModel+Setup/CurrentTDDSetup.swift
  28. 47 45
      Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift
  29. 21 0
      Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift
  30. 19 34
      Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift
  31. 28 57
      Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift
  32. 34 69
      Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift
  33. 28 58
      Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift
  34. 298 137
      Trio/Sources/Modules/Home/HomeStateModel.swift
  35. 28 23
      Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift
  36. 1 1
      Trio/Sources/Modules/Home/View/Chart/MainChartView.swift
  37. 0 2
      Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift
  38. 0 2
      Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift
  39. 6 0
      Trio/Sources/Modules/Stat/StatStateModel+Setup/BolusStatsSetup.swift
  40. 10 1
      Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift
  41. 3 0
      Trio/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift
  42. 17 4
      Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift
  43. 3 5
      Trio/Sources/Modules/Stat/StatStateModel.swift
  44. 208 222
      Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift
  45. 4 3
      Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift
  46. 8 5
      Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift
  47. 2 3
      Trio/Sources/Services/BolusSafety/BolusSafetyValidator.swift
  48. 9 6
      Trio/Sources/Services/Calendar/CalendarManager.swift
  49. 9 6
      Trio/Sources/Services/ContactImage/ContactImageManager.swift
  50. 101 60
      Trio/Sources/Services/HealthKit/HealthKitManager.swift
  51. 3 2
      Trio/Sources/Services/IOB/IOBService.swift
  52. 7 0
      Trio/Sources/Services/LiveActivity/Data/DataManager.swift
  53. 1 4
      Trio/Sources/Services/LiveActivity/LiveActivityManager.swift
  54. 45 56
      Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift
  55. 217 52
      Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
  56. 173 136
      Trio/Sources/Services/Network/TidepoolManager.swift
  57. 8 0
      Trio/Sources/Services/Notifications/SwiftNotificationCenter/WeakObjectSet.swift
  58. 5 4
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift
  59. 22 13
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  60. 17 12
      Trio/Sources/Services/WatchManager/GarminManager.swift
  61. 0 1
      Trio/Sources/Shortcuts/BaseIntentsRequest.swift
  62. 15 6
      Trio/Sources/Shortcuts/Override/OverridePresetsIntentRequest.swift
  63. 0 2
      Trio/Sources/Shortcuts/State/StateIntentRequest.swift
  64. 18 7
      Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift
  65. 6 6
      TrioTests/CoreDataTests/TestAssembly.swift

+ 6 - 82
Model/CoreDataObserver.swift

@@ -2,79 +2,20 @@ import Combine
 import CoreData
 import Foundation
 
-/// Represents the types of Core Data changes that can be observed
-/// Use as an option set to specify which types of changes to monitor
-public struct CoreDataChangeTypes: OptionSet {
-    /// The raw integer value used to store the option set bits
-    public let rawValue: Int
-
-    /// Required initializer for OptionSet conformance
-    public init(rawValue: Int) {
-        self.rawValue = rawValue
-    }
-
-    /// Represents newly created/inserted objects in Core Data
-    /// Binary: 001 (1 << 0)
-    public static let inserted = CoreDataChangeTypes(rawValue: 1 << 0)
-
-    /// Represents modified/updated objects in Core Data
-    /// Binary: 010 (1 << 1)
-    public static let updated = CoreDataChangeTypes(rawValue: 1 << 1)
-
-    /// Represents removed/deleted objects in Core Data
-    /// Binary: 100 (1 << 2)
-    public static let deleted = CoreDataChangeTypes(rawValue: 1 << 2)
-
-    /// Convenience option that includes all possible change types
-    /// This combines inserted, updated, and deleted into a single option
-    public static let all: CoreDataChangeTypes = [.inserted, .updated, .deleted]
-}
-
-/// Creates a publisher that emits sets of NSManagedObjectIDs when Core Data changes occur
-/// - Parameter changeTypes: The types of changes to observe (defaults to .all)
-/// - Returns: A publisher that emits Sets of NSManagedObjectIDs for the specified change types
-func changedObjectsOnManagedObjectContextDidSavePublisher(
-    observing changeTypes: CoreDataChangeTypes = .all
-) -> some Publisher<Set<NSManagedObjectID>, Never> {
-    Foundation.NotificationCenter.default
-        .publisher(for: .NSManagedObjectContextDidSave)
-        .compactMap { notification -> Set<NSManagedObjectID>? in
-
-            var objectIDs = Set<NSManagedObjectID>()
-
-            // Process inserted objects if requested
-            if changeTypes.contains(.inserted) {
-                objectIDs.formUnion(notification.insertedObjectIDs)
-            }
-
-            // Process updated objects if requested
-            if changeTypes.contains(.updated) {
-                objectIDs.formUnion(notification.updatedObjectIDs)
-            }
-
-            // Process deleted objects if requested
-            if changeTypes.contains(.deleted) {
-                objectIDs.formUnion(notification.deletedObjectIDs)
-            }
-
-            // Only emit non-empty sets
-            return objectIDs.isEmpty ? nil : objectIDs
-        }
-}
+// The app-wide Core Data change feed now lives on `CoreDataStack.entityChangePublisher`, which is
+// sourced from persistent history (and therefore also covers batch operations and cross-process
+// changes). This file only keeps the `filteredByEntityName` operator used by its subscribers.
 
 extension Publisher where Output == Set<NSManagedObjectID> {
-    /// Filters Core Data changes by entity name
-    ///
-    /// This method allows filtering Core Data changes by entity name.
+    /// Filters Core Data changes by entity name.
     ///
     /// Example usage:
     /// ```swift
     /// // Filter changes for "GlucoseStored" entity
-    /// publisher.filteredByEntityName("GlucoseStored")
+    /// CoreDataStack.shared.entityChangePublisher.filteredByEntityName("GlucoseStored")
     /// ```
     ///
-    /// - Parameters:
-    ///   - name: The name of the Core Data entity to filter for
+    /// - Parameter name: The name of the Core Data entity to filter for
     /// - Returns: A publisher emitting filtered sets of NSManagedObjectIDs
     func filteredByEntityName(
         _ name: String
@@ -90,20 +31,3 @@ extension Publisher where Output == Set<NSManagedObjectID> {
         }
     }
 }
-
-extension Notification {
-    var insertedObjectIDs: Set<NSManagedObjectID> {
-        guard let objects = userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> else { return [] }
-        return Set(objects.lazy.map(\.objectID))
-    }
-
-    var updatedObjectIDs: Set<NSManagedObjectID> {
-        guard let objects = userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> else { return [] }
-        return Set(objects.lazy.map(\.objectID))
-    }
-
-    var deletedObjectIDs: Set<NSManagedObjectID> {
-        guard let objects = userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject> else { return [] }
-        return Set(objects.lazy.map(\.objectID))
-    }
-}

+ 41 - 14
Model/CoreDataStack.swift

@@ -1,3 +1,4 @@
+import Combine
 import CoreData
 import Foundation
 import OSLog
@@ -9,11 +10,26 @@ 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
     private let initializationCoordinator = CoreDataInitializationCoordinator()
 
+    /// Emits the set of changed object IDs from each batch of persistent history transactions.
+    /// Sourced from persistent history, so — unlike `NSManagedObjectContextDidSave` — it also
+    /// covers `NSBatchInsertRequest`/`NSBatchDeleteRequest` and cross-process changes. App-side
+    /// observers subscribe via `entityChangePublisher` and filter with `filteredByEntityName(_:)`.
+    private let entityChangeSubject = PassthroughSubject<Set<NSManagedObjectID>, Never>()
+    var entityChangePublisher: AnyPublisher<Set<NSManagedObjectID>, Never> {
+        entityChangeSubject.eraseToAnyPublisher()
+    }
+
     private init(inMemory: Bool = false) {
         self.inMemory = inMemory
 
@@ -103,8 +119,6 @@ class CoreDataStack: ObservableObject {
         /// - Tag: newBackgroundContext
         let taskContext = persistentContainer.newBackgroundContext()
 
-        /// ensure that the background contexts stay in sync with the main context
-        taskContext.automaticallyMergesChangesFromParent = true
         taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
         taskContext.undoManager = nil
         return taskContext
@@ -160,11 +174,19 @@ class CoreDataStack: ObservableObject {
         // Update view context with objectIDs from history change request
         /// - Tag: mergeChanges
         let viewContext = persistentContainer.viewContext
+        let changedObjectIDs = Set(history.flatMap { $0.changes ?? [] }.map(\.changedObjectID))
+
         viewContext.perform {
             for transaction in history {
                 viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
                 self.lastToken = transaction.token
             }
+
+            // Notify app-side observers (services) about which objects changed. This history-sourced
+            // change feed replaces the hand-rolled changedObjectsOnManagedObjectContextDidSavePublisher.
+            if !changedObjectIDs.isEmpty {
+                self.entityChangeSubject.send(changedObjectIDs)
+            }
         }
     }
 
@@ -194,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")
     }
 
@@ -320,7 +349,9 @@ extension CoreDataStack {
         taskContext.transactionAuthor = "batchDelete"
 
         // Get the number of days we want to keep the data
-        let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
+        guard let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) else {
+            throw CoreDataError.validationError(function: callingFunction, file: callingClass)
+        }
 
         // Fetch all the objects that are older than the specified days
         let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
@@ -378,7 +409,9 @@ extension CoreDataStack {
         taskContext.transactionAuthor = "batchDelete"
 
         // Get the target date
-        let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
+        guard let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) else {
+            throw CoreDataError.validationError(function: callingFunction, file: callingClass)
+        }
 
         // Fetch Parent objects older than the target date
         let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
@@ -465,9 +498,6 @@ extension CoreDataStack {
             request.resultType = .managedObjectResultType
         }
 
-        context.name = "fetchContext"
-        context.transactionAuthor = "fetchEntities"
-
         /// we need to ensure that the fetch immediately returns a value as long as the whole app does not use the async await pattern, otherwise we could perform this asynchronously with backgroundContext.perform and not block the thread
         return try context.performAndWait {
             do {
@@ -519,9 +549,6 @@ extension CoreDataStack {
             request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
         }
 
-        context.name = "fetchContext"
-        context.transactionAuthor = "fetchEntities"
-
         return try await context.perform {
             do {
                 if propertiesToFetch != nil {

+ 6 - 0
Model/Helper/NSPredicates.swift

@@ -66,6 +66,12 @@ extension NSPredicate {
         return NSPredicate(format: "date >= %@", date as NSDate)
     }
 
+    /// For entities keyed on `startDate` (OverrideRunStored, TempTargetRunStored).
+    static var predicateForStartDateOneDayAgo: NSPredicate {
+        let date = Date.oneDayAgo
+        return NSPredicate(format: "startDate >= %@", date as NSDate)
+    }
+
     static var carbsHistory: NSPredicate {
         let date = Date.oneDayAgo
         return NSPredicate(format: "date >= %@ AND carbs > 0", date as NSDate)

+ 9 - 0
Model/NSFetchedResultsControllerDelegate.swift

@@ -0,0 +1,9 @@
+import CoreData
+
+final class FetchedResultsControllerDelegate: NSObject, NSFetchedResultsControllerDelegate {
+    var onContentChange: (() -> Void)?
+
+    func controllerDidChangeContent(_: NSFetchedResultsController<any NSFetchRequestResult>) {
+        onContentChange?()
+    }
+}

+ 1 - 1
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="25B78" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24837" systemVersion="25D125" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -415,6 +415,7 @@
 		BD04ECCE2D29952A008C5FEB /* BolusProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD04ECCD2D299522008C5FEB /* BolusProgressOverlay.swift */; };
 		BD0B2EF32C5998E600B3298F /* MealPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0B2EF22C5998E600B3298F /* MealPresetView.swift */; };
 		BD10516D2DA986E1007C6D89 /* PulsingLogoAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD10516C2DA986DC007C6D89 /* PulsingLogoAnimation.swift */; };
+		BD136FAE2F6AC55F002217F9 /* NSFetchedResultsControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD136FAD2F6AC555002217F9 /* NSFetchedResultsControllerDelegate.swift */; };
 		BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179212F4E22C100F90001 /* TrioAlertManager.swift */; };
 		BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */; };
 		BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; };
@@ -1427,6 +1428,7 @@
 		BD04ECCD2D299522008C5FEB /* BolusProgressOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressOverlay.swift; sourceTree = "<group>"; };
 		BD0B2EF22C5998E600B3298F /* MealPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPresetView.swift; sourceTree = "<group>"; };
 		BD10516C2DA986DC007C6D89 /* PulsingLogoAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PulsingLogoAnimation.swift; sourceTree = "<group>"; };
+		BD136FAD2F6AC555002217F9 /* NSFetchedResultsControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSFetchedResultsControllerDelegate.swift; sourceTree = "<group>"; };
 		BD1179212F4E22C100F90001 /* TrioAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertManager.swift; sourceTree = "<group>"; };
 		BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertScheduler.swift; sourceTree = "<group>"; };
 		BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioUserNotificationAlertScheduler.swift; sourceTree = "<group>"; };
@@ -3284,6 +3286,7 @@
 		587A54C82BCDCE0F009D38E2 /* Model */ = {
 			isa = PBXGroup;
 			children = (
+				BD136FAD2F6AC555002217F9 /* NSFetchedResultsControllerDelegate.swift */,
 				3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */,
 				BDF34F8F2C10CF8C00D51995 /* CoreDataStack.swift */,
 				BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */,
@@ -5519,6 +5522,7 @@
 				BDA7593E2D37CFC400E649A4 /* CarbEntryEditorView.swift in Sources */,
 				118DF76A2C5ECBC60067FEB7 /* ApplyOverridePresetIntent.swift in Sources */,
 				58645B992CA2D1A4008AFCE7 /* GlucoseSetup.swift in Sources */,
+				BD136FAE2F6AC55F002217F9 /* NSFetchedResultsControllerDelegate.swift in Sources */,
 				7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */,
 				38E44534274E411700EC9A94 /* Disk+InternalHelpers.swift in Sources */,
 				DDF691052DA2CA23008BF16C /* AppDiagnosticsStateModel.swift in Sources */,

+ 100 - 61
Trio/Sources/APS/APSManager.swift

@@ -113,8 +113,6 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
-    let privateContext = CoreDataStack.shared.newTaskContext()
-
     private var openAPS: OpenAPS!
 
     private var lifetime = Lifetime()
@@ -362,14 +360,16 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func calculateLoopInterval(loopStartDate: Date) async -> Double? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "calculateLoopInterval"
         do {
-            return try await privateContext.perform { [weak self] in
+            return try await context.perform { [weak self] in
                 guard let self else { return nil }
                 let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
                 let sortStats = NSSortDescriptor(key: "end", ascending: false)
                 requestStats.sortDescriptors = [sortStats]
                 requestStats.fetchLimit = 1
-                let previousLoop = try self.privateContext.fetch(requestStats)
+                let previousLoop = try context.fetch(requestStats)
 
                 if (previousLoop.first?.end ?? .distantFuture) < loopStartDate {
                     return self.roundDouble(
@@ -470,15 +470,27 @@ final class BaseAPSManager: APSManager, Injectable {
 
         try await calculateAndStoreTDD()
 
-        // Fetch glucose asynchronously
-        let glucose = try await fetchGlucose(predicate: NSPredicate.predicateForOneHourAgo, fetchLimit: 6)
-
         var invalidGlucoseError: String?
 
-        // Perform the context-related checks and actions
-        let isValidGlucoseData = await privateContext.perform { [weak self] in
+        // Fetch glucose and run validation on the same context to avoid cross-context property access.
+        let validationContext = CoreDataStack.shared.newTaskContext()
+        validationContext.name = "determineBasal.validation"
+
+        let isValidGlucoseData = await validationContext.perform { [weak self] in
             guard let self else { return false }
 
+            let glucose: [GlucoseStored]
+            do {
+                glucose = try self.fetchGlucose(
+                    on: validationContext,
+                    predicate: NSPredicate.predicateForOneHourAgo,
+                    fetchLimit: 6
+                )
+            } catch {
+                debug(.apsManager, "Failed to fetch glucose for validation: \(error)")
+                return false
+            }
+
             guard glucose.count > 2 else {
                 debug(.apsManager, "Not enough glucose data")
                 invalidGlucoseError =
@@ -699,16 +711,19 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func fetchCurrentTempBasal(date: Date) async throws -> TempBasal {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchCurrentTempBasal"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
-            onContext: privateContext,
+            onContext: context,
             predicate: NSPredicate.recentPumpHistory,
             key: "timestamp",
             ascending: false,
-            fetchLimit: 1
+            fetchLimit: 1,
+            relationshipKeyPathsForPrefetching: ["tempBasal"]
         )
 
-        let fetchedTempBasal = await privateContext.perform {
+        let fetchedTempBasal = await context.perform {
             guard let fetchedResults = results as? [PumpEventStored],
                   let tempBasalEvent = fetchedResults.first,
                   let tempBasal = tempBasalEvent.tempBasal,
@@ -773,9 +788,11 @@ final class BaseAPSManager: APSManager, Injectable {
     private func setValues(determinationID: NSManagedObjectID) async throws
         -> (NSDecimalNumber?, TimeInterval?, NSDecimalNumber?)
     {
-        return try await privateContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "setValues"
+        return try await context.perform {
             do {
-                let determination = try self.privateContext.existingObject(with: determinationID) as? OrefDetermination
+                let determination = try context.existingObject(with: determinationID) as? OrefDetermination
 
                 let rate = determination?.rate
                 let duration = determination?.duration.flatMap { TimeInterval(truncating: $0) * 60 }
@@ -806,8 +823,10 @@ final class BaseAPSManager: APSManager, Injectable {
                 return
             }
 
-            try await privateContext.perform {
-                guard let determinationUpdated = try self.privateContext
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "reportEnacted"
+            try await context.perform {
+                guard let determinationUpdated = try context
                     .existingObject(with: determinationID) as? OrefDetermination
                 else {
                     debug(.apsManager, "Could not find determination object in context")
@@ -818,8 +837,8 @@ final class BaseAPSManager: APSManager, Injectable {
                 determinationUpdated.enacted = wasEnacted
                 determinationUpdated.isUploadedToNS = false
 
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
                 debug(.apsManager, "Determination enacted. Enacted: \(wasEnacted)")
             }
         } catch {
@@ -866,8 +885,8 @@ final class BaseAPSManager: APSManager, Injectable {
         return Double(sorted[length / 2])
     }
 
-    /// Computes Time-in-Range statistics. Must be called from inside a
-    /// `privateContext.perform` block — the inner perform was redundant
+    /// Computes Time-in-Range statistics. Must be called from within a
+    /// `perform`/`performAndWait` block on the context that owns `glucose`.
     private func tir(_ glucose: [GlucoseStored]) -> (TIR: Double, hypos: Double, hypers: Double, normal_: Double) {
         let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
         let totalReadings = justGlucoseArray.count
@@ -985,25 +1004,26 @@ final class BaseAPSManager: APSManager, Injectable {
         return output
     }
 
-    // fetch glucose for time interval
-    func fetchGlucose(predicate: NSPredicate, fetchLimit: Int? = nil, batchSize: Int? = nil) async throws -> [GlucoseStored] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+    /// Synchronously fetches glucose on the given context. Must be called from within a `perform`/`performAndWait` block of that context
+    func fetchGlucose(
+        on context: NSManagedObjectContext,
+        predicate: NSPredicate,
+        fetchLimit: Int? = nil,
+        batchSize: Int? = nil
+    ) throws -> [GlucoseStored] {
+        let results = try CoreDataStack.shared.fetchEntities(
             ofType: GlucoseStored.self,
-            onContext: privateContext,
+            onContext: context,
             predicate: predicate,
             key: "date",
             ascending: false,
             fetchLimit: fetchLimit,
             batchSize: batchSize
         )
-
-        return try await privateContext.perform {
-            guard let glucoseResults = results as? [GlucoseStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-
-            return glucoseResults
+        guard let glucoseResults = results as? [GlucoseStored] else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
         }
+        return glucoseResults
     }
 
     private func lastLoopForStats() async -> Date? {
@@ -1012,9 +1032,11 @@ final class BaseAPSManager: APSManager, Injectable {
         requestStats.sortDescriptors = [sortStats]
         requestStats.fetchLimit = 1
 
-        return await privateContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "lastLoopForStats"
+        return await context.perform {
             do {
-                return try self.privateContext.fetch(requestStats).first?.lastrun
+                return try context.fetch(requestStats).first?.lastrun
             } catch {
                 print(error.localizedDescription)
                 return .distantPast
@@ -1031,9 +1053,11 @@ final class BaseAPSManager: APSManager, Injectable {
         let sortLSR = NSSortDescriptor(key: "start", ascending: false)
         requestLSR.sortDescriptors = [sortLSR]
 
-        return await privateContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "loopStats"
+        return await context.perform {
             do {
-                let lsr = try self.privateContext.fetch(requestLSR)
+                let lsr = try context.fetch(requestLSR)
 
                 // Compute LoopStats for 24 hours
                 let oneDayLoops = self.loops(lsr)
@@ -1083,23 +1107,36 @@ final class BaseAPSManager: APSManager, Injectable {
         hbs: Durations,
         variance: Variance
     )? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "glucoseForStats"
         do {
-            // Get the Glucose Values
-            let glucose24h = try await fetchGlucose(predicate: NSPredicate.predicateForOneDayAgo, fetchLimit: 288, batchSize: 50)
-            let glucoseOneWeek = try await fetchGlucose(
-                predicate: NSPredicate.predicateForOneWeek,
-                batchSize: 250
-            )
-            let glucoseOneMonth = try await fetchGlucose(
-                predicate: NSPredicate.predicateForOneMonth,
-                batchSize: 500
-            )
-            let glucoseThreeMonths = try await fetchGlucose(
-                predicate: NSPredicate.predicateForThreeMonths,
-                batchSize: 1000
-            )
+            return try await context.perform {
+                // Fetch all windows on the same context so subsequent property access is safe.
+                let glucose24h = try self.fetchGlucose(
+                    on: context,
+                    predicate: NSPredicate.predicateForOneDayAgo,
+                    fetchLimit: 288,
+                    batchSize: 50
+                )
+                let glucoseOneWeek = try self.fetchGlucose(
+                    on: context,
+                    predicate: NSPredicate.predicateForOneWeek,
+                    fetchLimit: 288 * 7,
+                    batchSize: 250
+                )
+                let glucoseOneMonth = try self.fetchGlucose(
+                    on: context,
+                    predicate: NSPredicate.predicateForOneMonth,
+                    fetchLimit: 288 * 7 * 30,
+                    batchSize: 500
+                )
+                let glucoseThreeMonths = try self.fetchGlucose(
+                    on: context,
+                    predicate: NSPredicate.predicateForThreeMonths,
+                    fetchLimit: 288 * 7 * 30 * 3,
+                    batchSize: 1000
+                )
 
-            return await privateContext.perform {
                 let units = self.settingsManager.settings.units
 
                 // First date
@@ -1221,9 +1258,10 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func loopStats(loopStatRecord: LoopStats) {
-        privateContext.perform { [weak self] in
-            guard let self else { return }
-            let nLS = LoopStatRecord(context: self.privateContext)
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "loopStats.save"
+        context.perform {
+            let nLS = LoopStatRecord(context: context)
             nLS.start = loopStatRecord.start
             nLS.end = loopStatRecord.end ?? Date()
             nLS.loopStatus = loopStatRecord.loopStatus
@@ -1231,8 +1269,8 @@ final class BaseAPSManager: APSManager, Injectable {
             nLS.interval = loopStatRecord.interval ?? 0.0
 
             do {
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 print(error.localizedDescription)
             }
@@ -1443,8 +1481,9 @@ extension BaseAPSManager: PumpManagerStatusObserver {
     func pumpManager(_: PumpManager, didUpdate status: PumpManagerStatus, oldStatus _: PumpManagerStatus) {
         let percent = Int((status.pumpBatteryChargeRemaining ?? 1) * 100)
 
-        privateContext.perform { [weak self] in
-            guard let self else { return }
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "storeBatteryStatus"
+        context.perform {
             /// only update the last item with the current battery infos instead of saving a new one each time
             let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
             fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
@@ -1452,13 +1491,13 @@ extension BaseAPSManager: PumpManagerStatusObserver {
             fetchRequest.fetchLimit = 1
 
             do {
-                let results = try self.privateContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 let batteryToStore: OpenAPS_Battery
 
                 if let existingBattery = results.first {
                     batteryToStore = existingBattery
                 } else {
-                    batteryToStore = OpenAPS_Battery(context: self.privateContext)
+                    batteryToStore = OpenAPS_Battery(context: context)
                     batteryToStore.id = UUID()
                 }
 
@@ -1468,8 +1507,8 @@ extension BaseAPSManager: PumpManagerStatusObserver {
                 batteryToStore.status = percent > 10 ? "normal" : "low"
                 batteryToStore.display = status.pumpBatteryChargeRemaining != nil
 
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 debug(.apsManager, "Failed to fetch or save battery: \(error)")
             }

+ 13 - 10
Trio/Sources/APS/DeviceDataManager.swift

@@ -80,7 +80,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     @SyncAccess private var pumpUpdateCancellable: AnyCancellable?
     private var pumpUpdatePromise: Future<Bool, Never>.Promise?
     @SyncAccess var loopInProgress: Bool = false
-    private let privateContext = CoreDataStack.shared.newTaskContext()
 
     var pumpManager: PumpManagerUI? {
         didSet {
@@ -174,8 +173,10 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                         display: simulatorPump.state.pumpBatteryChargeRemaining != nil
                     )
                     Task {
-                        await self.privateContext.perform {
-                            let saveBatteryToCoreData = OpenAPS_Battery(context: self.privateContext)
+                        let context = CoreDataStack.shared.newTaskContext()
+                        context.name = "storeSimulatorBattery"
+                        await context.perform {
+                            let saveBatteryToCoreData = OpenAPS_Battery(context: context)
                             saveBatteryToCoreData.id = UUID()
                             saveBatteryToCoreData.date = Date()
                             saveBatteryToCoreData.percent = Double(batteryPercent)
@@ -185,8 +186,8 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                             saveBatteryToCoreData.display = simulatorPump.state.pumpBatteryChargeRemaining != nil
 
                             do {
-                                guard self.privateContext.hasChanges else { return }
-                                try self.privateContext.save()
+                                guard context.hasChanges else { return }
+                                try context.save()
                             } catch {
                                 print(error.localizedDescription)
                             }
@@ -209,18 +210,20 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                 storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences)
                 // Remove OpenAPS_Battery entries
                 Task {
-                    await self.privateContext.perform {
+                    let context = CoreDataStack.shared.newTaskContext()
+                    context.name = "deleteBatteryEntries"
+                    await context.perform {
                         let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
 
                         do {
-                            let batteryEntries = try self.privateContext.fetch(fetchRequest)
+                            let batteryEntries = try context.fetch(fetchRequest)
 
                             for entry in batteryEntries {
-                                self.privateContext.delete(entry)
+                                context.delete(entry)
                             }
 
-                            guard self.privateContext.hasChanges else { return }
-                            try self.privateContext.save()
+                            guard context.hasChanges else { return }
+                            try context.save()
 
                         } catch {
                             debug(.deviceManager, "Failed to delete OpenAPS_Battery entries: \(error)")

+ 6 - 4
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -63,8 +63,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
 
     private lazy var simulatorSource = GlucoseSimulatorSource()
 
-    private let context = CoreDataStack.shared.newTaskContext()
-
     /// Enforce mutual exclusion on calls to glucoseStoreAndHeartDecision
     private let glucoseStoreAndHeartLock = DispatchSemaphore(value: 1)
 
@@ -305,7 +303,9 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         try await glucoseStorage.storeGlucose(filtered)
 
         if settingsManager.settings.smoothGlucose {
-            await exponentialSmoothingGlucose(context: context)
+            let smoothingContext = CoreDataStack.shared.newTaskContext()
+            smoothingContext.name = "exponentialSmoothingGlucose"
+            await exponentialSmoothingGlucose(context: smoothingContext)
         }
 
         deviceDataManager.heartbeat(date: Date())
@@ -382,7 +382,9 @@ extension BaseFetchGlucoseManager: SettingsObserver {
 
             self.glucoseStoreAndHeartLock.wait()
             Task {
-                await self.exponentialSmoothingGlucose(context: self.context)
+                let context = CoreDataStack.shared.newTaskContext()
+                context.name = "exponentialSmoothingGlucose"
+                await self.exponentialSmoothingGlucose(context: context)
                 self.glucoseStoreAndHeartLock.signal()
             }
         }

+ 0 - 1
Trio/Sources/APS/FetchTreatmentsManager.swift

@@ -14,7 +14,6 @@ final class BaseFetchTreatmentsManager: FetchTreatmentsManager, Injectable {
 
     private var lifetime = Lifetime()
     private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval)
-    private var backgroundContext = CoreDataStack.shared.newTaskContext()
 
     init(resolver: Resolver) {
         injectServices(resolver)

+ 58 - 37
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -10,10 +10,14 @@ final class OpenAPS {
     private let storage: FileStorage
     private let tddStorage: TDDStorage
 
-    let context = CoreDataStack.shared.newTaskContext()
-
     let jsonConverter = JSONConverter()
 
+    private func newContext(_ name: String) -> NSManagedObjectContext {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = name
+        return context
+    }
+
     init(storage: FileStorage, tddStorage: TDDStorage) {
         self.storage = storage
         self.tddStorage = tddStorage
@@ -32,9 +36,9 @@ final class OpenAPS {
     }
 
     // Use the helper function for cleaner code
-    func processDetermination(_ determination: Determination) async {
+    func processDetermination(_ determination: Determination, on context: NSManagedObjectContext) async {
         await context.perform {
-            let newOrefDetermination = OrefDetermination(context: self.context)
+            let newOrefDetermination = OrefDetermination(context: context)
             newOrefDetermination.id = UUID()
             newOrefDetermination.insulinSensitivity = self.decimalToNSDecimalNumber(determination.isf)
             newOrefDetermination.currentTarget = self.decimalToNSDecimalNumber(determination.current_target)
@@ -62,14 +66,14 @@ final class OpenAPS {
                 ["iob": predictions.iob, "zt": predictions.zt, "cob": predictions.cob, "uam": predictions.uam]
                     .forEach { type, values in
                         if let values = values {
-                            let forecast = Forecast(context: self.context)
+                            let forecast = Forecast(context: context)
                             forecast.id = UUID()
                             forecast.type = type
                             forecast.date = Date()
                             forecast.orefDetermination = newOrefDetermination
 
                             for (index, value) in values.enumerated() {
-                                let forecastValue = ForecastValue(context: self.context)
+                                let forecastValue = ForecastValue(context: context)
                                 forecastValue.index = Int32(index)
                                 forecastValue.value = Int32(value)
                                 forecast.addToForecastValues(forecastValue)
@@ -81,14 +85,14 @@ final class OpenAPS {
         }
 
         // First save the current Determination to Core Data
-        await attemptToSaveContext()
+        await attemptToSaveContext(on: context)
     }
 
-    func attemptToSaveContext() async {
+    func attemptToSaveContext(on context: NSManagedObjectContext) async {
         await context.perform {
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Determination to Core Data")
             }
@@ -161,7 +165,11 @@ final class OpenAPS {
         return jsonConverter.convertToJSON(algorithmGlucose)
     }
 
-    private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
+    private func fetchAndProcessCarbs(
+        on context: NSManagedObjectContext,
+        additionalCarbs: Decimal? = nil,
+        carbsDate: Date? = nil
+    ) async throws -> String {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
@@ -213,14 +221,15 @@ final class OpenAPS {
         return json
     }
 
-    private func fetchPumpHistoryObjectIDs() async throws -> [NSManagedObjectID]? {
+    private func fetchPumpHistoryObjectIDs(on context: NSManagedObjectContext) async throws -> [NSManagedObjectID]? {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpHistoryLast1440Minutes,
             key: "timestamp",
             ascending: false,
-            batchSize: 50
+            batchSize: 50,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return try await context.perform {
@@ -233,6 +242,7 @@ final class OpenAPS {
     }
 
     private func parsePumpHistory(
+        on context: NSManagedObjectContext,
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
         simulatedBolusAmount: Decimal? = nil
     ) async throws -> String {
@@ -245,12 +255,12 @@ final class OpenAPS {
         // the oldest event in pump history can be a resume with no preceding pump
         // activity. oref interprets this as the end of a suspend that never started,
         // which drives negative IOB and can cause excessive insulin delivery.
-        let orphanedResumes = try await fetchOrphanedResumes()
+        let orphanedResumes = try await fetchOrphanedResumes(on: context)
 
         // Execute all operations on the background context
         return await context.perform {
             // Load and map pump events to DTOs
-            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes)
+            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes, on: context)
 
             // Optionally add the IOB as a DTO
             if let simulatedBolusAmount = simulatedBolusAmount {
@@ -265,7 +275,8 @@ final class OpenAPS {
 
     private func loadAndMapPumpEvents(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
-        orphanedResumes: [NSManagedObjectID]
+        orphanedResumes: [NSManagedObjectID],
+        on context: NSManagedObjectContext
     ) -> [PumpEventDTO] {
         OpenAPS.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes, from: context)
     }
@@ -333,7 +344,7 @@ final class OpenAPS {
     }
 
     /// Detects a cold-start orphaned resume: returns the resume's object ID if it's an orphaned resume
-    private func fetchOrphanedResumes() async throws -> [NSManagedObjectID] {
+    private func fetchOrphanedResumes(on context: NSManagedObjectContext) async throws -> [NSManagedObjectID] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
@@ -394,12 +405,18 @@ final class OpenAPS {
     ) async throws -> Determination? {
         debug(.openAPS, "Start determineBasal")
 
+        let context = newContext("determineBasal")
+
         // temp_basal
         let tempBasal = currentTemp.rawJSON
 
         // Perform asynchronous calls in parallel
-        async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
-        async let carbs = fetchAndProcessCarbs(additionalCarbs: simulatedCarbsAmount ?? 0, carbsDate: simulatedCarbsDate)
+        async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs(on: context) ?? []
+        async let carbs = fetchAndProcessCarbs(
+            on: context,
+            additionalCarbs: simulatedCarbsAmount ?? 0,
+            carbsDate: simulatedCarbsDate
+        )
 
         var preferences = await storage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
         let glucoseFetchHours = preferences.maxMealAbsorptionTime + 0.5 // MMAT + half hour buffer
@@ -410,7 +427,7 @@ final class OpenAPS {
             fetchHours: glucoseFetchHours
         )
 
-        async let prepareTrioCustomOrefVariables = prepareTrioCustomOrefVariables()
+        async let prepareTrioCustomOrefVariables = prepareTrioCustomOrefVariables(on: context)
         async let profileAsync = loadFileFromStorageAsync(name: Settings.profile)
         async let basalAsync = loadFileFromStorageAsync(name: Settings.basalProfile)
         async let autosenseAsync = loadFileFromStorageAsync(name: Settings.autosense)
@@ -429,7 +446,7 @@ final class OpenAPS {
             reservoir,
             hasSufficientTdd
         ) = await (
-            try parsePumpHistory(await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
+            try parsePumpHistory(on: context, await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
             try carbs,
             try glucose,
             try prepareTrioCustomOrefVariables,
@@ -497,7 +514,7 @@ final class OpenAPS {
 
             if !simulation {
                 // save to core data asynchronously
-                await processDetermination(determination)
+                await processDetermination(determination, on: context)
             }
 
             return determination
@@ -510,7 +527,7 @@ final class OpenAPS {
         }
     }
 
-    func prepareTrioCustomOrefVariables() async throws -> RawJSON {
+    func prepareTrioCustomOrefVariables(on context: NSManagedObjectContext) async throws -> RawJSON {
         try await context.perform {
             // Retrieve user preferences
             let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
@@ -521,10 +538,10 @@ final class OpenAPS {
             // Fetch historical events for Total Daily Dose (TDD) calculation
             let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
             let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval)
-            let historicalTDDData = try self.fetchHistoricalTDDData(from: tenDaysAgo)
+            let historicalTDDData = try self.fetchHistoricalTDDData(from: tenDaysAgo, on: context)
 
             // Fetch the last active Override
-            let activeOverrides = try self.fetchActiveOverrides()
+            let activeOverrides = try self.fetchActiveOverrides(on: context)
             let isOverrideActive = activeOverrides.first?.enabled ?? false
             let overridePercentage = Decimal(activeOverrides.first?.percentage ?? 100)
             let isOverrideIndefinite = activeOverrides.first?.indefinite ?? true
@@ -546,7 +563,7 @@ final class OpenAPS {
             let averageTDDLastTenDays = totalTDD / Decimal(totalDaysCount)
             let weightedTDD = weightPercentage * averageTDDLastTwoHours + (1 - weightPercentage) * averageTDDLastTenDays
 
-            let glucose = try self.fetchGlucose()
+            let glucose = try self.fetchGlucose(on: context)
 
             // Prepare Trio's custom oref variables
             let trioCustomOrefVariablesData = TrioCustomOrefVariables(
@@ -581,9 +598,11 @@ final class OpenAPS {
     func autosense(shouldSmoothGlucose: Bool, useJavascriptOref: Bool) async throws -> Autosens? {
         debug(.openAPS, "Start autosens")
 
+        let context = newContext("autosense")
+
         // Perform asynchronous calls in parallel
-        async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
-        async let carbs = fetchAndProcessCarbs()
+        async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs(on: context) ?? []
+        async let carbs = fetchAndProcessCarbs(on: context)
         async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: nil)
         async let getProfile = loadFileFromStorageAsync(name: Settings.profile)
         async let getBasalProfile = loadFileFromStorageAsync(name: Settings.basalProfile)
@@ -591,7 +610,7 @@ final class OpenAPS {
 
         // Await the results of asynchronous tasks
         let (pumpHistoryJSON, carbsAsJSON, glucoseAsJSON, profile, basalProfile, tempTargets) = await (
-            try parsePumpHistory(await pumpHistoryObjectIDs),
+            try parsePumpHistory(on: context, await pumpHistoryObjectIDs),
             try carbs,
             try glucose,
             getProfile,
@@ -651,9 +670,10 @@ final class OpenAPS {
         var adjustedPreferences = preferences
 
         // Check for active Temp Targets and adjust HBT if necessary
+        let context = newContext("createProfiles")
         try await context.perform {
             // Check if a Temp Target is active and check HBT differs from setting and adjust
-            if let activeTempTarget = try self.fetchActiveTempTargets().first,
+            if let activeTempTarget = try self.fetchActiveTempTargets(on: context).first,
                activeTempTarget.enabled,
                let targetValue = activeTempTarget.target?.decimalValue
             {
@@ -1140,15 +1160,16 @@ final class OpenAPS {
 
     func processAndSave(forecastData: [String: [Int]]) {
         let currentDate = Date()
+        let context = newContext("processAndSave")
 
         context.perform {
             for (type, values) in forecastData {
-                self.createForecast(type: type, values: values, date: currentDate, context: self.context)
+                self.createForecast(type: type, values: values, date: currentDate, context: context)
             }
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 print(error.localizedDescription)
             }
@@ -1172,7 +1193,7 @@ final class OpenAPS {
 
 // Non-Async fetch methods for trio_custom_oref_variables
 extension OpenAPS {
-    func fetchActiveTempTargets() throws -> [TempTargetStored] {
+    func fetchActiveTempTargets(on context: NSManagedObjectContext) throws -> [TempTargetStored] {
         try CoreDataStack.shared.fetchEntities(
             ofType: TempTargetStored.self,
             onContext: context,
@@ -1183,7 +1204,7 @@ extension OpenAPS {
         ) as? [TempTargetStored] ?? []
     }
 
-    func fetchActiveOverrides() throws -> [OverrideStored] {
+    func fetchActiveOverrides(on context: NSManagedObjectContext) throws -> [OverrideStored] {
         try CoreDataStack.shared.fetchEntities(
             ofType: OverrideStored.self,
             onContext: context,
@@ -1194,7 +1215,7 @@ extension OpenAPS {
         ) as? [OverrideStored] ?? []
     }
 
-    func fetchHistoricalTDDData(from date: Date) throws -> [[String: Any]] {
+    func fetchHistoricalTDDData(from date: Date, on context: NSManagedObjectContext) throws -> [[String: Any]] {
         try CoreDataStack.shared.fetchEntities(
             ofType: TDDStored.self,
             onContext: context,
@@ -1205,7 +1226,7 @@ extension OpenAPS {
         ) as? [[String: Any]] ?? []
     }
 
-    func fetchGlucose() throws -> [GlucoseStored] {
+    func fetchGlucose(on context: NSManagedObjectContext) throws -> [GlucoseStored] {
         let results = try CoreDataStack.shared.fetchEntities(
             ofType: GlucoseStored.self,
             onContext: context,

+ 29 - 20
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -33,10 +33,10 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         updateSubject.eraseToAnyPublisher()
     }
 
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -74,6 +74,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     private func filterRemoteEntries(entries: [CarbsEntry]) async throws -> [CarbsEntry] {
+        let context = makeContext()
+        context.name = "filterRemoteEntries"
         // Fetch only the date property from Core Data
         guard let existing24hCarbEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
@@ -280,8 +282,10 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
         guard let entry = entries.last else { return }
 
+        let context = makeContext()
+        context.name = "saveCarbsToCoreData"
         await context.perform {
-            let newItem = CarbEntryStored(context: self.context)
+            let newItem = CarbEntryStored(context: context)
             newItem.date = entry.actualDate ?? entry.createdAt
             newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
             newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
@@ -298,8 +302,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             }
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 print(error.localizedDescription)
             }
@@ -327,9 +331,11 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             // do NOT set Health and Tidepool flags to ensure they will NOT be uploaded
             return false // return false to continue
         }
+        let context = makeContext()
+        context.name = "saveFPUToCoreDataAsBatchInsert"
         await context.perform {
             do {
-                try self.context.execute(batchInsert)
+                try context.execute(batchInsert)
                 debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
 
                 // Notify subscriber in Home State Model to update the FPU Array
@@ -345,19 +351,14 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async {
-        // Use injected context if available, otherwise create new task context
-        let taskContext = context != CoreDataStack.shared.newTaskContext()
-            ? context
-            : CoreDataStack.shared.newTaskContext()
-
-        taskContext.name = "deleteContext"
-        taskContext.transactionAuthor = "deleteCarbs"
+        let context = makeContext()
+        context.name = "deleteCarbsEntryStored"
 
         var carbEntryFromCoreData: CarbEntryStored?
 
-        await taskContext.perform {
+        await context.perform {
             do {
-                carbEntryFromCoreData = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
+                carbEntryFromCoreData = try context.existingObject(with: treatmentObjectID) as? CarbEntryStored
                 guard let carbEntry = carbEntryFromCoreData else {
                     debugPrint("Carb entry for batch delete not found. \(DebuggingIdentifiers.failed)")
                     return
@@ -377,7 +378,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                     deleteRequest.resultType = .resultTypeCount
 
                     // execute the batch delete request
-                    let result = try taskContext.execute(deleteRequest) as? NSBatchDeleteResult
+                    let result = try context.execute(deleteRequest) as? NSBatchDeleteResult
                     debugPrint("\(DebuggingIdentifiers.succeeded) Deleted \(result?.result ?? 0) items with FpuID \(fpuID)")
 
                     // Notifiy subscribers of the batch delete
@@ -386,10 +387,10 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
                 // entry has no fpuID
                 // => it's a carb-only entry. use its ID to for deletion
                 else {
-                    taskContext.delete(carbEntry)
+                    context.delete(carbEntry)
 
-                    guard taskContext.hasChanges else { return }
-                    try taskContext.save()
+                    guard context.hasChanges else { return }
+                    try context.save()
 
                     debugPrint(
                         "CarbsStorage: \(#function) \(DebuggingIdentifiers.succeeded) deleted carb entry from core data"
@@ -403,6 +404,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getCarbsNotYetUploadedToNightscout"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
@@ -442,6 +445,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getFPUsNotYetUploadedToNightscout"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
@@ -481,6 +486,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry] {
+        let context = makeContext()
+        context.name = "getCarbsNotYetUploadedToHealth"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,
@@ -512,6 +519,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     }
 
     func getCarbsNotYetUploadedToTidepool() async throws -> [CarbsEntry] {
+        let context = makeContext()
+        context.name = "getCarbsNotYetUploadedToTidepool"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: context,

+ 19 - 12
Trio/Sources/APS/Storage/ContactImageStorage.swift

@@ -13,9 +13,10 @@ protocol ContactImageStorage {
 final class BaseContactImageStorage: ContactImageStorage, Injectable {
     @Injected() private var settingsManager: SettingsManager!
 
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -26,16 +27,18 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
     ///
     /// - Returns: An array of `ContactImageEntry` objects.
     func fetchContactImageEntries() async -> [ContactImageEntry] {
+        let context = makeContext()
+        context.name = "fetchContactImageEntries"
         do {
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: ContactImageEntryStored.self,
-                onContext: backgroundContext,
+                onContext: context,
                 predicate: NSPredicate.all,
                 key: "hasHighContrast",
                 ascending: false
             )
 
-            return try await backgroundContext.perform {
+            return try await context.perform {
                 guard let fetchedContactImageEntries = results as? [ContactImageEntryStored]
                 else { throw CoreDataError.fetchError(function: #function, file: #file)
                 }
@@ -75,8 +78,10 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
     ///
     /// - Parameter contactImageEntry: The `ContactImageEntry` object to be stored.
     func storeContactImageEntry(_ contactImageEntry: ContactImageEntry) async {
-        await backgroundContext.perform {
-            let newContactImageEntry = ContactImageEntryStored(context: self.backgroundContext)
+        let context = makeContext()
+        context.name = "storeContactImageEntry"
+        await context.perform {
+            let newContactImageEntry = ContactImageEntryStored(context: context)
 
             newContactImageEntry.id = UUID()
             newContactImageEntry.name = contactImageEntry.name
@@ -96,8 +101,8 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
             newContactImageEntry.fontWeight = contactImageEntry.fontWeight.asString
 
             do {
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Contact Trick Entry to Core Data with error: \(error.userInfo)"
@@ -114,12 +119,14 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
     ///
     /// - Parameter contactImageEntry: The `ContactImageEntry` object with updated values.
     func updateContactImageEntry(_ contactImageEntry: ContactImageEntry) async {
-        await backgroundContext.perform {
+        let context = makeContext()
+        context.name = "updateContactImageEntry"
+        await context.perform {
             let fetchRequest: NSFetchRequest<ContactImageEntryStored> = ContactImageEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "contactId == %@", contactImageEntry.contactId ?? "")
 
             do {
-                if let existingEntry = try self.backgroundContext.fetch(fetchRequest).first {
+                if let existingEntry = try context.fetch(fetchRequest).first {
                     // Update the properties of the existing entry
                     existingEntry.name = contactImageEntry.name
                     existingEntry.layout = contactImageEntry.layout.rawValue
@@ -136,8 +143,8 @@ final class BaseContactImageStorage: ContactImageStorage, Injectable {
                     existingEntry.fontWeight = contactImageEntry.fontWeight.asString
                     existingEntry.fontWidth = contactImageEntry.fontWidth.asString
 
-                    guard self.backgroundContext.hasChanges else { return }
-                    try self.backgroundContext.save()
+                    guard context.hasChanges else { return }
+                    try context.save()
                 } else {
                     debugPrint(
                         "\(DebuggingIdentifiers.failed) \(#file) \(#function) No matching Contact Trick Entry found to update."

+ 30 - 27
Trio/Sources/APS/Storage/DeterminationStorage.swift

@@ -18,14 +18,16 @@ protocol DeterminationStorage {
 
 final class BaseDeterminationStorage: DeterminationStorage, Injectable {
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
     func fetchLastDeterminationObjectID(predicate: NSPredicate) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchLastDeterminationObjectID"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             onContext: context,
@@ -116,31 +118,30 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
 
     // Convert NSSet to array of Ints for Predictions
     func parseForecastValues(ofType type: String, from determinationID: NSManagedObjectID) async -> [Int]? {
-        let forecastIDs = await getForecastIDs(for: determinationID, in: context)
-
-        var forecastValuesList: [Int] = []
-
-        for forecastID in forecastIDs {
-            await context.perform {
-                if let forecast = try? self.context.existingObject(with: forecastID) as? Forecast {
-                    // Filter the forecast based on the type
-                    if forecast.type == type {
-                        let forecastValueIDs = forecast.forecastValues?.sorted(by: { $0.index < $1.index }).map(\.objectID) ?? []
-
-                        for forecastValueID in forecastValueIDs {
-                            if let forecastValue = try? self.context
-                                .existingObject(with: forecastValueID) as? ForecastValue
-                            {
-                                let forecastValueInt = Int(forecastValue.value)
-                                forecastValuesList.append(forecastValueInt)
-                            }
-                        }
-                    }
-                }
+        let context = makeContext()
+        context.name = "parseForecastValues"
+
+        return await context.perform {
+            let request = NSFetchRequest<Forecast>(entityName: "Forecast")
+            request.predicate = NSPredicate(
+                format: "orefDetermination = %@ AND type == %@",
+                determinationID,
+                type
+            )
+            request.fetchLimit = 1
+            request.relationshipKeyPathsForPrefetching = ["forecastValues"]
+
+            do {
+                guard let forecast = try context.fetch(request).first else { return nil }
+                let values = forecast.forecastValuesArray.map { Int($0.value) }
+                return values.isEmpty ? nil : values
+            } catch {
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast of type \(type): \(error)"
+                )
+                return nil
             }
         }
-
-        return forecastValuesList.isEmpty ? nil : forecastValuesList
     }
 
     func getOrefDeterminationNotYetUploadedToNightscout(_ determinationIds: [NSManagedObjectID]) async -> Determination? {
@@ -157,9 +158,11 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
             uam: await parseForecastValues(ofType: "uam", from: determinationId)
         )
 
+        let context = makeContext()
+        context.name = "getOrefDeterminationNotYetUploadedToNightscout"
         return await context.perform {
             do {
-                let orefDetermination = try self.context.existingObject(with: determinationId) as? OrefDetermination
+                let orefDetermination = try context.existingObject(with: determinationId) as? OrefDetermination
 
                 // Check if the fetched object is of the expected type
                 if let orefDetermination = orefDetermination {

+ 74 - 37
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -45,10 +45,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         static let minimumGlucose: Int = 39
     }
 
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -77,27 +77,32 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     ///  it isn't within 3.5 minutes of an existing glucose reading, which is simple but not perfect.
     ///  But since this is a corner case that really shouldn't happen often, it's good enough.
     func backfillGlucose(_ glucose: [BloodGlucose]) async throws {
+        let context = makeContext()
+        context.name = "backfillGlucose"
         let clamped = clampToMinimum(glucose)
+
         try await context.perform {
             // remove already deleted glucose values
             let withoutDeletedGlucose = self.filterGlucoseValues(
                 clamped,
                 fetchRequest: DeletedGlucoseStored.fetchRequest(),
-                timeBuffer: 1
+                timeBuffer: 1,
+                context: context
             )
 
             // check for a 3.5 minute difference between existing values
             let filteredGlucose = self.filterGlucoseValues(
                 withoutDeletedGlucose,
                 fetchRequest: GlucoseStored.fetchRequest(),
-                timeBuffer: 3.5 * 60
+                timeBuffer: 3.5 * 60,
+                context: context
             )
 
             guard !filteredGlucose.isEmpty else { return }
 
             do {
                 // Store glucose values in Core Data
-                try self.storeGlucoseInCoreData(filteredGlucose)
+                try self.storeGlucoseInCoreData(filteredGlucose, context: context)
             } catch {
                 throw CoreDataError.creationError(
                     function: #function,
@@ -108,15 +113,23 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func storeGlucose(_ glucose: [BloodGlucose]) async throws {
+        let context = makeContext()
+        context.name = "storeGlucose"
         let clamped = clampToMinimum(glucose)
+
         try await context.perform {
             // Get new glucose values that don't exist yet
-            let newGlucose = self.filterGlucoseValues(clamped, fetchRequest: GlucoseStored.fetchRequest(), timeBuffer: 1)
+            let newGlucose = self.filterGlucoseValues(
+                clamped,
+                fetchRequest: GlucoseStored.fetchRequest(),
+                timeBuffer: 1,
+                context: context
+            )
             guard !newGlucose.isEmpty else { return }
 
             do {
                 // Store glucose values in Core Data
-                try self.storeGlucoseInCoreData(newGlucose)
+                try self.storeGlucoseInCoreData(newGlucose, context: context)
             } catch {
                 throw CoreDataError.creationError(
                     function: #function,
@@ -161,7 +174,8 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     private func filterGlucoseValues(
         _ glucose: [BloodGlucose],
         fetchRequest: NSFetchRequest<NSFetchRequestResult>,
-        timeBuffer: TimeInterval
+        timeBuffer: TimeInterval,
+        context: NSManagedObjectContext
     ) -> [BloodGlucose] {
         let datesToCheck = glucose.map(\.dateString).sorted()
         guard let firstDate = datesToCheck.first.map({ $0.addingTimeInterval(-timeBuffer) }),
@@ -198,15 +212,15 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
-    private func storeGlucoseInCoreData(_ glucose: [BloodGlucose]) throws {
+    private func storeGlucoseInCoreData(_ glucose: [BloodGlucose], context: NSManagedObjectContext) throws {
         if glucose.count > 1 {
-            try storeGlucoseBatch(glucose)
+            try storeGlucoseBatch(glucose, context: context)
         } else {
-            try storeGlucoseRegular(glucose)
+            try storeGlucoseRegular(glucose, context: context)
         }
     }
 
-    private func storeGlucoseRegular(_ glucose: [BloodGlucose]) throws {
+    private func storeGlucoseRegular(_ glucose: [BloodGlucose], context: NSManagedObjectContext) throws {
         for entry in glucose {
             let glucoseEntry = GlucoseStored(context: context)
             configureGlucoseEntry(glucoseEntry, with: entry)
@@ -217,7 +231,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         updateSubject.send()
     }
 
-    private func storeGlucoseBatch(_ glucose: [BloodGlucose]) throws {
+    private func storeGlucoseBatch(_ glucose: [BloodGlucose], context: NSManagedObjectContext) throws {
         var remainingGlucose = glucose
         let batchInsert = NSBatchInsertRequest(
             entity: GlucoseStored.entity(),
@@ -314,8 +328,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func addManualGlucose(glucose: Int) {
+        let context = makeContext()
+        context.name = "addManualGlucose"
+
         context.perform {
-            let newItem = GlucoseStored(context: self.context)
+            let newItem = GlucoseStored(context: context)
             newItem.id = UUID()
             newItem.date = Date()
             newItem.glucose = Int16(glucose)
@@ -325,8 +342,8 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             newItem.isUploadedToTidepool = false
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
 
                 // Glucose subscribers already listen to the update publisher, so call here to update glucose-related data.
                 self.updateSubject.send()
@@ -344,8 +361,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func syncDate() -> Date {
+        let context = makeContext()
+        context.name = "syncDate"
+
         // Optimize fetch request to only get the date
-        let taskContext = CoreDataStack.shared.newTaskContext()
         let fr = NSFetchRequest<NSDictionary>(entityName: "GlucoseStored")
         fr.predicate = NSPredicate.predicateForOneDayAgo
         fr.propertiesToFetch = ["date"]
@@ -355,9 +374,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
         var fetchedDate: Date = .distantPast
 
-        taskContext.performAndWait {
+        context.performAndWait {
             do {
-                if let result = try taskContext.fetch(fr).first,
+                if let result = try context.fetch(fr).first,
                    let date = result["date"] as? Date
                 {
                     fetchedDate = date
@@ -371,6 +390,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func lastGlucoseDate() -> Date? {
+        let context = makeContext()
+        context.name = "lastGlucoseDate"
+
         let fetchRequest = GlucoseStored.fetchRequest()
         fetchRequest.predicate = NSPredicate.predicateForOneDayAgo
         fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)]
@@ -379,7 +401,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         var date: Date?
         context.performAndWait {
             do {
-                let results = try self.context.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 date = results.first?.date
             } catch let error as NSError {
                 debug(.storage, "Fetch error: \(DebuggingIdentifiers.failed) \(error), \(error.userInfo)")
@@ -409,7 +431,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         return filtered
     }
 
-    func fetchLatestGlucose() throws -> GlucoseStored? {
+    func fetchLatestGlucose(context: NSManagedObjectContext) throws -> GlucoseStored? {
         let predicate = NSPredicate.predicateFor20MinAgo
         return (try CoreDataStack.shared.fetchEntities(
             ofType: GlucoseStored.self,
@@ -424,6 +446,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
     func getGlucoseNotYetUploadedToNightscout() async throws -> [BloodGlucose] {
+        let context = makeContext()
+        context.name = "getGlucoseNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -477,6 +502,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of BloodGlucose to ensure the correct format for the NS Upload
     func getGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
+        let context = makeContext()
+        context.name = "getGlucoseNotYetUploadedToHealth"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -509,6 +537,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch manual glucose that is not uploaded to Nightscout yet
     /// - Returns: Array of NightscoutTreatment to ensure the correct format for the NS Upload
     func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
+        let context = makeContext()
+        context.name = "getManualGlucoseNotYetUploadedToHealth"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -541,6 +572,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch glucose that is not uploaded to Tidepool yet
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for Tidepool upload
     func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
+        let context = makeContext()
+        context.name = "getGlucoseNotYetUploadedToTidepool"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -574,6 +608,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     // Fetch manual glucose that is not uploaded to Tidepool yet
     /// - Returns: Array of StoredGlucoseSample to ensure the correct format for the Tidepool upload
     func getManualGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
+        let context = makeContext()
+        context.name = "getManualGlucoseNotYetUploadedToTidepool"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -726,16 +763,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 //    }
 
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
-        // Use injected context if available, otherwise create new task context
-        let taskContext = context != CoreDataStack.shared.newTaskContext()
-            ? context
-            : CoreDataStack.shared.newTaskContext()
-        taskContext.name = "deleteContext"
-        taskContext.transactionAuthor = "deleteGlucose"
-
-        await taskContext.perform {
+        let context = makeContext()
+        context.name = "deleteGlucose"
+        context.transactionAuthor = "deleteGlucose"
+
+        await context.perform {
             do {
-                let result = try taskContext.existingObject(with: treatmentObjectID) as? GlucoseStored
+                let result = try context.existingObject(with: treatmentObjectID) as? GlucoseStored
 
                 guard let glucoseToDelete = result else {
                     debugPrint("Data Table State: \(#function) \(DebuggingIdentifiers.failed) glucose not found in core data")
@@ -744,16 +778,16 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
                 // Create a new DeletedGlucoseStored object and copy the properties
                 if let date = glucoseToDelete.date {
-                    let deletedEntry = DeletedGlucoseStored(context: taskContext)
+                    let deletedEntry = DeletedGlucoseStored(context: context)
                     deletedEntry.date = date
                     deletedEntry.glucose = glucoseToDelete.glucose
                     deletedEntry.isManualGlucoseEntry = glucoseToDelete.isManual
                 }
 
-                taskContext.delete(glucoseToDelete)
+                context.delete(glucoseToDelete)
 
-                guard taskContext.hasChanges else { return }
-                try taskContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
                 debugPrint("\(#file) \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from core data")
             } catch {
                 debugPrint(
@@ -764,10 +798,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     var alarm: GlucoseAlarm? {
+        let context = makeContext()
+        context.name = "alarm"
+
         /// glucose can not be older than 20 minutes due to the predicate in the fetch request
-        context.performAndWait {
+        return context.performAndWait {
             do {
-                guard let glucose = try fetchLatestGlucose() else { return nil }
+                guard let glucose = try fetchLatestGlucose(context: context) else { return nil }
 
                 let glucoseValue = glucose.glucose
 

+ 35 - 12
Trio/Sources/APS/Storage/OverrideStorage.swift

@@ -25,10 +25,10 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     @Injected() private var settingsManager: SettingsManager!
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -41,6 +41,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func fetchLastCreatedOverride() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchLastCreatedOverride"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -63,6 +66,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func loadLatestOverrideConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "loadLatestOverrideConfigurations"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -83,6 +89,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
 
     /// Returns the NSManagedObjectID of the Override Presets
     func fetchForOverridePresets() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchForOverridePresets"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -114,8 +123,11 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
             presetCount = presets.count
         }
 
+        let context = makeContext()
+        context.name = "storeOverride"
+
         try await context.perform {
-            let newOverride = OverrideStored(context: self.context)
+            let newOverride = OverrideStored(context: context)
 
             // override key meta data
             if !override.name.isEmpty {
@@ -163,8 +175,8 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
                 newOverride.smbIsScheduledOff = false
             }
 
-            guard self.context.hasChanges else { return }
-            try self.context.save()
+            guard context.hasChanges else { return }
+            try context.save()
         }
     }
 
@@ -213,12 +225,8 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
 
     /// - Parameter: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
     func deleteOverridePreset(_ objectID: NSManagedObjectID) async {
-        // Use injected context if available, otherwise create new task context
-        let taskContext = context != CoreDataStack.shared.newTaskContext()
-            ? context
-            : CoreDataStack.shared.newTaskContext()
-
-        taskContext.name = "deleteContext"
+        let taskContext = makeContext()
+        taskContext.name = "deleteOverridePreset"
         taskContext.transactionAuthor = "deleteOverride"
 
         await taskContext.perform {
@@ -243,6 +251,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func getOverridesNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
+        let context = makeContext()
+        context.name = "getOverridesNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -271,6 +282,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func getOverrideRunsNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
+        let context = makeContext()
+        context.name = "getOverrideRunsNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideRunStored.self,
             onContext: context,
@@ -328,6 +342,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
 
         /// Build a predicate to fetch a stored override (from OverrideStored) whose date is within the tolerance window.
         let predicate = NSPredicate(format: "date >= %@ AND date <= %@", lowerBound as NSDate, upperBound as NSDate)
+        let context = makeContext()
+        context.name = "checkIfShouldDeleteNightscoutOverrideEntry"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -360,6 +377,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func getPresetOverridesForNightscout() async throws -> [NightscoutPresetOverride] {
+        let context = makeContext()
+        context.name = "getPresetOverridesForNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,
@@ -389,6 +409,9 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     }
 
     func fetchLatestActiveOverride() async throws -> NSManagedObjectID? {
+        let context = makeContext()
+        context.name = "fetchLatestActiveOverride"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
             onContext: context,

+ 103 - 131
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -31,10 +31,10 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
         updateSubject.eraseToAnyPublisher()
     }
 
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -47,178 +47,136 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func storePumpEvents(_ events: [NewPumpEvent]) async throws {
+        let context = makeContext()
+        context.name = "storePumpEvents"
         try await context.perform {
-            for event in events {
-                let existingEvents: [PumpEventStored] = try CoreDataStack.shared.fetchEntities(
-                    ofType: PumpEventStored.self,
-                    onContext: self.context,
-                    predicate: NSPredicate.duplicates(event.date),
-                    key: "timestamp",
-                    ascending: false,
-                    batchSize: 50
-                ) as? [PumpEventStored] ?? []
+            // Batched de-duplication: fetch every already-stored event whose timestamp matches one of the
+            // incoming events in a single request, instead of one fetch per event (avoids an N+1 query).
+            // The (timestamp, type) key mirrors the uniqueness constraint on PumpEventStored, which acts as
+            // a race-safe backstop should a concurrent context insert the same event.
+            let incomingTimestamps = events.map { $0.date as NSDate }
+            let existingEvents: [PumpEventStored] = try CoreDataStack.shared.fetchEntities(
+                ofType: PumpEventStored.self,
+                onContext: context,
+                predicate: NSPredicate(format: "timestamp IN %@", incomingTimestamps),
+                key: "timestamp",
+                ascending: false,
+                batchSize: 50
+            ) as? [PumpEventStored] ?? []
+
+            // (timestamp, type) -> stored event, for O(1) look-up inside the loop. Newly created events are
+            // inserted here too, so duplicates within the same batch are caught as well.
+            func dedupKey(timestamp: Date?, type: String?) -> String {
+                "\(timestamp?.timeIntervalSinceReferenceDate ?? 0)|\(type ?? "")"
+            }
+            var existingByKey: [String: PumpEventStored] = [:]
+            for stored in existingEvents {
+                existingByKey[dedupKey(timestamp: stored.timestamp, type: stored.type)] = stored
+            }
+
+            // Creates a bare PumpEventStored unless a (timestamp, type) duplicate already exists.
+            // Returns the new event, or nil if it was a duplicate.
+            @discardableResult func makeEventIfNew(timestamp: Date, type: PumpEvent) -> PumpEventStored? {
+                let key = dedupKey(timestamp: timestamp, type: type.rawValue)
+                guard existingByKey[key] == nil else {
+                    debug(.coreData, "Duplicate event found with timestamp: \(timestamp)")
+                    return nil
+                }
+                let newPumpEvent = PumpEventStored(context: context)
+                newPumpEvent.id = UUID().uuidString
+                newPumpEvent.timestamp = timestamp
+                newPumpEvent.type = type.rawValue
+                newPumpEvent.isUploadedToNS = false
+                newPumpEvent.isUploadedToHealth = false
+                newPumpEvent.isUploadedToTidepool = false
+                existingByKey[key] = newPumpEvent
+                return newPumpEvent
+            }
 
+            for event in events {
                 switch event.type {
                 case .bolus:
-
                     guard let dose = event.dose else { continue }
                     let amount = self.roundDose(
                         dose.unitsInDeliverableIncrements,
                         toIncrement: Double(self.settings.preferences.bolusIncrement)
                     )
+                    // Use the event's own timestamp to dedup
+                    let timestamp = event.date
+                    let key = dedupKey(timestamp: timestamp, type: PumpEvent.bolus.rawValue)
 
-                    guard existingEvents.isEmpty else {
+                    if let existingEvent = existingByKey[key] {
                         // Duplicate found, do not store the event
                         debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
 
-                        if let existingEvent = existingEvents.first(where: { $0.type == EventType.bolus.rawValue }) {
-                            if existingEvent.timestamp == event.date {
-                                if let existingAmount = existingEvent.bolus?.amount, amount < existingAmount as Decimal {
-                                    // Update existing event with new smaller value
-                                    existingEvent.bolus?.amount = amount as NSDecimalNumber
-                                    existingEvent.bolus?.isSMB = dose.automatic ?? true
-                                    existingEvent.isUploadedToNS = false
-                                    existingEvent.isUploadedToHealth = false
-                                    existingEvent.isUploadedToTidepool = false
-
-                                    debug(.coreData, "Updated existing event with smaller value: \(amount)")
-                                }
-                            }
+                        if let existingAmount = existingEvent.bolus?.amount, amount < existingAmount as Decimal {
+                            // Update existing event with new smaller value (e.g. a cancelled / partial bolus)
+                            existingEvent.bolus?.amount = amount as NSDecimalNumber
+                            existingEvent.bolus?.isSMB = dose.automatic ?? true
+                            existingEvent.isUploadedToNS = false
+                            existingEvent.isUploadedToHealth = false
+                            existingEvent.isUploadedToTidepool = false
+
+                            debug(.coreData, "Updated existing event with smaller value: \(amount)")
                         }
                         continue
                     }
 
-                    let newPumpEvent = PumpEventStored(context: self.context)
+                    let newPumpEvent = PumpEventStored(context: context)
                     newPumpEvent.id = UUID().uuidString
-                    // restrict entry to now or past
-                    newPumpEvent.timestamp = event.date > Date() ? Date() : event.date
+                    newPumpEvent.timestamp = timestamp
                     newPumpEvent.type = PumpEvent.bolus.rawValue
                     newPumpEvent.isUploadedToNS = false
                     newPumpEvent.isUploadedToHealth = false
                     newPumpEvent.isUploadedToTidepool = false
 
-                    let newBolusEntry = BolusStored(context: self.context)
+                    let newBolusEntry = BolusStored(context: context)
                     newBolusEntry.pumpEvent = newPumpEvent
                     newBolusEntry.amount = NSDecimalNumber(decimal: amount)
                     newBolusEntry.isExternal = dose.manuallyEntered
                     newBolusEntry.isSMB = dose.automatic ?? true
 
+                    existingByKey[key] = newPumpEvent
+
                 case .tempBasal:
                     guard let dose = event.dose else { continue }
 
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
+                    let delivered = dose.deliveredUnits
+                    let isCancel = delivered != nil
+                    guard !isCancel else { continue }
+
+                    guard let newPumpEvent = makeEventIfNew(timestamp: event.date, type: .tempBasal) else {
                         continue
                     }
 
                     let rate = Decimal(dose.unitsPerHour)
                     let minutes = (dose.endDate - dose.startDate).timeInterval / 60
-                    let delivered = dose.deliveredUnits
-                    let date = event.date
 
-                    let isCancel = delivered != nil
-                    guard !isCancel else { continue }
-
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = date
-                    newPumpEvent.type = PumpEvent.tempBasal.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
-
-                    let newTempBasal = TempBasalStored(context: self.context)
+                    let newTempBasal = TempBasalStored(context: context)
                     newTempBasal.pumpEvent = newPumpEvent
                     newTempBasal.duration = Int16(round(minutes))
                     newTempBasal.rate = rate as NSDecimalNumber
                     newTempBasal.tempType = TempType.absolute.rawValue
 
                 case .suspend:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.pumpSuspend.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .pumpSuspend)
 
                 case .resume:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.pumpResume.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .pumpResume)
 
                 case .rewind:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.rewind.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .rewind)
 
                 case .prime:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.prime.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .prime)
 
                 case .alarm:
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.pumpAlarm.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
-                    newPumpEvent.note = event.title
+                    let newPumpEvent = makeEventIfNew(timestamp: event.date, type: .pumpAlarm)
+                    newPumpEvent?.note = event.title
 
                 case .replaceComponent(componentType: .infusionSet),
                      .replaceComponent(componentType: .pump):
-                    guard existingEvents.isEmpty else {
-                        // Duplicate found, do not store the event
-                        debug(.coreData, "Duplicate event found with timestamp: \(event.date)")
-                        continue
-                    }
-                    let newPumpEvent = PumpEventStored(context: self.context)
-                    newPumpEvent.id = UUID().uuidString
-                    newPumpEvent.timestamp = event.date
-                    newPumpEvent.type = PumpEvent.siteChange.rawValue
-                    newPumpEvent.isUploadedToNS = false
-                    newPumpEvent.isUploadedToHealth = false
-                    newPumpEvent.isUploadedToTidepool = false
+                    makeEventIfNew(timestamp: event.date, type: .siteChange)
 
                 default:
                     continue
@@ -226,8 +184,8 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             }
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
 
                 self.updateSubject.send(())
                 debug(.coreData, "\(DebuggingIdentifiers.succeeded) stored pump events in Core Data")
@@ -239,9 +197,11 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func storeExternalInsulinEvent(amount: Decimal, timestamp: Date) async {
+        let context = makeContext()
+        context.name = "storeExternalInsulinEvent"
         await context.perform {
             // create pump event
-            let newPumpEvent = PumpEventStored(context: self.context)
+            let newPumpEvent = PumpEventStored(context: context)
             newPumpEvent.id = UUID().uuidString
             // restrict entry to now or past
             newPumpEvent.timestamp = timestamp > Date() ? Date() : timestamp
@@ -251,15 +211,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             newPumpEvent.isUploadedToTidepool = false
 
             // create bolus entry and specify relationship to pump event
-            let newBolusEntry = BolusStored(context: self.context)
+            let newBolusEntry = BolusStored(context: context)
             newBolusEntry.pumpEvent = newPumpEvent
             newBolusEntry.amount = amount as NSDecimalNumber
             newBolusEntry.isExternal = true // we are creating an external dose
             newBolusEntry.isSMB = false // the dose is manually administered
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
                 debug(.coreData, "External insulin saved")
                 self.updateSubject.send(())
             } catch {
@@ -269,12 +229,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func getPumpHistory() async throws -> [PumpHistoryEvent] {
+        let context = makeContext()
+        context.name = "getPumpHistory"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpHistoryLast24h,
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return await context.perform {
@@ -318,12 +281,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getPumpHistoryNotYetUploadedToNightscout"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToNightscout,
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return try await context.perform { [self] in
@@ -479,12 +445,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func getPumpHistoryNotYetUploadedToHealth() async throws -> [PumpHistoryEvent] {
+        let context = makeContext()
+        context.name = "getPumpHistoryNotYetUploadedToHealth"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToHealth,
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return try await context.perform {
@@ -523,12 +492,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     }
 
     func getPumpHistoryNotYetUploadedToTidepool() async throws -> [PumpHistoryEvent] {
+        let context = makeContext()
+        context.name = "getPumpHistoryNotYetUploadedToTidepool"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
             onContext: context,
             predicate: NSPredicate.pumpEventsNotYetUploadedToTidepool,
             key: "timestamp",
-            ascending: false
+            ascending: false,
+            relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
 
         return try await context.perform {

+ 54 - 28
Trio/Sources/APS/Storage/TDDStorage.swift

@@ -28,9 +28,10 @@ struct TDDResult {
 final class BaseTDDStorage: TDDStorage, Injectable {
     @Injected() private var storage: FileStorage!
 
-    private let privateContext = CoreDataStack.shared.newTaskContext()
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
@@ -144,8 +145,10 @@ final class BaseTDDStorage: TDDStorage, Injectable {
     /// Stores the Total Daily Dose (TDD) result in Core Data
     /// - Parameter tddResult: The TDD result to store, containing total insulin, bolus, temp basal, scheduled basal and weighted average
     func storeTDD(_ tddResult: TDDResult) async {
-        await privateContext.perform {
-            let tddStored = TDDStored(context: self.privateContext)
+        let context = makeContext()
+        context.name = "storeTDD"
+        await context.perform {
+            let tddStored = TDDStored(context: context)
             tddStored.id = UUID()
             tddStored.date = Date()
             tddStored.total = NSDecimalNumber(decimal: tddResult.total)
@@ -155,8 +158,8 @@ final class BaseTDDStorage: TDDStorage, Injectable {
             tddStored.weightedAverage = tddResult.weightedAverage.map { NSDecimalNumber(decimal: $0) }
 
             do {
-                guard self.privateContext.hasChanges else { return }
-                try self.privateContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to save TDD: \(error)")
             }
@@ -564,38 +567,29 @@ final class BaseTDDStorage: TDDStorage, Injectable {
     /// - Returns: A weighted average of TDD as Decimal, or nil if insufficient data
     /// - Note: The weight percentage can be configured in preferences. Default is 0.65 (65% recent, 35% historical)
     private func calculateWeightedAverage() async throws -> Decimal? {
-        // Fetch data from Core Data
         let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
         let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval)
 
-        let predicate = NSPredicate(format: "date >= %@", tenDaysAgo as NSDate)
+        let context = makeContext()
+        context.name = "calculateWeightedAverage"
 
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: TDDStored.self,
-            onContext: privateContext,
-            predicate: predicate,
-            key: "date",
-            ascending: false
-        )
-        return await privateContext.perform { () -> Decimal? in
-            guard let results = results as? [TDDStored], !results.isEmpty else { return 0 }
+        return try await context.perform { () -> Decimal? in
+            let recent = try Self.aggregateTDD(from: twoHoursAgo, in: context)
+            let historical = try Self.aggregateTDD(from: tenDaysAgo, in: context)
 
-            // Calculate recent (2h) average
-            let recentResults = results.filter { $0.date?.timeIntervalSince(twoHoursAgo) ?? 0 > 0 }
-            let recentTotal = recentResults.compactMap { $0.total?.decimalValue }.reduce(0, +)
-            let recentCount = max(Decimal(recentResults.count), 1)
-            let averageTDDLastTwoHours = recentTotal / recentCount
+            // Extract into locals so SwiftFormat's isEmpty rule doesn't
+            // mis-rewrite the tuple member access into `!tuple.isEmpty`
+            let historicalCount = historical.count
+            let recentCount = recent.count
+            guard historicalCount > 0 else { return 0 }
 
-            // Calculate 10-day average
-            let totalTDD = results.compactMap { $0.total?.decimalValue }.reduce(0, +)
-            let totalCount = max(Decimal(results.count), 1)
-            let averageTDDLastTenDays = totalTDD / totalCount
+            let averageTDDLastTwoHours = recent.total / max(Decimal(recentCount), 1)
+            let averageTDDLastTenDays = historical.total / Decimal(historicalCount)
 
             // Get weight percentage from preferences (default 0.65 if not set)
             let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
             let weightPercentage = userPreferences?.weightPercentage ?? Decimal(0.65) // why is this 1 as default in trio-oref??
 
-            // Calculate weighted average using the formula:
             // weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
             let weightedTDD = weightPercentage * averageTDDLastTwoHours +
                 (1 - weightPercentage) * averageTDDLastTenDays
@@ -604,6 +598,36 @@ final class BaseTDDStorage: TDDStorage, Injectable {
         }
     }
 
+    /// Runs a SUM(total) + COUNT aggregate on TDDStored for records with date >= `from`,
+    /// avoiding materializing hundreds of rows just to add them up.
+    private static func aggregateTDD(
+        from: Date,
+        in context: NSManagedObjectContext
+    ) throws -> (total: Decimal, count: Int) {
+        let request = NSFetchRequest<NSDictionary>(entityName: "TDDStored")
+        request.resultType = .dictionaryResultType
+        request.predicate = NSPredicate(format: "date >= %@ AND total != nil", from as NSDate)
+
+        let sumExp = NSExpressionDescription()
+        sumExp.name = "sumTotal"
+        sumExp.expression = NSExpression(forFunction: "sum:", arguments: [NSExpression(forKeyPath: "total")])
+        sumExp.expressionResultType = .decimalAttributeType
+
+        let countExp = NSExpressionDescription()
+        countExp.name = "countTotal"
+        countExp.expression = NSExpression(forFunction: "count:", arguments: [NSExpression(forKeyPath: "total")])
+        countExp.expressionResultType = .integer64AttributeType
+
+        request.propertiesToFetch = [sumExp, countExp]
+
+        guard let row = try context.fetch(request).first else {
+            return (0, 0)
+        }
+        let sum = (row["sumTotal"] as? NSDecimalNumber)?.decimalValue ?? 0
+        let count = (row["countTotal"] as? NSNumber)?.intValue ?? 0
+        return (sum, count)
+    }
+
     /// Checks if there is enough Total Daily Dose (TDD) data collected over the past 7 days.
     ///
     /// This function performs a count fetch for TDDStored records in Core Data where:
@@ -616,7 +640,9 @@ final class BaseTDDStorage: TDDStorage, Injectable {
     /// - Returns: `true` if sufficient TDD data is available, otherwise `false`.
     /// - Throws: An error if the Core Data count operation fails.
     func hasSufficientTDD() async throws -> Bool {
-        try await BaseTDDStorage.hasSufficientTDD(context: privateContext)
+        let context = makeContext()
+        context.name = "hasSufficientTDD"
+        return try await BaseTDDStorage.hasSufficientTDD(context: context)
     }
 
     /// internal function with context exposed to enable testing

+ 39 - 16
Trio/Sources/APS/Storage/TempTargetsStorage.swift

@@ -33,14 +33,17 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
-    private let context: NSManagedObjectContext
+    private let makeContext: () -> NSManagedObjectContext
 
-    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
-        self.context = context ?? CoreDataStack.shared.newTaskContext()
+    init(resolver: Resolver, contextProvider: (() -> NSManagedObjectContext)? = nil) {
+        makeContext = contextProvider ?? { CoreDataStack.shared.newTaskContext() }
         injectServices(resolver)
     }
 
     func loadLatestTempTargetConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "loadLatestTempTargetConfigurations"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: context,
@@ -61,6 +64,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
 
     /// Returns the NSManagedObjectID of the Temp Target Presets
     func fetchForTempTargetPresets() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchForTempTargetPresets"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: context,
@@ -79,6 +85,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func fetchScheduledTempTargets() async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchScheduledTempTargets"
+
         let scheduledTempTargets = NSPredicate(format: "date > %@", Date() as NSDate)
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -99,6 +108,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func fetchScheduledTempTarget(for targetDate: Date) async throws -> [NSManagedObjectID] {
+        let context = makeContext()
+        context.name = "fetchScheduledTempTarget"
+
         let predicate = NSPredicate(format: "date == %@", targetDate as NSDate)
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -120,6 +132,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func storeTempTarget(tempTarget: TempTarget) async throws {
+        let context = makeContext()
+        context.name = "storeTempTarget"
+
         var presetCount = -1
         if tempTarget.isPreset == true {
             let presets = try await fetchForTempTargetPresets()
@@ -127,7 +142,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
         }
 
         try await context.perform {
-            let newTempTarget = TempTargetStored(context: self.context)
+            let newTempTarget = TempTargetStored(context: context)
             newTempTarget.date = tempTarget.createdAt
             newTempTarget.id = UUID()
             newTempTarget.enabled = tempTarget.enabled ?? false
@@ -152,8 +167,8 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
             }
 
             do {
-                guard self.context.hasChanges else { return }
-                try self.context.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debug(.default, "\(DebuggingIdentifiers.failed) Failed to save new temp target with error: \(error.userInfo)")
                 throw error
@@ -183,13 +198,16 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func existsTempTarget(with date: Date) async -> Bool {
-        await context.perform {
+        let context = makeContext()
+        context.name = "existsTempTarget"
+
+        return await context.perform {
             // Fetch all Temp Targets with the given date
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "date == %@", date as NSDate)
 
             do {
-                let results = try self.context.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 return !results.isEmpty
             } catch let error as NSError {
                 debugPrint("\(DebuggingIdentifiers.failed) Failed to check for existing Temp Target: \(error)")
@@ -227,22 +245,21 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func deleteTempTargetPreset(_ objectID: NSManagedObjectID) async {
-        let taskContext = context != CoreDataStack.shared.newTaskContext()
-            ? context
-            : CoreDataStack.shared.newTaskContext()
+        let context = makeContext()
+        context.name = "deleteTempTargetPreset"
 
-        await taskContext.perform {
+        await context.perform {
             do {
-                let result = try taskContext.existingObject(with: objectID) as? TempTargetStored
+                let result = try context.existingObject(with: objectID) as? TempTargetStored
                 guard let tempTarget = result else {
                     debug(.default, "\(DebuggingIdentifiers.failed) Temp Target for batch delete not found.")
                     return
                 }
 
-                taskContext.delete(tempTarget)
+                context.delete(tempTarget)
 
-                guard taskContext.hasChanges else { return }
-                try taskContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch {
                 debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete Temp Target: \(error)")
             }
@@ -272,6 +289,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func getTempTargetsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getTempTargetsNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: context,
@@ -309,6 +329,9 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func getTempTargetRunsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
+        let context = makeContext()
+        context.name = "getTempTargetRunsNotYetUploadedToNightscout"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetRunStored.self,
             onContext: context,

Plik diff jest za duży
+ 26986 - 692
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 0 - 1
Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift

@@ -70,7 +70,6 @@ extension Adjustments {
         var didSaveSettings: Bool = false
 
         // Core Data
-        let coredataContext = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
         // Help Sheet

+ 3 - 2
Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift

@@ -39,8 +39,6 @@ extension DynamicSettings {
 
         var units: GlucoseUnits = .mgdL
 
-        let context = CoreDataStack.shared.newTaskContext()
-
         override func subscribe() {
             units = settingsManager.settings.units
 
@@ -102,6 +100,9 @@ extension DynamicSettings {
         /// - Returns: `true` if sufficient TDD data is available, otherwise `false`.
         /// - Throws: An error if the Core Data count operation fails.
         private func hasSufficientTDD() throws -> Bool {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "DynamicSettingsStateModel.hasSufficientTDD"
+
             var result = false
 
             context.performAndWait {

+ 0 - 2
Trio/Sources/Modules/History/HistoryStateModel.swift

@@ -14,8 +14,6 @@ extension History {
         @ObservationIgnored @Injected() var healthKitManager: HealthKitManager!
         @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
 
-        let coredataContext = CoreDataStack.shared.newTaskContext()
-
         var mode: Mode = .treatments
         var treatments: [Treatment] = []
         var manualGlucose: Decimal = 0

+ 18 - 28
Trio/Sources/Modules/Home/HomeStateModel+Setup/BatterySetup.swift

@@ -2,40 +2,30 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupBatteryArray() {
-        Task {
-            do {
-                let ids = try await self.fetchBattery()
-                let batteryObjects: [OpenAPS_Battery] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateBatteryArray(with: batteryObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up battery array: \(error)"
-                )
+    @MainActor func setupBatteryController() {
+        batteryControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateBatteryFromController()
             }
         }
-    }
 
-    private func fetchBattery() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OpenAPS_Battery.self,
-            onContext: batteryFetchContext,
-            predicate: NSPredicate.predicateFor30MinAgo,
-            key: "date",
-            ascending: false
-        )
-
-        return try await batteryFetchContext.perform {
-            guard let fetchedResults = results as? [OpenAPS_Battery] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try batteryController.performFetch()
+            updateBatteryFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform battery fetch: \(error)")
         }
     }
 
-    @MainActor private func updateBatteryArray(with objects: [OpenAPS_Battery]) {
+    @MainActor func updateBatteryFromController() {
+        guard let objects = batteryController.fetchedObjects else { return }
         batteryFromPersistence = objects
     }
+
+    /// Called from the `pumpDisplayState` sink, `settingsDidChange` and `pumpSettingsDidChange`.
+    func setupBatteryArray() {
+        Task { @MainActor in
+            updateBatteryFromController()
+        }
+    }
 }

+ 26 - 53
Trio/Sources/Modules/Home/HomeStateModel+Setup/CarbSetup.swift

@@ -2,74 +2,47 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupCarbsArray() {
-        Task {
-            do {
-                let ids = try await self.fetchCarbs()
-                let carbObjects: [CarbEntryStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateCarbsArray(with: carbObjects)
-            } catch {
-                debugPrint("\(DebuggingIdentifiers.failed) Error fetching carb objects: \(error) in \(#file):\(#line)")
-            }
-        }
-    }
+    // MARK: - Carbs
 
-    private func fetchCarbs() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: CarbEntryStored.self,
-            onContext: carbsFetchContext,
-            predicate: NSPredicate.carbsForChart,
-            key: "date",
-            ascending: false,
-            batchSize: 5
-        )
-
-        return try await carbsFetchContext.perform {
-            guard let fetchedResults = results as? [CarbEntryStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupCarbsController() {
+        carbsControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateCarbsFromController()
             }
+        }
 
-            return fetchedResults.map(\.objectID)
+        do {
+            try carbsController.performFetch()
+            updateCarbsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform carbs fetch: \(error)")
         }
     }
 
-    @MainActor private func updateCarbsArray(with objects: [CarbEntryStored]) {
+    @MainActor func updateCarbsFromController() {
+        guard let objects = carbsController.fetchedObjects else { return }
         carbsFromPersistence = objects
     }
 
-    func setupFPUsArray() {
-        Task {
-            do {
-                let ids = try await self.fetchFPUs()
-                let fpuObjects: [CarbEntryStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateFPUsArray(with: fpuObjects)
-            } catch {
-                debugPrint("\(DebuggingIdentifiers.failed) Error fetching FPU objects: \(error) in \(#file):\(#line)")
-            }
-        }
-    }
+    // MARK: - FPUs
 
-    private func fetchFPUs() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: CarbEntryStored.self,
-            onContext: fpuFetchContext,
-            predicate: NSPredicate.fpusForChart,
-            key: "date",
-            ascending: false
-        )
-
-        return try await fpuFetchContext.perform {
-            guard let fetchedResults = results as? [CarbEntryStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupFPUController() {
+        fpuControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateFPUsFromController()
             }
+        }
 
-            return fetchedResults.map(\.objectID)
+        do {
+            try fpuController.performFetch()
+            updateFPUsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform FPU fetch: \(error)")
         }
     }
 
-    @MainActor private func updateFPUsArray(with objects: [CarbEntryStored]) {
+    @MainActor func updateFPUsFromController() {
+        guard let objects = fpuController.fetchedObjects else { return }
         fpusFromPersistence = objects
     }
 }

+ 47 - 92
Trio/Sources/Modules/Home/HomeStateModel+Setup/ChartAxisSetup.swift

@@ -1,111 +1,66 @@
+import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func yAxisChartData(glucoseValues: [GlucoseStored]) {
-        // Capture the forecast values from `preprocessedData` on the main thread
-        Task { @MainActor in
-            let forecastValues = self.preprocessedData.map { Decimal($0.forecastValue.value) }
+    /// Recomputes the main glucose chart Y axis bounds from the fetched glucose objects.
+    /// Runs on the main actor since the inputs are viewContext managed objects.
+    @MainActor func updateGlucoseChartYAxis(glucoseValues: [GlucoseStored]) {
+        let glucoseMapped = glucoseValues.map { Decimal($0.glucose) }
+        let forecastValues = preprocessedData.map { Decimal($0.forecastValue.value) }
 
-            // Perform the glucose processing on the background context
-            glucoseFetchContext.perform {
-                let glucoseMapped = glucoseValues.map { Decimal($0.glucose) }
-
-                // Calculate min and max values for glucose and forecast
-                let minGlucose = glucoseMapped.min()
-                let maxGlucose = glucoseMapped.max()
-                let minForecast = forecastValues.min()
-                let maxForecast = forecastValues.max()
-
-                // Ensure all values exist, otherwise set default values
-                guard let minGlucose = minGlucose, let maxGlucose = maxGlucose else {
-                    Task {
-                        await self.updateChartBounds(minValue: 39, maxValue: 200)
-                    }
-                    return
-                }
+        // Ensure all values exist, otherwise set default values
+        guard let minGlucose = glucoseMapped.min(), let maxGlucose = glucoseMapped.max() else {
+            minYAxisValue = 39
+            maxYAxisValue = 200
+            return
+        }
 
-                // Adjust max forecast to be no more than 50 over max glucose
-                let adjustedMaxForecast = min(maxForecast ?? maxGlucose + 50, maxGlucose + 50)
-                let minOverall = min(minGlucose, minForecast ?? minGlucose)
-                let maxOverall = max(maxGlucose, adjustedMaxForecast)
+        let minForecast = forecastValues.min()
+        let maxForecast = forecastValues.max()
 
-                var maxYValue = Decimal(200)
-                if maxOverall > 200, maxOverall <= 225 {
-                    maxYValue = Decimal(250)
-                } else if maxOverall > 225, maxOverall <= 275 {
-                    maxYValue = Decimal(300)
-                } else if maxOverall > 275, maxOverall <= 325 {
-                    maxYValue = Decimal(350)
-                } else if maxOverall > 325 {
-                    maxYValue = Decimal(400)
-                }
+        // Adjust max forecast to be no more than 50 over max glucose
+        let adjustedMaxForecast = min(maxForecast ?? maxGlucose + 50, maxGlucose + 50)
+        let minOverall = min(minGlucose, minForecast ?? minGlucose)
+        let maxOverall = max(maxGlucose, adjustedMaxForecast)
 
-                // Update the chart bounds on the main thread
-                Task {
-                    await self.updateChartBounds(minValue: minOverall, maxValue: maxYValue)
-                }
-            }
+        var maxYValue: Decimal = 200
+        if maxOverall > 200, maxOverall <= 225 {
+            maxYValue = 250
+        } else if maxOverall > 225, maxOverall <= 275 {
+            maxYValue = 300
+        } else if maxOverall > 275, maxOverall <= 325 {
+            maxYValue = 350
+        } else if maxOverall > 325 {
+            maxYValue = 400
         }
-    }
 
-    @MainActor private func updateChartBounds(minValue: Decimal, maxValue: Decimal) async {
-        minYAxisValue = minValue
-        maxYAxisValue = maxValue
+        minYAxisValue = minOverall
+        maxYAxisValue = maxYValue
     }
 
-    func yAxisChartDataCobChart(determinations: [[String: Any]]) {
-        determinationFetchContext.perform {
-            // Map the COB values from the dictionary results
-            let cobMapped = determinations.compactMap { entry in
-                // First cast to Int16, then convert to Decimal
-                if let cobValue = entry["cob"] as? Int16 {
-                    return Decimal(cobValue)
-                }
-                return nil
-            }
-            let maxCob = cobMapped.max()
+    /// Recomputes the COB chart Y axis bounds from the fetched determination objects.
+    @MainActor func yAxisChartDataCobChart(determinations: [OrefDetermination]) {
+        let cobMapped = determinations.map { Decimal($0.cob) }
 
-            // Ensure the result exists or set default values
-            if let maxCob = maxCob {
-                let calculatedMax = maxCob == 0 ? 20 : maxCob + 20
-                Task {
-                    await self.updateCobChartBounds(minValue: 0, maxValue: calculatedMax)
-                }
-            } else {
-                Task {
-                    await self.updateCobChartBounds(minValue: 0, maxValue: 20)
-                }
-            }
+        if let maxCob = cobMapped.max() {
+            minValueCobChart = 0
+            maxValueCobChart = maxCob == 0 ? 20 : maxCob + 20
+        } else {
+            minValueCobChart = 0
+            maxValueCobChart = 20
         }
     }
 
-    @MainActor private func updateCobChartBounds(minValue: Decimal, maxValue: Decimal) {
-        minValueCobChart = minValue
-        maxValueCobChart = maxValue
-    }
-
-    func yAxisChartDataIobChart(determinations: [[String: Any]]) {
-        determinationFetchContext.perform {
-            // Map the IOB values from the fetched dictionaries
-            let iobMapped = determinations.compactMap { ($0["iob"] as? NSDecimalNumber)?.decimalValue }
-            let minIob = iobMapped.min()
-            let maxIob = iobMapped.max()
+    /// Recomputes the IOB chart Y axis bounds from the fetched determination objects.
+    @MainActor func yAxisChartDataIobChart(determinations: [OrefDetermination]) {
+        let iobMapped = determinations.compactMap { $0.iob?.decimalValue }
 
-            // Ensure min and max IOB values exist, or set defaults
-            if let minIob = minIob, let maxIob = maxIob {
-                Task {
-                    await self.updateIobChartBounds(minValue: minIob, maxValue: maxIob)
-                }
-            } else {
-                Task {
-                    await self.updateIobChartBounds(minValue: 0, maxValue: 5)
-                }
-            }
+        if let minIob = iobMapped.min(), let maxIob = iobMapped.max() {
+            minValueIobChart = minIob
+            maxValueIobChart = maxIob
+        } else {
+            minValueIobChart = 0
+            maxValueIobChart = 5
         }
     }
-
-    @MainActor private func updateIobChartBounds(minValue: Decimal, maxValue: Decimal) async {
-        minValueIobChart = minValue
-        maxValueIobChart = maxValue
-    }
 }

+ 13 - 41
Trio/Sources/Modules/Home/HomeStateModel+Setup/CurrentTDDSetup.swift

@@ -2,51 +2,23 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupTDDArray() {
-        Task {
-            do {
-                // Get the NSManagedObjectIDs
-                let tddObjectIds = try await fetchTDDIDs()
-
-                // Get the NSManagedObjects and map them to TDD on the Main Thread
-                try await updateTDDArray(with: tddObjectIds, keyPath: \.fetchedTDDs)
-            } catch {
-                debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch TDDs: \(error)")
+    @MainActor func setupTDDController() {
+        tddControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateTDDFromController()
             }
         }
-    }
 
-    @MainActor private func updateTDDArray(
-        with IDs: [NSManagedObjectID],
-        keyPath: ReferenceWritableKeyPath<Home.StateModel, [TDD]>
-    ) async throws {
-        let tddObjects: [TDD] = try await CoreDataStack.shared
-            .getNSManagedObject(with: IDs, context: viewContext)
-            .compactMap { managedObject in
-                // Safely extract date and total as optional
-                let timestamp = managedObject.value(forKey: "date") as? Date
-                let totalDailyDose = (managedObject.value(forKey: "total") as? NSNumber)?.decimalValue
-                return TDD(totalDailyDose: totalDailyDose, timestamp: timestamp)
-            }
-        self[keyPath: keyPath] = tddObjects
+        do {
+            try tddController.performFetch()
+            updateTDDFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform TDD fetch: \(error)")
+        }
     }
 
-    private func fetchTDDIDs() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: TDDStored.self,
-            onContext: tddFetchContext,
-            predicate: NSPredicate.predicateForOneDayAgo,
-            key: "date",
-            ascending: false,
-            fetchLimit: 1,
-            propertiesToFetch: ["total", "date", "objectID"]
-        )
-
-        return await tddFetchContext.perform {
-            guard let fetchedResults = results as? [[String: Any]] else {
-                return []
-            }
-            return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
-        }
+    @MainActor private func updateTDDFromController() {
+        guard let objects = tddController.fetchedObjects else { return }
+        fetchedTDDs = objects.map { TDD(totalDailyDose: $0.total?.decimalValue, timestamp: $0.date) }
     }
 }

+ 47 - 45
Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift

@@ -2,63 +2,65 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupDeterminationsArray() {
-        Task {
-            do {
-                // Get the NSManagedObjectIDs
-                async let enactedObjectIds = determinationStorage
-                    .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDetermination)
-                async let enactedAndNonEnactedObjectIds = fetchCobAndIob()
+    // MARK: - Enacted Determination
 
-                let enactedIDs = try await enactedObjectIds
-                let enactedAndNonEnactedIds = try await enactedAndNonEnactedObjectIds
-
-                // Get the NSManagedObjects and return them on the Main Thread
-                try await updateDeterminationsArray(with: enactedIDs, keyPath: \.determinationsFromPersistence)
-                try await updateDeterminationsArray(with: enactedAndNonEnactedIds, keyPath: \.enactedAndNonEnactedDeterminations)
+    @MainActor func setupEnactedDeterminationController() {
+        enactedDeterminationControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.scheduleForecastUpdate()
+            }
+        }
 
-                await updateForecastData()
-            } catch let error as CoreDataError {
-                debug(.default, "Core Data error in setupDeterminationsArray: \(error)")
-            } catch {
-                debug(.default, "Unexpected error in setupDeterminationsArray: \(error)")
+        do {
+            try enactedDeterminationController.performFetch()
+            updateEnactedDeterminationFromController()
+            // Initial population; assigned to `forecastUpdateTask` so a change arriving
+            // during startup cancels it instead of racing it.
+            forecastUpdateTask = Task { @MainActor in
+                await self.updateForecastData()
             }
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform enacted determination fetch: \(error)")
         }
     }
 
-    @MainActor private func updateDeterminationsArray(
-        with IDs: [NSManagedObjectID],
-        keyPath: ReferenceWritableKeyPath<Home.StateModel, [OrefDetermination]>
-    ) async throws {
-        // Fetch the objects off the main thread
-        let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-            .getNSManagedObject(with: IDs, context: viewContext)
+    /// 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()
+        }
+    }
 
-        // Update the array on the main thread
-        self[keyPath: keyPath] = determinationObjects
+    @MainActor private func updateEnactedDeterminationFromController() {
+        guard let objects = enactedDeterminationController.fetchedObjects else { return }
+        determinationsFromPersistence = objects
     }
 
-    // Custom fetch to more efficiently filter only for cob and iob
-    private func fetchCobAndIob() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OrefDetermination.self,
-            onContext: determinationFetchContext,
-            predicate: NSPredicate.determinationsForCobIobCharts,
-            key: "deliverAt",
-            ascending: false,
-            batchSize: 50,
-            propertiesToFetch: ["cob", "iob", "deliverAt", "objectID"]
-        )
+    // MARK: - Determinations for COB/IOB Charts
 
-        return try await determinationFetchContext.perform {
-            guard let fetchedResults = results as? [[String: Any]] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupDeterminationController() {
+        determinationControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateDeterminationsFromController()
             }
+        }
 
-            // Update Chart Scales
-            self.yAxisChartDataCobChart(determinations: fetchedResults)
-            self.yAxisChartDataIobChart(determinations: fetchedResults)
-            return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
+        do {
+            try determinationController.performFetch()
+            updateDeterminationsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform determination fetch: \(error)")
         }
     }
+
+    @MainActor func updateDeterminationsFromController() {
+        guard let objects = determinationController.fetchedObjects else { return }
+        enactedAndNonEnactedDeterminations = objects
+        yAxisChartDataCobChart(determinations: objects)
+        yAxisChartDataIobChart(determinations: objects)
+    }
 }

+ 21 - 0
Trio/Sources/Modules/Home/HomeStateModel+Setup/ForecastSetup.swift

@@ -15,6 +15,9 @@ extension Home.StateModel {
                 return []
             }
 
+            let taskContext = CoreDataStack.shared.newTaskContext()
+            taskContext.name = "HomeStateModel.preprocessForecastData"
+
             // Fetch complete forecast hierarchy with prefetched values
             return try await determinationStorage.fetchForecastHierarchy(
                 for: determination.objectID,
@@ -30,12 +33,28 @@ extension Home.StateModel {
     }
 
     // Update forecast data and UI on the main thread
+    //
+    // Runs inside `forecastUpdateTask`; cancellation is checked after every suspension
+    // point so a superseded run cannot overwrite fresher results.
     @MainActor func updateForecastData() async {
         let forecastDataIDs = await preprocessForecastData()
 
+        guard !Task.isCancelled else { return }
+
         var allForecastValues = [[Int]]()
         var preprocessedData = [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)]()
 
+        // Prefetch all Forecasts with their forecastValues into viewContext in a single IN-query
+        // to avoid N+1 individual SELECTs when materializing via existingObject below.
+        let forecastObjectIDs = forecastDataIDs.map(\.forecastID)
+        if !forecastObjectIDs.isEmpty {
+            let prefetchRequest = NSFetchRequest<Forecast>(entityName: "Forecast")
+            prefetchRequest.predicate = NSPredicate(format: "SELF IN %@", forecastObjectIDs)
+            prefetchRequest.relationshipKeyPathsForPrefetching = ["forecastValues"]
+            prefetchRequest.returnsObjectsAsFaults = false
+            _ = try? viewContext.fetch(prefetchRequest)
+        }
+
         // Process prefetched data directly
         for data in forecastDataIDs {
             if let forecast = try? viewContext.existingObject(with: data.forecastID) as? Forecast {
@@ -83,6 +102,8 @@ extension Home.StateModel {
             return (minForecast, maxForecast)
         }.value
 
+        guard !Task.isCancelled else { return }
+
         minForecast = minResult
         maxForecast = maxResult
     }

+ 19 - 34
Trio/Sources/Modules/Home/HomeStateModel+Setup/GlucoseSetup.swift

@@ -2,47 +2,32 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupGlucoseArray() {
-        Task {
-            do {
-                let ids = try await self.fetchGlucose()
-                let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateGlucoseArray(with: glucoseObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up glucose array: \(error)"
-                )
+    @MainActor func setupGlucoseController() {
+        glucoseControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateGlucoseFromController()
             }
         }
-    }
-
-    private func fetchGlucose() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: glucoseFetchContext,
-            predicate: NSPredicate.glucose,
-            key: "date",
-            ascending: true,
-            batchSize: 50
-        )
-
-        return try await glucoseFetchContext.perform {
-            guard let fetchedResults = results as? [GlucoseStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
 
-            // Update Main Chart Y Axis Values
-            // Perform everything on "context" to be thread safe
-            self.yAxisChartData(glucoseValues: fetchedResults)
-
-            return fetchedResults.map(\.objectID)
+        do {
+            try glucoseController.performFetch()
+            updateGlucoseFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform glucose fetch: \(error)")
         }
     }
 
-    @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
+    @MainActor func updateGlucoseFromController() {
+        guard let objects = glucoseController.fetchedObjects else { return }
         glucoseFromPersistence = objects
         latestTwoGlucoseValues = Array(objects.suffix(2))
+        updateGlucoseChartYAxis(glucoseValues: objects)
+    }
+
+    /// Called from `MainChartView` on `.onChange(of: units)` to recompute the glucose-derived chart state.
+    func setupGlucoseArray() {
+        Task { @MainActor in
+            updateGlucoseFromController()
+        }
     }
 }

+ 28 - 57
Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift

@@ -2,81 +2,52 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    // Setup Overrides
-    func setupOverrides() {
-        Task {
-            do {
-                let ids = try await self.fetchOverrides()
-                let overrideObjects: [OverrideStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateOverrideArray(with: overrideObjects)
-            } catch let error as CoreDataError {
-                debug(.default, "Core Data error in setupOverrides: \(error)")
-            } catch {
-                debug(.default, "Unexpected error in setupOverrides: \(error)")
+    // MARK: - Overrides
+
+    @MainActor func setupOverrideController() {
+        overrideControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateOverridesFromController()
             }
         }
-    }
 
-    private func fetchOverrides() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OverrideStored.self,
-            onContext: overrideFetchContext,
-            predicate: NSPredicate.lastActiveOverride, // this predicate filters for all Overrides within the last 24h
-            key: "date",
-            ascending: false
-        )
-
-        return try await overrideFetchContext.perform {
-            guard let fetchedResults = results as? [OverrideStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try overrideController.performFetch()
+            updateOverridesFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform override fetch: \(error)")
         }
     }
 
-    @MainActor private func updateOverrideArray(with objects: [OverrideStored]) {
+    @MainActor private func updateOverridesFromController() {
+        guard let objects = overrideController.fetchedObjects else { return }
         overrides = objects
     }
 
-    // Setup expired Overrides
-    func setupOverrideRunStored() {
-        Task {
-            do {
-                let ids = try await self.fetchOverrideRunStored()
-                let overrideRunObjects: [OverrideRunStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateOverrideRunStoredArray(with: overrideRunObjects)
-            } catch let error as CoreDataError {
-                debug(.default, "Core Data error in setupOverrideRunStored: \(error)")
-            } catch {
-                debug(.default, "Unexpected error in setupOverrideRunStored: \(error)")
+    // MARK: - Override Runs
+
+    @MainActor func setupOverrideRunController() {
+        overrideRunControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateOverrideRunsFromController()
             }
         }
-    }
 
-    private func fetchOverrideRunStored() async throws -> [NSManagedObjectID] {
-        let predicate = NSPredicate(format: "startDate >= %@", Date.oneDayAgo as NSDate)
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OverrideRunStored.self,
-            onContext: overrideFetchContext,
-            predicate: predicate,
-            key: "startDate",
-            ascending: false
-        )
-
-        return try await overrideFetchContext.perform {
-            guard let fetchedResults = results as? [OverrideRunStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try overrideRunController.performFetch()
+            updateOverrideRunsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform override run fetch: \(error)")
         }
     }
 
-    @MainActor private func updateOverrideRunStoredArray(with objects: [OverrideRunStored]) {
+    @MainActor func updateOverrideRunsFromController() {
+        guard let objects = overrideRunController.fetchedObjects else { return }
         overrideRunStored = objects
     }
 
+    // MARK: - Override Actions
+
     /// Cancels the running Override, creates an entry in the OverrideRunStored Core Data entity and posts a custom notification so that the AdjustmentsView gets updated
     @MainActor func cancelOverride(withID id: NSManagedObjectID) async {
         do {

+ 34 - 69
Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift

@@ -2,94 +2,59 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupInsulinArray() {
-        Task {
-            do {
-                let ids = try await self.fetchInsulin()
-                let insulinObjects: [PumpEventStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateInsulinArray(with: insulinObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up insulin array: \(error)"
-                )
+    // MARK: - Insulin / Pump History
+
+    @MainActor func setupInsulinController() {
+        insulinControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                guard let self else { return }
+                self.updateInsulinFromController()
+                self.displayPumpStatusHighlightMessage()
+                self.displayPumpStatusBadge()
             }
         }
-    }
-
-    private func fetchInsulin() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self,
-            onContext: pumpHistoryFetchContext,
-            predicate: NSPredicate.pumpHistoryLast24h,
-            key: "timestamp",
-            ascending: true,
-            batchSize: 30
-        )
-
-        return try await pumpHistoryFetchContext.perform {
-            guard let pumpEvents = results as? [PumpEventStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
 
-            return pumpEvents.map(\.objectID)
+        do {
+            try insulinController.performFetch()
+            updateInsulinFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform insulin fetch: \(error)")
         }
     }
 
-    @MainActor private func updateInsulinArray(with insulinObjects: [PumpEventStored]) {
-        insulinFromPersistence = insulinObjects
+    @MainActor func updateInsulinFromController() {
+        guard let objects = insulinController.fetchedObjects else { return }
+        insulinFromPersistence = objects
 
         manualTempBasal = apsManager.isManualTempBasal
-        tempBasals = insulinFromPersistence.filter { $0.tempBasal != nil }
-
-        suspendAndResumeEvents = insulinFromPersistence.filter {
+        tempBasals = objects.filter { $0.tempBasal != nil }
+        suspendAndResumeEvents = objects.filter {
             $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue
         }
     }
 
-    // Setup Last Bolus to display the bolus progress bar
-    // The predicate filters out all external boluses to prevent the progress bar from displaying the amount of an external bolus when an external bolus is added after a pump bolus
-    func setupLastBolus() {
-        Task {
-            do {
-                guard let id = try await self.fetchLastBolus() else { return }
-                await updateLastBolus(with: id)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up last bolus: \(error)"
-                )
-            }
-        }
-    }
+    // MARK: - Last Bolus
 
-    func fetchLastBolus() async throws -> NSManagedObjectID? {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self,
-            onContext: pumpHistoryFetchContext,
-            predicate: NSPredicate.lastPumpBolus,
-            key: "timestamp",
-            ascending: false,
-            fetchLimit: 1
-        )
+    //
+    // Drives the bolus progress bar. The predicate filters out external boluses so the progress bar
+    // does not display the amount of an external bolus added after a pump bolus.
 
-        return try await pumpHistoryFetchContext.perform {
-            guard let fetchedResults = results as? [PumpEventStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupLastBolusController() {
+        lastBolusControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateLastBolusFromController()
             }
-
-            return fetchedResults.map(\.objectID).first
         }
-    }
 
-    @MainActor private func updateLastBolus(with ID: NSManagedObjectID) {
         do {
-            lastPumpBolus = try viewContext.existingObject(with: ID) as? PumpEventStored
+            try lastBolusController.performFetch()
+            updateLastBolusFromController()
         } catch {
-            debugPrint(
-                "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the insulin array: \(error)"
-            )
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform last bolus fetch: \(error)")
         }
     }
+
+    @MainActor private func updateLastBolusFromController() {
+        lastPumpBolus = lastBolusController.fetchedObjects?.first
+    }
 }

+ 28 - 58
Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift

@@ -2,82 +2,52 @@ import CoreData
 import Foundation
 
 extension Home.StateModel {
-    func setupTempTargetsStored() {
-        Task {
-            do {
-                let ids = try await self.fetchTempTargets()
-                let tempTargetObjects: [TempTargetStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateTempTargetsArray(with: tempTargetObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up tempTargetStored: \(error)"
-                )
+    // MARK: - Temp Targets
+
+    @MainActor func setupTempTargetController() {
+        tempTargetControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateTempTargetsFromController()
             }
         }
-    }
 
-    private func fetchTempTargets() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: TempTargetStored.self,
-            onContext: tempTargetFetchContext,
-            predicate: NSPredicate.tempTargetsForMainChart,
-            key: "date",
-            ascending: false
-        )
-
-        return try await tempTargetFetchContext.perform {
-            guard let fetchedResults = results as? [TempTargetStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try tempTargetController.performFetch()
+            updateTempTargetsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform temp target fetch: \(error)")
         }
     }
 
-    @MainActor private func updateTempTargetsArray(with objects: [TempTargetStored]) {
+    @MainActor func updateTempTargetsFromController() {
+        guard let objects = tempTargetController.fetchedObjects else { return }
         tempTargetStored = objects
     }
 
-    // Setup expired TempTargets
-    func setupTempTargetsRunStored() {
-        Task {
-            do {
-                let ids = try await self.fetchTempTargetRunStored()
-                let tempTargetRunObjects: [TempTargetRunStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateTempTargetRunStoredArray(with: tempTargetRunObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up temp targetsRunStored: \(error)"
-                )
+    // MARK: - Temp Target Runs
+
+    @MainActor func setupTempTargetRunController() {
+        tempTargetRunControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                self?.updateTempTargetRunsFromController()
             }
         }
-    }
 
-    private func fetchTempTargetRunStored() async throws -> [NSManagedObjectID] {
-        let predicate = NSPredicate(format: "startDate >= %@", Date.oneDayAgo as NSDate)
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: TempTargetRunStored.self,
-            onContext: tempTargetFetchContext,
-            predicate: predicate,
-            key: "startDate",
-            ascending: false
-        )
-
-        return try await tempTargetFetchContext.perform {
-            guard let fetchedResults = results as? [TempTargetRunStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return fetchedResults.map(\.objectID)
+        do {
+            try tempTargetRunController.performFetch()
+            updateTempTargetRunsFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform temp target run fetch: \(error)")
         }
     }
 
-    @MainActor private func updateTempTargetRunStoredArray(with objects: [TempTargetRunStored]) {
+    @MainActor func updateTempTargetRunsFromController() {
+        guard let objects = tempTargetRunController.fetchedObjects else { return }
         tempTargetRunStored = objects
     }
 
+    // MARK: - Temp Target Actions
+
     @MainActor func cancelTempTarget(withID id: NSManagedObjectID) async {
         do {
             guard let profileToCancel = try viewContext.existingObject(with: id) as? TempTargetStored else { return }

+ 298 - 137
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -139,23 +139,272 @@ extension Home {
         var minValueIobChart: Decimal = 0
         var maxValueIobChart: Decimal = 5
 
-        let taskContext = CoreDataStack.shared.newTaskContext()
-        let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
-        let carbsFetchContext = CoreDataStack.shared.newTaskContext()
-        let fpuFetchContext = CoreDataStack.shared.newTaskContext()
-        let determinationFetchContext = CoreDataStack.shared.newTaskContext()
-        let tddFetchContext = CoreDataStack.shared.newTaskContext()
-        let pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
-        let overrideFetchContext = CoreDataStack.shared.newTaskContext()
-        let tempTargetFetchContext = CoreDataStack.shared.newTaskContext()
-        let batteryFetchContext = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
-        // Queue for handling Core Data change notifications
-        private let queue = DispatchQueue(label: "HomeStateModel.queue", qos: .userInitiated)
-        private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
+        // MARK: - NSFetchedResultsControllers
+
+        //
+        // Each Core Data backed array on this state model is driven by an NSFetchedResultsController
+        // bound to the viewContext. The controllers keep their `fetchedObjects` continuously in sync
+        // with the viewContext (which in turn is fed by the persistent history merge in CoreDataStack)
+        // and notify us via their delegate's `onContentChange` closure. This replaces the previous
+        // hand-rolled `changedObjectsOnManagedObjectContextDidSavePublisher` + re-fetch approach.
+
+        @ObservationIgnored let glucoseControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var glucoseController: NSFetchedResultsController<GlucoseStored> = {
+            let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: true)]
+            request.predicate = NSPredicate.glucose
+            request.fetchBatchSize = 50
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = glucoseControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let carbsControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var carbsController: NSFetchedResultsController<CarbEntryStored> = {
+            let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: false)]
+            request.predicate = NSPredicate.carbsForChart
+            request.fetchBatchSize = 5
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = carbsControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let fpuControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var fpuController: NSFetchedResultsController<CarbEntryStored> = {
+            let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: false)]
+            request.predicate = NSPredicate.fpusForChart
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = fpuControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let enactedDeterminationControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var enactedDeterminationController: NSFetchedResultsController<OrefDetermination> =
+            {
+                let request = NSFetchRequest<OrefDetermination>(entityName: "OrefDetermination")
+                request.sortDescriptors = [NSSortDescriptor(keyPath: \OrefDetermination.deliverAt, ascending: false)]
+                request.predicate = NSPredicate.enactedDetermination
+                request.fetchLimit = 1
+                let controller = NSFetchedResultsController(
+                    fetchRequest: request,
+                    managedObjectContext: viewContext,
+                    sectionNameKeyPath: nil,
+                    cacheName: nil
+                )
+                controller.delegate = enactedDeterminationControllerDelegate
+                return controller
+            }()
+
+        @ObservationIgnored let determinationControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var determinationController: NSFetchedResultsController<OrefDetermination> = {
+            let request = NSFetchRequest<OrefDetermination>(entityName: "OrefDetermination")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OrefDetermination.deliverAt, ascending: false)]
+            request.predicate = NSPredicate.determinationsForCobIobCharts
+            request.fetchBatchSize = 50
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = determinationControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let insulinControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var insulinController: NSFetchedResultsController<PumpEventStored> = {
+            let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: true)]
+            request.predicate = NSPredicate.pumpHistoryLast24h
+            request.fetchBatchSize = 30
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = insulinControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let lastBolusControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var lastBolusController: NSFetchedResultsController<PumpEventStored> = {
+            let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: false)]
+            request.predicate = NSPredicate.lastPumpBolus
+            request.fetchLimit = 1
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = lastBolusControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let overrideControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var overrideController: NSFetchedResultsController<OverrideStored> = {
+            let request = NSFetchRequest<OverrideStored>(entityName: "OverrideStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OverrideStored.date, ascending: false)]
+            request.predicate = NSPredicate.lastActiveOverride
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = overrideControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let overrideRunControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var overrideRunController: NSFetchedResultsController<OverrideRunStored> = {
+            let request = NSFetchRequest<OverrideRunStored>(entityName: "OverrideRunStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OverrideRunStored.startDate, ascending: false)]
+            request.predicate = NSPredicate.predicateForStartDateOneDayAgo
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = overrideRunControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let tempTargetControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var tempTargetController: NSFetchedResultsController<TempTargetStored> = {
+            let request = NSFetchRequest<TempTargetStored>(entityName: "TempTargetStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \TempTargetStored.date, ascending: false)]
+            request.predicate = NSPredicate.tempTargetsForMainChart
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = tempTargetControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let tempTargetRunControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var tempTargetRunController: NSFetchedResultsController<TempTargetRunStored> = {
+            let request = NSFetchRequest<TempTargetRunStored>(entityName: "TempTargetRunStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \TempTargetRunStored.startDate, ascending: false)]
+            request.predicate = NSPredicate.predicateForStartDateOneDayAgo
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = tempTargetRunControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let batteryControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var batteryController: NSFetchedResultsController<OpenAPS_Battery> = {
+            let request = NSFetchRequest<OpenAPS_Battery>(entityName: "OpenAPS_Battery")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OpenAPS_Battery.date, ascending: false)]
+            request.predicate = NSPredicate.predicateFor30MinAgo
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = batteryControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let tddControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var tddController: NSFetchedResultsController<TDDStored> = {
+            let request = NSFetchRequest<TDDStored>(entityName: "TDDStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \TDDStored.date, ascending: false)]
+            request.predicate = NSPredicate.predicateForOneDayAgo
+            request.fetchLimit = 1
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = tddControllerDelegate
+            return controller
+        }()
+
+        // MARK: - Fetch window re-anchoring
+
+        //
+        // Window predicates freeze their anchor at build time, so the unbounded controllers
+        // are re-anchored to "now" on foreground. fetchLimit-1 and override controllers keep
+        // the launch anchor on purpose; temp targets must re-anchor ("date >= now" disjunct).
+
+        /// Called on `willEnterForegroundNotification`; idempotent at launch.
+        @MainActor func reanchorFetchWindows() {
+            reanchor(glucoseController, with: NSPredicate.glucose) {
+                self.updateGlucoseFromController()
+                // Re-sync the chart domain even if no new reading arrived while backgrounded.
+                self.updateStartEndMarkers()
+            }
+            reanchor(carbsController, with: NSPredicate.carbsForChart) { self.updateCarbsFromController() }
+            reanchor(fpuController, with: NSPredicate.fpusForChart) { self.updateFPUsFromController() }
+            reanchor(determinationController, with: NSPredicate.determinationsForCobIobCharts) {
+                self.updateDeterminationsFromController()
+            }
+            reanchor(insulinController, with: NSPredicate.pumpHistoryLast24h) { self.updateInsulinFromController() }
+            reanchor(overrideRunController, with: NSPredicate.predicateForStartDateOneDayAgo) {
+                self.updateOverrideRunsFromController()
+            }
+            reanchor(tempTargetController, with: NSPredicate.tempTargetsForMainChart) {
+                self.updateTempTargetsFromController()
+            }
+            reanchor(tempTargetRunController, with: NSPredicate.predicateForStartDateOneDayAgo) {
+                self.updateTempTargetRunsFromController()
+            }
+            reanchor(batteryController, with: NSPredicate.predicateFor30MinAgo) { self.updateBatteryFromController() }
+        }
+
+        @MainActor private func reanchor<T: NSFetchRequestResult>(
+            _ controller: NSFetchedResultsController<T>,
+            with predicate: NSPredicate,
+            republish: () -> Void
+        ) {
+            controller.fetchRequest.predicate = predicate
+            do {
+                try controller.performFetch()
+                republish()
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) Failed to re-anchor fetch window for \(T.self): \(error)")
+            }
+        }
+
         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() {
@@ -163,14 +412,7 @@ extension Home {
         }
 
         override func subscribe() {
-            coreDataPublisher =
-                changedObjectsOnManagedObjectContextDidSavePublisher()
-                    .receive(on: queue)
-                    .share()
-                    .eraseToAnyPublisher()
-
             registerSubscribers()
-            registerHandlers()
 
             // Parallelize Setup functions
             setupHomeViewConcurrently()
@@ -184,33 +426,25 @@ extension Home {
                 await self.setupCGMSettings()
                 self.registerObservers()
 
+                // Set up the NSFetchedResultsControllers. These are bound to the viewContext,
+                // so `performFetch` and the initial population must run on the main actor.
+                await self.setupGlucoseController()
+                await self.setupCarbsController()
+                await self.setupFPUController()
+                await self.setupEnactedDeterminationController()
+                await self.setupDeterminationController()
+                await self.setupInsulinController()
+                await self.setupLastBolusController()
+                await self.setupOverrideController()
+                await self.setupOverrideRunController()
+                await self.setupTempTargetController()
+                await self.setupTempTargetRunController()
+                await self.setupBatteryController()
+                await self.setupTDDController()
+
                 // The rest can be initialized concurrently
                 await withTaskGroup(of: Void.self) { group in
                     group.addTask {
-                        self.setupGlucoseArray()
-                    }
-                    group.addTask {
-                        self.setupCarbsArray()
-                    }
-                    group.addTask {
-                        self.setupFPUsArray()
-                    }
-                    group.addTask {
-                        self.setupDeterminationsArray()
-                    }
-                    group.addTask {
-                        self.setupTDDArray()
-                    }
-                    group.addTask {
-                        self.setupInsulinArray()
-                    }
-                    group.addTask {
-                        self.setupLastBolus()
-                    }
-                    group.addTask {
-                        self.setupBatteryArray()
-                    }
-                    group.addTask {
                         await self.setupBasalProfile()
                     }
                     group.addTask {
@@ -220,25 +454,12 @@ extension Home {
                         self.setupReservoir()
                     }
                     group.addTask {
-                        self.setupOverrides()
-                    }
-                    group.addTask {
-                        self.setupOverrideRunStored()
-                    }
-                    group.addTask {
-                        self.setupTempTargetsStored()
-                    }
-                    group.addTask {
-                        self.setupTempTargetsRunStored()
-                    }
-                    group.addTask {
                         self.iobService.updateIOB()
                     }
                 }
             }
         }
 
-        // These combine subscribers are only necessary due to the batch inserts of glucose/FPUs which do not trigger a ManagedObjectContext change notification
         private func registerSubscribers() {
             iobService.iobPublisher
                 .receive(on: DispatchQueue.main)
@@ -248,86 +469,16 @@ extension Home {
                 }
                 .store(in: &subscriptions)
 
-            glucoseStorage.updatePublisher
-                .receive(on: queue)
-                .sink { [weak self] _ in
-                    guard let self = self else { return }
-                    self.setupGlucoseArray()
-                }
-                .store(in: &subscriptions)
-
-            carbsStorage.updatePublisher
-                .receive(on: queue)
-                .sink { [weak self] _ in
-                    guard let self = self else { return }
-                    self.setupFPUsArray()
-                }
-                .store(in: &subscriptions)
-
+            // core-data-fixes drives Core Data updates via NSFetchedResultsController delegates,
+            // so dev's glucose/carbs updatePublisher sinks and the coreDataPublisher-based
+            // registerHandlers() are obsolete here. Only the bolus-status subscription (a genuinely
+            // new feature, consumed by HomeRootView) is carried over, wired in our subscriber style.
             provider.deviceManager.bolusTrigger
-                .receive(on: queue)
-                .sink { [weak self] state in
-                    guard let self = self else { return }
-                    self.bolusStatus = state
-                }
+                .receive(on: DispatchQueue.main)
+                .weakAssign(to: \.bolusStatus, on: self)
                 .store(in: &subscriptions)
         }
 
-        private func registerHandlers() {
-            coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupDeterminationsArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("TDDStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupTDDArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupGlucoseArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("CarbEntryStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupCarbsArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupInsulinArray()
-                self.setupLastBolus()
-                self.displayPumpStatusHighlightMessage()
-                self.displayPumpStatusBadge()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("OpenAPS_Battery").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupBatteryArray()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("OverrideStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupOverrides()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("OverrideRunStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupOverrideRunStored()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("TempTargetStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupTempTargetsStored()
-            }.store(in: &subscriptions)
-
-            coreDataPublisher?.filteredByEntityName("TempTargetRunStored").sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupTempTargetsRunStored()
-            }.store(in: &subscriptions)
-        }
-
         private func registerObservers() {
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(SettingsObserver.self, observer: self)
@@ -382,6 +533,14 @@ extension Home {
             }
             timer.resume()
 
+            Foundation.NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
+                .sink { [weak self] _ in
+                    Task { @MainActor in
+                        self?.reanchorFetchWindows()
+                    }
+                }
+                .store(in: &lifetime)
+
             fetchGlucoseManager.cgmDisplayState
                 .receive(on: DispatchQueue.main)
                 .sink { [weak self] state in self?.cgmDisplayState = state }
@@ -549,6 +708,8 @@ extension Home {
 
         func loadQuickBolusSuggestions() async {
             guard enableQuickBolus else { return }
+
+            let fetchContext = CoreDataStack.shared.newTaskContext()
             let cutoff = Calendar.current.date(byAdding: .day, value: -90, to: Date()) ?? Date()
             let predicate = NSPredicate(
                 format: "isSMB == false AND isExternal == false AND pumpEvent.timestamp >= %@",
@@ -557,14 +718,14 @@ extension Home {
             do {
                 let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
                     ofType: BolusStored.self,
-                    onContext: pumpHistoryFetchContext,
+                    onContext: fetchContext,
                     predicate: predicate,
                     key: "pumpEvent.timestamp",
                     ascending: false,
                     batchSize: 100
                 )
 
-                let suggestions: [Decimal] = await pumpHistoryFetchContext.perform {
+                let suggestions: [Decimal] = await fetchContext.perform {
                     guard let boluses = results as? [BolusStored] else { return [] }
 
                     let now = Date()
@@ -679,7 +840,7 @@ extension Home {
 
         /// Display the eventual status message provided by the manager of the pump
         /// Only display if state is warning or critical message else return nil
-        private func displayPumpStatusHighlightMessage(_ didDeactivate: Bool = false) {
+        func displayPumpStatusHighlightMessage(_ didDeactivate: Bool = false) {
             DispatchQueue.main.async { [weak self] in
                 guard let self = self else { return }
                 if let statusHighlight = self.provider.deviceManager.pumpManager?.pumpStatusHighlight,
@@ -693,7 +854,7 @@ extension Home {
             }
         }
 
-        private func displayPumpStatusBadge(_ didDeactivate: Bool = false) {
+        func displayPumpStatusBadge(_ didDeactivate: Bool = false) {
             DispatchQueue.main.async { [weak self] in
                 guard let self = self else { return }
                 if let statusBadge = self.provider.deviceManager.pumpManager?.pumpStatusBadge,

+ 28 - 23
Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift

@@ -27,7 +27,7 @@ extension MainChartView {
                 drawSuspensions()
             }.onChange(of: state.tempBasals) {
                 calculateBasals()
-                calculateTempBasalsInBackground()
+                calculateTempBasals()
             }
             .onChange(of: state.maxBasal) {
                 calculateBasals()
@@ -137,34 +137,39 @@ extension MainChartView {
 // MARK: - Calculation
 
 extension MainChartView {
-    func calculateTempBasalsInBackground() {
-        Task {
-            let basals = await prepareTempBasals()
-            await MainActor.run {
-                preparedTempBasals = basals
-            }
-        }
-    }
-
-    func prepareTempBasals() async -> [(start: Date, end: Date, rate: Double)] {
+    @MainActor func calculateTempBasals() {
         let now = Date()
-        let tempBasals = state.tempBasals
+        let suspensionTimes = state.suspendAndResumeEvents.compactMap(\.timestamp)
 
-        return tempBasals.compactMap { temp -> (start: Date, end: Date, rate: Double)? in
-            let duration = temp.tempBasal?.duration ?? 0
-            let timestamp = temp.timestamp ?? Date()
-            let end = timestamp + duration.minutes
-            let isInsulinSuspended = state.suspendAndResumeEvents
-                .contains { $0.timestamp ?? now >= timestamp && $0.timestamp ?? now <= end }
+        // Snapshot the managed-object fields once; plain values from here on.
+        let events = state.tempBasals.map {
+            (timestamp: $0.timestamp, duration: $0.tempBasal?.duration ?? 0, rate: $0.tempBasal?.rate)
+        }
+
+        var prepared = [(start: Date, end: Date, rate: Double)]()
+        prepared.reserveCapacity(events.count)
 
-            let rate = Double(truncating: temp.tempBasal?.rate ?? Decimal.zero as NSDecimalNumber) * (isInsulinSuspended ? 0 : 1)
+        for (index, event) in events.enumerated() {
+            let timestamp = event.timestamp ?? now
+            let end = timestamp + event.duration.minutes
+            let isInsulinSuspended = suspensionTimes.contains { $0 >= timestamp && $0 <= end }
+            let rate = Double(truncating: event.rate ?? 0) * (isInsulinSuspended ? 0 : 1)
 
-            // Check if there's a subsequent temp basal to determine the end time
-            guard let nextTemp = state.tempBasals.first(where: { $0.timestamp ?? .distantPast > timestamp }) else {
-                return (timestamp, end, rate)
+            // A bar ends where the next later-starting temp basal begins,
+            // else at its own scheduled end.
+            var next = index + 1
+            while next < events.count {
+                if let nextStart = events[next].timestamp, nextStart > timestamp { break }
+                next += 1
+            }
+            if next < events.count, let nextStart = events[next].timestamp {
+                prepared.append((timestamp, nextStart, rate))
+            } else {
+                prepared.append((timestamp, end, rate))
             }
-            return (timestamp, nextTemp.timestamp ?? Date(), rate)
         }
+
+        preparedTempBasals = prepared
     }
 
     func findRegularBasalPoints(

+ 1 - 1
Trio/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -96,7 +96,7 @@ struct MainChartView: View {
                             if !mainChartHasInitialized {
                                 scroller.scrollTo("MainChart", anchor: .trailing)
                                 state.updateStartEndMarkers()
-                                calculateTempBasalsInBackground()
+                                calculateTempBasals()
                                 mainChartHasInitialized = true
                             }
                         }

+ 0 - 2
Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift

@@ -24,8 +24,6 @@ extension ISFEditor {
         var therapyItems: [TherapySettingItem] = []
         var shouldDisplaySaving: Bool = false
 
-        let context = CoreDataStack.shared.newTaskContext()
-
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 
         var rateValues: [Decimal] {

+ 0 - 2
Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift

@@ -15,8 +15,6 @@ extension NightscoutConfig {
         @Injected() private var storage: FileStorage!
         @Injected() var apsManager: APSManager!
 
-        let coredataContext = CoreDataStack.shared.newTaskContext()
-
         @Published var url = ""
         @Published var secret = ""
         @Published var message = ""

+ 6 - 0
Trio/Sources/Modules/Stat/StatStateModel+Setup/BolusStatsSetup.swift

@@ -48,6 +48,9 @@ extension Stat.StateModel {
     /// 3. Calculates total insulin for each time period
     /// 4. Returns the processed statistics as (hourly: [BolusStats], daily: [BolusStats])
     private func fetchBolusStats() async throws -> (hourly: [BolusStats], daily: [BolusStats]) {
+        let bolusTaskContext = CoreDataStack.shared.newTaskContext()
+        bolusTaskContext.name = "StatStateModel.fetchBolusStats"
+
         // Fetch PumpEventStored entries from Core Data
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: BolusStored.self,
@@ -155,6 +158,9 @@ extension Stat.StateModel {
     ///
     /// This only needs to be called once during subscribe.
     private func calculateAndCacheBolusAveragesAndTotals() async {
+        let bolusTaskContext = CoreDataStack.shared.newTaskContext()
+        bolusTaskContext.name = "StatStateModel.calculateAndCacheBolusAveragesAndTotals"
+
         let calendar = Calendar.current
 
         // Calculate averages in context

+ 10 - 1
Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift

@@ -81,6 +81,9 @@ extension Stat.StateModel {
     func fetchLoopStatRecords(for interval: StatsTimeIntervalWithToday) async throws
         -> ([NSManagedObjectID], [NSManagedObjectID])
     {
+        let loopTaskContext = CoreDataStack.shared.newTaskContext()
+        loopTaskContext.name = "StatStateModel.fetchLoopStatRecords"
+
         // Calculate the date range based on selected duration
         let now = Date()
         let startDate: Date
@@ -154,6 +157,9 @@ extension Stat.StateModel {
     ) async throws
         -> [LoopStatsProcessedData]
     {
+        let loopTaskContext = CoreDataStack.shared.newTaskContext()
+        loopTaskContext.name = "StatStateModel.getLoopStats"
+
         // Calculate the date range for glucose readings
         let now = Date()
         let startDate: Date
@@ -170,7 +176,7 @@ extension Stat.StateModel {
             startDate = now.addingTimeInterval(-90.days.timeInterval)
         }
 
-        // Get glucose statistics
+        // Get glucose statistics (uses its own local context)
         let totalGlucose = try await calculateGlucoseStats(from: startDate, to: now)
 
         // Get NSManagedObject
@@ -236,6 +242,9 @@ extension Stat.StateModel {
         from startDate: Date,
         to _: Date
     ) async throws -> Int {
+        let loopTaskContext = CoreDataStack.shared.newTaskContext()
+        loopTaskContext.name = "StatStateModel.calculateGlucoseStats"
+
         // Create predicate for glucose readings
         let glucosePredicate = NSPredicate(format: "date >= %@", startDate as NSDate)
 

+ 3 - 0
Trio/Sources/Modules/Stat/StatStateModel+Setup/MealStatsSetup.swift

@@ -48,6 +48,9 @@ extension Stat.StateModel {
     /// 3. Calculates total macronutrients for each time period
     /// 4. Returns the processed statistics as (hourly: [MealStats], daily: [MealStats])
     private func fetchMealStats() async throws -> (hourly: [MealStats], daily: [MealStats]) {
+        let mealTaskContext = CoreDataStack.shared.newTaskContext()
+        mealTaskContext.name = "StatStateModel.fetchMealStats"
+
         // Fetch CarbEntryStored entries from Core Data
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,

+ 17 - 4
Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

@@ -34,13 +34,21 @@ extension Stat.StateModel {
     /// - Returns: A tuple containing hourly and daily TDD statistics arrays
     /// - Note: Processes both hourly statistics for the last 10 days and complete daily statistics
     private func fetchTDDStats() async throws -> (hourly: [TDDStats], daily: [TDDStats]) {
+        let tddTaskContext = CoreDataStack.shared.newTaskContext()
+        tddTaskContext.name = "StatStateModel.fetchTDDStats"
+
         // MARK: - Fetch Required Data
 
         // Fetch data for daily statistics (TDDStored for week, month, total views)
-        let tddResults = try await fetchTDDStoredRecords()
+        let tddResults = try await fetchTDDStoredRecords(on: tddTaskContext)
 
         // Fetch data for hourly statistics (BolusStored and TempBasalStored for day view)
-        let (bolusResults, tempBasalResults, suspendEvents, resumeEvents) = try await fetchHourlyInsulinRecords()
+        let (
+            bolusResults,
+            tempBasalResults,
+            suspendEvents,
+            resumeEvents
+        ) = try await fetchHourlyInsulinRecords(on: tddTaskContext)
 
         // MARK: - Process Data on Background Context
 
@@ -77,7 +85,7 @@ extension Stat.StateModel {
     /// Fetches TDDStored records from CoreData for daily statistics
     /// - Returns: The results of the fetch request containing TDDStored records
     /// - Note: Fetches records from the last 3 months for week, month, and total views
-    private func fetchTDDStoredRecords() async throws -> Any {
+    private func fetchTDDStoredRecords(on tddTaskContext: NSManagedObjectContext) async throws -> Any {
         // Create a predicate to fetch TDD records from the last 3 months
         let threeMonthsAgo = Date().addingTimeInterval(-3.months.timeInterval)
         let predicate = NSPredicate(format: "date >= %@", threeMonthsAgo as NSDate)
@@ -96,7 +104,9 @@ extension Stat.StateModel {
     /// Fetches BolusStored and TempBasalStored records from CoreData for hourly statistics
     /// - Returns: A tuple containing the results of both fetch requests
     /// - Note: Fetches records from the last 20 days for detailed hourly view
-    private func fetchHourlyInsulinRecords() async throws -> (bolus: Any, tempBasal: Any, suspendEvents: Any, resumeEvents: Any) {
+    private func fetchHourlyInsulinRecords(on tddTaskContext: NSManagedObjectContext) async throws
+        -> (bolus: Any, tempBasal: Any, suspendEvents: Any, resumeEvents: Any)
+    {
         // Calculate date range for hourly statistics (last 20 days)
         let now = Date()
         let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -20, to: now) ?? now
@@ -498,6 +508,9 @@ extension Stat.StateModel {
     /// Calculates and caches the daily averages of Total Daily Dose (TDD) insulin values
     /// - Note: This function runs asynchronously and updates the tddAveragesCache on the main actor
     private func calculateAndCacheTDDAverages() async {
+        let tddTaskContext = CoreDataStack.shared.newTaskContext()
+        tddTaskContext.name = "StatStateModel.calculateAndCacheTDDAverages"
+
         // Get calendar for date calculations
         let calendar = Calendar.current
 

+ 3 - 5
Trio/Sources/Modules/Stat/StatStateModel.swift

@@ -77,12 +77,7 @@ extension Stat {
         var selectedMealChartType: MealChartType = .totalMeals
 
         // Fetching Contexts
-        let context = CoreDataStack.shared.newTaskContext()
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-        let tddTaskContext = CoreDataStack.shared.newTaskContext()
-        let loopTaskContext = CoreDataStack.shared.newTaskContext()
-        let mealTaskContext = CoreDataStack.shared.newTaskContext()
-        let bolusTaskContext = CoreDataStack.shared.newTaskContext()
 
         override func subscribe() {
             setupGlucoseArray(for: .today)
@@ -128,6 +123,9 @@ extension Stat {
 
         private func fetchGlucose(for interval: StatsTimeIntervalWithToday) async -> [NSManagedObjectID] {
             do {
+                let context = CoreDataStack.shared.newTaskContext()
+                context.name = "StatStateModel.fetchGlucose"
+
                 let predicate: NSPredicate
 
                 switch interval {

+ 208 - 222
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -110,7 +110,6 @@ extension Treatments {
         var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
         var predictionsForChart: Predictions?
         var simulatedDetermination: Determination?
-        @MainActor var determinationObjectIDs: [NSManagedObjectID] = []
 
         var minForecast: [Int] = []
         var maxForecast: [Int] = []
@@ -122,18 +121,67 @@ extension Treatments {
         let now = Date.now
 
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-        let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
-        let determinationFetchContext = CoreDataStack.shared.newTaskContext()
-        let pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
 
         var isActive: Bool = false
 
         var showDeterminationFailureAlert = false
         var determinationFailureMessage = ""
 
-        // Queue for handling Core Data change notifications
-        private let queue = DispatchQueue(label: "TreatmentsStateModel.queue", qos: .userInitiated)
-        private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
+        // MARK: - NSFetchedResultsControllers
+
+        //
+        // Glucose, the latest determination and the last pump bolus are driven by
+        // NSFetchedResultsControllers bound to the viewContext. They keep their `fetchedObjects`
+        // continuously in sync and notify us through their delegate's `onContentChange` closure.
+
+        @ObservationIgnored let glucoseControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var glucoseController: NSFetchedResultsController<GlucoseStored> = {
+            let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)]
+            request.predicate = NSPredicate.glucose
+            request.fetchBatchSize = 50
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = glucoseControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let determinationControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var determinationController: NSFetchedResultsController<OrefDetermination> = {
+            let request = NSFetchRequest<OrefDetermination>(entityName: "OrefDetermination")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \OrefDetermination.deliverAt, ascending: false)]
+            request.predicate = NSPredicate.predicateFor30MinAgoForDetermination
+            request.fetchLimit = 1
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = determinationControllerDelegate
+            return controller
+        }()
+
+        @ObservationIgnored let lastBolusControllerDelegate = FetchedResultsControllerDelegate()
+        @ObservationIgnored private(set) lazy var lastBolusController: NSFetchedResultsController<PumpEventStored> = {
+            let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+            request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: false)]
+            request.predicate = NSPredicate.lastPumpBolus
+            request.fetchLimit = 1
+            let controller = NSFetchedResultsController(
+                fetchRequest: request,
+                managedObjectContext: viewContext,
+                sectionNameKeyPath: nil,
+                cacheName: nil
+            )
+            controller.delegate = lastBolusControllerDelegate
+            return controller
+        }()
+
         private var subscriptions = Set<AnyCancellable>()
 
         typealias PumpEvent = PumpEventStored.EventType
@@ -153,13 +201,6 @@ extension Treatments {
             }
 
             debug(.bolusState, "subscribe fired")
-            coreDataPublisher =
-                changedObjectsOnManagedObjectContextDidSavePublisher()
-                    .receive(on: queue)
-                    .share()
-                    .eraseToAnyPublisher()
-            registerHandlers()
-            registerSubscribers()
             setupBolusStateConcurrently()
             subscribeToBolusProgress()
         }
@@ -170,6 +211,10 @@ extension Treatments {
 
         private var hasCleanedUp = false
 
+        /// In-flight work started by this instance; cancelled in `cleanupTreatmentState()`.
+        @ObservationIgnored private var setupTask: Task<Void, Never>?
+        @ObservationIgnored private var determinationUpdateTask: Task<Void, Never>?
+
         func cleanupTreatmentState() {
             guard !hasCleanedUp else { return }
             hasCleanedUp = true
@@ -177,6 +222,16 @@ extension Treatments {
             unsubscribe()
             lifetime = Lifetime()
 
+            // Stop the FRC → recompute pipelines; a dismissed instance must not keep
+            // re-running the bolus calculator on every viewContext merge.
+            glucoseControllerDelegate.onContentChange = nil
+            determinationControllerDelegate.onContentChange = nil
+            lastBolusControllerDelegate.onContentChange = nil
+
+            // Cancel in-flight work — the setup task awaits a full oref simulation.
+            setupTask?.cancel()
+            determinationUpdateTask?.cancel()
+
             broadcaster?.unregister(DeterminationObserver.self, observer: self)
             broadcaster?.unregister(BolusFailureObserver.self, observer: self)
 
@@ -185,14 +240,13 @@ extension Treatments {
 
         private func setupBolusStateConcurrently() {
             debug(.bolusState, "Setting up bolus state concurrently...")
-            Task {
+            setupTask = Task {
+                // Load settings and observers first so the determination controller's initial
+                // population (which runs calculateInsulin) sees correct values.
                 do {
                     try await withThrowingTaskGroup(of: Void.self) { group in
                         group.addTask {
-                            self.setupGlucoseArray()
-                        }
-                        group.addTask {
-                            self.setupDeterminationsAndForecasts()
+                            await self.getAllSettingsValues()
                         }
                         group.addTask {
                             await self.setupSettings()
@@ -200,9 +254,6 @@ extension Treatments {
                         group.addTask {
                             self.registerObservers()
                         }
-                        group.addTask {
-                            self.setupLastBolus()
-                        }
 
                         // Wait for all tasks to complete
                         try await group.waitForAll()
@@ -210,6 +261,14 @@ extension Treatments {
                 } catch let error as NSError {
                     debug(.default, "Failed to setup bolus state concurrently: \(error)")
                 }
+
+                // viewContext-bound FRCs: guard and wiring share one main-actor slice.
+                await MainActor.run {
+                    guard !Task.isCancelled, !self.hasCleanedUp else { return }
+                    self.setupGlucoseController()
+                    self.setupDeterminationController()
+                    self.setupLastBolusController()
+                }
             }
         }
 
@@ -274,20 +333,6 @@ extension Treatments {
             }
         }
 
-        private func setupDeterminationsAndForecasts() {
-            Task {
-                async let getAllSettingsDefaults: () = getAllSettingsValues()
-                async let setupDeterminations: () = setupDeterminationsArray()
-
-                await getAllSettingsDefaults
-                await setupDeterminations
-
-                // Determination has updated, so we can use this to draw the initial Forecast Chart
-                let forecastData = await mapForecastsForChart()
-                await updateForecasts(with: forecastData)
-            }
-        }
-
         private func registerObservers() {
             broadcaster.register(DeterminationObserver.self, observer: self)
             broadcaster.register(BolusFailureObserver.self, observer: self)
@@ -413,6 +458,9 @@ extension Treatments {
                 isBackdated: isBackdated
             )
 
+            // A superseded run must not overwrite the breakdown a newer run published.
+            guard !Task.isCancelled else { return apsManager.roundBolus(amount: result.insulinCalculated) }
+
             // Update state properties with calculation results on main thread
             await MainActor.run {
                 targetDifference = result.targetDifference
@@ -728,6 +776,8 @@ extension Treatments.StateModel: DeterminationObserver, BolusFailureObserver {
 
     func bolusDidFail() {
         DispatchQueue.main.async {
+            // A dismissed instance may still observe until dealloc — don't hide an unrelated modal.
+            guard self.isActive else { return }
             debug(.bolusState, "bolusDidFail fired")
             self.isAwaitingDeterminationResult = false
             if self.addButtonPressed {
@@ -737,79 +787,30 @@ extension Treatments.StateModel: DeterminationObserver, BolusFailureObserver {
     }
 }
 
-extension Treatments.StateModel {
-    private func registerHandlers() {
-        coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task {
-                await self.setupDeterminationsArray()
-                let forecastData = await self.mapForecastsForChart()
-                await self.updateForecasts(with: forecastData)
-            }
-        }.store(in: &subscriptions)
-
-        // Due to the Batch insert this only is used for observing Deletion of Glucose entries
-        coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            self.setupGlucoseArray()
-        }.store(in: &subscriptions)
-
-        // Refresh `lastPumpBolus` whenever a new pump event lands (mirrors HomeStateModel)
-        coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
-            self?.setupLastBolus()
-        }.store(in: &subscriptions)
-    }
-
-    private func registerSubscribers() {
-        glucoseStorage.updatePublisher
-            .receive(on: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                self.setupGlucoseArray()
-            }
-            .store(in: &subscriptions)
-    }
-}
-
-// MARK: - Setup Glucose and Determinations
+// MARK: - Setup Glucose, Determinations and Last Bolus
 
 extension Treatments.StateModel {
-    // Glucose
-    private func setupGlucoseArray() {
-        Task {
-            do {
-                let ids = try await self.fetchGlucose()
-                let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
-                    .getNSManagedObject(with: ids, context: viewContext)
-                await updateGlucoseArray(with: glucoseObjects)
-            } catch {
-                debug(
-                    .default,
-                    "\(DebuggingIdentifiers.failed) Error setting up glucose array: \(error)"
-                )
-            }
-        }
-    }
+    // MARK: - Glucose Controller
 
-    private func fetchGlucose() async throws -> [NSManagedObjectID] {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: glucoseFetchContext,
-            predicate: NSPredicate.glucose,
-            key: "date",
-            ascending: false
-        )
-
-        return try await glucoseFetchContext.perform {
-            guard let fetchedResults = results as? [GlucoseStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupGlucoseController() {
+        glucoseControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                guard let self, self.isActive else { return }
+                self.updateGlucoseFromController()
             }
+        }
 
-            return fetchedResults.map(\.objectID)
+        do {
+            try glucoseController.performFetch()
+            updateGlucoseFromController()
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform glucose fetch: \(error)")
         }
     }
 
-    @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
+    @MainActor private func updateGlucoseFromController() {
+        guard let objects = glucoseController.fetchedObjects else { return }
+
         // Store all objects for the forecast graph
         glucoseFromPersistence = objects
 
@@ -837,96 +838,89 @@ extension Treatments.StateModel {
         deltaBG = delta
     }
 
-    // Determinations
-    private func setupDeterminationsArray() async {
-        do {
-            let fetchedObjectIDs = try await determinationStorage.fetchLastDeterminationObjectID(
-                predicate: NSPredicate.predicateFor30MinAgoForDetermination
-            )
+    // MARK: - Determination Controller
 
-            await MainActor.run {
-                determinationObjectIDs = fetchedObjectIDs
+    @MainActor func setupDeterminationController() {
+        determinationControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                guard let self, self.isActive else { return }
+                self.updateDeterminationFromController()
+                self.scheduleInsulinAndForecastUpdate()
             }
+        }
 
-            let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-                .getNSManagedObject(with: determinationObjectIDs, context: viewContext)
-
-            updateDeterminationsArray(with: determinationObjects)
-        } catch let error as CoreDataError {
-            debug(.default, "Core Data error: \(error)")
+        do {
+            try determinationController.performFetch()
+            updateDeterminationFromController()
+            scheduleInsulinAndForecastUpdate()
         } catch {
-            debug(.default, "Unexpected error: \(error)")
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform determination fetch: \(error)")
         }
     }
 
-    private func mapForecastsForChart() async -> Determination? {
-        do {
-            let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-                .getNSManagedObject(with: determinationObjectIDs, context: determinationFetchContext)
-
-            let determination = await determinationFetchContext.perform {
-                let determinationObject = determinationObjects.first
-
-                let forecastsSet = determinationObject?.forecasts ?? []
-                let predictions = Predictions(
-                    iob: forecastsSet.extractValues(for: "iob"),
-                    zt: forecastsSet.extractValues(for: "zt"),
-                    cob: forecastsSet.extractValues(for: "cob"),
-                    uam: forecastsSet.extractValues(for: "uam")
-                )
-
-                return Determination(
-                    id: UUID(),
-                    reason: "",
-                    units: 0,
-                    insulinReq: 0,
-                    sensitivityRatio: 0,
-                    rate: 0,
-                    duration: 0,
-                    iob: 0,
-                    cob: 0,
-                    predictions: predictions.isEmpty ? nil : predictions,
-                    carbsReq: 0,
-                    temp: nil,
-                    reservoir: 0,
-                    carbRatio: 0,
-                    received: false
-                )
-            }
+    /// Recomputes bolus recommendation and forecast; each new determination cancels the
+    /// previous in-flight run so a superseded run cannot publish stale results.
+    @MainActor private func scheduleInsulinAndForecastUpdate() {
+        determinationUpdateTask?.cancel()
+        determinationUpdateTask = Task { @MainActor in
+            let insulinCalculated = await self.calculateInsulin()
+            guard !Task.isCancelled else { return }
+            self.insulinCalculated = insulinCalculated
+            let forecastData = self.mapForecastsFromController()
+            await self.updateForecasts(with: forecastData)
+        }
+    }
 
-            guard !determinationObjects.isEmpty else {
-                return nil
-            }
+    @MainActor private func updateDeterminationFromController() {
+        guard let objects = determinationController.fetchedObjects,
+              let mostRecentDetermination = objects.first else { return }
+
+        determination = objects
+
+        // setup vars for bolus calculation
+        insulinRequired = (mostRecentDetermination.insulinReq ?? 0) as Decimal
+        evBG = (mostRecentDetermination.eventualBG ?? 0) as Decimal
+        minPredBG = (mostRecentDetermination.minPredBGFromReason ?? 0) as Decimal
+        lastLoopDate = apsManager.lastLoopDate as Date?
+        insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal
+        target = (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal
+        isf = (mostRecentDetermination.insulinSensitivity ?? currentISF as NSDecimalNumber) as Decimal
+        cob = mostRecentDetermination.cob as Int16
+        iob = (mostRecentDetermination.iob ?? 0) as Decimal
+        basal = (mostRecentDetermination.tempBasal ?? 0) as Decimal
+        carbRatio = (mostRecentDetermination.carbRatio ?? currentCarbRatio as NSDecimalNumber) as Decimal
+    }
 
-            return determination
-        } catch {
-            debug(
-                .default,
-                "\(DebuggingIdentifiers.failed) Error mapping forecasts for chart: \(error)"
-            )
+    @MainActor private func mapForecastsFromController() -> Determination? {
+        guard let determinationObject = determinationController.fetchedObjects?.first else {
             return nil
         }
-    }
 
-    private func updateDeterminationsArray(with objects: [OrefDetermination]) {
-        Task { @MainActor in
-            guard let mostRecentDetermination = objects.first else { return }
-            determination = objects
-
-            // setup vars for bolus calculation
-            insulinRequired = (mostRecentDetermination.insulinReq ?? 0) as Decimal
-            evBG = (mostRecentDetermination.eventualBG ?? 0) as Decimal
-            minPredBG = (mostRecentDetermination.minPredBGFromReason ?? 0) as Decimal
-            lastLoopDate = apsManager.lastLoopDate as Date?
-            insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal
-            target = (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal
-            isf = (mostRecentDetermination.insulinSensitivity ?? currentISF as NSDecimalNumber) as Decimal
-            cob = mostRecentDetermination.cob as Int16
-            iob = (mostRecentDetermination.iob ?? 0) as Decimal
-            basal = (mostRecentDetermination.tempBasal ?? 0) as Decimal
-            carbRatio = (mostRecentDetermination.carbRatio ?? currentCarbRatio as NSDecimalNumber) as Decimal
-            insulinCalculated = await calculateInsulin()
-        }
+        let forecastsSet = determinationObject.forecasts ?? []
+        let predictions = Predictions(
+            iob: forecastsSet.extractValues(for: "iob"),
+            zt: forecastsSet.extractValues(for: "zt"),
+            cob: forecastsSet.extractValues(for: "cob"),
+            uam: forecastsSet.extractValues(for: "uam")
+        )
+
+        return Determination(
+            id: UUID(),
+            reason: "",
+            units: 0,
+            insulinReq: 0,
+            sensitivityRatio: 0,
+            rate: 0,
+            duration: 0,
+            iob: 0,
+            cob: 0,
+            predictions: predictions.isEmpty ? nil : predictions,
+            carbsReq: 0,
+            temp: nil,
+            reservoir: 0,
+            carbRatio: 0,
+            received: false
+        )
     }
 }
 
@@ -942,7 +936,7 @@ extension Treatments.StateModel {
             simulatedDetermination = forecastData
             debugPrint("\(DebuggingIdentifiers.failed) minPredBG: \(minPredBG)")
         } else {
-            simulatedDetermination = await Task { [self] in
+            let simulated = await Task { [self] in
                 debug(.bolusState, "calling simulateDetermineBasal to get forecast data")
                 return await apsManager.simulateDetermineBasal(
                     simulatedCarbsAmount: carbs,
@@ -951,8 +945,12 @@ extension Treatments.StateModel {
                 )
             }.value
 
+            // Stale minPredBG/cob from a superseded run would feed the next bolus calculation.
+            guard !Task.isCancelled else { return }
+            simulatedDetermination = simulated
+
             // Update evBG and minPredBG from simulated determination
-            if let simDetermination = simulatedDetermination {
+            if let simDetermination = simulated {
                 evBG = Decimal(simDetermination.eventualBG ?? 0)
                 minPredBG = simDetermination.minPredBGFromReason ?? 0
                 debugPrint("\(DebuggingIdentifiers.inProgress) minPredBG: \(minPredBG)")
@@ -991,18 +989,13 @@ extension Treatments.StateModel {
             }
         }.value
 
-        minForecast = await minForecastResult
-        maxForecast = await maxForecastResult
-    }
-}
+        let minResult = await minForecastResult
+        let maxResult = await maxForecastResult
 
-private extension Set where Element == Forecast {
-    func extractValues(for type: String) -> [Int]? {
-        let values = first { $0.type == type }?
-            .forecastValues?
-            .sorted { $0.index < $1.index }
-            .compactMap { Int($0.value) }
-        return values?.isEmpty ?? true ? nil : values
+        guard !Task.isCancelled else { return }
+
+        minForecast = minResult
+        maxForecast = maxResult
     }
 }
 
@@ -1015,43 +1008,36 @@ private extension Predictions {
 // MARK: - Last Pump Bolus
 
 extension Treatments.StateModel {
-    /// Mirrors `HomeStateModel.setupLastBolus` so the in-progress visualizer can show the
+    /// Mirrors `HomeStateModel`'s last-bolus controller so the in-progress visualizer can show the
     /// running pump-bolus's amount as the denominator (not the user's pending entry).
     /// Filters out external boluses via `NSPredicate.lastPumpBolus`.
-    func setupLastBolus() {
-        Task {
-            do {
-                guard let id = try await fetchLastBolus() else { return }
-                await updateLastBolus(with: id)
-            } catch {
-                debug(.default, "\(DebuggingIdentifiers.failed) Error setting up last bolus: \(error)")
-            }
-        }
-    }
-
-    private func fetchLastBolus() async throws -> NSManagedObjectID? {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self,
-            onContext: pumpHistoryFetchContext,
-            predicate: NSPredicate.lastPumpBolus,
-            key: "timestamp",
-            ascending: false,
-            fetchLimit: 1
-        )
-
-        return try await pumpHistoryFetchContext.perform {
-            guard let fetched = results as? [PumpEventStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
+    @MainActor func setupLastBolusController() {
+        lastBolusControllerDelegate.onContentChange = { [weak self] in
+            Task { @MainActor in
+                guard let self, self.isActive else { return }
+                self.updateLastBolusFromController()
             }
-            return fetched.map(\.objectID).first
         }
-    }
 
-    @MainActor private func updateLastBolus(with id: NSManagedObjectID) {
         do {
-            lastPumpBolus = try viewContext.existingObject(with: id) as? PumpEventStored
+            try lastBolusController.performFetch()
+            updateLastBolusFromController()
         } catch {
-            debug(.default, "\(DebuggingIdentifiers.failed) updateLastBolus: \(error)")
+            debug(.default, "\(DebuggingIdentifiers.failed) Failed to perform last bolus fetch: \(error)")
         }
     }
+
+    @MainActor private func updateLastBolusFromController() {
+        lastPumpBolus = lastBolusController.fetchedObjects?.first
+    }
+}
+
+private extension Set where Element == Forecast {
+    /// Extracts the sorted forecast values for a given prediction type (iob/zt/cob/uam).
+    func extractValues(for type: String) -> [Int]? {
+        let values = first { $0.type == type }?
+            .forecastValuesArray
+            .map { Int($0.value) }
+        return (values?.isEmpty ?? true) ? nil : values
+    }
 }

+ 4 - 3
Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift

@@ -27,7 +27,6 @@ final class GlucoseAlertCoordinator: Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
 
-    private let coreDataContext = CoreDataStack.shared.newTaskContext()
     private let evaluationQueue = DispatchQueue(label: "GlucoseAlertCoordinator.queue")
     private var firingAlertIDs: Set<UUID> = []
     private var subscriptions = Set<AnyCancellable>()
@@ -403,16 +402,18 @@ final class GlucoseAlertCoordinator: Injectable {
     private func fetchLatestReadingMgDL() async -> Decimal? {
         let cutoff = Date().addingTimeInterval(-Self.readingFreshnessWindow)
         let predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "GlucoseAlertCoordinator.fetchLatestReading"
         do {
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: GlucoseStored.self,
-                onContext: coreDataContext,
+                onContext: context,
                 predicate: predicate,
                 key: "date",
                 ascending: false,
                 fetchLimit: 1
             )
-            return await coreDataContext.perform {
+            return await context.perform {
                 guard let latest = (results as? [GlucoseStored])?.first else { return nil }
                 return Decimal(latest.glucose)
             }

+ 8 - 5
Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift

@@ -21,9 +21,6 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
     @Injected() private var fileStorage: FileStorage!
     @Injected() private var determinationStorage: DeterminationStorage!
 
-    let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
-    let determinationFetchContext = CoreDataStack.shared.newTaskContext()
-
     init(resolver: Resolver) {
         injectServices(resolver)
     }
@@ -191,15 +188,17 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
     /// Fetches recent glucose readings from CoreData
     /// - Returns: Array of NSManagedObjectIDs for glucose readings
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: glucoseFetchContext,
+            onContext: context,
             predicate: NSPredicate.glucose,
             key: "date",
             ascending: false
         )
 
-        return try await glucoseFetchContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -312,6 +311,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             let maxCOB = preferences.maxCOB
 
             // Fetch glucose data
+            let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
+            glucoseFetchContext.name = "handleBolusCalculation.glucose"
             let glucoseIds = try await fetchGlucose()
             let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared.getNSManagedObject(
                 with: glucoseIds,
@@ -322,6 +323,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             }
 
             // Fetch determination data
+            let determinationFetchContext = CoreDataStack.shared.newTaskContext()
+            determinationFetchContext.name = "handleBolusCalculation.determination"
             let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
                 predicate: NSPredicate.predicateFor30MinAgoForDetermination
             )

+ 2 - 3
Trio/Sources/Services/BolusSafety/BolusSafetyValidator.swift

@@ -64,10 +64,7 @@ final class BaseBolusSafetyValidator: BolusSafetyValidator, Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var iobService: IOBService!
 
-    private let fetchContext: NSManagedObjectContext
-
     init(resolver: Resolver) {
-        fetchContext = CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
@@ -84,6 +81,8 @@ final class BaseBolusSafetyValidator: BolusSafetyValidator, Injectable {
     }
 
     func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
+        let fetchContext = CoreDataStack.shared.newTaskContext()
+        fetchContext.name = "fetchTotalRecentBolusAmount"
         let predicate = NSPredicate(
             format: "type == %@ AND timestamp > %@",
             PumpEventStored.EventType.bolus.rawValue,

+ 9 - 6
Trio/Sources/Services/Calendar/CalendarManager.swift

@@ -65,7 +65,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
         setupCurrentCalendar()
 
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -77,7 +77,6 @@ final class BaseCalendarManager: CalendarManager, Injectable {
         registerSubscribers()
     }
 
-    let backgroundContext = CoreDataStack.shared.newTaskContext()
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
     private func setupCurrentCalendar() {
@@ -177,9 +176,11 @@ final class BaseCalendarManager: CalendarManager, Injectable {
     }
 
     private func getLastDetermination() async throws -> NSManagedObjectID? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "getLastDetermination"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor30MinAgoForDetermination,
             key: "timestamp",
             ascending: false,
@@ -187,7 +188,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
             propertiesToFetch: ["timestamp", "cob", "iob", "objectID"]
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [[String: Any]] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -197,16 +198,18 @@ final class BaseCalendarManager: CalendarManager, Injectable {
     }
 
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor30MinAgo,
             key: "date",
             ascending: false,
             propertiesToFetch: ["objectID", "glucose"]
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [[String: Any]] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }

+ 9 - 6
Trio/Sources/Services/ContactImage/ContactImageManager.swift

@@ -31,7 +31,6 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     private(set) var state = ContactImageState()
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
 
     // Queue for handling Core Data change notifications
     private let queue = DispatchQueue(label: "BaseContactImageManager.queue", qos: .background)
@@ -56,7 +55,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
         injectServices(resolver)
         units = settingsManager.settings.units
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -101,16 +100,18 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     // MARK: - Core Data Fetches
 
     private func fetchlastDetermination() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchlastDetermination"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate(format: "deliverAt >= %@", Date.halfHourAgo as NSDate), // fetches enacted and suggested
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OrefDetermination] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -120,16 +121,18 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     }
 
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor20MinAgo,
             key: "date",
             ascending: false,
             fetchLimit: 3 /// We only need 1-3 values, depending on whether the user wants to show delta or not
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let glucoseResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }

+ 101 - 60
Trio/Sources/Services/HealthKit/HealthKitManager.swift

@@ -55,12 +55,65 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
     @Injected() private var deviceDataManager: DeviceDataManager!
 
-    private var backgroundContext = CoreDataStack.shared.newTaskContext()
-
-    // Queue for handling Core Data change notifications
-    private let queue = DispatchQueue(label: "BaseHealthKitManager.queue", qos: .background)
-    private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
-    private var subscriptions = Set<AnyCancellable>()
+    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+
+    // MARK: - Upload triggers
+
+    //
+    // Each upload pipeline is driven by an NSFetchedResultsController whose predicate is the
+    // "not yet uploaded to Health" set. The controller fires whenever un-uploaded items appear
+    // (or drop out after a successful upload), which we use to (re-)trigger the matching upload.
+    // Bound to the viewContext, it also picks up batch-inserted glucose via the persistent history
+    // merge in CoreDataStack — replacing the previous changedObjects publisher plus the separate
+    // glucoseStorage.updatePublisher fallback.
+
+    let glucoseUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var glucoseUploadController: NSFetchedResultsController<GlucoseStored> = {
+        let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: true)]
+        request.predicate = NSPredicate.glucoseNotYetUploadedToHealth
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = glucoseUploadControllerDelegate
+        return controller
+    }()
+
+    let carbsUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var carbsUploadController: NSFetchedResultsController<CarbEntryStored> = {
+        let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: true)]
+        request.predicate = NSPredicate.carbsNotYetUploadedToHealth
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = carbsUploadControllerDelegate
+        return controller
+    }()
+
+    let insulinUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var insulinUploadController: NSFetchedResultsController<PumpEventStored> = {
+        let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: true)]
+        request.predicate = NSPredicate.pumpEventsNotYetUploadedToHealth
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = insulinUploadControllerDelegate
+        return controller
+    }()
 
     var isAvailableOnCurrentDevice: Bool {
         HKHealthStore.isHealthDataAvailable()
@@ -69,23 +122,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     init(resolver: Resolver) {
         injectServices(resolver)
 
-        coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
-                .receive(on: queue)
-                .share()
-                .eraseToAnyPublisher()
-
-        glucoseStorage.updatePublisher
-            .receive(on: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task {
-                    await self.uploadGlucose()
-                }
-            }
-            .store(in: &subscriptions)
-
-        registerHandlers()
+        registerUploadControllers()
 
         guard isAvailableOnCurrentDevice,
               AppleHealthConfig.healthBGObject != nil else { return }
@@ -93,31 +130,27 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
         debug(.service, "HealthKitManager did create")
     }
 
-    private func registerHandlers() {
-        coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadInsulin()
-            }
-        }.store(in: &subscriptions)
+    private func registerUploadControllers() {
+        glucoseUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadGlucose() }
+        }
+        carbsUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadCarbs() }
+        }
+        insulinUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadInsulin() }
+        }
 
-        coreDataPublisher?.filteredByEntityName("CarbEntryStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadCarbs()
-            }
-        }.store(in: &subscriptions)
-
-        // This works only for manual Glucose
-        coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadGlucose()
+        // performFetch must run on the viewContext's queue (main).
+        Task { @MainActor in
+            do {
+                try self.glucoseUploadController.performFetch()
+                try self.carbsUploadController.performFetch()
+                try self.insulinUploadController.performFetch()
+            } catch {
+                debug(.service, "\(DebuggingIdentifiers.failed) Failed to set up HealthKit upload controllers: \(error)")
             }
-        }.store(in: &subscriptions)
+        }
     }
 
     func checkWriteToHealthPermissions(objectTypeToHealthStore: HKObjectType) -> Bool {
@@ -214,19 +247,21 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     }
 
     private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateGlucoseAsUploaded"
+        await context.perform {
             let ids = glucose.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToHealth = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
@@ -335,19 +370,21 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     }
 
     private func updateCarbsAsUploaded(_ carbs: [CarbsEntry]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateCarbsAsUploaded"
+        await context.perform {
             let ids = carbs.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToHealth = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"
@@ -377,9 +414,11 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
               insulinEvents.isNotEmpty else { return }
 
         do {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "uploadInsulin"
             let fetchedInsulinEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: PumpEventStored.self,
-                onContext: backgroundContext,
+                onContext: context,
                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                     NSPredicate.pumpHistoryLast24h,
                     NSPredicate(format: "tempBasal != nil")
@@ -391,7 +430,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
 
             var insulinSamples: [HKQuantitySample] = []
 
-            try await backgroundContext.perform {
+            try await context.perform {
                 guard let existingTempBasalEntries = fetchedInsulinEntries as? [PumpEventStored] else {
                     throw CoreDataError.fetchError(function: #function, file: #file)
                 }
@@ -582,19 +621,21 @@ final class BaseHealthKitManager: HealthKitManager, Injectable {
     }
 
     private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateInsulinAsUploaded"
+        await context.perform {
             let ids = insulin.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToHealth = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToHealth: \(error.userInfo)"

+ 3 - 2
Trio/Sources/Services/IOB/IOBService.swift

@@ -34,12 +34,11 @@ final class BaseIOBService: IOBService, Injectable {
     private var subscriptions = Set<AnyCancellable>()
     private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
     private let queue = DispatchQueue(label: "BaseIOBService.queue", qos: .background)
-    private let context = CoreDataStack.shared.newTaskContext()
 
     init(resolver: Resolver) {
         injectServices(resolver)
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -64,6 +63,8 @@ final class BaseIOBService: IOBService, Injectable {
     private func fetchLatestDeterminationIOB() -> (iob: Decimal?, date: Date?) {
         var iob: Decimal?
         var date: Date?
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchLatestDeterminationIOB"
         context.performAndWait {
             let request = OrefDetermination.fetchRequest() as NSFetchRequest<OrefDetermination>
             request.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)]

+ 7 - 0
Trio/Sources/Services/LiveActivity/Data/DataManager.swift

@@ -6,6 +6,8 @@ import Foundation
 @available(iOS 16.2, *)
 extension LiveActivityManager {
     func fetchAndMapGlucose() async throws -> [GlucoseData] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndMapGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -27,6 +29,8 @@ extension LiveActivityManager {
 
     // TODO: extract logic or at least rename function appropiately
     func fetchAndMapDetermination() async throws -> DeterminationData? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndMapDetermination"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
             onContext: context,
@@ -143,6 +147,9 @@ extension LiveActivityManager {
         propertiesToFetch: [String],
         map: @escaping ([String: Any]) -> Output
     ) async throws -> Output? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndMapLatest"
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: type,
             onContext: context,

+ 1 - 4
Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

@@ -71,9 +71,6 @@ final class LiveActivityData: ObservableObject {
 
     private var data = LiveActivityData()
 
-    /// A Core Data task context.
-    let context = CoreDataStack.shared.newTaskContext()
-
     /// A dispatch queue for handling Core Data change notifications.
     private let queue = DispatchQueue(label: "LiveActivityBridge.queue", qos: .userInitiated)
     private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
@@ -84,7 +81,7 @@ final class LiveActivityData: ObservableObject {
     /// - Parameter resolver: The dependency injection resolver.
     init(resolver: Resolver) {
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()

+ 45 - 56
Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift

@@ -5,12 +5,10 @@ import Foundation
 extension BaseNightscoutManager {
     /// Call once from init. Hooks up:
     /// 1) external upload requests (NotificationCenter)
-    /// 2) Core Data change triggers → requests per upload pipeline
-    /// 3) Glucose storage updates → request glucose pipeline
+    /// 2) Core Data "not yet uploaded" triggers → requests per upload pipeline
     func wireSubscribers() {
         wireExternalUploadRequests()
-        wireCoreDataSubscribers()
-        wireGlucoseStorageSubscriber()
+        wireUploadControllers()
     }
 
     /// Listens for `.nightscoutUploadRequested`, converts userInfo pipelines to enums,
@@ -31,59 +29,50 @@ extension BaseNightscoutManager {
             .store(in: &subscriptions)
     }
 
-    /// Maps Core Data entity changes into upload pipeline requests. We rely on
-    /// per-pipeline throttle so rapid changes don’t spam Nightscout.
-    func wireCoreDataSubscribers() {
-        coreDataPublisher?
-            .filteredByEntityName("OrefDetermination")
-            .sink { [weak self] _ in self?.requestUpload(.deviceStatus) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("OverrideStored")
-            .sink { [weak self] _ in self?.requestUpload(.overrides) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("OverrideRunStored")
-            .sink { [weak self] _ in self?.requestUpload(.overrides) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("TempTargetStored")
-            .sink { [weak self] _ in self?.requestUpload(.tempTargets) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("TempTargetRunStored")
-            .sink { [weak self] _ in self?.requestUpload(.tempTargets) }
-            .store(in: &subscriptions)
+    /// Maps Core Data "not yet uploaded to Nightscout" sets to upload pipeline requests via
+    /// NSFetchedResultsControllers. Each controller fires when un-uploaded items appear (or drop
+    /// out after a successful upload). We rely on the per-pipeline throttle so rapid changes
+    /// don't spam Nightscout.
+    func wireUploadControllers() {
+        determinationUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.deviceStatus)
+        }
+        overrideUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.overrides)
+        }
+        overrideRunUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.overrides)
+        }
+        tempTargetUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.tempTargets)
+        }
+        tempTargetRunUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.tempTargets)
+        }
+        pumpEventUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.pumpHistory)
+        }
+        carbEntryUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.carbs)
+        }
+        glucoseUploadControllerDelegate.onContentChange = { [weak self] in
+            self?.requestUpload(.glucose)
+        }
 
-        coreDataPublisher?
-            .filteredByEntityName("PumpEventStored")
-            .sink { [weak self] _ in self?.requestUpload(.pumpHistory) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("CarbEntryStored")
-            .sink { [weak self] _ in self?.requestUpload(.carbs) }
-            .store(in: &subscriptions)
-
-        coreDataPublisher?
-            .filteredByEntityName("GlucoseStored")
-            .sink { [weak self] _ in
-                self?.requestUpload(.glucose)
+        // performFetch must run on the viewContext's queue (main).
+        Task { @MainActor in
+            do {
+                try self.determinationUploadController.performFetch()
+                try self.overrideUploadController.performFetch()
+                try self.overrideRunUploadController.performFetch()
+                try self.tempTargetUploadController.performFetch()
+                try self.tempTargetRunUploadController.performFetch()
+                try self.pumpEventUploadController.performFetch()
+                try self.carbEntryUploadController.performFetch()
+                try self.glucoseUploadController.performFetch()
+            } catch {
+                debug(.nightscout, "\(DebuggingIdentifiers.failed) Failed to set up Nightscout upload controllers: \(error)")
             }
-            .store(in: &subscriptions)
-    }
-
-    /// Glucose storage updates → request glucose pipeline
-    func wireGlucoseStorageSubscriber() {
-        glucoseStorage.updatePublisher
-            .receive(on: queue)
-            .sink { [weak self] _ in
-                self?.requestUpload(.glucose)
-            }
-            .store(in: &subscriptions)
+        }
     }
 }

+ 217 - 52
Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift

@@ -47,9 +47,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     // Queue where upload pipelines run.
     let uploadPipelineQueue = DispatchQueue(label: "NightscoutManager.uploadPipelines", qos: .utility)
 
-    // Background Core Data context for fetches used by upload tasks.
-    var backgroundContext = CoreDataStack.shared.newTaskContext()
-
     /// Throttle window (seconds) per upload pipeline. Any requests inside this window
     /// coalesce into a single upload run for that pipeline.
     let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
@@ -130,26 +127,174 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     private var lastEnactedDetermination: Determination?
     private var lastSuggestedDetermination: Determination?
 
-    // Queue for handling Core Data change notifications
-    let queue = DispatchQueue(label: "BaseNightscoutManager.queue", qos: .utility)
-
-    /// Emits changed Core Data object IDs from the app. We filter by entity names
-    /// and request upload pipelines based on what changed.
-    var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
-
     /// Bag for Combine subscriptions owned by this manager.
     var subscriptions = Set<AnyCancellable>()
 
+    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+
+    // MARK: - Upload triggers
+
+    //
+    // Each upload pipeline is driven by an NSFetchedResultsController whose predicate is the
+    // "not yet uploaded to Nightscout" set for that entity. The controller fires whenever
+    // un-uploaded items appear (or drop out after a successful upload), which we map to a
+    // `requestUpload(pipeline)` call (throttled per pipeline). Bound to the viewContext, they
+    // also pick up batch-inserted glucose via the persistent history merge in CoreDataStack —
+    // replacing the previous changedObjects publisher plus the glucoseStorage.updatePublisher
+    // fallback.
+
+    let determinationUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var determinationUploadController: NSFetchedResultsController<OrefDetermination> = {
+        let request = NSFetchRequest<OrefDetermination>(entityName: "OrefDetermination")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \OrefDetermination.deliverAt, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "deliverAt >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = determinationUploadControllerDelegate
+        return controller
+    }()
+
+    let overrideUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var overrideUploadController: NSFetchedResultsController<OverrideStored> = {
+        let request = NSFetchRequest<OverrideStored>(entityName: "OverrideStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \OverrideStored.date, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "date >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = overrideUploadControllerDelegate
+        return controller
+    }()
+
+    let overrideRunUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var overrideRunUploadController: NSFetchedResultsController<OverrideRunStored> = {
+        let request = NSFetchRequest<OverrideRunStored>(entityName: "OverrideRunStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \OverrideRunStored.startDate, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "startDate >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = overrideRunUploadControllerDelegate
+        return controller
+    }()
+
+    let tempTargetUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var tempTargetUploadController: NSFetchedResultsController<TempTargetStored> = {
+        let request = NSFetchRequest<TempTargetStored>(entityName: "TempTargetStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \TempTargetStored.date, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "date >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = tempTargetUploadControllerDelegate
+        return controller
+    }()
+
+    let tempTargetRunUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var tempTargetRunUploadController: NSFetchedResultsController<TempTargetRunStored> = {
+        let request = NSFetchRequest<TempTargetRunStored>(entityName: "TempTargetRunStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \TempTargetRunStored.startDate, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "startDate >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = tempTargetRunUploadControllerDelegate
+        return controller
+    }()
+
+    let pumpEventUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var pumpEventUploadController: NSFetchedResultsController<PumpEventStored> = {
+        let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: true)]
+        request.predicate = NSPredicate.pumpEventsNotYetUploadedToNightscout
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = pumpEventUploadControllerDelegate
+        return controller
+    }()
+
+    let carbEntryUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var carbEntryUploadController: NSFetchedResultsController<CarbEntryStored> = {
+        let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: true)]
+        request.predicate = NSPredicate(
+            format: "date >= %@ AND isUploadedToNS == %@",
+            Date.oneDayAgo as NSDate,
+            false as NSNumber
+        )
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = carbEntryUploadControllerDelegate
+        return controller
+    }()
+
+    let glucoseUploadControllerDelegate = FetchedResultsControllerDelegate()
+    lazy var glucoseUploadController: NSFetchedResultsController<GlucoseStored> = {
+        let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: true)]
+        request.predicate = NSPredicate.glucoseNotYetUploadedToNightscout
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = glucoseUploadControllerDelegate
+        return controller
+    }()
+
     init(resolver: Resolver) {
         injectServices(resolver)
         subscribe()
 
-        coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
-                .receive(on: queue)
-                .share()
-                .eraseToAnyPublisher()
-
         setupNotification()
 
         setupLanePipelines()
@@ -356,9 +501,11 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func fetchBattery() async -> Battery {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchBattery"
+        return await context.perform {
             do {
-                let results = try self.backgroundContext.fetch(OpenAPS_Battery.fetch(NSPredicate.predicateFor30MinAgo))
+                let results = try context.fetch(OpenAPS_Battery.fetch(NSPredicate.predicateFor30MinAgo))
                 if let last = results.first {
                     let percent: Int? = Int(last.percent)
                     let voltage: Decimal? = last.voltage as Decimal?
@@ -414,16 +561,18 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             return
         }
 
+        let tddContext = CoreDataStack.shared.newTaskContext()
+        tddContext.name = "uploadDeviceStatus.tdd"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TDDStored.self,
-            onContext: backgroundContext,
+            onContext: tddContext,
             predicate: NSPredicate.predicateFor30MinAgo,
             key: "date",
             ascending: false,
             fetchLimit: 1
         )
 
-        let tdd: Decimal? = await backgroundContext.perform {
+        let tdd: Decimal? = await tddContext.perform {
             (results as? [TDDStored])?.first?.total as? Decimal
         }
 
@@ -588,19 +737,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateOrefDeterminationAsUploaded(_ determination: [Determination]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateOrefDeterminationAsUploaded"
+        await context.perform {
             let ids = determination.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<OrefDetermination> = OrefDetermination.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -843,19 +994,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateGlucoseAsUploaded"
+        await context.perform {
             let ids = glucose.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -899,19 +1052,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updatePumpEventStoredsAsUploaded(_ treatments: [NightscoutTreatment]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updatePumpEventStoredsAsUploaded"
+        await context.perform {
             let ids = treatments.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -940,19 +1095,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateCarbsAsUploaded(_ treatments: [NightscoutTreatment]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateCarbsAsUploaded"
+        await context.perform {
             let ids = treatments.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -999,19 +1156,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateOverridesAsUploaded(_ overrides: [NightscoutExercise]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateOverridesAsUploaded"
+        await context.perform {
             let ids = overrides.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -1057,19 +1216,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateOverrideRunsAsUploaded(_ overrideRuns: [NightscoutExercise]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateOverrideRunsAsUploaded"
+        await context.perform {
             let ids = overrideRuns.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<OverrideRunStored> = OverrideRunStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
@@ -1098,19 +1259,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateTempTargetsAsUploaded(_ tempTargets: [NightscoutTreatment]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateTempTargetsAsUploaded"
+        await context.perform {
             let ids = tempTargets.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetStored: \(error.userInfo)"
@@ -1139,19 +1302,21 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     private func updateTempTargetRunsAsUploaded(_ tempTargetRuns: [NightscoutTreatment]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateTempTargetRunsAsUploaded"
+        await context.perform {
             let ids = tempTargetRuns.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<TempTargetRunStored> = TempTargetRunStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToNS = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetRunStored: \(error.userInfo)"

+ 173 - 136
Trio/Sources/Services/Network/TidepoolManager.swift

@@ -59,12 +59,65 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
         }
     }
 
-    private var backgroundContext = CoreDataStack.shared.newTaskContext()
-
-    // Queue for handling Core Data change notifications
-    private let queue = DispatchQueue(label: "BaseTidepoolManager.queue", qos: .background)
-    private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
-    private var subscriptions = Set<AnyCancellable>()
+    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+
+    // MARK: - Upload triggers
+
+    //
+    // Each upload pipeline is driven by an NSFetchedResultsController whose predicate is the
+    // "not yet uploaded to Tidepool" set. The controller fires whenever un-uploaded items appear
+    // (or drop out after a successful upload), which we use to (re-)trigger the matching upload.
+    // Bound to the viewContext, it also picks up batch-inserted glucose via the persistent history
+    // merge in CoreDataStack — replacing the previous changedObjects publisher plus the separate
+    // glucoseStorage.updatePublisher fallback.
+
+    let glucoseUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var glucoseUploadController: NSFetchedResultsController<GlucoseStored> = {
+        let request = NSFetchRequest<GlucoseStored>(entityName: "GlucoseStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: true)]
+        request.predicate = NSPredicate.glucoseNotYetUploadedToTidepool
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = glucoseUploadControllerDelegate
+        return controller
+    }()
+
+    let carbsUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var carbsUploadController: NSFetchedResultsController<CarbEntryStored> = {
+        let request = NSFetchRequest<CarbEntryStored>(entityName: "CarbEntryStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: true)]
+        request.predicate = NSPredicate.carbsNotYetUploadedToTidepool
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = carbsUploadControllerDelegate
+        return controller
+    }()
+
+    let insulinUploadControllerDelegate = FetchedResultsControllerDelegate()
+    private lazy var insulinUploadController: NSFetchedResultsController<PumpEventStored> = {
+        let request = NSFetchRequest<PumpEventStored>(entityName: "PumpEventStored")
+        request.sortDescriptors = [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: true)]
+        request.predicate = NSPredicate.pumpEventsNotYetUploadedToTidepool
+        request.fetchBatchSize = 50
+        let controller = NSFetchedResultsController(
+            fetchRequest: request,
+            managedObjectContext: viewContext,
+            sectionNameKeyPath: nil,
+            cacheName: nil
+        )
+        controller.delegate = insulinUploadControllerDelegate
+        return controller
+    }()
 
     @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue?
 
@@ -73,23 +126,33 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
         injectServices(resolver)
         loadTidepoolManager()
 
-        coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
-                .receive(on: queue)
-                .share()
-                .eraseToAnyPublisher()
+        registerUploadControllers()
+    }
 
-        glucoseStorage.updatePublisher
-            .receive(on: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task {
-                    await self.uploadGlucose()
-                }
+    private func registerUploadControllers() {
+        glucoseUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadGlucose() }
+        }
+        carbsUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadCarbs() }
+        }
+        insulinUploadControllerDelegate.onContentChange = { [weak self] in
+            Task { await self?.uploadInsulin() }
+        }
+
+        // performFetch must run on the viewContext's queue (main).
+        Task { @MainActor in
+            do {
+                try self.glucoseUploadController.performFetch()
+                try self.carbsUploadController.performFetch()
+                try self.insulinUploadController.performFetch()
+            } catch {
+                debug(.service, "\(DebuggingIdentifiers.failed) Failed to set up Tidepool upload controllers: \(error)")
             }
-            .store(in: &subscriptions)
+        }
 
-        registerHandlers()
+        broadcaster.register(SettingsObserver.self, observer: self)
+        broadcaster.register(PreferencesObserver.self, observer: self)
     }
 
     /// Loads the Tidepool service from saved state
@@ -128,38 +191,6 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
         return nil
     }
 
-    /// Registers handlers for Core Data changes
-    private func registerHandlers() {
-        coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadInsulin()
-            }
-        }.store(in: &subscriptions)
-
-        coreDataPublisher?.filteredByEntityName("CarbEntryStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadCarbs()
-            }
-        }.store(in: &subscriptions)
-
-        // This works only for manual Glucose
-        coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task { [weak self] in
-                guard let self = self else { return }
-                await self.uploadGlucose()
-            }
-        }.store(in: &subscriptions)
-
-        // Register for settings that aren't saved from a single editor screen
-        broadcaster.register(SettingsObserver.self, observer: self)
-        broadcaster.register(PreferencesObserver.self, observer: self)
-    }
-
     func sourceInfo() -> [String: Any]? {
         nil
     }
@@ -246,19 +277,21 @@ extension BaseTidepoolManager {
     }
 
     private func updateCarbsAsUploaded(_ carbs: [CarbsEntry]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateCarbsAsUploaded"
+        await context.perform {
             let ids = carbs.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToTidepool = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
@@ -316,10 +349,12 @@ extension BaseTidepoolManager {
         guard !events.isEmpty, let tidepoolService = self.tidepoolService else { return }
 
         do {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "uploadDose"
             // Fetch all temp basal entries from Core Data for the last 24 hours
             let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: PumpEventStored.self,
-                onContext: backgroundContext,
+                onContext: context,
                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                     NSPredicate.pumpHistoryLast24h,
                     NSPredicate(format: "tempBasal != nil")
@@ -330,7 +365,7 @@ extension BaseTidepoolManager {
             )
 
             // Ensure that the processing happens within the background context for thread safety
-            try await backgroundContext.perform {
+            try await context.perform {
                 guard let existingTempBasalEntries = results as? [PumpEventStored] else {
                     throw CoreDataError.fetchError(function: #function, file: #file)
                 }
@@ -432,19 +467,21 @@ extension BaseTidepoolManager {
     }
 
     private func updateInsulinAsUploaded(_ insulin: [PumpHistoryEvent]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateInsulinAsUploaded"
+        await context.perform {
             let ids = insulin.map(\.id) as NSArray
             let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToTidepool = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"
@@ -486,79 +523,77 @@ extension BaseTidepoolManager {
     ) -> [DoseEntry] {
         var insulinDoseEvents: [DoseEntry] = []
 
-        backgroundContext.performAndWait {
-            // Loop through the pump history events within the background context
-            guard let duration = event.duration, let amount = event.amount,
-                  let currentBasalRate = self.getCurrentBasalRate()
-            else {
-                return
-            }
-            let value = (Decimal(duration) / 60.0) * amount
-
-            // Find the corresponding temp basal entry in existingTempBasalEntries
-            if let matchingEntryIndex = existingTempBasalEntries.firstIndex(where: { $0.timestamp == event.timestamp }) {
-                // Check for a predecessor (the entry before the matching entry)
-                let predecessorIndex = matchingEntryIndex - 1
-                if predecessorIndex >= 0 {
-                    let predecessorEntry = existingTempBasalEntries[predecessorIndex]
-                    if let predecessorTimestamp = predecessorEntry.timestamp,
-                       let predecessorEntrySyncIdentifier = predecessorEntry.id
-                    {
-                        let predecessorEndDate = predecessorTimestamp
-                            .addingTimeInterval(TimeInterval(
-                                Int(predecessorEntry.tempBasal?.duration ?? 0) *
-                                    60
-                            )) // parse duration to minutes
-
-                        // If the predecessor's end date is later than the current event's start date, adjust it
-                        if predecessorEndDate > event.timestamp {
-                            let adjustedEndDate = event.timestamp
-                            let adjustedDuration = adjustedEndDate.timeIntervalSince(predecessorTimestamp)
-                            let adjustedDeliveredUnits = (adjustedDuration / 3600) *
-                                Double(truncating: predecessorEntry.tempBasal?.rate ?? 0)
-
-                            // Create updated predecessor dose entry
-                            let updatedPredecessorEntry = DoseEntry(
-                                type: .tempBasal,
-                                startDate: predecessorTimestamp,
-                                endDate: adjustedEndDate,
-                                value: adjustedDeliveredUnits,
-                                unit: .units,
-                                deliveredUnits: adjustedDeliveredUnits,
-                                syncIdentifier: predecessorEntrySyncIdentifier,
-                                insulinType: self.apsManager.pumpManager?.status.insulinType ?? nil,
-                                automatic: true,
-                                manuallyEntered: false,
-                                isMutable: false
-                            )
-                            // Add the updated predecessor entry to the result
-                            insulinDoseEvents.append(updatedPredecessorEntry)
-                        }
+        // Caller (uploadDose) already executes within context.perform, so we run directly here
+        guard let duration = event.duration, let amount = event.amount,
+              let currentBasalRate = getCurrentBasalRate()
+        else {
+            return insulinDoseEvents
+        }
+        let value = (Decimal(duration) / 60.0) * amount
+
+        // Find the corresponding temp basal entry in existingTempBasalEntries
+        if let matchingEntryIndex = existingTempBasalEntries.firstIndex(where: { $0.timestamp == event.timestamp }) {
+            // Check for a predecessor (the entry before the matching entry)
+            let predecessorIndex = matchingEntryIndex - 1
+            if predecessorIndex >= 0 {
+                let predecessorEntry = existingTempBasalEntries[predecessorIndex]
+                if let predecessorTimestamp = predecessorEntry.timestamp,
+                   let predecessorEntrySyncIdentifier = predecessorEntry.id
+                {
+                    let predecessorEndDate = predecessorTimestamp
+                        .addingTimeInterval(TimeInterval(
+                            Int(predecessorEntry.tempBasal?.duration ?? 0) *
+                                60
+                        )) // parse duration to minutes
+
+                    // If the predecessor's end date is later than the current event's start date, adjust it
+                    if predecessorEndDate > event.timestamp {
+                        let adjustedEndDate = event.timestamp
+                        let adjustedDuration = adjustedEndDate.timeIntervalSince(predecessorTimestamp)
+                        let adjustedDeliveredUnits = (adjustedDuration / 3600) *
+                            Double(truncating: predecessorEntry.tempBasal?.rate ?? 0)
+
+                        // Create updated predecessor dose entry
+                        let updatedPredecessorEntry = DoseEntry(
+                            type: .tempBasal,
+                            startDate: predecessorTimestamp,
+                            endDate: adjustedEndDate,
+                            value: adjustedDeliveredUnits,
+                            unit: .units,
+                            deliveredUnits: adjustedDeliveredUnits,
+                            syncIdentifier: predecessorEntrySyncIdentifier,
+                            insulinType: apsManager.pumpManager?.status.insulinType ?? nil,
+                            automatic: true,
+                            manuallyEntered: false,
+                            isMutable: false
+                        )
+                        // Add the updated predecessor entry to the result
+                        insulinDoseEvents.append(updatedPredecessorEntry)
                     }
                 }
-
-                // Create a new dose entry for the current event
-                let currentEndDate = event.timestamp.addingTimeInterval(TimeInterval(minutes: Double(duration)))
-                let newDoseEntry = DoseEntry(
-                    type: .tempBasal,
-                    startDate: event.timestamp,
-                    endDate: currentEndDate,
-                    value: Double(value),
-                    unit: .units,
-                    deliveredUnits: Double(value),
-                    syncIdentifier: event.id,
-                    scheduledBasalRate: HKQuantity(
-                        unit: .internationalUnitsPerHour,
-                        doubleValue: Double(currentBasalRate.rate)
-                    ),
-                    insulinType: self.apsManager.pumpManager?.status.insulinType ?? nil,
-                    automatic: true,
-                    manuallyEntered: false,
-                    isMutable: false
-                )
-                // Add the new event entry to the result
-                insulinDoseEvents.append(newDoseEntry)
             }
+
+            // Create a new dose entry for the current event
+            let currentEndDate = event.timestamp.addingTimeInterval(TimeInterval(minutes: Double(duration)))
+            let newDoseEntry = DoseEntry(
+                type: .tempBasal,
+                startDate: event.timestamp,
+                endDate: currentEndDate,
+                value: Double(value),
+                unit: .units,
+                deliveredUnits: Double(value),
+                syncIdentifier: event.id,
+                scheduledBasalRate: HKQuantity(
+                    unit: .internationalUnitsPerHour,
+                    doubleValue: Double(currentBasalRate.rate)
+                ),
+                insulinType: apsManager.pumpManager?.status.insulinType ?? nil,
+                automatic: true,
+                manuallyEntered: false,
+                isMutable: false
+            )
+            // Add the new event entry to the result
+            insulinDoseEvents.append(newDoseEntry)
         }
 
         return insulinDoseEvents
@@ -651,19 +686,21 @@ extension BaseTidepoolManager {
     }
 
     private func updateGlucoseAsUploaded(_ glucose: [StoredGlucoseSample]) async {
-        await backgroundContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "updateGlucoseAsUploaded"
+        await context.perform {
             let ids = glucose.map(\.syncIdentifier) as NSArray
             let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try context.fetch(fetchRequest)
                 for result in results {
                     result.isUploadedToTidepool = true
                 }
 
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard context.hasChanges else { return }
+                try context.save()
             } catch let error as NSError {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToTidepool: \(error.userInfo)"

+ 8 - 0
Trio/Sources/Services/Notifications/SwiftNotificationCenter/WeakObjectSet.swift

@@ -41,6 +41,9 @@ struct WeakObjectSet<T: AnyObject>: Sequence {
     }
 
     mutating func add(_ object: T) {
+        // Registration doubles as the reaping hook — dead boxes are removed nowhere
+        // else, and most observers never unregister explicitly.
+        compact()
         // prevent ObjectIdentifier be reused
         if contains(object) {
             remove(object)
@@ -48,6 +51,11 @@ struct WeakObjectSet<T: AnyObject>: Sequence {
         objects.insert(WeakObject(object))
     }
 
+    /// Drops entries whose weakly-held object has been deallocated.
+    mutating func compact() {
+        objects = objects.filter { $0.object != nil }
+    }
+
     mutating func add(_ objects: [T]) {
         objects.forEach { self.add($0) }
     }

+ 5 - 4
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -35,7 +35,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     private let notificationCenter = UNUserNotificationCenter.current()
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
 
     // Queue for handling Core Data change notifications
     private let queue = DispatchQueue(label: "BaseUserNotificationsManager.queue", qos: .userInitiated)
@@ -48,7 +47,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         injectServices(resolver)
 
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -130,16 +129,18 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     }
 
     private func fetchGlucoseIDs() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucoseIDs"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor20MinAgo,
             key: "date",
             ascending: false,
             fetchLimit: 3
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }

+ 22 - 13
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -41,7 +41,6 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
     typealias PumpEvent = PumpEventStored.EventType
 
-    let backgroundContext = CoreDataStack.shared.newTaskContext()
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
     init(resolver: Resolver) {
@@ -61,7 +60,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
         // Observer for OrefDetermination and adjustments
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -184,6 +183,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             return WatchState(date: Date())
         }
         do {
+            let context = CoreDataStack.shared.newTaskContext()
+            context.name = "setupWatchState"
+
             // Get NSManagedObjectIDs
             let glucoseIds = try await fetchGlucose()
             let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
@@ -194,15 +196,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
             // Get NSManagedObjects
             let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
-                .getNSManagedObject(with: glucoseIds, context: backgroundContext)
+                .getNSManagedObject(with: glucoseIds, context: context)
             let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
-                .getNSManagedObject(with: determinationIds, context: backgroundContext)
+                .getNSManagedObject(with: determinationIds, context: context)
             let overridePresetObjects: [OverrideStored] = try await CoreDataStack.shared
-                .getNSManagedObject(with: overridePresetIds, context: backgroundContext)
+                .getNSManagedObject(with: overridePresetIds, context: context)
             let tempTargetPresetObjects: [TempTargetStored] = try await CoreDataStack.shared
-                .getNSManagedObject(with: tempTargetPresetIds, context: backgroundContext)
+                .getNSManagedObject(with: tempTargetPresetIds, context: context)
 
-            return await backgroundContext.perform {
+            return await context.perform {
                 var watchState = WatchState(date: Date())
 
                 // Set lastLoopDate
@@ -374,15 +376,17 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     /// Fetches recent glucose readings from CoreData
     /// - Returns: Array of NSManagedObjectIDs for glucose readings
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.glucose,
             key: "date",
             ascending: false
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -394,16 +398,19 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     /// Fetches last pump event that is a non-external bolus from CoreData
     /// - Returns: NSManagedObjectIDs for last bolus
     func fetchLastBolus() async throws -> NSManagedObjectID? {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchLastBolus"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastPumpBolus,
             key: "timestamp",
             ascending: false,
-            fetchLimit: 1
+            fetchLimit: 1,
+            relationshipKeyPathsForPrefetching: ["bolus"]
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [PumpEventStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -640,13 +647,15 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     guard let self = self else { return }
 
                     do {
+                        let context = CoreDataStack.shared.newTaskContext()
+                        context.name = "requestBolusRecommendation"
                         // Fetch determination data
                         let determinationIds = try await determinationStorage.fetchLastDeterminationObjectID(
                             predicate: NSPredicate.predicateFor30MinAgoForDetermination
                         )
                         let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared.getNSManagedObject(
                             with: determinationIds,
-                            context: backgroundContext
+                            context: context
                         )
 
                         await MainActor.run {

+ 17 - 12
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -158,9 +158,6 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Additional local subscriptions (separate from `cancellables`) for CoreData events.
     private var subscriptions = Set<AnyCancellable>()
 
-    /// Represents the context for background tasks in CoreData.
-    let backgroundContext = CoreDataStack.shared.newTaskContext()
-
     /// Represents the main (view) context for CoreData, typically used on the main thread.
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
@@ -198,7 +195,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         broadcaster.register(SettingsObserver.self, observer: self)
 
         coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
+            CoreDataStack.shared.entityChangePublisher
                 .receive(on: queue)
                 .share()
                 .eraseToAnyPublisher()
@@ -456,16 +453,18 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// - Parameter limit: Maximum number of glucose entries to fetch (default: 2)
     /// - Returns: An array of `NSManagedObjectID`s for glucose readings.
     private func fetchGlucose(limit: Int = 2) async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchGlucose"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.glucose,
             key: "date",
             ascending: false,
             fetchLimit: limit
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -476,6 +475,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Fetches the most recent temporary basal rate from CoreData pump history.
     /// - Returns: An array containing the NSManagedObjectID of the latest temp basal event, if any.
     private func fetchTempBasals() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchTempBasals"
         let tempBasalPredicate = NSPredicate(format: "tempBasal != nil")
         let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
             NSPredicate.pumpHistoryLast24h,
@@ -484,14 +485,15 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: compoundPredicate,
             key: "timestamp",
             ascending: false,
-            fetchLimit: 1
+            fetchLimit: 1,
+            relationshipKeyPathsForPrefetching: ["tempBasal"]
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let pumpEvents = results as? [PumpEventStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -503,16 +505,18 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Returns them sorted newest first, allowing us to find both enacted and suggested determinations.
     /// - Returns: An array of `NSManagedObjectID`s for all determinations in the 30-minute window.
     private func fetchDeterminations30Min() async throws -> [NSManagedObjectID] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchDeterminations30Min"
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.predicateFor30MinAgoForDetermination,
             key: "deliverAt",
             ascending: false,
             fetchLimit: 0 // No limit - get all determinations in 30min window
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OrefDetermination] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -558,7 +562,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
         let previousWatchState = lastPreparedWatchState
 
         // Capture context locally for use in perform block
-        let context = backgroundContext
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "setupGarminWatchState"
 
         let watchStates = await context.perform {
             // Fetch Core Data objects inside perform block

+ 0 - 1
Trio/Sources/Shortcuts/BaseIntentsRequest.swift

@@ -21,7 +21,6 @@ import Swinject
 
     let resolver: Resolver
 
-    let coredataContext = CoreDataStack.shared.newTaskContext()
     let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
     override init() {

+ 15 - 6
Trio/Sources/Shortcuts/Override/OverridePresetsIntentRequest.swift

@@ -18,14 +18,17 @@ import UIKit
      - Throws: An error if fetching fails or Core Data operations fail.
      */
     func fetchAndProcessOverrides() async throws -> [OverridePreset] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndProcessOverrides"
+
         do {
             // Fetch all Override Presets via OverrideStorage
             let allOverridePresetsIDs = try await overrideStorage.fetchForOverridePresets()
 
             // Since we are fetching on a different background Thread we need to unpack the NSManagedObjectID on the correct Thread first
-            return try await coredataContext.perform {
+            return try await context.perform {
                 let overrideObjects = try allOverridePresetsIDs.compactMap { id in
-                    try self.coredataContext.existingObject(with: id) as? OverrideStored
+                    try context.existingObject(with: id) as? OverrideStored
                 }
 
                 return overrideObjects.map { object in
@@ -51,12 +54,15 @@ import UIKit
      - Throws: `overridePresetsError.noTempOverrideFound` if no presets are found.
      */
     func fetchIDs(_ uuid: [OverridePreset.ID]) async throws -> [OverridePreset] {
-        try await coredataContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchIDs"
+
+        return try await context.perform {
             let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", uuid)
 
             do {
-                let result = try self.coredataContext.fetch(fetchRequest)
+                let result = try context.fetch(fetchRequest)
 
                 if result.isEmpty {
                     debug(
@@ -87,12 +93,15 @@ import UIKit
      - Throws: `overridePresetsError.noTempOverrideFound` if the preset is not found.
      */
     private func fetchOverrideID(_ preset: OverridePreset) async throws -> NSManagedObjectID {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchOverrideID"
+
         let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
         fetchRequest.predicate = NSPredicate(format: "id == %@", preset.id)
         fetchRequest.fetchLimit = 1
 
-        return try await coredataContext.perform {
-            guard let objectID = try self.coredataContext.fetch(fetchRequest).first?.objectID else {
+        return try await context.perform {
+            guard let objectID = try context.fetch(fetchRequest).first?.objectID else {
                 debug(
                     .default,
                     "\(DebuggingIdentifiers.failed) No override found for preset: \(preset.name)"

+ 0 - 2
Trio/Sources/Shortcuts/State/StateIntentRequest.swift

@@ -55,8 +55,6 @@ struct StateBGQuery: EntityQuery {
 }
 
 final class StateIntentRequest: BaseIntentsRequest {
-    let moc = CoreDataStack.shared.newTaskContext()
-
     func getLastGlucose(onContext: NSManagedObjectContext) throws
         -> (dateGlucose: Date, glucose: String, trend: String, delta: String)
     {

+ 18 - 7
Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift

@@ -18,14 +18,17 @@ final class TempPresetsIntentRequest: BaseIntentsRequest {
     /// - Returns: An array of `TempPreset` objects.
     /// - Throws: An error if fetching or processing fails.
     func fetchAndProcessTempTargets() async throws -> [TempPreset] {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchAndProcessTempTargets"
+
         // Fetch all Temp Target Presets via TempTargetStorage
         let allTempTargetPresetsIDs = try await tempTargetsStorage.fetchForTempTargetPresets()
 
         // Perform the fetch and process on the Core Data context's thread
-        return try await coredataContext.perform {
+        return try await context.perform {
             // Fetch existing TempTargetStored objects based on their NSManagedObjectIDs
             let tempTargetObjects: [TempTargetStored] = allTempTargetPresetsIDs.compactMap { id in
-                guard let object = try? self.coredataContext.existingObject(with: id) as? TempTargetStored else {
+                guard let object = try? context.existingObject(with: id) as? TempTargetStored else {
                     debugPrint("\(#file) \(#function) Failed to fetch object for ID: \(id)")
                     return nil
                 }
@@ -52,12 +55,15 @@ final class TempPresetsIntentRequest: BaseIntentsRequest {
     /// - Parameter uuid: An array of preset IDs to fetch.
     /// - Returns: An array of `TempPreset` objects.
     func fetchIDs(_ uuid: [TempPreset.ID]) async -> [TempPreset] {
-        await coredataContext.perform {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchIDs"
+
+        return await context.perform {
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "id IN %@", uuid)
 
             do {
-                let result = try self.coredataContext.fetch(fetchRequest)
+                let result = try context.fetch(fetchRequest)
 
                 if result.isEmpty {
                     debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) No TempTargetStored found for ids: \(uuid)")
@@ -84,14 +90,19 @@ final class TempPresetsIntentRequest: BaseIntentsRequest {
     ///
     /// - Parameter preset: The `TempPreset` to find.
     /// - Returns: The `NSManagedObjectID` of the temp target if found, otherwise `nil`.
-    private func fetchTempTargetID(_ preset: TempPreset) async -> NSManagedObjectID? {
+    private func fetchTempTargetID(_ preset: TempPreset) async ->
+        NSManagedObjectID?
+    {
+        let context = CoreDataStack.shared.newTaskContext()
+        context.name = "fetchTempTargetID"
+
         let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
         fetchRequest.predicate = NSPredicate(format: "id == %@", preset.id.uuidString)
         fetchRequest.fetchLimit = 1
 
-        return await coredataContext.perform {
+        return await context.perform {
             do {
-                return try self.coredataContext.fetch(fetchRequest).first?.objectID
+                return try context.fetch(fetchRequest).first?.objectID
             } catch {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch Temp Target: \(error)"

+ 6 - 6
TrioTests/CoreDataTests/TestAssembly.swift

@@ -13,32 +13,32 @@ class TestAssembly: Assembly {
     func assemble(container: Container) {
         // Override PumpHistoryStorage registration for tests
         container.register(PumpHistoryStorage.self) { r in
-            BasePumpHistoryStorage(resolver: r, context: self.testContext)
+            BasePumpHistoryStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override DeterminationStorage registration for tests
         container.register(DeterminationStorage.self) { r in
-            BaseDeterminationStorage(resolver: r, context: self.testContext)
+            BaseDeterminationStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override CarbsStorage registration for tests
         container.register(CarbsStorage.self) { r in
-            BaseCarbsStorage(resolver: r, context: self.testContext)
+            BaseCarbsStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override GlucoseStorage registration for tests
         container.register(GlucoseStorage.self) { r in
-            BaseGlucoseStorage(resolver: r, context: self.testContext)
+            BaseGlucoseStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override TempTargetStorage registration for tests
         container.register(TempTargetsStorage.self) { r in
-            BaseTempTargetsStorage(resolver: r, context: self.testContext)
+            BaseTempTargetsStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
 
         // Override OverrideStorage registration for tests
         container.register(OverrideStorage.self) { r in
-            BaseOverrideStorage(resolver: r, context: self.testContext)
+            BaseOverrideStorage(resolver: r, contextProvider: { self.testContext })
         }.inObjectScope(.container)
     }
 }