@@ -397,6 +397,32 @@ crash-prone work is the legacy `TypeCount.detailCacheData` precompute after the |
||
| 397 | 397 |
snapshot save. Large type detail caches should be skipped and served from the |
| 398 | 398 |
SQLite/Core Data archive/cache path instead. |
| 399 | 399 |
|
| 400 |
+### 2026-06-03 Incremental Snapshot After Large Detail Cache Skip |
|
| 401 |
+ |
|
| 402 |
+Commit context: after `e49a79d`. Source: user-provided diagnostic report. The |
|
| 403 |
+user still observed app unresponsiveness after the operation completed. |
|
| 404 |
+ |
|
| 405 |
+| Metric | Previous | Current | Change | |
|
| 406 |
+|--------|----------|---------|--------| |
|
| 407 |
+| Total records | 1,579,243 | 1,579,253 | +10 | |
|
| 408 |
+| Wall clock | 44.7s | 31.3s | -13.4s | |
|
| 409 |
+| Summed metric total | 26.7s | 30.6s | +3.9s | |
|
| 410 |
+| Summed fetch | 0.2s | 0.2s | flat | |
|
| 411 |
+| Summed processing | 18.4s | 18.1s | -0.3s | |
|
| 412 |
+| Summed insert | 0.0s | 0.0s | flat | |
|
| 413 |
+| Summed finalize | 7.4s | 11.5s | +4.1s | |
|
| 414 |
+| Heart Rate processing | 13.5s | 13.1s | -0.4s | |
|
| 415 |
+| Heart Rate finalize | 4.8s | 8.8s | +4.0s | |
|
| 416 |
+| Active Energy processing | 4.9s | 4.7s | -0.2s | |
|
| 417 |
+| Active Energy finalize | 1.8s | 1.9s | +0.1s | |
|
| 418 |
+ |
|
| 419 |
+Conclusion: the large legacy detail-cache skip removed the earlier crash signal |
|
| 420 |
+and reduced wall clock, but the app can still become unresponsive after the |
|
| 421 |
+snapshot reports success. The remaining suspect is the Dashboard Core Data cache |
|
| 422 |
+refresh because the view model still awaited cache rebuild before releasing the |
|
| 423 |
+post-snapshot UI path. That refresh should run fire-and-forget and publish its |
|
| 424 |
+result back to the main actor only when complete. |
|
| 425 |
+ |
|
| 400 | 426 |
## Optimization Iterations |
| 401 | 427 |
|
| 402 | 428 |
| Date | Commit | Change | Result / Status | |
@@ -422,6 +448,7 @@ SQLite/Core Data archive/cache path instead. |
||
| 422 | 448 |
| 2026-06-03 | `f60f09a` | Fast-path unchanged samples whose current version already has an open visibility range. | Confirmed on repeated no-delta capture: `SummedInsertElapsed` remained 0.0s; remaining cost is `SummedFinalizeElapsed` 12.9s, with Heart Rate finalize 9.1s. | |
| 423 | 449 |
| 2026-06-03 | `19ba656` | Copy previous type summaries and daily aggregates for unchanged metric observations instead of rebuilding from visible ranges. | First follow-up was not no-delta (+71 records), so it is inconclusive for the unchanged path. It did show `SummedInsertElapsed` 0.1s and a new changed-metric processing bottleneck: Heart Rate processing 13.9s, Active Energy processing 5.0s. | |
| 424 | 450 |
| 2026-06-03 | `e49a79d` | Skip legacy SwiftData detail-cache precompute for large type archives. | Triggered by a crash after building Heart Rate and Active Energy detail caches. Expected signal: no post-import `NSGenericException`, lower post-import wall-clock gap, and `healthKit.detailCache.skipLargeLegacyArchive` logs for high-volume types. | |
| 451 |
+| 2026-06-03 | pending | 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. | |
|
| 425 | 452 |
|
| 426 | 453 |
## Current Diagnosis |
| 427 | 454 |
|
@@ -442,6 +469,8 @@ The likely bottleneck is per-row SQLite work: |
||
| 442 | 469 |
- legacy SwiftData detail-cache precompute after SQLite completes. This became |
| 443 | 470 |
the active crash/performance issue when Heart Rate and Active Energy detail |
| 444 | 471 |
caches scanned large archives and Core Data aborted during change processing. |
| 472 |
+- Dashboard Core Data archive cache refresh after snapshot completion, when the |
|
| 473 |
+ UI path awaits the rebuild instead of publishing results asynchronously. |
|
| 445 | 474 |
|
| 446 | 475 |
## Open Issues / Observations |
| 447 | 476 |
|
@@ -459,9 +488,9 @@ The likely bottleneck is per-row SQLite work: |
||
| 459 | 488 |
|
| 460 | 489 |
Prioritize experiments in this order: |
| 461 | 490 |
|
| 462 |
-1. Run an incremental snapshot after skipping large legacy detail caches. Confirm |
|
| 463 |
- that Heart Rate / Active Energy emit `healthKit.detailCache.skipLargeLegacyArchive` |
|
| 464 |
- and that the app does not crash or freeze after import. |
|
| 491 |
+1. Run an incremental snapshot after the fire-and-forget Dashboard cache refresh. |
|
| 492 |
+ Confirm that the progress/result UI remains responsive immediately after |
|
| 493 |
+ `complete_success`, even if dashboard archive rows update a little later. |
|
| 465 | 494 |
2. Run a repeated no-delta benchmark after copying unchanged metric summaries and daily aggregates. Compare `SummedFinalizeElapsed`, `Heart Rate finalizeElapsed`, `Active Energy finalizeElapsed`, and wall clock. |
| 466 | 495 |
3. Add or inspect timing around per-record processing for changed high-volume metrics, especially Heart Rate, to separate sample DTO/fingerprint work from SQLite idempotency checks. |
| 467 | 496 |
4. Run a non-chain-start/full-scan benchmark after skipping unchanged `verified` events and fast-pathing already-open visibility ranges. Compare `SummedInsertElapsed`, `Heart Rate insertElapsed`, `Steps insertElapsed`, and `Walking + Running Distance insertElapsed`. |
@@ -30,6 +30,7 @@ final class DashboardViewModel {
|
||
| 30 | 30 |
private let healthKit = HealthKitService.shared |
| 31 | 31 |
private var pendingPartialSnapshotID: UUID? |
| 32 | 32 |
private var pendingAmbiguousSnapshotID: UUID? |
| 33 |
+ private var archiveCacheRefreshTask: Task<Void, Never>? |
|
| 33 | 34 |
|
| 34 | 35 |
private struct ArchiveCacheRefreshResult: Sendable {
|
| 35 | 36 |
let observationRows: [CachedArchiveObservationRow] |
@@ -191,7 +192,7 @@ final class DashboardViewModel {
|
||
| 191 | 192 |
} |
| 192 | 193 |
|
| 193 | 194 |
snapshotProgress = .complete |
| 194 |
- await refreshArchiveCache() |
|
| 195 |
+ startArchiveCacheRefresh() |
|
| 195 | 196 |
} catch is CancellationError {
|
| 196 | 197 |
snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available." |
| 197 | 198 |
snapshotProgress = .idle |
@@ -285,7 +286,7 @@ final class DashboardViewModel {
|
||
| 285 | 286 |
monitoredTypeSetHash: outcome.monitoredTypeSetHash, |
| 286 | 287 |
monitoredRegistryVersion: outcome.monitoredRegistryVersion |
| 287 | 288 |
) |
| 288 |
- await refreshArchiveCache() |
|
| 289 |
+ startArchiveCacheRefresh() |
|
| 289 | 290 |
} catch {
|
| 290 | 291 |
snapshotError = "Failed to save partial snapshot: \(error.localizedDescription)" |
| 291 | 292 |
showProgressSheet = true |
@@ -368,7 +369,7 @@ final class DashboardViewModel {
|
||
| 368 | 369 |
fetchProgress = nil |
| 369 | 370 |
showProgressSheet = false |
| 370 | 371 |
snapshotProgress = .idle |
| 371 |
- await refreshArchiveCache() |
|
| 372 |
+ startArchiveCacheRefresh() |
|
| 372 | 373 |
} |
| 373 | 374 |
|
| 374 | 375 |
private func applyOutcome(_ outcome: LegacySnapshotOutcome) {
|
@@ -379,28 +380,45 @@ final class DashboardViewModel {
|
||
| 379 | 380 |
completedSnapshotRetryOfSnapshotID = outcome.retryOfSnapshotID |
| 380 | 381 |
} |
| 381 | 382 |
|
| 382 |
- private func refreshArchiveCache() async {
|
|
| 383 |
- do {
|
|
| 384 |
- let result = try await Task.detached(priority: .utility) {
|
|
| 383 |
+ private func startArchiveCacheRefresh() {
|
|
| 384 |
+ archiveCacheRefreshTask?.cancel() |
|
| 385 |
+ archiveCacheRefreshTask = Task.detached(priority: .utility) { [weak self] in
|
|
| 386 |
+ do {
|
|
| 385 | 387 |
let cache = try CoreDataArchiveCacheStore() |
| 386 | 388 |
_ = try cache.rebuild(fromArchiveAt: SQLiteHealthArchiveStore.defaultDatabaseURL) |
| 387 | 389 |
let rows = try cache.observationRows(limit: 2) |
| 388 | 390 |
let healthStatus = try cache.latestArchiveHealthStatus() |
| 389 |
- return ArchiveCacheRefreshResult( |
|
| 391 |
+ let result = ArchiveCacheRefreshResult( |
|
| 390 | 392 |
observationRows: rows, |
| 391 | 393 |
healthStatus: healthStatus |
| 392 | 394 |
) |
| 393 |
- }.value |
|
| 394 |
- archiveObservationRows = result.observationRows |
|
| 395 |
- latestArchiveObservation = result.observationRows.first |
|
| 396 |
- archiveHealthStatus = result.healthStatus |
|
| 397 |
- archiveCacheError = nil |
|
| 398 |
- } catch {
|
|
| 399 |
- archiveObservationRows = [] |
|
| 400 |
- latestArchiveObservation = nil |
|
| 401 |
- archiveCacheError = error.localizedDescription |
|
| 395 |
+ |
|
| 396 |
+ guard !Task.isCancelled else { return }
|
|
| 397 |
+ guard let self else { return }
|
|
| 398 |
+ await self.applyArchiveCacheRefreshResult(result) |
|
| 399 |
+ } catch {
|
|
| 400 |
+ guard !Task.isCancelled else { return }
|
|
| 401 |
+ guard let self else { return }
|
|
| 402 |
+ await self.applyArchiveCacheRefreshError(error.localizedDescription) |
|
| 403 |
+ } |
|
| 402 | 404 |
} |
| 403 | 405 |
} |
| 406 |
+ |
|
| 407 |
+ @MainActor |
|
| 408 |
+ private func applyArchiveCacheRefreshResult(_ result: ArchiveCacheRefreshResult) {
|
|
| 409 |
+ archiveObservationRows = result.observationRows |
|
| 410 |
+ latestArchiveObservation = result.observationRows.first |
|
| 411 |
+ archiveHealthStatus = result.healthStatus |
|
| 412 |
+ archiveCacheError = nil |
|
| 413 |
+ } |
|
| 414 |
+ |
|
| 415 |
+ @MainActor |
|
| 416 |
+ private func applyArchiveCacheRefreshError(_ message: String) {
|
|
| 417 |
+ archiveObservationRows = [] |
|
| 418 |
+ latestArchiveObservation = nil |
|
| 419 |
+ archiveHealthStatus = nil |
|
| 420 |
+ archiveCacheError = message |
|
| 421 |
+ } |
|
| 404 | 422 |
} |
| 405 | 423 |
|
| 406 | 424 |
enum SnapshotProgress {
|