1 contributor
import Foundation
import HealthKit
import SwiftData
import UIKit
import os.log
private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "HealthKitService")
enum TypeCategory: String, CaseIterable {
case activity = "Activity"
case heart = "Heart"
case mobility = "Mobility"
case respiratory = "Respiratory"
case sleep = "Sleep"
case hearing = "Hearing"
case body = "Body"
case nutrition = "Nutrition"
case reproductive = "Reproductive Health"
case symptoms = "Symptoms"
case other = "Other"
}
struct MonitoredType: Identifiable, @unchecked Sendable {
let id: String
let displayName: String
let category: TypeCategory
let isEnabledByDefault: Bool
let objectType: HKObjectType? // nil = unsupported on this OS/device
}
extension Array where Element == MonitoredType {
func sortedByFetchDisplayNameDescending() -> [MonitoredType] {
sorted {
let displayNameOrder = $0.displayName.localizedCaseInsensitiveCompare($1.displayName)
if displayNameOrder == .orderedSame {
return $0.id > $1.id
}
return displayNameOrder == .orderedDescending
}
}
}
enum LegacySwiftDataBridgeError: LocalizedError {
case notConfigured
case snapshotNotSaved
var errorDescription: String? {
switch self {
case .notConfigured:
return "Legacy SwiftData bridge is not configured."
case .snapshotNotSaved:
return "Snapshot was not saved to database. This may indicate a HealthKit permission issue or data corruption. Try requesting health access again in the Actions section."
}
}
}
struct AmbiguousDisappearedMetric: Identifiable, Equatable, Sendable {
let id: String
let displayName: String
let previousCount: Int
}
struct LegacySnapshotTypeSummary: Sendable {
let typeIdentifier: String
let displayName: String
let count: Int
let quality: SnapshotQuality
}
struct LegacySnapshotOutcome: Sendable {
let snapshotID: UUID
let archiveObservationID: Int64?
let timestamp: Date
let deviceID: String
let triggerReason: String
let retryOfSnapshotID: UUID?
let previousSnapshotID: UUID?
let isChainStart: Bool
let snapshotChecksum: String
let monitoredTypeSetHash: String
let monitoredRegistryVersion: Int
}
struct LegacySnapshotCaptureResult: Sendable {
let outcome: LegacySnapshotOutcome
let snapshotQuality: SnapshotQuality
let typeSummaries: [LegacySnapshotTypeSummary]
let shouldAutoSaveKnownUnauthorizedPartial: Bool
let ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric]
}
enum LegacySwiftDataBridge {
private static var container: ModelContainer?
private static var context: ModelContext?
private static var pendingSnapshots: [UUID: HealthSnapshot] = [:]
static func configure(container: ModelContainer) {
self.container = container
context = ModelContext(container)
}
static func createSnapshot(
using healthKit: HealthKitService,
selectedTypeIDs: Set<String>,
adaptiveTimeoutsEnabled: Bool,
triggerReason: String,
retryOfSnapshotID: UUID?,
timeoutMultiplier: Double,
reviewAmbiguousCompleteDisappearedTypes: Bool,
progress: SnapshotFetchProgress?
) async throws -> LegacySnapshotCaptureResult {
let snapshot = try await healthKit.createSnapshot(
in: modelContext(),
selectedTypeIDs: selectedTypeIDs,
adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
triggerReason: triggerReason,
retryOfSnapshotID: retryOfSnapshotID,
timeoutMultiplier: timeoutMultiplier,
reviewAmbiguousCompleteDisappearedTypes: reviewAmbiguousCompleteDisappearedTypes,
progress: progress
)
let ambiguousDisappearedMetrics = snapshot.snapshotQuality == .complete
? ambiguousDisappearedMetrics(for: snapshot)
: []
let shouldKeepPending = snapshot.snapshotQuality != .complete || !ambiguousDisappearedMetrics.isEmpty
if shouldKeepPending {
pendingSnapshots[snapshot.id] = snapshot
} else if try !snapshotExists(id: snapshot.id) {
throw LegacySwiftDataBridgeError.snapshotNotSaved
}
return LegacySnapshotCaptureResult(
outcome: snapshotOutcome(from: snapshot),
snapshotQuality: snapshot.snapshotQuality,
typeSummaries: typeSummaries(from: snapshot),
shouldAutoSaveKnownUnauthorizedPartial: shouldAutoSaveKnownUnauthorizedPartial(snapshot: snapshot),
ambiguousDisappearedMetrics: ambiguousDisappearedMetrics
)
}
static func savePartialSnapshot(
id snapshotID: UUID,
using healthKit: HealthKitService
) async throws -> LegacySnapshotOutcome {
guard let snapshot = try resolveSnapshot(id: snapshotID) else {
throw LegacySwiftDataBridgeError.snapshotNotSaved
}
let saved = try await healthKit.savePartialSnapshot(snapshot, in: modelContext())
pendingSnapshots.removeValue(forKey: snapshotID)
return snapshotOutcome(from: saved)
}
static func saveReviewedCompleteSnapshot(
id snapshotID: UUID,
using healthKit: HealthKitService
) async throws -> LegacySnapshotOutcome {
guard let snapshot = try resolveSnapshot(id: snapshotID) else {
throw LegacySwiftDataBridgeError.snapshotNotSaved
}
let saved = try await healthKit.saveReviewedCompleteSnapshot(snapshot, in: modelContext())
pendingSnapshots.removeValue(forKey: snapshotID)
return snapshotOutcome(from: saved)
}
static func markMetricsUnauthorized(
snapshotID: UUID,
typeIdentifiers: Set<String>,
using healthKit: HealthKitService
) async throws -> LegacySnapshotOutcome {
guard let snapshot = try resolveSnapshot(id: snapshotID) else {
throw LegacySwiftDataBridgeError.snapshotNotSaved
}
for typeCount in snapshot.typeCounts ?? [] where typeIdentifiers.contains(typeCount.typeIdentifier) {
typeCount.count = -1
typeCount.contentHash = ""
typeCount.earliestDate = nil
typeCount.latestDate = nil
typeCount.quality = .unauthorized
typeCount.yearlyCounts = []
}
snapshot.snapshotQuality = healthKit.deriveSnapshotQuality(from: snapshot.typeCounts ?? [])
let saved = try await healthKit.savePartialSnapshot(snapshot, in: modelContext())
pendingSnapshots.removeValue(forKey: snapshotID)
return snapshotOutcome(from: saved)
}
static func snapshotExists(id: UUID) throws -> Bool {
var descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == id }
)
descriptor.fetchLimit = 1
return try !modelContext().fetch(descriptor).isEmpty
}
static func previousSnapshot(for snapshot: HealthSnapshot) -> HealthSnapshot? {
guard let previousID = snapshot.previousSnapshotID else { return nil }
let descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == previousID }
)
return try? modelContext().fetch(descriptor).first
}
static func deleteSnapshot(id: UUID) throws {
pendingSnapshots.removeValue(forKey: id)
let context = try modelContext()
var descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == id }
)
descriptor.fetchLimit = 1
if let snapshot = try context.fetch(descriptor).first {
context.delete(snapshot)
}
}
static func discardPendingSnapshot(id: UUID) {
pendingSnapshots.removeValue(forKey: id)
}
private static func fetchSnapshot(id: UUID) throws -> HealthSnapshot? {
let context = try modelContext()
var descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == id }
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private static func resolveSnapshot(id: UUID) throws -> HealthSnapshot? {
if let pending = pendingSnapshots[id] {
return pending
}
return try fetchSnapshot(id: id)
}
private static func ambiguousDisappearedMetrics(for snapshot: HealthSnapshot) -> [AmbiguousDisappearedMetric] {
guard let previous = previousSnapshot(for: snapshot) else { return [] }
let previousByType = Dictionary(
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
return (snapshot.typeCounts ?? []).compactMap { current in
guard current.quality == .complete,
current.count == 0,
let previous = previousByType[current.typeIdentifier],
previous.quality == .complete,
previous.count > 0 else {
return nil
}
return AmbiguousDisappearedMetric(
id: current.typeIdentifier,
displayName: current.displayName,
previousCount: previous.count
)
}.sorted { $0.displayName < $1.displayName }
}
private static func shouldAutoSaveKnownUnauthorizedPartial(snapshot: HealthSnapshot) -> Bool {
let currentUnauthorized = (snapshot.typeCounts ?? []).filter { $0.quality == .unauthorized }
guard snapshot.snapshotQuality != .complete,
!currentUnauthorized.isEmpty,
!(snapshot.typeCounts ?? []).contains(where: { $0.quality == .failed }),
let previous = previousSnapshot(for: snapshot) else {
return false
}
let previousByType = Dictionary(
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
return currentUnauthorized.allSatisfy { previousByType[$0.typeIdentifier]?.quality == .unauthorized }
}
private static func typeSummaries(from snapshot: HealthSnapshot) -> [LegacySnapshotTypeSummary] {
(snapshot.typeCounts ?? []).map { typeCount in
LegacySnapshotTypeSummary(
typeIdentifier: typeCount.typeIdentifier,
displayName: typeCount.displayName,
count: typeCount.count,
quality: typeCount.quality
)
}
}
private static func snapshotOutcome(from snapshot: HealthSnapshot) -> LegacySnapshotOutcome {
LegacySnapshotOutcome(
snapshotID: snapshot.id,
archiveObservationID: snapshot.archiveObservationID,
timestamp: snapshot.timestamp,
deviceID: snapshot.deviceID,
triggerReason: snapshot.triggerReason,
retryOfSnapshotID: snapshot.retryOfSnapshotID,
previousSnapshotID: snapshot.previousSnapshotID,
isChainStart: snapshot.isChainStart,
snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
monitoredRegistryVersion: snapshot.monitoredRegistryVersion
)
}
static func snapshotID(forArchiveObservationID observationID: Int64) -> UUID? {
do {
let context = try modelContext()
let descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate { snapshot in
snapshot.archiveObservationID == observationID
}
)
return try context.fetch(descriptor).first?.id
} catch {
return nil
}
}
private static func modelContext() throws -> ModelContext {
if let context { return context }
guard let container else {
throw LegacySwiftDataBridgeError.notConfigured
}
let context = ModelContext(container)
self.context = context
return context
}
}
final class HealthKitService {
static let shared = HealthKitService()
let store = HKHealthStore()
private let archiveStore: HealthArchiveStore
private init(archiveStore: HealthArchiveStore = SQLiteHealthArchiveStore.shared) {
self.archiveStore = archiveStore
}
static let allTypes: [MonitoredType] = buildAllTypes()
static let defaultInitialTimeoutSeconds: TimeInterval = LocalMetricTimeoutProfile.defaultInitialTimeout
static let maximumTimeoutSeconds: TimeInterval = LocalMetricTimeoutProfile.maximumTimeout
static let captureOperationWatchdogTimeoutSeconds: TimeInterval = 12 * 60 * 60
// Prevents 3N simultaneous HK queries from exhausting resources at N=20 types.
static let maxConcurrentTypeFetches = 6
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
var hasRequestedPermissionsBefore: Bool {
get {
UserDefaults.standard.bool(forKey: "healthkit.permissions.requested")
}
set {
UserDefaults.standard.set(newValue, forKey: "healthkit.permissions.requested")
}
}
// MARK: - Authorization
func requestAuthorization() async throws {
guard isAvailable else { return }
let readTypes = Set(Self.allTypes.compactMap { $0.objectType })
try await store.requestAuthorization(toShare: [], read: readTypes)
hasRequestedPermissionsBefore = true
}
// MARK: - Snapshot creation
@MainActor
func createSnapshot(
in context: ModelContext,
selectedTypeIDs: Set<String>,
adaptiveTimeoutsEnabled: Bool,
triggerReason: String = "manual",
retryOfSnapshotID: UUID? = nil,
timeoutMultiplier: Double = 1,
reviewAmbiguousCompleteDisappearedTypes: Bool = false,
progress: SnapshotFetchProgress? = nil
) async throws -> HealthSnapshot {
let active = Self.allTypes
.filter { selectedTypeIDs.contains($0.id) }
.sortedByFetchDisplayNameDescending()
let deviceResolution = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: isStoreEmpty(context: context))
let snapshot = HealthSnapshot(
timestamp: Date(),
osVersion: ProcessInfo.processInfo.operatingSystemVersionString,
deviceName: UIDevice.current.name,
deviceID: deviceResolution.id
)
let intendedTypeIDs = active.map { $0.id }
let monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intendedTypeIDs)
let archiveObservationID = try await archiveStore.beginObservation(
observedAt: snapshot.timestamp,
triggerReason: triggerReason,
selectedTypeSetHash: monitoredTypeSetHash
)
snapshot.archiveObservationID = archiveObservationID
snapshot.monitoredTypeSetHash = monitoredTypeSetHash
snapshot.recoveredDeviceID = deviceResolution.isRecovered
snapshot.triggerReason = triggerReason
snapshot.retryOfSnapshotID = retryOfSnapshotID
snapshot.yearlyCountTimezoneIdentifier = TimeZone.current.identifier
let previousSnapshot = findPreviousSnapshot(deviceID: snapshot.deviceID, excluding: snapshot.id, context: context)
// Fetch raw HealthKit data one type at a time, then assemble each SwiftData model
// immediately so large per-type archives are not duplicated in an intermediate result array.
let typeCounts = await fetchAllTypeCounts(
for: active,
context: context,
snapshot: snapshot,
previousSnapshot: previousSnapshot,
archiveObservationID: archiveObservationID,
adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
timeoutMultiplier: timeoutMultiplier,
progress: progress
)
snapshot.typeCounts = typeCounts
applyStickyUnavailableState(
snapshot: snapshot,
typeCounts: typeCounts,
context: context
)
// Invariant assertions before save — debug asserts + release silent correction
for tc in typeCounts {
let isComplete = tc.quality == SnapshotQuality.complete
assert(
!isComplete || tc.count >= 0,
"TypeCount with quality .complete must have count >= 0"
)
assert(
isComplete || tc.count == -1,
"TypeCount with quality != .complete must have count == -1"
)
if !isComplete && tc.count != -1 {
logger.critical("TypeCount invariant violation: quality=\(tc.quality.rawValue) count=\(tc.count) type=\(tc.typeIdentifier)")
tc.count = -1
}
}
snapshot.snapshotQuality = deriveSnapshotQuality(from: typeCounts)
updateSnapshotSummaryCache(snapshot: snapshot, typeCounts: typeCounts)
configureSnapshotMetadata(
snapshot,
typeCounts: typeCounts,
intendedTypeIDs: intendedTypeIDs,
context: context
)
markContentEquivalenceIfNeeded(
snapshot: snapshot,
typeCounts: typeCounts,
context: context
)
precomputeTypeCountDetailCaches(
snapshot: snapshot,
typeCounts: typeCounts,
context: context
)
if snapshot.snapshotQuality == .complete,
reviewAmbiguousCompleteDisappearedTypes,
hasAmbiguousCompleteDisappearance(snapshot: snapshot, typeCounts: typeCounts, context: context) {
try await archiveStore.finishObservation(
observationID: archiveObservationID,
status: "needs_review",
endedAt: Date()
)
return snapshot
}
do {
if snapshot.snapshotQuality == .complete {
try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context)
}
try await archiveStore.finishObservation(
observationID: archiveObservationID,
status: snapshot.snapshotQuality == .complete ? "completed" : "partial_\(snapshot.snapshotQuality.rawValue)",
endedAt: Date()
)
} catch {
try? await archiveStore.finishObservation(
observationID: archiveObservationID,
status: "failed",
endedAt: Date()
)
throw error
}
return snapshot
}
@MainActor
func savePartialSnapshot(_ snapshot: HealthSnapshot, in context: ModelContext) async throws -> HealthSnapshot {
let typeCounts = snapshot.typeCounts ?? []
guard snapshot.snapshotQuality != .complete else {
return snapshot
}
updateSnapshotSummaryCache(snapshot: snapshot, typeCounts: typeCounts)
configureSnapshotMetadata(
snapshot,
typeCounts: typeCounts,
intendedTypeIDs: typeCounts.map(\.typeIdentifier),
context: context
)
markContentEquivalenceIfNeeded(
snapshot: snapshot,
typeCounts: typeCounts,
context: context
)
precomputeTypeCountDetailCaches(
snapshot: snapshot,
typeCounts: typeCounts,
context: context
)
try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context)
return snapshot
}
@MainActor
func saveReviewedCompleteSnapshot(_ snapshot: HealthSnapshot, in context: ModelContext) async throws -> HealthSnapshot {
let typeCounts = snapshot.typeCounts ?? []
snapshot.snapshotQuality = deriveSnapshotQuality(from: typeCounts)
guard snapshot.snapshotQuality == .complete else {
return try await savePartialSnapshot(snapshot, in: context)
}
updateSnapshotSummaryCache(snapshot: snapshot, typeCounts: typeCounts)
configureSnapshotMetadata(
snapshot,
typeCounts: typeCounts,
intendedTypeIDs: typeCounts.map(\.typeIdentifier),
context: context
)
markContentEquivalenceIfNeeded(
snapshot: snapshot,
typeCounts: typeCounts,
context: context
)
precomputeTypeCountDetailCaches(
snapshot: snapshot,
typeCounts: typeCounts,
context: context
)
try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context)
return snapshot
}
// MARK: - Snapshot persistence
private func persistSnapshot(
_ snapshot: HealthSnapshot,
typeCounts: [TypeCount],
context: ModelContext
) async throws {
context.insert(snapshot)
for typeCount in typeCounts {
context.insert(typeCount)
typeCount.snapshot = snapshot
}
snapshot.typeCounts = typeCounts
try context.save()
}
private func updateSnapshotSummaryCache(
snapshot: HealthSnapshot,
typeCounts: [TypeCount]
) {
snapshot.cachedSummaryVersion = HealthSnapshot.currentCachedSummaryVersion
snapshot.cachedTypeCount = typeCounts.count
snapshot.cachedRecordCount = typeCounts.reduce(0) { partial, typeCount in
typeCount.count > 0 ? partial + typeCount.count : partial
}
let datedTypeCounts = typeCounts.filter { !$0.isUnsupported && $0.count > 0 }
snapshot.cachedEarliestRecordDate = datedTypeCounts.compactMap(\.earliestDate).min()
snapshot.cachedLatestRecordDate = datedTypeCounts.compactMap(\.latestDate).max()
}
private func configureSnapshotMetadata(
_ snapshot: HealthSnapshot,
typeCounts: [TypeCount],
intendedTypeIDs: [String],
context: ModelContext
) {
// Chain metadata — set BEFORE context.save()
// localSequenceNumber is used here solely to find the latest local candidate during
// snapshot creation. Once previousSnapshotID is set, all chain reconstruction must use
// previousSnapshotID exclusively — never reconstruct a chain by walking localSequenceNumber.
let previous = findPreviousSnapshot(deviceID: snapshot.deviceID, excluding: snapshot.id, context: context)
if let previous {
snapshot.previousSnapshotID = previous.id
snapshot.localSequenceNumber = previous.localSequenceNumber + 1
snapshot.isChainStart = false
snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intendedTypeIDs)
if snapshot.monitoredTypeSetHash != previous.monitoredTypeSetHash {
snapshot.monitoredRegistryVersion = previous.monitoredRegistryVersion + 1
} else {
snapshot.monitoredRegistryVersion = previous.monitoredRegistryVersion
}
// Auto-detect post-restore: previous was fully unauthorized, current is complete
if previous.snapshotQuality == SnapshotQuality.unauthorized && snapshot.snapshotQuality == SnapshotQuality.complete {
snapshot.isPostRestore = true
snapshot.isPostRestoreInferred = true
}
} else {
snapshot.previousSnapshotID = nil
snapshot.localSequenceNumber = 0
snapshot.isChainStart = true
snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intendedTypeIDs)
snapshot.monitoredRegistryVersion = 0
// Auto-detect post-restore on chain start with significant data
let completeTypeCounts = typeCounts.filter { $0.quality == SnapshotQuality.complete }
let completeCount = completeTypeCounts.reduce(0) { $0 + max($1.count, 0) }
if completeCount > 1000 {
snapshot.isPostRestore = true
snapshot.isPostRestoreInferred = true
}
}
// Device metadata — informational only, never used for chain linkage
snapshot.hardwareModel = hardwareModel()
snapshot.appBuildVersion = appBuildVersion()
}
private func applyStickyUnavailableState(
snapshot: HealthSnapshot,
typeCounts: [TypeCount],
context: ModelContext
) {
guard let previous = findPreviousSnapshot(deviceID: snapshot.deviceID, excluding: snapshot.id, context: context) else {
return
}
let previousByType = Dictionary(
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
for current in typeCounts {
guard current.quality == .complete,
current.count == 0,
let previousType = previousByType[current.typeIdentifier],
previousType.quality == .unauthorized else {
continue
}
current.count = -1
current.contentHash = ""
current.earliestDate = nil
current.latestDate = nil
current.quality = .unauthorized
current.yearlyCounts = []
}
}
private func precomputeTypeCountDetailCaches(
snapshot: HealthSnapshot,
typeCounts: [TypeCount],
context _: ModelContext
) {
MemoryLog.log("healthKit.precomputeDetailCaches.disabled", metadata: [
"typeCountCount": "\(typeCounts.count)",
"hasPrevious": "\(snapshot.previousSnapshotID != nil)",
"reason": "legacySwiftDataCacheCrash"
])
}
private func markContentEquivalenceIfNeeded(
snapshot: HealthSnapshot,
typeCounts: [TypeCount],
context: ModelContext
) {
snapshot.contentEquivalentSnapshotID = nil
for typeCount in typeCounts {
typeCount.contentEquivalentTypeCountID = nil
}
guard let previousID = snapshot.previousSnapshotID,
let previous = fetchSnapshot(id: previousID, context: context),
previous.monitoredTypeSetHash == snapshot.monitoredTypeSetHash else {
return
}
let previousByType = Dictionary(
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
for typeCount in typeCounts {
guard let previousType = previousByType[typeCount.typeIdentifier],
areTypeCountsContentEquivalent(typeCount, previousType) else {
continue
}
typeCount.contentEquivalentTypeCountID = previousType.contentRepresentativeTypeCountID
}
if areTypeCountsContentEquivalent(previous.typeCounts ?? [], typeCounts) {
snapshot.contentEquivalentSnapshotID = previous.contentRepresentativeSnapshotID
}
}
private func areTypeCountsContentEquivalent(_ lhs: [TypeCount], _ rhs: [TypeCount]) -> Bool {
let lhsByType = Dictionary(uniqueKeysWithValues: lhs.map { ($0.typeIdentifier, $0) })
let rhsByType = Dictionary(uniqueKeysWithValues: rhs.map { ($0.typeIdentifier, $0) })
guard lhsByType.keys == rhsByType.keys else { return false }
for typeIdentifier in lhsByType.keys {
guard let lhsType = lhsByType[typeIdentifier],
let rhsType = rhsByType[typeIdentifier],
lhsType.count == rhsType.count,
lhsType.contentHash == rhsType.contentHash,
lhsType.quality == rhsType.quality,
lhsType.isUnsupported == rhsType.isUnsupported else {
return false
}
}
return true
}
private func areTypeCountsContentEquivalent(_ lhs: TypeCount, _ rhs: TypeCount) -> Bool {
lhs.count == rhs.count &&
lhs.contentHash == rhs.contentHash &&
lhs.quality == rhs.quality &&
lhs.isUnsupported == rhs.isUnsupported
}
private func hasAmbiguousCompleteDisappearance(
snapshot: HealthSnapshot,
typeCounts: [TypeCount],
context: ModelContext
) -> Bool {
guard let previousID = snapshot.previousSnapshotID,
let previous = fetchSnapshot(id: previousID, context: context) else {
return false
}
let previousByType = Dictionary(
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
return typeCounts.contains { current in
guard current.quality == .complete,
current.count == 0,
let previous = previousByType[current.typeIdentifier],
previous.quality == .complete,
previous.count > 0 else {
return false
}
return true
}
}
// MARK: - Per-type fetch pipeline
// Fetches sequentially to prevent race conditions and resource exhaustion.
@MainActor
private func fetchAllTypeCounts(
for active: [MonitoredType],
context: ModelContext,
snapshot: HealthSnapshot,
previousSnapshot: HealthSnapshot?,
archiveObservationID: Int64,
adaptiveTimeoutsEnabled: Bool,
timeoutMultiplier: Double,
progress: SnapshotFetchProgress? = nil
) async -> [TypeCount] {
var typeCounts: [TypeCount] = []
typeCounts.reserveCapacity(active.count)
var protectedDataBecameUnavailable = !UIApplication.shared.isProtectedDataAvailable
for monitoredType in active {
var profile = timeoutProfile(for: monitoredType)
let timeout = timeoutForFetch(
profile: profile,
adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
timeoutMultiplier: timeoutMultiplier
)
var result: TypeCountFetchResult
if protectedDataBecameUnavailable {
result = protectedDataUnavailableResult(
for: monitoredType,
timeoutProfile: profile,
timeoutSeconds: timeout,
progress: progress
)
} else {
result = await fetchTypeCountData(
for: monitoredType,
timeoutProfile: profile,
timeoutSeconds: timeout,
previousTypeCount: previousSnapshot?.typeCounts?.first { $0.typeIdentifier == monitoredType.id },
archiveObservationID: archiveObservationID,
progress: progress
)
if result.indicatesProtectedDataInaccessible {
protectedDataBecameUnavailable = true
}
}
updateTimeoutProfile(&profile, with: result, monitoredType: monitoredType)
LocalMetricTimeoutProfileStore.save(profile)
result.applyTimeoutProfile(profile)
progress?.updateTimeoutProfile(from: profile, for: monitoredType.id)
let typeCount = result.makeTypeCount()
typeCount.snapshot = snapshot
typeCounts.append(typeCount)
}
return typeCounts
}
private func protectedDataUnavailableResult(
for monitoredType: MonitoredType,
timeoutProfile: LocalMetricTimeoutProfile,
timeoutSeconds: TimeInterval,
progress: SnapshotFetchProgress?
) -> TypeCountFetchResult {
let message = "Protected health data is inaccessible because the device is locked."
let call = HealthKitAPICallResult(
queryType: "earliest_sample",
status: .failed,
elapsedSeconds: 0,
resultValue: nil,
errorCode: "\(HKError.Code.errorDatabaseInaccessible.rawValue)",
errorDomain: HKError.errorDomain,
errorDescription: message,
failureKind: "HealthKit error",
cancellationReason: nil
)
let companionCall = HealthKitAPICallResult(
queryType: "latest_sample",
status: .failed,
elapsedSeconds: 0,
resultValue: nil,
errorCode: "\(HKError.Code.errorDatabaseInaccessible.rawValue)",
errorDomain: HKError.errorDomain,
errorDescription: message,
failureKind: "HealthKit error",
cancellationReason: nil
)
var result = TypeCountFetchResult(
typeIdentifier: monitoredType.id,
displayName: monitoredType.displayName,
count: -1,
contentHash: "",
earliestDate: nil,
latestDate: nil,
quality: .failed,
diagnosticQuality: HealthKitAPICallResult.Status.failed.rawValue,
isUnsupported: false,
authorizationStatus: "unavailable",
apiCalls: [
Self.placeholderAPICall(
queryType: "record_import",
status: .unknown,
message: "Skipped because protected health data became inaccessible earlier in this run."
),
call,
companionCall
],
yearlyCounts: [],
distributionBins: [],
records: [],
recordArchiveData: nil
)
result.timeoutConfiguredSeconds = timeoutSeconds
result.applyTimeoutProfile(timeoutProfile)
progress?.updateDetails(from: result)
progress?.updateStatus(monitoredType.id, status: .failed("Protected data unavailable"))
return result
}
private func fetchTypeCountData(
for monitoredType: MonitoredType,
timeoutProfile: LocalMetricTimeoutProfile,
timeoutSeconds: TimeInterval,
previousTypeCount: TypeCount?,
archiveObservationID: Int64,
progress: SnapshotFetchProgress? = nil
) async -> TypeCountFetchResult {
let timer = MonotonicTimer()
progress?.updateStatus(monitoredType.id, status: .fetching)
// Unsupported type: HKObjectType factory returned nil for this identifier
guard let objectType = monitoredType.objectType,
let sampleType = objectType as? HKSampleType else {
var result = TypeCountFetchResult(
typeIdentifier: monitoredType.id,
displayName: monitoredType.displayName,
count: -1,
contentHash: "",
earliestDate: nil,
latestDate: nil,
quality: SnapshotQuality.failed,
diagnosticQuality: HealthKitAPICallResult.Status.unsupported.rawValue,
isUnsupported: true,
authorizationStatus: "unavailable",
apiCalls: Self.placeholderAPICalls(status: .unsupported, message: "HealthKit type is not available on this OS or device"),
yearlyCounts: [],
distributionBins: [],
records: [],
recordArchiveData: nil
)
result.totalElapsedSeconds = timer.elapsedSeconds
result.timeoutConfiguredSeconds = timeoutSeconds
result.applyTimeoutProfile(timeoutProfile)
progress?.updateDetails(from: result)
progress?.updateStatus(monitoredType.id, status: .failed("Unsupported"))
return result
}
var result = await fetchTypeCountDataFromHK(
monitoredType: monitoredType,
sampleType: sampleType,
timeoutSeconds: timeoutSeconds,
previousTypeCount: previousTypeCount,
archiveObservationID: archiveObservationID,
progress: progress
)
result.totalElapsedSeconds = timer.elapsedSeconds
result.timeoutConfiguredSeconds = timeoutSeconds
result.applyTimeoutProfile(timeoutProfile)
progress?.updateDetails(from: result)
if result.diagnosticQuality == SnapshotQuality.complete.rawValue {
progress?.updateStatus(monitoredType.id, status: .complete, recordCount: max(result.count, 0))
} else if result.apiCalls.contains(where: { $0.status == .timeout }) {
progress?.updateStatus(monitoredType.id, status: .failed("Timeout"))
} else if result.diagnosticQuality == SnapshotQuality.unauthorized.rawValue {
progress?.updateStatus(monitoredType.id, status: .failed("Not authorized"))
} else {
progress?.updateStatus(monitoredType.id, status: .failed("Failed"))
}
return result
}
private func fetchTypeCountDataFromHK(
monitoredType: MonitoredType,
sampleType: HKSampleType,
timeoutSeconds: TimeInterval,
previousTypeCount: TypeCount?,
archiveObservationID: Int64,
progress: SnapshotFetchProgress?
) async -> TypeCountFetchResult {
let dateFetchTimer = MonotonicTimer()
let earliestResult = await measureAPICall(
queryType: "earliest_sample",
timeoutSeconds: timeoutSeconds
) {
try await self.fetchEarliestDate(for: sampleType)
} resultDescription: { date in
Self.iso8601String(for: date)
}
guard earliestResult.apiCall.status == .complete else {
let apiCalls = [
Self.placeholderAPICall(
queryType: "record_import",
status: .unknown,
message: "Skipped because earliest sample lookup failed."
),
earliestResult.apiCall,
Self.placeholderAPICall(
queryType: "latest_sample",
status: .unknown,
message: "Skipped because earliest sample lookup failed."
)
]
let status = firstImpairedStatus(in: apiCalls)
let quality = diagnosticQuality(for: status)
var result = TypeCountFetchResult(
typeIdentifier: monitoredType.id,
displayName: monitoredType.displayName,
count: -1,
contentHash: "",
earliestDate: earliestResult.value ?? nil,
latestDate: nil,
quality: snapshotQuality(for: status),
diagnosticQuality: quality,
isUnsupported: false,
authorizationStatus: authorizationStatus(from: apiCalls),
apiCalls: apiCalls,
yearlyCounts: [],
distributionBins: [],
records: [],
recordArchiveData: nil
)
result.timingBreakdown.fetchElapsedSeconds = dateFetchTimer.elapsedSeconds
return result
}
let earliest = earliestResult.value ?? nil
if earliest == nil {
let dateFetchElapsedSeconds = dateFetchTimer.elapsedSeconds
var result = TypeCountFetchResult(
typeIdentifier: monitoredType.id,
displayName: monitoredType.displayName,
count: 0,
contentHash: HashService.typeHash(typeIdentifier: monitoredType.id, recordFingerprints: []),
earliestDate: nil,
latestDate: nil,
quality: .complete,
diagnosticQuality: HealthKitAPICallResult.Status.complete.rawValue,
isUnsupported: false,
authorizationStatus: "granted",
apiCalls: [
HealthKitAPICallResult(
queryType: "record_import",
status: .complete,
elapsedSeconds: 0,
resultValue: "0 samples (skipped after empty earliest_sample)"
),
earliestResult.apiCall,
HealthKitAPICallResult(
queryType: "latest_sample",
status: .complete,
elapsedSeconds: 0,
resultValue: "none (skipped after empty earliest_sample)"
)
],
yearlyCounts: [],
distributionBins: [],
records: [],
recordArchiveData: nil
)
result.captureMode = SampleDistribution.CaptureMode.initialImport.diagnosticValue
result.timingBreakdown.fetchElapsedSeconds = dateFetchElapsedSeconds
return result
}
let latestResult: APICallMeasurement<Date?>
latestResult = await measureAPICall(
queryType: "latest_sample",
timeoutSeconds: timeoutSeconds
) {
try await self.fetchLatestDate(for: sampleType)
} resultDescription: { date in
Self.iso8601String(for: date)
}
var apiCalls = [earliestResult.apiCall, latestResult.apiCall]
let dateFetchElapsedSeconds = dateFetchTimer.elapsedSeconds
guard latestResult.apiCall.status == .complete else {
apiCalls.insert(
Self.placeholderAPICall(
queryType: "record_import",
status: .unknown,
message: "Skipped because latest sample lookup failed."
),
at: 0
)
let status = firstImpairedStatus(in: apiCalls)
let quality = diagnosticQuality(for: status)
var result = TypeCountFetchResult(
typeIdentifier: monitoredType.id,
displayName: monitoredType.displayName,
count: -1,
contentHash: "",
earliestDate: earliest,
latestDate: latestResult.value ?? nil,
quality: snapshotQuality(for: status),
diagnosticQuality: quality,
isUnsupported: false,
authorizationStatus: authorizationStatus(from: apiCalls),
apiCalls: apiCalls,
yearlyCounts: [],
distributionBins: [],
records: [],
recordArchiveData: nil
)
result.timingBreakdown.fetchElapsedSeconds = dateFetchElapsedSeconds
return result
}
let latest = latestResult.value ?? nil
let previousArchiveState = try? await archiveStore.typeCaptureState(sampleTypeIdentifier: monitoredType.id)
let previousDistribution = PreviousDistributionState(
typeCount: previousTypeCount,
archiveState: previousArchiveState
)
let distributionResult = await measureAPICall(
queryType: "record_import",
timeoutSeconds: nil
) {
try await self.fetchDistribution(
for: sampleType,
typeIdentifier: monitoredType.id,
earliestDate: earliest,
latestDate: latest,
previousDistribution: previousDistribution,
archiveObservationID: archiveObservationID,
progress: progress
)
} resultDescription: { distribution in
switch distribution.captureMode {
case .unchangedDelta:
return "\(distribution.totalCount) unchanged samples via empty HealthKit delta"
case .delta:
return "\(distribution.totalCount) samples after applying \(distribution.deltaEventCount) HealthKit delta events"
case .initialImport:
return "\(distribution.totalCount) samples from full HealthKit import"
}
}
apiCalls.insert(distributionResult.apiCall, at: 0)
guard distributionResult.apiCall.status == .complete, let distribution = distributionResult.value else {
let status = distributionResult.apiCall.status
let quality = diagnosticQuality(for: status)
var result = TypeCountFetchResult(
typeIdentifier: monitoredType.id,
displayName: monitoredType.displayName,
count: -1,
contentHash: "",
earliestDate: earliest,
latestDate: latest,
quality: snapshotQuality(for: status),
diagnosticQuality: quality,
isUnsupported: false,
authorizationStatus: authorizationStatus(from: apiCalls),
apiCalls: apiCalls,
yearlyCounts: [],
distributionBins: [],
records: [],
recordArchiveData: nil
)
result.timingBreakdown.fetchElapsedSeconds = dateFetchElapsedSeconds
return result
}
let contentHash = distribution.contentHash ?? HashService.typeHash(
typeIdentifier: monitoredType.id,
recordFingerprints: distribution.records.map(\.recordFingerprint)
)
// YearlyCount uses Calendar.current; year attribution is local-time based.
let yearMap: [Int: Int]
if let cachedYearlyCounts = distribution.yearlyCounts {
yearMap = cachedYearlyCounts
} else {
var computedYearMap: [Int: Int] = [:]
for record in distribution.records {
let year = Calendar.current.component(.year, from: record.startDate)
computedYearMap[year, default: 0] += 1
}
yearMap = computedYearMap
}
let yearlyCounts = yearMap.map { year, yearCount in
TypeCountFetchResult.YearlyCountData(
year: year,
count: yearCount,
typeIdentifier: monitoredType.id,
isApproximate: false
)
}
progress?.updateBlockProgress(
monitoredType.id,
detail: "Preparing capture summary",
recordCount: distribution.totalCount
)
var timingBreakdown = distribution.timingBreakdown
timingBreakdown.fetchElapsedSeconds += dateFetchElapsedSeconds
var result = TypeCountFetchResult(
typeIdentifier: monitoredType.id,
displayName: monitoredType.displayName,
count: distribution.totalCount,
contentHash: contentHash,
earliestDate: earliest,
latestDate: latest,
quality: SnapshotQuality.complete,
diagnosticQuality: HealthKitAPICallResult.Status.complete.rawValue,
isUnsupported: false,
authorizationStatus: authorizationStatus(from: apiCalls),
apiCalls: apiCalls,
yearlyCounts: yearlyCounts,
distributionBins: distribution.bins.map {
TypeCountFetchResult.DistributionBinData(
bucketStart: $0.start,
bucketEnd: $0.end,
count: $0.count,
contentHash: $0.contentHash,
anchorData: $0.anchorData
)
},
records: [],
recordArchiveData: distribution.recordArchiveData
)
result.captureMode = distribution.captureMode.diagnosticValue
result.deltaEventCount = distribution.deltaEventCount
result.timingBreakdown = timingBreakdown
await persistTypeCaptureState(from: result, observationID: archiveObservationID)
return result
}
private func persistTypeCaptureState(from result: TypeCountFetchResult, observationID: Int64) async {
guard result.quality == .complete,
result.count >= 0,
let anchorData = result.distributionBins.last?.anchorData else {
return
}
let yearlyCounts = Dictionary(uniqueKeysWithValues: result.yearlyCounts.map { ($0.year, $0.count) })
let state = HealthArchiveTypeCaptureState(
sampleTypeIdentifier: result.typeIdentifier,
count: result.count,
contentHash: result.contentHash,
earliestDate: result.earliestDate,
latestDate: result.latestDate,
yearlyCounts: yearlyCounts,
anchorData: anchorData
)
do {
try await archiveStore.upsertTypeCaptureState(state, observationID: observationID)
} catch {
print("[HealthProbeImport] capture_state_persist_failed type=\(result.typeIdentifier) error=\(error)")
}
}
// MARK: - HealthKit queries
private func fetchDistribution(
for sampleType: HKSampleType,
typeIdentifier: String,
earliestDate: Date?,
latestDate: Date?,
previousDistribution: PreviousDistributionState,
archiveObservationID: Int64,
progress: SnapshotFetchProgress?
) async throws -> SampleDistribution {
var anchor = previousDistribution.globalAnchor
let startedFromAnchor = anchor != nil
let incrementalStrategy = DistributionCaptureConfiguration.incrementalStrategy
var persistenceState = incrementalStrategy.makePersistenceState()
var captureTimings = DistributionCaptureTimings.zero
let estimatedPageCount = startedFromAnchor
? Self.estimatedPageCount(
for: previousDistribution.count,
pageLimit: incrementalStrategy.queryPageLimit
)
: nil
let progressStarted = Date()
var pageNumber = 0
var processedEventCount = 0
var firstDeltaPage: SampleDistributionPage?
if anchor != nil {
pageNumber = 1
progress?.updateBlockProgress(
typeIdentifier,
detail: Self.pageProgressDetail(
operation: "Delta",
pageNumber: pageNumber,
estimatedPageCount: estimatedPageCount
),
recordCount: previousDistribution.count,
elapsedSeconds: 0,
samplesPerSecond: 0
)
let firstPageFetchStartedAt = Date()
let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
try await self.fetchDistributionPage(
for: sampleType,
predicate: nil,
anchor: anchor,
limit: incrementalStrategy.queryPageLimit
)
}
captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(firstPageFetchStartedAt)
anchor = page.anchor
if !page.samples.isEmpty || !page.deletedObjects.isEmpty,
!previousDistribution.canApplyDeltaChanges {
progress?.updateBlockProgress(
typeIdentifier,
detail: "Delta requires full archive refresh",
recordCount: previousDistribution.count,
elapsedSeconds: Date().timeIntervalSince(progressStarted),
samplesPerSecond: 0
)
return try await fetchInitialDistributionStreaming(
for: sampleType,
typeIdentifier: typeIdentifier,
earliestDate: earliestDate,
latestDate: latestDate,
progressStarted: progressStarted,
archiveObservationID: archiveObservationID,
progress: progress
)
}
let archiveResult = try await archivePage(
page,
sampleType: sampleType,
observationID: archiveObservationID,
progress: progress,
typeIdentifier: typeIdentifier,
progressDetail: "Persisting delta page \(pageNumber)",
recordCount: previousDistribution.count,
progressStarted: progressStarted,
processedEventCount: processedEventCount,
persistenceState: persistenceState
)
persistenceState = archiveResult.persistenceState
captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds
if page.samples.isEmpty, page.deletedObjects.isEmpty,
let unchanged = previousDistribution.unchangedDistribution(
updatedAnchor: anchor,
typeIdentifier: typeIdentifier,
earliestDate: earliestDate,
latestDate: latestDate
) {
captureTimings.addFinalization(try await finalizeUnchangedArchiveVerification(
sampleType: sampleType,
typeIdentifier: typeIdentifier,
recordCount: unchanged.totalCount,
progressStarted: progressStarted,
processedEventCount: processedEventCount,
archiveObservationID: archiveObservationID,
progress: progress
))
progress?.updateBlockProgress(
typeIdentifier,
detail: "No HealthKit delta",
recordCount: unchanged.totalCount,
elapsedSeconds: Date().timeIntervalSince(progressStarted),
samplesPerSecond: 0
)
return SampleDistribution(
totalCount: unchanged.totalCount,
bins: unchanged.bins,
records: unchanged.records,
contentHash: unchanged.contentHash,
yearlyCounts: unchanged.yearlyCounts,
recordArchiveData: unchanged.recordArchiveData,
captureMode: .unchangedDelta,
deltaEventCount: 0,
timingBreakdown: captureTimings.importBreakdown
)
}
firstDeltaPage = page
}
if !startedFromAnchor {
return try await fetchInitialDistributionStreaming(
for: sampleType,
typeIdentifier: typeIdentifier,
earliestDate: earliestDate,
latestDate: latestDate,
progressStarted: progressStarted,
archiveObservationID: archiveObservationID,
progress: progress
)
}
var shouldFetchNextPage = true
var deltaPages: [SampleDistributionPage] = []
deltaPages.reserveCapacity(1)
var estimatedRecordCount = previousDistribution.count
if let firstDeltaPage {
let firstPageApplyStartedAt = Date()
deltaPages.append(firstDeltaPage)
estimatedRecordCount += firstDeltaPage.samples.count - firstDeltaPage.deletedObjects.count
let deltaApplyElapsed = Date().timeIntervalSince(firstPageApplyStartedAt)
captureTimings.processingElapsedSeconds += deltaApplyElapsed
captureTimings.processingDeltaApplyElapsedSeconds += deltaApplyElapsed
processedEventCount += pageEventCount(firstDeltaPage)
shouldFetchNextPage = firstDeltaPage.samples.count + firstDeltaPage.deletedObjects.count >= incrementalStrategy.queryPageLimit
progress?.updateBlockProgress(
typeIdentifier,
detail: Self.pageProgressDetail(
operation: "Delta",
pageNumber: pageNumber,
estimatedPageCount: estimatedPageCount
),
recordCount: max(0, estimatedRecordCount),
elapsedSeconds: Date().timeIntervalSince(progressStarted),
samplesPerSecond: Self.samplesPerSecond(
processedCount: processedEventCount,
elapsedSeconds: Date().timeIntervalSince(progressStarted)
)
)
}
while shouldFetchNextPage {
pageNumber += 1
progress?.updateBlockProgress(
typeIdentifier,
detail: Self.pageProgressDetail(
operation: anchor == nil ? "Import" : "Delta",
pageNumber: pageNumber,
estimatedPageCount: anchor == nil ? nil : estimatedPageCount
),
recordCount: max(0, estimatedRecordCount),
elapsedSeconds: Date().timeIntervalSince(progressStarted),
samplesPerSecond: Self.samplesPerSecond(
processedCount: processedEventCount,
elapsedSeconds: Date().timeIntervalSince(progressStarted)
)
)
let pageFetchStartedAt = Date()
let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
try await self.fetchDistributionPage(
for: sampleType,
predicate: nil,
anchor: anchor,
limit: incrementalStrategy.queryPageLimit
)
}
captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(pageFetchStartedAt)
let archiveResult = try await archivePage(
page,
sampleType: sampleType,
observationID: archiveObservationID,
progress: progress,
typeIdentifier: typeIdentifier,
progressDetail: "Persisting delta page \(pageNumber)",
recordCount: max(0, estimatedRecordCount),
progressStarted: progressStarted,
processedEventCount: processedEventCount,
persistenceState: persistenceState
)
persistenceState = archiveResult.persistenceState
captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds
anchor = page.anchor
let applyStartedAt = Date()
deltaPages.append(page)
estimatedRecordCount += page.samples.count - page.deletedObjects.count
let deltaApplyElapsed = Date().timeIntervalSince(applyStartedAt)
captureTimings.processingElapsedSeconds += deltaApplyElapsed
captureTimings.processingDeltaApplyElapsedSeconds += deltaApplyElapsed
processedEventCount += pageEventCount(page)
shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= incrementalStrategy.queryPageLimit
progress?.updateBlockProgress(
typeIdentifier,
detail: Self.pageProgressDetail(
operation: anchor == nil ? "Import" : "Delta",
pageNumber: pageNumber,
estimatedPageCount: anchor == nil ? nil : estimatedPageCount
),
recordCount: max(0, estimatedRecordCount),
elapsedSeconds: Date().timeIntervalSince(progressStarted),
samplesPerSecond: Self.samplesPerSecond(
processedCount: processedEventCount,
elapsedSeconds: Date().timeIntervalSince(progressStarted)
)
)
}
let archiveRebuildStartedAt = Date()
let rebuiltArchive = Self.makeDeltaDistributionState(
typeIdentifier: typeIdentifier,
previousDistribution: previousDistribution,
sampleType: sampleType,
earliestDate: earliestDate,
latestDate: latestDate,
deltaPages: deltaPages
)
let archiveRebuildElapsed = Date().timeIntervalSince(archiveRebuildStartedAt)
captureTimings.processingElapsedSeconds += archiveRebuildElapsed
captureTimings.processingRecordArchiveRebuildElapsedSeconds += archiveRebuildElapsed
captureTimings.addFinalization(try await finalizeArchiveVerification(
sampleType: sampleType,
typeIdentifier: typeIdentifier,
recordCount: rebuiltArchive.count,
progressStarted: progressStarted,
processedEventCount: processedEventCount,
archiveObservationID: archiveObservationID,
progress: progress
))
progress?.updateBlockProgress(
typeIdentifier,
detail: pageNumber == 1 ? "Imported 1 page" : "Imported \(pageNumber) pages",
recordCount: rebuiltArchive.count
)
guard rebuiltArchive.count > 0 || anchor != nil else {
return SampleDistribution(
totalCount: 0,
bins: [],
records: [],
contentHash: nil,
yearlyCounts: nil,
recordArchiveData: nil,
captureMode: .initialImport,
deltaEventCount: 0,
timingBreakdown: captureTimings.importBreakdown
)
}
let binStart = earliestDate ?? rebuiltArchive.earliestDate ?? previousDistribution.earliestRecordDate ?? Date()
let rawBinEnd = latestDate ?? rebuiltArchive.latestDate ?? previousDistribution.latestRecordDate ?? binStart
let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1)
return SampleDistribution(
totalCount: rebuiltArchive.count,
bins: [
SampleDistribution.Bin(
start: binStart,
end: binEnd,
count: rebuiltArchive.count,
contentHash: rebuiltArchive.contentHash,
anchorData: anchor.flatMap(Self.archiveAnchor(_:))
)
],
records: [],
contentHash: rebuiltArchive.contentHash,
yearlyCounts: nil,
recordArchiveData: nil,
captureMode: .delta,
deltaEventCount: processedEventCount,
timingBreakdown: captureTimings.importBreakdown
)
}
private static func makeDeltaDistributionState(
typeIdentifier: String,
previousDistribution: PreviousDistributionState,
sampleType: HKSampleType,
earliestDate: Date?,
latestDate: Date?,
deltaPages: [SampleDistributionPage]
) -> RebuiltRecordArchive {
let deltaSampleCount = deltaPages.reduce(0) { $0 + $1.samples.count }
let deltaDeletedCount = deltaPages.reduce(0) { $0 + $1.deletedObjects.count }
var replacementFingerprints: [String] = []
replacementFingerprints.reserveCapacity(deltaSampleCount)
var deletedUUIDHashes: [String] = []
deletedUUIDHashes.reserveCapacity(deltaDeletedCount)
for page in deltaPages {
for deletedObject in page.deletedObjects {
deletedUUIDHashes.append(HashService.sampleUUIDHash(deletedObject.uuid.uuidString))
}
for sample in page.samples {
let value = recordValue(for: sample, sampleType: sampleType, typeIdentifier: typeIdentifier)
replacementFingerprints.append(value.recordFingerprint)
}
}
let recordCount = max(0, previousDistribution.count + deltaSampleCount - deltaDeletedCount)
let effectiveEarliestDate = earliestDate ?? previousDistribution.earliestRecordDate
let effectiveLatestDate = latestDate ?? previousDistribution.latestRecordDate
let contentHash = HashService.archiveContentHash(
domain: "hp:sqlite_delta_type_state:v1",
parts: [
typeIdentifier,
previousDistribution.contentHash,
String(previousDistribution.count),
String(recordCount),
effectiveEarliestDate.map { String(format: "%.6f", $0.timeIntervalSince1970) },
effectiveLatestDate.map { String(format: "%.6f", $0.timeIntervalSince1970) },
replacementFingerprints.sorted().joined(separator: "|"),
deletedUUIDHashes.sorted().joined(separator: "|")
]
)
return RebuiltRecordArchive(
count: recordCount,
contentHash: contentHash,
earliestDate: effectiveEarliestDate,
latestDate: effectiveLatestDate,
recordArchiveData: nil
)
}
private func fetchInitialDistributionStreaming(
for sampleType: HKSampleType,
typeIdentifier: String,
earliestDate: Date?,
latestDate: Date?,
progressStarted: Date,
archiveObservationID: Int64,
progress: SnapshotFetchProgress?
) async throws -> SampleDistribution {
let importStrategy = DistributionCaptureConfiguration.initialImportStrategy(for: typeIdentifier)
var persistenceState = importStrategy.makePersistenceState()
var captureTimings = DistributionCaptureTimings.zero
var anchor: HKQueryAnchor?
var pageNumber = 0
var recordCount = 0
var processedEventCount = 0
var firstRecordDate: Date?
var latestRecordDate: Date?
var yearMap: [Int: Int] = [:]
var hashBuilder = HashService.TypeHashBuilder(typeIdentifier: typeIdentifier)
var shouldFetchNextPage = true
var pendingArchiveSamples: [HKSample] = []
pendingArchiveSamples.reserveCapacity(importStrategy.initialArchiveFlushSampleLimit)
func flushPendingArchiveSamples(pageNumber: Int) async throws {
guard !pendingArchiveSamples.isEmpty else { return }
let samplesToArchive = pendingArchiveSamples
pendingArchiveSamples.removeAll(keepingCapacity: true)
let archiveResult = try await archivePage(
SampleDistributionPage(samples: samplesToArchive, deletedObjects: [], anchor: nil),
sampleType: sampleType,
observationID: archiveObservationID,
progress: progress,
typeIdentifier: typeIdentifier,
progressDetail: "Persisting import pages through \(pageNumber)",
recordCount: nil,
progressStarted: progressStarted,
processedEventCount: processedEventCount,
persistenceState: persistenceState
)
persistenceState = archiveResult.persistenceState
captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds
}
while shouldFetchNextPage {
pageNumber += 1
let elapsedBeforePage = Date().timeIntervalSince(progressStarted)
progress?.updateBlockProgress(
typeIdentifier,
detail: Self.pageProgressDetail(
operation: "Import",
pageNumber: pageNumber,
estimatedPageCount: nil
),
recordCount: recordCount,
elapsedSeconds: elapsedBeforePage,
samplesPerSecond: Self.samplesPerSecond(
processedCount: processedEventCount,
elapsedSeconds: elapsedBeforePage
)
)
let pageFetchStartedAt = Date()
let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
try await self.fetchDistributionPage(
for: sampleType,
predicate: nil,
anchor: anchor,
limit: importStrategy.queryPageLimit
)
}
captureTimings.fetchElapsedSeconds += Date().timeIntervalSince(pageFetchStartedAt)
anchor = page.anchor
pendingArchiveSamples.append(contentsOf: page.samples)
if !page.deletedObjects.isEmpty {
try await flushPendingArchiveSamples(pageNumber: pageNumber)
let archiveResult = try await archivePage(
SampleDistributionPage(samples: [], deletedObjects: page.deletedObjects, anchor: page.anchor),
sampleType: sampleType,
observationID: archiveObservationID,
progress: progress,
typeIdentifier: typeIdentifier,
progressDetail: "Persisting import deletes page \(pageNumber)",
recordCount: nil,
progressStarted: progressStarted,
processedEventCount: processedEventCount,
persistenceState: persistenceState
)
persistenceState = archiveResult.persistenceState
captureTimings.insertElapsedSeconds += archiveResult.insertElapsedSeconds
}
let processStartedAt = Date()
for sample in page.samples {
autoreleasepool {
let value = Self.recordValue(for: sample, sampleType: sampleType, typeIdentifier: typeIdentifier)
hashBuilder.append(recordFingerprint: value.recordFingerprint)
yearMap[Calendar.current.component(.year, from: value.startDate), default: 0] += 1
firstRecordDate = min(firstRecordDate ?? value.startDate, value.startDate)
latestRecordDate = max(latestRecordDate ?? value.endDate, value.endDate)
recordCount += 1
}
}
let recordProcessingElapsed = Date().timeIntervalSince(processStartedAt)
captureTimings.processingElapsedSeconds += recordProcessingElapsed
captureTimings.processingInitialRecordElapsedSeconds += recordProcessingElapsed
processedEventCount += pageEventCount(page)
shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= importStrategy.queryPageLimit
if pendingArchiveSamples.count >= importStrategy.initialArchiveFlushSampleLimit || !shouldFetchNextPage {
try await flushPendingArchiveSamples(pageNumber: pageNumber)
}
let elapsedAfterPage = Date().timeIntervalSince(progressStarted)
progress?.updateBlockProgress(
typeIdentifier,
detail: Self.pageProgressDetail(
operation: "Import",
pageNumber: pageNumber,
estimatedPageCount: nil
),
recordCount: recordCount,
elapsedSeconds: elapsedAfterPage,
samplesPerSecond: Self.samplesPerSecond(
processedCount: processedEventCount,
elapsedSeconds: elapsedAfterPage
)
)
await Task.yield()
}
let finalizeHashStartedAt = Date()
let contentHash = hashBuilder.finalize()
let hashFinalizeElapsed = Date().timeIntervalSince(finalizeHashStartedAt)
captureTimings.processingElapsedSeconds += hashFinalizeElapsed
progress?.updateBlockProgress(
typeIdentifier,
detail: pageNumber == 1 ? "Imported 1 page" : "Imported \(pageNumber) pages",
recordCount: recordCount,
elapsedSeconds: Date().timeIntervalSince(progressStarted),
samplesPerSecond: Self.samplesPerSecond(
processedCount: processedEventCount,
elapsedSeconds: Date().timeIntervalSince(progressStarted)
)
)
captureTimings.addFinalization(try await finalizeArchiveVerification(
sampleType: sampleType,
typeIdentifier: typeIdentifier,
recordCount: recordCount,
progressStarted: progressStarted,
processedEventCount: processedEventCount,
archiveObservationID: archiveObservationID,
progress: progress
))
guard recordCount > 0 || anchor != nil else {
return SampleDistribution(
totalCount: 0,
bins: [],
records: [],
contentHash: nil,
yearlyCounts: nil,
recordArchiveData: nil,
captureMode: .initialImport,
deltaEventCount: 0,
timingBreakdown: captureTimings.importBreakdown
)
}
let binStart = earliestDate ?? firstRecordDate ?? Date()
let rawBinEnd = latestDate ?? latestRecordDate ?? binStart
let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1)
return SampleDistribution(
totalCount: recordCount,
bins: [
SampleDistribution.Bin(
start: binStart,
end: binEnd,
count: recordCount,
contentHash: contentHash,
anchorData: anchor.flatMap(Self.archiveAnchor(_:))
)
],
records: [],
contentHash: contentHash,
yearlyCounts: yearMap,
recordArchiveData: nil,
captureMode: .initialImport,
deltaEventCount: processedEventCount,
timingBreakdown: captureTimings.importBreakdown
)
}
private static func recordValue(
for sample: HKSample,
sampleType: HKSampleType,
typeIdentifier: String
) -> HealthRecordValue {
HealthRecordValue(
typeIdentifier: typeIdentifier,
sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
recordFingerprint: HashService.sampleFingerprint(
typeIdentifier: sampleType.identifier,
sampleUUID: sample.uuid.uuidString,
startDate: sample.startDate,
endDate: sample.endDate
),
startDate: sample.startDate,
endDate: sample.endDate,
displayValue: nil
)
}
private func pageEventCount(_ page: SampleDistributionPage) -> Int {
page.samples.count + page.deletedObjects.count
}
private static func estimatedPageCount(for recordCount: Int, pageLimit: Int) -> Int {
let count = max(recordCount, 0)
let normalizedPageLimit = max(pageLimit, 1)
return max(1, (count + normalizedPageLimit - 1) / normalizedPageLimit)
}
private static func samplesPerSecond(processedCount: Int, elapsedSeconds: TimeInterval) -> Double {
guard elapsedSeconds > 0, processedCount > 0 else { return 0 }
return Double(processedCount) / elapsedSeconds
}
private func finalizeUnchangedArchiveVerification(
sampleType: HKSampleType,
typeIdentifier: String,
recordCount: Int,
progressStarted: Date,
processedEventCount: Int,
archiveObservationID: Int64,
progress: SnapshotFetchProgress?
) async throws -> HealthArchiveFinalizationBreakdown {
try await finalizeArchiveVerification(
sampleType: sampleType,
typeIdentifier: typeIdentifier,
recordCount: recordCount,
progressStarted: progressStarted,
processedEventCount: processedEventCount,
archiveObservationID: archiveObservationID,
progress: progress,
markVerification: {
try await self.archiveStore.markUnchangedVerification(
sampleType: sampleType,
verifiedAt: Date(),
observationID: archiveObservationID
)
}
)
}
private func finalizeArchiveVerification(
sampleType: HKSampleType,
typeIdentifier: String,
recordCount: Int,
progressStarted: Date,
processedEventCount: Int,
archiveObservationID: Int64,
progress: SnapshotFetchProgress?,
markVerification: (() async throws -> HealthArchiveFinalizationBreakdown)? = nil
) async throws -> HealthArchiveFinalizationBreakdown {
let elapsedBeforeFinalize = Date().timeIntervalSince(progressStarted)
progress?.updateBlockProgress(
typeIdentifier,
detail: "Finalizing archive aggregates",
recordCount: recordCount,
elapsedSeconds: elapsedBeforeFinalize,
samplesPerSecond: Self.samplesPerSecond(
processedCount: processedEventCount,
elapsedSeconds: elapsedBeforeFinalize
)
)
let verificationStartedAt = Date()
let breakdown: HealthArchiveFinalizationBreakdown
if let markVerification {
breakdown = try await markVerification()
} else {
breakdown = try await archiveStore.markVerification(
sampleType: sampleType,
verifiedAt: Date(),
observationID: archiveObservationID
)
}
let finalizeElapsed = Date().timeIntervalSince(verificationStartedAt)
var normalizedBreakdown = breakdown
normalizedBreakdown.totalElapsedSeconds = finalizeElapsed
let elapsedAfterFinalize = Date().timeIntervalSince(progressStarted)
progress?.updateBlockProgress(
typeIdentifier,
detail: "Archive aggregates finalized",
recordCount: recordCount,
elapsedSeconds: elapsedAfterFinalize,
samplesPerSecond: Self.samplesPerSecond(
processedCount: processedEventCount,
elapsedSeconds: elapsedAfterFinalize
)
)
return normalizedBreakdown
}
private static func pageProgressDetail(
operation: String,
pageNumber: Int,
estimatedPageCount: Int?
) -> String {
guard let estimatedPageCount else {
return "\(operation) page \(pageNumber) (total unknown)"
}
if pageNumber <= estimatedPageCount {
return "\(operation) page \(pageNumber) of ~\(estimatedPageCount)"
}
return "\(operation) page \(pageNumber) of ~\(estimatedPageCount)+"
}
private func fetchDistributionPage(
for sampleType: HKSampleType,
predicate: NSPredicate?,
anchor: HKQueryAnchor?,
limit: Int
) async throws -> SampleDistributionPage {
let box = HealthKitQueryContinuationBox<SampleDistributionPage>()
nonisolated(unsafe) let queryPredicate = predicate
let queryLimit = max(limit, 1)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
box.setContinuation(continuation)
let query = HKAnchoredObjectQuery(
type: sampleType,
predicate: queryPredicate,
anchor: anchor,
limit: queryLimit
) { _, samples, deletedObjects, newAnchor, error in
if let error {
box.resume(throwing: error)
return
}
box.resume(
returning: SampleDistributionPage(
samples: samples ?? [],
deletedObjects: deletedObjects ?? [],
anchor: newAnchor
)
)
}
box.setQuery(query, store: store)
store.execute(query)
}
} onCancel: {
box.cancel()
}
}
private func archivePage(
_ page: SampleDistributionPage,
sampleType: HKSampleType,
observationID: Int64,
progress: SnapshotFetchProgress? = nil,
typeIdentifier: String? = nil,
progressDetail: String? = nil,
recordCount: Int? = nil,
progressStarted: Date? = nil,
processedEventCount: Int? = nil,
persistenceState: DistributionPagePersistenceState
) async throws -> DistributionPageArchiveResult {
var persistenceState = persistenceState
let completedEventCountBeforePage = processedEventCount ?? 0
var persistedSampleCount = 0
var persistedDeletedCount = 0
var insertElapsedSeconds: TimeInterval = 0
let observedAt = Date()
if !page.samples.isEmpty {
var batchStart = 0
while batchStart < page.samples.count {
let chunkSize = min(
max(persistenceState.currentWriteChunkSize, persistenceState.minimumWriteChunkSize),
page.samples.count - batchStart
)
let batchEnd = min(
batchStart + chunkSize,
page.samples.count
)
let sampleBatch = Array(page.samples[batchStart..<batchEnd])
if let progress, let typeIdentifier, let progressDetail {
let detail = page.samples.count <= chunkSize
? progressDetail
: "\(progressDetail) (\(batchStart + 1)-\(batchEnd)/\(page.samples.count))"
progress.updateBlockProgress(
typeIdentifier,
detail: detail,
recordCount: recordCount.map { $0 + persistedSampleCount },
elapsedSeconds: progressStarted.map { Date().timeIntervalSince($0) },
samplesPerSecond: {
guard let progressStarted else { return nil }
return Self.samplesPerSecond(
processedCount: completedEventCountBeforePage + persistedSampleCount + persistedDeletedCount,
elapsedSeconds: Date().timeIntervalSince(progressStarted)
)
}()
)
}
let batchStartedAt = Date()
_ = try await archiveStore.upsertSamples(
sampleBatch,
observedAt: observedAt,
observationID: observationID
)
let batchElapsedSeconds = Date().timeIntervalSince(batchStartedAt)
insertElapsedSeconds += batchElapsedSeconds
persistedSampleCount += sampleBatch.count
persistenceState.registerBatchDuration(batchElapsedSeconds)
batchStart = batchEnd
await Task.yield()
}
}
if !page.deletedObjects.isEmpty {
let deletedHashes = page.deletedObjects.map { HashService.sampleUUIDHash($0.uuid.uuidString) }
var deleteBatchStart = 0
while deleteBatchStart < deletedHashes.count {
let deleteBatchEnd = min(
deleteBatchStart + DistributionCaptureConfiguration.deleteBatchSize,
deletedHashes.count
)
let deleteBatch = Array(deletedHashes[deleteBatchStart..<deleteBatchEnd])
if let progress, let typeIdentifier, let progressDetail {
let detail = deletedHashes.count <= DistributionCaptureConfiguration.deleteBatchSize
? "\(progressDetail) (deletes \(deleteBatchEnd)/\(deletedHashes.count))"
: "\(progressDetail) (deletes \(deleteBatchStart + 1)-\(deleteBatchEnd)/\(deletedHashes.count))"
progress.updateBlockProgress(
typeIdentifier,
detail: detail,
recordCount: recordCount.map { $0 + persistedSampleCount },
elapsedSeconds: progressStarted.map { Date().timeIntervalSince($0) },
samplesPerSecond: {
guard let progressStarted else { return nil }
return Self.samplesPerSecond(
processedCount: completedEventCountBeforePage + persistedSampleCount + persistedDeletedCount,
elapsedSeconds: Date().timeIntervalSince(progressStarted)
)
}()
)
}
let batchStartedAt = Date()
let deletedCount = try await archiveStore.recordDisappearances(
sampleUUIDHashes: deleteBatch,
sampleTypeIdentifier: sampleType.identifier,
observedMissingAt: observedAt,
observationID: observationID
)
insertElapsedSeconds += Date().timeIntervalSince(batchStartedAt)
_ = deletedCount
persistedDeletedCount += deleteBatch.count
deleteBatchStart = deleteBatchEnd
await Task.yield()
}
}
return DistributionPageArchiveResult(
persistenceState: persistenceState,
persistedSampleCount: persistedSampleCount,
persistedDeletedCount: persistedDeletedCount,
insertElapsedSeconds: insertElapsedSeconds
)
}
private static func archiveAnchor(_ anchor: HKQueryAnchor) -> Data? {
try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
}
private static func unarchiveAnchor(_ data: Data?) -> HKQueryAnchor? {
guard let data else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
}
private static func displayValue(for sample: HKSample) -> String? {
if let quantitySample = sample as? HKQuantitySample {
return quantityDisplayValue(for: quantitySample)
}
if let categorySample = sample as? HKCategorySample {
return categoryDisplayValue(for: categorySample)
}
if let workout = sample as? HKWorkout {
return workoutDisplayValue(for: workout)
}
return nil
}
private static func quantityDisplayValue(for sample: HKQuantitySample) -> String? {
let identifier = sample.quantityType.identifier
switch identifier {
case HKQuantityTypeIdentifier.stepCount.rawValue:
return format(sample.quantity.doubleValue(for: .count()), unit: "")
case HKQuantityTypeIdentifier.distanceWalkingRunning.rawValue:
let meters = sample.quantity.doubleValue(for: .meter())
return meters >= 1_000
? "\(format(meters / 1_000, maximumFractionDigits: 2)) km"
: "\(format(meters, maximumFractionDigits: 0)) m"
case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:
return "\(format(sample.quantity.doubleValue(for: .kilocalorie()), maximumFractionDigits: 0)) kcal"
case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:
return "\(format(sample.quantity.doubleValue(for: .minute()), maximumFractionDigits: 0)) min"
case HKQuantityTypeIdentifier.heartRate.rawValue,
HKQuantityTypeIdentifier.restingHeartRate.rawValue:
return "\(format(sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), maximumFractionDigits: 0)) bpm"
case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
return "\(format(sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), maximumFractionDigits: 1)) breaths/min"
case HKQuantityTypeIdentifier.environmentalAudioExposure.rawValue,
HKQuantityTypeIdentifier.headphoneAudioExposure.rawValue:
return "\(format(sample.quantity.doubleValue(for: .decibelAWeightedSoundPressureLevel()), maximumFractionDigits: 1)) dB"
case HKQuantityTypeIdentifier.bodyMass.rawValue:
return "\(format(sample.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo)), maximumFractionDigits: 1)) kg"
case HKQuantityTypeIdentifier.vo2Max.rawValue:
let unit = HKUnit.literUnit(with: .milli)
.unitDivided(by: HKUnit.gramUnit(with: .kilo))
.unitDivided(by: .minute())
return "\(format(sample.quantity.doubleValue(for: unit), maximumFractionDigits: 1)) mL/kg/min"
default:
return nil
}
}
private static func categoryDisplayValue(for sample: HKCategorySample) -> String {
switch sample.categoryType.identifier {
case HKCategoryTypeIdentifier.appleStandHour.rawValue:
if sample.value == HKCategoryValueAppleStandHour.stood.rawValue {
return "Stood"
}
if sample.value == HKCategoryValueAppleStandHour.idle.rawValue {
return "Idle"
}
return "Stand hour \(sample.value)"
case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
return sleepValueLabel(sample.value)
case HKCategoryTypeIdentifier.highHeartRateEvent.rawValue:
return "High heart rate event"
default:
return "Category value \(sample.value)"
}
}
private static func workoutDisplayValue(for workout: HKWorkout) -> String {
let duration = format(workout.duration / 60, maximumFractionDigits: 0)
return "\(workoutActivityName(workout.workoutActivityType)), \(duration) min"
}
private static func workoutActivityName(_ type: HKWorkoutActivityType) -> String {
switch type {
case .walking: return "Walking"
case .running: return "Running"
case .cycling: return "Cycling"
case .swimming: return "Swimming"
case .traditionalStrengthTraining: return "Strength training"
case .functionalStrengthTraining: return "Functional strength training"
case .highIntensityIntervalTraining: return "HIIT"
case .yoga: return "Yoga"
case .hiking: return "Hiking"
case .mindAndBody: return "Mind and body"
case .other: return "Workout"
default: return "Workout \(type.rawValue)"
}
}
private static func sleepValueLabel(_ value: Int) -> String {
if value == HKCategoryValueSleepAnalysis.inBed.rawValue { return "In bed" }
if value == HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue { return "Asleep" }
if value == HKCategoryValueSleepAnalysis.awake.rawValue { return "Awake" }
if value == HKCategoryValueSleepAnalysis.asleepCore.rawValue { return "Core sleep" }
if value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue { return "Deep sleep" }
if value == HKCategoryValueSleepAnalysis.asleepREM.rawValue { return "REM sleep" }
return "Sleep value \(value)"
}
private static func format(_ value: Double, maximumFractionDigits: Int = 2, unit: String? = nil) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = maximumFractionDigits
formatter.minimumFractionDigits = 0
let formatted = formatter.string(from: NSNumber(value: value)) ?? "\(value)"
if let unit, !unit.isEmpty {
return "\(formatted) \(unit)"
}
return formatted
}
private func fetchEarliestDate(for sampleType: HKSampleType) async throws -> Date? {
try await fetchBoundaryDate(for: sampleType, ascending: true)
}
private func fetchLatestDate(for sampleType: HKSampleType) async throws -> Date? {
try await fetchBoundaryDate(for: sampleType, ascending: false)
}
private func fetchBoundaryDate(for sampleType: HKSampleType, ascending: Bool) async throws -> Date? {
let box = HealthKitQueryContinuationBox<Date?>()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
box.setContinuation(continuation)
let query = HKSampleQuery(
sampleType: sampleType,
predicate: nil,
limit: 1,
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)]
) { _, samples, error in
if let error {
box.resume(throwing: error)
return
}
box.resume(returning: samples?.first?.startDate)
}
box.setQuery(query, store: store)
store.execute(query)
}
} onCancel: {
box.cancel()
}
}
// MARK: - Timeout profiles
private func timeoutProfile(for monitoredType: MonitoredType) -> LocalMetricTimeoutProfile {
LocalMetricTimeoutProfileStore.profile(for: monitoredType)
}
private func timeoutForFetch(
profile: LocalMetricTimeoutProfile,
adaptiveTimeoutsEnabled: Bool,
timeoutMultiplier: Double
) -> TimeInterval {
let base = adaptiveTimeoutsEnabled ? profile.effectiveTimeout : Self.defaultInitialTimeoutSeconds
return min(max(base * timeoutMultiplier, Self.defaultInitialTimeoutSeconds), Self.maximumTimeoutSeconds)
}
private func updateTimeoutProfile(
_ profile: inout LocalMetricTimeoutProfile,
with result: TypeCountFetchResult,
monitoredType: MonitoredType
) {
if result.quality == .complete {
profile.recordSuccess(elapsed: result.totalElapsedSeconds, displayName: monitoredType.displayName)
} else if result.apiCalls.contains(where: { $0.status == .timeout }) {
profile.recordTimeout(elapsed: result.totalElapsedSeconds, displayName: monitoredType.displayName)
} else {
profile.displayName = monitoredType.displayName
}
}
// MARK: - Query diagnostics
private struct APICallMeasurement<Value: Sendable>: Sendable {
let value: Value?
let apiCall: HealthKitAPICallResult
}
private func measureAPICall<Value: Sendable>(
queryType: String,
timeoutSeconds: TimeInterval?,
operation: @escaping @Sendable () async throws -> Value,
resultDescription: @escaping @Sendable (Value) -> String
) async -> APICallMeasurement<Value> {
let timer = MonotonicTimer()
do {
let value: Value
if let timeoutSeconds {
guard timeoutSeconds > 0 else { throw CancellationError() }
value = try await withTimeout(seconds: timeoutSeconds, operation: operation)
} else {
// Initial full imports can legitimately run far longer than a fixed
// wall clock cap. Keep per-page HealthKit timeouts, but do not
// abort the whole import while pages continue to make progress.
value = try await operation()
}
return APICallMeasurement(
value: value,
apiCall: HealthKitAPICallResult(
queryType: queryType,
status: .complete,
elapsedSeconds: timer.elapsedSeconds,
resultValue: resultDescription(value)
)
)
} catch {
let failure = Self.apiFailure(for: error)
let nsError = error as NSError
return APICallMeasurement(
value: nil,
apiCall: HealthKitAPICallResult(
queryType: queryType,
status: failure.status,
elapsedSeconds: timer.elapsedSeconds,
resultValue: nil,
errorCode: failure.errorCode ?? "\(nsError.code)",
errorDomain: failure.errorDomain ?? nsError.domain,
errorDescription: failure.errorDescription ?? nsError.localizedDescription,
failureKind: failure.failureKind,
cancellationReason: failure.cancellationReason
)
)
}
}
private static func apiFailure(for error: Error) -> (
status: HealthKitAPICallResult.Status,
failureKind: String,
errorDomain: String?,
errorCode: String?,
errorDescription: String?,
cancellationReason: String?
) {
if error is CancellationError {
return (
.timeout,
"internal cancellation",
"HealthProbe",
"perTypeTimeout",
"HealthProbe cancelled this query after the per-type timeout",
"per-type timeout reached"
)
}
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut {
return (.timeout, "timeout", nil, nil, nil, nil)
}
if (error as? HKError)?.code == .errorAuthorizationDenied {
return (.unauthorized, "HealthKit error", nil, nil, nil, nil)
}
return (.failed, "HealthKit error", nil, nil, nil, nil)
}
private func authorizationStatus(from apiCalls: [HealthKitAPICallResult]) -> String {
if apiCalls.contains(where: { $0.status == .complete }) {
return "granted"
}
if apiCalls.contains(where: { $0.status == .unauthorized }) {
return "denied"
}
if apiCalls.contains(where: \.indicatesProtectedDataInaccessible) {
return "unavailable"
}
return HealthKitAPICallResult.Status.unknown.rawValue
}
private func snapshotQuality(for status: HealthKitAPICallResult.Status) -> SnapshotQuality {
status == .unauthorized ? .unauthorized : .failed
}
private func diagnosticQuality(for status: HealthKitAPICallResult.Status) -> String {
switch status {
case .complete:
return HealthKitAPICallResult.Status.complete.rawValue
case .unauthorized:
return HealthKitAPICallResult.Status.unauthorized.rawValue
case .unsupported:
return HealthKitAPICallResult.Status.unsupported.rawValue
case .timeout:
return SnapshotQuality.failed.rawValue
case .failed:
return HealthKitAPICallResult.Status.failed.rawValue
case .unknown:
return HealthKitAPICallResult.Status.unknown.rawValue
}
}
private func firstImpairedStatus(in apiCalls: [HealthKitAPICallResult]) -> HealthKitAPICallResult.Status {
let statuses = apiCalls.map(\.status)
if statuses.contains(.unauthorized) { return .unauthorized }
if statuses.contains(.timeout) { return .timeout }
if statuses.contains(.unsupported) { return .unsupported }
if statuses.contains(.failed) { return .failed }
return .unknown
}
private static func placeholderAPICalls(status: HealthKitAPICallResult.Status, message: String) -> [HealthKitAPICallResult] {
[
placeholderAPICall(queryType: "record_import", status: status, message: message),
placeholderAPICall(queryType: "earliest_sample", status: status, message: message),
placeholderAPICall(queryType: "latest_sample", status: status, message: message)
]
}
private static func placeholderAPICall(
queryType: String,
status: HealthKitAPICallResult.Status,
message: String
) -> HealthKitAPICallResult {
HealthKitAPICallResult(
queryType: queryType,
status: status,
elapsedSeconds: 0,
resultValue: nil,
errorCode: "none",
errorDomain: "none",
errorDescription: message,
failureKind: status == .unknown ? "not run" : status.rawValue,
cancellationReason: "none"
)
}
private static func iso8601String(for date: Date?) -> String {
guard let date else { return "none" }
return ISO8601DateFormatter().string(from: date)
}
// MARK: - Quality aggregation
func deriveSnapshotQuality(from typeCounts: [TypeCount]) -> SnapshotQuality {
guard !typeCounts.isEmpty else { return .failed }
if typeCounts.contains(where: { $0.quality == .loading }) { return .loading }
let allUnauthorized = typeCounts.allSatisfy { $0.quality == .unauthorized }
if allUnauthorized { return .unauthorized }
let anyImpaired = typeCounts.contains { $0.quality == .failed || $0.quality == .unauthorized }
if anyImpaired { return .partial }
return .complete
}
// MARK: - Chain helpers
private func findPreviousSnapshot(deviceID: String, excluding id: UUID, context: ModelContext) -> HealthSnapshot? {
var descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.deviceID == deviceID && $0.id != id },
sortBy: [SortDescriptor(\.localSequenceNumber, order: .reverse)]
)
descriptor.fetchLimit = 1
return try? context.fetch(descriptor).first
}
private func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
var descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == id }
)
descriptor.fetchLimit = 1
return try? context.fetch(descriptor).first
}
private func isStoreEmpty(context: ModelContext) -> Bool {
var descriptor = FetchDescriptor<HealthSnapshot>()
descriptor.fetchLimit = 1
return (try? context.fetch(descriptor).isEmpty) ?? true
}
// MARK: - Device metadata
private func hardwareModel() -> String {
var size = 0
sysctlbyname("hw.machine", nil, &size, nil, 0)
var machine = [CChar](repeating: 0, count: size)
sysctlbyname("hw.machine", &machine, &size, nil, 0)
return String(cString: machine)
}
private func appBuildVersion() -> String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
return "\(version) (\(build))"
}
// MARK: - Timeout utility
private func withTimeout<T: Sendable>(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T {
guard seconds > 0, seconds.isFinite else { throw CancellationError() }
let nanoseconds = UInt64((seconds * 1_000_000_000).rounded(.up))
let box = TimeoutContinuationBox<T>()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
let operationTask = Task {
do {
let value = try await operation()
box.resume(continuation, with: .success(value))
} catch {
box.resume(continuation, with: .failure(error))
}
}
box.setOperationTask(operationTask)
let timeoutTask = Task {
do {
try await Task.sleep(nanoseconds: nanoseconds)
} catch {
return
}
box.cancelOperation()
box.resume(continuation, with: .failure(CancellationError()))
}
box.setTimeoutTask(timeoutTask)
}
} onCancel: {
box.cancelAll()
}
}
// MARK: - Type registry
private static func buildAllTypes() -> [MonitoredType] {
var result: [MonitoredType] = []
func addQty(_ id: HKQuantityTypeIdentifier, _ name: String, _ cat: TypeCategory, on: Bool) {
let t = HKObjectType.quantityType(forIdentifier: id)
result.append(MonitoredType(id: t?.identifier ?? id.rawValue, displayName: name, category: cat, isEnabledByDefault: on, objectType: t))
}
func addQtyRaw(_ rawIdentifier: String, _ name: String, _ cat: TypeCategory, on: Bool = false) {
let id = HKQuantityTypeIdentifier(rawValue: rawIdentifier)
let t = HKObjectType.quantityType(forIdentifier: id)
result.append(MonitoredType(id: t?.identifier ?? rawIdentifier, displayName: name, category: cat, isEnabledByDefault: on, objectType: t))
}
func addCat(_ id: HKCategoryTypeIdentifier, _ name: String, _ cat: TypeCategory, on: Bool) {
let t = HKObjectType.categoryType(forIdentifier: id)
result.append(MonitoredType(id: t?.identifier ?? id.rawValue, displayName: name, category: cat, isEnabledByDefault: on, objectType: t))
}
func addCatRaw(_ rawIdentifier: String, _ name: String, _ cat: TypeCategory, on: Bool = false) {
let id = HKCategoryTypeIdentifier(rawValue: rawIdentifier)
let t = HKObjectType.categoryType(forIdentifier: id)
result.append(MonitoredType(id: t?.identifier ?? rawIdentifier, displayName: name, category: cat, isEnabledByDefault: on, objectType: t))
}
let workout = HKObjectType.workoutType()
result.append(MonitoredType(id: workout.identifier, displayName: "Workouts", category: .activity, isEnabledByDefault: true, objectType: workout))
addQty(.stepCount, "Steps", .activity, on: true)
addQty(.distanceWalkingRunning, "Walking + Running Distance", .activity, on: true)
addQty(.activeEnergyBurned, "Active Energy", .activity, on: true)
addQty(.appleExerciseTime, "Exercise Minutes", .activity, on: true)
addCat(.appleStandHour, "Stand Hours", .activity, on: true)
addQtyRaw("HKQuantityTypeIdentifierBasalEnergyBurned", "Basal Energy", .activity)
addQtyRaw("HKQuantityTypeIdentifierFlightsClimbed", "Flights Climbed", .activity)
addQtyRaw("HKQuantityTypeIdentifierAppleMoveTime", "Move Time", .activity)
addQtyRaw("HKQuantityTypeIdentifierAppleStandTime", "Stand Time", .activity)
addQtyRaw("HKQuantityTypeIdentifierDistanceCycling", "Cycling Distance", .activity)
addQtyRaw("HKQuantityTypeIdentifierDistanceSwimming", "Swimming Distance", .activity)
addQtyRaw("HKQuantityTypeIdentifierSwimmingStrokeCount", "Swimming Stroke Count", .activity)
addQtyRaw("HKQuantityTypeIdentifierDistanceDownhillSnowSports", "Downhill Snow Sports Distance", .activity)
addQtyRaw("HKQuantityTypeIdentifierPushCount", "Wheelchair Pushes", .activity)
addQtyRaw("HKQuantityTypeIdentifierDistanceWheelchair", "Wheelchair Distance", .activity)
addQty(.heartRate, "Heart Rate", .heart, on: true)
addQty(.restingHeartRate, "Resting Heart Rate", .heart, on: true)
addCat(.highHeartRateEvent, "High Heart Rate Notifications", .heart, on: true)
addQtyRaw("HKQuantityTypeIdentifierWalkingHeartRateAverage", "Walking Heart Rate Average", .heart)
addQtyRaw("HKQuantityTypeIdentifierHeartRateVariabilitySDNN", "Heart Rate Variability", .heart)
addQtyRaw("HKQuantityTypeIdentifierAtrialFibrillationBurden", "Atrial Fibrillation Burden", .heart)
addCatRaw("HKCategoryTypeIdentifierLowHeartRateEvent", "Low Heart Rate Notifications", .heart)
addCatRaw("HKCategoryTypeIdentifierIrregularHeartRhythmEvent", "Irregular Rhythm Notifications", .heart)
addQty(.respiratoryRate, "Respiratory Rate", .respiratory, on: true)
addQtyRaw("HKQuantityTypeIdentifierOxygenSaturation", "Blood Oxygen", .respiratory)
addQtyRaw("HKQuantityTypeIdentifierForcedVitalCapacity", "Forced Vital Capacity", .respiratory)
addQtyRaw("HKQuantityTypeIdentifierForcedExpiratoryVolume1", "Forced Expiratory Volume, 1 sec", .respiratory)
addQtyRaw("HKQuantityTypeIdentifierPeakExpiratoryFlowRate", "Peak Expiratory Flow Rate", .respiratory)
addQtyRaw("HKQuantityTypeIdentifierInhalerUsage", "Inhaler Usage", .respiratory)
addCat(.sleepAnalysis, "Sleep", .sleep, on: true)
addQty(.environmentalAudioExposure, "Environmental Sound Levels", .hearing, on: false)
addQty(.headphoneAudioExposure, "Headphone Audio Levels", .hearing, on: false)
addCatRaw("HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", "Headphone Audio Notifications", .hearing)
addCatRaw("HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", "Environmental Audio Notifications", .hearing)
addQty(.bodyMass, "Body Mass", .body, on: false)
addQty(.vo2Max, "VO2 Max", .body, on: false)
addQtyRaw("HKQuantityTypeIdentifierBodyMassIndex", "Body Mass Index", .body)
addQtyRaw("HKQuantityTypeIdentifierBodyFatPercentage", "Body Fat Percentage", .body)
addQtyRaw("HKQuantityTypeIdentifierHeight", "Height", .body)
addQtyRaw("HKQuantityTypeIdentifierLeanBodyMass", "Lean Body Mass", .body)
addQtyRaw("HKQuantityTypeIdentifierWaistCircumference", "Waist Circumference", .body)
addQtyRaw("HKQuantityTypeIdentifierBodyTemperature", "Body Temperature", .body)
addQtyRaw("HKQuantityTypeIdentifierBasalBodyTemperature", "Basal Body Temperature", .body)
addQtyRaw("HKQuantityTypeIdentifierBloodPressureSystolic", "Blood Pressure Systolic", .body)
addQtyRaw("HKQuantityTypeIdentifierBloodPressureDiastolic", "Blood Pressure Diastolic", .body)
addQtyRaw("HKQuantityTypeIdentifierBloodGlucose", "Blood Glucose", .body)
addQtyRaw("HKQuantityTypeIdentifierInsulinDelivery", "Insulin Delivery", .body)
addQtyRaw("HKQuantityTypeIdentifierPeripheralPerfusionIndex", "Peripheral Perfusion Index", .body)
addQtyRaw("HKQuantityTypeIdentifierElectrodermalActivity", "Electrodermal Activity", .body)
addQtyRaw("HKQuantityTypeIdentifierWalkingSpeed", "Walking Speed", .mobility)
addQtyRaw("HKQuantityTypeIdentifierWalkingStepLength", "Walking Step Length", .mobility)
addQtyRaw("HKQuantityTypeIdentifierWalkingAsymmetryPercentage", "Walking Asymmetry", .mobility)
addQtyRaw("HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", "Double Support Time", .mobility)
addQtyRaw("HKQuantityTypeIdentifierSixMinuteWalkTestDistance", "Six-Minute Walk Distance", .mobility)
addQtyRaw("HKQuantityTypeIdentifierStairAscentSpeed", "Stair Ascent Speed", .mobility)
addQtyRaw("HKQuantityTypeIdentifierStairDescentSpeed", "Stair Descent Speed", .mobility)
addQtyRaw("HKQuantityTypeIdentifierAppleWalkingSteadiness", "Walking Steadiness", .mobility)
addCatRaw("HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", "Walking Steadiness Notifications", .mobility)
addQtyRaw("HKQuantityTypeIdentifierDietaryEnergyConsumed", "Dietary Energy", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryWater", "Water", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryProtein", "Protein", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryCarbohydrates", "Carbohydrates", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryFiber", "Fiber", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietarySugar", "Sugar", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryFatTotal", "Total Fat", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryFatSaturated", "Saturated Fat", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryFatMonounsaturated", "Monounsaturated Fat", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryFatPolyunsaturated", "Polyunsaturated Fat", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryCholesterol", "Cholesterol", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietarySodium", "Sodium", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryPotassium", "Potassium", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryCalcium", "Calcium", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryIron", "Iron", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryMagnesium", "Magnesium", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryZinc", "Zinc", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryVitaminA", "Vitamin A", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryVitaminB6", "Vitamin B6", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryVitaminB12", "Vitamin B12", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryVitaminC", "Vitamin C", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryVitaminD", "Vitamin D", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryVitaminE", "Vitamin E", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryVitaminK", "Vitamin K", .nutrition)
addQtyRaw("HKQuantityTypeIdentifierDietaryCaffeine", "Caffeine", .nutrition)
addCatRaw("HKCategoryTypeIdentifierMenstrualFlow", "Menstrual Flow", .reproductive)
addCatRaw("HKCategoryTypeIdentifierIntermenstrualBleeding", "Intermenstrual Bleeding", .reproductive)
addCatRaw("HKCategoryTypeIdentifierOvulationTestResult", "Ovulation Test Result", .reproductive)
addCatRaw("HKCategoryTypeIdentifierCervicalMucusQuality", "Cervical Mucus Quality", .reproductive)
addCatRaw("HKCategoryTypeIdentifierSexualActivity", "Sexual Activity", .reproductive)
addCatRaw("HKCategoryTypeIdentifierPregnancy", "Pregnancy", .reproductive)
addCatRaw("HKCategoryTypeIdentifierLactation", "Lactation", .reproductive)
addCatRaw("HKCategoryTypeIdentifierContraceptive", "Contraceptive", .reproductive)
addCatRaw("HKCategoryTypeIdentifierAbdominalCramps", "Abdominal Cramps", .symptoms)
addCatRaw("HKCategoryTypeIdentifierAcne", "Acne", .symptoms)
addCatRaw("HKCategoryTypeIdentifierAppetiteChanges", "Appetite Changes", .symptoms)
addCatRaw("HKCategoryTypeIdentifierBloating", "Bloating", .symptoms)
addCatRaw("HKCategoryTypeIdentifierBreastPain", "Breast Pain", .symptoms)
addCatRaw("HKCategoryTypeIdentifierChestTightnessOrPain", "Chest Tightness or Pain", .symptoms)
addCatRaw("HKCategoryTypeIdentifierChills", "Chills", .symptoms)
addCatRaw("HKCategoryTypeIdentifierConstipation", "Constipation", .symptoms)
addCatRaw("HKCategoryTypeIdentifierCoughing", "Coughing", .symptoms)
addCatRaw("HKCategoryTypeIdentifierDiarrhea", "Diarrhea", .symptoms)
addCatRaw("HKCategoryTypeIdentifierDizziness", "Dizziness", .symptoms)
addCatRaw("HKCategoryTypeIdentifierFatigue", "Fatigue", .symptoms)
addCatRaw("HKCategoryTypeIdentifierFever", "Fever", .symptoms)
addCatRaw("HKCategoryTypeIdentifierHeadache", "Headache", .symptoms)
addCatRaw("HKCategoryTypeIdentifierHeartburn", "Heartburn", .symptoms)
addCatRaw("HKCategoryTypeIdentifierHotFlashes", "Hot Flashes", .symptoms)
addCatRaw("HKCategoryTypeIdentifierLossOfSmell", "Loss of Smell", .symptoms)
addCatRaw("HKCategoryTypeIdentifierLossOfTaste", "Loss of Taste", .symptoms)
addCatRaw("HKCategoryTypeIdentifierLowerBackPain", "Lower Back Pain", .symptoms)
addCatRaw("HKCategoryTypeIdentifierMemoryLapse", "Memory Lapse", .symptoms)
addCatRaw("HKCategoryTypeIdentifierMoodChanges", "Mood Changes", .symptoms)
addCatRaw("HKCategoryTypeIdentifierNausea", "Nausea", .symptoms)
addCatRaw("HKCategoryTypeIdentifierPelvicPain", "Pelvic Pain", .symptoms)
addCatRaw("HKCategoryTypeIdentifierRapidPoundingOrFlutteringHeartbeat", "Rapid or Pounding Heartbeat", .symptoms)
addCatRaw("HKCategoryTypeIdentifierRunnyNose", "Runny Nose", .symptoms)
addCatRaw("HKCategoryTypeIdentifierShortnessOfBreath", "Shortness of Breath", .symptoms)
addCatRaw("HKCategoryTypeIdentifierSinusCongestion", "Sinus Congestion", .symptoms)
addCatRaw("HKCategoryTypeIdentifierSkippedHeartbeat", "Skipped Heartbeat", .symptoms)
addCatRaw("HKCategoryTypeIdentifierSleepChanges", "Sleep Changes", .symptoms)
addCatRaw("HKCategoryTypeIdentifierSoreThroat", "Sore Throat", .symptoms)
addCatRaw("HKCategoryTypeIdentifierVaginalDryness", "Vaginal Dryness", .symptoms)
addCatRaw("HKCategoryTypeIdentifierVomiting", "Vomiting", .symptoms)
addCatRaw("HKCategoryTypeIdentifierWheezing", "Wheezing", .symptoms)
addCatRaw("HKCategoryTypeIdentifierMindfulSession", "Mindful Sessions", .other)
addCatRaw("HKCategoryTypeIdentifierToothbrushingEvent", "Toothbrushing", .other)
addCatRaw("HKCategoryTypeIdentifierHandwashingEvent", "Handwashing", .other)
return result
}
}
private struct SampleDistribution: Sendable {
enum CaptureMode: Sendable {
case initialImport
case delta
case unchangedDelta
var diagnosticValue: String {
switch self {
case .initialImport:
return "initialImport"
case .delta:
return "delta"
case .unchangedDelta:
return "unchangedDelta"
}
}
}
struct Bin: Sendable {
let start: Date
let end: Date
let count: Int
let contentHash: String
let anchorData: Data?
}
let totalCount: Int
let bins: [Bin]
let records: [HealthRecordValue]
let contentHash: String?
let yearlyCounts: [Int: Int]?
let recordArchiveData: Data?
let captureMode: CaptureMode
let deltaEventCount: Int
let timingBreakdown: ImportTimingBreakdown
}
private struct SampleDistributionPage: Sendable {
let samples: [HKSample]
let deletedObjects: [HKDeletedObject]
let anchor: HKQueryAnchor?
}
private struct RebuiltRecordArchive: Sendable {
let count: Int
let contentHash: String
let earliestDate: Date?
let latestDate: Date?
let recordArchiveData: Data?
}
private final class TimeoutContinuationBox<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
nonisolated(unsafe) private var didResume = false
nonisolated(unsafe) private var operationTask: Task<Void, Never>?
nonisolated(unsafe) private var timeoutTask: Task<Void, Never>?
nonisolated func setOperationTask(_ task: Task<Void, Never>) {
setTask(task, keyPath: \.operationTask)
}
nonisolated func setTimeoutTask(_ task: Task<Void, Never>) {
setTask(task, keyPath: \.timeoutTask)
}
nonisolated func cancelOperation() {
lock.lock()
let task = operationTask
lock.unlock()
task?.cancel()
}
nonisolated func cancelAll() {
lock.lock()
let operationTask = operationTask
let timeoutTask = timeoutTask
lock.unlock()
operationTask?.cancel()
timeoutTask?.cancel()
}
nonisolated func resume(
_ continuation: CheckedContinuation<Value, Error>,
with result: Result<Value, Error>
) {
let shouldResume: Bool
let operationTask: Task<Void, Never>?
let timeoutTask: Task<Void, Never>?
lock.lock()
if didResume {
shouldResume = false
operationTask = nil
timeoutTask = nil
} else {
didResume = true
shouldResume = true
operationTask = self.operationTask
timeoutTask = self.timeoutTask
}
lock.unlock()
guard shouldResume else { return }
operationTask?.cancel()
timeoutTask?.cancel()
continuation.resume(with: result)
}
private nonisolated func setTask(
_ task: Task<Void, Never>,
keyPath: ReferenceWritableKeyPath<TimeoutContinuationBox<Value>, Task<Void, Never>?>
) {
let shouldCancel: Bool
lock.lock()
if didResume {
shouldCancel = true
} else {
shouldCancel = false
self[keyPath: keyPath] = task
}
lock.unlock()
if shouldCancel {
task.cancel()
}
}
}
private final class HealthKitQueryContinuationBox<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
nonisolated(unsafe) private var continuation: CheckedContinuation<Value, Error>?
nonisolated(unsafe) private var query: HKQuery?
nonisolated(unsafe) private weak var store: HKHealthStore?
nonisolated(unsafe) private var didResume = false
nonisolated func setContinuation(_ continuation: CheckedContinuation<Value, Error>) {
let shouldCancel: Bool
lock.lock()
if didResume {
shouldCancel = true
} else {
shouldCancel = false
self.continuation = continuation
}
lock.unlock()
if shouldCancel {
continuation.resume(throwing: CancellationError())
}
}
nonisolated func setQuery(_ query: HKQuery, store: HKHealthStore) {
lock.lock()
self.query = query
self.store = store
let shouldStop = didResume
lock.unlock()
if shouldStop {
store.stop(query)
}
}
nonisolated func resume(returning value: Value) {
complete { $0.resume(returning: value) }
}
nonisolated func resume(throwing error: Error) {
complete { $0.resume(throwing: error) }
}
nonisolated func cancel() {
let queryToStop: HKQuery?
let storeToStop: HKHealthStore?
lock.lock()
queryToStop = query
storeToStop = store
lock.unlock()
if let queryToStop, let storeToStop {
storeToStop.stop(queryToStop)
}
resume(throwing: CancellationError())
}
nonisolated private func complete(_ resume: (CheckedContinuation<Value, Error>) -> Void) {
let continuationToResume: CheckedContinuation<Value, Error>?
lock.lock()
if didResume {
continuationToResume = nil
} else {
didResume = true
continuationToResume = continuation
continuation = nil
}
lock.unlock()
if let continuationToResume {
resume(continuationToResume)
}
}
}
private struct PreviousDistributionState: Sendable {
struct Bin: Sendable {
let bucketStart: Date
let bucketEnd: Date
let anchorData: Data?
var anchor: HKQueryAnchor? {
guard let anchorData else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: anchorData)
}
}
let typeIdentifier: String
let count: Int
let contentHash: String
let earliestRecordDate: Date?
let latestRecordDate: Date?
let recordArchiveData: Data?
let yearlyCounts: [Int: Int]
let bins: [Bin]
var globalAnchor: HKQueryAnchor? {
guard bins.count == 1 else { return nil }
return bins[0].anchor
}
var canApplyDeltaChanges: Bool {
count == 0 || !contentHash.isEmpty
}
init(typeCount: TypeCount?, archiveState: HealthArchiveTypeCaptureState?) {
let typeCountBins = typeCount?.distributionBins ?? []
self.typeIdentifier = typeCount?.typeIdentifier ?? archiveState?.sampleTypeIdentifier ?? ""
self.count = typeCount?.count ?? archiveState?.count ?? 0
self.contentHash = typeCount?.contentHash ?? archiveState?.contentHash ?? ""
self.earliestRecordDate = typeCount?.earliestDate ?? archiveState?.earliestDate
self.latestRecordDate = typeCount?.latestDate ?? archiveState?.latestDate
self.recordArchiveData = typeCount?.recordArchiveData
let typeCountYearlyCounts = Dictionary(
uniqueKeysWithValues: (typeCount?.yearlyCounts ?? []).map { ($0.year, $0.count) }
)
self.yearlyCounts = typeCountYearlyCounts.isEmpty
? archiveState?.yearlyCounts ?? [:]
: typeCountYearlyCounts
if typeCountBins.count == 1, typeCountBins[0].anchorData != nil {
self.bins = typeCountBins.map {
Bin(
bucketStart: $0.bucketStart,
bucketEnd: $0.bucketEnd,
anchorData: $0.anchorData
)
}
} else if let archiveState, let anchorData = archiveState.anchorData {
let bucketStart = archiveState.earliestDate ?? Date()
let rawBucketEnd = archiveState.latestDate ?? bucketStart
self.bins = [
Bin(
bucketStart: bucketStart,
bucketEnd: rawBucketEnd > bucketStart ? rawBucketEnd : bucketStart.addingTimeInterval(1),
anchorData: anchorData
)
]
} else {
self.bins = []
}
}
func unchangedDistribution(
updatedAnchor: HKQueryAnchor?,
typeIdentifier fallbackTypeIdentifier: String,
earliestDate: Date?,
latestDate: Date?
) -> SampleDistribution? {
guard count >= 0,
count == 0 || !contentHash.isEmpty else {
return nil
}
let binStart = earliestDate ?? earliestRecordDate ?? Date()
let rawBinEnd = latestDate ?? latestRecordDate ?? binStart
let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1)
let effectiveTypeIdentifier = typeIdentifier.isEmpty ? fallbackTypeIdentifier : typeIdentifier
let effectiveContentHash = contentHash.isEmpty
? HashService.typeHash(typeIdentifier: effectiveTypeIdentifier, recordFingerprints: [])
: contentHash
return SampleDistribution(
totalCount: count,
bins: [
SampleDistribution.Bin(
start: binStart,
end: binEnd,
count: count,
contentHash: effectiveContentHash,
anchorData: updatedAnchor.flatMap(Self.archiveAnchor(_:))
)
],
records: [],
contentHash: effectiveContentHash,
yearlyCounts: yearlyCounts,
recordArchiveData: recordArchiveData,
captureMode: .unchangedDelta,
deltaEventCount: 0,
timingBreakdown: .zero
)
}
private nonisolated static func archiveAnchor(_ anchor: HKQueryAnchor) -> Data? {
try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
}
}
private struct TypeCountFetchResult: Sendable {
struct YearlyCountData: Sendable {
let year: Int
let count: Int
let typeIdentifier: String
let isApproximate: Bool
}
struct RecordData: Sendable {
let typeIdentifier: String
let sampleUUIDHash: String
let recordFingerprint: String
let startDate: Date
let endDate: Date
let displayValue: String?
}
struct DistributionBinData: Sendable {
let bucketStart: Date
let bucketEnd: Date
let count: Int
let contentHash: String
let anchorData: Data?
}
let typeIdentifier: String
let displayName: String
let count: Int
let contentHash: String
let earliestDate: Date?
let latestDate: Date?
let quality: SnapshotQuality
let diagnosticQuality: String
let isUnsupported: Bool
let authorizationStatus: String
let apiCalls: [HealthKitAPICallResult]
let yearlyCounts: [YearlyCountData]
let distributionBins: [DistributionBinData]
let records: [RecordData]
let recordArchiveData: Data?
var captureMode: String = "unavailable"
var deltaEventCount: Int = 0
var timeoutConfiguredSeconds: TimeInterval = 0
var totalElapsedSeconds: TimeInterval = 0
var timeoutMode: String = "default"
var lastSuccessfulElapsed: TimeInterval = 0
var learnedTimeout: TimeInterval = 0
var suggestedRetryTimeout: TimeInterval = 0
var timeoutCount: Int = 0
var successCount: Int = 0
var timingBreakdown: ImportTimingBreakdown = .zero
var indicatesProtectedDataInaccessible: Bool {
apiCalls.contains(where: \.indicatesProtectedDataInaccessible)
}
mutating func applyTimeoutProfile(_ profile: LocalMetricTimeoutProfile) {
timeoutMode = profile.timeoutMode
lastSuccessfulElapsed = profile.lastSuccessfulElapsed
learnedTimeout = profile.effectiveTimeout
suggestedRetryTimeout = profile.suggestedRetryTimeout
timeoutCount = profile.timeoutCount
successCount = profile.successCount
}
@MainActor
func makeTypeCount() -> TypeCount {
let typeCount = TypeCount(
typeIdentifier: typeIdentifier,
displayName: displayName,
count: count,
quality: quality
)
typeCount.contentHash = contentHash
typeCount.earliestDate = earliestDate
typeCount.latestDate = latestDate
typeCount.isUnsupported = isUnsupported
typeCount.yearlyCounts = yearlyCounts.map { yearlyCountData in
YearlyCount(
year: yearlyCountData.year,
count: yearlyCountData.count,
typeIdentifier: yearlyCountData.typeIdentifier,
isApproximate: yearlyCountData.isApproximate
)
}
typeCount.distributionBins = distributionBins.map { binData in
var bin = TypeDistributionBin(
bucketStart: binData.bucketStart,
bucketEnd: binData.bucketEnd,
count: binData.count
)
bin.contentHash = binData.contentHash
bin.anchorData = binData.anchorData
return bin
}
if let recordArchiveData {
typeCount.recordArchiveData = recordArchiveData
} else if !records.isEmpty {
typeCount.setRecordValues(records.map { recordData in
HealthRecordValue(
typeIdentifier: recordData.typeIdentifier,
sampleUUIDHash: recordData.sampleUUIDHash,
recordFingerprint: recordData.recordFingerprint,
startDate: recordData.startDate,
endDate: recordData.endDate,
displayValue: recordData.displayValue
)
})
}
return typeCount
}
}
private extension SnapshotFetchProgress {
func updateDetails(from result: TypeCountFetchResult) {
updateDetails(
result.typeIdentifier,
quality: result.diagnosticQuality,
recordCount: result.count,
isUnsupported: result.isUnsupported,
authorizationStatus: result.authorizationStatus,
earliestDate: result.earliestDate,
latestDate: result.latestDate,
yearlyCounts: result.yearlyCounts.map {
SnapshotFetchProgress.YearlyCountProgress(
year: $0.year,
count: $0.count,
isApproximate: $0.isApproximate
)
},
apiCallDetails: result.apiCalls,
timeoutConfiguredSeconds: result.timeoutConfiguredSeconds,
totalElapsedSeconds: result.totalElapsedSeconds,
timeoutMode: result.timeoutMode,
lastSuccessfulElapsed: result.lastSuccessfulElapsed,
learnedTimeout: result.learnedTimeout,
suggestedRetryTimeout: result.suggestedRetryTimeout,
timeoutCount: result.timeoutCount,
successCount: result.successCount,
timingBreakdown: result.timingBreakdown,
captureMode: result.captureMode,
deltaEventCount: result.deltaEventCount
)
}
func updateTimeoutProfile(from profile: LocalMetricTimeoutProfile, for typeID: String) {
updateTimeoutProfile(
typeID,
timeoutMode: profile.timeoutMode,
lastSuccessfulElapsed: profile.lastSuccessfulElapsed,
learnedTimeout: profile.effectiveTimeout,
suggestedRetryTimeout: profile.suggestedRetryTimeout,
timeoutCount: profile.timeoutCount,
successCount: profile.successCount
)
}
}
struct DistributionCaptureStrategy: Equatable {
let queryPageLimit: Int
let initialWriteChunkSize: Int
let initialArchiveFlushSampleLimit: Int
let minimumWriteChunkSize: Int
let slowBatchThresholdSeconds: TimeInterval
let severeBatchThresholdSeconds: TimeInterval
func makePersistenceState() -> DistributionPagePersistenceState {
DistributionPagePersistenceState(
currentWriteChunkSize: initialWriteChunkSize,
maximumWriteChunkSize: initialWriteChunkSize,
minimumWriteChunkSize: minimumWriteChunkSize,
slowBatchThresholdSeconds: slowBatchThresholdSeconds,
severeBatchThresholdSeconds: severeBatchThresholdSeconds
)
}
}
struct DistributionPagePersistenceState: Equatable {
var currentWriteChunkSize: Int
let maximumWriteChunkSize: Int
let minimumWriteChunkSize: Int
let slowBatchThresholdSeconds: TimeInterval
let severeBatchThresholdSeconds: TimeInterval
mutating func registerBatchDuration(_ duration: TimeInterval) {
if duration >= severeBatchThresholdSeconds {
currentWriteChunkSize = max(minimumWriteChunkSize, currentWriteChunkSize / 2)
return
}
if duration >= slowBatchThresholdSeconds {
currentWriteChunkSize = max(minimumWriteChunkSize, currentWriteChunkSize - max(minimumWriteChunkSize / 2, 50))
return
}
if duration <= 0.35, currentWriteChunkSize < maximumWriteChunkSize {
currentWriteChunkSize = min(maximumWriteChunkSize, currentWriteChunkSize + max(minimumWriteChunkSize / 2, 50))
}
}
}
struct DistributionPageArchiveResult: Equatable {
let persistenceState: DistributionPagePersistenceState
let persistedSampleCount: Int
let persistedDeletedCount: Int
let insertElapsedSeconds: TimeInterval
}
private struct DistributionCaptureTimings: Sendable, Equatable {
var fetchElapsedSeconds: TimeInterval = 0
var processingElapsedSeconds: TimeInterval = 0
var processingDeltaApplyElapsedSeconds: TimeInterval = 0
var processingRecordArchiveRebuildElapsedSeconds: TimeInterval = 0
var processingInitialRecordElapsedSeconds: TimeInterval = 0
var processingRecordArchiveFinalizeElapsedSeconds: TimeInterval = 0
var insertElapsedSeconds: TimeInterval = 0
var finalizeElapsedSeconds: TimeInterval = 0
var finalizeEventCountElapsedSeconds: TimeInterval = 0
var finalizeTypeSummaryElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateBucketLookupElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateCopyElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateDeleteElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateRebuildElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateInsertElapsedSeconds: TimeInterval = 0
var finalizeDailyAggregateOtherElapsedSeconds: TimeInterval = 0
var finalizeRunUpdateElapsedSeconds: TimeInterval = 0
var finalizeOtherElapsedSeconds: TimeInterval = 0
static let zero = DistributionCaptureTimings()
mutating func addFinalization(_ breakdown: HealthArchiveFinalizationBreakdown) {
finalizeElapsedSeconds += breakdown.totalElapsedSeconds
finalizeEventCountElapsedSeconds += breakdown.eventCountElapsedSeconds
finalizeTypeSummaryElapsedSeconds += breakdown.typeSummaryElapsedSeconds
finalizeDailyAggregateElapsedSeconds += breakdown.dailyAggregateElapsedSeconds
finalizeDailyAggregateBucketLookupElapsedSeconds += breakdown.dailyAggregateBucketLookupElapsedSeconds
finalizeDailyAggregateCopyElapsedSeconds += breakdown.dailyAggregateCopyElapsedSeconds
finalizeDailyAggregateDeleteElapsedSeconds += breakdown.dailyAggregateDeleteElapsedSeconds
finalizeDailyAggregateRebuildElapsedSeconds += breakdown.dailyAggregateRebuildElapsedSeconds
finalizeDailyAggregateInsertElapsedSeconds += breakdown.dailyAggregateInsertElapsedSeconds
finalizeDailyAggregateOtherElapsedSeconds += breakdown.dailyAggregateOtherElapsedSeconds
finalizeRunUpdateElapsedSeconds += breakdown.runUpdateElapsedSeconds
finalizeOtherElapsedSeconds += breakdown.otherElapsedSeconds
}
var importBreakdown: ImportTimingBreakdown {
ImportTimingBreakdown(
fetchElapsedSeconds: fetchElapsedSeconds,
processingElapsedSeconds: processingElapsedSeconds,
processingDeltaApplyElapsedSeconds: processingDeltaApplyElapsedSeconds,
processingRecordArchiveRebuildElapsedSeconds: processingRecordArchiveRebuildElapsedSeconds,
processingInitialRecordElapsedSeconds: processingInitialRecordElapsedSeconds,
processingRecordArchiveFinalizeElapsedSeconds: processingRecordArchiveFinalizeElapsedSeconds,
insertElapsedSeconds: insertElapsedSeconds,
finalizeElapsedSeconds: finalizeElapsedSeconds,
finalizeEventCountElapsedSeconds: finalizeEventCountElapsedSeconds,
finalizeTypeSummaryElapsedSeconds: finalizeTypeSummaryElapsedSeconds,
finalizeDailyAggregateElapsedSeconds: finalizeDailyAggregateElapsedSeconds,
finalizeDailyAggregateBucketLookupElapsedSeconds: finalizeDailyAggregateBucketLookupElapsedSeconds,
finalizeDailyAggregateCopyElapsedSeconds: finalizeDailyAggregateCopyElapsedSeconds,
finalizeDailyAggregateDeleteElapsedSeconds: finalizeDailyAggregateDeleteElapsedSeconds,
finalizeDailyAggregateRebuildElapsedSeconds: finalizeDailyAggregateRebuildElapsedSeconds,
finalizeDailyAggregateInsertElapsedSeconds: finalizeDailyAggregateInsertElapsedSeconds,
finalizeDailyAggregateOtherElapsedSeconds: finalizeDailyAggregateOtherElapsedSeconds,
finalizeRunUpdateElapsedSeconds: finalizeRunUpdateElapsedSeconds,
finalizeOtherElapsedSeconds: finalizeOtherElapsedSeconds
)
}
}
enum DistributionCaptureConfiguration {
static let pageTimeoutSeconds: TimeInterval = 60
static let incrementalStrategy = DistributionCaptureStrategy(
queryPageLimit: 10_000,
initialWriteChunkSize: 2_000,
initialArchiveFlushSampleLimit: 2_000,
minimumWriteChunkSize: 500,
slowBatchThresholdSeconds: 2.5,
severeBatchThresholdSeconds: 6
)
private static let veryHighVolumeTypeIdentifiers: Set<String> = [
HKQuantityTypeIdentifier.heartRate.rawValue,
]
private static let highVolumeTypeIdentifiers: Set<String> = [
HKQuantityTypeIdentifier.stepCount.rawValue,
HKQuantityTypeIdentifier.distanceWalkingRunning.rawValue,
HKQuantityTypeIdentifier.activeEnergyBurned.rawValue,
HKQuantityTypeIdentifier.appleExerciseTime.rawValue,
HKCategoryTypeIdentifier.sleepAnalysis.rawValue,
HKQuantityTypeIdentifier.headphoneAudioExposure.rawValue,
HKQuantityTypeIdentifier.environmentalAudioExposure.rawValue
]
static func initialImportStrategy(for typeIdentifier: String) -> DistributionCaptureStrategy {
if veryHighVolumeTypeIdentifiers.contains(typeIdentifier) {
return DistributionCaptureStrategy(
queryPageLimit: 2_000,
initialWriteChunkSize: 5_000,
initialArchiveFlushSampleLimit: 10_000,
minimumWriteChunkSize: 500,
slowBatchThresholdSeconds: 2.5,
severeBatchThresholdSeconds: 6
)
}
if highVolumeTypeIdentifiers.contains(typeIdentifier) {
return DistributionCaptureStrategy(
queryPageLimit: 5_000,
initialWriteChunkSize: 2_500,
initialArchiveFlushSampleLimit: 10_000,
minimumWriteChunkSize: 500,
slowBatchThresholdSeconds: 1.75,
severeBatchThresholdSeconds: 4.5
)
}
return DistributionCaptureStrategy(
queryPageLimit: 20_000,
initialWriteChunkSize: 5_000,
initialArchiveFlushSampleLimit: 5_000,
minimumWriteChunkSize: 500,
slowBatchThresholdSeconds: 2.5,
severeBatchThresholdSeconds: 6
)
}
static let deleteBatchSize = 100
}