Showing 6 changed files with 157 additions and 28 deletions
+25 -6
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -591,7 +591,9 @@ rows exist".
591 591
 | 2026-06-04 | `d4de48c` | Copy unchanged daily aggregates inside SQLite. | First small-delta run before this commit completed in `23.6s` with `127/127` complete, `0` degraded, `CaptureModes: unchangedDelta=120, delta=7`, and `DeltaEvents: 11`. The hash change helped processing (`9.6s -> 8.1s`; Heart Rate `5.7s -> 4.2s`), but finalization stayed high (`11.4s`, Heart Rate `4.8s`). Changed daily aggregates were copying all previous daily rows through Swift before replacing affected buckets. This commit moved unchanged daily aggregate copying to SQLite `INSERT ... SELECT`, while affected buckets remain recalculated. Follow-up report completed in `21.1s` with `127/127` complete, `0` degraded, `CaptureModes: unchangedDelta=123, delta=4`, and `DeltaEvents: 50`. `SummedFinalizeElapsed` improved `11.4s -> 9.3s` and wall clock improved `23.6s -> 21.1s`; however Heart Rate finalize was still `5.0s` with `4` events, so this helped overall finalize cost but did not remove the high-volume changed-type floor. |
592 592
 | 2026-06-04 | `c9091de` | Include build identity in diagnostic reports. | The latest diagnostic report only included `App Version: 1.0(1)` near the end and did not include a commit/build source identifier. This makes it too easy to compare reports from the wrong installed binary. Diagnostics now emit `appVersion`, `buildFingerprint`, `sourceCommit`, and `sourceDirty` in `OPERATION METADATA`. `buildFingerprint` is derived from the installed executable and should change when a different binary is installed; `sourceCommit/sourceDirty` remain available for builds that inject those Info.plist keys. Expected signal: future pasted reports have enough build identity to detect wrong-version reports immediately. |
593 593
 | 2026-06-04 | `7e3b997` | Avoid summary full scans when delta safely replaces extremes. | Follow-up after `d4de48c` still showed `SummedFinalizeElapsed: 9.3s`, with Heart Rate finalize at `5.0s` despite only `4` Heart Rate delta events. `markVerification` already tries to update type summaries incrementally, but it fell back to a full visible-row aggregate scan whenever a removed row matched the previous earliest/latest/max, even if the same delta added a row that safely preserved or extended that extreme. The fallback is now narrower: full scan is still used when an extreme becomes unknown, but not when added rows replace the removed earliest/latest/max with an equivalent or stronger value. Follow-up report with `reportSchemaVersion: 3` and `buildFingerprint: 1.0(1)-1780603665-92064` completed in `21.0s`, with `127/127` complete, `CaptureModes: unchangedDelta=121, delta=6`, and `DeltaEvents: 20`. This was effectively flat versus the `d4de48c` report: wall `21.1s -> 21.0s`, processing `7.8s -> 7.4s`, finalize `9.3s -> 9.4s`, and Heart Rate finalize `5.0s -> 4.8s` while Heart Rate had `7` delta events. Conclusion: this optimization did not materially move the bottleneck; either the safe-extreme case did not trigger or type-summary fallback is no longer the dominant finalize cost. |
