Showing 2 changed files with 66 additions and 19 deletions
+32 -3
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -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`.
+34 -16
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -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 {