HealthProbe / HealthProbe / Utilities / SnapshotFetchProgress.swift
a6d7479 15 hours ago History
1 contributor
365 lines | 14.579kb
import Foundation
import HealthKit

struct ImportTimingBreakdown: 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 = ImportTimingBreakdown()

    var accountedElapsedSeconds: TimeInterval {
        fetchElapsedSeconds + processingElapsedSeconds + insertElapsedSeconds + finalizeElapsedSeconds
    }

    var processingAccountedElapsedSeconds: TimeInterval {
        processingDeltaApplyElapsedSeconds
        + processingRecordArchiveRebuildElapsedSeconds
        + processingInitialRecordElapsedSeconds
        + processingRecordArchiveFinalizeElapsedSeconds
    }

    var processingOtherElapsedSeconds: TimeInterval {
        max(0, processingElapsedSeconds - processingAccountedElapsedSeconds)
    }

    var finalizeAccountedElapsedSeconds: TimeInterval {
        finalizeEventCountElapsedSeconds
        + finalizeTypeSummaryElapsedSeconds
        + finalizeDailyAggregateElapsedSeconds
        + finalizeRunUpdateElapsedSeconds
        + finalizeOtherElapsedSeconds
    }

    var finalizeDailyAggregateAccountedElapsedSeconds: TimeInterval {
        finalizeDailyAggregateBucketLookupElapsedSeconds
        + finalizeDailyAggregateCopyElapsedSeconds
        + finalizeDailyAggregateDeleteElapsedSeconds
        + finalizeDailyAggregateRebuildElapsedSeconds
        + finalizeDailyAggregateInsertElapsedSeconds
        + finalizeDailyAggregateOtherElapsedSeconds
    }
}

struct HealthKitAPICallResult: Sendable, Equatable {
    enum Status: String, Sendable {
        case complete
        case failed
        case timeout
        case unknown
        case unauthorized
        case unsupported
    }

    let queryType: String
    let status: Status
    let elapsedSeconds: TimeInterval
    let resultValue: String?
    let errorCode: String?
    let errorDomain: String?
    let errorDescription: String?
    let failureKind: String?
    let cancellationReason: String?

    init(
        queryType: String,
        status: Status,
        elapsedSeconds: TimeInterval,
        resultValue: String?,
        errorCode: String? = nil,
        errorDomain: String? = nil,
        errorDescription: String? = nil,
        failureKind: String? = nil,
        cancellationReason: String? = nil
    ) {
        self.queryType = queryType
        self.status = status
        self.elapsedSeconds = elapsedSeconds
        self.resultValue = resultValue
        self.errorCode = errorCode
        self.errorDomain = errorDomain
        self.errorDescription = errorDescription
        self.failureKind = failureKind
        self.cancellationReason = cancellationReason
    }

    var statusDescription: String {
        switch status {
        case .complete: return "complete"
        case .failed: return "failed"
        case .timeout: return "timeout"
        case .unknown: return "unknown"
        case .unauthorized: return "unauthorized"
        case .unsupported: return "unsupported"
        }
    }

    var indicatesProtectedDataInaccessible: Bool {
        errorDomain == HKError.errorDomain
            && errorCode == "\(HKError.Code.errorDatabaseInaccessible.rawValue)"
    }
}

@Observable
final class SnapshotFetchProgress {
    struct YearlyCountProgress: Identifiable, Sendable, Equatable {
        var id: Int { year }
        let year: Int
        let count: Int
        let isApproximate: Bool
    }

    struct TypeProgress: Identifiable, Sendable, Equatable {
        enum FetchStatus: Sendable, Equatable {
            case pending
            case fetching
            case complete
            case failed(String)

            var icon: String {
                switch self {
                case .pending: return "circle"
                case .fetching: return "arrow.triangle.2.circlepath"
                case .complete: return "checkmark.circle.fill"
                case .failed: return "xmark.circle.fill"
                }
            }
        }

        let id: String
        let displayName: String
        var status: FetchStatus = .pending
        var quality: String = SnapshotQuality.loading.rawValue
        var recordCount: Int = 0
        var isUnsupported: Bool = false
        var authorizationStatus: String = "unknown"
        var earliestDate: Date?
        var latestDate: Date?
        var yearlyCounts: [YearlyCountProgress] = []
        var apiCallDetails: [HealthKitAPICallResult] = []
        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 captureMode: String = "unavailable"
        var deltaEventCount: Int = 0
        var blockProgress: String = ""
        var blockElapsedSeconds: TimeInterval = 0
        var blockSamplesPerSecond: Double = 0
    }

