crossmate

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

commit 2329fba9234266b08cd5288f10f2339bc5a330f5
parent adef3fa3892c2cf2567ee8b79b181e13033b4ab6
Author: Michael Camilleri <[email protected]>
Date:   Sun, 24 May 2026 06:53:04 +0900

Coalesce Player-record sends and log send success correctly

A diagnostics log review of a normal puzzle-open flow showed five separate
'private sent: 1 saved (Player=1)' lines within six seconds with no user input,
and a 'Last Success' timestamp that ignored every one of them. This commit
addresses this in two ways.

First, enqueuePlayerRecord now takes a `reason: String` that is traced at
enqueue time, and the outbound drain is routed through a 400 ms per-scope
coalescing window (CKSyncEngine state already dedupes repeated .saveRecord for
the same record ID, so several writes in the same tick ride one round-trip
instead of one round-trip each).  Reasons distinguish PlayerSelectionPublisher
('selection'), PlayerNamePublisher's open vs. rename paths ('name-open',
'name-rename'), and the two publishReadCursor modes ('readCursor(activeLease)',
'readCursor(currentTime)').

Second, a new SyncMonitor.noteSuccess() now fires from the three delegate
handlers (handleFetchedDatabaseChanges, handleFetchedRecordZoneChanges and
handleSentRecordZoneChanges when failedRecordSaves is empty) via a
successCheckpoint callback wired in AppServices alongside the existing
tracer. Error-clearing stays with recordSuccess(_ phase:) — it relies on the
failing/recovering phase names matching, which a generic delegate event cannot
provide.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 29+++++++++++++++++++++++++----
MCrossmate/Services/DebuggingMonitors.swift | 11+++++++++++
MCrossmate/Services/PlayerNamePublisher.swift | 8++++----
MCrossmate/Sync/SyncEngine.swift | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MTests/Unit/PlayerNamePublisherTests.swift | 4++--
MTests/Unit/Sync/ZoneOrphaningTests.swift | 2+-
6 files changed, 105 insertions(+), 16 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -208,7 +208,11 @@ final class AppServices { sink: { gameID, authorID in let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } guard isEnabled else { return } - await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID) + await syncEngine.enqueuePlayerRecord( + gameID: gameID, + authorID: authorID, + reason: "selection" + ) } ) self.friendController = FriendController( @@ -258,6 +262,10 @@ final class AppServices { syncMonitor.note(message) } + await syncEngine.setSuccessCheckpoint { [syncMonitor] in + syncMonitor.noteSuccess() + } + await syncEngine.setLocalAuthorIDProvider { [identity] in identity.currentID } @@ -383,10 +391,14 @@ final class AppServices { preferences: preferences, persistence: persistence, authorIdentity: identity, - enqueuePlayerRecord: { [preferences, syncEngine] gameID, authorID in + enqueuePlayerRecord: { [preferences, syncEngine] gameID, authorID, reason in let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } guard isEnabled else { return } - await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID) + await syncEngine.enqueuePlayerRecord( + gameID: gameID, + authorID: authorID, + reason: reason + ) } ) @@ -1621,7 +1633,16 @@ final class AppServices { didUpdate = store.setReadCursor(gameID: gameID, readAt: now) } guard didUpdate else { return } - await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID) + let reason: String + switch mode { + case .activeLease: reason = "readCursor(activeLease)" + case .currentTime: reason = "readCursor(currentTime)" + } + await syncEngine.enqueuePlayerRecord( + gameID: gameID, + authorID: authorID, + reason: reason + ) } /// Sets the app icon badge to the number of shared games with unread diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift @@ -252,6 +252,17 @@ final class SyncMonitor { log.note(message) } + /// Bumps `lastSuccessAt` without writing a `succeeded` log line. Used by + /// the CKSyncEngine delegate handlers so that auto-drained sends and + /// framework-initiated fetches — which never go through `run(_:_:)` — + /// still count as "the engine is alive and round-tripping with the + /// server". Without this the timestamp pins to whichever instrumented + /// phase last ran and silently lies about engine health when only + /// auto-drain activity is happening. + func noteSuccess() { + lastSuccessAt = Date() + } + func updateSnapshot(_ snapshot: SyncEngine.DiagnosticSnapshot) { self.snapshot = snapshot } diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift @@ -14,7 +14,7 @@ final class PlayerNamePublisher { private let preferences: PlayerPreferences private let persistence: PersistenceController private let authorIdentity: AuthorIdentity - private let enqueuePlayerRecord: (UUID, String) async -> Void + private let enqueuePlayerRecord: (UUID, String, String) async -> Void private var debounceTask: Task<Void, Never>? private var observationTask: Task<Void, Never>? @@ -27,7 +27,7 @@ final class PlayerNamePublisher { preferences: PlayerPreferences, persistence: PersistenceController, authorIdentity: AuthorIdentity, - enqueuePlayerRecord: @escaping (UUID, String) async -> Void + enqueuePlayerRecord: @escaping (UUID, String, String) async -> Void ) { self.preferences = preferences self.persistence = persistence @@ -89,7 +89,7 @@ final class PlayerNamePublisher { name: preferences.name ) else { return } - await enqueuePlayerRecord(gameID, authorID) + await enqueuePlayerRecord(gameID, authorID, "name-open") } private func fanOut(newName: String) async { @@ -104,7 +104,7 @@ final class PlayerNamePublisher { ) for gameID in touchedGameIDs { - await enqueuePlayerRecord(gameID, authorID) + await enqueuePlayerRecord(gameID, authorID, "name-rename") } onFanOutForTesting?(newName) diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -104,6 +104,12 @@ actor SyncEngine { var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date)]) async -> Void)? private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)? private var tracer: (@MainActor @Sendable (String) -> Void)? + /// Fires when a delegate event reports a successful round-trip with the + /// CloudKit server (fetched DB changes, fetched zone changes, or sent + /// zone changes with no failures). Bumps `Last Success` in the + /// diagnostics view so the timestamp reflects actual engine activity + /// rather than only instrumented phases run through `SyncMonitor.run`. + private var successCheckpoint: (@MainActor @Sendable () -> Void)? var liveQueryCheckpoints: [String: Date] = [:] let liveQueryCheckpointOverlap: TimeInterval = 5 @@ -126,6 +132,15 @@ actor SyncEngine { tracer = t } + func setSuccessCheckpoint(_ cb: @MainActor @Sendable @escaping () -> Void) { + successCheckpoint = cb + } + + private func noteRoundTripSuccess() async { + guard let successCheckpoint else { return } + await successCheckpoint() + } + func setOnRemoteMovesUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { onRemoteMovesUpdated = cb } @@ -271,6 +286,34 @@ actor SyncEngine { Task.detached { [engine] in try? await engine.sendChanges() } } + /// Per-scope debounce slot for the coalesced Player-record drain. + /// Keyed by `GameEntity.databaseScope` (0 = private, 1 = shared). + private var playerSendDebounce: [Int16: Task<Void, Never>] = [:] + /// How long to wait after the first Player enqueue before firing + /// `sendChanges`. Long enough to absorb a puzzle-open burst (read cursor + /// + name + cursor track land within ~500 ms), short enough that a true + /// solo edit doesn't perceptibly defer its push. + private static let playerSendCoalesceWindow: Duration = .milliseconds(400) + + /// Coalescing variant of `sendChangesDetached` for the Player-record + /// path. The first enqueue in a window schedules a deferred drain; + /// subsequent enqueues for the same scope are absorbed into that same + /// drain (CKSyncEngine state dedupes repeated `.saveRecord` for the same + /// record ID, and one `sendChanges` ships them all in a single batch). + private func scheduleCoalescedPlayerSend(on engine: CKSyncEngine, scope: Int16) { + if playerSendDebounce[scope] != nil { return } + let window = Self.playerSendCoalesceWindow + playerSendDebounce[scope] = Task.detached { [engine, weak self] in + try? await Task.sleep(for: window) + await self?.clearPlayerSendDebounce(scope: scope) + try? await engine.sendChanges() + } + } + + private func clearPlayerSendDebounce(scope: Int16) { + playerSendDebounce.removeValue(forKey: scope) + } + // MARK: - Outbound /// Registers each game's local-device Moves record as a pending save and @@ -638,10 +681,15 @@ actor SyncEngine { sendChangesDetached(on: engine) } - /// Registers a Player record as a pending send. Used by `PlayerNamePublisher` - /// when the local user renames; one record per (game, authorID), so - /// participants only ever write their own slot. - func enqueuePlayerRecord(gameID: UUID, authorID: String) { + /// Registers a Player record as a pending send. One record per + /// (game, authorID), so participants only ever write their own slot. + /// `reason` is logged so the diagnostics view can attribute each enqueue + /// to its caller (rename, read cursor, cursor track, …); it does not + /// affect routing. The outbound drain is coalesced via + /// `scheduleCoalescedPlayerSend` so several enqueues in the same tick + /// (puzzle-open fans out name + read cursor + cursor track in <1s) ship + /// as one batch instead of one round-trip per record. + func enqueuePlayerRecord(gameID: UUID, authorID: String, reason: String) async { let ctx = persistence.container.newBackgroundContext() guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } let engine = info.scope == 1 ? sharedEngine : privateEngine @@ -649,7 +697,8 @@ actor SyncEngine { let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) - sendChangesDetached(on: engine) + await trace("enqueue Player[\(gameID.uuidString.prefix(8))] reason=\(reason)") + scheduleCoalescedPlayerSend(on: engine, scope: info.scope) } /// Registers a Game record as a pending send and ensures its zone is @@ -782,6 +831,7 @@ actor SyncEngine { "\(isPrivate ? "private" : "shared") db changes [src=\(src)]: " + "\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions" ) + await noteRoundTripSuccess() // Private-DB zone deletions reflect the user removing one of their own // games on another device — hard-delete locally so the row stops @@ -882,6 +932,7 @@ actor SyncEngine { "\(isPrivate ? "private" : "shared") fetch [src=\(src)]: " + "\(event.modifications.count) modifications, \(event.deletions.count) deletions" ) + await noteRoundTripSuccess() if !isPrivate, !loggedFirstSharedPushPayload, src == "push", event.modifications.count + event.deletions.count > 0 { loggedFirstSharedPushPayload = true @@ -1086,6 +1137,12 @@ actor SyncEngine { "\(event.deletedRecordIDs.count) deleted" + "\(deletedSummary.isEmpty ? "" : " (\(deletedSummary))")" ) + // Only bump on a clean batch: a round trip with per-record failures + // is not "success" from the user's point of view, and the existing + // SyncMonitor.recordError path owns reporting whatever did break. + if event.failedRecordSaves.isEmpty { + await noteRoundTripSuccess() + } for record in event.savedRecords { let name = record.recordID.recordName if name.hasPrefix("ping-") { diff --git a/Tests/Unit/PlayerNamePublisherTests.swift b/Tests/Unit/PlayerNamePublisherTests.swift @@ -73,7 +73,7 @@ struct PlayerNamePublisherTests { preferences: preferences, persistence: persistence, authorIdentity: AuthorIdentity(testing: authorID), - enqueuePlayerRecord: { _, _ in } + enqueuePlayerRecord: { _, _, _ in } ) } @@ -184,7 +184,7 @@ struct PlayerNamePublisherTests { preferences: prefs, persistence: p, authorIdentity: AuthorIdentity(testing: "_local"), - enqueuePlayerRecord: { gameID, _ in enqueued.append(gameID) } + enqueuePlayerRecord: { gameID, _, _ in enqueued.append(gameID) } ) await broadcaster.publishName(for: secondID) diff --git a/Tests/Unit/Sync/ZoneOrphaningTests.swift b/Tests/Unit/Sync/ZoneOrphaningTests.swift @@ -93,7 +93,7 @@ struct ZoneOrphaningTests { let (gameID, zoneName, owner) = try makeSharedGame(in: ctx) let engine = await makeEngine(persistence: persistence) - await engine.enqueuePlayerRecord(gameID: gameID, authorID: "_localAuthor") + await engine.enqueuePlayerRecord(gameID: gameID, authorID: "_localAuthor", reason: "test") let beforeNames = await engine.pendingSaveRecordNames(scope: .shared) let playerRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: "_localAuthor") #expect(beforeNames.contains(playerRecordName))