Files
yattee/Yattee/Services/CloudKit/CloudKitZoneRecovery.swift
Arkadiusz Fal b67b3472f6 Let a purge be answered once and a re-seed be answered until it sticks
The two answers to a deleted zone were sharing one lifecycle, and only one of
them deserved it. A re-seed only adds, so it is held open until the engine has
actually serialized the work and redone for as long as it has not. A purge
removes, and replaying it removes more than it was asked to — the account added
while the app kept running, the category switched back on, the reset that runs
its own upload afterwards. It is done the moment its durable half is, and the
comment that called both idempotent now says which is which.

The note itself was never scoped to an identity. A marker written under one
Apple account replayed against the next one's namespace, answering a wipe that
never happened there, and a purge would have taken that account's staged records
and its outbox with it. The account change forgets it now, the way it forgets
everything else it holds.

Retiring the marker trusted whichever state update arrived next, which is not
the same as one that carries the work: an update handed over before the recovery
registered anything can still reach the main actor after it, describing a state
in which none of it exists. Deliveries are counted where the engine hands them
over, and only a later one can retire what was armed.

A refused re-seed waited for the next launch. The accounts it could not speak
for announce themselves when they load, and the recovery is answered then —
outside the callback tree that announcement comes from.

A purge left two ways back for what it dropped: a pending zone save would have
recreated the zone on the next send, and the pre-engine pending-change keys
would have been rediscovered on a later launch. It clears both, and it now runs
after everything that puts work into the engine rather than before.

The account half fences before it destroys, like its upload twin, so a purge
arriving during the identity reload gap answers with nothing rather than with
somebody else's records.

And the reset that promises a re-upload is refused while sync is off, where
nothing would have performed one.

Re-entering the engine from inside its own callback is what started all of this,
and nothing in the code said so. A task-local marks the callback tree and the
two entry points assert on it in debug builds — a plain Task inherits it, which
is the mistake; Task.detached escapes it, which is the fix.
2026-09-09 23:59:59 +02:00

244 lines
9.3 KiB
Swift