594
-| 2026-06-04 | pending | Add archive finalization phase timings to diagnostics. | The post-`7e3b997` report proves the top-level `finalizeElapsed` bucket is too opaque for the next optimization. Diagnostics now split finalization into event-count/previous-summary lookup, type-summary work, daily-aggregate work, observation-type-run update, and residual other time. Expected signal: the next report should show whether Heart Rate's `~4.8s` finalize cost is mostly daily aggregate replacement, type summary work, event lookup, or unaccounted SQLite/transaction overhead. Use that result before attempting another finalize optimization. |
594
+| 2026-06-04 | `f73f076` | Add archive finalization phase timings to diagnostics. | The post-`7e3b997` report proved the top-level `finalizeElapsed` bucket was too opaque for the next optimization. Diagnostics now split finalization into event-count/previous-summary lookup, type-summary work, daily-aggregate work, observation-type-run update, and residual other time. Follow-up report with `reportSchemaVersion: 3` and `buildFingerprint: 1.0(1)-1780606903-92064` completed in `22.6s`, with `127/127` complete, `CaptureModes: unchangedDelta=119, delta=8`, and `DeltaEvents: 27`. Finalization was `10.3s`: event-count/previous-summary lookup `1.8s`, type-summary `0.0s`, daily aggregates `7.3s`, run update `0.0s`, and other `1.2s`. Heart Rate had `9` delta events and spent `4.8s` finalizing, of which `3.8s` was daily aggregate work and `0.9s` was event-count/previous-summary lookup. Conclusion: the remaining finalize bottleneck is not type-summary fallback; it is changed-type daily aggregate maintenance, especially Heart Rate. |
595
+| 2026-06-04 | older build / schema v2 | Captured large first-import baseline on a bigger device database. | Initial full-profile snapshot on an older build completed with `127/127` metrics and `8,421,978` records, but it used `reportSchemaVersion: 2` and has no build fingerprint. Treat it as a volume/shape baseline, not a precise current-build comparison. Wall clock was `166m10s`; summed fetch `5m19s`, processing `20m29s`, insert `137m31s`, finalize `1m53s`. The high-volume types dominated: Heart Rate `2,225,738` records and `46m57s` total (`39m16s` insert), Active Energy `1,914,449` records and `41m35s` total (`35m21s` insert), another high-volume type around `2,007,920` records and `41m20s` total (`34m29s` insert), and Basal Energy `1,116,074` records and `21m37s` total (`17m48s` insert). Conclusion: for clean first imports on very large databases, SQLite insert/index/write-path cost remains the central risk; incremental daily-aggregate optimization should not add first-import indexes without measurement. |
596
+| 2026-06-05 | pending | Split daily aggregate finalization timings. | The first finalization phase report identified daily aggregate work as the remaining changed-type bottleneck, but `finalizeDailyAggregateElapsed` still mixed affected-bucket lookup, previous aggregate copy, destination delete, affected-bucket rebuild, replacement insert, and residual SQL/transaction overhead. Diagnostics now emit aggregate and per-type daily subphase fields: bucket lookup, copy, delete, rebuild, insert, and other. Expected signal: the next repeated full-profile report should say whether Heart Rate's `~3.8s` daily aggregate cost is mostly copying previous materialized rows or rebuilding affected buckets from visible samples. Do not add `sample_versions`/visibility date indexes until this split shows rebuild is dominant, because the `8.4M`-record first-import baseline shows insert/index overhead is already the large-database risk. |
595 597
 
596 598
 ## Current Diagnosis
597 599
 
@@ -700,6 +702,19 @@ The likely bottleneck is per-row SQLite work:
700 702
   inside finalization without phase timings; the diagnostic report now needs to
701 703
   show whether the remaining time is type-summary, daily-aggregate,
702 704
   event-count, run-update, or other overhead.
705
+- Finalization phase timings identified the current small-delta bottleneck:
706
+  type-summary work is effectively gone, while daily aggregate maintenance is
707
+  `7.3s` of `10.3s` finalize on the latest full-profile repeated capture. Heart
708
+  Rate spent `3.8s` in daily aggregates for only `9` delta events. The changed
709
+  daily aggregate path still copies previous materialized rows for the type and
710
+  then rebuilds affected buckets; distinguish copy cost from affected-bucket
711
+  rebuild cost before adding new SQLite indexes, because first-import reports
712
+  show insert/index overhead is already the dominant large-database risk.
713
+- A large older-build first import on an `8.4M`-record database completed but
714
+  took `166m10s`, with `137m31s` summed insert time. This confirms that full
715
+  authorized backup volume can be much larger than the original 15-type test
716
+  subset and that first-import write-path performance must remain a separate
717
+  acceptance track from repeated incremental capture performance.
703 718
 
704 719
 ## Open Issues / Observations
705 720
 
@@ -758,11 +773,15 @@ Prioritize experiments in this order:
758 773
    identity unless the build provenance is otherwise certain. `sourceCommit`
759 774
    and `sourceDirty` are useful when present, but may be `unknown` for normal
760 775
    Xcode test installs.
