@@ -27,9 +27,9 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 27 | 27 |
| HealthKit capture | Capture now opens one archive observation per user-visible snapshot and attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it | Continue moving UI/cache reads to archive-backed observation ids | |
| 28 | 28 |
| SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, 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; the legacy `archive_samples` mirror has been removed | Continue moving capture/Dashboard actions to archive/cache DTOs | |
| 29 | 29 |
| Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation | |
| 30 |
-| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration, local device profile settings, operation logging, ContentView preview, Settings data maintenance, legacy detail/PDF views, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture/review actions before removing `ModelContainer` | |
|
| 30 |
+| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration, local device profile settings, operation logging, ContentView preview, Settings data maintenance, legacy detail/PDF views, unused legacy repair/observer services, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture/review actions before removing `ModelContainer` | |
|
| 31 | 31 |
| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; unused legacy SwiftData snapshot/type detail and PDF views have been deleted; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language | Move remaining Dashboard capture/review actions away from SwiftData | |
| 32 |
-| Diff/change explanation | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows; the old direct `HealthSnapshot.typeCounts` diff helper has been retired | Continue moving remaining SwiftData fallback detail paths to archive/cache DTOs | |
|
| 32 |
+| Diff/change explanation | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows; the old direct `HealthSnapshot.typeCounts` diff helper has been retired | Keep active diff/count views on archive/cache DTOs | |
|
| 33 | 33 |
| Export | SQLite export preview, paged JSON writing, SHA256 manifest hashing, and `export_manifests` rows are in place for selected records and observation diffs | Fill remaining recovery-compatible envelope metadata, CSV export, relationship preservation, and reproducibility checks | |
| 34 | 34 |
| Legacy device support | Simplified detail UI mode is implemented for small/accessibility layouts and as a Settings toggle | Remove SwiftData dependency and validate lower deployment targets | |
| 35 | 35 |
| Recovery workflows | Not supported | Preserve export/archive structure for external recovery tools only | |
@@ -48,7 +48,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 48 | 48 |
|
| 49 | 49 |
- SwiftData currently blocks iOS 15-era device support. |
| 50 | 50 |
- Some screens still imply snapshot-count monitoring rather than Time Machine inspection. |
| 51 |
-- Current UI/cache layers still depend on 15 SwiftData-backed files for launch container, capture review actions, model definitions, and legacy repair services. |
|
| 51 |
+- Current UI/cache layers still depend on 12 SwiftData-backed files for launch container, capture review actions, legacy delta creation, and model definitions. |
|
| 52 | 52 |
- Snapshots timeline/detail rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist. |
| 53 | 53 |
- Legacy SwiftData-only snapshots are reset for archive v2 test installs rather than migrated. |
| 54 | 54 |
- Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices. |
@@ -234,6 +234,7 @@ Checklist: |
||
| 234 | 234 |
- [x] Snapshots root reads Core Data cached observation rows directly and no longer imports SwiftData. |
| 235 | 235 |
- [x] Delete unused legacy SwiftData snapshot/type detail views and the PDF |
| 236 | 236 |
exporter tied to those views. |
| 237 |
+- [x] Delete unused legacy SwiftData lifecycle/observer/repair services. |
|
| 237 | 238 |
- [x] Data type detail uses Core Data/SQLite `diffSummary` when archive observation ids exist and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI. |
| 238 | 239 |
- [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper. |
| 239 | 240 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
@@ -264,7 +265,9 @@ Checklist: |
||
| 264 | 265 |
data maintenance now uses the rebuildable Core Data cache; legacy |
| 265 | 266 |
anomaly/count-drop review has been deleted; Snapshots/Data Types tab roots no |
| 266 | 267 |
longer import SwiftData; unused legacy snapshot/type detail and PDF views have |
| 267 |
- been deleted; Dashboard capture/review actions remain. |
|
| 268 |
+ been deleted; unused legacy lifecycle/observer/repair services have been |
|
| 269 |
+ deleted; Dashboard capture/review actions and capture-time legacy delta |
|
| 270 |
+ creation remain. |
|
| 268 | 271 |
- [ ] Remove/disable `ModelContainer` as required for target builds. |
| 269 | 272 |
- [x] Add prototype-store ignore/delete/reset path for test installs. |
| 270 | 273 |
- [ ] Verify no old-store compatibility layer remains in active flows. |
@@ -10,10 +10,9 @@ local settings stored outside SwiftData where needed. |
||
| 10 | 10 |
## Current Count |
| 11 | 11 |
|
| 12 | 12 |
After moving the Snapshots and Data Types tab roots to archive/cache |
| 13 |
-observations and deleting the unused legacy snapshot/type detail views, 15 app |
|
| 14 |
-files still have SwiftData imports because capture, Dashboard review actions, |
|
| 15 |
-model definitions, and legacy repair services still use prototype snapshot |
|
| 16 |
-handles. |
|
| 13 |
+observations and deleting unused legacy repair/detail services, 12 app files |
|
| 14 |
+still have SwiftData imports because capture, Dashboard review actions, legacy |
|
| 15 |
+delta creation, and model definitions still use prototype snapshot handles. |
|
| 17 | 16 |
|
| 18 | 17 |
## Launch Container |
| 19 | 18 |
|
@@ -50,16 +49,12 @@ These services still write/read legacy SwiftData transition models: |
||
| 50 | 49 |
|
| 51 | 50 |
- `HealthProbe/Services/DeltaService.swift` |
| 52 | 51 |
- `HealthProbe/Services/HealthKitService.swift` |
| 53 |
-- `HealthProbe/Services/ObserverService.swift` |
|
| 54 |
-- `HealthProbe/Services/SnapshotLifecycleService.swift` |
|
| 55 |
-- `HealthProbe/Utilities/TypeCountArchiveRepair.swift` |
|
| 56 | 52 |
|
| 57 | 53 |
Retirement path: |
| 58 | 54 |
- make capture persist archive observations without writing prototype |
| 59 | 55 |
`HealthSnapshot` bridge rows; |
| 60 |
-- delete legacy record repair once old SwiftData stores are no longer opened; |
|
| 61 |
-- remove snapshot deletion/repair logic after capture and Dashboard actions no |
|
| 62 |
- longer require prototype snapshots. |
|
| 56 |
+- replace or remove `DeltaService` once capture no longer writes prototype |
|
| 57 |
+ `SnapshotDelta` rows. |
|
| 63 | 58 |
|
| 64 | 59 |
## UI And View Models |
| 65 | 60 |
|
@@ -129,6 +124,13 @@ The following SwiftData dependencies were removed from active flows: |
||
| 129 | 124 |
`HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift`, and |
| 130 | 125 |
`HealthProbe/Utilities/SnapshotPDFExporter.swift` were deleted. Active |
| 131 | 126 |
snapshot/type drill-down now uses archive/cache DTOs. |
| 127 |
+- The unused legacy maintenance services |
|
| 128 |
+ `HealthProbe/Services/SnapshotLifecycleService.swift`, |
|
| 129 |
+ `HealthProbe/Services/IntegrityService.swift`, |
|
| 130 |
+ `HealthProbe/Services/ObserverService.swift`, and |
|
| 131 |
+ `HealthProbe/Utilities/TypeCountArchiveRepair.swift` were deleted. Active |
|
| 132 |
+ observation deletion/repair/background-capture policy now belongs to the |
|
| 133 |
+ SQLite archive/cache design, not the old SwiftData chain. |
|
| 132 | 134 |
- The unused `HealthProbe/Views/DataTypes/TypeEvolutionTimeline.swift` legacy |
| 133 | 135 |
chart was deleted, and the remaining `TypeDiff`/`DiffFilter` DTOs now live in |
| 134 | 136 |
`HealthProbe/Models/TypeDiff.swift` instead of the removed |
@@ -1,79 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
- |
|
| 3 |
-enum IntegrityService {
|
|
| 4 |
- enum ValidationResult: Equatable {
|
|
| 5 |
- case valid |
|
| 6 |
- case checksumMismatch(snapshotID: UUID, expected: String, actual: String) |
|
| 7 |
- case missingDelta(fromID: UUID, toID: UUID) |
|
| 8 |
- case corrupted(snapshotID: UUID, reason: String) |
|
| 9 |
- } |
|
| 10 |
- |
|
| 11 |
- // Strict mode: used by chain traversal and analysis. |
|
| 12 |
- // Recomputes checksum from TypeCounts; compares with stored delta.checksumAfter. |
|
| 13 |
- // Returns .valid only if they match exactly. |
|
| 14 |
- static func validate(snapshot: HealthSnapshot, delta: SnapshotDelta?) -> ValidationResult {
|
|
| 15 |
- guard let delta else {
|
|
| 16 |
- guard snapshot.isChainStart else {
|
|
| 17 |
- return .missingDelta(fromID: snapshot.previousSnapshotID ?? UUID(), toID: snapshot.id) |
|
| 18 |
- } |
|
| 19 |
- return .valid |
|
| 20 |
- } |
|
| 21 |
- |
|
| 22 |
- let actual = HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []) |
|
| 23 |
- guard actual == delta.checksumAfter else {
|
|
| 24 |
- return .checksumMismatch(snapshotID: snapshot.id, expected: delta.checksumAfter, actual: actual) |
|
| 25 |
- } |
|
| 26 |
- return .valid |
|
| 27 |
- } |
|
| 28 |
- |
|
| 29 |
- // Strict chain walk via previousSnapshotID from latest backward. |
|
| 30 |
- // Stops immediately at first missing delta or checksum mismatch — no skips, no auto-repair. |
|
| 31 |
- // FORK DETECTION runs before traversal: if any previousSnapshotID value appears more than |
|
| 32 |
- // once across the snapshot set, returns .corrupted immediately without traversal. |
|
| 33 |
- static func validateChain(snapshots: [HealthSnapshot], deltas: [SnapshotDelta]) -> [ValidationResult] {
|
|
| 34 |
- // Fork detection: assert no duplicate previousSnapshotID values |
|
| 35 |
- var seenPrevIDs: [UUID: UUID] = [:] // prevID → first snapshot ID that used it |
|
| 36 |
- for snapshot in snapshots {
|
|
| 37 |
- guard let prevID = snapshot.previousSnapshotID else { continue }
|
|
| 38 |
- if let existingSnapshotID = seenPrevIDs[prevID] {
|
|
| 39 |
- return [.corrupted( |
|
| 40 |
- snapshotID: existingSnapshotID, |
|
| 41 |
- reason: "chain fork detected — two snapshots share the same previousSnapshotID" |
|
| 42 |
- )] |
|
| 43 |
- } |
|
| 44 |
- seenPrevIDs[prevID] = snapshot.id |
|
| 45 |
- } |
|
| 46 |
- |
|
| 47 |
- let snapshotByID = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.id, $0) })
|
|
| 48 |
- let deltaByToID = Dictionary(uniqueKeysWithValues: deltas.map { ($0.toSnapshotID, $0) })
|
|
| 49 |
- |
|
| 50 |
- // Walk from the latest snapshot backward |
|
| 51 |
- guard let latest = snapshots.max(by: { $0.localSequenceNumber < $1.localSequenceNumber }) else {
|
|
| 52 |
- return [] |
|
| 53 |
- } |
|
| 54 |
- |
|
| 55 |
- var results: [ValidationResult] = [] |
|
| 56 |
- var current: HealthSnapshot? = latest |
|
| 57 |
- |
|
| 58 |
- while let node = current {
|
|
| 59 |
- let delta = deltaByToID[node.id] |
|
| 60 |
- let result = validate(snapshot: node, delta: delta) |
|
| 61 |
- |
|
| 62 |
- switch result {
|
|
| 63 |
- case .valid: |
|
| 64 |
- break |
|
| 65 |
- case .checksumMismatch, .missingDelta, .corrupted: |
|
| 66 |
- // Strict mode — stop immediately on any error |
|
| 67 |
- results.append(result) |
|
| 68 |
- return results |
|
| 69 |
- } |
|
| 70 |
- |
|
| 71 |
- if node.isChainStart {
|
|
| 72 |
- break |
|
| 73 |
- } |
|
| 74 |
- current = node.previousSnapshotID.flatMap { snapshotByID[$0] }
|
|
| 75 |
- } |
|
| 76 |
- |
|
| 77 |
- return results |
|
| 78 |
- } |
|
| 79 |
-} |
|
@@ -1,129 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
-import HealthKit |
|
| 3 |
-import SwiftData |
|
| 4 |
-import os.log |
|
| 5 |
- |
|
| 6 |
-private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "ObserverService") |
|
| 7 |
- |
|
| 8 |
-// Without background observation, a HealthKit deletion followed by reinsertion between two |
|
| 9 |
-// manual snapshots is completely invisible. HKObserverQuery with background delivery closes this gap. |
|
| 10 |
-// Note: HKObserverQuery signals that something changed but does not identify what changed. |
|
| 11 |
-// Actual detection still comes from the next full snapshot + delta comparison. |
|
| 12 |
-final class ObserverService {
|
|
| 13 |
- static let shared = ObserverService() |
|
| 14 |
- |
|
| 15 |
- // Minimum interval between observer-triggered snapshots — manual snapshots bypass this entirely. |
|
| 16 |
- private static let debounceIntervalSeconds: TimeInterval = 600 // 10 minutes |
|
| 17 |
- |
|
| 18 |
- private var observerQueries: [HKObserverQuery] = [] |
|
| 19 |
- private var debounceTask: Task<Void, Never>? |
|
| 20 |
- private var lastCallbackTimestamp: Date? |
|
| 21 |
- private var accumulatedTypeIDs: Set<String> = [] |
|
| 22 |
- private let lock = NSLock() |
|
| 23 |
- |
|
| 24 |
- private weak var modelContainer: ModelContainer? |
|
| 25 |
- private var selectedTypeIDs: Set<String> = [] |
|
| 26 |
- |
|
| 27 |
- func startObserving(types: [HKObjectType], store: HKHealthStore, container: ModelContainer, selectedTypeIDs: Set<String>) {
|
|
| 28 |
- self.modelContainer = container |
|
| 29 |
- self.selectedTypeIDs = selectedTypeIDs |
|
| 30 |
- |
|
| 31 |
- for objectType in types {
|
|
| 32 |
- let query = HKObserverQuery(sampleType: objectType as! HKSampleType, predicate: nil) { [weak self] _, completionHandler, error in
|
|
| 33 |
- // Always call first — HealthKit re-fires indefinitely if not called |
|
| 34 |
- defer { completionHandler() }
|
|
| 35 |
- // Schedule snapshot task separately; failure is logged, not fatal |
|
| 36 |
- if let error {
|
|
| 37 |
- logger.error("ObserverQuery error for \(objectType.identifier): \(error)")
|
|
| 38 |
- return |
|
| 39 |
- } |
|
| 40 |
- self?.handleObserverCallback(typeID: objectType.identifier) |
|
| 41 |
- } |
|
| 42 |
- store.execute(query) |
|
| 43 |
- |
|
| 44 |
- // Frequency: .immediate for critical types, .daily for others |
|
| 45 |
- let frequency: HKUpdateFrequency = isCriticalType(objectType.identifier) ? .immediate : .daily |
|
| 46 |
- store.enableBackgroundDelivery(for: objectType, frequency: frequency) { success, error in
|
|
| 47 |
- if !success {
|
|
| 48 |
- logger.error("Failed to enable background delivery for \(objectType.identifier): \(String(describing: error))")
|
|
| 49 |
- } |
|
| 50 |
- } |
|
| 51 |
- observerQueries.append(query) |
|
| 52 |
- } |
|
| 53 |
- } |
|
| 54 |
- |
|
| 55 |
- // MARK: - Callback handling |
|
| 56 |
- |
|
| 57 |
- private func handleObserverCallback(typeID: String) {
|
|
| 58 |
- let alreadyScheduled = lock.withLock {
|
|
| 59 |
- let now = Date() |
|
| 60 |
- lastCallbackTimestamp = now |
|
| 61 |
- accumulatedTypeIDs.insert(typeID) |
|
| 62 |
- return debounceTask != nil |
|
| 63 |
- } |
|
| 64 |
- |
|
| 65 |
- guard !alreadyScheduled else { return }
|
|
| 66 |
- |
|
| 67 |
- debounceTask = Task { [weak self] in
|
|
| 68 |
- guard let self else { return }
|
|
| 69 |
- // Wait out the debounce window |
|
| 70 |
- try? await Task.sleep(nanoseconds: UInt64(Self.debounceIntervalSeconds * 1_000_000_000)) |
|
| 71 |
- await self.tryCreateObserverSnapshot() |
|
| 72 |
- } |
|
| 73 |
- } |
|
| 74 |
- |
|
| 75 |
- @MainActor |
|
| 76 |
- private func tryCreateObserverSnapshot() async {
|
|
| 77 |
- lock.withLock {
|
|
| 78 |
- debounceTask = nil |
|
| 79 |
- } |
|
| 80 |
- |
|
| 81 |
- guard let container = modelContainer else {
|
|
| 82 |
- logger.error("ObserverService: no modelContainer — cannot create snapshot")
|
|
| 83 |
- return |
|
| 84 |
- } |
|
| 85 |
- |
|
| 86 |
- // Manual overlap suppression: if a manual snapshot was created during the debounce window, |
|
| 87 |
- // cancel the observer snapshot to avoid a redundant .unchanged delta. |
|
| 88 |
- let context = ModelContext(container) |
|
| 89 |
- if let lastCallback = lastCallbackTimestamp {
|
|
| 90 |
- let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 91 |
- sortBy: [SortDescriptor(\.timestamp, order: .reverse)] |
|
| 92 |
- ) |
|
| 93 |
- let recent = try? context.fetch(descriptor) |
|
| 94 |
- if let latestManual = recent?.first(where: { $0.triggerReason == "manual" }),
|
|
| 95 |
- latestManual.timestamp > lastCallback {
|
|
| 96 |
- logger.info("ObserverService: suppressed — manual snapshot captured during debounce window")
|
|
| 97 |
- return |
|
| 98 |
- } |
|
| 99 |
- } |
|
| 100 |
- |
|
| 101 |
- // Create one consolidated snapshot covering all monitored types |
|
| 102 |
- do {
|
|
| 103 |
- let snapshot = try await HealthKitService.shared.createSnapshot( |
|
| 104 |
- in: context, |
|
| 105 |
- selectedTypeIDs: selectedTypeIDs, |
|
| 106 |
- adaptiveTimeoutsEnabled: true, |
|
| 107 |
- triggerReason: "observerCallback" |
|
| 108 |
- ) |
|
| 109 |
- logger.info("ObserverService: observer-triggered snapshot created \(snapshot.id)")
|
|
| 110 |
- } catch {
|
|
| 111 |
- logger.error("ObserverService: failed to create snapshot — \(error)")
|
|
| 112 |
- } |
|
| 113 |
- |
|
| 114 |
- lock.withLock {
|
|
| 115 |
- accumulatedTypeIDs.removeAll() |
|
| 116 |
- lastCallbackTimestamp = nil |
|
| 117 |
- } |
|
| 118 |
- } |
|
| 119 |
- |
|
| 120 |
- // MARK: - Type classification |
|
| 121 |
- |
|
| 122 |
- private func isCriticalType(_ typeID: String) -> Bool {
|
|
| 123 |
- let critical: Set<String> = Set([ |
|
| 124 |
- HKQuantityType.quantityType(forIdentifier: .heartRate)?.identifier, |
|
| 125 |
- HKQuantityType.quantityType(forIdentifier: .stepCount)?.identifier, |
|
| 126 |
- ].compactMap { $0 })
|
|
| 127 |
- return critical.contains(typeID) |
|
| 128 |
- } |
|
| 129 |
-} |
|
@@ -1,431 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
-import SwiftData |
|
| 3 |
-import os.log |
|
| 4 |
- |
|
| 5 |
-private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "SnapshotLifecycleService") |
|
| 6 |
- |
|
| 7 |
-enum SnapshotLifecycleService {
|
|
| 8 |
- struct DeletionPreview {
|
|
| 9 |
- let target: HealthSnapshot |
|
| 10 |
- let affectedDeltas: [SnapshotDelta] |
|
| 11 |
- let mergedDelta: SnapshotDelta? |
|
| 12 |
- let willBreakChain: Bool |
|
| 13 |
- let description: String |
|
| 14 |
- } |
|
| 15 |
- |
|
| 16 |
- static func previewDeletion(of snapshot: HealthSnapshot, context: ModelContext) throws -> DeletionPreview {
|
|
| 17 |
- let incomingDelta = try fetchIncomingDelta(toSnapshotID: snapshot.id, context: context) |
|
| 18 |
- let outgoingDelta = try fetchOutgoingDelta(fromSnapshotID: snapshot.id, context: context) |
|
| 19 |
- |
|
| 20 |
- var willBreakChain = false |
|
| 21 |
- var description = "" |
|
| 22 |
- |
|
| 23 |
- let integrityResult = IntegrityService.validate(snapshot: snapshot, delta: incomingDelta) |
|
| 24 |
- switch integrityResult {
|
|
| 25 |
- case .valid: |
|
| 26 |
- break |
|
| 27 |
- case .checksumMismatch(_, let expected, let actual): |
|
| 28 |
- willBreakChain = true |
|
| 29 |
- description = "Checksum mismatch: expected \(expected.prefix(8))…, got \(actual.prefix(8))…" |
|
| 30 |
- case .missingDelta(let fromID, _): |
|
| 31 |
- willBreakChain = true |
|
| 32 |
- description = "Missing delta from \(fromID)" |
|
| 33 |
- case .corrupted(_, let reason): |
|
| 34 |
- willBreakChain = true |
|
| 35 |
- description = reason |
|
| 36 |
- } |
|
| 37 |
- |
|
| 38 |
- var affectedDeltas: [SnapshotDelta] = [] |
|
| 39 |
- if let d = incomingDelta { affectedDeltas.append(d) }
|
|
| 40 |
- if let d = outgoingDelta { affectedDeltas.append(d) }
|
|
| 41 |
- |
|
| 42 |
- // For intermediate deletion, compute the merged delta preview |
|
| 43 |
- var mergedDelta: SnapshotDelta? = nil |
|
| 44 |
- if let d1 = incomingDelta, let d2 = outgoingDelta, |
|
| 45 |
- let prevSnap = try fetchSnapshot(id: d1.fromSnapshotID, context: context), |
|
| 46 |
- let nextSnap = try fetchSnapshot(id: d2.toSnapshotID, context: context) {
|
|
| 47 |
- mergedDelta = DeltaService.mergeDeltas( |
|
| 48 |
- d1: d1, d2: d2, |
|
| 49 |
- snapshotBefore: prevSnap, |
|
| 50 |
- snapshotAfter: nextSnap |
|
| 51 |
- ) |
|
| 52 |
- } |
|
| 53 |
- |
|
| 54 |
- return DeletionPreview( |
|
| 55 |
- target: snapshot, |
|
| 56 |
- affectedDeltas: affectedDeltas, |
|
| 57 |
- mergedDelta: mergedDelta, |
|
| 58 |
- willBreakChain: willBreakChain, |
|
| 59 |
- description: description |
|
| 60 |
- ) |
|
| 61 |
- } |
|
| 62 |
- |
|
| 63 |
- static func delete(_ snapshot: HealthSnapshot, context: ModelContext) throws {
|
|
| 64 |
- let incomingDelta = try fetchIncomingDelta(toSnapshotID: snapshot.id, context: context) |
|
| 65 |
- let outgoingDelta = try fetchOutgoingDelta(fromSnapshotID: snapshot.id, context: context) |
|
| 66 |
- |
|
| 67 |
- let deviceID = snapshot.deviceID |
|
| 68 |
- let version = Bundle.main.appBuildVersion |
|
| 69 |
- |
|
| 70 |
- let log = LocalOperationLog( |
|
| 71 |
- operationType: "delete", |
|
| 72 |
- summary: buildSummary(snapshot: snapshot, incoming: incomingDelta, outgoing: outgoingDelta), |
|
| 73 |
- deviceID: deviceID, |
|
| 74 |
- appBuildVersion: version |
|
| 75 |
- ) |
|
| 76 |
- var completedLog = log |
|
| 77 |
- completedLog.affectedSnapshotIDs = [snapshot.id.uuidString] |
|
| 78 |
- |
|
| 79 |
- if incomingDelta == nil && outgoingDelta == nil {
|
|
| 80 |
- // Standalone snapshot — just delete |
|
| 81 |
- context.delete(snapshot) |
|
| 82 |
- } else if incomingDelta == nil, let outgoing = outgoingDelta {
|
|
| 83 |
- // Oldest snapshot: delete it and outgoing delta, set next as chain start |
|
| 84 |
- if let nextSnap = try fetchSnapshot(id: outgoing.toSnapshotID, context: context) {
|
|
| 85 |
- nextSnap.previousSnapshotID = nil |
|
| 86 |
- nextSnap.isChainStart = true |
|
| 87 |
- nextSnap.contentEquivalentSnapshotID = nil |
|
| 88 |
- for typeCount in nextSnap.typeCounts ?? [] {
|
|
| 89 |
- typeCount.contentEquivalentTypeCountID = nil |
|
| 90 |
- } |
|
| 91 |
- invalidateDetailCaches(for: nextSnap) |
|
| 92 |
- } |
|
| 93 |
- context.delete(outgoing) |
|
| 94 |
- context.delete(snapshot) |
|
| 95 |
- } else if outgoingDelta == nil, let incoming = incomingDelta {
|
|
| 96 |
- // Latest snapshot: delete it and incoming delta |
|
| 97 |
- context.delete(incoming) |
|
| 98 |
- context.delete(snapshot) |
|
| 99 |
- } else if let d1 = incomingDelta, let d2 = outgoingDelta {
|
|
| 100 |
- // Intermediate snapshot: merge deltas and delete |
|
| 101 |
- guard let prevSnap = try fetchSnapshot(id: d1.fromSnapshotID, context: context), |
|
| 102 |
- let nextSnap = try fetchSnapshot(id: d2.toSnapshotID, context: context) else {
|
|
| 103 |
- logger.error("SnapshotLifecycleService: failed to find surrounding snapshots for merge")
|
|
| 104 |
- throw LifecycleError.missingNeighbor |
|
| 105 |
- } |
|
| 106 |
- |
|
| 107 |
- let merged = DeltaService.mergeDeltas( |
|
| 108 |
- d1: d1, d2: d2, |
|
| 109 |
- snapshotBefore: prevSnap, |
|
| 110 |
- snapshotAfter: nextSnap |
|
| 111 |
- ) |
|
| 112 |
- merged.deviceID = deviceID |
|
| 113 |
- context.insert(merged) |
|
| 114 |
- for td in merged.typeDeltas ?? [] { context.insert(td) }
|
|
| 115 |
- |
|
| 116 |
- nextSnap.previousSnapshotID = prevSnap.id |
|
| 117 |
- _ = refreshContentEquivalence(for: nextSnap, baseline: prevSnap) |
|
| 118 |
- invalidateDetailCaches(for: nextSnap) |
|
| 119 |
- context.delete(d1) |
|
| 120 |
- context.delete(d2) |
|
| 121 |
- context.delete(snapshot) |
|
| 122 |
- } |
|
| 123 |
- |
|
| 124 |
- try context.save() |
|
| 125 |
- LocalOperationLogStore.append(completedLog) |
|
| 126 |
- } |
|
| 127 |
- |
|
| 128 |
- static func rebuildMissingDetailCaches( |
|
| 129 |
- context: ModelContext, |
|
| 130 |
- maxTypeCounts: Int |
|
| 131 |
- ) throws -> Bool {
|
|
| 132 |
- guard maxTypeCounts > 0 else { return false }
|
|
| 133 |
- MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.begin", metadata: [
|
|
| 134 |
- "maxTypeCounts": "\(maxTypeCounts)" |
|
| 135 |
- ]) |
|
| 136 |
- |
|
| 137 |
- let snapshotIDs = try context.fetch(FetchDescriptor<HealthSnapshot>()).map { $0.id }
|
|
| 138 |
- MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.snapshotsFetched", metadata: [
|
|
| 139 |
- "snapshotCount": "\(snapshotIDs.count)" |
|
| 140 |
- ]) |
|
| 141 |
- var rebuiltCount = 0 |
|
| 142 |
- var updatedAliases = 0 |
|
| 143 |
- |
|
| 144 |
- // Alias pass: process in batches to avoid memory bloat |
|
| 145 |
- let aliasBatchSize = 5 |
|
| 146 |
- for batchStart in stride(from: 0, to: snapshotIDs.count, by: aliasBatchSize) {
|
|
| 147 |
- let batchEnd = min(batchStart + aliasBatchSize, snapshotIDs.count) |
|
| 148 |
- for id in snapshotIDs[batchStart..<batchEnd] {
|
|
| 149 |
- guard let snapshot = try fetchSnapshot(id: id, context: context), |
|
| 150 |
- let baselineID = snapshot.previousSnapshotID, |
|
| 151 |
- let baseline = try fetchSnapshot(id: baselineID, context: context) else {
|
|
| 152 |
- continue |
|
| 153 |
- } |
|
| 154 |
- |
|
| 155 |
- if refreshContentEquivalence(for: snapshot, baseline: baseline) {
|
|
| 156 |
- updatedAliases += 1 |
|
| 157 |
- } |
|
| 158 |
- } |
|
| 159 |
- // Save batch to flush object cache and allow garbage collection |
|
| 160 |
- if updatedAliases > 0 {
|
|
| 161 |
- try context.save() |
|
| 162 |
- } |
|
| 163 |
- } |
|
| 164 |
- |
|
| 165 |
- MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.aliasPassFinished", metadata: [
|
|
| 166 |
- "updatedAliases": "\(updatedAliases)" |
|
| 167 |
- ]) |
|
| 168 |
- if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
|
|
| 169 |
- MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedMemoryPressure", metadata: [
|
|
| 170 |
- "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit), |
|
| 171 |
- "phase": "afterAliasPass", |
|
| 172 |
- "updatedAliases": "\(updatedAliases)" |
|
| 173 |
- ]) |
|
| 174 |
- return false |
|
| 175 |
- } |
|
| 176 |
- |
|
| 177 |
- // Detail cache pass |
|
| 178 |
- for id in snapshotIDs {
|
|
| 179 |
- guard let snapshot = try fetchSnapshot(id: id, context: context), |
|
| 180 |
- let baselineID = snapshot.previousSnapshotID, |
|
| 181 |
- let baseline = try fetchSnapshot(id: baselineID, context: context) else {
|
|
| 182 |
- continue |
|
| 183 |
- } |
|
| 184 |
- |
|
| 185 |
- let baselineByType = Dictionary( |
|
| 186 |
- uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 187 |
- ) |
|
| 188 |
- |
|
| 189 |
- if snapshot.isContentAlias, |
|
| 190 |
- snapshot.contentEquivalentSnapshotID == baseline.contentRepresentativeSnapshotID {
|
|
| 191 |
- continue |
|
| 192 |
- } |
|
| 193 |
- |
|
| 194 |
- for typeCount in snapshot.typeCounts ?? [] {
|
|
| 195 |
- guard shouldBackfillDetailCache( |
|
| 196 |
- typeCount: typeCount, |
|
| 197 |
- baseline: baselineByType[typeCount.typeIdentifier], |
|
| 198 |
- baselineID: baseline.id |
|
| 199 |
- ) else {
|
|
| 200 |
- continue |
|
| 201 |
- } |
|
| 202 |
- |
|
| 203 |
- if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
|
|
| 204 |
- MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedMemoryPressure", metadata: [
|
|
| 205 |
- "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit), |
|
| 206 |
- "phase": "beforeDetailCacheBuild", |
|
| 207 |
- "rebuiltCount": "\(rebuiltCount)", |
|
| 208 |
- "updatedAliases": "\(updatedAliases)" |
|
| 209 |
- ]) |
|
| 210 |
- if rebuiltCount > 0 || updatedAliases > 0 {
|
|
| 211 |
- try context.save() |
|
| 212 |
- } |
|
| 213 |
- return false |
|
| 214 |
- } |
|
| 215 |
- |
|
| 216 |
- MemoryLog.log("snapshotLifecycle.detailCache.buildBegin", metadata: detailCacheMetadata(
|
|
| 217 |
- current: typeCount, |
|
| 218 |
- previous: baselineByType[typeCount.typeIdentifier], |
|
| 219 |
- source: "backfill" |
|
| 220 |
- )) |
|
| 221 |
- typeCount.setDetailCache( |
|
| 222 |
- TypeCountDetailCacheBuilder.build( |
|
| 223 |
- current: typeCount, |
|
| 224 |
- previous: baselineByType[typeCount.typeIdentifier], |
|
| 225 |
- baselineSnapshotID: baseline.id |
|
| 226 |
- ) |
|
| 227 |
- ) |
|
| 228 |
- MemoryLog.log("snapshotLifecycle.detailCache.buildEnd", metadata: detailCacheMetadata(
|
|
| 229 |
- current: typeCount, |
|
| 230 |
- previous: baselineByType[typeCount.typeIdentifier], |
|
| 231 |
- source: "backfill" |
|
| 232 |
- )) |
|
| 233 |
- rebuiltCount += 1 |
|
| 234 |
- |
|
| 235 |
- if rebuiltCount >= maxTypeCounts {
|
|
| 236 |
- MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedAtLimit", metadata: [
|
|
| 237 |
- "rebuiltCount": "\(rebuiltCount)", |
|
| 238 |
- "updatedAliases": "\(updatedAliases)" |
|
| 239 |
- ]) |
|
| 240 |
- try context.save() |
|
| 241 |
- return false |
|
| 242 |
- } |
|
| 243 |
- } |
|
| 244 |
- } |
|
| 245 |
- |
|
| 246 |
- if rebuiltCount > 0 || updatedAliases > 0 {
|
|
| 247 |
- try context.save() |
|
| 248 |
- } |
|
| 249 |
- MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.complete", metadata: [
|
|
| 250 |
- "rebuiltCount": "\(rebuiltCount)", |
|
| 251 |
- "updatedAliases": "\(updatedAliases)" |
|
| 252 |
- ]) |
|
| 253 |
- return true |
|
| 254 |
- } |
|
| 255 |
- |
|
| 256 |
- // MARK: - Fetch helpers |
|
| 257 |
- |
|
| 258 |
- private static func fetchIncomingDelta(toSnapshotID snapshotID: UUID, context: ModelContext) throws -> SnapshotDelta? {
|
|
| 259 |
- let descriptor = FetchDescriptor<SnapshotDelta>( |
|
| 260 |
- predicate: #Predicate<SnapshotDelta> { $0.toSnapshotID == snapshotID }
|
|
| 261 |
- ) |
|
| 262 |
- return try context.fetch(descriptor).first |
|
| 263 |
- } |
|
| 264 |
- |
|
| 265 |
- private static func fetchOutgoingDelta(fromSnapshotID snapshotID: UUID, context: ModelContext) throws -> SnapshotDelta? {
|
|
| 266 |
- let descriptor = FetchDescriptor<SnapshotDelta>( |
|
| 267 |
- predicate: #Predicate<SnapshotDelta> { $0.fromSnapshotID == snapshotID }
|
|
| 268 |
- ) |
|
| 269 |
- return try context.fetch(descriptor).first |
|
| 270 |
- } |
|
| 271 |
- |
|
| 272 |
- private static func fetchSnapshot(id: UUID, context: ModelContext) throws -> HealthSnapshot? {
|
|
| 273 |
- let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 274 |
- predicate: #Predicate<HealthSnapshot> { $0.id == id }
|
|
| 275 |
- ) |
|
| 276 |
- return try context.fetch(descriptor).first |
|
| 277 |
- } |
|
| 278 |
- |
|
| 279 |
- private static func invalidateDetailCaches(for snapshot: HealthSnapshot) {
|
|
| 280 |
- for typeCount in snapshot.typeCounts ?? [] {
|
|
| 281 |
- typeCount.setDetailCache(nil) |
|
| 282 |
- } |
|
| 283 |
- } |
|
| 284 |
- |
|
| 285 |
- @discardableResult |
|
| 286 |
- private static func refreshContentEquivalence(for snapshot: HealthSnapshot, baseline: HealthSnapshot) -> Bool {
|
|
| 287 |
- let previousSnapshotAliasID = snapshot.contentEquivalentSnapshotID |
|
| 288 |
- let previousTypeAliasIDs = Dictionary( |
|
| 289 |
- uniqueKeysWithValues: (snapshot.typeCounts ?? []).map { ($0.id, $0.contentEquivalentTypeCountID) }
|
|
| 290 |
- ) |
|
| 291 |
- |
|
| 292 |
- snapshot.contentEquivalentSnapshotID = nil |
|
| 293 |
- for typeCount in snapshot.typeCounts ?? [] {
|
|
| 294 |
- typeCount.contentEquivalentTypeCountID = nil |
|
| 295 |
- } |
|
| 296 |
- |
|
| 297 |
- guard snapshot.monitoredTypeSetHash == baseline.monitoredTypeSetHash else {
|
|
| 298 |
- return contentEquivalenceDidChange( |
|
| 299 |
- snapshot: snapshot, |
|
| 300 |
- previousSnapshotAliasID: previousSnapshotAliasID, |
|
| 301 |
- previousTypeAliasIDs: previousTypeAliasIDs |
|
| 302 |
- ) |
|
| 303 |
- } |
|
| 304 |
- |
|
| 305 |
- let baselineByType = Dictionary( |
|
| 306 |
- uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 307 |
- ) |
|
| 308 |
- |
|
| 309 |
- for typeCount in snapshot.typeCounts ?? [] {
|
|
| 310 |
- guard let baselineType = baselineByType[typeCount.typeIdentifier], |
|
| 311 |
- areTypeCountsContentEquivalent(typeCount, baselineType) else {
|
|
| 312 |
- continue |
|
| 313 |
- } |
|
| 314 |
- |
|
| 315 |
- typeCount.contentEquivalentTypeCountID = baselineType.contentRepresentativeTypeCountID |
|
| 316 |
- typeCount.setDetailCache(nil) |
|
| 317 |
- } |
|
| 318 |
- |
|
| 319 |
- if areTypeCountsContentEquivalent(snapshot.typeCounts ?? [], baseline.typeCounts ?? []) {
|
|
| 320 |
- snapshot.contentEquivalentSnapshotID = baseline.contentRepresentativeSnapshotID |
|
| 321 |
- } |
|
| 322 |
- |
|
| 323 |
- return contentEquivalenceDidChange( |
|
| 324 |
- snapshot: snapshot, |
|
| 325 |
- previousSnapshotAliasID: previousSnapshotAliasID, |
|
| 326 |
- previousTypeAliasIDs: previousTypeAliasIDs |
|
| 327 |
- ) |
|
| 328 |
- } |
|
| 329 |
- |
|
| 330 |
- private static func contentEquivalenceDidChange( |
|
| 331 |
- snapshot: HealthSnapshot, |
|
| 332 |
- previousSnapshotAliasID: UUID?, |
|
| 333 |
- previousTypeAliasIDs: [UUID: UUID?] |
|
| 334 |
- ) -> Bool {
|
|
| 335 |
- if snapshot.contentEquivalentSnapshotID != previousSnapshotAliasID {
|
|
| 336 |
- return true |
|
| 337 |
- } |
|
| 338 |
- |
|
| 339 |
- for typeCount in snapshot.typeCounts ?? [] {
|
|
| 340 |
- if typeCount.contentEquivalentTypeCountID != (previousTypeAliasIDs[typeCount.id] ?? nil) {
|
|
| 341 |
- return true |
|
| 342 |
- } |
|
| 343 |
- } |
|
| 344 |
- |
|
| 345 |
- return false |
|
| 346 |
- } |
|
| 347 |
- |
|
| 348 |
- private static func areTypeCountsContentEquivalent(_ lhs: [TypeCount], _ rhs: [TypeCount]) -> Bool {
|
|
| 349 |
- let lhsByType = Dictionary(uniqueKeysWithValues: lhs.map { ($0.typeIdentifier, $0) })
|
|
| 350 |
- let rhsByType = Dictionary(uniqueKeysWithValues: rhs.map { ($0.typeIdentifier, $0) })
|
|
| 351 |
- guard lhsByType.keys == rhsByType.keys else { return false }
|
|
| 352 |
- |
|
| 353 |
- for typeIdentifier in lhsByType.keys {
|
|
| 354 |
- guard let lhsType = lhsByType[typeIdentifier], |
|
| 355 |
- let rhsType = rhsByType[typeIdentifier], |
|
| 356 |
- lhsType.count == rhsType.count, |
|
| 357 |
- lhsType.contentHash == rhsType.contentHash, |
|
| 358 |
- lhsType.quality == rhsType.quality, |
|
| 359 |
- lhsType.isUnsupported == rhsType.isUnsupported else {
|
|
| 360 |
- return false |
|
| 361 |
- } |
|
| 362 |
- } |
|
| 363 |
- |
|
| 364 |
- return true |
|
| 365 |
- } |
|
| 366 |
- |
|
| 367 |
- private static func areTypeCountsContentEquivalent(_ lhs: TypeCount, _ rhs: TypeCount) -> Bool {
|
|
| 368 |
- lhs.count == rhs.count && |
|
| 369 |
- lhs.contentHash == rhs.contentHash && |
|
| 370 |
- lhs.quality == rhs.quality && |
|
| 371 |
- lhs.isUnsupported == rhs.isUnsupported |
|
| 372 |
- } |
|
| 373 |
- |
|
| 374 |
- @MainActor private static func shouldBackfillDetailCache( |
|
| 375 |
- typeCount: TypeCount, |
|
| 376 |
- baseline: TypeCount?, |
|
| 377 |
- baselineID: UUID |
|
| 378 |
- ) -> Bool {
|
|
| 379 |
- if typeCount.isContentAlias {
|
|
| 380 |
- return false |
|
| 381 |
- } |
|
| 382 |
- |
|
| 383 |
- if typeCount.detailCache?.matchesBaseline(baselineID) == true {
|
|
| 384 |
- return false |
|
| 385 |
- } |
|
| 386 |
- |
|
| 387 |
- guard canBuildDetailCache(typeCount), |
|
| 388 |
- baseline.map(canBuildDetailCache(_:)) ?? true else {
|
|
| 389 |
- return false |
|
| 390 |
- } |
|
| 391 |
- |
|
| 392 |
- return true |
|
| 393 |
- } |
|
| 394 |
- |
|
| 395 |
- @MainActor private static func canBuildDetailCache(_ typeCount: TypeCount) -> Bool {
|
|
| 396 |
- typeCount.count <= 0 || typeCount.recordArchiveData != nil |
|
| 397 |
- } |
|
| 398 |
- |
|
| 399 |
- private static func detailCacheMetadata(current: TypeCount, previous: TypeCount?, source: String) -> [String: String] {
|
|
| 400 |
- [ |
|
| 401 |
- "source": source, |
|
| 402 |
- "type": current.typeIdentifier, |
|
| 403 |
- "currentCount": "\(current.count)", |
|
| 404 |
- "previousCount": "\(previous?.count ?? 0)", |
|
| 405 |
- "currentArchive": current.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 406 |
- "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 407 |
- "isAlias": "\(current.isContentAlias)" |
|
| 408 |
- ] |
|
| 409 |
- } |
|
| 410 |
- |
|
| 411 |
- private static func buildSummary(snapshot: HealthSnapshot, incoming: SnapshotDelta?, outgoing: SnapshotDelta?) -> String {
|
|
| 412 |
- let position: String |
|
| 413 |
- if incoming == nil && outgoing == nil { position = "standalone" }
|
|
| 414 |
- else if incoming == nil { position = "oldest" }
|
|
| 415 |
- else if outgoing == nil { position = "latest" }
|
|
| 416 |
- else { position = "intermediate" }
|
|
| 417 |
- return "Deleted \(position) snapshot \(snapshot.id) at \(snapshot.timestamp)" |
|
| 418 |
- } |
|
| 419 |
- |
|
| 420 |
- enum LifecycleError: Error {
|
|
| 421 |
- case missingNeighbor |
|
| 422 |
- } |
|
| 423 |
-} |
|
| 424 |
- |
|
| 425 |
-private extension Bundle {
|
|
| 426 |
- var appBuildVersion: String {
|
|
| 427 |
- let version = infoDictionary?["CFBundleShortVersionString"] as? String ?? "" |
|
| 428 |
- let build = infoDictionary?["CFBundleVersion"] as? String ?? "" |
|
| 429 |
- return "\(version) (\(build))" |
|
| 430 |
- } |
|
| 431 |
-} |
|
@@ -1,168 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
-import SwiftData |
|
| 3 |
- |
|
| 4 |
-struct TypeCountDetailCacheResolution: Sendable {
|
|
| 5 |
- let cache: TypeCountDetailCache? |
|
| 6 |
- let diagnostic: String |
|
| 7 |
-} |
|
| 8 |
- |
|
| 9 |
-extension TypeCount {
|
|
| 10 |
- private static let detailCacheResolverVersion = "resolver-v5" |
|
| 11 |
- |
|
| 12 |
- @MainActor |
|
| 13 |
- func ensureRecordArchiveDataIfNeeded() -> Bool {
|
|
| 14 |
- if count <= 0 {
|
|
| 15 |
- return true |
|
| 16 |
- } |
|
| 17 |
- |
|
| 18 |
- if let existingArchive = recordArchiveData {
|
|
| 19 |
- if HealthRecordArchive.isCompact(existingArchive) {
|
|
| 20 |
- return true |
|
| 21 |
- } |
|
| 22 |
- |
|
| 23 |
- guard let compactArchive = HealthRecordArchive.compactedIfNeeded(existingArchive) else {
|
|
| 24 |
- return false |
|
| 25 |
- } |
|
| 26 |
- recordArchiveData = compactArchive |
|
| 27 |
- return true |
|
| 28 |
- } |
|
| 29 |
- |
|
| 30 |
- let legacyRecords = records ?? [] |
|
| 31 |
- guard !legacyRecords.isEmpty else {
|
|
| 32 |
- return false |
|
| 33 |
- } |
|
| 34 |
- |
|
| 35 |
- let values = legacyRecords.map { record in
|
|
| 36 |
- HealthRecordValue( |
|
| 37 |
- typeIdentifier: record.typeIdentifier, |
|
| 38 |
- sampleUUIDHash: record.sampleUUIDHash, |
|
| 39 |
- recordFingerprint: record.recordFingerprint, |
|
| 40 |
- startDate: record.startDate, |
|
| 41 |
- endDate: record.endDate, |
|
| 42 |
- displayValue: record.displayValue |
|
| 43 |
- ) |
|
| 44 |
- } |
|
| 45 |
- guard let archive = HealthRecordArchive.encode(values) else {
|
|
| 46 |
- return false |
|
| 47 |
- } |
|
| 48 |
- |
|
| 49 |
- recordArchiveData = archive |
|
| 50 |
- records?.removeAll() |
|
| 51 |
- return true |
|
| 52 |
- } |
|
| 53 |
- |
|
| 54 |
- @MainActor |
|
| 55 |
- func resolveDetailCache( |
|
| 56 |
- previous: TypeCount?, |
|
| 57 |
- baselineSnapshotID: UUID?, |
|
| 58 |
- context: ModelContext, |
|
| 59 |
- source: String |
|
| 60 |
- ) -> TypeCountDetailCache? {
|
|
| 61 |
- resolveDetailCacheWithDiagnostics( |
|
| 62 |
- previous: previous, |
|
| 63 |
- baselineSnapshotID: baselineSnapshotID, |
|
| 64 |
- context: context, |
|
| 65 |
- source: source |
|
| 66 |
- ).cache |
|
| 67 |
- } |
|
| 68 |
- |
|
| 69 |
- @MainActor |
|
| 70 |
- func resolveDetailCacheWithDiagnostics( |
|
| 71 |
- previous: TypeCount?, |
|
| 72 |
- baselineSnapshotID: UUID?, |
|
| 73 |
- context: ModelContext, |
|
| 74 |
- source: String |
|
| 75 |
- ) -> TypeCountDetailCacheResolution {
|
|
| 76 |
- if let cache = detailCache, |
|
| 77 |
- cache.matchesBaseline(baselineSnapshotID) {
|
|
| 78 |
- return TypeCountDetailCacheResolution( |
|
| 79 |
- cache: cache, |
|
| 80 |
- diagnostic: detailCacheDiagnostic( |
|
| 81 |
- previous: previous, |
|
| 82 |
- baselineSnapshotID: baselineSnapshotID, |
|
| 83 |
- phase: "cache-hit" |
|
| 84 |
- ) |
|
| 85 |
- ) |
|
| 86 |
- } |
|
| 87 |
- |
|
| 88 |
- let currentArchiveWasMissing = count > 0 && recordArchiveData == nil |
|
| 89 |
- let previousArchiveWasMissing = (previous?.count ?? 0) > 0 && previous?.recordArchiveData == nil |
|
| 90 |
- let currentArchiveAvailable = ensureRecordArchiveDataIfNeeded() |
|
| 91 |
- let previousArchiveAvailable = previous?.ensureRecordArchiveDataIfNeeded() ?? true |
|
| 92 |
- |
|
| 93 |
- guard currentArchiveAvailable, previousArchiveAvailable else {
|
|
| 94 |
- let diagnostic = detailCacheDiagnostic( |
|
| 95 |
- previous: previous, |
|
| 96 |
- baselineSnapshotID: baselineSnapshotID, |
|
| 97 |
- phase: "missing-archive" |
|
| 98 |
- ) |
|
| 99 |
- MemoryLog.log("\(source).detailCache.resolveUnavailable", metadata: detailCacheMetadata(previous: previous).merging([
|
|
| 100 |
- "diagnostic": diagnostic |
|
| 101 |
- ]) { _, new in new })
|
|
| 102 |
- return TypeCountDetailCacheResolution(cache: nil, diagnostic: diagnostic) |
|
| 103 |
- } |
|
| 104 |
- |
|
| 105 |
- MemoryLog.log("\(source).detailCache.buildBegin", metadata: detailCacheMetadata(previous: previous))
|
|
| 106 |
- let cache = TypeCountDetailCacheBuilder.build( |
|
| 107 |
- current: self, |
|
| 108 |
- previous: previous, |
|
| 109 |
- baselineSnapshotID: baselineSnapshotID |
|
| 110 |
- ) |
|
| 111 |
- let diagnostic = detailCacheDiagnostic( |
|
| 112 |
- previous: previous, |
|
| 113 |
- baselineSnapshotID: baselineSnapshotID, |
|
| 114 |
- phase: cache == nil ? "build-nil" : "built" |
|
| 115 |
- ) |
|
| 116 |
- MemoryLog.log("\(source).detailCache.buildEnd", metadata: [
|
|
| 117 |
- "source": source, |
|
| 118 |
- "type": typeIdentifier, |
|
| 119 |
- "cacheBuilt": "\(cache != nil)", |
|
| 120 |
- "diagnostic": diagnostic |
|
| 121 |
- ]) |
|
| 122 |
- |
|
| 123 |
- if let cache {
|
|
| 124 |
- setDetailCache(cache) |
|
| 125 |
- } |
|
| 126 |
- |
|
| 127 |
- if cache != nil || |
|
| 128 |
- (currentArchiveWasMissing && recordArchiveData != nil) || |
|
| 129 |
- (previousArchiveWasMissing && previous?.recordArchiveData != nil) {
|
|
| 130 |
- try? context.save() |
|
| 131 |
- } |
|
| 132 |
- |
|
| 133 |
- return TypeCountDetailCacheResolution(cache: cache, diagnostic: diagnostic) |
|
| 134 |
- } |
|
| 135 |
- |
|
| 136 |
- private func detailCacheMetadata(previous: TypeCount?) -> [String: String] {
|
|
| 137 |
- [ |
|
| 138 |
- "type": typeIdentifier, |
|
| 139 |
- "currentCount": "\(count)", |
|
| 140 |
- "previousCount": "\(previous?.count ?? 0)", |
|
| 141 |
- "currentArchive": recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 142 |
- "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil"
|
|
| 143 |
- ] |
|
| 144 |
- } |
|
| 145 |
- |
|
| 146 |
- private func detailCacheDiagnostic( |
|
| 147 |
- previous: TypeCount?, |
|
| 148 |
- baselineSnapshotID: UUID?, |
|
| 149 |
- phase: String |
|
| 150 |
- ) -> String {
|
|
| 151 |
- let baseline = baselineSnapshotID?.uuidString.prefix(6) ?? "none" |
|
| 152 |
- let currentArchive = archiveDebugLabel(for: self) |
|
| 153 |
- let previousArchive = archiveDebugLabel(for: previous) |
|
| 154 |
- return "\(Self.detailCacheResolverVersion) phase=\(phase) base=\(baseline) curr=\(currentArchive) prev=\(previousArchive)" |
|
| 155 |
- } |
|
| 156 |
- |
|
| 157 |
- private func archiveDebugLabel(for typeCount: TypeCount?) -> String {
|
|
| 158 |
- guard let typeCount else { return "none" }
|
|
| 159 |
- if let archive = typeCount.recordArchiveData {
|
|
| 160 |
- let formatSuffix = HealthRecordArchive.isCompact(archive) ? "-c" : "-p" |
|
| 161 |
- return "\(MemoryLog.format(UInt64(archive.count)))\(formatSuffix)" |
|
| 162 |
- } |
|
| 163 |
- if typeCount.count <= 0 {
|
|
| 164 |
- return "empty" |
|
| 165 |
- } |
|
| 166 |
- return "missing" |
|
| 167 |
- } |
|
| 168 |
-} |
|