//
// CloudKitZoneRecovery.swift
// Yattee
//
// Deciding — and remembering — what to do when the record zone is deleted out
// from under this device.
//
import CloudKit
import Foundation
/// What a remote deletion of the app's record zone requires of this device.
enum ZoneDeletionResponse: String, Sendable {
/// Recreate the zone and register every local record for upload again.
///
/// The zone was deleted by this app — "Reset iCloud Sync Data" on another
/// device — so local data is still the truth and iCloud is the copy that
/// went missing.
case reseed
/// Drop what the zone held, and never send it back.
///
/// The account owner asked iCloud to forget this app's data (Settings →
/// iCloud → Manage Storage) or reset their encrypted data. CloudKit is
/// explicit that data deleted for either reason must not be resent, so
/// nothing is republished and everything still queued from before the
/// deletion is dropped. Local data is left alone: it is this app's own
/// store, not a cache of the zone.
case discard
/// The response CloudKit's deletion reason calls for.
///
/// A reason this build does not recognise is answered as a purge:
/// republishing data the server deliberately dropped is the one mistake
/// here that cannot be taken back.
init(reason: CKDatabase.DatabaseChange.Deletion.Reason) {
switch reason {
case .deleted:
self = .reseed
case .purged, .encryptedDataReset:
self = .discard
@unknown default:
self = .discard
}
}
}
/// When the marker for a recovery may be retired.
enum ZoneRecoveryRetirement: Sendable {
/// Only once the engine has serialized the state the recovery registered,
/// so anything interrupted in between is redone on the next launch.
case whenStatePersists
/// As soon as the recovery's own durable half has been done.
case immediately
}
extension ZoneDeletionResponse {
/// The lifecycle this response's marker follows.
///
/// The two do not share one, because being redone does not cost them the
/// same thing.
///
/// A re-seed only adds: registering the same records twice collapses in the
/// engine's state, and marking the account outbox dirty is a set union. It
/// can therefore afford to be held until the engine has actually written
/// the work down, and to be repeated for as long as it has not.
///
/// A discard removes, and a replay removes more than it was asked to: it
/// drops every pending change the engine holds *now*, including work
/// registered after the purge was answered — an account added while the app
/// kept running, a category switched back on, a re-run of `setupSyncEngine`.
/// Its durable half — this identity's staged records and its account outbox
/// — is the answer the purge actually needs, and it is complete the moment
/// that half succeeds. So the marker goes then, and the engine-state
/// clearing beside it is one-shot.
var retirement: ZoneRecoveryRetirement {
switch self {
case .reseed: .whenStatePersists
case .discard: .immediately
}
}
}
/// Counts sync-engine state updates as they are delivered, off the main actor.
///
/// A state update is handed to the delegate on the engine's own thread and only
/// then hops to the main actor. An update delivered *before* a recovery
/// registered its work can still run its main-actor body after — the recovery
/// holds the main actor while it registers — and the serialization it carries
/// predates that work entirely. Retiring a marker on one of those would forget
/// work the engine has not written down. Counting at delivery is what tells the
/// two apart.
final class ZoneRecoveryDeliveryCounter: @unchecked Sendable {
private let lock = NSLock()
private var count: UInt64 = 0
/// Records a delivery and returns its sequence number.
func next() -> UInt64 {
lock.lock()
defer { lock.unlock() }
count += 1
return count
}
/// How many deliveries have happened so far.
var current: UInt64 {
lock.lock()
defer { lock.unlock() }
return count
}
}
/// One category of local data a re-seed publishes.
enum ReseedCategory: String, CaseIterable, Sendable {
case accounts
case subscriptions
case watchHistory
case bookmarks
case playlists
case searchHistory
case recentChannels
case recentPlaylists
case channelNotificationSettings
case controlsPresets
/// The order a re-seed registers categories in.
///
/// Accounts lead. They carry the logins, the deletion tombstones and the
/// selected context — the records every other category is read through —
/// and a re-seed can be cut short at any point by the same backgrounding,
/// sign-out or crash that interrupts any other upload. Going last, as the
/// initial upload used to, is how a wipe took the accounts with it.
static let reseedOrder: [ReseedCategory] = [
.accounts,
.subscriptions,
.watchHistory,
.bookmarks,
.playlists,
.searchHistory,
.recentChannels,
.recentPlaylists,
.channelNotificationSettings,
.controlsPresets
]
}
/// Remembers that the zone was wiped, until the answer to it is durably queued.
///
/// Answering a wipe takes several steps — recreate the zone, re-register every
/// local record — and the process can die between any two of them. Without a
/// marker written *before* the first step, a relaunch finds a zone that exists
/// again and no local sign that anything was lost, and the wipe is invisible
/// for good.
///
/// Durability rests on what the engine's own change token rests on: both are
/// `UserDefaults` writes, so a crash that preserved the token preserved a
/// marker written before it too.
@MainActor
final class CloudKitZoneRecoveryMarker {
private let defaults: UserDefaults
private let key: String
/// Whether the work this marker stands for is registered with the engine
/// and only waiting for the engine to serialize its state.
private var awaitingStatePersistence = false
/// How many state updates the engine had delivered when the work was
/// registered. Only a later one can carry it.
private var armedAfterDelivery: UInt64 = 0
init(defaults: UserDefaults = .standard, key: String = "cloudKitZoneNeedsReseed") {
self.defaults = defaults
self.key = key
}
/// The response a run recorded and may not have finished.
var pending: ZoneDeletionResponse? {
defaults.string(forKey: key).flatMap(ZoneDeletionResponse.init(rawValue:))
}
/// Records what has to happen — before any of it does.
func begin(_ response: ZoneDeletionResponse) {
defaults.set(response.rawValue, forKey: key)
awaitingStatePersistence = false
}
/// Says the recovery is fully registered with the engine, so a *later*
/// serialization of that state may retire the marker.
///
/// Deliberately separate from `begin`: a recovery that could not register
/// everything — accounts belonging to an identity that has not loaded yet —
/// keeps its marker and is redone on the next launch.
///
/// - Parameter sequence: the number of state updates delivered so far. An
/// update already delivered when this is called was serialized before the
/// work was registered, however late its main-actor half runs.
func readyToClear(afterDelivery sequence: UInt64) {
awaitingStatePersistence = true
armedAfterDelivery = sequence
}
/// Retires the marker now, without waiting for the engine to write anything
/// down.
///
/// For the response whose answer is its own durable half — see
/// ``ZoneRecoveryRetirement``. Redoing that one would destroy work that was
/// registered after it, so it is not held open for a redo.
func retireNow() {
clear()
}
/// Retires the marker once the engine has persisted the state the recovery
/// registered, and only then.
///
/// State updates arrive for every other reason too, and one of those must
/// not retire a marker whose work has not been redone — that is exactly the
/// state a relaunch after an interrupted recovery starts in. Nor may one
/// that was already on its way when the work was registered: it is carrying
/// a serialization taken before any of it existed.
///
/// - Parameter sequence: the delivery number of the state update being
/// handled, taken when the engine handed it over.
func stateDidPersist(deliveredAt sequence: UInt64) {
guard awaitingStatePersistence, sequence > armedAfterDelivery else { return }
clear()
}
/// Forgets the marker entirely, without treating its work as done.
///
/// For the Apple account moving underneath us: the marker names a decision
/// about *that* identity's zone, and replaying it against the next one's
/// namespace answers a wipe that never happened there.
func forget() {
clear()
}
private func clear() {
awaitingStatePersistence = false
armedAfterDelivery = 0
defaults.removeObject(forKey: key)
}
}