Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
54 changes: 50 additions & 4 deletions Sources/CloudSaveKit/CloudSaveEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down Expand Up @@ -397,13 +411,31 @@ 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
)
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,
Expand All @@ -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:
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions Sources/CloudSaveKit/CloudSaveEngineError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
25 changes: 23 additions & 2 deletions Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ actor CloudSaveFetchCoordinator {
private var activeFetchGenerations: [Int] = []
private var completedGeneration = 0
private var fetchGeneration = 0
private var failedFetchGenerations: Set<Int> = []
private var idleWaiters: [UUID: CheckedContinuation<Void, Error>] = [:]
private var latestSuccessfulFetchGeneration = 0
private var lifecycleGeneration = 0
private var requestGeneration = 0

Expand Down Expand Up @@ -37,29 +39,48 @@ 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()
}
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 {
throw CloudSaveEngineError.hostRecoveryRequired
}
if latestSuccessfulFetchGeneration >= request.requiredFetchGeneration {
return
}
guard completedGeneration >= request.requiredFetchGeneration else {
throw CloudSaveEngineError.freshFetchNotObserved
}
throw CloudSaveEngineError.configuredZoneFetchFailed
}

/// Invalidates suspended requests when their engine lifecycle ends.
func invalidate() {
lifecycleGeneration &+= 1
activeFetchGenerations.removeAll()
failedFetchGenerations.removeAll()
latestSuccessfulFetchGeneration = 0
let waiters = idleWaiters.values
idleWaiters.removeAll()
for waiter in waiters {
Expand Down
44 changes: 44 additions & 0 deletions Sources/CloudSaveKit/CloudSaveLogging.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppLogger
import CloudKit

enum CloudSaveLogging {
static let emoji = "☁️"
Expand Down Expand Up @@ -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"
}
}
}
66 changes: 66 additions & 0 deletions Tests/CloudSaveKitTests/CloudSaveFetchCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,70 @@ 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 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)
}
}
}
18 changes: 18 additions & 0 deletions Tests/CloudSaveKitTests/CloudSaveLoggingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}
}