    let totalTypeCount: Int
    var types: [TypeProgress] = []
    var perTypeTimeoutSeconds: TimeInterval = 0
    var maxConcurrentTypeFetches: Int = 0
    var adaptiveTimeoutsEnabled: Bool = false
    var previousSnapshotID: UUID?
    var isChainStart: Bool?
    var snapshotChecksum: String = ""
    var monitoredTypeSetHash: String = ""
    var monitoredRegistryVersion: Int?
    private let displayNamesByID: [String: String]

    var visibleTypes: [TypeProgress] { types }
    var completedCount: Int { types.filter { $0.status == .complete }.count }
    var failedCount: Int {
        types.filter {
            if case .failed = $0.status { return true }
            return false
        }.count
    }
    var totalRecords: Int {
        types.reduce(0) { $0 + max($1.recordCount, 0) }
    }
    var aggregateTimingBreakdown: ImportTimingBreakdown {
        types.reduce(.zero) { partial, type in
            var combined = partial
            combined.fetchElapsedSeconds += type.timingBreakdown.fetchElapsedSeconds
            combined.processingElapsedSeconds += type.timingBreakdown.processingElapsedSeconds
            combined.processingDeltaApplyElapsedSeconds += type.timingBreakdown.processingDeltaApplyElapsedSeconds
            combined.processingRecordArchiveRebuildElapsedSeconds += type.timingBreakdown.processingRecordArchiveRebuildElapsedSeconds
            combined.processingInitialRecordElapsedSeconds += type.timingBreakdown.processingInitialRecordElapsedSeconds
            combined.processingRecordArchiveFinalizeElapsedSeconds += type.timingBreakdown.processingRecordArchiveFinalizeElapsedSeconds
            combined.insertElapsedSeconds += type.timingBreakdown.insertElapsedSeconds
            combined.finalizeElapsedSeconds += type.timingBreakdown.finalizeElapsedSeconds
            combined.finalizeEventCountElapsedSeconds += type.timingBreakdown.finalizeEventCountElapsedSeconds
            combined.finalizeTypeSummaryElapsedSeconds += type.timingBreakdown.finalizeTypeSummaryElapsedSeconds
            combined.finalizeDailyAggregateElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateElapsedSeconds
            combined.finalizeDailyAggregateBucketLookupElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateBucketLookupElapsedSeconds
            combined.finalizeDailyAggregateCopyElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateCopyElapsedSeconds
            combined.finalizeDailyAggregateDeleteElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateDeleteElapsedSeconds
            combined.finalizeDailyAggregateRebuildElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateRebuildElapsedSeconds
            combined.finalizeDailyAggregateInsertElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateInsertElapsedSeconds
            combined.finalizeDailyAggregateOtherElapsedSeconds += type.timingBreakdown.finalizeDailyAggregateOtherElapsedSeconds
            combined.finalizeRunUpdateElapsedSeconds += type.timingBreakdown.finalizeRunUpdateElapsedSeconds
            combined.finalizeOtherElapsedSeconds += type.timingBreakdown.finalizeOtherElapsedSeconds
            return combined
        }
    }

    init(monitoredTypes: [(id: String, displayName: String)]) {
        self.totalTypeCount = monitoredTypes.count
        self.displayNamesByID = Dictionary(uniqueKeysWithValues: monitoredTypes.map { ($0.id, $0.displayName) })
    }

    func updateConfiguration(
        perTypeTimeoutSeconds: TimeInterval,
        maxConcurrentTypeFetches: Int,
        adaptiveTimeoutsEnabled: Bool
    ) {
        self.perTypeTimeoutSeconds = perTypeTimeoutSeconds
        self.maxConcurrentTypeFetches = maxConcurrentTypeFetches
        self.adaptiveTimeoutsEnabled = adaptiveTimeoutsEnabled
    }

