@@ -71,6 +71,37 @@ Interpretation rules: |
||
| 71 | 71 |
|
| 72 | 72 |
## Real-Device Results |
| 73 | 73 |
|
| 74 |
+### 2026-06-07 Overnight Initial Imports On Both Large Devices |
|
| 75 |
+ |
|
| 76 |
+Source: two user-provided overnight first-import reports from large devices, |
|
| 77 |
+both on build fingerprint `1.0(1)-1780767682-92064`. |
|
| 78 |
+ |
|
| 79 |
+Observed behavior: |
|
| 80 |
+- report A: `264,844` records, `80 complete / 47 degraded`, wall clock |
|
| 81 |
+ `405m 54s`, summed fetch `157m 3s`, summed insert `56.6s`; |
|
| 82 |
+- report B: `783,190` records, `51 complete / 76 degraded`, wall clock |
|
| 83 |
+ `250m 47s`, summed fetch `237m 43s`, summed insert `9m 5s`; |
|
| 84 |
+- the dominant regression was not SQLite write time; it was fetch time spent on |
|
| 85 |
+ failed or stalled type-boundary queries; |
|
| 86 |
+- one report showed explicit `HKErrorDatabaseInaccessible` (`Protected health |
|
| 87 |
+ data is inaccessible`) for many failed types, while the other showed the same |
|
| 88 |
+ failure mode amplified by adaptive timeout-based retries. |
|
| 89 |
+ |
|
| 90 |
+Interpretation: |
|
| 91 |
+- this is primarily a HealthKit availability / device-lock regression path, not |
|
| 92 |
+ an archive insert regression; |
|
| 93 |
+- debug binaries may add overhead, but they do not explain multi-hour fetch-only |
|
| 94 |
+ inflation while insert remains in seconds or single-digit minutes; |
|
| 95 |
+- once protected health data becomes inaccessible mid-run, continuing to probe |
|
| 96 |
+ all remaining types wastes wall-clock time and pollutes timeout learning. |
|
| 97 |
+ |
|
| 98 |
+Action taken: |
|
| 99 |
+- detect `HKErrorDatabaseInaccessible` explicitly; |
|
| 100 |
+- stop inferring that protected-data failures should keep probing the remaining |
|
| 101 |
+ types; |
|
| 102 |
+- fast-fail all remaining types in the same snapshot once protected data becomes |
|
| 103 |
+ unavailable, instead of paying one timeout/error cycle per metric. |
|
| 104 |
+ |
|
| 74 | 105 |
### 2026-06-02 Baseline Before Latest Batch/Chunk Work |
| 75 | 106 |
|
| 76 | 107 |
Source: user-provided diagnostic report. |
@@ -758,6 +758,7 @@ final class HealthKitService {
|
||
| 758 | 758 |
) async -> [TypeCount] {
|
| 759 | 759 |
var typeCounts: [TypeCount] = [] |
| 760 | 760 |
typeCounts.reserveCapacity(active.count) |
| 761 |
+ var protectedDataBecameUnavailable = !UIApplication.shared.isProtectedDataAvailable |
|
| 761 | 762 |
|
| 762 | 763 |
for monitoredType in active {
|
| 763 | 764 |
var profile = timeoutProfile(for: monitoredType) |
@@ -766,14 +767,27 @@ final class HealthKitService {
|
||
| 766 | 767 |
adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled, |
| 767 | 768 |
timeoutMultiplier: timeoutMultiplier |
| 768 | 769 |
) |
| 769 |
- var result = await fetchTypeCountData( |
|
| 770 |
- for: monitoredType, |
|
| 771 |
- timeoutProfile: profile, |
|
| 772 |
- timeoutSeconds: timeout, |
|
| 773 |
- previousTypeCount: previousSnapshot?.typeCounts?.first { $0.typeIdentifier == monitoredType.id },
|
|
| 774 |
- archiveObservationID: archiveObservationID, |
|
| 775 |
- progress: progress |
|
| 776 |
- ) |
|
| 770 |
+ var result: TypeCountFetchResult |
|
| 771 |
+ if protectedDataBecameUnavailable {
|
|
| 772 |
+ result = protectedDataUnavailableResult( |
|
| 773 |
+ for: monitoredType, |
|
| 774 |
+ timeoutProfile: profile, |
|
| 775 |
+ timeoutSeconds: timeout, |
|
| 776 |
+ progress: progress |
|
| 777 |
+ ) |
|
| 778 |
+ } else {
|
|
| 779 |
+ result = await fetchTypeCountData( |
|
| 780 |
+ for: monitoredType, |
|
| 781 |
+ timeoutProfile: profile, |
|
| 782 |
+ timeoutSeconds: timeout, |
|
| 783 |
+ previousTypeCount: previousSnapshot?.typeCounts?.first { $0.typeIdentifier == monitoredType.id },
|
|
| 784 |
+ archiveObservationID: archiveObservationID, |
|
| 785 |
+ progress: progress |
|
| 786 |
+ ) |
|
| 787 |
+ if result.indicatesProtectedDataInaccessible {
|
|
| 788 |
+ protectedDataBecameUnavailable = true |
|
| 789 |
+ } |
|
| 790 |
+ } |
|
| 777 | 791 |
updateTimeoutProfile(&profile, with: result, monitoredType: monitoredType) |
| 778 | 792 |
LocalMetricTimeoutProfileStore.save(profile) |
| 779 | 793 |
result.applyTimeoutProfile(profile) |
@@ -785,6 +799,67 @@ final class HealthKitService {
|
||
| 785 | 799 |
return typeCounts |
| 786 | 800 |
} |
| 787 | 801 |
|
| 802 |
+ private func protectedDataUnavailableResult( |
|
| 803 |
+ for monitoredType: MonitoredType, |
|
| 804 |
+ timeoutProfile: LocalMetricTimeoutProfile, |
|
| 805 |
+ timeoutSeconds: TimeInterval, |
|
| 806 |
+ progress: SnapshotFetchProgress? |
|
| 807 |
+ ) -> TypeCountFetchResult {
|
|
| 808 |
+ let message = "Protected health data is inaccessible because the device is locked." |
|
| 809 |
+ let call = HealthKitAPICallResult( |
|
| 810 |
+ queryType: "earliest_sample", |
|
| 811 |
+ status: .failed, |
|
| 812 |
+ elapsedSeconds: 0, |
|
| 813 |
+ resultValue: nil, |
|
| 814 |
+ errorCode: "\(HKError.Code.errorDatabaseInaccessible.rawValue)", |
|
| 815 |
+ errorDomain: HKError.errorDomain, |
|
| 816 |
+ errorDescription: message, |
|
| 817 |
+ failureKind: "HealthKit error", |
|
| 818 |
+ cancellationReason: nil |
|
| 819 |
+ ) |
|
| 820 |
+ let companionCall = HealthKitAPICallResult( |
|
| 821 |
+ queryType: "latest_sample", |
|
| 822 |
+ status: .failed, |
|
| 823 |
+ elapsedSeconds: 0, |
|
| 824 |
+ resultValue: nil, |
|
| 825 |
+ errorCode: "\(HKError.Code.errorDatabaseInaccessible.rawValue)", |
|
| 826 |
+ errorDomain: HKError.errorDomain, |
|
| 827 |
+ errorDescription: message, |
|
| 828 |
+ failureKind: "HealthKit error", |
|
| 829 |
+ cancellationReason: nil |
|
| 830 |
+ ) |
|
| 831 |
+ var result = TypeCountFetchResult( |
|
| 832 |
+ typeIdentifier: monitoredType.id, |
|
| 833 |
+ displayName: monitoredType.displayName, |
|
| 834 |
+ count: -1, |
|
| 835 |
+ contentHash: "", |
|
| 836 |
+ earliestDate: nil, |
|
| 837 |
+ latestDate: nil, |
|
| 838 |
+ quality: .failed, |
|
| 839 |
+ diagnosticQuality: HealthKitAPICallResult.Status.failed.rawValue, |
|
| 840 |
+ isUnsupported: false, |
|
| 841 |
+ authorizationStatus: "unavailable", |
|
| 842 |
+ apiCalls: [ |
|
| 843 |
+ Self.placeholderAPICall( |
|
| 844 |
+ queryType: "record_import", |
|
| 845 |
+ status: .unknown, |
|
| 846 |
+ message: "Skipped because protected health data became inaccessible earlier in this run." |
|
| 847 |
+ ), |
|
| 848 |
+ call, |
|
| 849 |
+ companionCall |
|
| 850 |
+ ], |
|
| 851 |
+ yearlyCounts: [], |
|
| 852 |
+ distributionBins: [], |
|
| 853 |
+ records: [], |
|
| 854 |
+ recordArchiveData: nil |
|
| 855 |
+ ) |
|
| 856 |
+ result.timeoutConfiguredSeconds = timeoutSeconds |
|
| 857 |
+ result.applyTimeoutProfile(timeoutProfile) |
|
| 858 |
+ progress?.updateDetails(from: result) |
|
| 859 |
+ progress?.updateStatus(monitoredType.id, status: .failed("Protected data unavailable"))
|
|
| 860 |
+ return result |
|
| 861 |
+ } |
|
| 862 |
+ |
|
| 788 | 863 |
private func fetchTypeCountData( |
| 789 | 864 |
for monitoredType: MonitoredType, |
| 790 | 865 |
timeoutProfile: LocalMetricTimeoutProfile, |
@@ -2153,6 +2228,9 @@ final class HealthKitService {
|
||
| 2153 | 2228 |
if apiCalls.contains(where: { $0.status == .unauthorized }) {
|
| 2154 | 2229 |
return "denied" |
| 2155 | 2230 |
} |
| 2231 |
+ if apiCalls.contains(where: \.indicatesProtectedDataInaccessible) {
|
|
| 2232 |
+ return "unavailable" |
|
| 2233 |
+ } |
|
| 2156 | 2234 |
return HealthKitAPICallResult.Status.unknown.rawValue |
| 2157 | 2235 |
} |
| 2158 | 2236 |
|
@@ -2762,6 +2840,10 @@ private struct TypeCountFetchResult: Sendable {
|
||
| 2762 | 2840 |
var successCount: Int = 0 |
| 2763 | 2841 |
var timingBreakdown: ImportTimingBreakdown = .zero |
| 2764 | 2842 |
|
| 2843 |
+ var indicatesProtectedDataInaccessible: Bool {
|
|
| 2844 |
+ apiCalls.contains(where: \.indicatesProtectedDataInaccessible) |
|
| 2845 |
+ } |
|
| 2846 |
+ |
|
| 2765 | 2847 |
mutating func applyTimeoutProfile(_ profile: LocalMetricTimeoutProfile) {
|
| 2766 | 2848 |
timeoutMode = profile.timeoutMode |
| 2767 | 2849 |
lastSuccessfulElapsed = profile.lastSuccessfulElapsed |
@@ -1,4 +1,5 @@ |
||
| 1 | 1 |
import Foundation |
| 2 |
+import HealthKit |
|
| 2 | 3 |
|
| 3 | 4 |
struct ImportTimingBreakdown: Sendable, Equatable {
|
| 4 | 5 |
var fetchElapsedSeconds: TimeInterval = 0 |
@@ -108,6 +109,11 @@ struct HealthKitAPICallResult: Sendable, Equatable {
|
||
| 108 | 109 |
case .unsupported: return "unsupported" |
| 109 | 110 |
} |
| 110 | 111 |
} |
| 112 |
+ |
|
| 113 |
+ var indicatesProtectedDataInaccessible: Bool {
|
|
| 114 |
+ errorDomain == HKError.errorDomain |
|
| 115 |
+ && errorCode == "\(HKError.Code.errorDatabaseInaccessible.rawValue)" |
|
| 116 |
+ } |
|
| 111 | 117 |
} |
| 112 | 118 |
|
| 113 | 119 |
@Observable |
@@ -0,0 +1,37 @@ |
||
| 1 |
+import HealthKit |
|
| 2 |
+import XCTest |
|
| 3 |
+@testable import HealthProbe |
|
| 4 |
+ |
|
| 5 |
+final class HealthKitAPICallResultTests: XCTestCase {
|
|
| 6 |
+ func testProtectedDataInaccessibleDetectionRecognizesHealthKitDatabaseLockError() {
|
|
| 7 |
+ let result = HealthKitAPICallResult( |
|
| 8 |
+ queryType: "earliest_sample", |
|
| 9 |
+ status: .failed, |
|
| 10 |
+ elapsedSeconds: 0, |
|
| 11 |
+ resultValue: nil, |
|
| 12 |
+ errorCode: "\(HKError.Code.errorDatabaseInaccessible.rawValue)", |
|
| 13 |
+ errorDomain: HKError.errorDomain, |
|
| 14 |
+ errorDescription: "Protected health data is inaccessible because the device is locked.", |
|
| 15 |
+ failureKind: "HealthKit error", |
|
| 16 |
+ cancellationReason: nil |
|
| 17 |
+ ) |
|
| 18 |
+ |
|
| 19 |
+ XCTAssertTrue(result.indicatesProtectedDataInaccessible) |
|
| 20 |
+ } |
|
| 21 |
+ |
|
| 22 |
+ func testProtectedDataInaccessibleDetectionIgnoresOtherHealthKitFailures() {
|
|
| 23 |
+ let result = HealthKitAPICallResult( |
|
| 24 |
+ queryType: "earliest_sample", |
|
| 25 |
+ status: .failed, |
|
| 26 |
+ elapsedSeconds: 0, |
|
| 27 |
+ resultValue: nil, |
|
| 28 |
+ errorCode: "\(HKError.Code.errorAuthorizationDenied.rawValue)", |
|
| 29 |
+ errorDomain: HKError.errorDomain, |
|
| 30 |
+ errorDescription: "Authorization denied.", |
|
| 31 |
+ failureKind: "HealthKit error", |
|
| 32 |
+ cancellationReason: nil |
|
| 33 |
+ ) |
|
| 34 |
+ |
|
| 35 |
+ XCTAssertFalse(result.indicatesProtectedDataInaccessible) |
|
| 36 |
+ } |
|
| 37 |
+} |
|