crossmate

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

commit 6d4ec291b5791ec93708398a9b8562848e7ab4db
parent 0b2b30ef128811ee8ba95995670cffce96008e58
Author: Michael Camilleri <[email protected]>
Date:   Sun, 17 May 2026 13:57:33 +0900

Reap stale .opened pings from the account zone

Prior to this commit, .opened pings coordinate notification dismissal between a
user's own devices in the private-database account zone, but every puzzle open
enqueued a distinct record and nothing ever deleted them: deleteNonWinPings
only touches per-game zones, and applyOpenedPing can't delete on consume since
a sibling that hasn't synced yet still needs the record — so the zone grew
without bound. With no device registry to drive consumption-based cleanup,
sweepStaleOpenedPings instead deletes only this device's own records older than
14 days, on the assumption every other device has synced within that window (a
missed dismissal past two weeks is within the app's eventual-consistency
tolerance). The sweep runs as its own Task from enqueueOpenedPing using direct
CKDatabase operations, so it neither blocks the per-open path nor risks
CKSyncEngine re-entry, and is throttled to once a day via shared defaults with
the timestamp written only on success.

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

Diffstat:
MCrossmate/Sync/SyncEngine.swift | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MShared/NotificationState.swift | 23+++++++++++++++++++++++
MTests/Unit/NotificationStateTests.swift | 44++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 129 insertions(+), 1 deletion(-)

diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -441,6 +441,52 @@ actor SyncEngine { let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) Task { try? await engine.sendChanges() } + Task { await sweepStaleOpenedPings() } + } + + /// Time after which this device's own `.opened` pings are eligible for + /// reaping. `.opened` records can't be consumed-then-deleted — a sibling + /// device that hasn't synced yet still needs them — so age is the only + /// safe signal. A missed dismissal past this window is well within the + /// app's eventual-consistency tolerance. + private static let openedPingTTL: TimeInterval = 14 * 86_400 + + /// Best-effort reaper for this device's own stale `.opened` pings in the + /// account zone, keeping that zone from growing without bound. Filtering + /// is server-side — `Ping.kind` and `Ping.deviceID` are QUERYABLE — so the + /// query returns only the records to delete. Scoped to this device's own + /// records on the assumption every other device of the same user has + /// synced within `openedPingTTL`. Throttled via shared defaults so the + /// per-open call site stays cheap; the timestamp is written only on + /// success, so a transient failure simply retries on the next open. + func sweepStaleOpenedPings() async { + guard NotificationState.shouldRunOpenedSweep() else { return } + let cutoff = Date().addingTimeInterval(-Self.openedPingTTL) + let predicate = NSPredicate( + format: "kind == %@ AND deviceID == %@ AND modificationDate < %@", + PingKind.opened.rawValue, + RecordSerializer.localDeviceID, + cutoff as NSDate + ) + do { + let records = try await queryRecords( + type: "Ping", + database: container.privateCloudDatabase, + zoneID: RecordSerializer.accountZoneID, + predicate: predicate, + desiredKeys: [] + ) + try await deleteRecords( + withIDs: records.map(\.recordID), + in: container.privateCloudDatabase + ) + NotificationState.markOpenedSweepRun() + if !records.isEmpty { + await trace("opened ping sweep: deleted \(records.count) stale own ping(s)") + } + } catch { + await trace("opened ping sweep failed: \(describe(error))") + } } /// Registers an `.invite` Ping into an existing *friend* zone. Unlike @@ -1296,7 +1342,22 @@ actor SyncEngine { desiredKeys: [CKRecord.FieldKey] ) async throws -> [CKRecord] { let since = since ?? Date(timeIntervalSince1970: 0) - let predicate = NSPredicate(format: "modificationDate > %@", since as NSDate) + return try await queryRecords( + type: type, + database: database, + zoneID: zoneID, + predicate: NSPredicate(format: "modificationDate > %@", since as NSDate), + desiredKeys: desiredKeys + ) + } + + private func queryRecords( + type: CKRecord.RecordType, + database: CKDatabase, + zoneID: CKRecordZone.ID, + predicate: NSPredicate, + desiredKeys: [CKRecord.FieldKey] + ) async throws -> [CKRecord] { let query = CKQuery(recordType: type, predicate: predicate) var records: [CKRecord] = [] diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -106,4 +106,27 @@ enum NotificationState { private static func shownPingNames() -> [String] { defaults?.stringArray(forKey: shownPingNamesKey) ?? [] } + + private static let openedSweepAtKey = "notif.openedSweepAt" + + /// Minimum spacing between account-zone `.opened` ping sweeps. The sweep + /// is invoked from the per-open ping broadcast, which is far too hot to + /// query CloudKit each time; once a day bounds zone growth without churn. + static let openedSweepInterval: TimeInterval = 24 * 60 * 60 + + /// True if at least `openedSweepInterval` has elapsed since the last + /// successful sweep, or it has never run. Returns false when the shared + /// suite is unavailable so a missing throttle never means "sweep on every + /// open". + static func shouldRunOpenedSweep(now: Date = Date()) -> Bool { + guard let defaults else { return false } + let last = defaults.double(forKey: openedSweepAtKey) + guard last > 0 else { return true } + return now.timeIntervalSince1970 - last >= openedSweepInterval + } + + /// Records that a sweep just completed successfully. + static func markOpenedSweepRun(now: Date = Date()) { + defaults?.set(now.timeIntervalSince1970, forKey: openedSweepAtKey) + } } diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift @@ -29,4 +29,48 @@ struct NotificationStateTests { #expect(NotificationState.activePuzzleID() == gameID) NotificationState.setActivePuzzleID(nil) } + + @Test("Opened sweep is suppressed until the interval elapses, then allowed") + func openedSweepThrottleBoundary() { + let base = Date(timeIntervalSince1970: 1_000_000) + let interval = NotificationState.openedSweepInterval + NotificationState.markOpenedSweepRun(now: base) + + // Immediately after a run, and right up to one tick before the + // interval, the sweep stays suppressed. + #expect(!NotificationState.shouldRunOpenedSweep(now: base)) + #expect(!NotificationState.shouldRunOpenedSweep( + now: base.addingTimeInterval(interval - 1) + )) + + // The window opens exactly at the interval (the `>=` boundary) and + // stays open afterwards. + #expect(NotificationState.shouldRunOpenedSweep( + now: base.addingTimeInterval(interval) + )) + #expect(NotificationState.shouldRunOpenedSweep( + now: base.addingTimeInterval(interval + 1) + )) + } + + @Test("Recording a later sweep re-arms the throttle") + func openedSweepThrottleReArms() { + let base = Date(timeIntervalSince1970: 2_000_000) + let interval = NotificationState.openedSweepInterval + NotificationState.markOpenedSweepRun(now: base) + + let due = base.addingTimeInterval(interval) + #expect(NotificationState.shouldRunOpenedSweep(now: due)) + + // A successful sweep at `due` pushes the next eligible time forward + // by another full interval. + NotificationState.markOpenedSweepRun(now: due) + #expect(!NotificationState.shouldRunOpenedSweep(now: due)) + #expect(!NotificationState.shouldRunOpenedSweep( + now: due.addingTimeInterval(interval - 1) + )) + #expect(NotificationState.shouldRunOpenedSweep( + now: due.addingTimeInterval(interval) + )) + } }