HealthProbe / HealthProbeTests / SQLiteLargeImportPerformanceTests.swift
1 contributor
202 lines | 8.034kb
import HealthKit
import SQLite3
import XCTest
@testable import HealthProbe

final class SQLiteLargeImportPerformanceTests: XCTestCase {
    private final class AsyncResultBox<T>: @unchecked Sendable {
        var result: Result<T, Error>?
    }

    private var temporaryDirectory: URL!

    override func setUpWithError() throws {
        temporaryDirectory = FileManager.default.temporaryDirectory
            .appending(path: "HealthProbeLargeImportTests-\(UUID().uuidString)", directoryHint: .isDirectory)
        try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true)
    }

    override func tearDownWithError() throws {
        if let temporaryDirectory {
            try? FileManager.default.removeItem(at: temporaryDirectory)
        }
        temporaryDirectory = nil
    }

    func testLargeSyntheticFullImportSmoke() throws {
        let definitions = syntheticImportDefinitions()
        let samples = makeSyntheticImportSamples(totalCount: 5_000, definitions: definitions)
        let url = databaseURL(named: "LargeImportSmoke-\(UUID().uuidString).sqlite")

        try runSyntheticImport(
            samples: samples,
            definitions: definitions,
            databaseURL: url
        )
    }

    func testLargeSyntheticFullImportBenchmark() throws {
        let environment = ProcessInfo.processInfo.environment
        let defaults = UserDefaults.standard
        let isEnabled = environment["HP_ENABLE_LARGE_IMPORT_BENCHMARK"] == "1"
            || defaults.bool(forKey: "HP_ENABLE_LARGE_IMPORT_BENCHMARK")

        guard isEnabled else {
            throw XCTSkip("""
            Large import benchmark is opt-in. Run with environment variable \
            HP_ENABLE_LARGE_IMPORT_BENCHMARK=1 or launch argument \
            -HP_ENABLE_LARGE_IMPORT_BENCHMARK YES, plus optional \
            HP_LARGE_IMPORT_SAMPLE_COUNT / HP_LARGE_IMPORT_ITERATIONS overrides.
            """)
        }

        let sampleCount = max(
            1_000,
            Int(environment["HP_LARGE_IMPORT_SAMPLE_COUNT"] ?? "")
                ?? defaults.integer(forKey: "HP_LARGE_IMPORT_SAMPLE_COUNT").nonZero
                ?? 250_000
        )
        let iterationCount = max(
            1,
            Int(environment["HP_LARGE_IMPORT_ITERATIONS"] ?? "")
                ?? defaults.integer(forKey: "HP_LARGE_IMPORT_ITERATIONS").nonZero
                ?? 1
        )
        let definitions = syntheticImportDefinitions()
        let samples = makeSyntheticImportSamples(totalCount: sampleCount, definitions: definitions)
        let options = XCTMeasureOptions()
        options.iterationCount = iterationCount

        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
            let url = databaseURL(named: "LargeImport-\(UUID().uuidString).sqlite")
            do {
                try runSyntheticImport(
                    samples: samples,
                    definitions: definitions,
                    databaseURL: url
                )
            } catch {
                XCTFail("Large synthetic import benchmark failed: \(error)")
            }
        }
    }

    private func databaseURL(named name: String) -> URL {
        temporaryDirectory.appending(path: name)
    }

    private func syntheticImportDefinitions() -> [(sampleType: HKQuantityType, unit: HKUnit, baseline: Double)] {
        [
            (HKQuantityType.quantityType(forIdentifier: .heartRate)!, HKUnit.count().unitDivided(by: .minute()), 55),
            (HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!, .kilocalorie(), 1.5),
            (HKQuantityType.quantityType(forIdentifier: .basalEnergyBurned)!, .kilocalorie(), 1.1),
            (HKQuantityType.quantityType(forIdentifier: .stepCount)!, .count(), 12),
            (HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!, .meter(), 8)
        ]
    }

    private func makeSyntheticImportSamples(
        totalCount: Int,
        definitions: [(sampleType: HKQuantityType, unit: HKUnit, baseline: Double)]
    ) -> [HKQuantitySample] {
        return (0..<totalCount).map { index in
            let definition = definitions[index % definitions.count]
            let value = definition.baseline + Double(index % 17)
            let start = Date(timeIntervalSince1970: 10_000 + Double(index * 60))
            let end = start.addingTimeInterval(definition.sampleType.identifier == HKQuantityTypeIdentifier.heartRate.rawValue ? 5 : 60)
            let quantity = HKQuantity(unit: definition.unit, doubleValue: value)
            return HKQuantitySample(type: definition.sampleType, quantity: quantity, start: start, end: end)
        }
    }

    private func runSyntheticImport(
        samples: [HKQuantitySample],
        definitions: [(sampleType: HKQuantityType, unit: HKUnit, baseline: Double)],
        databaseURL url: URL
    ) throws {
        let store = SQLiteHealthArchiveStore(databaseURL: url)
        let observedAt = Date(timeIntervalSince1970: 5_000_000)

        defer {
            try? FileManager.default.removeItem(at: url)
            try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + "-shm"))
            try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + "-wal"))
        }

        try waitForArchiveOperation {
            let observationID = try await store.beginObservation(
                observedAt: observedAt,
                triggerReason: "benchmarkLargeSyntheticImport",
                selectedTypeSetHash: "synthetic-large-import"
            )
            _ = try await store.upsertSamples(
                samples,
                observedAt: observedAt,
                observationID: observationID
            )
            for definition in definitions {
                _ = try await store.markVerification(
                    sampleType: definition.sampleType,
                    verifiedAt: observedAt,
                    observationID: observationID
                )
            }
            try await store.finishObservation(
                observationID: observationID,
                status: "completed",
                endedAt: observedAt.addingTimeInterval(1)
            )
            return ()
        }

        XCTAssertEqual(try countRows(in: "samples", at: url), samples.count)
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), samples.count)
        XCTAssertEqual(try countRows(in: "observation_type_runs", at: url), definitions.count)
    }

    private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
        let expectation = expectation(description: "archive operation")
        let box = AsyncResultBox<T>()

        Task {
            do {
                box.result = .success(try await operation())
            } catch {
                box.result = .failure(error)
            }
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 120)
        return try XCTUnwrap(box.result).get()
    }

    private func countRows(in tableName: String, at url: URL) throws -> Int {
        var db: OpaquePointer?
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
            sqlite3_close(db)
            XCTFail("Could not open benchmark database")
            return 0
        }
        defer { sqlite3_close(db) }

        var statement: OpaquePointer?
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
            sqlite3_finalize(statement)
            XCTFail("Could not prepare benchmark count query")
            return 0
        }
        defer { sqlite3_finalize(statement) }

        guard sqlite3_step(statement) == SQLITE_ROW else {
            return 0
        }
        return Int(sqlite3_column_int(statement, 0))
    }
}

private extension Int {
    var nonZero: Int? {
        self == 0 ? nil : self
    }
}