fix: wrong sync status

This commit is contained in:
Kaede Akino committed 2026-09-24 14:57:38 +08:00
1 parent 5e75c9dfcd
commit f4a7e2c30f
3 files changed
+99 -42

No files matched your search

+47
View File
@@ -173,6 +173,53 @@ struct HistorySyncTests {
#expect(fixture.record(fixture.event.key) != nil)
}
@Test func sourceQuarantineUpdatesAnAlreadySuccessfulStatusWithoutNewUploads() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
try await fixture.start()
try fixture.persistence.journal.markUploaded(fixture.event.key, version: fixture.event.version)
try fixture.persistence.journal.markUploaded(fixture.device.key, version: fixture.device.version)
try fixture.context.save()
try fixture.coordinator.lifecycle.updateUpToDateStatus()
let initialSync = try fixture.persistence.journal.state().lastSuccessfulSync
#expect(fixture.coordinator.historyStatus == .upToDate(lastSuccessfulSync: initialSync))
let future = try fixture.legacyRecord(fixture.event)
var json = try #require(
JSONSerialization.jsonObject(with: VaultSyncCoordinator.payloadData(from: future)) as? [String: Any])
json["formatVersion"] = 2
CloudSyncSafetyFixture.setPayload(try JSONSerialization.data(withJSONObject: json), in: future)
future["formatVersion"] = NSNumber(value: 2)
try fixture.coordinator.legacyImporter.beginFetch()
try fixture.coordinator.legacyImporter.receive([future])
try fixture.coordinator.legacyImporter.finishFetch(succeeded: true)
#expect(
fixture.coordinator.historyStatus
== .partiallyBlocked(pendingCount: 0, blockedCount: 1, requiresUpdate: true))
#expect(fixture.coordinator.deviceStatus == .upToDate(lastSuccessfulSync: initialSync))
try fixture.coordinator.legacyImporter.beginFetch()
try fixture.coordinator.legacyImporter.receive([fixture.legacyRecord(fixture.event)])
try fixture.coordinator.legacyImporter.finishFetch(succeeded: true)
let resumedSync = try fixture.persistence.journal.state().lastSuccessfulSync
#expect(fixture.coordinator.historyStatus == .upToDate(lastSuccessfulSync: resumedSync))
}
@Test func unrelatedRecordTypeInLegacyZoneDoesNotQuarantineOrBlockHistory() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
try await fixture.start()
let record = CKRecord(
recordType: "FutureVaultFeature",
recordID: CKRecord.ID(
recordName: "v1/futureFeature/\(UUID().uuidString.lowercased())",
zoneID: CKRecordZone.ID(zoneName: VaultSyncCoordinator.zoneName, ownerName: CKCurrentUserDefaultName)))
try fixture.coordinator.legacyImporter.receive([record])
let sourceSafety = CloudSyncSafetyStore(context: fixture.context, scope: fixture.scope.historyImportScope)
#expect(try sourceSafety.blockedRecordNames().isEmpty)
#expect(try fixture.persistence.journal.envelope(for: fixture.event.key) == fixture.event)
}
@Test func initialImporterFailurePreventsAllDestinationUploadsAndRetryRecovers() async throws {
let fixture = try HistoryFixture()
defer { fixture.stop() }
@@ -44,45 +44,6 @@ public final class HistorySyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
public var historyStatus: VaultSyncStatus { includesHistory ? status(for: .connectionHistory) : .paused }
public var deviceStatus: VaultSyncStatus { status(for: .device) }
private func status(for kind: HistoryRecordKind) -> VaultSyncStatus {
guard let scope = lifecycle.scope else { return lifecycle.status }
if case .upToDate = lifecycle.status, !legacyImporter.ready(for: scope) { return .syncing(pendingCount: 0) }
switch lifecycle.status {
case .syncing, .partiallyBlocked:
do {
let pending = try journal.uploadablePendingRecordNames(in: scope).filter {
HistoryRecordKey(recordName: $0)?.kind == kind
}.count
var blocked = Set<String>()
var requiresUpdate = false
for replica in [scope, scope.historyImportScope] {
let safety = CloudSyncSafetyStore(context: context, scope: replica)
for name in try safety.blockedRecordNames() {
let recordKind = HistoryRecordKey(recordName: name)?.kind
// Unknown kinds belong to the always-selected portion so an
// optional History toggle cannot hide an upgrade requirement.
guard recordKind == kind || (recordKind == nil && kind == .device) else { continue }
blocked.insert(name)
if try safety.entry(recordName: name)?.reasonCode
== CloudSyncQuarantineReason.unsupportedFormat.rawValue
{
requiresUpdate = true
}
}
}
if !blocked.isEmpty {
return .partiallyBlocked(
pendingCount: pending, blockedCount: blocked.count, requiresUpdate: requiresUpdate)
}
if case .partiallyBlocked = lifecycle.status, pending == 0 {
return .upToDate(lastSuccessfulSync: try journal.state().lastSuccessfulSync)
}
return .syncing(pendingCount: pending)
} catch { return .failed(error.localizedDescription) }
default: return lifecycle.status
}
}
public convenience init(
persistence: HistorySyncPersistence,
container: CKContainer = CKContainer(identifier: HistorySyncCoordinator.containerIdentifier),
@@ -168,7 +129,13 @@ public final class HistorySyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
importer.didApply = { [weak self] keys in self?.didApplyRemote?(keys) }
importer.didFinish = { [weak self, weak importer] in
guard let self, let importer, let scope = lifecycle.scope, importer.ready(for: scope) else { return }
do { lifecycle.noteLocalChanges(try journal.uploadablePendingRecordNames(in: scope)) } catch {
do {
lifecycle.noteLocalChanges(try journal.uploadablePendingRecordNames(in: scope))
// The source may have gained or cleared a quarantine without
// producing any destination mutations. Recompute the visible
// status after its checkpoint commits, even with no uploads.
try lifecycle.updateUpToDateStatus()
} catch {
lifecycle.failClosed(error)
}
}
@@ -416,5 +383,45 @@ public final class HistorySyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
public nonisolated static func systemFields(of record: CKRecord) throws -> Data {
try CloudSyncRecordCoding.systemFields(of: record)
}
}
extension HistorySyncCoordinator {
private func status(for kind: HistoryRecordKind) -> VaultSyncStatus {
guard let scope = lifecycle.scope else { return lifecycle.status }
if case .upToDate = lifecycle.status, !legacyImporter.ready(for: scope) { return .syncing(pendingCount: 0) }
switch lifecycle.status {
case .syncing, .partiallyBlocked:
do {
let pending = try journal.uploadablePendingRecordNames(in: scope).filter {
HistoryRecordKey(recordName: $0)?.kind == kind
}.count
var blocked = Set<String>()
var requiresUpdate = false
for replica in [scope, scope.historyImportScope] {
let safety = CloudSyncSafetyStore(context: context, scope: replica)
for name in try safety.blockedRecordNames() {
let recordKind = HistoryRecordKey(recordName: name)?.kind
// Unknown kinds belong to the always-selected portion so an
// optional History toggle cannot hide an upgrade requirement.
guard recordKind == kind || (recordKind == nil && kind == .device) else { continue }
blocked.insert(name)
if try safety.entry(recordName: name)?.reasonCode
== CloudSyncQuarantineReason.unsupportedFormat.rawValue
{
requiresUpdate = true
}
}
}
if !blocked.isEmpty {
return .partiallyBlocked(
pendingCount: pending, blockedCount: blocked.count, requiresUpdate: requiresUpdate)
}
if case .partiallyBlocked = lifecycle.status, pending == 0 {
return .upToDate(lastSuccessfulSync: try journal.state().lastSuccessfulSync)
}
return .syncing(pendingCount: pending)
} catch { return .failed(error.localizedDescription) }
default: return lifecycle.status
}
}
}
@@ -173,7 +173,10 @@ final class LegacyHistoryImporter: NSObject, CKSyncEngineDelegate, @unchecked Se
let safety = CloudSyncSafetyStore(context: context, scope: scope)
var decoded: [HistoryEnvelope] = []
var rejected = Set<String>()
for record in records where record.recordID.zoneID == zoneID {
// A separate record type in the legacy zone is not part of the Vault V1
// history stream. Do not quarantine unrelated future features.
for record in records
where record.recordID.zoneID == zoneID && record.recordType == VaultSyncCoordinator.recordType {
if let key = VaultRecordKey(recordName: record.recordID.recordName), key.kind.isConfiguration { continue }
if let key = HistoryRecordKey(recordName: record.recordID.recordName),
key.kind == .connectionHistory && !includesHistory()