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:
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)
+ }
+}