Showing 2 changed files with 124 additions and 7 deletions
+34 -0
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -71,6 +71,40 @@ Interpretation rules:
71 71
 
72 72
 ## Real-Device Results
73 73
 
74
+### 2026-06-07 Empty-Type Regression During 127-Type First Import
75
+
76
+Source: user screenshot and overnight reports on build fingerprint
77
+`1.0(1)-1780767682-92064`.
78
+
79
+Observed behavior:
80
+- types with no HealthKit records regressed from effectively instant to timeout-
81
+  scale work;
82
+- the overnight reports show many degraded metrics with `record_import:
83
+  unknown` and both `earliest_sample` / `latest_sample` timing out;
84
+- a live screenshot showed `Zinc` stuck for more than 5 minutes at
85
+  `Import page 1 (total unknown)`, `0/127 fetched`, `0 records`;
86
+- that means at least one empty type got past boundary detection and entered the
87
+  import page loop, where it did not make progress.
88
+
89
+Interpretation:
90
+- this is an app-side regression from the recent fetch/timeout path, not a debug
91
+  build explanation; debug build overhead remains relevant only when comparing
92
+  final background-fetch benchmark numbers;
93
+- empty types must not run a full `latest_sample` or `record_import` after
94
+  `earliest_sample` has completed with `none`;
95
+- preserving an anchor for empty types is less important than avoiding a stalled
96
+  import on the first metric. Future runs can re-check the cheap empty
97
+  `earliest_sample` path.
98
+
99
+Action taken:
100
+- serialize boundary lookup so `latest_sample` only runs after
101
+  `earliest_sample` found an actual sample;
102
+- when `earliest_sample` returns `none`, complete the type immediately with
103
+  `count = 0`, empty content hash, and diagnostic calls marking
104
+  `record_import` / `latest_sample` as skipped complete work;
105
+- keep failed `earliest_sample` behavior as failed/timeout, because no safe zero
106
+  inference is possible when HealthKit did not answer the existence query.
107
+
74 108
 ### 2026-06-07 Overnight Initial Imports On Both Large Devices
75 109
 
76 110
 Source: two user-provided overnight first-import reports from large devices,
