@@ -140,6 +140,18 @@ final class TypeDistributionBin {
|
||
| 140 | 140 |
// changes chain links. Existing stores are backfilled incrementally with a strict |
| 141 | 141 |
// per-launch TypeCount cap to avoid decoding many large archives in one run. |
| 142 | 142 |
|
| 143 |
+// Interface updated 2026-05-17 — see AGENTS.md |
|
| 144 |
+// Models/HealthSnapshot.contentEquivalentSnapshotID marks snapshots whose TypeCount |
|
| 145 |
+// content is identical to a previous snapshot on the same device. These snapshots are |
|
| 146 |
+// retained as temporal labels but behave as aliases to the representative content |
|
| 147 |
+// snapshot for expensive detail cache/diff work. |
|
| 148 |
+ |
|
| 149 |
+// Interface updated 2026-05-17 — see AGENTS.md |
|
| 150 |
+// Models/TypeCount.contentEquivalentTypeCountID marks individual data types whose |
|
| 151 |
+// content is identical to the previous snapshot's same TypeCount. This allows a |
|
| 152 |
+// snapshot to contain real changes for some metrics while long-stable metrics behave |
|
| 153 |
+// as temporal aliases and skip per-type detail cache/diff work. |
|
| 154 |
+ |
|
| 143 | 155 |
// Models/DetectedAnomaly.swift |
| 144 | 156 |
enum AnomalyType: String, Codable {
|
| 145 | 157 |
case historicalInsertion = "historical_insertion" |
@@ -36,12 +36,28 @@ struct ContentView: View {
|
||
| 36 | 36 |
didAttemptTypeDetailCacheBackfill = true |
| 37 | 37 |
|
| 38 | 38 |
try? await Task.sleep(for: .seconds(2)) |
| 39 |
+ MemoryLog.log("typeDetailCacheBackfill.begin", metadata: [
|
|
| 40 |
+ "storedVersion": "\(typeDetailCacheBackfillVersion)", |
|
| 41 |
+ "targetVersion": "\(AppSettings.currentTypeDetailCacheBackfillVersion)" |
|
| 42 |
+ ]) |
|
| 43 |
+ let memoryPulse = MemoryLog.startPulse("typeDetailCacheBackfill", metadata: [
|
|
| 44 |
+ "maxTypeCounts": "1" |
|
| 45 |
+ ]) |
|
| 46 |
+ defer {
|
|
| 47 |
+ memoryPulse.cancel() |
|
| 48 |
+ MemoryLog.log("typeDetailCacheBackfill.end", metadata: [
|
|
| 49 |
+ "storedVersion": "\(typeDetailCacheBackfillVersion)" |
|
| 50 |
+ ]) |
|
| 51 |
+ } |
|
| 39 | 52 |
|
| 40 | 53 |
do {
|
| 41 | 54 |
let isComplete = try SnapshotLifecycleService.rebuildMissingDetailCaches( |
| 42 | 55 |
context: modelContext, |
| 43 | 56 |
maxTypeCounts: 1 |
| 44 | 57 |
) |
| 58 |
+ MemoryLog.log("typeDetailCacheBackfill.result", metadata: [
|
|
| 59 |
+ "isComplete": "\(isComplete)" |
|
| 60 |
+ ]) |
|
| 45 | 61 |
if isComplete {
|
| 46 | 62 |
typeDetailCacheBackfillVersion = AppSettings.currentTypeDetailCacheBackfillVersion |
| 47 | 63 |
} |
@@ -45,13 +45,58 @@ enum HealthRecordArchive {
|
||
| 45 | 45 |
static func fingerprintSet(from data: Data?) -> Set<String>? {
|
| 46 | 46 |
guard let data else { return [] }
|
| 47 | 47 |
|
| 48 |
+ MemoryLog.log("healthRecordArchive.fingerprintSet.begin", metadata: [
|
|
| 49 |
+ "archive": MemoryLog.format(UInt64(data.count)), |
|
| 50 |
+ "format": data.starts(with: compactMagic) ? "compact" : "plist" |
|
| 51 |
+ ]) |
|
| 52 |
+ if data.starts(with: compactMagic) {
|
|
| 53 |
+ let fingerprints = compactFingerprintSet(from: data) |
|
| 54 |
+ MemoryLog.log("healthRecordArchive.fingerprintSet.end", metadata: [
|
|
| 55 |
+ "archive": MemoryLog.format(UInt64(data.count)), |
|
| 56 |
+ "fingerprints": "\(fingerprints?.count ?? 0)", |
|
| 57 |
+ "success": "\(fingerprints != nil)" |
|
| 58 |
+ ]) |
|
| 59 |
+ return fingerprints |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 48 | 62 |
var fingerprints = Set<String>() |
| 49 | 63 |
let didRead = forEachRecord(in: data) { value in
|
| 50 | 64 |
fingerprints.insert(value.recordFingerprint) |
| 51 | 65 |
} |
| 66 |
+ MemoryLog.log("healthRecordArchive.fingerprintSet.end", metadata: [
|
|
| 67 |
+ "archive": MemoryLog.format(UInt64(data.count)), |
|
| 68 |
+ "fingerprints": "\(fingerprints.count)", |
|
| 69 |
+ "success": "\(didRead)" |
|
| 70 |
+ ]) |
|
| 52 | 71 |
return didRead ? fingerprints : nil |
| 53 | 72 |
} |
| 54 | 73 |
|
| 74 |
+ private static func compactFingerprintSet(from data: Data) -> Set<String>? {
|
|
| 75 |
+ guard data.starts(with: compactMagic) else { return nil }
|
|
| 76 |
+ var reader = CompactReader(data: data, offset: compactMagic.count) |
|
| 77 |
+ guard reader.skipString(), |
|
| 78 |
+ let count = reader.readUInt64() else {
|
|
| 79 |
+ return nil |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 82 |
+ let expectedCount = Int(min(count, UInt64(Int.max))) |
|
| 83 |
+ var fingerprints = Set<String>() |
|
| 84 |
+ fingerprints.reserveCapacity(expectedCount) |
|
| 85 |
+ |
|
| 86 |
+ for _ in 0..<count {
|
|
| 87 |
+ guard reader.skipString(), |
|
| 88 |
+ let recordFingerprint = reader.readString(), |
|
| 89 |
+ reader.skipDouble(), |
|
| 90 |
+ reader.skipDouble(), |
|
| 91 |
+ reader.skipOptionalString() else {
|
|
| 92 |
+ return nil |
|
| 93 |
+ } |
|
| 94 |
+ fingerprints.insert(recordFingerprint) |
|
| 95 |
+ } |
|
| 96 |
+ |
|
| 97 |
+ return fingerprints |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 55 | 100 |
static func makeCompactWriter(typeIdentifier: String, estimatedRecordCount: Int = 0) -> CompactWriter {
|
| 56 | 101 |
CompactWriter(typeIdentifier: typeIdentifier, estimatedRecordCount: estimatedRecordCount) |
| 57 | 102 |
} |
@@ -194,10 +239,10 @@ enum HealthRecordArchive {
|
||
| 194 | 239 |
|
| 195 | 240 |
mutating func readString() -> String? {
|
| 196 | 241 |
guard let length = readUInt32(), |
| 197 |
- let bytes = readBytes(count: Int(length)) else {
|
|
| 242 |
+ let value = readUTF8String(count: Int(length)) else {
|
|
| 198 | 243 |
return nil |
| 199 | 244 |
} |
| 200 |
- return String(data: Data(bytes), encoding: .utf8) |
|
| 245 |
+ return value |
|
| 201 | 246 |
} |
| 202 | 247 |
|
| 203 | 248 |
mutating func readOptionalString() -> String?? {
|
@@ -205,8 +250,8 @@ enum HealthRecordArchive {
|
||
| 205 | 250 |
if length < 0 {
|
| 206 | 251 |
return .some(nil) |
| 207 | 252 |
} |
| 208 |
- guard let bytes = readBytes(count: Int(length)) else { return nil }
|
|
| 209 |
- return .some(String(data: Data(bytes), encoding: .utf8)) |
|
| 253 |
+ guard let value = readUTF8String(count: Int(length)) else { return nil }
|
|
| 254 |
+ return .some(value) |
|
| 210 | 255 |
} |
| 211 | 256 |
|
| 212 | 257 |
mutating func readUInt32() -> UInt32? {
|
@@ -226,19 +271,53 @@ enum HealthRecordArchive {
|
||
| 226 | 271 |
return Double(bitPattern: bitPattern) |
| 227 | 272 |
} |
| 228 | 273 |
|
| 229 |
- private mutating func readBytes(count: Int) -> [UInt8]? {
|
|
| 274 |
+ private mutating func readUTF8String(count: Int) -> String? {
|
|
| 230 | 275 |
guard count >= 0, offset + count <= data.count else { return nil }
|
| 231 |
- let bytes = Array(data[offset..<offset + count]) |
|
| 276 |
+ let startOffset = offset |
|
| 232 | 277 |
offset += count |
| 233 |
- return bytes |
|
| 278 |
+ return data.withUnsafeBytes { rawBuffer in
|
|
| 279 |
+ guard let baseAddress = rawBuffer.baseAddress else {
|
|
| 280 |
+ return count == 0 ? "" : nil |
|
| 281 |
+ } |
|
| 282 |
+ let buffer = UnsafeBufferPointer( |
|
| 283 |
+ start: baseAddress.advanced(by: startOffset).assumingMemoryBound(to: UInt8.self), |
|
| 284 |
+ count: count |
|
| 285 |
+ ) |
|
| 286 |
+ return String(decoding: buffer, as: UTF8.self) |
|
| 287 |
+ } |
|
| 234 | 288 |
} |
| 235 | 289 |
|
| 236 | 290 |
private mutating func readFixedWidthInteger<T: FixedWidthInteger>() -> T? {
|
| 237 | 291 |
let size = MemoryLayout<T>.size |
| 238 |
- guard let bytes = readBytes(count: size) else { return nil }
|
|
| 239 |
- return bytes.enumerated().reduce(T.zero) { result, item in
|
|
| 240 |
- result | (T(item.element) << T(item.offset * 8)) |
|
| 292 |
+ guard size >= 0, offset + size <= data.count else { return nil }
|
|
| 293 |
+ let startOffset = offset |
|
| 294 |
+ offset += size |
|
| 295 |
+ var rawValue: UInt64 = 0 |
|
| 296 |
+ for byteOffset in 0..<size {
|
|
| 297 |
+ rawValue |= UInt64(data[startOffset + byteOffset]) << UInt64(byteOffset * 8) |
|
| 241 | 298 |
} |
| 299 |
+ return T(truncatingIfNeeded: rawValue) |
|
| 300 |
+ } |
|
| 301 |
+ |
|
| 302 |
+ mutating func skipString() -> Bool {
|
|
| 303 |
+ guard let length = readUInt32() else { return false }
|
|
| 304 |
+ return skipBytes(count: Int(length)) |
|
| 305 |
+ } |
|
| 306 |
+ |
|
| 307 |
+ mutating func skipOptionalString() -> Bool {
|
|
| 308 |
+ guard let length = readInt32() else { return false }
|
|
| 309 |
+ if length < 0 { return true }
|
|
| 310 |
+ return skipBytes(count: Int(length)) |
|
| 311 |
+ } |
|
| 312 |
+ |
|
| 313 |
+ mutating func skipDouble() -> Bool {
|
|
| 314 |
+ skipBytes(count: MemoryLayout<UInt64>.size) |
|
| 315 |
+ } |
|
| 316 |
+ |
|
| 317 |
+ private mutating func skipBytes(count: Int) -> Bool {
|
|
| 318 |
+ guard count >= 0, offset + count <= data.count else { return false }
|
|
| 319 |
+ offset += count |
|
| 320 |
+ return true |
|
| 242 | 321 |
} |
| 243 | 322 |
} |
| 244 | 323 |
} |
@@ -9,6 +9,7 @@ import SwiftData |
||
| 9 | 9 |
var deviceID: String = "" |
| 10 | 10 |
var localSequenceNumber: Int = 0 |
| 11 | 11 |
var previousSnapshotID: UUID? |
| 12 |
+ var contentEquivalentSnapshotID: UUID? |
|
| 12 | 13 |
var isChainStart: Bool = false |
| 13 | 14 |
var recoveredDeviceID: Bool = false |
| 14 | 15 |
var snapshotQualityRaw: String = SnapshotQuality.complete.rawValue |
@@ -46,4 +47,12 @@ extension HealthSnapshot {
|
||
| 46 | 47 |
get { (try? JSONDecoder().decode([String].self, from: Data(anomalyFlagsJSON.utf8))) ?? [] }
|
| 47 | 48 |
set { anomalyFlagsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
|
| 48 | 49 |
} |
| 50 |
+ |
|
| 51 |
+ var isContentAlias: Bool {
|
|
| 52 |
+ contentEquivalentSnapshotID != nil |
|
| 53 |
+ } |
|
| 54 |
+ |
|
| 55 |
+ var contentRepresentativeSnapshotID: UUID {
|
|
| 56 |
+ contentEquivalentSnapshotID ?? id |
|
| 57 |
+ } |
|
| 49 | 58 |
} |
@@ -12,6 +12,7 @@ import SwiftData |
||
| 12 | 12 |
var latestDate: Date? |
| 13 | 13 |
var qualityRaw: String = SnapshotQuality.complete.rawValue |
| 14 | 14 |
var isUnsupported: Bool = false |
| 15 |
+ var contentEquivalentTypeCountID: UUID? |
|
| 15 | 16 |
@Attribute(.externalStorage) var recordArchiveData: Data? |
| 16 | 17 |
@Attribute(.externalStorage) var detailCacheData: Data? |
| 17 | 18 |
var snapshot: HealthSnapshot? |
@@ -59,4 +60,12 @@ extension TypeCount {
|
||
| 59 | 60 |
@MainActor func setDetailCache(_ cache: TypeCountDetailCache?) {
|
| 60 | 61 |
detailCacheData = cache.flatMap(TypeCountDetailCacheArchive.encode) |
| 61 | 62 |
} |
| 63 |
+ |
|
| 64 |
+ var isContentAlias: Bool {
|
|
| 65 |
+ contentEquivalentTypeCountID != nil |
|
| 66 |
+ } |
|
| 67 |
+ |
|
| 68 |
+ var contentRepresentativeTypeCountID: UUID {
|
|
| 69 |
+ contentEquivalentTypeCountID ?? id |
|
| 70 |
+ } |
|
| 62 | 71 |
} |
@@ -46,8 +46,40 @@ enum TypeCountDetailCacheBuilder {
|
||
| 46 | 46 |
previous: TypeCount?, |
| 47 | 47 |
baselineSnapshotID: UUID? |
| 48 | 48 |
) -> TypeCountDetailCache? {
|
| 49 |
- guard let currentFingerprints = HealthRecordArchive.fingerprintSet(from: current.recordArchiveData), |
|
| 50 |
- let previousFingerprints = HealthRecordArchive.fingerprintSet(from: previous?.recordArchiveData) else {
|
|
| 49 |
+ let metadata = buildMetadata(current: current, previous: previous) |
|
| 50 |
+ MemoryLog.log("typeCountDetailCache.build.begin", metadata: metadata)
|
|
| 51 |
+ guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
|
|
| 52 |
+ MemoryLog.log("typeCountDetailCache.build.skippedMemoryPressure", metadata: metadata.merging([
|
|
| 53 |
+ "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit) |
|
| 54 |
+ ]) { _, new in new })
|
|
| 55 |
+ return nil |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ guard let currentFingerprints = HealthRecordArchive.fingerprintSet(from: current.recordArchiveData) else {
|
|
| 59 |
+ MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: metadata)
|
|
| 60 |
+ return nil |
|
| 61 |
+ } |
|
| 62 |
+ MemoryLog.log("typeCountDetailCache.build.currentFingerprintsReady", metadata: metadata.merging([
|
|
| 63 |
+ "currentFingerprintCount": "\(currentFingerprints.count)" |
|
| 64 |
+ ]) { _, new in new })
|
|
| 65 |
+ guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
|
|
| 66 |
+ MemoryLog.log("typeCountDetailCache.build.skippedAfterCurrentFingerprints", metadata: metadata.merging([
|
|
| 67 |
+ "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit) |
|
| 68 |
+ ]) { _, new in new })
|
|
| 69 |
+ return nil |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 72 |
+ guard let previousFingerprints = HealthRecordArchive.fingerprintSet(from: previous?.recordArchiveData) else {
|
|
| 73 |
+ MemoryLog.log("typeCountDetailCache.build.previousFingerprintFailed", metadata: metadata)
|
|
| 74 |
+ return nil |
|
| 75 |
+ } |
|
| 76 |
+ MemoryLog.log("typeCountDetailCache.build.previousFingerprintsReady", metadata: metadata.merging([
|
|
| 77 |
+ "previousFingerprintCount": "\(previousFingerprints.count)" |
|
| 78 |
+ ]) { _, new in new })
|
|
| 79 |
+ guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
|
|
| 80 |
+ MemoryLog.log("typeCountDetailCache.build.skippedAfterPreviousFingerprints", metadata: metadata.merging([
|
|
| 81 |
+ "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit) |
|
| 82 |
+ ]) { _, new in new })
|
|
| 51 | 83 |
return nil |
| 52 | 84 |
} |
| 53 | 85 |
|
@@ -62,8 +94,10 @@ enum TypeCountDetailCacheBuilder {
|
||
| 62 | 94 |
accumulator.add(record, as: .disappeared) |
| 63 | 95 |
} |
| 64 | 96 |
}) else {
|
| 97 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
|
|
| 65 | 98 |
return nil |
| 66 | 99 |
} |
| 100 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
|
|
| 67 | 101 |
} |
| 68 | 102 |
|
| 69 | 103 |
if let currentArchive = current.recordArchiveData {
|
@@ -74,11 +108,13 @@ enum TypeCountDetailCacheBuilder {
|
||
| 74 | 108 |
accumulator.add(record, as: .added) |
| 75 | 109 |
} |
| 76 | 110 |
}) else {
|
| 111 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
|
|
| 77 | 112 |
return nil |
| 78 | 113 |
} |
| 114 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
|
|
| 79 | 115 |
} |
| 80 | 116 |
|
| 81 |
- return TypeCountDetailCache( |
|
| 117 |
+ let cache = TypeCountDetailCache( |
|
| 82 | 118 |
baselineSnapshotID: baselineSnapshotID, |
| 83 | 119 |
addedCount: accumulator.addedCount, |
| 84 | 120 |
disappearedCount: accumulator.disappearedCount, |
@@ -88,6 +124,22 @@ enum TypeCountDetailCacheBuilder {
|
||
| 88 | 124 |
earliestRecordDate: accumulator.earliestRecordDate, |
| 89 | 125 |
latestRecordDate: accumulator.latestRecordDate |
| 90 | 126 |
) |
| 127 |
+ MemoryLog.log("typeCountDetailCache.build.finished", metadata: metadata.merging([
|
|
| 128 |
+ "added": "\(cache.addedCount)", |
|
| 129 |
+ "disappeared": "\(cache.disappearedCount)", |
|
| 130 |
+ "dailyBins": "\(cache.dailyChangeBins.count)" |
|
| 131 |
+ ]) { _, new in new })
|
|
| 132 |
+ return cache |
|
| 133 |
+ } |
|
| 134 |
+ |
|
| 135 |
+ private static func buildMetadata(current: TypeCount, previous: TypeCount?) -> [String: String] {
|
|
| 136 |
+ [ |
|
| 137 |
+ "type": current.typeIdentifier, |
|
| 138 |
+ "currentCount": "\(current.count)", |
|
| 139 |
+ "previousCount": "\(previous?.count ?? 0)", |
|
| 140 |
+ "currentArchive": current.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 141 |
+ "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil"
|
|
| 142 |
+ ] |
|
| 91 | 143 |
} |
| 92 | 144 |
} |
| 93 | 145 |
|
@@ -33,6 +33,14 @@ enum DeltaService {
|
||
| 33 | 33 |
delta.checksumBefore = HashService.snapshotChecksum(typeCounts: Array(prevByID.values)) |
| 34 | 34 |
delta.checksumAfter = HashService.snapshotChecksum(typeCounts: Array(currByID.values)) |
| 35 | 35 |
|
| 36 |
+ if current.isContentAlias, |
|
| 37 |
+ current.contentEquivalentSnapshotID == previous.contentRepresentativeSnapshotID {
|
|
| 38 |
+ delta.typeDeltas = [] |
|
| 39 |
+ context.insert(delta) |
|
| 40 |
+ try context.save() |
|
| 41 |
+ return delta |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 36 | 44 |
let allTypeIDs = Set(prevByID.keys).union(currByID.keys) |
| 37 | 45 |
var typeDeltas: [TypeDelta] = [] |
| 38 | 46 |
|
@@ -40,6 +48,12 @@ enum DeltaService {
|
||
| 40 | 48 |
let prev = prevByID[typeID] |
| 41 | 49 |
let curr = currByID[typeID] |
| 42 | 50 |
|
| 51 |
+ if let prev, |
|
| 52 |
+ let curr, |
|
| 53 |
+ curr.contentEquivalentTypeCountID == prev.contentRepresentativeTypeCountID {
|
|
| 54 |
+ continue |
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 43 | 57 |
let effectivePrev = historicalBaselinePreviousTypeCount( |
| 44 | 58 |
typeID: typeID, |
| 45 | 59 |
prev: prev, |
@@ -140,6 +140,11 @@ final class HealthKitService {
|
||
| 140 | 140 |
intendedTypeIDs: active.map { $0.id },
|
| 141 | 141 |
context: context |
| 142 | 142 |
) |
| 143 |
+ markContentEquivalenceIfNeeded( |
|
| 144 |
+ snapshot: snapshot, |
|
| 145 |
+ typeCounts: typeCounts, |
|
| 146 |
+ context: context |
|
| 147 |
+ ) |
|
| 143 | 148 |
precomputeTypeCountDetailCaches( |
| 144 | 149 |
snapshot: snapshot, |
| 145 | 150 |
typeCounts: typeCounts, |
@@ -172,6 +177,11 @@ final class HealthKitService {
|
||
| 172 | 177 |
intendedTypeIDs: typeCounts.map(\.typeIdentifier), |
| 173 | 178 |
context: context |
| 174 | 179 |
) |
| 180 |
+ markContentEquivalenceIfNeeded( |
|
| 181 |
+ snapshot: snapshot, |
|
| 182 |
+ typeCounts: typeCounts, |
|
| 183 |
+ context: context |
|
| 184 |
+ ) |
|
| 175 | 185 |
precomputeTypeCountDetailCaches( |
| 176 | 186 |
snapshot: snapshot, |
| 177 | 187 |
typeCounts: typeCounts, |
@@ -195,6 +205,11 @@ final class HealthKitService {
|
||
| 195 | 205 |
intendedTypeIDs: typeCounts.map(\.typeIdentifier), |
| 196 | 206 |
context: context |
| 197 | 207 |
) |
| 208 |
+ markContentEquivalenceIfNeeded( |
|
| 209 |
+ snapshot: snapshot, |
|
| 210 |
+ typeCounts: typeCounts, |
|
| 211 |
+ context: context |
|
| 212 |
+ ) |
|
| 198 | 213 |
precomputeTypeCountDetailCaches( |
| 199 | 214 |
snapshot: snapshot, |
| 200 | 215 |
typeCounts: typeCounts, |
@@ -314,19 +329,40 @@ final class HealthKitService {
|
||
| 314 | 329 |
typeCounts: [TypeCount], |
| 315 | 330 |
context: ModelContext |
| 316 | 331 |
) {
|
| 332 |
+ MemoryLog.log("healthKit.precomputeDetailCaches.begin", metadata: [
|
|
| 333 |
+ "typeCountCount": "\(typeCounts.count)", |
|
| 334 |
+ "hasPrevious": "\(snapshot.previousSnapshotID != nil)" |
|
| 335 |
+ ]) |
|
| 336 |
+ |
|
| 317 | 337 |
guard let previousID = snapshot.previousSnapshotID, |
| 318 | 338 |
let previous = fetchSnapshot(id: previousID, context: context) else {
|
| 319 | 339 |
for typeCount in typeCounts {
|
| 320 | 340 |
typeCount.setDetailCache(nil) |
| 321 | 341 |
} |
| 342 |
+ MemoryLog.log("healthKit.precomputeDetailCaches.noPrevious", metadata: [
|
|
| 343 |
+ "typeCountCount": "\(typeCounts.count)" |
|
| 344 |
+ ]) |
|
| 322 | 345 |
return |
| 323 | 346 |
} |
| 324 | 347 |
|
| 325 | 348 |
let previousByType = Dictionary( |
| 326 | 349 |
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
| 327 | 350 |
) |
| 351 |
+ var builtCount = 0 |
|
| 352 |
+ var skippedAliasCount = 0 |
|
| 328 | 353 |
|
| 329 | 354 |
for typeCount in typeCounts {
|
| 355 |
+ if typeCount.isContentAlias {
|
|
| 356 |
+ typeCount.setDetailCache(nil) |
|
| 357 |
+ skippedAliasCount += 1 |
|
| 358 |
+ continue |
|
| 359 |
+ } |
|
| 360 |
+ |
|
| 361 |
+ MemoryLog.log("healthKit.detailCache.buildBegin", metadata: detailCacheMetadata(
|
|
| 362 |
+ current: typeCount, |
|
| 363 |
+ previous: previousByType[typeCount.typeIdentifier], |
|
| 364 |
+ source: "snapshotSave" |
|
| 365 |
+ )) |
|
| 330 | 366 |
typeCount.setDetailCache( |
| 331 | 367 |
TypeCountDetailCacheBuilder.build( |
| 332 | 368 |
current: typeCount, |
@@ -334,9 +370,91 @@ final class HealthKitService {
|
||
| 334 | 370 |
baselineSnapshotID: previous.id |
| 335 | 371 |
) |
| 336 | 372 |
) |
| 373 |
+ builtCount += 1 |
|
| 374 |
+ MemoryLog.log("healthKit.detailCache.buildEnd", metadata: detailCacheMetadata(
|
|
| 375 |
+ current: typeCount, |
|
| 376 |
+ previous: previousByType[typeCount.typeIdentifier], |
|
| 377 |
+ source: "snapshotSave" |
|
| 378 |
+ )) |
|
| 379 |
+ } |
|
| 380 |
+ MemoryLog.log("healthKit.precomputeDetailCaches.end", metadata: [
|
|
| 381 |
+ "builtCount": "\(builtCount)", |
|
| 382 |
+ "skippedAliasCount": "\(skippedAliasCount)" |
|
| 383 |
+ ]) |
|
| 384 |
+ } |
|
| 385 |
+ |
|
| 386 |
+ private func markContentEquivalenceIfNeeded( |
|
| 387 |
+ snapshot: HealthSnapshot, |
|
| 388 |
+ typeCounts: [TypeCount], |
|
| 389 |
+ context: ModelContext |
|
| 390 |
+ ) {
|
|
| 391 |
+ snapshot.contentEquivalentSnapshotID = nil |
|
| 392 |
+ for typeCount in typeCounts {
|
|
| 393 |
+ typeCount.contentEquivalentTypeCountID = nil |
|
| 394 |
+ } |
|
| 395 |
+ |
|
| 396 |
+ guard let previousID = snapshot.previousSnapshotID, |
|
| 397 |
+ let previous = fetchSnapshot(id: previousID, context: context), |
|
| 398 |
+ previous.monitoredTypeSetHash == snapshot.monitoredTypeSetHash else {
|
|
| 399 |
+ return |
|
| 400 |
+ } |
|
| 401 |
+ |
|
| 402 |
+ let previousByType = Dictionary( |
|
| 403 |
+ uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 404 |
+ ) |
|
| 405 |
+ |
|
| 406 |
+ for typeCount in typeCounts {
|
|
| 407 |
+ guard let previousType = previousByType[typeCount.typeIdentifier], |
|
| 408 |
+ areTypeCountsContentEquivalent(typeCount, previousType) else {
|
|
| 409 |
+ continue |
|
| 410 |
+ } |
|
| 411 |
+ |
|
| 412 |
+ typeCount.contentEquivalentTypeCountID = previousType.contentRepresentativeTypeCountID |
|
| 413 |
+ } |
|
| 414 |
+ |
|
| 415 |
+ if areTypeCountsContentEquivalent(previous.typeCounts ?? [], typeCounts) {
|
|
| 416 |
+ snapshot.contentEquivalentSnapshotID = previous.contentRepresentativeSnapshotID |
|
| 337 | 417 |
} |
| 338 | 418 |
} |
| 339 | 419 |
|
| 420 |
+ private func areTypeCountsContentEquivalent(_ lhs: [TypeCount], _ rhs: [TypeCount]) -> Bool {
|
|
| 421 |
+ let lhsByType = Dictionary(uniqueKeysWithValues: lhs.map { ($0.typeIdentifier, $0) })
|
|
| 422 |
+ let rhsByType = Dictionary(uniqueKeysWithValues: rhs.map { ($0.typeIdentifier, $0) })
|
|
| 423 |
+ guard lhsByType.keys == rhsByType.keys else { return false }
|
|
| 424 |
+ |
|
| 425 |
+ for typeIdentifier in lhsByType.keys {
|
|
| 426 |
+ guard let lhsType = lhsByType[typeIdentifier], |
|
| 427 |
+ let rhsType = rhsByType[typeIdentifier], |
|
| 428 |
+ lhsType.count == rhsType.count, |
|
| 429 |
+ lhsType.contentHash == rhsType.contentHash, |
|
| 430 |
+ lhsType.quality == rhsType.quality, |
|
| 431 |
+ lhsType.isUnsupported == rhsType.isUnsupported else {
|
|
| 432 |
+ return false |
|
| 433 |
+ } |
|
| 434 |
+ } |
|
| 435 |
+ |
|
| 436 |
+ return true |
|
| 437 |
+ } |
|
| 438 |
+ |
|
| 439 |
+ private func areTypeCountsContentEquivalent(_ lhs: TypeCount, _ rhs: TypeCount) -> Bool {
|
|
| 440 |
+ lhs.count == rhs.count && |
|
| 441 |
+ lhs.contentHash == rhs.contentHash && |
|
| 442 |
+ lhs.quality == rhs.quality && |
|
| 443 |
+ lhs.isUnsupported == rhs.isUnsupported |
|
| 444 |
+ } |
|
| 445 |
+ |
|
| 446 |
+ private func detailCacheMetadata(current: TypeCount, previous: TypeCount?, source: String) -> [String: String] {
|
|
| 447 |
+ [ |
|
| 448 |
+ "source": source, |
|
| 449 |
+ "type": current.typeIdentifier, |
|
| 450 |
+ "currentCount": "\(current.count)", |
|
| 451 |
+ "previousCount": "\(previous?.count ?? 0)", |
|
| 452 |
+ "currentArchive": current.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 453 |
+ "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 454 |
+ "isAlias": "\(current.isContentAlias)" |
|
| 455 |
+ ] |
|
| 456 |
+ } |
|
| 457 |
+ |
|
| 340 | 458 |
private func hasAmbiguousCompleteDisappearance( |
| 341 | 459 |
snapshot: HealthSnapshot, |
| 342 | 460 |
typeCounts: [TypeCount], |
@@ -88,6 +88,10 @@ enum SnapshotLifecycleService {
|
||
| 88 | 88 |
if let nextSnap = try fetchSnapshot(id: outgoing.toSnapshotID, context: context) {
|
| 89 | 89 |
nextSnap.previousSnapshotID = nil |
| 90 | 90 |
nextSnap.isChainStart = true |
| 91 |
+ nextSnap.contentEquivalentSnapshotID = nil |
|
| 92 |
+ for typeCount in nextSnap.typeCounts ?? [] {
|
|
| 93 |
+ typeCount.contentEquivalentTypeCountID = nil |
|
| 94 |
+ } |
|
| 91 | 95 |
refreshDetailCaches(for: nextSnap, baseline: nil) |
| 92 | 96 |
} |
| 93 | 97 |
context.delete(outgoing) |
@@ -114,6 +118,7 @@ enum SnapshotLifecycleService {
|
||
| 114 | 118 |
for td in merged.typeDeltas ?? [] { context.insert(td) }
|
| 115 | 119 |
|
| 116 | 120 |
nextSnap.previousSnapshotID = prevSnap.id |
| 121 |
+ _ = refreshContentEquivalence(for: nextSnap, baseline: prevSnap) |
|
| 117 | 122 |
refreshDetailCaches(for: nextSnap, baseline: prevSnap) |
| 118 | 123 |
context.delete(d1) |
| 119 | 124 |
context.delete(d2) |
@@ -146,15 +151,57 @@ enum SnapshotLifecycleService {
|
||
| 146 | 151 |
maxTypeCounts: Int |
| 147 | 152 |
) throws -> Bool {
|
| 148 | 153 |
guard maxTypeCounts > 0 else { return false }
|
| 154 |
+ MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.begin", metadata: [
|
|
| 155 |
+ "maxTypeCounts": "\(maxTypeCounts)" |
|
| 156 |
+ ]) |
|
| 149 | 157 |
|
| 150 | 158 |
let descriptor = FetchDescriptor<HealthSnapshot>( |
| 151 | 159 |
sortBy: [SortDescriptor(\.timestamp, order: .forward)] |
| 152 | 160 |
) |
| 153 |
- let snapshots = try context.fetch(descriptor) |
|
| 161 |
+ let snapshotIDs = try context.fetch(FetchDescriptor<HealthSnapshot>()).map { $0.id }
|
|
| 162 |
+ MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.snapshotsFetched", metadata: [
|
|
| 163 |
+ "snapshotCount": "\(snapshotIDs.count)" |
|
| 164 |
+ ]) |
|
| 154 | 165 |
var rebuiltCount = 0 |
| 166 |
+ var updatedAliases = 0 |
|
| 167 |
+ |
|
| 168 |
+ // Alias pass: process in batches to avoid memory bloat |
|
| 169 |
+ let aliasBatchSize = 5 |
|
| 170 |
+ for batchStart in stride(from: 0, to: snapshotIDs.count, by: aliasBatchSize) {
|
|
| 171 |
+ let batchEnd = min(batchStart + aliasBatchSize, snapshotIDs.count) |
|
| 172 |
+ for id in snapshotIDs[batchStart..<batchEnd] {
|
|
| 173 |
+ guard let snapshot = try fetchSnapshot(id: id, context: context), |
|
| 174 |
+ let baselineID = snapshot.previousSnapshotID, |
|
| 175 |
+ let baseline = try fetchSnapshot(id: baselineID, context: context) else {
|
|
| 176 |
+ continue |
|
| 177 |
+ } |
|
| 178 |
+ |
|
| 179 |
+ if refreshContentEquivalence(for: snapshot, baseline: baseline) {
|
|
| 180 |
+ updatedAliases += 1 |
|
| 181 |
+ } |
|
| 182 |
+ } |
|
| 183 |
+ // Save batch to flush object cache and allow garbage collection |
|
| 184 |
+ if updatedAliases > 0 {
|
|
| 185 |
+ try context.save() |
|
| 186 |
+ } |
|
| 187 |
+ } |
|
| 188 |
+ |
|
| 189 |
+ MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.aliasPassFinished", metadata: [
|
|
| 190 |
+ "updatedAliases": "\(updatedAliases)" |
|
| 191 |
+ ]) |
|
| 192 |
+ if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
|
|
| 193 |
+ MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedMemoryPressure", metadata: [
|
|
| 194 |
+ "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit), |
|
| 195 |
+ "phase": "afterAliasPass", |
|
| 196 |
+ "updatedAliases": "\(updatedAliases)" |
|
| 197 |
+ ]) |
|
| 198 |
+ return false |
|
| 199 |
+ } |
|
| 155 | 200 |
|
| 156 |
- for snapshot in snapshots {
|
|
| 157 |
- guard let baselineID = snapshot.previousSnapshotID, |
|
| 201 |
+ // Detail cache pass |
|
| 202 |
+ for id in snapshotIDs {
|
|
| 203 |
+ guard let snapshot = try fetchSnapshot(id: id, context: context), |
|
| 204 |
+ let baselineID = snapshot.previousSnapshotID, |
|
| 158 | 205 |
let baseline = try fetchSnapshot(id: baselineID, context: context) else {
|
| 159 | 206 |
continue |
| 160 | 207 |
} |
@@ -163,6 +210,11 @@ enum SnapshotLifecycleService {
|
||
| 163 | 210 |
uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
| 164 | 211 |
) |
| 165 | 212 |
|
| 213 |
+ if snapshot.isContentAlias, |
|
| 214 |
+ snapshot.contentEquivalentSnapshotID == baseline.contentRepresentativeSnapshotID {
|
|
| 215 |
+ continue |
|
| 216 |
+ } |
|
| 217 |
+ |
|
| 166 | 218 |
for typeCount in snapshot.typeCounts ?? [] {
|
| 167 | 219 |
guard shouldBackfillDetailCache( |
| 168 | 220 |
typeCount: typeCount, |
@@ -172,6 +224,24 @@ enum SnapshotLifecycleService {
|
||
| 172 | 224 |
continue |
| 173 | 225 |
} |
| 174 | 226 |
|
| 227 |
+ if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
|
|
| 228 |
+ MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedMemoryPressure", metadata: [
|
|
| 229 |
+ "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit), |
|
| 230 |
+ "phase": "beforeDetailCacheBuild", |
|
| 231 |
+ "rebuiltCount": "\(rebuiltCount)", |
|
| 232 |
+ "updatedAliases": "\(updatedAliases)" |
|
| 233 |
+ ]) |
|
| 234 |
+ if rebuiltCount > 0 || updatedAliases > 0 {
|
|
| 235 |
+ try context.save() |
|
| 236 |
+ } |
|
| 237 |
+ return false |
|
| 238 |
+ } |
|
| 239 |
+ |
|
| 240 |
+ MemoryLog.log("snapshotLifecycle.detailCache.buildBegin", metadata: detailCacheMetadata(
|
|
| 241 |
+ current: typeCount, |
|
| 242 |
+ previous: baselineByType[typeCount.typeIdentifier], |
|
| 243 |
+ source: "backfill" |
|
| 244 |
+ )) |
|
| 175 | 245 |
typeCount.setDetailCache( |
| 176 | 246 |
TypeCountDetailCacheBuilder.build( |
| 177 | 247 |
current: typeCount, |
@@ -179,18 +249,31 @@ enum SnapshotLifecycleService {
|
||
| 179 | 249 |
baselineSnapshotID: baseline.id |
| 180 | 250 |
) |
| 181 | 251 |
) |
| 252 |
+ MemoryLog.log("snapshotLifecycle.detailCache.buildEnd", metadata: detailCacheMetadata(
|
|
| 253 |
+ current: typeCount, |
|
| 254 |
+ previous: baselineByType[typeCount.typeIdentifier], |
|
| 255 |
+ source: "backfill" |
|
| 256 |
+ )) |
|
| 182 | 257 |
rebuiltCount += 1 |
| 183 | 258 |
|
| 184 | 259 |
if rebuiltCount >= maxTypeCounts {
|
| 260 |
+ MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedAtLimit", metadata: [
|
|
| 261 |
+ "rebuiltCount": "\(rebuiltCount)", |
|
| 262 |
+ "updatedAliases": "\(updatedAliases)" |
|
| 263 |
+ ]) |
|
| 185 | 264 |
try context.save() |
| 186 | 265 |
return false |
| 187 | 266 |
} |
| 188 | 267 |
} |
| 189 | 268 |
} |
| 190 | 269 |
|
| 191 |
- if rebuiltCount > 0 {
|
|
| 270 |
+ if rebuiltCount > 0 || updatedAliases > 0 {
|
|
| 192 | 271 |
try context.save() |
| 193 | 272 |
} |
| 273 |
+ MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.complete", metadata: [
|
|
| 274 |
+ "rebuiltCount": "\(rebuiltCount)", |
|
| 275 |
+ "updatedAliases": "\(updatedAliases)" |
|
| 276 |
+ ]) |
|
| 194 | 277 |
return true |
| 195 | 278 |
} |
| 196 | 279 |
|
@@ -220,6 +303,11 @@ enum SnapshotLifecycleService {
|
||
| 220 | 303 |
) |
| 221 | 304 |
|
| 222 | 305 |
for typeCount in snapshot.typeCounts ?? [] {
|
| 306 |
+ if typeCount.isContentAlias {
|
|
| 307 |
+ typeCount.setDetailCache(nil) |
|
| 308 |
+ continue |
|
| 309 |
+ } |
|
| 310 |
+ |
|
| 223 | 311 |
typeCount.setDetailCache( |
| 224 | 312 |
TypeCountDetailCacheBuilder.build( |
| 225 | 313 |
current: typeCount, |
@@ -230,11 +318,104 @@ enum SnapshotLifecycleService {
|
||
| 230 | 318 |
} |
| 231 | 319 |
} |
| 232 | 320 |
|
| 321 |
+ @discardableResult |
|
| 322 |
+ private static func refreshContentEquivalence(for snapshot: HealthSnapshot, baseline: HealthSnapshot) -> Bool {
|
|
| 323 |
+ let previousSnapshotAliasID = snapshot.contentEquivalentSnapshotID |
|
| 324 |
+ let previousTypeAliasIDs = Dictionary( |
|
| 325 |
+ uniqueKeysWithValues: (snapshot.typeCounts ?? []).map { ($0.id, $0.contentEquivalentTypeCountID) }
|
|
| 326 |
+ ) |
|
| 327 |
+ |
|
| 328 |
+ snapshot.contentEquivalentSnapshotID = nil |
|
| 329 |
+ for typeCount in snapshot.typeCounts ?? [] {
|
|
| 330 |
+ typeCount.contentEquivalentTypeCountID = nil |
|
| 331 |
+ } |
|
| 332 |
+ |
|
| 333 |
+ guard snapshot.monitoredTypeSetHash == baseline.monitoredTypeSetHash else {
|
|
| 334 |
+ return contentEquivalenceDidChange( |
|
| 335 |
+ snapshot: snapshot, |
|
| 336 |
+ previousSnapshotAliasID: previousSnapshotAliasID, |
|
| 337 |
+ previousTypeAliasIDs: previousTypeAliasIDs |
|
| 338 |
+ ) |
|
| 339 |
+ } |
|
| 340 |
+ |
|
| 341 |
+ let baselineByType = Dictionary( |
|
| 342 |
+ uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 343 |
+ ) |
|
| 344 |
+ |
|
| 345 |
+ for typeCount in snapshot.typeCounts ?? [] {
|
|
| 346 |
+ guard let baselineType = baselineByType[typeCount.typeIdentifier], |
|
| 347 |
+ areTypeCountsContentEquivalent(typeCount, baselineType) else {
|
|
| 348 |
+ continue |
|
| 349 |
+ } |
|
| 350 |
+ |
|
| 351 |
+ typeCount.contentEquivalentTypeCountID = baselineType.contentRepresentativeTypeCountID |
|
| 352 |
+ typeCount.setDetailCache(nil) |
|
| 353 |
+ } |
|
| 354 |
+ |
|
| 355 |
+ if areTypeCountsContentEquivalent(snapshot.typeCounts ?? [], baseline.typeCounts ?? []) {
|
|
| 356 |
+ snapshot.contentEquivalentSnapshotID = baseline.contentRepresentativeSnapshotID |
|
| 357 |
+ } |
|
| 358 |
+ |
|
| 359 |
+ return contentEquivalenceDidChange( |
|
| 360 |
+ snapshot: snapshot, |
|
| 361 |
+ previousSnapshotAliasID: previousSnapshotAliasID, |
|
| 362 |
+ previousTypeAliasIDs: previousTypeAliasIDs |
|
| 363 |
+ ) |
|
| 364 |
+ } |
|
| 365 |
+ |
|
| 366 |
+ private static func contentEquivalenceDidChange( |
|
| 367 |
+ snapshot: HealthSnapshot, |
|
| 368 |
+ previousSnapshotAliasID: UUID?, |
|
| 369 |
+ previousTypeAliasIDs: [UUID: UUID?] |
|
| 370 |
+ ) -> Bool {
|
|
| 371 |
+ if snapshot.contentEquivalentSnapshotID != previousSnapshotAliasID {
|
|
| 372 |
+ return true |
|
| 373 |
+ } |
|
| 374 |
+ |
|
| 375 |
+ for typeCount in snapshot.typeCounts ?? [] {
|
|
| 376 |
+ if typeCount.contentEquivalentTypeCountID != (previousTypeAliasIDs[typeCount.id] ?? nil) {
|
|
| 377 |
+ return true |
|
| 378 |
+ } |
|
| 379 |
+ } |
|
| 380 |
+ |
|
| 381 |
+ return false |
|
| 382 |
+ } |
|
| 383 |
+ |
|
| 384 |
+ private static func areTypeCountsContentEquivalent(_ lhs: [TypeCount], _ rhs: [TypeCount]) -> Bool {
|
|
| 385 |
+ let lhsByType = Dictionary(uniqueKeysWithValues: lhs.map { ($0.typeIdentifier, $0) })
|
|
| 386 |
+ let rhsByType = Dictionary(uniqueKeysWithValues: rhs.map { ($0.typeIdentifier, $0) })
|
|
| 387 |
+ guard lhsByType.keys == rhsByType.keys else { return false }
|
|
| 388 |
+ |
|
| 389 |
+ for typeIdentifier in lhsByType.keys {
|
|
| 390 |
+ guard let lhsType = lhsByType[typeIdentifier], |
|
| 391 |
+ let rhsType = rhsByType[typeIdentifier], |
|
| 392 |
+ lhsType.count == rhsType.count, |
|
| 393 |
+ lhsType.contentHash == rhsType.contentHash, |
|
| 394 |
+ lhsType.quality == rhsType.quality, |
|
| 395 |
+ lhsType.isUnsupported == rhsType.isUnsupported else {
|
|
| 396 |
+ return false |
|
| 397 |
+ } |
|
| 398 |
+ } |
|
| 399 |
+ |
|
| 400 |
+ return true |
|
| 401 |
+ } |
|
| 402 |
+ |
|
| 403 |
+ private static func areTypeCountsContentEquivalent(_ lhs: TypeCount, _ rhs: TypeCount) -> Bool {
|
|
| 404 |
+ lhs.count == rhs.count && |
|
| 405 |
+ lhs.contentHash == rhs.contentHash && |
|
| 406 |
+ lhs.quality == rhs.quality && |
|
| 407 |
+ lhs.isUnsupported == rhs.isUnsupported |
|
| 408 |
+ } |
|
| 409 |
+ |
|
| 233 | 410 |
@MainActor private static func shouldBackfillDetailCache( |
| 234 | 411 |
typeCount: TypeCount, |
| 235 | 412 |
baseline: TypeCount?, |
| 236 | 413 |
baselineID: UUID |
| 237 | 414 |
) -> Bool {
|
| 415 |
+ if typeCount.isContentAlias {
|
|
| 416 |
+ return false |
|
| 417 |
+ } |
|
| 418 |
+ |
|
| 238 | 419 |
if typeCount.detailCache?.matchesBaseline(baselineID) == true {
|
| 239 | 420 |
return false |
| 240 | 421 |
} |
@@ -251,6 +432,18 @@ enum SnapshotLifecycleService {
|
||
| 251 | 432 |
typeCount.count <= 0 || typeCount.recordArchiveData != nil |
| 252 | 433 |
} |
| 253 | 434 |
|
| 435 |
+ private static func detailCacheMetadata(current: TypeCount, previous: TypeCount?, source: String) -> [String: String] {
|
|
| 436 |
+ [ |
|
| 437 |
+ "source": source, |
|
| 438 |
+ "type": current.typeIdentifier, |
|
| 439 |
+ "currentCount": "\(current.count)", |
|
| 440 |
+ "previousCount": "\(previous?.count ?? 0)", |
|
| 441 |
+ "currentArchive": current.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 442 |
+ "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 443 |
+ "isAlias": "\(current.isContentAlias)" |
|
| 444 |
+ ] |
|
| 445 |
+ } |
|
| 446 |
+ |
|
| 254 | 447 |
private static func buildSummary(snapshot: HealthSnapshot, incoming: SnapshotDelta?, outgoing: SnapshotDelta?) -> String {
|
| 255 | 448 |
let position: String |
| 256 | 449 |
if incoming == nil && outgoing == nil { position = "standalone" }
|
@@ -7,7 +7,7 @@ final class AppSettings {
|
||
| 7 | 7 |
private static let selectedDeviceIDsKey = "hp_selectedDeviceIDs" |
| 8 | 8 |
static let adaptiveTimeoutsEnabledKey = "hp_adaptiveTimeoutsEnabled" |
| 9 | 9 |
static let typeDetailCacheBackfillVersionKey = "hp_typeDetailCacheBackfillVersion" |
| 10 |
- static let currentTypeDetailCacheBackfillVersion = 1 |
|
| 10 |
+ static let currentTypeDetailCacheBackfillVersion = 2 |
|
| 11 | 11 |
|
| 12 | 12 |
var selectedTypeIDs: Set<String> {
|
| 13 | 13 |
didSet { persistTypes() }
|
@@ -0,0 +1,97 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+import MachO |
|
| 3 |
+ |
|
| 4 |
+struct MemorySample: Sendable {
|
|
| 5 |
+ let residentBytes: UInt64 |
|
| 6 |
+ let physicalFootprintBytes: UInt64 |
|
| 7 |
+ let virtualBytes: UInt64 |
|
| 8 |
+} |
|
| 9 |
+ |
|
| 10 |
+enum MemoryLog {
|
|
| 11 |
+ static let detailCacheBuildFootprintLimit: UInt64 = 1_500 * 1_024 * 1_024 |
|
| 12 |
+ |
|
| 13 |
+ static func log(_ event: String, metadata: [String: String] = [:]) {
|
|
| 14 |
+ let sample = currentSample() |
|
| 15 |
+ let metadataText = metadata |
|
| 16 |
+ .sorted { $0.key < $1.key }
|
|
| 17 |
+ .map { "\($0.key)=\($0.value)" }
|
|
| 18 |
+ .joined(separator: " ") |
|
| 19 |
+ let memoryText: String |
|
| 20 |
+ if let sample {
|
|
| 21 |
+ memoryText = "rss=\(format(sample.residentBytes)) footprint=\(format(sample.physicalFootprintBytes)) virtual=\(format(sample.virtualBytes))" |
|
| 22 |
+ } else {
|
|
| 23 |
+ memoryText = "memory=unavailable" |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ let line = "[HealthProbeMemory] \(event) \(memoryText)\(metadataText.isEmpty ? "" : " \(metadataText)")" |
|
| 27 |
+ print(line) |
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 30 |
+ static func startPulse(_ event: String, metadata: [String: String] = [:], intervalSeconds: UInt64 = 1) -> Task<Void, Never> {
|
|
| 31 |
+ Task.detached(priority: .background) {
|
|
| 32 |
+ var previousSample = currentSample() |
|
| 33 |
+ var previousTime = Date() |
|
| 34 |
+ log("\(event).pulse.start", metadata: metadata)
|
|
| 35 |
+ |
|
| 36 |
+ while !Task.isCancelled {
|
|
| 37 |
+ try? await Task.sleep(for: .seconds(intervalSeconds)) |
|
| 38 |
+ guard !Task.isCancelled else { break }
|
|
| 39 |
+ |
|
| 40 |
+ let current = currentSample() |
|
| 41 |
+ let now = Date() |
|
| 42 |
+ var pulseMetadata = metadata |
|
| 43 |
+ |
|
| 44 |
+ if let previousSample, |
|
| 45 |
+ let current {
|
|
| 46 |
+ let elapsed = max(now.timeIntervalSince(previousTime), 0.001) |
|
| 47 |
+ let footprintDelta = Int64(current.physicalFootprintBytes) - Int64(previousSample.physicalFootprintBytes) |
|
| 48 |
+ let bytesPerSecond = Double(footprintDelta) / elapsed |
|
| 49 |
+ pulseMetadata["footprint_delta"] = signedFormat(footprintDelta) |
|
| 50 |
+ pulseMetadata["footprint_rate"] = "\(signedFormat(Int64(bytesPerSecond)))/s" |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 53 |
+ log("\(event).pulse", metadata: pulseMetadata)
|
|
| 54 |
+ previousSample = current |
|
| 55 |
+ previousTime = now |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ log("\(event).pulse.stop", metadata: metadata)
|
|
| 59 |
+ } |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ static func format(_ bytes: UInt64) -> String {
|
|
| 63 |
+ ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .memory) |
|
| 64 |
+ } |
|
| 65 |
+ |
|
| 66 |
+ static func isFootprintAtOrAbove(_ bytes: UInt64) -> Bool {
|
|
| 67 |
+ guard let currentSample = currentSample() else { return false }
|
|
| 68 |
+ return currentSample.physicalFootprintBytes >= bytes |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ private static func signedFormat(_ bytes: Int64) -> String {
|
|
| 72 |
+ if bytes >= 0 {
|
|
| 73 |
+ return "+\(format(UInt64(bytes)))" |
|
| 74 |
+ } |
|
| 75 |
+ return "-\(format(UInt64(-bytes)))" |
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ private static func currentSample() -> MemorySample? {
|
|
| 79 |
+ var info = task_vm_info_data_t() |
|
| 80 |
+ var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size) |
|
| 81 |
+ let result = withUnsafeMutablePointer(to: &info) { pointer in
|
|
| 82 |
+ pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { reboundPointer in
|
|
| 83 |
+ task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), reboundPointer, &count) |
|
| 84 |
+ } |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 87 |
+ guard result == KERN_SUCCESS else {
|
|
| 88 |
+ return nil |
|
| 89 |
+ } |
|
| 90 |
+ |
|
| 91 |
+ return MemorySample( |
|
| 92 |
+ residentBytes: info.resident_size, |
|
| 93 |
+ physicalFootprintBytes: info.phys_footprint, |
|
| 94 |
+ virtualBytes: info.virtual_size |
|
| 95 |
+ ) |
|
| 96 |
+ } |
|
| 97 |
+} |
|
@@ -41,12 +41,18 @@ struct RecordChangeEvolutionChart: View {
|
||
| 41 | 41 |
|
| 42 | 42 |
private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> (added: Int, disappeared: Int) {
|
| 43 | 43 |
guard let previous = previous else { return (0, 0) }
|
| 44 |
- guard let cache = current.typeCounts? |
|
| 45 |
- .first(where: { $0.typeIdentifier == typeIdentifier })?
|
|
| 46 |
- .detailCache, |
|
| 47 |
- cache.matchesBaseline(previous.id) else {
|
|
| 44 |
+ guard let currentType = current.typeCounts? |
|
| 45 |
+ .first(where: { $0.typeIdentifier == typeIdentifier }) else {
|
|
| 48 | 46 |
return (0, 0) |
| 49 | 47 |
} |
| 48 |
+ |
|
| 49 |
+ if let previousType = previous.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
|
|
| 50 |
+ currentType.contentEquivalentTypeCountID == previousType.contentRepresentativeTypeCountID {
|
|
| 51 |
+ return (0, 0) |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ guard let cache = currentType.detailCache, |
|
| 55 |
+ cache.matchesBaseline(previous.id) else { return (0, 0) }
|
|
| 50 | 56 |
return (cache.addedCount, cache.disappearedCount) |
| 51 | 57 |
} |
| 52 | 58 |
|
@@ -42,6 +42,12 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 42 | 42 |
previousSnapshot.flatMap(typeCount(in:)) |
| 43 | 43 |
} |
| 44 | 44 |
|
| 45 |
+ private var isCurrentTypeContentAliasToPrevious: Bool {
|
|
| 46 |
+ guard let currentTypeCount, |
|
| 47 |
+ let previousTypeCount else { return false }
|
|
| 48 |
+ return currentTypeCount.contentEquivalentTypeCountID == previousTypeCount.contentRepresentativeTypeCountID |
|
| 49 |
+ } |
|
| 50 |
+ |
|
| 45 | 51 |
private var diffTaskID: String {
|
| 46 | 52 |
[ |
| 47 | 53 |
currentSnapshot.id.uuidString, |
@@ -278,7 +284,7 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 278 | 284 |
|
| 279 | 285 |
@ViewBuilder |
| 280 | 286 |
private var temporalDistributionSection: some View {
|
| 281 |
- if previousSnapshot != nil, currentTypeCount != nil {
|
|
| 287 |
+ if previousSnapshot != nil, currentTypeCount != nil, !isCurrentTypeContentAliasToPrevious {
|
|
| 282 | 288 |
Button {
|
| 283 | 289 |
showTemporalDistribution = true |
| 284 | 290 |
} label: {
|
@@ -333,6 +339,11 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 333 | 339 |
return |
| 334 | 340 |
} |
| 335 | 341 |
|
| 342 |
+ if isCurrentTypeContentAliasToPrevious {
|
|
| 343 |
+ diffState = .loaded(.empty) |
|
| 344 |
+ return |
|
| 345 |
+ } |
|
| 346 |
+ |
|
| 336 | 347 |
let currentCount = currentTypeCount?.count ?? 0 |
| 337 | 348 |
let previousCount = previousTypeCount?.count ?? 0 |
| 338 | 349 |
|
@@ -351,6 +362,10 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 351 | 362 |
|
| 352 | 363 |
@MainActor |
| 353 | 364 |
private func currentDetailCache() -> TypeCountDetailCache? {
|
| 365 |
+ if isCurrentTypeContentAliasToPrevious {
|
|
| 366 |
+ return nil |
|
| 367 |
+ } |
|
| 368 |
+ |
|
| 354 | 369 |
if let cache = currentTypeCount?.detailCache, |
| 355 | 370 |
cache.matchesBaseline(previousSnapshot?.id) {
|
| 356 | 371 |
return cache |
@@ -361,11 +376,25 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 361 | 376 |
return nil |
| 362 | 377 |
} |
| 363 | 378 |
|
| 379 |
+ MemoryLog.log("dataTypeDetail.detailCache.buildBegin", metadata: [
|
|
| 380 |
+ "source": "detailView", |
|
| 381 |
+ "type": currentTypeCount.typeIdentifier, |
|
| 382 |
+ "currentCount": "\(currentTypeCount.count)", |
|
| 383 |
+ "previousCount": "\(previousTypeCount?.count ?? 0)", |
|
| 384 |
+ "currentArchive": currentTypeCount.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 385 |
+ "previousArchive": previousTypeCount?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 386 |
+ "isAlias": "\(currentTypeCount.isContentAlias)" |
|
| 387 |
+ ]) |
|
| 364 | 388 |
let cache = TypeCountDetailCacheBuilder.build( |
| 365 | 389 |
current: currentTypeCount, |
| 366 | 390 |
previous: previousTypeCount, |
| 367 | 391 |
baselineSnapshotID: previousSnapshot.id |
| 368 | 392 |
) |
| 393 |
+ MemoryLog.log("dataTypeDetail.detailCache.buildEnd", metadata: [
|
|
| 394 |
+ "source": "detailView", |
|
| 395 |
+ "type": currentTypeCount.typeIdentifier, |
|
| 396 |
+ "cacheBuilt": "\(cache != nil)" |
|
| 397 |
+ ]) |
|
| 369 | 398 |
currentTypeCount.setDetailCache(cache) |
| 370 | 399 |
if cache != nil {
|
| 371 | 400 |
try? modelContext.save() |