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:
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