@@ -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 |
|
@@ -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) |