HealthProbe / HealthProbeTests / SQLiteLargeImportPerformanceTests.swift
Newer Older
202 lines | 8.034kb
Bogdan Timofte authored a day ago
1
import HealthKit
2
import SQLite3
3
import XCTest
4
@testable import HealthProbe
5

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

            
11
    private var temporaryDirectory: URL!
12

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

            
19
    override func tearDownWithError() throws {
20
        if let temporaryDirectory {
21
            try? FileManager.default.removeItem(at: temporaryDirectory)
22
        }
23
        temporaryDirectory = nil
24
    }
25

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

            
31
        try runSyntheticImport(
32
            samples: samples,
33
            definitions: definitions,
34
            databaseURL: url
35
        )
36
    }
37

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

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

            
53
        let sampleCount = max(
54
            1_000,
55
            Int(environment["HP_LARGE_IMPORT_SAMPLE_COUNT"] ?? "")
56
                ?? defaults.integer(forKey: "HP_LARGE_IMPORT_SAMPLE_COUNT").nonZero
57
                ?? 250_000
58
        )
59
        let iterationCount = max(
60
            1,
61
            Int(environment["HP_LARGE_IMPORT_ITERATIONS"] ?? "")
62
                ?? defaults.integer(forKey: "HP_LARGE_IMPORT_ITERATIONS").nonZero
63
                ?? 1
64
        )
65
        let definitions = syntheticImportDefinitions()
66
        let samples = makeSyntheticImportSamples(totalCount: sampleCount, definitions: definitions)
67
        let options = XCTMeasureOptions()
68
        options.iterationCount = iterationCount
69

            
70
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
71
            let url = databaseURL(named: "LargeImport-\(UUID().uuidString).sqlite")
72
            do {
73
                try runSyntheticImport(
74
                    samples: samples,
75
                    definitions: definitions,
76
                    databaseURL: url
77
                )
78
            } catch {
79
                XCTFail("Large synthetic import benchmark failed: \(error)")
80
            }
81
        }
82
    }
83

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

            
88
    private func syntheticImportDefinitions() -> [(sampleType: HKQuantityType, unit: HKUnit, baseline: Double)] {
89
        [
90
            (HKQuantityType.quantityType(forIdentifier: .heartRate)!, HKUnit.count().unitDivided(by: .minute()), 55),
91
            (HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!, .kilocalorie(), 1.5),
92
            (HKQuantityType.quantityType(forIdentifier: .basalEnergyBurned)!, .kilocalorie(), 1.1),
93
            (HKQuantityType.quantityType(forIdentifier: .stepCount)!, .count(), 12),
94
            (HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!, .meter(), 8)
95
        ]
96
    }
97

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

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

            
120
        defer {
121
            try? FileManager.default.removeItem(at: url)
122
            try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + "-shm"))
123
            try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + "-wal"))
124
        }
125

            
126
        try waitForArchiveOperation {
127
            let observationID = try await store.beginObservation(
128
                observedAt: observedAt,
129
                triggerReason: "benchmarkLargeSyntheticImport",
130
                selectedTypeSetHash: "synthetic-large-import"
131
            )
132
            _ = try await store.upsertSamples(
133
                samples,
134
                observedAt: observedAt,
135
                observationID: observationID
136
            )
137
            for definition in definitions {
138
                _ = try await store.markVerification(
139
                    sampleType: definition.sampleType,
140
                    verifiedAt: observedAt,
141
                    observationID: observationID
142
                )
143
            }
144
            try await store.finishObservation(
145
                observationID: observationID,
146
                status: "completed",
147
                endedAt: observedAt.addingTimeInterval(1)
148
            )
149
            return ()
150
        }
151

            
152
        XCTAssertEqual(try countRows(in: "samples", at: url), samples.count)
153
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), samples.count)
154
        XCTAssertEqual(try countRows(in: "observation_type_runs", at: url), definitions.count)
155
    }
156

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

            
161
        Task {
162
            do {
163
                box.result = .success(try await operation())
164
            } catch {
165
                box.result = .failure(error)
166
            }
167
            expectation.fulfill()
168
        }
169

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

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

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

            
191
        guard sqlite3_step(statement) == SQLITE_ROW else {
192
            return 0
193
        }
194
        return Int(sqlite3_column_int(statement, 0))
195
    }
196
}
197

            
198
private extension Int {
199
    var nonZero: Int? {
200
        self == 0 ? nil : self
201
    }
202
}