commit b59a5e909158dd77df01ce8fdfd2839f4a222c30
parent 56a24c0f36ebf50d2785ccc7487bd1c4e2413e84
Author: Michael Camilleri <[email protected]>
Date: Sat, 4 Jul 2026 13:18:28 +0900
Serialise detached CloudKit sends per scope
Repeated Player enqueues could launch overlapping CKSyncEngine drains
for the same account-level record. One send could advance the server
etag while another in-flight drain was still carrying the previous tag,
surfacing a recoverable OpLock failure even though the presence lease
movement itself was legitimate.
This commit keeps one detached send in flight per database scope and
records a follow-up drain when more pending work arrives during that
send. The retry still rebuilds records from current local state, but
adjacent enqueues now coalesce behind the active drain instead of racing
separate sends against neighbouring etags.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
1 file changed, 32 insertions(+), 2 deletions(-)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -407,9 +407,37 @@ actor SyncEngine {
/// This must be detached: several enqueue paths are reachable from
/// CKSyncEngine delegate callbacks, and a plain `Task {}` can inherit the
/// callback's executor and re-enter CKSyncEngine before the callback
- /// unwinds, tripping CloudKit's serialization guard.
+ /// unwinds, tripping CloudKit's serialization guard. Keep one detached
+ /// send in flight per scope so repeated enqueues for the same record
+ /// coalesce instead of racing separate drains against adjacent etags.
private func sendChangesDetached(on engine: CKSyncEngine) {
- Task.detached { [engine] in try? await engine.sendChanges() }
+ guard let scope = scope(for: engine) else {
+ Task.detached { [engine] in try? await engine.sendChanges() }
+ return
+ }
+ if sendChangesInFlight.contains(scope) {
+ sendChangesPending.insert(scope)
+ return
+ }
+ sendChangesInFlight.insert(scope)
+ Task.detached { [engine] in
+ try? await engine.sendChanges()
+ await self.finishDetachedSendChanges(for: scope)
+ }
+ }
+
+ private func scope(for engine: CKSyncEngine) -> DatabaseScope? {
+ if let privateEngine, engine === privateEngine { return .private }
+ if let sharedEngine, engine === sharedEngine { return .shared }
+ return nil
+ }
+
+ private func finishDetachedSendChanges(for scope: DatabaseScope) {
+ sendChangesInFlight.remove(scope)
+ guard sendChangesPending.remove(scope) != nil else { return }
+ let engine = scope == .shared ? sharedEngine : privateEngine
+ guard let engine else { return }
+ sendChangesDetached(on: engine)
}
/// Per-scope burst depth. `enqueuePlayer` consults this — while
@@ -423,6 +451,8 @@ actor SyncEngine {
/// it rather than by a wall-clock window.
private var playerSendBurstDepth: [DatabaseScope: Int] = [:]
private var playerSendBurstPending: Set<DatabaseScope> = []
+ private var sendChangesInFlight: Set<DatabaseScope> = []
+ private var sendChangesPending: Set<DatabaseScope> = []
/// Opens a Player-record send burst for `gameID`'s scope. Subsequent
/// `enqueuePlayer` calls on that scope skip their immediate