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:
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 [] }