+90 -7
HealthProbe/Services/HealthKitService.swift
@@ -934,7 +934,7 @@ final class HealthKitService {
934 934
         progress: SnapshotFetchProgress?
935 935
     ) async -> TypeCountFetchResult {
936 936
         let dateFetchTimer = MonotonicTimer()
937
-        async let earliestTask = measureAPICall(
937
+        let earliestResult = await measureAPICall(
938 938
             queryType: "earliest_sample",
939 939
             timeoutSeconds: timeoutSeconds
940 940
         ) {
@@ -942,7 +942,85 @@ final class HealthKitService {
942 942
         } resultDescription: { date in
943 943
             Self.iso8601String(for: date)
944 944
         }
945
-        async let latestTask = measureAPICall(
945
+
946
+        guard earliestResult.apiCall.status == .complete else {
947
+            let apiCalls = [
948
+                Self.placeholderAPICall(
949
+                    queryType: "record_import",
950
+                    status: .unknown,
951
+                    message: "Skipped because earliest sample lookup failed."
952
+                ),
953
+                earliestResult.apiCall,
954
+                Self.placeholderAPICall(
955
+                    queryType: "latest_sample",
956
+                    status: .unknown,
957
+                    message: "Skipped because earliest sample lookup failed."
958
+                )
959
+            ]
960
+            let status = firstImpairedStatus(in: apiCalls)
961
+            let quality = diagnosticQuality(for: status)
962
+            var result = TypeCountFetchResult(
963
+                typeIdentifier: monitoredType.id,
964
+                displayName: monitoredType.displayName,
965
+                count: -1,
966
+                contentHash: "",
967
+                earliestDate: earliestResult.value ?? nil,
968
+                latestDate: nil,
969
+                quality: snapshotQuality(for: status),
970
+                diagnosticQuality: quality,
971
+                isUnsupported: false,
972
+                authorizationStatus: authorizationStatus(from: apiCalls),
973
+                apiCalls: apiCalls,
974
+                yearlyCounts: [],
975
+                distributionBins: [],
976
+                records: [],
977
+                recordArchiveData: nil
978
+            )
979
+            result.timingBreakdown.fetchElapsedSeconds = dateFetchTimer.elapsedSeconds
980
+            return result
981
+        }
982
+
983
+        let earliest = earliestResult.value ?? nil
984
+        if earliest == nil {
985
+            let dateFetchElapsedSeconds = dateFetchTimer.elapsedSeconds
986
+            var result = TypeCountFetchResult(
987
+                typeIdentifier: monitoredType.id,
988
+                displayName: monitoredType.displayName,
989
+                count: 0,
990
+                contentHash: HashService.typeHash(typeIdentifier: monitoredType.id, recordFingerprints: []),
991
+                earliestDate: nil,
992
+                latestDate: nil,
993
+                quality: .complete,
994
+                diagnosticQuality: HealthKitAPICallResult.Status.complete.rawValue,
995
+                isUnsupported: false,
996
+                authorizationStatus: "granted",
997
+                apiCalls: [
998
+                    HealthKitAPICallResult(
999
+                        queryType: "record_import",
1000
+                        status: .complete,
1001
+                        elapsedSeconds: 0,
1002
+                        resultValue: "0 samples (skipped after empty earliest_sample)"
1003
+                    ),
1004
+                    earliestResult.apiCall,
1005
+                    HealthKitAPICallResult(
1006
+                        queryType: "latest_sample",
1007
+                        status: .complete,
1008
+                        elapsedSeconds: 0,
1009
+                        resultValue: "none (skipped after empty earliest_sample)"
1010
+                    )
1011
+                ],
1012
+                yearlyCounts: [],
1013
+                distributionBins: [],
1014
+                records: [],
1015
+                recordArchiveData: nil
1016
+            )
1017
+            result.captureMode = SampleDistribution.CaptureMode.initialImport.diagnosticValue
1018
+            result.timingBreakdown.fetchElapsedSeconds = dateFetchElapsedSeconds
1019
+            return result
1020
+        }
1021
+
1022
+        let latestResult: APICallMeasurement<Date?>
1023
+        latestResult = await measureAPICall(
946 1024
             queryType: "latest_sample",
947 1025
             timeoutSeconds: timeoutSeconds
948 1026
         ) {
@@ -950,12 +1028,18 @@ final class HealthKitService {
950 1028
         } resultDescription: { date in
951 1029
             Self.iso8601String(for: date)
952 1030
         }
953
-        let earliestResult = await earliestTask
954
-        let latestResult = await latestTask
955 1031
         var apiCalls = [earliestResult.apiCall, latestResult.apiCall]
956 1032
         let dateFetchElapsedSeconds = dateFetchTimer.elapsedSeconds
957 1033
 
958
-        guard earliestResult.apiCall.status == .complete, latestResult.apiCall.status == .complete else {
1034
+        guard latestResult.apiCall.status == .complete else {
1035
+            apiCalls.insert(
1036
+                Self.placeholderAPICall(
1037
+                    queryType: "record_import",
1038
+                    status: .unknown,
1039
+                    message: "Skipped because latest sample lookup failed."
1040
+                ),
1041
+                at: 0
1042
+            )
959 1043
             let status = firstImpairedStatus(in: apiCalls)
960 1044
             let quality = diagnosticQuality(for: status)
961 1045
             var result = TypeCountFetchResult(
@@ -963,7 +1047,7 @@ final class HealthKitService {
963 1047
                 displayName: monitoredType.displayName,
964 1048
                 count: -1,
965 1049
                 contentHash: "",
966
-                earliestDate: earliestResult.value ?? nil,
1050
+                earliestDate: earliest,
967 1051
                 latestDate: latestResult.value ?? nil,
968 1052
                 quality: snapshotQuality(for: status),
969 1053
                 diagnosticQuality: quality,
@@ -979,7 +1063,6 @@ final class HealthKitService {
979 1063
             return result
980 1064
         }
981 1065
 
982
-        let earliest = earliestResult.value ?? nil
983 1066
         let latest = latestResult.value ?? nil
984 1067
         let previousArchiveState = try? await archiveStore.typeCaptureState(sampleTypeIdentifier: monitoredType.id)
985 1068
         let previousDistribution = PreviousDistributionState(