@@ -52,7 +52,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 52 | 52 |
- Snapshots timeline/detail rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist. |
| 53 | 53 |
- Legacy SwiftData-only snapshots are reset for archive v2 test installs rather than migrated. |
| 54 | 54 |
- Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices. |
| 55 |
-- Very large first-run HealthKit imports may still require adaptive paging, retryable partial progress, and background-friendly collection beyond the current smaller pages, chunked persistence, and prepared-statement reuse. |
|
| 55 |
+- Very large first-run HealthKit imports may still require adaptive paging, retryable partial progress, and background-friendly collection beyond the current smaller pages, chunked persistence, and prepared-statement reuse. Diagnostic import reports now also expose explicit per-metric and aggregate fetch / processing / insert / finalize timings so large import runs can be compared without inferring phases from progress counters. |
|
| 56 | 56 |
- Old prototype database compatibility is no longer required. |
| 57 | 57 |
- Initial SQLite archive tests cover open/init/reset/idempotency, snapshot-level observation grouping, legacy mirror removal, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, consolidation-evidence labels, export preview, paged JSON output, and manifest row persistence. |
| 58 | 58 |
- Initial Core Data cache tests cover full rebuild from SQLite and delete-cache-then-rebuild without losing archive data. |
@@ -932,11 +932,12 @@ final class HealthKitService {
|
||
| 932 | 932 |
let earliestResult = await earliestTask |
| 933 | 933 |
let latestResult = await latestTask |
| 934 | 934 |
var apiCalls = [earliestResult.apiCall, latestResult.apiCall] |
| 935 |
+ let dateFetchElapsedSeconds = earliestResult.apiCall.elapsedSeconds + latestResult.apiCall.elapsedSeconds |
|
| 935 | 936 |
|
| 936 | 937 |
guard earliestResult.apiCall.status == .complete, latestResult.apiCall.status == .complete else {
|
| 937 | 938 |
let status = firstImpairedStatus(in: apiCalls) |
| 938 | 939 |
let quality = diagnosticQuality(for: status) |
| 939 |
- return TypeCountFetchResult( |
|
| 940 |
+ var result = TypeCountFetchResult( |
|
| 940 | 941 |
typeIdentifier: monitoredType.id, |
| 941 | 942 |
displayName: monitoredType.displayName, |
| 942 | 943 |
count: -1, |
@@ -953,6 +954,8 @@ final class HealthKitService {
|
||
| 953 | 954 |
records: [], |
| 954 | 955 |
recordArchiveData: nil |
| 955 | 956 |
) |
| 957 |
+ result.timingBreakdown.fetchElapsedSeconds = dateFetchElapsedSeconds |
|
| 958 |
+ return result |
|
| 956 | 959 |
} |
| 957 | 960 |
|
| 958 | 961 |
let earliest = earliestResult.value ?? nil |
@@ -979,7 +982,7 @@ final class HealthKitService {
|
||
| 979 | 982 |
guard distributionResult.apiCall.status == .complete, let distribution = distributionResult.value else {
|
| 980 | 983 |
let status = distributionResult.apiCall.status |
| 981 | 984 |
let quality = diagnosticQuality(for: status) |
| 982 |
- return TypeCountFetchResult( |
|
| 985 |
+ var result = TypeCountFetchResult( |
|
| 983 | 986 |
typeIdentifier: monitoredType.id, |
| 984 | 987 |
displayName: monitoredType.displayName, |
| 985 | 988 |
count: -1, |
@@ -996,6 +999,8 @@ final class HealthKitService {
|
||
| 996 | 999 |
records: [], |
| 997 | 1000 |
recordArchiveData: nil |
| 998 | 1001 |
) |
| 1002 |
+ result.timingBreakdown.fetchElapsedSeconds = dateFetchElapsedSeconds |
|
| 1003 |
+ return result |
|
| 999 | 1004 |
} |
| 1000 | 1005 |
|
| 1001 | 1006 |
let contentHash = distribution.contentHash ?? HashService.typeHash( |
@@ -1028,9 +1033,13 @@ final class HealthKitService {
|
||
| 1028 | 1033 |
detail: "Preparing record archive", |
| 1029 | 1034 |
recordCount: distribution.totalCount |
| 1030 | 1035 |
) |
| 1036 |
+ var timingBreakdown = distribution.timingBreakdown |
|
| 1037 |
+ timingBreakdown.fetchElapsedSeconds += dateFetchElapsedSeconds |
|
| 1038 |
+ let recordArchiveStartedAt = Date() |
|
| 1031 | 1039 |
let recordArchiveData = distribution.recordArchiveData ?? HealthRecordArchive.encode(distribution.records) |
| 1040 |
+ timingBreakdown.processingElapsedSeconds += Date().timeIntervalSince(recordArchiveStartedAt) |
|
| 1032 | 1041 |
|
| 1033 |
- return TypeCountFetchResult( |
|
| 1042 |
+ var result = TypeCountFetchResult( |
|
| 1034 | 1043 |
typeIdentifier: monitoredType.id, |
| 1035 | 1044 |
displayName: monitoredType.displayName, |
| 1036 | 1045 |
count: distribution.totalCount, |
@@ -1055,6 +1064,8 @@ final class HealthKitService {
|
||
| 1055 | 1064 |
records: [], |
| 1056 | 1065 |
recordArchiveData: recordArchiveData |
| 1057 | 1066 |
) |
| 1067 |
+ result.timingBreakdown = timingBreakdown |
|
| 1068 |
+ return result |
|
| 1058 | 1069 |
} |
| 1059 | 1070 |
|
| 1060 | 1071 |
// MARK: - HealthKit queries |
@@ -1072,6 +1083,7 @@ final class HealthKitService {
|
||
| 1072 | 1083 |
let startedFromAnchor = anchor != nil |
| 1073 | 1084 |
let incrementalStrategy = DistributionCaptureConfiguration.incrementalStrategy |
| 1074 | 1085 |
var persistenceState = incrementalStrategy.makePersistenceState() |
| 1086 |
+ var captureTimings = DistributionCaptureTimings.zero |
|
| 1075 | 1087 |
let estimatedPageCount = startedFromAnchor |
| 1076 | 1088 |
? Self.estimatedPageCount( |
| 1077 | 1089 |
for: previousDistribution.count, |
@@ -1097,6 +1109,7 @@ final class HealthKitService {
|
||
| 1097 | 1109 |
samplesPerSecond: 0 |
| 1098 | 1110 |
) |
| 1099 | 1111 |
|
| 1112 |
+ let firstPageFetchStartedAt = Date() |
|
| 1100 | 1113 |
let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
|
| 1101 | 1114 |
try await self.fetchDistributionPage( |
| 1102 | 1115 |
for: sampleType, |
@@ -1105,6 +1118,7 @@ final class HealthKitService {
|
||
| 1105 | 1118 |
limit: incrementalStrategy.queryPageLimit |
| 1106 | 1119 |
) |
| 1107 | 1120 |
} |
| 1121 |
+ captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(firstPageFetchStartedAt) |
|
| 1108 | 1122 |
let archiveResult = try await archivePage( |
| 1109 | 1123 |
page, |
| 1110 | 1124 |
sampleType: sampleType, |
@@ -1118,6 +1132,7 @@ final class HealthKitService {
|
||
| 1118 | 1132 |
persistenceState: persistenceState |
| 1119 | 1133 |
) |
| 1120 | 1134 |
persistenceState = archiveResult.persistenceState |
| 1135 |
+ captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds |
|
| 1121 | 1136 |
anchor = page.anchor |
| 1122 | 1137 |
|
| 1123 | 1138 |
if page.samples.isEmpty, page.deletedObjects.isEmpty, |
@@ -1127,11 +1142,13 @@ final class HealthKitService {
|
||
| 1127 | 1142 |
earliestDate: earliestDate, |
| 1128 | 1143 |
latestDate: latestDate |
| 1129 | 1144 |
) {
|
| 1145 |
+ let verificationStartedAt = Date() |
|
| 1130 | 1146 |
try await archiveStore.markVerification( |
| 1131 | 1147 |
sampleType: sampleType, |
| 1132 | 1148 |
verifiedAt: Date(), |
| 1133 | 1149 |
observationID: archiveObservationID |
| 1134 | 1150 |
) |
| 1151 |
+ captureTimings.finalizeElapsedSeconds += Date().timeIntervalSince(verificationStartedAt) |
|
| 1135 | 1152 |
progress?.updateBlockProgress( |
| 1136 | 1153 |
typeIdentifier, |
| 1137 | 1154 |
detail: "No HealthKit delta", |
@@ -1139,7 +1156,15 @@ final class HealthKitService {
|
||
| 1139 | 1156 |
elapsedSeconds: Date().timeIntervalSince(progressStarted), |
| 1140 | 1157 |
samplesPerSecond: 0 |
| 1141 | 1158 |
) |
| 1142 |
- return unchanged |
|
| 1159 |
+ return SampleDistribution( |
|
| 1160 |
+ totalCount: unchanged.totalCount, |
|
| 1161 |
+ bins: unchanged.bins, |
|
| 1162 |
+ records: unchanged.records, |
|
| 1163 |
+ contentHash: unchanged.contentHash, |
|
| 1164 |
+ yearlyCounts: unchanged.yearlyCounts, |
|
| 1165 |
+ recordArchiveData: unchanged.recordArchiveData, |
|
| 1166 |
+ timingBreakdown: captureTimings.importBreakdown |
|
| 1167 |
+ ) |
|
| 1143 | 1168 |
} |
| 1144 | 1169 |
|
| 1145 | 1170 |
firstDeltaPage = page |
@@ -1157,11 +1182,15 @@ final class HealthKitService {
|
||
| 1157 | 1182 |
) |
| 1158 | 1183 |
} |
| 1159 | 1184 |
|
| 1185 |
+ let recordMapStartedAt = Date() |
|
| 1160 | 1186 |
var recordMap = startedFromAnchor ? try previousDistribution.makeRecordMap() : [:] |
| 1187 |
+ captureTimings.processingElapsedSeconds += Date().timeIntervalSince(recordMapStartedAt) |
|
| 1161 | 1188 |
var shouldFetchNextPage = true |
| 1162 | 1189 |
|
| 1163 | 1190 |
if let firstDeltaPage {
|
| 1191 |
+ let firstPageApplyStartedAt = Date() |
|
| 1164 | 1192 |
applyDistributionPage(firstDeltaPage, sampleType: sampleType, to: &recordMap) |
| 1193 |
+ captureTimings.processingElapsedSeconds += Date().timeIntervalSince(firstPageApplyStartedAt) |
|
| 1165 | 1194 |
processedEventCount += pageEventCount(firstDeltaPage) |
| 1166 | 1195 |
shouldFetchNextPage = firstDeltaPage.samples.count + firstDeltaPage.deletedObjects.count >= incrementalStrategy.queryPageLimit |
| 1167 | 1196 |
progress?.updateBlockProgress( |
@@ -1197,6 +1226,7 @@ final class HealthKitService {
|
||
| 1197 | 1226 |
) |
| 1198 | 1227 |
) |
| 1199 | 1228 |
|
| 1229 |
+ let pageFetchStartedAt = Date() |
|
| 1200 | 1230 |
let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
|
| 1201 | 1231 |
try await self.fetchDistributionPage( |
| 1202 | 1232 |
for: sampleType, |
@@ -1205,6 +1235,7 @@ final class HealthKitService {
|
||
| 1205 | 1235 |
limit: incrementalStrategy.queryPageLimit |
| 1206 | 1236 |
) |
| 1207 | 1237 |
} |
| 1238 |
+ captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(pageFetchStartedAt) |
|
| 1208 | 1239 |
let archiveResult = try await archivePage( |
| 1209 | 1240 |
page, |
| 1210 | 1241 |
sampleType: sampleType, |
@@ -1218,9 +1249,12 @@ final class HealthKitService {
|
||
| 1218 | 1249 |
persistenceState: persistenceState |
| 1219 | 1250 |
) |
| 1220 | 1251 |
persistenceState = archiveResult.persistenceState |
| 1252 |
+ captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds |
|
| 1221 | 1253 |
anchor = page.anchor |
| 1222 | 1254 |
|
| 1255 |
+ let applyStartedAt = Date() |
|
| 1223 | 1256 |
applyDistributionPage(page, sampleType: sampleType, to: &recordMap) |
| 1257 |
+ captureTimings.processingElapsedSeconds += Date().timeIntervalSince(applyStartedAt) |
|
| 1224 | 1258 |
processedEventCount += pageEventCount(page) |
| 1225 | 1259 |
shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= incrementalStrategy.queryPageLimit |
| 1226 | 1260 |
progress?.updateBlockProgress( |
@@ -1239,8 +1273,11 @@ final class HealthKitService {
|
||
| 1239 | 1273 |
) |
| 1240 | 1274 |
} |
| 1241 | 1275 |
|
| 1276 |
+ let verificationStartedAt = Date() |
|
| 1242 | 1277 |
try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date(), observationID: archiveObservationID) |
| 1278 |
+ captureTimings.finalizeElapsedSeconds += Date().timeIntervalSince(verificationStartedAt) |
|
| 1243 | 1279 |
|
| 1280 |
+ let sortedRecordsStartedAt = Date() |
|
| 1244 | 1281 |
let sortedKeys = recordMap.keys.sorted {
|
| 1245 | 1282 |
guard let left = recordMap[$0], |
| 1246 | 1283 |
let right = recordMap[$1] else {
|
@@ -1270,6 +1307,7 @@ final class HealthKitService {
|
||
| 1270 | 1307 |
typeIdentifier: typeIdentifier, |
| 1271 | 1308 |
recordFingerprints: sortedRecords.map(\.recordFingerprint) |
| 1272 | 1309 |
) |
| 1310 |
+ captureTimings.processingElapsedSeconds += Date().timeIntervalSince(sortedRecordsStartedAt) |
|
| 1273 | 1311 |
|
| 1274 | 1312 |
progress?.updateBlockProgress( |
| 1275 | 1313 |
typeIdentifier, |
@@ -1284,7 +1322,8 @@ final class HealthKitService {
|
||
| 1284 | 1322 |
records: [], |
| 1285 | 1323 |
contentHash: nil, |
| 1286 | 1324 |
yearlyCounts: nil, |
| 1287 |
- recordArchiveData: nil |
|
| 1325 |
+ recordArchiveData: nil, |
|
| 1326 |
+ timingBreakdown: captureTimings.importBreakdown |
|
| 1288 | 1327 |
) |
| 1289 | 1328 |
} |
| 1290 | 1329 |
|
@@ -1306,7 +1345,8 @@ final class HealthKitService {
|
||
| 1306 | 1345 |
records: sortedRecords, |
| 1307 | 1346 |
contentHash: contentHash, |
| 1308 | 1347 |
yearlyCounts: nil, |
| 1309 |
- recordArchiveData: nil |
|
| 1348 |
+ recordArchiveData: nil, |
|
| 1349 |
+ timingBreakdown: captureTimings.importBreakdown |
|
| 1310 | 1350 |
) |
| 1311 | 1351 |
} |
| 1312 | 1352 |
|
@@ -1321,6 +1361,7 @@ final class HealthKitService {
|
||
| 1321 | 1361 |
) async throws -> SampleDistribution {
|
| 1322 | 1362 |
let importStrategy = DistributionCaptureConfiguration.initialImportStrategy(for: typeIdentifier) |
| 1323 | 1363 |
var persistenceState = importStrategy.makePersistenceState() |
| 1364 |
+ var captureTimings = DistributionCaptureTimings.zero |
|
| 1324 | 1365 |
var anchor: HKQueryAnchor? |
| 1325 | 1366 |
var pageNumber = 0 |
| 1326 | 1367 |
var recordCount = 0 |
@@ -1350,6 +1391,7 @@ final class HealthKitService {
|
||
| 1350 | 1391 |
) |
| 1351 | 1392 |
) |
| 1352 | 1393 |
|
| 1394 |
+ let pageFetchStartedAt = Date() |
|
| 1353 | 1395 |
let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
|
| 1354 | 1396 |
try await self.fetchDistributionPage( |
| 1355 | 1397 |
for: sampleType, |
@@ -1358,6 +1400,7 @@ final class HealthKitService {
|
||
| 1358 | 1400 |
limit: importStrategy.queryPageLimit |
| 1359 | 1401 |
) |
| 1360 | 1402 |
} |
| 1403 |
+ captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(pageFetchStartedAt) |
|
| 1361 | 1404 |
let archiveResult = try await archivePage( |
| 1362 | 1405 |
page, |
| 1363 | 1406 |
sampleType: sampleType, |
@@ -1371,8 +1414,10 @@ final class HealthKitService {
|
||
| 1371 | 1414 |
persistenceState: persistenceState |
| 1372 | 1415 |
) |
| 1373 | 1416 |
persistenceState = archiveResult.persistenceState |
| 1417 |
+ captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds |
|
| 1374 | 1418 |
anchor = page.anchor |
| 1375 | 1419 |
|
| 1420 |
+ let processStartedAt = Date() |
|
| 1376 | 1421 |
for sample in page.samples {
|
| 1377 | 1422 |
autoreleasepool {
|
| 1378 | 1423 |
let value = Self.recordValue(for: sample, sampleType: sampleType, typeIdentifier: typeIdentifier) |
@@ -1384,6 +1429,7 @@ final class HealthKitService {
|
||
| 1384 | 1429 |
recordCount += 1 |
| 1385 | 1430 |
} |
| 1386 | 1431 |
} |
| 1432 |
+ captureTimings.processingElapsedSeconds += Date().timeIntervalSince(processStartedAt) |
|
| 1387 | 1433 |
|
| 1388 | 1434 |
processedEventCount += pageEventCount(page) |
| 1389 | 1435 |
shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= importStrategy.queryPageLimit |
@@ -1405,7 +1451,9 @@ final class HealthKitService {
|
||
| 1405 | 1451 |
await Task.yield() |
| 1406 | 1452 |
} |
| 1407 | 1453 |
|
| 1454 |
+ let finalizeHashStartedAt = Date() |
|
| 1408 | 1455 |
let contentHash = hashBuilder.finalize() |
| 1456 |
+ captureTimings.processingElapsedSeconds += Date().timeIntervalSince(finalizeHashStartedAt) |
|
| 1409 | 1457 |
progress?.updateBlockProgress( |
| 1410 | 1458 |
typeIdentifier, |
| 1411 | 1459 |
detail: pageNumber == 1 ? "Imported 1 page" : "Imported \(pageNumber) pages", |
@@ -1417,7 +1465,9 @@ final class HealthKitService {
|
||
| 1417 | 1465 |
) |
| 1418 | 1466 |
) |
| 1419 | 1467 |
|
| 1468 |
+ let verificationStartedAt = Date() |
|
| 1420 | 1469 |
try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date(), observationID: archiveObservationID) |
| 1470 |
+ captureTimings.finalizeElapsedSeconds += Date().timeIntervalSince(verificationStartedAt) |
|
| 1421 | 1471 |
|
| 1422 | 1472 |
guard recordCount > 0 || anchor != nil else {
|
| 1423 | 1473 |
return SampleDistribution( |
@@ -1426,7 +1476,8 @@ final class HealthKitService {
|
||
| 1426 | 1476 |
records: [], |
| 1427 | 1477 |
contentHash: nil, |
| 1428 | 1478 |
yearlyCounts: nil, |
| 1429 |
- recordArchiveData: nil |
|
| 1479 |
+ recordArchiveData: nil, |
|
| 1480 |
+ timingBreakdown: captureTimings.importBreakdown |
|
| 1430 | 1481 |
) |
| 1431 | 1482 |
} |
| 1432 | 1483 |
|
@@ -1434,6 +1485,10 @@ final class HealthKitService {
|
||
| 1434 | 1485 |
let rawBinEnd = latestDate ?? latestRecordDate ?? binStart |
| 1435 | 1486 |
let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1) |
| 1436 | 1487 |
|
| 1488 |
+ let archiveFinalizeStartedAt = Date() |
|
| 1489 |
+ let recordArchiveData = archiveWriter.finalize() |
|
| 1490 |
+ captureTimings.processingElapsedSeconds += Date().timeIntervalSince(archiveFinalizeStartedAt) |
|
| 1491 |
+ |
|
| 1437 | 1492 |
return SampleDistribution( |
| 1438 | 1493 |
totalCount: recordCount, |
| 1439 | 1494 |
bins: [ |
@@ -1448,7 +1503,8 @@ final class HealthKitService {
|
||
| 1448 | 1503 |
records: [], |
| 1449 | 1504 |
contentHash: contentHash, |
| 1450 | 1505 |
yearlyCounts: yearMap, |
| 1451 |
- recordArchiveData: archiveWriter.finalize() |
|
| 1506 |
+ recordArchiveData: recordArchiveData, |
|
| 1507 |
+ timingBreakdown: captureTimings.importBreakdown |
|
| 1452 | 1508 |
) |
| 1453 | 1509 |
} |
| 1454 | 1510 |
|
@@ -1579,6 +1635,7 @@ final class HealthKitService {
|
||
| 1579 | 1635 |
let completedEventCountBeforePage = processedEventCount ?? 0 |
| 1580 | 1636 |
var persistedSampleCount = 0 |
| 1581 | 1637 |
var persistedDeletedCount = 0 |
| 1638 |
+ var insertElapsedSeconds: TimeInterval = 0 |
|
| 1582 | 1639 |
let observedAt = Date() |
| 1583 | 1640 |
if !page.samples.isEmpty {
|
| 1584 | 1641 |
var batchStart = 0 |
@@ -1616,8 +1673,10 @@ final class HealthKitService {
|
||
| 1616 | 1673 |
observedAt: observedAt, |
| 1617 | 1674 |
observationID: observationID |
| 1618 | 1675 |
) |
| 1676 |
+ let batchElapsedSeconds = Date().timeIntervalSince(batchStartedAt) |
|
| 1677 |
+ insertElapsedSeconds += batchElapsedSeconds |
|
| 1619 | 1678 |
persistedSampleCount += sampleBatch.count |
| 1620 |
- persistenceState.registerBatchDuration(Date().timeIntervalSince(batchStartedAt)) |
|
| 1679 |
+ persistenceState.registerBatchDuration(batchElapsedSeconds) |
|
| 1621 | 1680 |
batchStart = batchEnd |
| 1622 | 1681 |
await Task.yield() |
| 1623 | 1682 |
} |
@@ -1649,12 +1708,14 @@ final class HealthKitService {
|
||
| 1649 | 1708 |
}() |
| 1650 | 1709 |
) |
| 1651 | 1710 |
} |
| 1711 |
+ let batchStartedAt = Date() |
|
| 1652 | 1712 |
let deletedCount = try await archiveStore.recordDisappearances( |
| 1653 | 1713 |
sampleUUIDHashes: deleteBatch, |
| 1654 | 1714 |
sampleTypeIdentifier: sampleType.identifier, |
| 1655 | 1715 |
observedMissingAt: observedAt, |
| 1656 | 1716 |
observationID: observationID |
| 1657 | 1717 |
) |
| 1718 |
+ insertElapsedSeconds += Date().timeIntervalSince(batchStartedAt) |
|
| 1658 | 1719 |
_ = deletedCount |
| 1659 | 1720 |
persistedDeletedCount += deleteBatch.count |
| 1660 | 1721 |
deleteBatchStart = deleteBatchEnd |
@@ -1664,7 +1725,8 @@ final class HealthKitService {
|
||
| 1664 | 1725 |
return DistributionPageArchiveResult( |
| 1665 | 1726 |
persistenceState: persistenceState, |
| 1666 | 1727 |
persistedSampleCount: persistedSampleCount, |
| 1667 |
- persistedDeletedCount: persistedDeletedCount |
|
| 1728 |
+ persistedDeletedCount: persistedDeletedCount, |
|
| 1729 |
+ insertElapsedSeconds: insertElapsedSeconds |
|
| 1668 | 1730 |
) |
| 1669 | 1731 |
} |
| 1670 | 1732 |
|
@@ -2134,6 +2196,7 @@ private struct SampleDistribution: Sendable {
|
||
| 2134 | 2196 |
let contentHash: String? |
| 2135 | 2197 |
let yearlyCounts: [Int: Int]? |
| 2136 | 2198 |
let recordArchiveData: Data? |
| 2199 |
+ let timingBreakdown: ImportTimingBreakdown |
|
| 2137 | 2200 |
} |
| 2138 | 2201 |
|
| 2139 | 2202 |
private struct SampleDistributionPage: Sendable {
|
@@ -2318,7 +2381,8 @@ private struct PreviousDistributionState: Sendable {
|
||
| 2318 | 2381 |
records: [], |
| 2319 | 2382 |
contentHash: effectiveContentHash, |
| 2320 | 2383 |
yearlyCounts: yearlyCounts, |
| 2321 |
- recordArchiveData: recordArchiveData |
|
| 2384 |
+ recordArchiveData: recordArchiveData, |
|
| 2385 |
+ timingBreakdown: .zero |
|
| 2322 | 2386 |
) |
| 2323 | 2387 |
} |
| 2324 | 2388 |
|
@@ -2398,6 +2462,7 @@ private struct TypeCountFetchResult: Sendable {
|
||
| 2398 | 2462 |
var suggestedRetryTimeout: TimeInterval = 0 |
| 2399 | 2463 |
var timeoutCount: Int = 0 |
| 2400 | 2464 |
var successCount: Int = 0 |
| 2465 |
+ var timingBreakdown: ImportTimingBreakdown = .zero |
|
| 2401 | 2466 |
|
| 2402 | 2467 |
mutating func applyTimeoutProfile(_ profile: LocalMetricTimeoutProfile) {
|
| 2403 | 2468 |
timeoutMode = profile.timeoutMode |
@@ -2485,7 +2550,8 @@ private extension SnapshotFetchProgress {
|
||
| 2485 | 2550 |
learnedTimeout: result.learnedTimeout, |
| 2486 | 2551 |
suggestedRetryTimeout: result.suggestedRetryTimeout, |
| 2487 | 2552 |
timeoutCount: result.timeoutCount, |
| 2488 |
- successCount: result.successCount |
|
| 2553 |
+ successCount: result.successCount, |
|
| 2554 |
+ timingBreakdown: result.timingBreakdown |
|
| 2489 | 2555 |
) |
| 2490 | 2556 |
} |
| 2491 | 2557 |
|
@@ -2546,6 +2612,25 @@ struct DistributionPageArchiveResult: Equatable {
|
||
| 2546 | 2612 |
let persistenceState: DistributionPagePersistenceState |
| 2547 | 2613 |
let persistedSampleCount: Int |
| 2548 | 2614 |
let persistedDeletedCount: Int |
| 2615 |
+ let insertElapsedSeconds: TimeInterval |
|
| 2616 |
+} |
|
| 2617 |
+ |
|
| 2618 |
+private struct DistributionCaptureTimings: Sendable, Equatable {
|
|
| 2619 |
+ var fetchElapsedSeconds: TimeInterval = 0 |
|
| 2620 |
+ var processingElapsedSeconds: TimeInterval = 0 |
|
| 2621 |
+ var insertElapsedSeconds: TimeInterval = 0 |
|
| 2622 |
+ var finalizeElapsedSeconds: TimeInterval = 0 |
|
| 2623 |
+ |
|
| 2624 |
+ static let zero = DistributionCaptureTimings() |
|
| 2625 |
+ |
|
| 2626 |
+ var importBreakdown: ImportTimingBreakdown {
|
|
| 2627 |
+ ImportTimingBreakdown( |
|
| 2628 |
+ fetchElapsedSeconds: fetchElapsedSeconds, |
|
| 2629 |
+ processingElapsedSeconds: processingElapsedSeconds, |
|
| 2630 |
+ insertElapsedSeconds: insertElapsedSeconds, |
|
| 2631 |
+ finalizeElapsedSeconds: finalizeElapsedSeconds |
|
| 2632 |
+ ) |
|
| 2633 |
+ } |
|
| 2549 | 2634 |
} |
| 2550 | 2635 |
|
| 2551 | 2636 |
enum DistributionCaptureConfiguration {
|
@@ -1,5 +1,18 @@ |
||
| 1 | 1 |
import Foundation |
| 2 | 2 |
|
| 3 |
+struct ImportTimingBreakdown: Sendable, Equatable {
|
|
| 4 |
+ var fetchElapsedSeconds: TimeInterval = 0 |
|
| 5 |
+ var processingElapsedSeconds: TimeInterval = 0 |
|
| 6 |
+ var insertElapsedSeconds: TimeInterval = 0 |
|
| 7 |
+ var finalizeElapsedSeconds: TimeInterval = 0 |
|
| 8 |
+ |
|
| 9 |
+ static let zero = ImportTimingBreakdown() |
|
| 10 |
+ |
|
| 11 |
+ var accountedElapsedSeconds: TimeInterval {
|
|
| 12 |
+ fetchElapsedSeconds + processingElapsedSeconds + insertElapsedSeconds + finalizeElapsedSeconds |
|
| 13 |
+ } |
|
| 14 |
+} |
|
| 15 |
+ |
|
| 3 | 16 |
struct HealthKitAPICallResult: Sendable, Equatable {
|
| 4 | 17 |
enum Status: String, Sendable {
|
| 5 | 18 |
case complete |
@@ -99,6 +112,7 @@ final class SnapshotFetchProgress {
|
||
| 99 | 112 |
var suggestedRetryTimeout: TimeInterval = 0 |
| 100 | 113 |
var timeoutCount: Int = 0 |
| 101 | 114 |
var successCount: Int = 0 |
| 115 |
+ var timingBreakdown: ImportTimingBreakdown = .zero |
|
| 102 | 116 |
var blockProgress: String = "" |
| 103 | 117 |
var blockElapsedSeconds: TimeInterval = 0 |
| 104 | 118 |
var blockSamplesPerSecond: Double = 0 |
@@ -127,6 +141,16 @@ final class SnapshotFetchProgress {
|
||
| 127 | 141 |
var totalRecords: Int {
|
| 128 | 142 |
types.reduce(0) { $0 + max($1.recordCount, 0) }
|
| 129 | 143 |
} |
| 144 |
+ var aggregateTimingBreakdown: ImportTimingBreakdown {
|
|
| 145 |
+ types.reduce(.zero) { partial, type in
|
|
| 146 |
+ var combined = partial |
|
| 147 |
+ combined.fetchElapsedSeconds += type.timingBreakdown.fetchElapsedSeconds |
|
| 148 |
+ combined.processingElapsedSeconds += type.timingBreakdown.processingElapsedSeconds |
|
| 149 |
+ combined.insertElapsedSeconds += type.timingBreakdown.insertElapsedSeconds |
|
| 150 |
+ combined.finalizeElapsedSeconds += type.timingBreakdown.finalizeElapsedSeconds |
|
| 151 |
+ return combined |
|
| 152 |
+ } |
|
| 153 |
+ } |
|
| 130 | 154 |
|
| 131 | 155 |
init(monitoredTypes: [(id: String, displayName: String)]) {
|
| 132 | 156 |
self.totalTypeCount = monitoredTypes.count |
@@ -188,7 +212,8 @@ final class SnapshotFetchProgress {
|
||
| 188 | 212 |
learnedTimeout: TimeInterval, |
| 189 | 213 |
suggestedRetryTimeout: TimeInterval, |
| 190 | 214 |
timeoutCount: Int, |
| 191 |
- successCount: Int |
|
| 215 |
+ successCount: Int, |
|
| 216 |
+ timingBreakdown: ImportTimingBreakdown |
|
| 192 | 217 |
) {
|
| 193 | 218 |
let index = visibleTypeIndex(for: id) |
| 194 | 219 |
types[index].quality = quality |
@@ -207,6 +232,7 @@ final class SnapshotFetchProgress {
|
||
| 207 | 232 |
types[index].suggestedRetryTimeout = suggestedRetryTimeout |
| 208 | 233 |
types[index].timeoutCount = timeoutCount |
| 209 | 234 |
types[index].successCount = successCount |
| 235 |
+ types[index].timingBreakdown = timingBreakdown |
|
| 210 | 236 |
} |
| 211 | 237 |
|
| 212 | 238 |
func updateTimeoutProfile( |
@@ -132,6 +132,10 @@ struct DashboardView: View {
|
||
| 132 | 132 |
return "\(Int(seconds) / 60)m \(Int(seconds) % 60)s" |
| 133 | 133 |
} |
| 134 | 134 |
|
| 135 |
+ private func timingResidual(totalElapsed: TimeInterval, breakdown: ImportTimingBreakdown) -> TimeInterval {
|
|
| 136 |
+ max(0, totalElapsed - breakdown.accountedElapsedSeconds) |
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 135 | 139 |
private func qualityLabel(progress: SnapshotFetchProgress) -> String {
|
| 136 | 140 |
switch viewModel.snapshotProgress {
|
| 137 | 141 |
case .complete: return "Complete" |
@@ -325,7 +329,7 @@ struct DashboardView: View {
|
||
| 325 | 329 |
lines.append("operationResult: \(operationResultValue())")
|
| 326 | 330 |
lines.append("primaryObjectType: snapshot")
|
| 327 | 331 |
lines.append("primaryObjectID: \(snapshotID)")
|
| 328 |
- lines.append("reportSchemaVersion: 1")
|
|
| 332 |
+ lines.append("reportSchemaVersion: 2")
|
|
| 329 | 333 |
lines.append("reportGeneratedAt: \(reportGeneratedAt)")
|
| 330 | 334 |
lines.append("")
|
| 331 | 335 |
lines.append("OPERATION SUMMARY")
|
@@ -346,6 +350,7 @@ struct DashboardView: View {
|
||
| 346 | 350 |
lines.append("Do not infer deletion from partial snapshots")
|
| 347 | 351 |
lines.append("All-values-missing metrics require review before saving")
|
| 348 | 352 |
lines.append("Timeout means HealthProbe cancelled after configured timeout, not necessarily HealthKit denial")
|
| 353 |
+ lines.append("Per-metric timing sums are cumulative work and can exceed wall-clock duration because type fetches overlap")
|
|
| 349 | 354 |
lines.append("")
|
| 350 | 355 |
lines.append("CONFIGURATION")
|
| 351 | 356 |
lines.append("adaptiveTimeoutsEnabled: \(progress.adaptiveTimeoutsEnabled ? "true" : "false")")
|
@@ -363,6 +368,18 @@ struct DashboardView: View {
|
||
| 363 | 368 |
lines.append("STATISTICS")
|
| 364 | 369 |
lines.append("Records: \(progress.totalRecords)")
|
| 365 | 370 |
lines.append("Types: \(progress.types.count)/\(progress.totalTypeCount) processed, \(progress.completedCount) complete, \(degraded.count) degraded")
|
| 371 |
+ lines.append("WallClockDuration: \(duration)")
|
|
| 372 |
+ let aggregateTiming = progress.aggregateTimingBreakdown |
|
| 373 |
+ let summedMetricTotalElapsed = progress.types.reduce(0) { $0 + max(0, $1.totalElapsedSeconds) }
|
|
| 374 |
+ let summedMetricResidualElapsed = progress.types.reduce(0) { partial, type in
|
|
| 375 |
+ partial + timingResidual(totalElapsed: type.totalElapsedSeconds, breakdown: type.timingBreakdown) |
|
| 376 |
+ } |
|
| 377 |
+ lines.append("SummedMetricTotalElapsed: \(formatDuration(summedMetricTotalElapsed))")
|
|
| 378 |
+ lines.append("SummedFetchElapsed: \(formatDuration(aggregateTiming.fetchElapsedSeconds))")
|
|
| 379 |
+ lines.append("SummedProcessingElapsed: \(formatDuration(aggregateTiming.processingElapsedSeconds))")
|
|
| 380 |
+ lines.append("SummedInsertElapsed: \(formatDuration(aggregateTiming.insertElapsedSeconds))")
|
|
| 381 |
+ lines.append("SummedFinalizeElapsed: \(formatDuration(aggregateTiming.finalizeElapsedSeconds))")
|
|
| 382 |
+ lines.append("SummedResidualElapsed: \(formatDuration(summedMetricResidualElapsed))")
|
|
| 366 | 383 |
lines.append("")
|
| 367 | 384 |
lines.append(failedLines) |
| 368 | 385 |
|
@@ -401,6 +418,11 @@ struct DashboardView: View {
|
||
| 401 | 418 |
lines.append(" timeoutCount: \(type.timeoutCount)")
|
| 402 | 419 |
lines.append(" successCount: \(type.successCount)")
|
| 403 | 420 |
lines.append(" totalElapsed: \(formatDuration(type.totalElapsedSeconds))")
|
| 421 |
+ lines.append(" fetchElapsed: \(formatDuration(type.timingBreakdown.fetchElapsedSeconds))")
|
|
| 422 |
+ lines.append(" processingElapsed: \(formatDuration(type.timingBreakdown.processingElapsedSeconds))")
|
|
| 423 |
+ lines.append(" insertElapsed: \(formatDuration(type.timingBreakdown.insertElapsedSeconds))")
|
|
| 424 |
+ lines.append(" finalizeElapsed: \(formatDuration(type.timingBreakdown.finalizeElapsedSeconds))")
|
|
| 425 |
+ lines.append(" residualElapsed: \(formatDuration(timingResidual(totalElapsed: type.totalElapsedSeconds, breakdown: type.timingBreakdown)))")
|
|
| 404 | 426 |
if type.blockElapsedSeconds > 0 {
|
| 405 | 427 |
lines.append(" fetchProgressElapsed: \(formatDuration(type.blockElapsedSeconds))")
|
| 406 | 428 |
} |