commit 6cc37b4bd278bb5fad64012ee1b9db59e2aa28d3
parent cf666c58b22632fe256db9e9d566de6d26444bd1
Author: Michael Camilleri <[email protected]>
Date: Thu, 14 May 2026 12:35:28 +0900
Orphan shared zones whose participant binding turns invalid
The send path already treats 'Cannot convert userId to dsId due to an invalid
userId' as a terminal share failure and orphans the affected zone, but the ping
fast-path and background-session-scan only logged it. Every silent push
re-queried the same zone, the same error came back, and a user's device filled
its diagnostics log with the same line on every notification.
Both per-zone catch blocks now detect the 'invalid userId' error on the shared
scope and report the zone as orphaned alongside their existing results. After
the task group joins, the collected zones are funnelled through the same
applyZoneOrphaning path used by the send-side handler — pending changes are
removed and the GameEntity is marked isAccessRevoked. knownZones,
incompleteKnownZones, and knownGameIDs now filter on isAccessRevoked == NO so
the direct-fetch paths stop selecting the zone after orphaning runs; otherwise
each subsequent notification would re-query and re-orphan it.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
1 file changed, 56 insertions(+), 12 deletions(-)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -600,7 +600,11 @@ actor SyncEngine {
// requests overlap; a serial N-zone scan becomes a single parallel
// batch. Per-zone errors are caught and traced so one transient
// failure doesn't suppress notifications from healthy zones.
- let perZoneRecords = await withTaskGroup(of: [CKRecord].self) { group in
+ struct PerZonePings: Sendable {
+ let records: [CKRecord]
+ let orphanedZone: CKRecordZone.ID?
+ }
+ let perZoneRecords = await withTaskGroup(of: PerZonePings.self) { group in
for (zoneID, createdAt) in zones {
// Scope checkpoint (if present) wins — it's forward-moving
// across all zones. On first run for a given scope we fall
@@ -610,36 +614,50 @@ actor SyncEngine {
let since = scopeCheckpoint
?? createdAt.addingTimeInterval(-pingPushCheckpointOverlap)
group.addTask { [weak self] in
- guard let self else { return [] }
+ guard let self else { return PerZonePings(records: [], orphanedZone: nil) }
do {
- return try await self.queryLiveRecords(
+ let records = try await self.queryLiveRecords(
type: "Ping",
database: database,
zoneID: zoneID,
since: since,
desiredKeys: ["authorID", "playerName", "puzzleTitle", "kind", "scope"]
)
+ return PerZonePings(records: records, orphanedZone: nil)
} catch {
+ let orphan: CKRecordZone.ID?
+ if scope == .shared,
+ self.isInvalidSharedZoneOwnerError(error as NSError) {
+ orphan = zoneID
+ } else {
+ orphan = nil
+ }
await self.trace(
"\(label) ping fast-path: zone \(zoneID.zoneName) failed: " +
"\(error.localizedDescription)"
)
- return []
+ return PerZonePings(records: [], orphanedZone: orphan)
}
}
}
- var all: [[CKRecord]] = []
+ var all: [PerZonePings] = []
for await batch in group {
all.append(batch)
}
return all
}
- let collected: [CKRecord] = perZoneRecords.flatMap { $0 }
+ let collected: [CKRecord] = perZoneRecords.flatMap(\.records)
let pings = collected.compactMap(Self.parsePingRecord)
if let latest = collected.compactMap(\.modificationDate).max() {
pingPushCheckpoints[scopeValue] = latest
}
+
+ let orphans = Set(perZoneRecords.compactMap(\.orphanedZone))
+ if !orphans.isEmpty {
+ await applyZoneOrphaning(orphans, isPrivate: scope == .private)
+ }
+
await trace(
"\(label) ping fast-path: zones=\(zones.count), pings=\(pings.count)"
)
@@ -682,13 +700,14 @@ actor SyncEngine {
let records: [CKRecord]
let pings: [Ping]
let players: [Session]
+ let orphanedZone: CKRecordZone.ID?
}
let perZone = await withTaskGroup(of: PerZoneActivity.self) { group in
for zone in zones {
group.addTask { [weak self] in
guard let self else {
- return PerZoneActivity(records: [], pings: [], players: [])
+ return PerZoneActivity(records: [], pings: [], players: [], orphanedZone: nil)
}
do {
async let pingRecords = self.queryLiveRecords(
@@ -712,14 +731,27 @@ actor SyncEngine {
return PerZoneActivity(
records: players,
pings: pings.compactMap(Self.parsePingRecord),
- players: activities
+ players: activities,
+ orphanedZone: nil
)
} catch {
+ let orphan: CKRecordZone.ID?
+ if scope == .shared,
+ self.isInvalidSharedZoneOwnerError(error as NSError) {
+ orphan = zone.zoneID
+ } else {
+ orphan = nil
+ }
await self.trace(
"\(label) background session scan: zone \(zone.zoneID.zoneName) failed: " +
"\(error.localizedDescription)"
)
- return PerZoneActivity(records: [], pings: [], players: [])
+ return PerZoneActivity(
+ records: [],
+ pings: [],
+ players: [],
+ orphanedZone: orphan
+ )
}
}
}
@@ -739,6 +771,11 @@ actor SyncEngine {
)
}
+ let orphans = Set(perZone.compactMap(\.orphanedZone))
+ if !orphans.isEmpty {
+ await applyZoneOrphaning(orphans, isPrivate: scope == .private)
+ }
+
let pings = perZone.flatMap(\.pings)
let players = perZone.flatMap(\.players)
await trace(
@@ -1329,7 +1366,7 @@ actor SyncEngine {
ctx.performAndWait {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(
- format: "databaseScope == %d AND completedAt == nil",
+ format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO",
scope
)
guard let entities = try? ctx.fetch(req) else { return [] }
@@ -1349,7 +1386,14 @@ actor SyncEngine {
) -> [(zoneID: CKRecordZone.ID, createdAt: Date)] {
ctx.performAndWait {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "databaseScope == %d", scope)
+ // Skip access-revoked entries so an orphaned shared zone — e.g.
+ // one whose participant binding became invalid and now returns
+ // "Cannot convert userId to dsId" on every query — stops being
+ // re-queried by the direct fetch paths.
+ req.predicate = NSPredicate(
+ format: "databaseScope == %d AND isAccessRevoked == NO",
+ scope
+ )
guard let entities = try? ctx.fetch(req) else { return [] }
var seen = Set<String>()
var result: [(CKRecordZone.ID, Date)] = []
@@ -1373,7 +1417,7 @@ actor SyncEngine {
ctx.performAndWait {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(
- format: "databaseScope == %d AND completedAt == nil",
+ format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO",
scope
)
guard let entities = try? ctx.fetch(req) else { return [] }