crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 6077c07f1b2ca35436809f35ab5ae60c50486e86
parent 41f17411431fd219e259038be58c12c233911788
Author: Michael Camilleri <[email protected]>
Date:   Thu, 25 Jun 2026 03:47:14 +0900

Use a broader diagnostic to identify orphan zones

Diffstat:
MCrossmate/Sync/SyncEngine.swift | 93++++++++++++++++++++++++++++++++-----------------------------------------------
1 file changed, 38 insertions(+), 55 deletions(-)

diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -829,59 +829,61 @@ actor SyncEngine { } } - /// Read-only diagnostic — lists, but does **not** delete, stranded private - /// game zones. An orphan is a `game-<uuid>` zone that exists on the private - /// server but has no local `GameEntity` and no `Game` record: a relic of a - /// dev build or a pre-`enqueueDeleteGame` deletion. Logs the candidate zone - /// names so a captured diagnostic dump shows exactly what a future cleanup - /// would remove before the destructive version ships. + /// 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. /// - /// These are the guards that cleanup would delete under, applied here so the - /// list is faithful: - /// - private database only (never the shared DB — those zones aren't ours), - /// - `game-` prefix only — skips `_defaultZone` and the account/friend - /// zones, which legitimately carry no `Game` record, - /// - no local `GameEntity` for the zone — the race guard: a game mid-creation - /// (here or on a sibling) has, or is about to have, a local row, so it is - /// never mistaken for an orphan. + /// 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 gameZones = try await database.allRecordZones() + let serverZones = try await database.allRecordZones() .map(\.zoneID) - .filter { $0 != .default && $0.zoneName.hasPrefix("game-") } - guard !gameZones.isEmpty else { return } - - let knownZoneNames = localGameZoneNames() + .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 ) - - var orphans: [String] = [] - for zoneID in gameZones where !knownZoneNames.contains(zoneID.zoneName) { - let games = try await queryRecords( + 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: [] - ) - if games.isEmpty { orphans.append(zoneID.zoneName) } - } - - if orphans.isEmpty { + )) ?? []).isEmpty) await trace( - "orphan-zone diagnostic: none " + - "(private game-zones=\(gameZones.count))" - ) - } else { - await trace( - "orphan-zone diagnostic: \(orphans.count) orphan(s) of " + - "\(gameZones.count) private game-zone(s): " + - "[\(orphans.sorted().joined(separator: ", "))]" + " \(zoneID.zoneName) " + + "(owner=\(zoneID.ownerName), game=\(hasGame))" ) } } catch { @@ -889,25 +891,6 @@ actor SyncEngine { } } - /// Zone names of every locally-tracked game, regardless of scope or - /// access-revoked state — anything we hold a `GameEntity` for is a tracked - /// game, not an orphan. - private func localGameZoneNames() -> Set<String> { - let ctx = persistence.container.newBackgroundContext() - return ctx.performAndWait { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - var names = Set<String>() - for entity in (try? ctx.fetch(req)) ?? [] { - if let zoneName = entity.ckZoneName { - names.insert(zoneName) - } else if let gameID = entity.id { - names.insert("game-\(gameID.uuidString)") - } - } - return names - } - } - /// 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