Showing 2 changed files with 83 additions and 36 deletions
+24 -0
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -471,6 +471,27 @@ from a detached task. Automatic post-snapshot refresh should read the small
471 471
 timeline/status data directly from SQLite; full Core Data cache rebuild should
472 472
 remain explicit/manual until partial invalidation exists.
473 473
 
474
+### 2026-06-03 Core Data Cache Rebuild Crash Stack
475
+
476
+Source: user-provided LLDB backtrace after a fast crash. The stack still pointed
477
+to an app binary that called `CoreDataArchiveCacheStore.rebuild` from
478
+`DashboardViewModel.startArchiveCacheRefresh`, which was removed in `199d2ef`.
479
+However, the stack also exposed a real cache-store bug: rebuild inserted
480
+`NSManagedObject`s through `container.viewContext` while running on a Swift
481
+utility task.
482
+
483
+Crash location:
484
+
485
+- `CoreDataArchiveCacheStore.insertDailyAggregateRows`
486
+- `NSEntityDescription.insertNewObject`
487
+- `NSManagedObjectContext insertObject`
488
+- Core Data `__CFBasicHashAddValue` / `EXC_BAD_ACCESS`
489
+
490
+Conclusion: even though automatic post-snapshot rebuild has been removed, manual
491
+cache rebuild must also be safe. `CoreDataArchiveCacheStore.rebuild` and
492
+`deleteCache` should use a dedicated background context and perform all Core
493
+Data mutations on that context's queue.
494
+
474 495
 ## Optimization Iterations
475 496
 
476 497
 | Date | Commit | Change | Result / Status |
@@ -499,6 +520,7 @@ remain explicit/manual until partial invalidation exists.
499 520
 | 2026-06-03 | `7d52262` | Start Dashboard archive cache refresh without awaiting it after snapshot completion. | Triggered by continued app unresponsiveness after a successful 31.3s incremental snapshot. Expected signal: progress sheet/result UI remains responsive while cache rows refresh later. |
500 521
 | 2026-06-03 | `1229f19` | Disable legacy SwiftData detail-cache precompute completely and load Snapshots timeline from SQLite. | Triggered by overnight crash after two small detail caches were built. Expected signal: no `healthKit.detailCache.buildBegin` logs during snapshot save, no Core Data mutated-while-enumerated abort, and the new SQLite observation appears in Snapshots without waiting for cache rebuild. |
501 522
 | 2026-06-03 | `199d2ef` | Stop automatic Dashboard Core Data cache rebuild after snapshot; refresh latest rows from SQLite only. | Triggered by freeze after copying a successful diagnostic report. Expected signal: copying diagnostics and returning to Dashboard/Snapshots remains responsive; Core Data cache rebuild is no longer started automatically after snapshot completion. |
523
+| 2026-06-03 | pending | Run Core Data cache rebuild/delete on a dedicated background context. | Triggered by `EXC_BAD_ACCESS` inside Core Data object insertion during cache rebuild. Expected signal: manual Settings cache rebuild no longer crashes due to `NSManagedObjectContext` queue misuse. |
502 524
 
503 525
 ## Current Diagnosis
504 526
 
@@ -523,6 +545,8 @@ The likely bottleneck is per-row SQLite work:
523 545
   app starts a full rebuild immediately after the import. Even detached rebuilds
524 546
   can overwhelm real-device I/O/CPU, so automatic post-snapshot UI refresh should
525 547
   use SQLite summary rows only.
548
+- Core Data cache rebuild must not mutate `viewContext` from background Swift
549
+  tasks. Rebuild/delete should use a private background context.
526 550
 
527 551
 ## Open Issues / Observations
528 552
 
+59 -36
HealthProbe/Services/CoreDataArchiveCacheStore.swift
@@ -113,47 +113,52 @@ nonisolated final class CoreDataArchiveCacheStore {
113 113
     }
114 114
 
