@@ -292,6 +292,32 @@ rebuild / dashboard refresh work. The dashboard cache refresh was moved to a |
||
| 292 | 292 |
background task after this report; next reports should distinguish import time |
| 293 | 293 |
from post-import UI recovery. |
| 294 | 294 |
|
| 295 |
+### 2026-06-03 Incremental No-Delta Snapshot After Visibility Fast-Path |
|
| 296 |
+ |
|
| 297 |
+Commit context: after `f60f09a`. Source: user-provided diagnostic report with |
|
| 298 |
+`previousSnapshotID` present, `isChainStart: false`, and total record count |
|
| 299 |
+unchanged at 1,579,168. |
|
| 300 |
+ |
|
| 301 |
+| Metric | Value | |
|
| 302 |
+|--------|-------| |
|
| 303 |
+| Wall clock | 13.6s | |
|
| 304 |
+| Summed metric total | 13.2s | |
|
| 305 |
+| Summed fetch | 0.2s | |
|
| 306 |
+| Summed processing | 0.0s | |
|
| 307 |
+| Summed insert | 0.0s | |
|
| 308 |
+| Summed finalize | 12.9s | |
|
| 309 |
+| Total records | 1,579,168 | |
|
| 310 |
+| Heart Rate finalize | 9.1s | |
|
| 311 |
+| Active Energy finalize | 1.8s | |
|
| 312 |
+| Steps finalize | 0.5s | |
|
| 313 |
+| Walking + Running Distance finalize | 0.4s | |
|
| 314 |
+ |
|
| 315 |
+Conclusion: the repeated no-delta write path is effectively eliminated. The |
|
| 316 |
+remaining measured import cost is finalization, especially Heart Rate daily |
|
| 317 |
+aggregate/materialized summary work. The next optimization should avoid |
|
| 318 |
+rescanning all visible records when a metric has no appeared, disappeared, or |
|
| 319 |
+representation-changed events in the current observation. |
|
| 320 |
+ |
|
| 295 | 321 |
## Optimization Iterations |
| 296 | 322 |
|
| 297 | 323 |
| Date | Commit | Change | Result / Status | |
@@ -314,7 +340,8 @@ from post-import UI recovery. |
||
| 314 | 340 |
| 2026-06-02 | `3dd5f48` | Fortified scheduled test database reset with a disk marker and extra SQLite sidecar cleanup. | Real-device report confirmed reset produced `previousSnapshotID: none`, `isChainStart: true`, and a clean first-snapshot timeline. | |
| 315 | 341 |
| 2026-06-02 | `06ee6be` | Removed unused `sample_versions(start_date, end_date)` and redundant `sample_visibility_ranges(sample_id, last_observation_id)` indexes. | Comparable first-import report was flat: wall clock 12m43s -> 12m39s and summed insert 10m11s -> 10m07s. Treat as no material performance change. | |
| 316 | 342 |
| 2026-06-02 | pending | Moved Dashboard archive cache refresh/rebuild off the UI path after snapshot completion. | Awaiting real-device confirmation that the app no longer stays unresponsive for roughly one minute after a completed snapshot. | |
| 317 |
-| 2026-06-03 | pending | Fast-path unchanged samples whose current version already has an open visibility range. | Awaiting non-chain-start/full-scan report. Expected signal is lower insert elapsed on repeated captures with mostly unchanged rows, especially Heart Rate. | |
|
| 343 |
+| 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. | |
|
| 344 |
+| 2026-06-03 | pending | Copy previous type summaries and daily aggregates for unchanged metric observations instead of rebuilding from visible ranges. | Awaiting repeated no-delta report. Expected signal is lower `SummedFinalizeElapsed`, especially Heart Rate finalize. | |
|
| 318 | 345 |
|
| 319 | 346 |
## Current Diagnosis |
| 320 | 347 |
|
@@ -331,6 +358,7 @@ The likely bottleneck is per-row SQLite work: |
||
| 331 | 358 |
- multiple dependent writes per sample; |
| 332 | 359 |
- commit / transaction shape; |
| 333 | 360 |
- no-op visibility range maintenance for unchanged existing samples; |
| 361 |
+- full daily aggregate rebuilds for unchanged metric observations; |
|
| 334 | 362 |
- Core Data or UI refresh work after SQLite completes. This became the active |
| 335 | 363 |
bottleneck after a no-delta incremental snapshot completed in 9.2s but the UI |
| 336 | 364 |
remained unresponsive for roughly one minute. |
@@ -349,17 +377,18 @@ The likely bottleneck is per-row SQLite work: |
||
| 349 | 377 |
Prioritize experiments in this order: |
| 350 | 378 |
|
| 351 | 379 |
1. Confirm whether the background dashboard cache refresh removes the post-import UI freeze. If not, add explicit timings around cache rebuild, dashboard refresh, diagnostic report generation, and sheet dismissal. |
| 352 |
-2. 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`. |
|
| 353 |
-3. Reduce any remaining per-sample SQLite writes for unchanged existing samples during non-chain-start full scans. |
|
| 354 |
-4. Profile whether index maintenance dominates first-import insert cost. |
|
| 355 |
-5. Consider a guarded bulk-import mode for first observations: |
|
| 380 |
+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. |
|
| 381 |
+3. 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`. |
|
| 382 |
+4. Reduce any remaining per-sample SQLite writes for unchanged existing samples during non-chain-start full scans. |
|
| 383 |
+5. Profile whether index maintenance dominates first-import insert cost. |
|
| 384 |
+6. Consider a guarded bulk-import mode for first observations: |
|
| 356 | 385 |
- keep archive semantics unchanged; |
| 357 | 386 |
- only relax work that can be safely reconstructed or validated; |
| 358 | 387 |
- re-enable normal idempotent paths for incremental observations. |
| 359 |
-6. Run a fresh first-import benchmark after the unused-index removal and compare `SummedInsertElapsed`, `Heart Rate insertElapsed`, and `Active Energy insertElapsed`. |
|
| 360 |
-7. Investigate whether first-import-only deferred index creation or temporary staging tables can reduce `samples` / `sample_versions` / `sample_observation_events` write cost without weakening final archive integrity. |
|
| 361 |
-8. Revisit adaptive page sizes only after SQLite write-path costs are reduced. |
|
| 362 |
-9. Revisit background / scheduled collection once initial import can finish reliably and post-import UI recovery is bounded. |
|
| 388 |
+7. Run a fresh first-import benchmark after the unused-index removal and compare `SummedInsertElapsed`, `Heart Rate insertElapsed`, and `Active Energy insertElapsed`. |
|
| 389 |
+8. Investigate whether first-import-only deferred index creation or temporary staging tables can reduce `samples` / `sample_versions` / `sample_observation_events` write cost without weakening final archive integrity. |
|
| 390 |
+9. Revisit adaptive page sizes only after SQLite write-path costs are reduced. |
|
| 391 |
+10. Revisit background / scheduled collection once initial import can finish reliably and post-import UI recovery is bounded. |
|
| 363 | 392 |
|
| 364 | 393 |
## Verification Checklist For Each Optimization |
| 365 | 394 |
|
@@ -1930,6 +1930,42 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1930 | 1930 |
lookupCache: lookupCache |
| 1931 | 1931 |
) |
| 1932 | 1932 |
let counts = try eventCounts(observationID: observationID, sampleTypeID: sampleTypeID, db: db) |
| 1933 |
+ if counts.appeared == 0, |
|
| 1934 |
+ counts.disappeared == 0, |
|
| 1935 |
+ counts.representationChanged == 0, |
|
| 1936 |
+ let previousSummary = try previousTypeSummary( |
|
| 1937 |
+ sampleTypeID: sampleTypeID, |
|
| 1938 |
+ beforeObservationID: observationID, |
|
| 1939 |
+ db: db, |
|
| 1940 |
+ statementCache: statementCache |
|
| 1941 |
+ ) {
|
|
| 1942 |
+ try insertObservationTypeRun( |
|
| 1943 |
+ observationID: observationID, |
|
| 1944 |
+ sampleTypeID: sampleTypeID, |
|
| 1945 |
+ status: "completed", |
|
| 1946 |
+ observedAt: verifiedAt, |
|
| 1947 |
+ insertedEventCount: 0, |
|
| 1948 |
+ deletedEventCount: 0, |
|
| 1949 |
+ verifiedVisibleCount: previousSummary.aggregate.visibleRecordCount, |
|
| 1950 |
+ db: db, |
|
| 1951 |
+ statementCache: statementCache |
|
| 1952 |
+ ) |
|
| 1953 |
+ try upsertTypeSummary( |
|
| 1954 |
+ observationID: observationID, |
|
| 1955 |
+ sampleTypeID: sampleTypeID, |
|
| 1956 |
+ counts: counts, |
|
| 1957 |
+ aggregate: previousSummary.aggregate, |
|
| 1958 |
+ db: db |
|
| 1959 |
+ ) |
|
| 1960 |
+ try copyDailyAggregates( |
|
| 1961 |
+ fromObservationID: previousSummary.observationID, |
|
| 1962 |
+ toObservationID: observationID, |
|
| 1963 |
+ sampleTypeID: sampleTypeID, |
|
| 1964 |
+ db: db |
|
| 1965 |
+ ) |
|
| 1966 |
+ return |
|
| 1967 |
+ } |
|
| 1968 |
+ |
|
| 1933 | 1969 |
let aggregate = try visibleAggregate(sampleTypeID: sampleTypeID, db: db) |
| 1934 | 1970 |
let visibleCount = aggregate.visibleRecordCount |
| 1935 | 1971 |
try insertObservationTypeRun( |
@@ -2995,6 +3031,38 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2995 | 3031 |
} |
| 2996 | 3032 |
} |
| 2997 | 3033 |
|
| 3034 |
+ private func previousTypeSummary( |
|
| 3035 |
+ sampleTypeID: Int64, |
|
| 3036 |
+ beforeObservationID observationID: Int64, |
|
| 3037 |
+ db: OpaquePointer?, |
|
| 3038 |
+ statementCache: SQLiteStatementCache? = nil |
|
| 3039 |
+ ) throws -> (observationID: Int64, aggregate: ArchiveV2VisibleAggregate)? {
|
|
| 3040 |
+ let sql = """ |
|
| 3041 |
+ SELECT observation_id, visible_record_count, earliest_start_date, latest_end_date, value_sum, value_max |
|
| 3042 |
+ FROM observation_type_summaries |
|
| 3043 |
+ WHERE sample_type_id = ? AND observation_id < ? |
|
| 3044 |
+ ORDER BY observation_id DESC |
|
| 3045 |
+ LIMIT 1 |
|
| 3046 |
+ """ |
|
| 3047 |
+ return try withStatement(sql, db: db, statementCache: statementCache) { statement in
|
|
| 3048 |
+ bindInt64(sampleTypeID, to: 1, in: statement) |
|
| 3049 |
+ bindInt64(observationID, to: 2, in: statement) |
|
| 3050 |
+ guard sqlite3_step(statement) == SQLITE_ROW else {
|
|
| 3051 |
+ return nil |
|
| 3052 |
+ } |
|
| 3053 |
+ return ( |
|
| 3054 |
+ observationID: sqlite3_column_int64(statement, 0), |
|
| 3055 |
+ aggregate: ArchiveV2VisibleAggregate( |
|
| 3056 |
+ visibleRecordCount: columnInt(statement, 1) ?? 0, |
|
| 3057 |
+ earliestStartDate: columnDouble(statement, 2), |
|
| 3058 |
+ latestEndDate: columnDouble(statement, 3), |
|
| 3059 |
+ valueSum: columnDouble(statement, 4), |
|
| 3060 |
+ valueMax: columnDouble(statement, 5) |
|
| 3061 |
+ ) |
|
| 3062 |
+ ) |
|
| 3063 |
+ } |
|
| 3064 |
+ } |
|
| 3065 |
+ |
|
| 2998 | 3066 |
private func rebuildDailyAggregates( |
| 2999 | 3067 |
observationID: Int64, |
| 3000 | 3068 |
sampleTypeID: Int64, |
@@ -3014,6 +3082,45 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 3014 | 3082 |
} |
| 3015 | 3083 |
|
| 3016 | 3084 |
let rows = try dailyAggregateRows(sampleTypeID: sampleTypeID, secondsFromGMT: secondsFromGMT, db: db) |
| 3085 |
+ try insertDailyAggregateRows(rows, observationID: observationID, sampleTypeID: sampleTypeID, db: db) |
|
| 3086 |
+ } |
|
| 3087 |
+ |
|
| 3088 |
+ private func copyDailyAggregates( |
|
| 3089 |
+ fromObservationID: Int64, |
|
| 3090 |
+ toObservationID: Int64, |
|
| 3091 |
+ sampleTypeID: Int64, |
|
| 3092 |
+ db: OpaquePointer? |
|
| 3093 |
+ ) throws {
|
|
| 3094 |
+ try withStatement( |
|
| 3095 |
+ "DELETE FROM daily_type_aggregates WHERE observation_id = ? AND sample_type_id = ?", |
|
| 3096 |
+ db: db |
|
| 3097 |
+ ) { statement in
|
|
| 3098 |
+ bindInt64(toObservationID, to: 1, in: statement) |
|
| 3099 |
+ bindInt64(sampleTypeID, to: 2, in: statement) |
|
| 3100 |
+ guard sqlite3_step(statement) == SQLITE_DONE else {
|
|
| 3101 |
+ throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
|
| 3102 |
+ } |
|
| 3103 |
+ } |
|
| 3104 |
+ |
|
| 3105 |
+ let rows = try dailyAggregateRows( |
|
| 3106 |
+ observationID: fromObservationID, |
|
| 3107 |
+ sampleTypeID: sampleTypeID, |
|
| 3108 |
+ db: db |
|
| 3109 |
+ ) |
|
| 3110 |
+ try insertDailyAggregateRows( |
|
| 3111 |
+ rows, |
|
| 3112 |
+ observationID: toObservationID, |
|
| 3113 |
+ sampleTypeID: sampleTypeID, |
|
| 3114 |
+ db: db |
|
| 3115 |
+ ) |
|
| 3116 |
+ } |
|
| 3117 |
+ |
|
| 3118 |
+ private func insertDailyAggregateRows( |
|
| 3119 |
+ _ rows: [ArchiveV2DailyAggregateRow], |
|
| 3120 |
+ observationID: Int64, |
|
| 3121 |
+ sampleTypeID: Int64, |
|
| 3122 |
+ db: OpaquePointer? |
|
| 3123 |
+ ) throws {
|
|
| 3017 | 3124 |
try withStatement( |
| 3018 | 3125 |
""" |
| 3019 | 3126 |
INSERT INTO daily_type_aggregates ( |
@@ -3046,6 +3153,36 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 3046 | 3153 |
} |
| 3047 | 3154 |
} |
| 3048 | 3155 |
|
| 3156 |
+ private func dailyAggregateRows( |
|
| 3157 |
+ observationID: Int64, |
|
| 3158 |
+ sampleTypeID: Int64, |
|
| 3159 |
+ db: OpaquePointer? |
|
| 3160 |
+ ) throws -> [ArchiveV2DailyAggregateRow] {
|
|
| 3161 |
+ let sql = """ |
|
| 3162 |
+ SELECT bucket_start, bucket_end, visible_record_count, value_sum, value_max, source_revision_id |
|
| 3163 |
+ FROM daily_type_aggregates |
|
| 3164 |
+ WHERE observation_id = ? AND sample_type_id = ? |
|
| 3165 |
+ ORDER BY bucket_start ASC, source_revision_id ASC |
|
| 3166 |
+ """ |
|
| 3167 |
+ return try withStatement(sql, db: db) { statement in
|
|
| 3168 |
+ bindInt64(observationID, to: 1, in: statement) |
|
| 3169 |
+ bindInt64(sampleTypeID, to: 2, in: statement) |
|
| 3170 |
+ |
|
| 3171 |
+ var rows: [ArchiveV2DailyAggregateRow] = [] |
|
| 3172 |
+ while sqlite3_step(statement) == SQLITE_ROW {
|
|
| 3173 |
+ rows.append(ArchiveV2DailyAggregateRow( |
|
| 3174 |
+ bucketStart: sqlite3_column_double(statement, 0), |
|
| 3175 |
+ bucketEnd: sqlite3_column_double(statement, 1), |
|
| 3176 |
+ visibleRecordCount: columnInt(statement, 2) ?? 0, |
|
| 3177 |
+ valueSum: columnDouble(statement, 3), |
|
| 3178 |
+ valueMax: columnDouble(statement, 4), |
|
| 3179 |
+ sourceRevisionID: columnInt64(statement, 5) |
|
| 3180 |
+ )) |
|
| 3181 |
+ } |
|
| 3182 |
+ return rows |
|
| 3183 |
+ } |
|
| 3184 |
+ } |
|
| 3185 |
+ |
|
| 3049 | 3186 |
private func dailyAggregateRows( |
| 3050 | 3187 |
sampleTypeID: Int64, |
| 3051 | 3188 |
secondsFromGMT: Int, |
@@ -99,6 +99,7 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 99 | 99 |
XCTAssertEqual(try countRows(in: "observation_type_runs", at: url), 2) |
| 100 | 100 |
XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationIDs[0]) AND inserted_event_count = 1", at: url), 1) |
| 101 | 101 |
XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationIDs[1]) AND visible_record_count = 1", at: url), 1) |
| 102 |
+ XCTAssertEqual(try countRows(in: "daily_type_aggregates WHERE observation_id = \(observationIDs[1]) AND visible_record_count = 1", at: url), 1) |
|
| 102 | 103 |
XCTAssertFalse(try tableExists("archive_samples", at: url))
|
| 103 | 104 |
} |
| 104 | 105 |
|