crossmate

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

commit 3c0109fc51617fb3e7e469531d5c044c5b38d32a
parent 65d6d5da27b95af2201de210b5780385c24a6e2f
Author: Michael Camilleri <[email protected]>
Date:   Sun, 17 May 2026 19:52:58 +0900

Exclude completed puzzles from the ping fast path

Prior to this commit, the background ping fast path scanned every zone
knownZones returned for a scope — all owned and joined game zones plus the
account and friend zones — on every database push. Most of a long-lived library
is finished puzzles, so a device with 25 games (of which 23 were complete)
fanned 23 private CKQuery operations out per push (every couple of seconds
during a collaborative session) across completed zones that, by definition,
have no live collaboration and so no incoming pings worth the sub-second
shortcut.

knownZones gains an opt-in onlyIncomplete flag (completedAt == nil added to the
GameEntity predicate), matching the existing incompleteKnownZones vocabulary;
only fetchPushPingsDirect passes it. The account and friend zones are still
appended unconditionally — they carry .opened/.invite/ .friend, have no
GameEntity, and no completion concept — so their pings keep their fast path.
The flag must stay opt-in: discoverNewZonesDirect diffs the full known set
against the server to find new zones, so excluding completed games there would
make every finished zone look new and re-pull it on each discovery. Skipping a
completed zone's fast path is within the eventual-consistency tolerance: a late
.win/.opened still arrives via CKSyncEngine's own push-driven
fetchedRecordZoneChanges, which surfaces Ping records for every tracked zone
regardless of completion. The traced zones= count now reflects the active set,
so it stays a meaningful signal instead of a fixed library-size constant.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Sync/SyncEngine.swift | 29+++++++++++++++++++++++++++--
1 file changed, 27 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -814,7 +814,15 @@ actor SyncEngine { } let ctx = persistence.container.newBackgroundContext() - let zones = knownZones(forScope: scopeValue, in: ctx) + // Completed puzzles are excluded: the fast path only shaves push + // latency for live collaboration, and finished zones' late pings + // still land via CKSyncEngine's own change fetch. This trims the + // per-push fan-out from every known zone to just the active ones. + let zones = knownZones( + forScope: scopeValue, + onlyIncomplete: true, + in: ctx + ) guard !zones.isEmpty else { await trace("\(label) ping fast-path: no known zones") return 0 @@ -1821,6 +1829,7 @@ actor SyncEngine { /// games, they pre-date the game's existence). private nonisolated func knownZones( forScope scope: Int16, + onlyIncomplete: Bool = false, in ctx: NSManagedObjectContext ) -> [(zoneID: CKRecordZone.ID, createdAt: Date)] { ctx.performAndWait { @@ -1829,8 +1838,24 @@ actor SyncEngine { // 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. + // + // `onlyIncomplete` additionally drops finished puzzles' game + // zones (keeps only completedAt == nil). Only the ping fast path + // passes it: that path is a + // latency shortcut, and a completed puzzle has no live + // collaboration, so a late .win/.opened there still arrives via + // CKSyncEngine's own push-driven fetchedRecordZoneChanges, which + // surfaces Ping records for every tracked zone regardless of + // completion. It must stay opt-in — discoverNewZonesDirect diffs + // the *full* known set against the server to spot new zones, so + // excluding completed games there would make every finished zone + // look new and re-pull it on each discovery. The account and + // friend zones below are appended unconditionally: they carry + // .opened/.invite/.friend, have no GameEntity, and no completion. req.predicate = NSPredicate( - format: "databaseScope == %d AND isAccessRevoked == NO", + format: onlyIncomplete + ? "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO" + : "databaseScope == %d AND isAccessRevoked == NO", scope ) guard let entities = try? ctx.fetch(req) else { return [] }