115 115
     func rebuild(fromArchiveAt archiveURL: URL) throws -> CoreDataArchiveCacheRebuildSummary {
116
-        let archive = try openArchive(at: archiveURL)
117
-        defer { sqlite3_close(archive) }
118
-
119
-        let archiveSchemaVersion = try archiveSchemaVersion(db: archive)
120
-        let integrityStatus = try firstText("PRAGMA integrity_check", db: archive) ?? "missing"
121
-        let computedAt = Date()
122
-        let context = container.viewContext
123
-
124
-        try resetCache(context: context)
125
-
126
-        let observationCount = try insertObservationRows(db: archive, context: context, archiveSchemaVersion: archiveSchemaVersion, computedAt: computedAt)
127
-        let typeSummaryCount = try insertTypeSummaryRows(db: archive, context: context, computedAt: computedAt)
128
-        let dailyAggregateCount = try insertDailyAggregateRows(db: archive, context: context, computedAt: computedAt)
129
-        let diffSummaryCount = try insertDiffSummaryRows(db: archive, context: context, computedAt: computedAt)
130
-        let exportManifestCount = try insertExportManifestRows(db: archive, context: context, computedAt: computedAt)
131
-        try insertArchiveHealthRow(
132
-            context: context,
133
-            archiveSchemaVersion: archiveSchemaVersion,
134
-            integrityStatus: integrityStatus,
135
-            computedAt: computedAt
136
-        )
116
+        let context = makeBackgroundContext()
117
+
118
+        return try performAndWait(in: context) {
119
+            let archive = try openArchive(at: archiveURL)
120
+            defer { sqlite3_close(archive) }
121
+
122
+            let archiveSchemaVersion = try archiveSchemaVersion(db: archive)
123
+            let integrityStatus = try firstText("PRAGMA integrity_check", db: archive) ?? "missing"
124
+            let computedAt = Date()
125
+
126
+            try resetCache(context: context)
127
+
128
+            let observationCount = try insertObservationRows(db: archive, context: context, archiveSchemaVersion: archiveSchemaVersion, computedAt: computedAt)
129
+            let typeSummaryCount = try insertTypeSummaryRows(db: archive, context: context, computedAt: computedAt)
130
+            let dailyAggregateCount = try insertDailyAggregateRows(db: archive, context: context, computedAt: computedAt)
131
+            let diffSummaryCount = try insertDiffSummaryRows(db: archive, context: context, computedAt: computedAt)
132
+            let exportManifestCount = try insertExportManifestRows(db: archive, context: context, computedAt: computedAt)
133
+            try insertArchiveHealthRow(
134
+                context: context,
135
+                archiveSchemaVersion: archiveSchemaVersion,
136
+                integrityStatus: integrityStatus,
137
+                computedAt: computedAt
138
+            )
137 139
 
138
-        if context.hasChanges {
139
-            try context.save()
140
-        }
140
+            if context.hasChanges {
141
+                try context.save()
142
+            }
141 143
 
142
-        return CoreDataArchiveCacheRebuildSummary(
143
-            observationRows: observationCount,
144
-            typeSummaryRows: typeSummaryCount,
145
-            dailyAggregateRows: dailyAggregateCount,
146
-            diffSummaryRows: diffSummaryCount,
147
-            exportManifestRows: exportManifestCount,
148
-            archiveHealthRows: 1
149
-        )
144
+            return CoreDataArchiveCacheRebuildSummary(
145
+                observationRows: observationCount,
146
+                typeSummaryRows: typeSummaryCount,
147
+                dailyAggregateRows: dailyAggregateCount,
148
+                diffSummaryRows: diffSummaryCount,
149
+                exportManifestRows: exportManifestCount,
150
+                archiveHealthRows: 1
151
+            )
152
+        }
150 153
     }
151 154
 
152 155
     func deleteCache() throws {
153
-        let context = container.viewContext
154
-        try resetCache(context: context)
155
-        if context.hasChanges {
156
-            try context.save()
156
+        let context = makeBackgroundContext()
157
+        try performAndWait(in: context) {
158
+            try resetCache(context: context)
159
+            if context.hasChanges {
160
+                try context.save()
161
+            }
157 162
         }
158 163
     }
159 164
 
@@ -217,6 +222,24 @@ nonisolated final class CoreDataArchiveCacheStore {
217 222
         return try container.viewContext.fetch(request).first.map(Self.archiveHealthStatus)
218 223
     }
219 224
 
225
+    private func makeBackgroundContext() -> NSManagedObjectContext {
226
+        let context = container.newBackgroundContext()
227
+        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
228
+        context.undoManager = nil
229
+        return context
230
+    }
231
+
232
+    private func performAndWait<T>(
233
+        in context: NSManagedObjectContext,
234
+        _ body: () throws -> T
235
+    ) throws -> T {
236
+        var result: Result<T, Error>?
237
+        context.performAndWait {
238
+            result = Result { try body() }
239
+        }
240
+        return try result!.get()
241
+    }
242
+
220 243
     private func resetCache(context: NSManagedObjectContext) throws {
221 244
         for entityName in Self.cacheEntityNames {
222 245
             let request = NSFetchRequest<NSManagedObject>(entityName: entityName)