    func updateChainContext(
        previousSnapshotID: UUID?,
        isChainStart: Bool,
        snapshotChecksum: String,
        monitoredTypeSetHash: String,
        monitoredRegistryVersion: Int
    ) {
        self.previousSnapshotID = previousSnapshotID
        self.isChainStart = isChainStart
        self.snapshotChecksum = snapshotChecksum
        self.monitoredTypeSetHash = monitoredTypeSetHash
        self.monitoredRegistryVersion = monitoredRegistryVersion
    }

    func updateStatus(_ id: String, status: TypeProgress.FetchStatus, recordCount: Int? = nil) {
        let index = visibleTypeIndex(for: id)
        types[index].status = status
        switch status {
        case .complete, .failed, .pending:
            types[index].blockProgress = ""
        case .fetching:
            break
        }
        if let recordCount {
            types[index].recordCount = recordCount
        }
    }

    func updateDetails(
        _ id: String,
        quality: String,
        recordCount: Int,
        isUnsupported: Bool,
        authorizationStatus: String,
        earliestDate: Date?,
        latestDate: Date?,
        yearlyCounts: [YearlyCountProgress],
        apiCallDetails: [HealthKitAPICallResult],
        timeoutConfiguredSeconds: TimeInterval,
        totalElapsedSeconds: TimeInterval,
        timeoutMode: String,
        lastSuccessfulElapsed: TimeInterval,
        learnedTimeout: TimeInterval,
        suggestedRetryTimeout: TimeInterval,
        timeoutCount: Int,
        successCount: Int,
        timingBreakdown: ImportTimingBreakdown,
        captureMode: String = "unavailable",
        deltaEventCount: Int = 0
    ) {
        let index = visibleTypeIndex(for: id)
        types[index].quality = quality
        types[index].recordCount = recordCount
        types[index].isUnsupported = isUnsupported
        types[index].authorizationStatus = authorizationStatus
        types[index].earliestDate = earliestDate
        types[index].latestDate = latestDate
        types[index].yearlyCounts = yearlyCounts
        types[index].apiCallDetails = apiCallDetails
        types[index].timeoutConfiguredSeconds = timeoutConfiguredSeconds
        types[index].totalElapsedSeconds = totalElapsedSeconds
        types[index].timeoutMode = timeoutMode
        types[index].lastSuccessfulElapsed = lastSuccessfulElapsed
        types[index].learnedTimeout = learnedTimeout
        types[index].suggestedRetryTimeout = suggestedRetryTimeout
        types[index].timeoutCount = timeoutCount
        types[index].successCount = successCount
        types[index].timingBreakdown = timingBreakdown
        types[index].captureMode = captureMode
        types[index].deltaEventCount = deltaEventCount
    }

    func updateTimeoutProfile(
        _ id: String,
        timeoutMode: String,
        lastSuccessfulElapsed: TimeInterval,
        learnedTimeout: TimeInterval,
        suggestedRetryTimeout: TimeInterval,
        timeoutCount: Int,
        successCount: Int
    ) {
        let index = visibleTypeIndex(for: id)
        types[index].timeoutMode = timeoutMode
        types[index].lastSuccessfulElapsed = lastSuccessfulElapsed
        types[index].learnedTimeout = learnedTimeout
        types[index].suggestedRetryTimeout = suggestedRetryTimeout
        types[index].timeoutCount = timeoutCount
        types[index].successCount = successCount
    }

    func updateBlockProgress(
        _ id: String,
        detail: String,
        recordCount: Int? = nil,
        elapsedSeconds: TimeInterval? = nil,
        samplesPerSecond: Double? = nil
    ) {
        let index = visibleTypeIndex(for: id)
        types[index].blockProgress = detail
        if let recordCount {
            types[index].recordCount = recordCount
        }
        if let elapsedSeconds {
            types[index].blockElapsedSeconds = elapsedSeconds
        }
        if let samplesPerSecond {
            types[index].blockSamplesPerSecond = samplesPerSecond
        }
    }

    func markUnavailable(_ id: String) {
        let index = visibleTypeIndex(for: id)
        types[index].status = .failed("Not authorized")
        types[index].quality = SnapshotQuality.unauthorized.rawValue
        types[index].recordCount = -1
        types[index].authorizationStatus = "unavailable"
    }

    private func visibleTypeIndex(for id: String) -> Int {
        if let index = types.firstIndex(where: { $0.id == id }) {
            return index
        }

        types.insert(
            TypeProgress(id: id, displayName: displayNamesByID[id] ?? id),
            at: 0
        )
        return 0
    }
}