crossmate

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

commit e2a65536e80906b0a95ea0eda1f79de531705f47
parent eb2ae22303c2f408d41f25c7c08d504aee3337de
Author: Michael Camilleri <[email protected]>
Date:   Wed, 22 Apr 2026 18:19:24 +0900

Improve throttle avoidance

The way that Crossmate records moves leads to a large number of CloudKit
operations. For large puzzles, this will almost inevitably lead to
throttling. This commit adds debouncing and serialised pushes in an
attempt to avoid this.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/CrossmateApp.swift | 7++++++-
MCrossmate/Services/AppServices.swift | 31++++++++++++++++++++++---------
ACrossmate/Sync/PushDebouncer.swift | 44++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
ATests/Unit/PushDebouncerTests.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 222 insertions(+), 17 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; }; 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; }; + 2604F612080A211A8D249237 /* PushDebouncerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D859D183886DEE009E5495B /* PushDebouncerTests.swift */; }; 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; }; 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; }; 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; @@ -45,6 +46,7 @@ C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; + C80AA954E10B5C1A65CAE335 /* PushDebouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D1A5FDF357F6541B4D485AE /* PushDebouncer.swift */; }; C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; }; CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */; }; CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; }; @@ -95,9 +97,11 @@ 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; + 7D1A5FDF357F6541B4D485AE /* PushDebouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushDebouncer.swift; sourceTree = "<group>"; }; 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; }; 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; + 8D859D183886DEE009E5495B /* PushDebouncerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushDebouncerTests.swift; sourceTree = "<group>"; }; 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; @@ -147,6 +151,7 @@ isa = PBXGroup; children = ( E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */, + 7D1A5FDF357F6541B4D485AE /* PushDebouncer.swift */, 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */, AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */, @@ -170,6 +175,7 @@ BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */, + 8D859D183886DEE009E5495B /* PushDebouncerTests.swift */, 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, ); name = Unit; @@ -388,6 +394,7 @@ 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, 83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */, + 2604F612080A211A8D249237 /* PushDebouncerTests.swift in Sources */, ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, ); @@ -428,6 +435,7 @@ 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */, F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */, 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */, + C80AA954E10B5C1A65CAE335 /* PushDebouncer.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -81,8 +81,13 @@ struct RootView: View { } } .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { + switch newPhase { + case .active: Task { await services.syncOnForeground() } + case .background, .inactive: + Task { await services.syncOnBackground() } + @unknown default: + break } } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -12,20 +12,30 @@ final class AppServices { let nytFetcher: NYTPuzzleFetcher private let outboxRecorder: OutboxRecorder + private let pushDebouncer: PushDebouncer private var started = false init() { self.persistence = PersistenceController() self.outboxRecorder = OutboxRecorder(persistence: persistence) self.store = GameStore(persistence: persistence) - self.syncEngine = SyncEngine( + let syncEngine = SyncEngine( container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v2"), persistence: persistence ) + self.syncEngine = syncEngine self.syncMonitor = SyncMonitor() self.nytAuth = NYTAuthService() self.driveMonitor = DriveMonitor() self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } + // 1.5s trailing-edge debounce: rapid keystrokes share a single push. + // Foreground/background transitions flush immediately. + let monitor = self.syncMonitor + self.pushDebouncer = PushDebouncer(interval: .milliseconds(1500)) { + await Self.run("debounced push", monitor: monitor) { + try await syncEngine.pushChanges() + } + } } func start(appDelegate: AppDelegate) async { @@ -35,9 +45,9 @@ final class AppServices { nytAuth.loadStoredSession() driveMonitor.start() - store.onPendingChangesAvailable = { + store.onPendingChangesAvailable = { [pushDebouncer] in Task { - await self.pushPendingChanges() + await pushDebouncer.schedule() } } @@ -68,6 +78,9 @@ final class AppServices { } func syncOnForeground() async { + // Flush any debounced keystroke before our own push so the two share + // one operation against CloudKit instead of racing each other. + await pushDebouncer.flush() await Self.run("foreground fetch", monitor: syncMonitor) { try await syncEngine.fetchChanges() } @@ -77,6 +90,12 @@ final class AppServices { await refreshSnapshot() } + /// Flushes any debounced push so pending edits reach CloudKit before the + /// app suspends. Called from the scene-phase observer on background. + func syncOnBackground() async { + await pushDebouncer.flush() + } + func handleOpenURL(_ url: URL) -> UUID? { guard url.pathExtension.lowercased() == "xd" else { return nil } @@ -104,12 +123,6 @@ final class AppServices { return try? store.createGame(from: source) } - private func pushPendingChanges() async { - await Self.run("local pending push", monitor: syncMonitor) { - try await syncEngine.pushChanges() - } - } - private func handleRemoteNotification() async { await Self.run("remote-notification fetch", monitor: syncMonitor) { try await syncEngine.fetchChanges() diff --git a/Crossmate/Sync/PushDebouncer.swift b/Crossmate/Sync/PushDebouncer.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Coalesces rapid push triggers into a single delayed call. Every keystroke +/// notifies the debouncer; the underlying `pushChanges()` action runs at most +/// once per `interval` after the last trigger. `flush()` bypasses the delay +/// and runs the action immediately — used when the app backgrounds or comes +/// to the foreground and we want sync to catch up without waiting. +actor PushDebouncer { + private let interval: Duration + private let action: @Sendable () async -> Void + private var pending: Task<Void, Never>? + + init(interval: Duration, action: @escaping @Sendable () async -> Void) { + self.interval = interval + self.action = action + } + + /// Schedule the action to run after `interval`. Repeated calls within the + /// window replace the pending task, extending the wait (trailing-edge + /// debounce). + func schedule() { + pending?.cancel() + let interval = interval + let action = action + pending = Task { [weak self] in + try? await Task.sleep(for: interval) + if Task.isCancelled { return } + await self?.clearPending() + await action() + } + } + + /// Cancels any pending schedule and runs the action now. Safe to call + /// even if nothing is scheduled. + func flush() async { + pending?.cancel() + pending = nil + await action() + } + + private func clearPending() { + pending = nil + } +} diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -53,6 +53,11 @@ actor SyncEngine { /// on-device diagnostics view. private var tracer: (@MainActor @Sendable (String) -> Void)? + /// The task running the current `performPushChanges`. Second callers + /// await this task's completion instead of starting a second push, so + /// overlapping triggers coalesce into one operation against CloudKit. + private var currentPushTask: Task<Void, Error>? + func setTracer(_ callback: @MainActor @Sendable @escaping (String) -> Void) { tracer = callback } @@ -247,9 +252,25 @@ actor SyncEngine { // MARK: - Push /// Drains the `PendingChangeEntity` outbox and pushes records to CloudKit. - /// Loops until the outbox is empty, processing up to 400 records per batch - /// (the CloudKit per-operation limit). + /// Single-flight: if a push is already running, callers await that task + /// rather than starting an overlapping operation. The drain loop inside + /// `performPushChanges` picks up any pending changes enqueued during the + /// push, so no second call is needed to ship in-flight edits. func pushChanges() async throws { + if let existing = currentPushTask { + try await existing.value + return + } + let task = Task { [weak self] () async throws -> Void in + guard let self else { return } + try await self.performPushChanges() + } + currentPushTask = task + defer { currentPushTask = nil } + try await task.value + } + + private func performPushChanges() async throws { guard await accountAvailable() else { return } try await resolveOwnerIfNeeded() @@ -365,6 +386,16 @@ actor SyncEngine { recordIDsToDelete: recordIDsToDelete ) } catch { + // Operation-level failures (e.g. `.requestRateLimited` or + // `.zoneBusy` when CloudKit rejects the whole operation + // rather than reporting per-record results) deserve a + // `CKErrorRetryAfterKey` sleep before the next batch. Any + // other error propagates so the caller can surface it. + if let delay = throttleDelay(for: error) { + await trace("push[\(iteration)]: operation-level throttle (\(describe(error))), sleeping \(delay)s") + try await Task.sleep(for: .seconds(delay)) + continue + } await trace("push[\(iteration)]: pushRecords THREW: \(describe(error))") throw error } @@ -372,12 +403,13 @@ actor SyncEngine { // Process results on the background context. Returns a tuple so // we don't have to mutate captured locals from inside the closure. - let processed: (successes: Int, failures: Int, errors: [String], cellChanges: [RemoteCellChange]) = + let processed: (successes: Int, failures: Int, errors: [String], cellChanges: [RemoteCellChange], retryAfter: TimeInterval?) = context.performAndWait { var successes = 0 var failures = 0 var errors: [String] = [] var cellChanges: [RemoteCellChange] = [] + var maxRetryAfter: TimeInterval? for (recordID, result) in perRecordResults { let recordName = recordID.recordName @@ -402,6 +434,9 @@ actor SyncEngine { continue } failures += 1 + if let delay = self.throttleDelay(for: error) { + maxRetryAfter = max(maxRetryAfter ?? 0, delay) + } let changes = self.handlePushError( error: error, recordName: recordName, @@ -412,12 +447,13 @@ actor SyncEngine { } } try? context.save() - return (successes, failures, errors, cellChanges) + return (successes, failures, errors, cellChanges, maxRetryAfter) } let successes = processed.successes let failures = processed.failures let errorMessages = processed.errors + let retryAfter = processed.retryAfter serverWinsCellChanges.append(contentsOf: processed.cellChanges) await trace("push[\(iteration)]: processed \(successes) success / \(failures) failure") @@ -425,10 +461,20 @@ actor SyncEngine { await trace("push[\(iteration)]: \(msg)") } + if let retryAfter { + // Per-record throttle: sleep before the next iteration so we + // don't hammer CloudKit and tighten the device cooldown. + // Pending changes stay in the outbox; drain picks them up + // again after the sleep. + await trace("push[\(iteration)]: per-record throttle, sleeping \(retryAfter)s before retry") + try await Task.sleep(for: .seconds(retryAfter)) + continue + } + if failures > 0 && successes == 0 { - // If every record in this batch failed, the next iteration - // will pull the same items and loop forever. Break out and - // surface the situation through the diagnostics view. + // Non-throttle all-failure batch: next iteration would pull + // the same items and loop forever. Break out and surface the + // situation through the diagnostics view. await trace("push[\(iteration)]: all-failure batch, aborting drain to avoid infinite loop") break } @@ -754,6 +800,27 @@ actor SyncEngine { return ckError.code == .unknownItem } + /// Returns the number of seconds to wait before retrying a throttled + /// operation, or `nil` if the error is not a throttle. Prefers the + /// server-supplied `CKErrorRetryAfterKey` and falls back to a modest + /// default when CloudKit omitted the hint. + private nonisolated func throttleDelay(for error: Error) -> TimeInterval? { + guard let ckError = error as? CKError else { return nil } + switch ckError.code { + case .serviceUnavailable, .requestRateLimited, .zoneBusy: + break + default: + return nil + } + if let seconds = ckError.userInfo[CKErrorRetryAfterKey] as? TimeInterval { + return seconds + } + if let number = ckError.userInfo[CKErrorRetryAfterKey] as? NSNumber { + return number.doubleValue + } + return 5 + } + /// Returns any `RemoteCellChange` values that resulted from the server /// winning a conflict (so the caller can route them through the single /// inbox on the main actor). diff --git a/Tests/Unit/PushDebouncerTests.swift b/Tests/Unit/PushDebouncerTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("PushDebouncer", .serialized) +struct PushDebouncerTests { + + /// Thread-safe counter for actions observed by the debouncer under test. + actor Counter { + private(set) var count = 0 + func increment() { count += 1 } + } + + @Test("Multiple schedules within the window collapse to one call") + func coalescesWithinWindow() async throws { + let counter = Counter() + let debouncer = PushDebouncer(interval: .milliseconds(100)) { + await counter.increment() + } + + for _ in 0..<5 { + await debouncer.schedule() + try await Task.sleep(for: .milliseconds(20)) + } + + try await Task.sleep(for: .milliseconds(200)) + let count = await counter.count + #expect(count == 1) + } + + @Test("Flush runs the action immediately and cancels pending") + func flushFiresImmediately() async throws { + let counter = Counter() + let debouncer = PushDebouncer(interval: .seconds(5)) { + await counter.increment() + } + + await debouncer.schedule() + await debouncer.flush() + let afterFlush = await counter.count + #expect(afterFlush == 1) + + // The previously-scheduled task should have been cancelled by flush, + // so waiting past the original interval must not add another call. + try await Task.sleep(for: .milliseconds(200)) + let later = await counter.count + #expect(later == 1) + } + + @Test("Schedule after completion fires again") + func secondBurstFires() async throws { + let counter = Counter() + let debouncer = PushDebouncer(interval: .milliseconds(80)) { + await counter.increment() + } + + await debouncer.schedule() + try await Task.sleep(for: .milliseconds(200)) + let afterFirst = await counter.count + #expect(afterFirst == 1) + + await debouncer.schedule() + try await Task.sleep(for: .milliseconds(200)) + let afterSecond = await counter.count + #expect(afterSecond == 2) + } +}