Showing 4 changed files with 164 additions and 8 deletions
+31 -0
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -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.
+90 -8
HealthProbe/Services/HealthKitService.swift
@@ -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
+6 -0
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -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
+37 -0
HealthProbeTests/HealthKitAPICallResultTests.swift
@@ -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
+}