@@ -25,7 +25,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 25 | 25 |
|------|----------------|--------------------| |
| 26 | 26 |
| Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index | |
| 27 | 27 |
| HealthKit capture | Prototype exists | Adapt capture to write differential SQLite observations first | |
| 28 |
-| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, and XCTest coverage are in place; legacy write mirror still exists | Add formal timing/memory metrics, then retire `archive_samples` | |
|
| 28 |
+| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; legacy write mirror still exists | Investigate and retire `archive_samples`, then start Core Data cache work | |
|
| 29 | 29 |
| Core Data cache | Not implemented | Add rebuildable cache for expensive counts, summaries, report metadata, UI state | |
| 30 | 30 |
| SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition | |
| 31 | 31 |
| UI | Prototype exists | Reframe screens around observations, diffs, export, archive status | |
@@ -38,13 +38,12 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 38 | 38 |
|
| 39 | 39 |
Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.md). |
| 40 | 40 |
|
| 41 |
-1. Add formal timing/memory metrics for synthetic SQLite queries. |
|
| 42 |
-2. Retire the legacy `archive_samples` write mirror. |
|
| 43 |
-3. Add Core Data UI/report cache and rebuild pipeline. |
|
| 44 |
-4. Replace SwiftData UI dependencies with Core Data/cache DTOs. |
|
| 45 |
-5. Update UI language from anomaly/status to observation/diff/export. |
|
| 46 |
-6. Add streaming exports with manifests. |
|
| 47 |
-7. Validate on low-memory/legacy-class devices. |
|
| 41 |
+1. Investigate why removing the legacy `archive_samples` write mirror changes v2 idempotency in tests, then retire it safely. |
|
| 42 |
+2. Add Core Data UI/report cache and rebuild pipeline. |
|
| 43 |
+3. Replace SwiftData UI dependencies with Core Data/cache DTOs. |
|
| 44 |
+4. Update UI language from anomaly/status to observation/diff/export. |
|
| 45 |
+5. Add streaming exports with manifests. |
|
| 46 |
+6. Validate on low-memory/legacy-class devices. |
|
| 48 | 47 |
|
| 49 | 48 |
## Known Prototype Mismatches |
| 50 | 49 |
|
@@ -54,7 +53,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 54 | 53 |
- Current archive schema is not sufficient as the long-term source of truth. |
| 55 | 54 |
- Existing implementation may decode or cache too much data for low-end devices. |
| 56 | 55 |
- Old prototype database compatibility is no longer required. |
| 57 |
-- Initial SQLite archive tests cover open/init/reset/idempotency, small observation diffs, large synthetic diff pagination, materialized aggregate comparison, source/provenance breakdowns, and consolidation-evidence labels, but not yet export behavior or formal timing/memory metrics. |
|
| 56 |
+- Initial SQLite archive tests cover open/init/reset/idempotency, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, and consolidation-evidence labels, but not yet export behavior. |
|
| 58 | 57 |
|
| 59 | 58 |
## Verification Checklist |
| 60 | 59 |
|
@@ -153,7 +153,7 @@ Checklist: |
||
| 153 | 153 |
- [x] Implement consolidation-likely evidence query. |
| 154 | 154 |
- [x] Implement source/provenance breakdown query. |
| 155 | 155 |
- [x] Add large synthetic diff/pagination regression. |
| 156 |
-- [ ] Add formal query timing/memory metrics on synthetic large datasets. |
|
| 156 |
+- [x] Add formal query timing/memory metrics on synthetic large datasets. |
|
| 157 | 157 |
|
| 158 | 158 |
Acceptance: |
| 159 | 159 |
- [x] Observation T can be reconstructed from ranges/events. |
@@ -4,6 +4,10 @@ import XCTest |
||
| 4 | 4 |
@testable import HealthProbe |
| 5 | 5 |
|
| 6 | 6 |
final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
| 7 |
+ private final class AsyncResultBox<T>: @unchecked Sendable {
|
|
| 8 |
+ var result: Result<T, Error>? |
|
| 9 |
+ } |
|
| 10 |
+ |
|
| 7 | 11 |
private var temporaryDirectory: URL! |
| 8 | 12 |
|
| 9 | 13 |
override func setUpWithError() throws {
|
@@ -190,6 +194,61 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 190 | 194 |
XCTAssertLessThan(queryElapsedSeconds, 10) |
| 191 | 195 |
} |
| 192 | 196 |
|
| 197 |
+ func testLargeSyntheticDiffQueryMetrics() throws {
|
|
| 198 |
+ let url = databaseURL() |
|
| 199 |
+ let store = SQLiteHealthArchiveStore(databaseURL: url) |
|
| 200 |
+ let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue |
|
| 201 |
+ let initialCount = 900 |
|
| 202 |
+ let appearedCount = 120 |
|
| 203 |
+ let disappearedCount = 100 |
|
| 204 |
+ let pageSize = 25 |
|
| 205 |
+ let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0) |
|
| 206 |
+ let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount) |
|
| 207 |
+ |
|
| 208 |
+ try waitForArchiveOperation {
|
|
| 209 |
+ _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_200_000)) |
|
| 210 |
+ _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_200_060)) |
|
| 211 |
+ for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
|
|
| 212 |
+ try await store.recordDisappearance( |
|
| 213 |
+ sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString), |
|
| 214 |
+ sampleTypeIdentifier: typeIdentifier, |
|
| 215 |
+ observedMissingAt: Date(timeIntervalSince1970: 1_200_120 + Double(offset)) |
|
| 216 |
+ ) |
|
| 217 |
+ } |
|
| 218 |
+ } |
|
| 219 |
+ |
|
| 220 |
+ let observationIDs = try observationIDs(at: url) |
|
| 221 |
+ let firstObservationID = try XCTUnwrap(observationIDs.first) |
|
| 222 |
+ let lastObservationID = try XCTUnwrap(observationIDs.last) |
|
| 223 |
+ let options = XCTMeasureOptions() |
|
| 224 |
+ options.iterationCount = 3 |
|
| 225 |
+ |
|
| 226 |
+ measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
|
|
| 227 |
+ do {
|
|
| 228 |
+ let result = try waitForArchiveOperation {
|
|
| 229 |
+ let summary = try await store.diffSummary(HealthArchiveDiffRequest( |
|
| 230 |
+ fromObservationID: firstObservationID, |
|
| 231 |
+ toObservationID: lastObservationID, |
|
| 232 |
+ sampleTypeIdentifier: typeIdentifier |
|
| 233 |
+ )) |
|
| 234 |
+ let records = try await store.diffRecords(HealthArchiveDiffRecordRequest( |
|
| 235 |
+ fromObservationID: firstObservationID, |
|
| 236 |
+ toObservationID: lastObservationID, |
|
| 237 |
+ sampleTypeIdentifier: typeIdentifier, |
|
| 238 |
+ kind: .appeared, |
|
| 239 |
+ limit: pageSize |
|
| 240 |
+ )) |
|
| 241 |
+ return (summary, records) |
|
| 242 |
+ } |
|
| 243 |
+ XCTAssertEqual(result.0.appearedCount, appearedCount) |
|
| 244 |
+ XCTAssertEqual(result.0.disappearedCount, disappearedCount) |
|
| 245 |
+ XCTAssertEqual(result.1.count, pageSize) |
|
| 246 |
+ } catch {
|
|
| 247 |
+ XCTFail("Measured archive query failed: \(error)")
|
|
| 248 |
+ } |
|
| 249 |
+ } |
|
| 250 |
+ } |
|
| 251 |
+ |
|
| 193 | 252 |
func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
|
| 194 | 253 |
let url = databaseURL() |
| 195 | 254 |
let store = SQLiteHealthArchiveStore(databaseURL: url) |
@@ -344,6 +403,23 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 344 | 403 |
} |
| 345 | 404 |
} |
| 346 | 405 |
|
| 406 |
+ private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
|
|
| 407 |
+ let expectation = expectation(description: "archive operation") |
|
| 408 |
+ let box = AsyncResultBox<T>() |
|
| 409 |
+ |
|
| 410 |
+ Task {
|
|
| 411 |
+ do {
|
|
| 412 |
+ box.result = .success(try await operation()) |
|
| 413 |
+ } catch {
|
|
| 414 |
+ box.result = .failure(error) |
|
| 415 |
+ } |
|
| 416 |
+ expectation.fulfill() |
|
| 417 |
+ } |
|
| 418 |
+ |
|
| 419 |
+ wait(for: [expectation], timeout: 20) |
|
| 420 |
+ return try XCTUnwrap(box.result).get() |
|
| 421 |
+ } |
|
| 422 |
+ |
|
| 347 | 423 |
private func countRows(in tableName: String, at url: URL) throws -> Int {
|
| 348 | 424 |
var db: OpaquePointer? |
| 349 | 425 |
guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|