@@ -578,6 +578,7 @@ rows exist". |
||
| 578 | 578 |
| 2026-06-03 | pending | Migrate legacy core-profile selections to full available capture by default. | A follow-up real-device report still showed the old `4907...` monitored type-set hash and `Types: 15/15 processed`, proving the running app still used the old persisted selected type set. New installs and pre-profile settings that exactly match the old core profile now migrate to `All available`; only an explicit `Select Core Profile` action persists the core subset. Settings also shows the active profile label (`All available`, `Core`, or `Custom`) for quick verification before capture. | |
| 579 | 579 |
| 2026-06-03 | pending | Harden quantity unit conversion for full-profile imports. | The first 127-metric run crashed while archiving `HKQuantityTypeIdentifierDietaryWater`: the previous fallback converted unknown quantities with `.count()`, and HealthKit raised `NSInvalidArgumentException` for incompatible `mL` to `count`. The archive now maps known extended units, stores quantity rows with nil numeric/unit when a future type is unmapped, and removes the unsafe display fallback. Next run should pass Water and reveal any remaining type-specific unit gaps. | |
| 580 | 580 |
| 2026-06-03 | pending | Preserve completed import diagnostics and render large diagnostic reports lazily. | A copied full-profile reimport report completed successfully: `Types: 127/127 processed`, `Records: 2,646,527`, `WallClock: 40.3s`, `SummedProcessingElapsed: 21.7s`, `SummedFinalizeElapsed: 15.1s`, `SummedFetchElapsed: 1.7s`, `SummedInsertElapsed: 0.1s`, and `0` degraded metrics. The Diagnostics sheet copied the report text but displayed a blank body for the huge report; completed/partial/review reports are now persisted under Application Support and displayed in chunked lazy text blocks. | |
| 581 |
+| 2026-06-03 | pending | Fast-path unchanged verification after an empty HealthKit delta page. | The successful full-profile reimport spent `15.1s` in finalize despite `0.1s` insert time; Heart Rate alone spent `9.0s` finalizing. The empty-delta path now calls an explicit unchanged-verification store method that copies the previous type summary and daily aggregates without first scanning sample events to prove zero changes. Expected signal: repeated no-delta/full-profile `SummedFinalizeElapsed` and Heart Rate finalize time drop sharply. | |
|
| 581 | 582 |
|
| 582 | 583 |
## Current Diagnosis |
| 583 | 584 |
|
@@ -616,6 +617,10 @@ The likely bottleneck is per-row SQLite work: |
||
| 616 | 617 |
metrics and 2.6M archived rows. This is not comparable to clean first-import |
| 617 | 618 |
timing, but it confirms that the expanded registry can pass the previous Water |
| 618 | 619 |
unit crash point. |
| 620 |
+- On unchanged full-profile reimports, `markVerification` can still be expensive |
|
| 621 |
+ if it counts nonexistent events for high-volume types. The capture layer knows |
|
| 622 |
+ the first anchored delta page is empty, so unchanged verification should skip |
|
| 623 |
+ event-count scans and copy previous materialized summaries directly. |
|
| 619 | 624 |
|
| 620 | 625 |
## Open Issues / Observations |
| 621 | 626 |
|
@@ -1089,7 +1089,7 @@ final class HealthKitService {
|
||
| 1089 | 1089 |
earliestDate: earliestDate, |
| 1090 | 1090 |
latestDate: latestDate |
| 1091 | 1091 |
) {
|
| 1092 |
- captureTimings.finalizeElapsedSeconds += try await finalizeArchiveVerification( |
|
| 1092 |
+ captureTimings.finalizeElapsedSeconds += try await finalizeUnchangedArchiveVerification( |
|
| 1093 | 1093 |
sampleType: sampleType, |
| 1094 | 1094 |
typeIdentifier: typeIdentifier, |
| 1095 | 1095 |
recordCount: unchanged.totalCount, |
@@ -1554,7 +1554,7 @@ final class HealthKitService {
|
||
| 1554 | 1554 |
return Double(processedCount) / elapsedSeconds |
| 1555 | 1555 |
} |
| 1556 | 1556 |
|
| 1557 |
- private func finalizeArchiveVerification( |
|
| 1557 |
+ private func finalizeUnchangedArchiveVerification( |
|
| 1558 | 1558 |
sampleType: HKSampleType, |
| 1559 | 1559 |
typeIdentifier: String, |
| 1560 | 1560 |
recordCount: Int, |
@@ -1562,6 +1562,34 @@ final class HealthKitService {
|
||
| 1562 | 1562 |
processedEventCount: Int, |
| 1563 | 1563 |
archiveObservationID: Int64, |
| 1564 | 1564 |
progress: SnapshotFetchProgress? |
| 1565 |
+ ) async throws -> TimeInterval {
|
|
| 1566 |
+ try await finalizeArchiveVerification( |
|
| 1567 |
+ sampleType: sampleType, |
|
| 1568 |
+ typeIdentifier: typeIdentifier, |
|
| 1569 |
+ recordCount: recordCount, |
|
| 1570 |
+ progressStarted: progressStarted, |
|
| 1571 |
+ processedEventCount: processedEventCount, |
|
| 1572 |
+ archiveObservationID: archiveObservationID, |
|
| 1573 |
+ progress: progress, |
|
| 1574 |
+ markVerification: {
|
|
| 1575 |
+ try await self.archiveStore.markUnchangedVerification( |
|
| 1576 |
+ sampleType: sampleType, |
|
| 1577 |
+ verifiedAt: Date(), |
|
| 1578 |
+ observationID: archiveObservationID |
|
| 1579 |
+ ) |
|
| 1580 |
+ } |
|
| 1581 |
+ ) |
|
| 1582 |
+ } |
|
| 1583 |
+ |
|
| 1584 |
+ private func finalizeArchiveVerification( |
|
| 1585 |
+ sampleType: HKSampleType, |
|
| 1586 |
+ typeIdentifier: String, |
|
| 1587 |
+ recordCount: Int, |
|
| 1588 |
+ progressStarted: Date, |
|
| 1589 |
+ processedEventCount: Int, |
|
| 1590 |
+ archiveObservationID: Int64, |
|
| 1591 |
+ progress: SnapshotFetchProgress?, |
|
| 1592 |
+ markVerification: (() async throws -> Void)? = nil |
|
| 1565 | 1593 |
) async throws -> TimeInterval {
|
| 1566 | 1594 |
let elapsedBeforeFinalize = Date().timeIntervalSince(progressStarted) |
| 1567 | 1595 |
progress?.updateBlockProgress( |
@@ -1576,11 +1604,15 @@ final class HealthKitService {
|
||
| 1576 | 1604 |
) |
| 1577 | 1605 |
|
| 1578 | 1606 |
let verificationStartedAt = Date() |
| 1579 |
- try await archiveStore.markVerification( |
|
| 1580 |
- sampleType: sampleType, |
|
| 1581 |
- verifiedAt: Date(), |
|
| 1582 |
- observationID: archiveObservationID |
|
| 1583 |
- ) |
|
| 1607 |
+ if let markVerification {
|
|
| 1608 |
+ try await markVerification() |
|
| 1609 |
+ } else {
|
|
| 1610 |
+ try await archiveStore.markVerification( |
|
| 1611 |
+ sampleType: sampleType, |
|
| 1612 |
+ verifiedAt: Date(), |
|
| 1613 |
+ observationID: archiveObservationID |
|
| 1614 |
+ ) |
|
| 1615 |
+ } |
|
| 1584 | 1616 |
let finalizeElapsed = Date().timeIntervalSince(verificationStartedAt) |
| 1585 | 1617 |
|
| 1586 | 1618 |
let elapsedAfterFinalize = Date().timeIntervalSince(progressStarted) |
@@ -9,6 +9,7 @@ protocol HealthArchiveStore {
|
||
| 9 | 9 |
func upsertSamples(_ samples: [HKSample], observedAt: Date, observationID: Int64) async throws -> HealthArchiveWriteSummary |
| 10 | 10 |
func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws |
| 11 | 11 |
func markVerification(sampleType: HKSampleType, verifiedAt: Date, observationID: Int64) async throws |
| 12 |
+ func markUnchangedVerification(sampleType: HKSampleType, verifiedAt: Date, observationID: Int64) async throws |
|
| 12 | 13 |
func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws |
| 13 | 14 |
func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date, observationID: Int64) async throws |
| 14 | 15 |
func recordDisappearances(sampleUUIDHashes: [String], sampleTypeIdentifier: String, observedMissingAt: Date, observationID: Int64) async throws -> Int |
@@ -191,6 +191,25 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 191 | 191 |
} |
| 192 | 192 |
} |
| 193 | 193 |
|
| 194 |
+ func markUnchangedVerification(sampleType: HKSampleType, verifiedAt: Date, observationID: Int64) async throws {
|
|
| 195 |
+ let db = try openDatabase() |
|
| 196 |
+ defer { sqlite3_close(db) }
|
|
| 197 |
+ try prepareSchemaIfNeeded(db) |
|
| 198 |
+ try execute("BEGIN IMMEDIATE TRANSACTION", db: db)
|
|
| 199 |
+ do {
|
|
| 200 |
+ try markUnchangedVerification( |
|
| 201 |
+ sampleType: sampleType, |
|
| 202 |
+ verifiedAt: verifiedAt, |
|
| 203 |
+ observationID: observationID, |
|
| 204 |
+ db: db |
|
| 205 |
+ ) |
|
| 206 |
+ try execute("COMMIT", db: db)
|
|
| 207 |
+ } catch {
|
|
| 208 |
+ try? execute("ROLLBACK", db: db)
|
|
| 209 |
+ throw error |
|
| 210 |
+ } |
|
| 211 |
+ } |
|
| 212 |
+ |
|
| 194 | 213 |
func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws {
|
| 195 | 214 |
let db = try openDatabase() |
| 196 | 215 |
defer { sqlite3_close(db) }
|
@@ -2016,6 +2035,62 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 2016 | 2035 |
) |
| 2017 | 2036 |
} |
| 2018 | 2037 |
|
| 2038 |
+ private func markUnchangedVerification( |
|
| 2039 |
+ sampleType: HKSampleType, |
|
| 2040 |
+ verifiedAt: Date, |
|
| 2041 |
+ observationID: Int64, |
|
| 2042 |
+ db: OpaquePointer? |
|
| 2043 |
+ ) throws {
|
|
| 2044 |
+ let statementCache = SQLiteStatementCache(db: db) |
|
| 2045 |
+ defer { statementCache.finalizeAll() }
|
|
| 2046 |
+ let lookupCache = SQLiteWriteLookupCache() |
|
| 2047 |
+ let sampleTypeID = try upsertSampleType( |
|
| 2048 |
+ typeIdentifier: sampleType.identifier, |
|
| 2049 |
+ db: db, |
|
| 2050 |
+ statementCache: statementCache, |
|
| 2051 |
+ lookupCache: lookupCache |
|
| 2052 |
+ ) |
|
| 2053 |
+ guard let previousSummary = try previousTypeSummary( |
|
| 2054 |
+ sampleTypeID: sampleTypeID, |
|
| 2055 |
+ beforeObservationID: observationID, |
|
| 2056 |
+ db: db, |
|
| 2057 |
+ statementCache: statementCache |
|
| 2058 |
+ ) else {
|
|
| 2059 |
+ try markVerification( |
|
| 2060 |
+ sampleType: sampleType, |
|
| 2061 |
+ verifiedAt: verifiedAt, |
|
| 2062 |
+ observationID: observationID, |
|
| 2063 |
+ db: db |
|
| 2064 |
+ ) |
|
| 2065 |
+ return |
|
| 2066 |
+ } |
|
| 2067 |
+ |
|
| 2068 |
+ try insertObservationTypeRun( |
|
| 2069 |
+ observationID: observationID, |
|
| 2070 |
+ sampleTypeID: sampleTypeID, |
|
| 2071 |
+ status: "completed", |
|
| 2072 |
+ observedAt: verifiedAt, |
|
| 2073 |
+ insertedEventCount: 0, |
|
| 2074 |
+ deletedEventCount: 0, |
|
| 2075 |
+ verifiedVisibleCount: previousSummary.aggregate.visibleRecordCount, |
|
| 2076 |
+ db: db, |
|
| 2077 |
+ statementCache: statementCache |
|
| 2078 |
+ ) |
|
| 2079 |
+ try upsertTypeSummary( |
|
| 2080 |
+ observationID: observationID, |
|
| 2081 |
+ sampleTypeID: sampleTypeID, |
|
| 2082 |
+ counts: (appeared: 0, disappeared: 0, representationChanged: 0), |
|
| 2083 |
+ aggregate: previousSummary.aggregate, |
|
| 2084 |
+ db: db |
|
| 2085 |
+ ) |
|
| 2086 |
+ try copyDailyAggregates( |
|
| 2087 |
+ fromObservationID: previousSummary.observationID, |
|
| 2088 |
+ toObservationID: observationID, |
|
| 2089 |
+ sampleTypeID: sampleTypeID, |
|
| 2090 |
+ db: db |
|
| 2091 |
+ ) |
|
| 2092 |
+ } |
|
| 2093 |
+ |
|
| 2019 | 2094 |
private func recordDisappearance( |
| 2020 | 2095 |
sampleUUIDHash: String, |
| 2021 | 2096 |
sampleTypeIdentifier: String, |
@@ -103,6 +103,36 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 103 | 103 |
XCTAssertFalse(try tableExists("archive_samples", at: url))
|
| 104 | 104 |
} |
| 105 | 105 |
|
| 106 |
+ func testUnchangedVerificationCopiesPreviousSummaryWithoutNewEvents() async throws {
|
|
| 107 |
+ let url = databaseURL() |
|
| 108 |
+ let store = SQLiteHealthArchiveStore(databaseURL: url) |
|
| 109 |
+ let sample = makeStepCountSample() |
|
| 110 |
+ |
|
| 111 |
+ _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000)) |
|
| 112 |
+ try await store.markVerification(sampleType: sample.sampleType, verifiedAt: Date(timeIntervalSince1970: 2_060)) |
|
| 113 |
+ let unchangedObservationID = try await store.beginObservation( |
|
| 114 |
+ observedAt: Date(timeIntervalSince1970: 2_120), |
|
| 115 |
+ triggerReason: "manual", |
|
| 116 |
+ selectedTypeSetHash: "selected-types" |
|
| 117 |
+ ) |
|
| 118 |
+ try await store.markUnchangedVerification( |
|
| 119 |
+ sampleType: sample.sampleType, |
|
| 120 |
+ verifiedAt: Date(timeIntervalSince1970: 2_120), |
|
| 121 |
+ observationID: unchangedObservationID |
|
| 122 |
+ ) |
|
| 123 |
+ try await store.finishObservation( |
|
| 124 |
+ observationID: unchangedObservationID, |
|
| 125 |
+ status: "completed", |
|
| 126 |
+ endedAt: Date(timeIntervalSince1970: 2_121) |
|
| 127 |
+ ) |
|
| 128 |
+ |
|
| 129 |
+ XCTAssertEqual(try countRows(in: "sample_observation_events", at: url), 1) |
|
| 130 |
+ XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(unchangedObservationID) AND inserted_event_count = 0 AND deleted_event_count = 0 AND verified_visible_count = 1", at: url), 1) |
|
| 131 |
+ XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(unchangedObservationID) AND visible_record_count = 1 AND appeared_count = 0 AND disappeared_count = 0 AND representation_changed_count = 0", at: url), 1) |
|
| 132 |
+ XCTAssertEqual(try countRows(in: "daily_type_aggregates WHERE observation_id = \(unchangedObservationID) AND visible_record_count = 1", at: url), 1) |
|
| 133 |
+ XCTAssertFalse(try tableExists("archive_samples", at: url))
|
|
| 134 |
+ } |
|
| 135 |
+ |
|
| 106 | 136 |
func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
|
| 107 | 137 |
let url = databaseURL() |
| 108 | 138 |
let store = SQLiteHealthArchiveStore(databaseURL: url) |