crossmate

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

commit 65d6d5da27b95af2201de210b5780385c24a6e2f
parent c6073ccdf2e5365010503edf6095c641e6ca2344
Author: Michael Camilleri <[email protected]>
Date:   Sun, 17 May 2026 18:51:00 +0900

Keep the ping fast-path from matching its own newest record

Prior to this commit, the background ping fast path floored each per-zone Ping
query at the stored checkpoint minus a 30s overlap, then set that checkpoint to
the maximum modificationDate of the records the scan had just returned. Because
the floor was always derived from — and 30s behind — the newest record it had
just matched, `modificationDate > floor` re-matched that record on every
subsequent push. The checkpoint could never climb past it; it was pinned 30s
behind itself. So the newest ping in a scope (e.g. a freshly written .win) was
re-fetched and re-delivered to onPings on every database push for the whole
session — a full N-zone CKQuery fan-out every couple of seconds accomplishing
nothing — and the traced `pings=` count could never fall back to zero once any
ping had been seen. State stayed correct only because the presentation layer
dedupes notifications by record name and the .opened/.friend handlers are
idempotent; the cost was pure wasted work and a metric that no longer signalled
anything.

The checkpoint now advances monotonically — max of the prior value and the
batch's latest, never assigned the batch max outright — so a slow zone's older
records can't drag every zone's window backward. Emission is deduped at the
detection layer via a per-scope record-name to modificationDate map: a ping
reaches onPings only on first sighting, mirroring the presentation- layer
dedupe already in NotificationState. The overlap window keeps doing its real
job — a skew-tolerant re-fetch so a record whose server modificationDate lands
behind the checkpoint isn't missed — without driving an unbounded re-emit. The
map is pruned to the overlap window each scan so it stays bounded to ~30s of
pings rather than the session, and is cleared alongside pingPushCheckpoints on
engine reinit. The trace gains a `dup=` field for suppressed re-fetches, so
`pings=` is now an honest count of genuinely new pings that returns to zero at
rest.

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

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

diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -139,6 +139,13 @@ actor SyncEngine { /// (0 = private, 1 = shared). private var pingPushCheckpoints: [Int16: Date] = [:] private let pingPushCheckpointOverlap: TimeInterval = 30 + /// Per-scope record-name → modificationDate of Ping records already + /// surfaced by the fast path. The time-window query deliberately re-fetches + /// anything within `pingPushCheckpointOverlap` of the floor (skew safety), + /// so without this a record — unboundedly, the newest one — would re-emit + /// on every push. Pruned to the overlap window each scan, so it stays + /// small. Mirrors the presentation-layer dedupe in `NotificationState`. + private var seenPingRecords: [Int16: [String: Date]] = [:] private let backgroundSessionLookback: TimeInterval = 10 * 60 func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { @@ -869,10 +876,36 @@ actor SyncEngine { } let collected: [CKRecord] = perZoneRecords.flatMap(\.records) - let pings = collected.compactMap(Self.parsePingRecord) + // Dedupe by record name: the overlap window re-fetches recent pings on + // every push, so emit each only on first sighting. Without this the + // newest ping re-fires forever — the floor is the stored checkpoint + // minus the overlap, so `modificationDate > floor` always re-matches it. + var seen = seenPingRecords[scopeValue] ?? [:] + var pings: [Ping] = [] + var fetchedCount = 0 + for record in collected { + guard let ping = Self.parsePingRecord(record) else { continue } + fetchedCount += 1 + let modDate = record.modificationDate ?? Date() + if seen.updateValue(modDate, forKey: record.recordID.recordName) == nil { + pings.append(ping) + } + } + + // Advance the checkpoint monotonically — `max(prior, latest)`, never + // `= latest` — so a slow zone's older batch can't drag every zone's + // window backward. if let latest = collected.compactMap(\.modificationDate).max() { - pingPushCheckpoints[scopeValue] = latest + let prior = pingPushCheckpoints[scopeValue] ?? .distantPast + pingPushCheckpoints[scopeValue] = max(prior, latest) + } + // Forget names the next query's floor can no longer return, keeping + // the seen set bounded to the overlap window rather than the session. + if let checkpoint = pingPushCheckpoints[scopeValue] { + let floor = checkpoint.addingTimeInterval(-pingPushCheckpointOverlap) + seen = seen.filter { $0.value >= floor } } + seenPingRecords[scopeValue] = seen let orphans = Set(perZoneRecords.compactMap(\.orphanedZone)) if !orphans.isEmpty { @@ -880,7 +913,8 @@ actor SyncEngine { } await trace( - "\(label) ping fast-path: zones=\(zones.count), pings=\(pings.count)" + "\(label) ping fast-path: zones=\(zones.count), " + + "pings=\(pings.count), dup=\(fetchedCount - pings.count)" ) if !pings.isEmpty, let onPings { @@ -1711,6 +1745,7 @@ actor SyncEngine { )) pendingPings = [:] pingPushCheckpoints = [:] + seenPingRecords = [:] liveQueryCheckpoints = [:] loggedFirstSharedPushPayload = false _ = enqueueUnconfirmedMoves()