From fa1f0fe16159b023a182efa90964d54bcee74a16 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Mon, 14 Sep 2026 17:00:39 +0200 Subject: [PATCH 1/2] Expose configured-zone fetch failures --- CHANGELOG.md | 7 +++ README.md | 2 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 54 +++++++++++++++++-- .../CloudSaveKit/CloudSaveEngineError.swift | 3 ++ .../CloudSaveFetchCoordinator.swift | 13 +++++ Sources/CloudSaveKit/CloudSaveLogging.swift | 44 +++++++++++++++ .../CloudSaveFetchCoordinatorTests.swift | 26 +++++++++ .../CloudSaveLoggingTests.swift | 18 +++++++ 8 files changed, 162 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fd32e..b7015bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to CloudSaveKit are documented here. +## Unreleased + +### Fixed + +- Propagate configured-zone fetch failures through explicit freshness requests instead of treating only the outer fetch generation as success. +- Add privacy-safe fetch-stage diagnostics for database discovery, configured-zone delivery, and dirty-state transitions. + ## 0.2.2 — 2026-09-14 ### Fixed diff --git a/README.md b/README.md index e6a7570..cfaec00 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ try await engine.syncNow() Automatic synchronization should remain enabled in production. Explicit operations complement the system scheduler; they do not replace durable local saves or make offline networking possible. -Explicit operations are serialized per engine. An explicit fetch waits for any fetch already active when the request arrives, then requires a fetch generation that began after the request before reporting success. Because CKSyncEngine completes its API call only after related delegate events finish, success is both a freshness barrier and an apply barrier for the configured zone. A post-request automatic fetch may satisfy the barrier; a pre-request fetch cannot. `freshFetchNotObserved` is raised instead of reporting false success if CKSyncEngine returns without a qualifying generation. +Explicit operations are serialized per engine. An explicit fetch waits for any fetch already active when the request arrives, then requires a fetch generation that began after the request before reporting success. Because CKSyncEngine completes its API call only after related delegate events finish, success is both a freshness barrier and an apply barrier for the configured zone. A post-request automatic fetch may satisfy the barrier; a pre-request fetch cannot. `freshFetchNotObserved` is raised instead of reporting false success if CKSyncEngine returns without a qualifying generation. A configured-zone fetch error also fails its qualifying explicit request. Privacy-safe stage logs report database discovery, configured-zone delivery counts, per-zone completion, and dirty-state transitions without exposing zone or record identities. Call `start()` successfully before any explicit synchronization. `fetchNow()` and `sendNow()` throw `CloudSaveEngineError.notStarted` before startup and `CloudSaveEngineError.hostRecoveryRequired` after a host persistence callback fails. Once the local store is healthy again, call `start()` to rebuild from the last successfully persisted CKSyncEngine checkpoint and the host's current durable pending-change ledger. diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index da286d4..99f14c0 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -163,12 +163,26 @@ public final actor CloudSaveEngine { ) do { + CloudSaveLogging.log( + CloudSaveLogging.fetchState( + phase: "before", + configuredZoneDirty: session.syncEngine.state.zoneIDsWithUnfetchedServerChanges + .contains(configuration.zoneID) + ) + ) let options = CKSyncEngine.FetchChangesOptions( scope: .zoneIDs([configuration.zoneID]) ) try await session.syncEngine.fetchChanges(options) try await fetchCoordinator.validate(request) try validate(session) + CloudSaveLogging.log( + CloudSaveLogging.fetchState( + phase: "after", + configuredZoneDirty: session.syncEngine.state.zoneIDsWithUnfetchedServerChanges + .contains(configuration.zoneID) + ) + ) CloudSaveLogging.log( CloudSaveLogging.fetchRequestSucceeded(request: request.requestGeneration) ) @@ -397,6 +411,18 @@ extension CloudSaveEngine { syncEngine: syncEngine ) case .fetchedDatabaseChanges(let event): + CloudSaveLogging.log( + CloudSaveLogging.fetchedDatabaseChanges( + modifications: event.modifications.count, + deletions: event.deletions.count, + configuredZoneChanged: event.modifications.contains { + isInConfiguredZone($0.zoneID) + } + || event.deletions.contains { + isInConfiguredZone($0.zoneID) + } + ) + ) try await restoreDeletedZones( event.deletions.map(\.zoneID).filter(isInConfiguredZone), syncEngine: syncEngine @@ -404,6 +430,12 @@ extension CloudSaveEngine { case .fetchedRecordZoneChanges(let event): let fetchedRecords = event.modifications.map(\.record).filter(isInConfiguredZone) let deletedRecordIDs = event.deletions.map(\.recordID).filter(isInConfiguredZone) + CloudSaveLogging.log( + CloudSaveLogging.fetchedConfiguredZoneChanges( + modifications: fetchedRecords.count, + deletions: deletedRecordIDs.count + ) + ) try await commitPendingChangesMutation { [client] in try await client.applyFetchedChanges( records: fetchedRecords, @@ -420,10 +452,13 @@ extension CloudSaveEngine { event, syncEngine: syncEngine ) - case .willFetchChanges: + case .willFetchChanges(let event): begin(.fetching, syncEngine: syncEngine) let generation = await fetchCoordinator.beginFetch() - CloudSaveLogging.log(CloudSaveLogging.fetchGeneration(generation, phase: "started")) + let reason = event.context.reason == .manual ? "manual" : "scheduled" + CloudSaveLogging.log( + "\(CloudSaveLogging.fetchGeneration(generation, phase: "started")), reason=\(reason)" + ) case .willSendChanges: begin(.sending, syncEngine: syncEngine) case .didFetchChanges: @@ -432,8 +467,19 @@ extension CloudSaveEngine { CloudSaveLogging.log(CloudSaveLogging.fetchGeneration(generation, phase: "completed")) case .didSendChanges: complete(.sending, syncEngine: syncEngine) - case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges: - break + case .willFetchRecordZoneChanges(let event): + guard isInConfiguredZone(event.zoneID) else { + break + } + CloudSaveLogging.log("fetch zone | phase=started, configured=true") + case .didFetchRecordZoneChanges(let event): + guard isInConfiguredZone(event.zoneID) else { + break + } + CloudSaveLogging.log(CloudSaveLogging.configuredZoneFetchCompleted(error: event.error)) + if event.error != nil { + await fetchCoordinator.failConfiguredZoneFetch() + } @unknown default: CloudSaveLogging.log( level: .info, diff --git a/Sources/CloudSaveKit/CloudSaveEngineError.swift b/Sources/CloudSaveKit/CloudSaveEngineError.swift index b736667..d836e03 100644 --- a/Sources/CloudSaveKit/CloudSaveEngineError.swift +++ b/Sources/CloudSaveKit/CloudSaveEngineError.swift @@ -13,4 +13,7 @@ public enum CloudSaveEngineError: Error, Equatable, Sendable { /// CKSyncEngine completed an explicit call without emitting a qualifying fresh fetch generation. case freshFetchNotObserved + + /// The configured zone failed during the qualifying fetch generation. + case configuredZoneFetchFailed } diff --git a/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift b/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift index c26ec61..4769c83 100644 --- a/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift +++ b/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift @@ -5,6 +5,7 @@ actor CloudSaveFetchCoordinator { private var activeFetchGenerations: [Int] = [] private var completedGeneration = 0 private var fetchGeneration = 0 + private var failedFetchGenerations: Set = [] private var idleWaiters: [UUID: CheckedContinuation] = [:] private var lifecycleGeneration = 0 private var requestGeneration = 0 @@ -46,6 +47,14 @@ actor CloudSaveFetchCoordinator { return completedGeneration } + /// Marks the active fetch generation as failed for the configured zone. + func failConfiguredZoneFetch() { + guard let generation = activeFetchGenerations.first else { + return + } + failedFetchGenerations.insert(generation) + } + /// Verifies that a fetch which began no earlier than the request has completed. func validate(_ request: Request) throws { guard request.lifecycleGeneration == lifecycleGeneration else { @@ -54,12 +63,16 @@ actor CloudSaveFetchCoordinator { guard completedGeneration >= request.requiredFetchGeneration else { throw CloudSaveEngineError.freshFetchNotObserved } + guard !failedFetchGenerations.contains(request.requiredFetchGeneration) else { + throw CloudSaveEngineError.configuredZoneFetchFailed + } } /// Invalidates suspended requests when their engine lifecycle ends. func invalidate() { lifecycleGeneration &+= 1 activeFetchGenerations.removeAll() + failedFetchGenerations.removeAll() let waiters = idleWaiters.values idleWaiters.removeAll() for waiter in waiters { diff --git a/Sources/CloudSaveKit/CloudSaveLogging.swift b/Sources/CloudSaveKit/CloudSaveLogging.swift index 5b7905c..f2e96b9 100644 --- a/Sources/CloudSaveKit/CloudSaveLogging.swift +++ b/Sources/CloudSaveKit/CloudSaveLogging.swift @@ -1,4 +1,5 @@ import AppLogger +import CloudKit enum CloudSaveLogging { static let emoji = "☁️" @@ -41,4 +42,47 @@ enum CloudSaveLogging { static func fetchGeneration(_ generation: Int, phase: String) -> String { "fetch generation | generation=\(generation), phase=\(phase)" } + + /// Describes configured-zone server-change knowledge without exposing its identity. + static func fetchState(phase: String, configuredZoneDirty: Bool) -> String { + "fetch state | phase=\(phase), configured-zone-dirty=\(configuredZoneDirty)" + } + + /// Describes bounded database-change discovery without exposing zone identities. + static func fetchedDatabaseChanges( + modifications: Int, + deletions: Int, + configuredZoneChanged: Bool + ) -> String { + "fetch database | modifications=\(modifications), deletions=\(deletions), configured-zone-changed=\(configuredZoneChanged)" + } + + /// Describes bounded configured-zone record changes without exposing record identities. + static func fetchedConfiguredZoneChanges(modifications: Int, deletions: Int) -> String { + "fetch zone | phase=changes, modifications=\(modifications), deletions=\(deletions)" + } + + /// Describes configured-zone fetch completion with a privacy-safe failure classification. + static func configuredZoneFetchCompleted(error: CKError?) -> String { + guard let error else { + return "fetch zone | phase=completed, result=success" + } + return + "fetch zone | phase=completed, result=failure, classification=\(failureToken(CloudSaveFailure(error: error))), code=\(error.errorCode)" + } + + /// Converts a stable failure category to a log-safe token. + private static func failureToken(_ failure: CloudSaveFailure) -> String { + switch failure { + case .accountUnavailable: "account-unavailable" + case .configuration: "configuration" + case .localPersistence: "local-persistence" + case .networkUnavailable: "network-unavailable" + case .quotaExceeded: "quota-exceeded" + case .recordConflict: "record-conflict" + case .restricted: "restricted" + case .zoneUnavailable: "zone-unavailable" + case .unknown: "unknown" + } + } } diff --git a/Tests/CloudSaveKitTests/CloudSaveFetchCoordinatorTests.swift b/Tests/CloudSaveKitTests/CloudSaveFetchCoordinatorTests.swift index d7bf2bc..31d1903 100644 --- a/Tests/CloudSaveKitTests/CloudSaveFetchCoordinatorTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveFetchCoordinatorTests.swift @@ -104,4 +104,30 @@ struct CloudSaveFetchCoordinatorTests { try await coordinator.validate(request) } } + + @Test func configuredZoneFailureRejectsQualifyingRequest() async throws { + let coordinator = CloudSaveFetchCoordinator() + let request = try await coordinator.prepareRequest() + _ = await coordinator.beginFetch() + + await coordinator.failConfiguredZoneFetch() + _ = await coordinator.completeFetch() + + await #expect(throws: CloudSaveEngineError.configuredZoneFetchFailed) { + try await coordinator.validate(request) + } + } + + @Test func olderZoneFailureDoesNotPoisonLaterRequest() async throws { + let coordinator = CloudSaveFetchCoordinator() + _ = await coordinator.beginFetch() + await coordinator.failConfiguredZoneFetch() + _ = await coordinator.completeFetch() + let request = try await coordinator.prepareRequest() + + _ = await coordinator.beginFetch() + _ = await coordinator.completeFetch() + + try await coordinator.validate(request) + } } diff --git a/Tests/CloudSaveKitTests/CloudSaveLoggingTests.swift b/Tests/CloudSaveKitTests/CloudSaveLoggingTests.swift index 5a0d542..2df6494 100644 --- a/Tests/CloudSaveKitTests/CloudSaveLoggingTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveLoggingTests.swift @@ -34,4 +34,22 @@ struct CloudSaveLoggingTests { == "☁️ fetch request | request=12, result=success" ) } + + @Test func formatsFetchDiagnosticsWithoutCloudKitIdentities() { + #expect( + CloudSaveLogging.fetchState(phase: "before", configuredZoneDirty: true) + == "fetch state | phase=before, configured-zone-dirty=true" + ) + #expect( + CloudSaveLogging.fetchedDatabaseChanges( + modifications: 2, + deletions: 1, + configuredZoneChanged: true + ) == "fetch database | modifications=2, deletions=1, configured-zone-changed=true" + ) + #expect( + CloudSaveLogging.fetchedConfiguredZoneChanges(modifications: 3, deletions: 1) + == "fetch zone | phase=changes, modifications=3, deletions=1" + ) + } } From 8133474933c3370127a397f71e4d4a8b530bba8a Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Mon, 14 Sep 2026 17:07:36 +0200 Subject: [PATCH 2/2] Accept later successful fetch generations --- .../CloudSaveFetchCoordinator.swift | 18 +++++--- .../CloudSaveFetchCoordinatorTests.swift | 42 ++++++++++++++++++- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift b/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift index 4769c83..cf417f8 100644 --- a/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift +++ b/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift @@ -7,6 +7,7 @@ actor CloudSaveFetchCoordinator { private var fetchGeneration = 0 private var failedFetchGenerations: Set = [] private var idleWaiters: [UUID: CheckedContinuation] = [:] + private var latestSuccessfulFetchGeneration = 0 private var lifecycleGeneration = 0 private var requestGeneration = 0 @@ -38,8 +39,13 @@ actor CloudSaveFetchCoordinator { /// Records one terminal fetch event and releases requests after all older work drains. @discardableResult func completeFetch() -> Int { - if !activeFetchGenerations.isEmpty { - completedGeneration = max(completedGeneration, activeFetchGenerations.removeFirst()) + guard !activeFetchGenerations.isEmpty else { + return completedGeneration + } + let generation = activeFetchGenerations.removeFirst() + completedGeneration = max(completedGeneration, generation) + if !failedFetchGenerations.contains(generation) { + latestSuccessfulFetchGeneration = max(latestSuccessfulFetchGeneration, generation) } if activeFetchGenerations.isEmpty { resumeIdleWaiters() @@ -60,12 +66,13 @@ actor CloudSaveFetchCoordinator { guard request.lifecycleGeneration == lifecycleGeneration else { throw CloudSaveEngineError.hostRecoveryRequired } + if latestSuccessfulFetchGeneration >= request.requiredFetchGeneration { + return + } guard completedGeneration >= request.requiredFetchGeneration else { throw CloudSaveEngineError.freshFetchNotObserved } - guard !failedFetchGenerations.contains(request.requiredFetchGeneration) else { - throw CloudSaveEngineError.configuredZoneFetchFailed - } + throw CloudSaveEngineError.configuredZoneFetchFailed } /// Invalidates suspended requests when their engine lifecycle ends. @@ -73,6 +80,7 @@ actor CloudSaveFetchCoordinator { lifecycleGeneration &+= 1 activeFetchGenerations.removeAll() failedFetchGenerations.removeAll() + latestSuccessfulFetchGeneration = 0 let waiters = idleWaiters.values idleWaiters.removeAll() for waiter in waiters { diff --git a/Tests/CloudSaveKitTests/CloudSaveFetchCoordinatorTests.swift b/Tests/CloudSaveKitTests/CloudSaveFetchCoordinatorTests.swift index 31d1903..9db30d6 100644 --- a/Tests/CloudSaveKitTests/CloudSaveFetchCoordinatorTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveFetchCoordinatorTests.swift @@ -118,16 +118,56 @@ struct CloudSaveFetchCoordinatorTests { } } - @Test func olderZoneFailureDoesNotPoisonLaterRequest() async throws { + @Test func failedFirstQualifyingGenerationDoesNotPoisonLaterSuccess() async throws { let coordinator = CloudSaveFetchCoordinator() + let request = try await coordinator.prepareRequest() _ = await coordinator.beginFetch() await coordinator.failConfiguredZoneFetch() _ = await coordinator.completeFetch() + + _ = await coordinator.beginFetch() + _ = await coordinator.completeFetch() + + try await coordinator.validate(request) + } + + @Test func laterFailureDoesNotPoisonEarlierQualifyingSuccess() async throws { + let coordinator = CloudSaveFetchCoordinator() let request = try await coordinator.prepareRequest() + _ = await coordinator.beginFetch() + _ = await coordinator.completeFetch() _ = await coordinator.beginFetch() + await coordinator.failConfiguredZoneFetch() _ = await coordinator.completeFetch() try await coordinator.validate(request) } + + @Test func allCompletedQualifyingGenerationsFailRequest() async throws { + let coordinator = CloudSaveFetchCoordinator() + let request = try await coordinator.prepareRequest() + for _ in 0..<2 { + _ = await coordinator.beginFetch() + await coordinator.failConfiguredZoneFetch() + _ = await coordinator.completeFetch() + } + + await #expect(throws: CloudSaveEngineError.configuredZoneFetchFailed) { + try await coordinator.validate(request) + } + } + + @Test func lifecycleInvalidationOutranksSuccessfulGeneration() async throws { + let coordinator = CloudSaveFetchCoordinator() + let request = try await coordinator.prepareRequest() + _ = await coordinator.beginFetch() + _ = await coordinator.completeFetch() + + await coordinator.invalidate() + + await #expect(throws: CloudSaveEngineError.hostRecoveryRequired) { + try await coordinator.validate(request) + } + } }