761
-8. Run a repeated full-profile capture with finalization phase timings. Compare
762
-   `SummedFinalizeTypeSummaryElapsed`, `SummedFinalizeDailyAggregateElapsed`,
763
-   `SummedFinalizeEventCountElapsed`, `SummedFinalizeRunUpdateElapsed`, and
764
-   `SummedFinalizeOtherElapsed`, plus the same per-metric fields for Heart Rate.
765
-   This determines the next real finalize optimization target.
776
+8. Run a repeated full-profile capture with daily aggregate subphase timings.
777
+   The current known target is Heart Rate small deltas:
778
+   `finalizeDailyAggregateElapsed` was `3.8s` for `9` events. Compare
779
+   `finalizeDailyAggregateCopyElapsed`,
780
+   `finalizeDailyAggregateRebuildElapsed`,
781
+   `finalizeDailyAggregateBucketLookupElapsed`,
782
+   `finalizeDailyAggregateInsertElapsed`, and
783
+   `finalizeDailyAggregateOtherElapsed` before adding indexes that could slow
784
+   first import.
766 785
 9. Investigate replacing legacy compact `recordArchiveData` delta rebuild with
767 786
    a SQLite-derived capture-state/hash path. The current repeated full-profile
768 787
    reports still spend about `4s` processing Heart Rate for tiny deltas because
