@@ -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, |
@@ -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( |