commit d6dc855b680028881e2c3bb9a7f445b6c219d5d2
parent 6077c07f1b2ca35436809f35ab5ae60c50486e86
Author: Michael Camilleri <[email protected]>
Date: Thu, 25 Jun 2026 04:13:50 +0900
Tear down the archive backup when a finished game is deleted
A participant who finishes a shared puzzle keeps a self-contained
backup of it — final grid and full move journal — in a separate
archive-<id> zone in their own private database, so the game survives
the owner later deleting the shared original (see GameArchiver). When
the user deleted their own copy, though, enqueueDeleteGame removed only
the live shared zone and left that archive zone stranded in the private
database indefinitely.
Now deleteGame tears the backup down too, but only for a game that still
owns one in a separate zone: a finished, still-participant game (scope
1, archivedAt set, not access-revoked). A materialised or revoked
archive is left alone — its own zone already is the archive and is
covered by the primary deletion, so nothing is removed twice.
This commit also stops discovery re-probing the zones it can never
resolve. discoverNewZonesDirect now skips the account zone and every
archive zone when diffing the server against the known set: Archive
records arrive through the engine's own fetchedRecordZoneChanges, not
the Game query discovery runs, so listing them as candidates only
re-fanned a wasted query each pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
3 files changed, 41 insertions(+), 65 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -243,6 +243,12 @@ struct GameCloudDeletion: Sendable, Equatable {
let databaseScope: Int16
let ckZoneName: String
let ckZoneOwnerName: String
+ /// The private-DB archive backup zone (archive-<id>) to tear down alongside
+ /// the game, set only for a finished participant game whose backup lives in
+ /// a zone separate from its live (shared) one. nil otherwise — an unarchived
+ /// game, or a materialized archive whose own zone already is the archive and
+ /// is covered by ckZoneName.
+ let archiveZoneName: String?
}
/// Per-entity memoisation of `GameSummary`. The library list re-runs on
@@ -957,11 +963,22 @@ final class GameStore {
guard let entity = try context.fetch(request).first else { return }
+ // A finished participant game (scope 1, archived, still a participant)
+ // carries a separate private-DB archive backup under archive-<id>;
+ // deleting the game outright should drop that backup too. A materialized
+ // or revoked archive is excluded — its own zone already is the archive,
+ // covered by ckZoneName above, so it needs no second teardown.
+ let hasSeparateArchive = entity.databaseScope == 1
+ && entity.archivedAt != nil
+ && !entity.isAccessRevoked
let deletion = GameCloudDeletion(
gameID: id,
databaseScope: entity.databaseScope,
ckZoneName: entity.ckZoneName ?? "game-\(id.uuidString)",
- ckZoneOwnerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
+ ckZoneOwnerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName,
+ archiveZoneName: hasSeparateArchive
+ ? Archive.zoneID(forOriginalGameID: id).zoneName
+ : nil
)
// Clear current references if this is the active game
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -513,7 +513,14 @@ extension SyncEngine {
.map(\.zoneID)
.filter { id in
id != CKRecordZone.ID.default &&
- !knownKeys.contains("\(id.ownerName)|\(id.zoneName)")
+ !knownKeys.contains("\(id.ownerName)|\(id.zoneName)") &&
+ // Skip zones this probe can never resolve to a game: the
+ // account-scoped zone and the private-DB archive backups of
+ // finished shared games. Archive records arrive through the
+ // engine's own fetchedRecordZoneChanges, not this Game query, so
+ // probing them here only re-fans a wasted query on every pass.
+ id.zoneName != RecordSerializer.accountZoneID.zoneName &&
+ !Archive.isArchiveZone(id.zoneName)
}
guard !candidates.isEmpty else {
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -329,7 +329,6 @@ actor SyncEngine {
Task { await purgeStaleHailPings_v1() }
Task { await purgeDebugPreviewFriends_v1() }
Task { await purgeLegacyPlayPings_v1() }
- Task { await diagnoseOrphanZones_v1() }
}
private func ensureDatabaseSubscriptions() async {
@@ -545,6 +544,21 @@ actor SyncEngine {
guard let engine else { return }
engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
sendChangesDetached(on: engine)
+
+ // A finished participant game keeps a self-contained backup in a
+ // separate archive-<id> zone (see GameArchiver). The live game lives in
+ // the shared database, so the deletion above never reaches that backup —
+ // tear it down here. The archive is always in this account's private
+ // database, so it routes through the private engine regardless of the
+ // game's own scope.
+ guard let archiveZoneName = deletion.archiveZoneName,
+ let privateEngine else { return }
+ let archiveZoneID = CKRecordZone.ID(
+ zoneName: archiveZoneName,
+ ownerName: CKCurrentUserDefaultName
+ )
+ privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(archiveZoneID)])
+ sendChangesDetached(on: privateEngine)
}
/// Registers a Ping record as a pending send. Pings now only cover
@@ -829,68 +843,6 @@ actor SyncEngine {
}
}
- /// Read-only diagnostic — lists, but does **not** delete, every private
- /// zone the sync engine keeps re-probing without ever resolving it to a
- /// game. The candidate set mirrors discoverNewZonesDirect exactly: a
- /// non-default private server zone that is neither a tracked game zone nor
- /// a friend zone (the two sets knownZones already covers). That is the same
- /// `candidates=N` discovery logs each pass; this names them so a captured
- /// diagnostic shows what they are — the account zone, or stranded relics of
- /// dev builds and deletions that predate enqueueDeleteGame.
- ///
- /// Each candidate is probed for a `Game` record so the log distinguishes a
- /// truly empty orphan from one still holding stranded game data. Nothing is
- /// deleted: a future guarded cleanup decides removal from this list once the
- /// names have been reviewed.
- func diagnoseOrphanZones_v1() async {
- let database = container.privateCloudDatabase
- let ctx = persistence.container.newBackgroundContext()
- do {
- let serverZones = try await database.allRecordZones()
- .map(\.zoneID)
- .filter { $0 != .default }
- let knownKeys = Set(
- knownZones(forScope: 0, in: ctx)
- .map { "\($0.zoneID.ownerName)|\($0.zoneID.zoneName)" }
- )
- let candidates = serverZones
- .filter { !knownKeys.contains("\($0.ownerName)|\($0.zoneName)") }
- .sorted { $0.zoneName < $1.zoneName }
- guard !candidates.isEmpty else {
- await trace(
- "orphan-zone diagnostic: no untracked private zones " +
- "(server=\(serverZones.count))"
- )
- return
- }
- // Match what the live catch-up queries use; a bare TRUEPREDICATE is
- // not reliably accepted for a zone query.
- let anyRecord = NSPredicate(
- format: "modificationDate > %@",
- Date(timeIntervalSince1970: 0) as NSDate
- )
- await trace(
- "orphan-zone diagnostic: \(candidates.count) untracked private " +
- "zone(s) of \(serverZones.count):"
- )
- for zoneID in candidates {
- let hasGame = !(((try? await queryRecords(
- type: "Game",
- database: database,
- zoneID: zoneID,
- predicate: anyRecord,
- desiredKeys: []
- )) ?? []).isEmpty)
- await trace(
- " \(zoneID.zoneName) " +
- "(owner=\(zoneID.ownerName), game=\(hasGame))"
- )
- }
- } catch {
- await trace("orphan-zone diagnostic failed: \(describe(error))")
- }
- }
-
/// One-shot local cleanup for pre-release debug friend rows whose
/// `friend-debug-preview-*` zones no longer exist or have invalid
/// participants. These rows are local metadata, but while present they