+18 -0
HealthProbe/Services/HealthKitService.swift
@@ -2967,6 +2967,12 @@ private struct DistributionCaptureTimings: Sendable, Equatable {
2967 2967
     var finalizeEventCountElapsedSeconds: TimeInterval = 0
2968 2968
     var finalizeTypeSummaryElapsedSeconds: TimeInterval = 0
2969 2969
     var finalizeDailyAggregateElapsedSeconds: TimeInterval = 0
2970
+    var finalizeDailyAggregateBucketLookupElapsedSeconds: TimeInterval = 0
2971
+    var finalizeDailyAggregateCopyElapsedSeconds: TimeInterval = 0
2972
+    var finalizeDailyAggregateDeleteElapsedSeconds: TimeInterval = 0
2973
+    var finalizeDailyAggregateRebuildElapsedSeconds: TimeInterval = 0
2974
+    var finalizeDailyAggregateInsertElapsedSeconds: TimeInterval = 0
2975
+    var finalizeDailyAggregateOtherElapsedSeconds: TimeInterval = 0
2970 2976
     var finalizeRunUpdateElapsedSeconds: TimeInterval = 0
2971 2977
     var finalizeOtherElapsedSeconds: TimeInterval = 0
2972 2978
 
@@ -2977,6 +2983,12 @@ private struct DistributionCaptureTimings: Sendable, Equatable {
2977 2983
         finalizeEventCountElapsedSeconds += breakdown.eventCountElapsedSeconds
2978 2984
         finalizeTypeSummaryElapsedSeconds += breakdown.typeSummaryElapsedSeconds
2979 2985
         finalizeDailyAggregateElapsedSeconds += breakdown.dailyAggregateElapsedSeconds
2986
+        finalizeDailyAggregateBucketLookupElapsedSeconds += breakdown.dailyAggregateBucketLookupElapsedSeconds
2987
+        finalizeDailyAggregateCopyElapsedSeconds += breakdown.dailyAggregateCopyElapsedSeconds
2988
+        finalizeDailyAggregateDeleteElapsedSeconds += breakdown.dailyAggregateDeleteElapsedSeconds
2989
+        finalizeDailyAggregateRebuildElapsedSeconds += breakdown.dailyAggregateRebuildElapsedSeconds
2990
+        finalizeDailyAggregateInsertElapsedSeconds += breakdown.dailyAggregateInsertElapsedSeconds
2991
+        finalizeDailyAggregateOtherElapsedSeconds += breakdown.dailyAggregateOtherElapsedSeconds
2980 2992
         finalizeRunUpdateElapsedSeconds += breakdown.runUpdateElapsedSeconds
2981 2993
         finalizeOtherElapsedSeconds += breakdown.otherElapsedSeconds
2982 2994
     }
@@ -2990,6 +3002,12 @@ private struct DistributionCaptureTimings: Sendable, Equatable {
2990 3002
             finalizeEventCountElapsedSeconds: finalizeEventCountElapsedSeconds,
2991 3003
             finalizeTypeSummaryElapsedSeconds: finalizeTypeSummaryElapsedSeconds,
2992 3004
             finalizeDailyAggregateElapsedSeconds: finalizeDailyAggregateElapsedSeconds,
3005
+            finalizeDailyAggregateBucketLookupElapsedSeconds: finalizeDailyAggregateBucketLookupElapsedSeconds,
3006
+            finalizeDailyAggregateCopyElapsedSeconds: finalizeDailyAggregateCopyElapsedSeconds,
3007
+            finalizeDailyAggregateDeleteElapsedSeconds: finalizeDailyAggregateDeleteElapsedSeconds,
3008
+            finalizeDailyAggregateRebuildElapsedSeconds: finalizeDailyAggregateRebuildElapsedSeconds,
3009
+            finalizeDailyAggregateInsertElapsedSeconds: finalizeDailyAggregateInsertElapsedSeconds,
3010
+            finalizeDailyAggregateOtherElapsedSeconds: finalizeDailyAggregateOtherElapsedSeconds,
2993 3011
             finalizeRunUpdateElapsedSeconds: finalizeRunUpdateElapsedSeconds,
2994 3012
             finalizeOtherElapsedSeconds: finalizeOtherElapsedSeconds
2995 3013
         )
+25 -0
HealthProbe/Services/Protocols/HealthArchiveStore.swift
@@ -36,11 +36,36 @@ struct HealthArchiveFinalizationBreakdown: Equatable, Sendable {
36 36
     var eventCountElapsedSeconds: TimeInterval = 0
37 37
     var typeSummaryElapsedSeconds: TimeInterval = 0
38 38
     var dailyAggregateElapsedSeconds: TimeInterval = 0
39
+    var dailyAggregateBucketLookupElapsedSeconds: TimeInterval = 0
40
+    var dailyAggregateCopyElapsedSeconds: TimeInterval = 0
41
+    var dailyAggregateDeleteElapsedSeconds: TimeInterval = 0
42
+    var dailyAggregateRebuildElapsedSeconds: TimeInterval = 0
43
+    var dailyAggregateInsertElapsedSeconds: TimeInterval = 0
39 44
     var runUpdateElapsedSeconds: TimeInterval = 0
40 45
     var totalElapsedSeconds: TimeInterval = 0
41 46
 
42 47
     static let zero = HealthArchiveFinalizationBreakdown()
43 48
 
49
+    mutating func addDailyAggregateBreakdown(_ breakdown: HealthArchiveFinalizationBreakdown) {
50
+        dailyAggregateBucketLookupElapsedSeconds += breakdown.dailyAggregateBucketLookupElapsedSeconds
51
+        dailyAggregateCopyElapsedSeconds += breakdown.dailyAggregateCopyElapsedSeconds
52
+        dailyAggregateDeleteElapsedSeconds += breakdown.dailyAggregateDeleteElapsedSeconds
53
+        dailyAggregateRebuildElapsedSeconds += breakdown.dailyAggregateRebuildElapsedSeconds
54
+        dailyAggregateInsertElapsedSeconds += breakdown.dailyAggregateInsertElapsedSeconds
55
+    }
56
+
57
+    var dailyAggregateAccountedElapsedSeconds: TimeInterval {
58
+        dailyAggregateBucketLookupElapsedSeconds
59
+        + dailyAggregateCopyElapsedSeconds
60
+        + dailyAggregateDeleteElapsedSeconds
61
+        + dailyAggregateRebuildElapsedSeconds
62
+        + dailyAggregateInsertElapsedSeconds
63
+    }
64
+
65
+    var dailyAggregateOtherElapsedSeconds: TimeInterval {
66
+        max(0, dailyAggregateElapsedSeconds - dailyAggregateAccountedElapsedSeconds)
67
+    }
68
+
44 69
     var accountedElapsedSeconds: TimeInterval {
45 70
         eventCountElapsedSeconds
46 71
         + typeSummaryElapsedSeconds
+52 -22
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -1981,7 +1981,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
1981 1981
             )
1982 1982
             if rebuildDerivedState {
1983 1983
                 try rebuildTypeSummary(observationID: observationID, sampleTypeID: sampleTypeID, db: db)
1984
-                try rebuildDailyAggregates(
1984
+                _ = try rebuildDailyAggregates(
1985 1985
                     observationID: observationID,
1986 1986
                     sampleTypeID: sampleTypeID,
1987 1987
                     observedAt: observedAt,
@@ -2050,12 +2050,13 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
2050 2050
             )
2051 2051
             breakdown.typeSummaryElapsedSeconds += Date().timeIntervalSince(summaryStartedAt)
2052 2052
             let dailyStartedAt = Date()
2053
-            try copyDailyAggregates(
2053
+            let dailyBreakdown = try copyDailyAggregates(
2054 2054
                 fromObservationID: previousSummary.observationID,
2055 2055
                 toObservationID: observationID,
2056 2056
                 sampleTypeID: sampleTypeID,
2057 2057
                 db: db
2058 2058
             )
2059
+            breakdown.addDailyAggregateBreakdown(dailyBreakdown)
2059 2060
             breakdown.dailyAggregateElapsedSeconds += Date().timeIntervalSince(dailyStartedAt)
2060 2061
             breakdown.totalElapsedSeconds = Date().timeIntervalSince(startedAt)
2061 2062
             return breakdown
@@ -2093,25 +2094,29 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
2093 2094
         )
2094 2095
         breakdown.typeSummaryElapsedSeconds += Date().timeIntervalSince(summaryUpsertStartedAt)
2095 2096
         let dailyStartedAt = Date()
2096
-        if let previousSummary,
2097
-           try replaceChangedDailyAggregates(
2098
-            fromObservationID: previousSummary.observationID,
2099
-            toObservationID: observationID,
2100
-            sampleTypeID: sampleTypeID,
2101
-            observedAt: verifiedAt,
2102
-            db: db
2103
-           ) {
2104
-            breakdown.dailyAggregateElapsedSeconds += Date().timeIntervalSince(dailyStartedAt)
2105
-            breakdown.totalElapsedSeconds = Date().timeIntervalSince(startedAt)
2106
-            return breakdown
2097
+        if let previousSummary {
2098
+            let replacement = try replaceChangedDailyAggregates(
2099
+                fromObservationID: previousSummary.observationID,
2100
+                toObservationID: observationID,
2101
+                sampleTypeID: sampleTypeID,
2102
+                observedAt: verifiedAt,
2103
+                db: db
2104
+            )
2105
+            breakdown.addDailyAggregateBreakdown(replacement.breakdown)
2106
+            if replacement.replaced {
2107
+                breakdown.dailyAggregateElapsedSeconds += Date().timeIntervalSince(dailyStartedAt)
2108
+                breakdown.totalElapsedSeconds = Date().timeIntervalSince(startedAt)
2109
+                return breakdown
2110
+            }
2107 2111
         }
2108 2112
 
2109
-        try rebuildDailyAggregates(
2113
+        let dailyBreakdown = try rebuildDailyAggregates(
2110 2114
             observationID: observationID,
2111 2115
             sampleTypeID: sampleTypeID,
2112 2116
             observedAt: verifiedAt,
2113 2117
             db: db
2114 2118
         )
2119
+        breakdown.addDailyAggregateBreakdown(dailyBreakdown)
2115 2120
         breakdown.dailyAggregateElapsedSeconds += Date().timeIntervalSince(dailyStartedAt)
2116 2121
         breakdown.totalElapsedSeconds = Date().timeIntervalSince(startedAt)
2117 2122
         return breakdown
@@ -2174,12 +2179,13 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
2174 2179
         )
2175 2180
         breakdown.typeSummaryElapsedSeconds += Date().timeIntervalSince(summaryStartedAt)
2176 2181
         let dailyStartedAt = Date()
2177
-        try copyDailyAggregates(
2182
+        let dailyBreakdown = try copyDailyAggregates(
2178 2183
             fromObservationID: previousSummary.observationID,
2179 2184
             toObservationID: observationID,
2180 2185
             sampleTypeID: sampleTypeID,
2181 2186
             db: db
2182 2187
         )
2188
+        breakdown.addDailyAggregateBreakdown(dailyBreakdown)
2183 2189
         breakdown.dailyAggregateElapsedSeconds += Date().timeIntervalSince(dailyStartedAt)
2184 2190
         breakdown.totalElapsedSeconds = Date().timeIntervalSince(startedAt)
2185 2191
         return breakdown
@@ -2272,7 +2278,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
2272 2278
         )
2273 2279
         if rebuildDerivedState {
2274 2280
             try rebuildTypeSummary(observationID: observationID, sampleTypeID: sampleTypeID, db: db)
2275
-            try rebuildDailyAggregates(
2281
+            _ = try rebuildDailyAggregates(
2276 2282
                 observationID: observationID,
2277 2283
                 sampleTypeID: sampleTypeID,
2278 2284
                 observedAt: observedMissingAt,
@@ -3468,8 +3474,10 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
3468 3474
         sampleTypeID: Int64,
3469 3475
         observedAt: Date,
3470 3476
         db: OpaquePointer?
3471
-    ) throws {
3477
+    ) throws -> HealthArchiveFinalizationBreakdown {
3478
+        var breakdown = HealthArchiveFinalizationBreakdown.zero
3472 3479
         let secondsFromGMT = TimeZone.current.secondsFromGMT(for: observedAt)
3480
+        let deleteStartedAt = Date()
3473 3481
         try withStatement(
3474 3482
             "DELETE FROM daily_type_aggregates WHERE observation_id = ? AND sample_type_id = ?",
3475 3483
             db: db
@@ -3480,9 +3488,15 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
3480 3488
                 throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
3481 3489
             }
3482 3490
         }
3491
+        breakdown.dailyAggregateDeleteElapsedSeconds += Date().timeIntervalSince(deleteStartedAt)
3483 3492
 
3493
+        let rebuildStartedAt = Date()
3484 3494
         let rows = try dailyAggregateRows(sampleTypeID: sampleTypeID, secondsFromGMT: secondsFromGMT, db: db)
3495
+        breakdown.dailyAggregateRebuildElapsedSeconds += Date().timeIntervalSince(rebuildStartedAt)
3496
+        let insertStartedAt = Date()
3485 3497
         try insertDailyAggregateRows(rows, observationID: observationID, sampleTypeID: sampleTypeID, db: db)
3498
+        breakdown.dailyAggregateInsertElapsedSeconds += Date().timeIntervalSince(insertStartedAt)
3499
+        return breakdown
3486 3500
     }
3487 3501
 
3488 3502
     private func copyDailyAggregates(
@@ -3490,7 +3504,9 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
3490 3504
         toObservationID: Int64,
3491 3505
         sampleTypeID: Int64,
3492 3506
         db: OpaquePointer?
3493
-    ) throws {
3507
+    ) throws -> HealthArchiveFinalizationBreakdown {
3508
+        var breakdown = HealthArchiveFinalizationBreakdown.zero
3509
+        let deleteStartedAt = Date()
3494 3510
         try withStatement(
3495 3511
             "DELETE FROM daily_type_aggregates WHERE observation_id = ? AND sample_type_id = ?",
3496 3512
             db: db
@@ -3501,13 +3517,17 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
3501 3517
                 throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
3502 3518
             }
3503 3519
         }
3520
+        breakdown.dailyAggregateDeleteElapsedSeconds += Date().timeIntervalSince(deleteStartedAt)
3504 3521
 
3522
+        let copyStartedAt = Date()
3505 3523
         try copyDailyAggregateRows(
3506 3524
             fromObservationID: fromObservationID,
3507 3525
             toObservationID: toObservationID,
3508 3526
             sampleTypeID: sampleTypeID,
3509 3527
             db: db
3510 3528
         )
3529
+        breakdown.dailyAggregateCopyElapsedSeconds += Date().timeIntervalSince(copyStartedAt)
3530
+        return breakdown
3511 3531
     }
3512 3532
 
3513 3533
     private func replaceChangedDailyAggregates(
@@ -3516,41 +3536,51 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
3516 3536
         sampleTypeID: Int64,
3517 3537
         observedAt: Date,
3518 3538
         db: OpaquePointer?
3519
-    ) throws -> Bool {
3539
+    ) throws -> (replaced: Bool, breakdown: HealthArchiveFinalizationBreakdown) {
3540
+        var breakdown = HealthArchiveFinalizationBreakdown.zero
3520 3541
         let secondsFromGMT = TimeZone.current.secondsFromGMT(for: observedAt)
3542
+        let bucketLookupStartedAt = Date()
3521 3543
         let affectedBuckets = try changedDailyAggregateBuckets(
3522 3544
             observationID: toObservationID,
3523 3545
             sampleTypeID: sampleTypeID,
3524 3546
             secondsFromGMT: secondsFromGMT,
3525 3547
             db: db
3526 3548
         )
3527
-        guard !affectedBuckets.isEmpty else { return false }
3549
+        breakdown.dailyAggregateBucketLookupElapsedSeconds += Date().timeIntervalSince(bucketLookupStartedAt)
3550
+        guard !affectedBuckets.isEmpty else { return (false, breakdown) }
3528 3551
 
3529
-        try copyDailyAggregates(
3552
+        let copyBreakdown = try copyDailyAggregates(
3530 3553
             fromObservationID: fromObservationID,
3531 3554
             toObservationID: toObservationID,
3532 3555
             sampleTypeID: sampleTypeID,
3533 3556
             db: db
3534 3557
         )
3558
+        breakdown.addDailyAggregateBreakdown(copyBreakdown)
3559
+        let deleteStartedAt = Date()
3535 3560
         try deleteDailyAggregateRows(
3536 3561
             observationID: toObservationID,
3537 3562
             sampleTypeID: sampleTypeID,
3538 3563
             bucketStarts: affectedBuckets,
3539 3564
             db: db
3540 3565
         )
3566
+        breakdown.dailyAggregateDeleteElapsedSeconds += Date().timeIntervalSince(deleteStartedAt)
3567
+        let rebuildStartedAt = Date()
3541 3568
         let replacementRows = try dailyAggregateRows(
3542 3569
             sampleTypeID: sampleTypeID,
3543 3570
             secondsFromGMT: secondsFromGMT,
3544 3571
             bucketStarts: affectedBuckets,
3545 3572
             db: db
3546 3573
         )
3574
+        breakdown.dailyAggregateRebuildElapsedSeconds += Date().timeIntervalSince(rebuildStartedAt)
3575
+        let insertStartedAt = Date()
3547 3576
         try insertDailyAggregateRows(
3548 3577
             replacementRows,
3549 3578
             observationID: toObservationID,
3550 3579
             sampleTypeID: sampleTypeID,
3551 3580
             db: db
3552 3581
         )
3553
-        return true
3582
+        breakdown.dailyAggregateInsertElapsedSeconds += Date().timeIntervalSince(insertStartedAt)
3583
+        return (true, breakdown)
3554 3584
     }
3555 3585
 
3556 3586
     private func copyDailyAggregateRows(
+21 -0
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -8,6 +8,12 @@ struct ImportTimingBreakdown: Sendable, Equatable {
8 8
     var finalizeEventCountElapsedSeconds: TimeInterval = 0
9 9
     var finalizeTypeSummaryElapsedSeconds: TimeInterval = 0
10 10
     var finalizeDailyAggregateElapsedSeconds: TimeInterval = 0
11
+    var finalizeDailyAggregateBucketLookupElapsedSeconds: TimeInterval = 0
12
+    var finalizeDailyAggregateCopyElapsedSeconds: TimeInterval = 0
13
+    var finalizeDailyAggregateDeleteElapsedSeconds: TimeInterval = 0
14
+    var finalizeDailyAggregateRebuildElapsedSeconds: TimeInterval = 0
15
+    var finalizeDailyAggregateInsertElapsedSeconds: TimeInterval = 0
16
+    var finalizeDailyAggregateOtherElapsedSeconds: TimeInterval = 0
11 17
     var finalizeRunUpdateElapsedSeconds: TimeInterval = 0
12 18
     var finalizeOtherElapsedSeconds: TimeInterval = 0
13 19
 
@@ -24,6 +30,15 @@ struct ImportTimingBreakdown: Sendable, Equatable {
24 30
         + finalizeRunUpdateElapsedSeconds
25 31
         + finalizeOtherElapsedSeconds
26 32
     }
33
+
34
+    var finalizeDailyAggregateAccountedElapsedSeconds: TimeInterval {
35
+        finalizeDailyAggregateBucketLookupElapsedSeconds
36
+        + finalizeDailyAggregateCopyElapsedSeconds
37
+        + finalizeDailyAggregateDeleteElapsedSeconds
38
+        + finalizeDailyAggregateRebuildElapsedSeconds
39
+        + finalizeDailyAggregateInsertElapsedSeconds
40
+        + finalizeDailyAggregateOtherElapsedSeconds
41
+    }
27 42
 }
28 43
 
29 44
 struct HealthKitAPICallResult: Sendable, Equatable {
@@ -166,6 +181,12 @@ final class SnapshotFetchProgress {
166 181
             combined.finalizeEventCountElapsedSeconds += type.timingBreakdown.finalizeEventCountElapsedSeconds
167 182
             combined.finalizeTypeSummaryElapsedSeconds += type.timingBreakdown.finalizeTypeSummaryElapsedSeconds
168 183
             combined.finalizeDailyAggregateElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateElapsedSeconds
184
+            combined.finalizeDailyAggregateBucketLookupElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateBucketLookupElapsedSeconds
185
+            combined.finalizeDailyAggregateCopyElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateCopyElapsedSeconds
186
+            combined.finalizeDailyAggregateDeleteElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateDeleteElapsedSeconds
187
+            combined.finalizeDailyAggregateRebuildElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateRebuildElapsedSeconds
188
+            combined.finalizeDailyAggregateInsertElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateInsertElapsedSeconds
189
+            combined.finalizeDailyAggregateOtherElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateOtherElapsedSeconds
169 190
             combined.finalizeRunUpdateElapsedSeconds += type.timingBreakdown.finalizeRunUpdateElapsedSeconds
170 191
             combined.finalizeOtherElapsedSeconds += type.timingBreakdown.finalizeOtherElapsedSeconds
171 192
             return combined
+16 -0
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -398,6 +398,14 @@ struct DashboardView: View {
398 398
             lines.append("SummedFinalizeEventCountElapsed: \(formatDuration(aggregateTiming.finalizeEventCountElapsedSeconds))")
399 399
             lines.append("SummedFinalizeTypeSummaryElapsed: \(formatDuration(aggregateTiming.finalizeTypeSummaryElapsedSeconds))")
400 400
             lines.append("SummedFinalizeDailyAggregateElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateElapsedSeconds))")
401
+            if aggregateTiming.finalizeDailyAggregateAccountedElapsedSeconds > 0 {
402
+                lines.append("SummedFinalizeDailyAggregateBucketLookupElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateBucketLookupElapsedSeconds))")
403
+                lines.append("SummedFinalizeDailyAggregateCopyElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateCopyElapsedSeconds))")
404
+                lines.append("SummedFinalizeDailyAggregateDeleteElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateDeleteElapsedSeconds))")
405
+                lines.append("SummedFinalizeDailyAggregateRebuildElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateRebuildElapsedSeconds))")
406
+                lines.append("SummedFinalizeDailyAggregateInsertElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateInsertElapsedSeconds))")
407
+                lines.append("SummedFinalizeDailyAggregateOtherElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateOtherElapsedSeconds))")
408
+            }
401 409
             lines.append("SummedFinalizeRunUpdateElapsed: \(formatDuration(aggregateTiming.finalizeRunUpdateElapsedSeconds))")
402 410
             lines.append("SummedFinalizeOtherElapsed: \(formatDuration(aggregateTiming.finalizeOtherElapsedSeconds))")
403 411
         }
@@ -452,6 +460,14 @@ struct DashboardView: View {
452 460
                 lines.append("  finalizeEventCountElapsed: \(formatDuration(type.timingBreakdown.finalizeEventCountElapsedSeconds))")
453 461
                 lines.append("  finalizeTypeSummaryElapsed: \(formatDuration(type.timingBreakdown.finalizeTypeSummaryElapsedSeconds))")
454 462
                 lines.append("  finalizeDailyAggregateElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateElapsedSeconds))")
463
+                if type.timingBreakdown.finalizeDailyAggregateAccountedElapsedSeconds > 0 {
464
+                    lines.append("  finalizeDailyAggregateBucketLookupElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateBucketLookupElapsedSeconds))")
465
+                    lines.append("  finalizeDailyAggregateCopyElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateCopyElapsedSeconds))")
466
+                    lines.append("  finalizeDailyAggregateDeleteElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateDeleteElapsedSeconds))")
467
+                    lines.append("  finalizeDailyAggregateRebuildElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateRebuildElapsedSeconds))")
468
+                    lines.append("  finalizeDailyAggregateInsertElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateInsertElapsedSeconds))")
469
+                    lines.append("  finalizeDailyAggregateOtherElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateOtherElapsedSeconds))")
470
+                }
455 471
                 lines.append("  finalizeRunUpdateElapsed: \(formatDuration(type.timingBreakdown.finalizeRunUpdateElapsedSeconds))")
456 472
                 lines.append("  finalizeOtherElapsed: \(formatDuration(type.timingBreakdown.finalizeOtherElapsedSeconds))")
457 473
             }