Files
Termind/Frameworks/TermindPersistence/MetricsSyncStore.swift
T

363 lines
15 KiB
Swift

import Foundation
import SwiftData
import TermindApplication
import TermindDomain
public nonisolated struct MetricsRemoteMerge: Sendable {
public var accepted: [MetricsEnvelope] = []
public var needsUpload: Set<MetricsRecordKey> = []
}
public nonisolated struct MetricsOutgoingEntry: Sendable {
public var envelope: MetricsEnvelope
public var systemFieldsData: Data?
public init(envelope: MetricsEnvelope, systemFieldsData: Data? = nil) {
self.envelope = envelope
self.systemFieldsData = systemFieldsData
}
}
@MainActor
public final class MetricsSyncJournal {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func state() throws -> PersistentMetricsSyncState {
var descriptor = FetchDescriptor<PersistentMetricsSyncState>()
descriptor.fetchLimit = 1
if let existing = try context.fetch(descriptor).first { return existing }
let state = PersistentMetricsSyncState()
context.insert(state)
return state
}
@discardableResult
public func bootstrapIfNeeded(_ snapshot: MetricsSyncSnapshot) throws -> MetricsMutationSet {
let state = try state()
guard !state.bootstrapCompleted else { return .empty }
var entries = try entriesByName()
var changed = Set<MetricsRecordKey>()
for payload in snapshot.recordPayloads.values {
let key = payload.key
guard entries[key.recordName] == nil else { continue }
let envelope = MetricsEnvelope(
key: key, version: .baseline(writerID: state.installationID), payload: payload)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
state.bootstrapCompleted = true
return MetricsMutationSet(keys: changed)
}
public func recordLocalChanges(
from oldSnapshot: MetricsSyncSnapshot, to newSnapshot: MetricsSyncSnapshot,
now: Date = .now
) throws -> MetricsMutationSet {
let state = try state()
let old = oldSnapshot.recordPayloads
let new = newSnapshot.recordPayloads
var entries = try entriesByName()
var changed = Set<MetricsRecordKey>()
for (key, payload) in new where old[key] != payload {
let row = entries[key.recordName]
let envelope = MetricsEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
for key in old.keys where new[key] == nil {
let row = entries[key.recordName]
let envelope = MetricsEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
isDeleted: true)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
return MetricsMutationSet(keys: changed)
}
public func pendingEnvelopes() throws -> [MetricsEnvelope] {
try context.fetch(
FetchDescriptor<PersistentMetricsSyncEntry>(predicate: #Predicate { $0.needsUpload })
).compactMap(\.envelope)
}
public func uploadablePendingRecordNames(in scope: CloudSyncScope) throws -> [String] {
let blocked = try CloudSyncSafetyStore(context: context, scope: scope).blockedRecordNames()
return try pendingEnvelopes().map(\.key.recordName).filter { !blocked.contains($0) }
}
public func importLegacyQuarantine(into scope: CloudSyncScope) throws {
let safety = CloudSyncSafetyStore(context: context, scope: scope)
for row in try context.fetch(FetchDescriptor<PersistentMetricsSyncQuarantine>()) {
if try !safety.isUploadBlocked(recordName: row.recordName) {
try safety.quarantine(recordName: row.recordName, payloadData: row.payloadData, reason: row.reason)
}
context.delete(row)
}
}
public func outgoingEntry(for key: MetricsRecordKey, scope: CloudSyncScope? = nil) throws -> MetricsOutgoingEntry? {
if let scope,
try CloudSyncSafetyStore(context: context, scope: scope).isUploadBlocked(recordName: key.recordName)
{
return nil
}
guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else { return nil }
return MetricsOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData)
}
public func envelope(for key: MetricsRecordKey) throws -> MetricsEnvelope? {
try entry(recordName: key.recordName)?.envelope
}
public func mergeDecision(for remote: [MetricsEnvelope]) throws -> MetricsRemoteMerge {
var result = MetricsRemoteMerge()
let entries = try entriesByName()
var candidates: [MetricsRecordKey: MetricsEnvelope] = [:]
for envelope in remote {
guard envelope.isValid else {
throw MetricsSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
if let previous = candidates[envelope.key] {
if envelope.version < previous.version { continue }
let previousData = try MetricsSyncCodec.encode(previous)
let incomingData = try MetricsSyncCodec.encode(envelope)
if envelope.version == previous.version,
!previousData.lexicographicallyPrecedes(incomingData)
{
continue
}
}
candidates[envelope.key] = envelope
}
for envelope in candidates.values {
guard envelope.formatVersion <= MetricsEnvelope.currentFormatVersion else {
throw MetricsSyncPersistenceError.unsupportedFormat(envelope.formatVersion)
}
guard envelope.isValid else {
throw MetricsSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
guard let local = entries[envelope.key.recordName]?.envelope else {
result.accepted.append(envelope)
continue
}
if local.version.modifiedAt == VaultVersion.baselineDate, !local.isDeleted, local != envelope {
result.accepted.append(envelope)
} else if local.version < envelope.version {
result.accepted.append(envelope)
} else if envelope.version < local.version {
result.needsUpload.insert(local.key)
} else if local != envelope {
let localData = try MetricsSyncCodec.encode(local)
let remoteData = try MetricsSyncCodec.encode(envelope)
if localData.lexicographicallyPrecedes(remoteData) {
result.accepted.append(envelope)
} else {
result.needsUpload.insert(local.key)
}
}
}
return result
}
public func acceptRemote(_ envelopes: [MetricsEnvelope]) throws {
var entries = try entriesByName()
for envelope in envelopes { try store(envelope, needsUpload: false, in: &entries) }
}
public func markNeedsUpload(_ keys: Set<MetricsRecordKey>) throws {
let entries = try entriesByName()
for key in keys { entries[key.recordName]?.needsUpload = true }
}
public func markUploaded(_ key: MetricsRecordKey, version: VaultVersion) throws {
guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else { return }
row.needsUpload = false
}
public func updateSystemFields(_ data: Data?, for key: MetricsRecordKey) throws {
try entry(recordName: key.recordName)?.systemFieldsData = data
}
public func quarantine(recordName: String, payloadData: Data, reason: String) throws {
var descriptor = FetchDescriptor<PersistentMetricsSyncQuarantine>(
predicate: #Predicate { $0.recordName == recordName })
descriptor.fetchLimit = 1
if let row = try context.fetch(descriptor).first {
row.payloadData = payloadData
row.reason = reason
row.receivedAt = .now
} else {
context.insert(
PersistentMetricsSyncQuarantine(
recordName: recordName, payloadData: payloadData, reason: reason))
}
}
public func clearQuarantine(recordName: String) throws {
try context.delete(
model: PersistentMetricsSyncQuarantine.self,
where: #Predicate { $0.recordName == recordName })
}
public func firstQuarantineReason() throws -> String? {
var descriptor = FetchDescriptor<PersistentMetricsSyncQuarantine>(
sortBy: [SortDescriptor(\.receivedAt, order: .reverse)])
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first?.reason
}
public func markAllLiveRecordsNeedsUploadForNewAccount() throws {
for row in try context.fetch(FetchDescriptor<PersistentMetricsSyncEntry>()) {
guard let envelope = row.envelope else { continue }
if envelope.isDeleted {
context.delete(row)
} else {
row.needsUpload = true
row.systemFieldsData = nil
}
}
let state = try state()
state.engineStateData = nil
state.lastSuccessfulSync = nil
}
public func markAllEntriesNeedsUploadAfterZoneReset() throws {
for row in try context.fetch(FetchDescriptor<PersistentMetricsSyncEntry>()) {
row.needsUpload = true
row.systemFieldsData = nil
}
let state = try state()
state.engineStateData = nil
state.lastSuccessfulSync = nil
}
public func markAllEntriesNeedsUploadAfterEnvironmentChange() throws {
try markAllEntriesNeedsUploadAfterZoneReset()
}
private func entry(recordName: String) throws -> PersistentMetricsSyncEntry? {
var descriptor = FetchDescriptor<PersistentMetricsSyncEntry>(
predicate: #Predicate { $0.recordName == recordName })
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private func store(
_ envelope: MetricsEnvelope, needsUpload: Bool,
in entries: inout [String: PersistentMetricsSyncEntry]
) throws {
if let row = entries[envelope.key.recordName] {
try row.apply(envelope, needsUpload: needsUpload)
} else {
let row = try PersistentMetricsSyncEntry(envelope: envelope, needsUpload: needsUpload)
context.insert(row)
entries[envelope.key.recordName] = row
}
}
private func entriesByName() throws -> [String: PersistentMetricsSyncEntry] {
Dictionary(
try context.fetch(FetchDescriptor<PersistentMetricsSyncEntry>()).map { ($0.recordName, $0) },
uniquingKeysWith: { first, duplicate in
if let firstVersion = first.envelope?.version,
let duplicateVersion = duplicate.envelope?.version,
firstVersion < duplicateVersion
{
return duplicate
}
return first
})
}
}
@MainActor
public final class MetricsSyncPersistence: MetricsConfigurationStoring {
public let context: ModelContext
public let journal: MetricsSyncJournal
private let defaults: UserDefaults
public var didRecordChanges: ((MetricsMutationSet) -> Void)?
public init(context: ModelContext, defaults: UserDefaults = .standard) {
self.context = context
self.defaults = defaults
journal = MetricsSyncJournal(context: context)
}
public func prepare() throws {
var mutations = MetricsMutationSet.empty
try context.commit {
let state = try journal.state()
guard !state.bootstrapCompleted else { return }
mutations = try journal.bootstrapIfNeeded(MetricsSyncSnapshot(selections: legacySelections()))
}
didRecordChanges?(mutations)
}
public func load() throws -> [MetricsSelection] { try materialize().selections }
public func save(_ selections: [MetricsSelection]) throws {
var mutations = MetricsMutationSet.empty
try context.commit {
let previous = try materialize()
try journal.bootstrapIfNeeded(previous)
mutations = try journal.recordLocalChanges(
from: previous, to: MetricsSyncSnapshot(selections: Self.deduplicated(selections)))
}
didRecordChanges?(mutations)
}
public func applyRemote(_ envelopes: [MetricsEnvelope], beforeSave: () throws -> Void = {}) throws
-> (snapshot: MetricsSyncSnapshot, acceptedKeys: Set<MetricsRecordKey>, needsUpload: Set<MetricsRecordKey>)
{
var merge = MetricsRemoteMerge()
var snapshot = try materialize()
try context.commit(beforeSave: beforeSave) {
merge = try journal.mergeDecision(for: envelopes)
guard !merge.accepted.isEmpty else {
try journal.markNeedsUpload(merge.needsUpload)
return
}
try journal.acceptRemote(merge.accepted)
snapshot = try materialize()
try journal.markNeedsUpload(merge.needsUpload)
}
return (snapshot, Set(merge.accepted.map(\.key)), merge.needsUpload)
}
private func materialize() throws -> MetricsSyncSnapshot {
var rows: [SyncedMetricsSelection] = []
for row in try context.fetch(FetchDescriptor<PersistentMetricsSyncEntry>()) {
guard let envelope = row.envelope, !envelope.isDeleted else { continue }
if case .selection(let value) = envelope.payload { rows.append(value) }
}
rows.sort {
($0.sortIndex, $0.selection.id.uuidString) < ($1.sortIndex, $1.selection.id.uuidString)
}
return MetricsSyncSnapshot(selections: Self.deduplicated(rows.map(\.selection)))
}
private func legacySelections() -> [MetricsSelection] {
let keys = ["metrics.servers", "mobile.metrics.v1"]
return Self.deduplicated(
keys.flatMap { key -> [MetricsSelection] in
guard let data = defaults.data(forKey: key) else { return [] }
return (try? JSONDecoder().decode([MetricsSelection].self, from: data)) ?? []
})
}
private static func deduplicated(_ values: [MetricsSelection]) -> [MetricsSelection] {
var seen = Set<UUID>()
return values.filter { seen.insert($0.id).inserted }
}
}