commit 93846a924abeb02a1c3c82f332ce058596f713a5
parent 42feaecb07d3ea34ca4e117e8fac91f7b91bff1a
Author: Michael Camilleri <[email protected]>
Date: Thu, 14 May 2026 12:20:24 +0900
Drop fetched record snapshots older than local cache
CKSyncEngine.send returns the saved CKRecord, and the writeback path adopts the
new etag into ckSystemFields so the next push targets the current server state.
A query that was kicked off before the save can still return the prior server
snapshot after the writeback has landed; the inbound apply path then overwrites
ckSystemFields with the older etag, and the next push fails with client oplock
error / serverRecordChanged.
applyMovesRecord, applyPlayerRecord, and applyGameRecord now decode the
stored system fields' modificationDate, compare against the incoming record's
modificationDate, and short-circuit the apply when the incoming snapshot is
older. The first-time path (no stored system fields, or no modification
date) keeps adopting unconditionally.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 58 insertions(+), 8 deletions(-)
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -333,6 +333,14 @@ enum RecordSerializer {
entity.id = UUID(uuidString: uuidString)
}
+ // Drop fetched snapshots older than what we already have; adopting
+ // them downgrades the local etag and OpLock-fails the next save
+ // (same rationale as `applyMovesRecord` / `applyPlayerRecord`).
+ if entity.ckSystemFields != nil,
+ !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) {
+ return entity
+ }
+
// Seed createdAt/updatedAt from the server record so the library
// can order newly-arrived games. The CKRecord timestamps are the
// source of truth when we don't have a local creation event.
@@ -406,8 +414,18 @@ enum RecordSerializer {
foundExisting = false
}
- // Always adopt system fields so future saves target the server's
- // current change tag. If this is our own per-device row and it already
+ // Drop fetched snapshots that are older than what we already have.
+ // The writeback after a successful push advances `ckSystemFields` to
+ // the latest server etag; a query that started before that push
+ // landed can return the prior server state, and adopting it here
+ // would downgrade the etag and OpLock-fail the next save.
+ if foundExisting,
+ !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) {
+ return false
+ }
+
+ // Adopt system fields so future saves target the server's current
+ // change tag. If this is our own per-device row and it already
// exists locally, the local value state is authoritative; tokenless
// push-driven direct fetches can re-deliver an older server copy while
// newer edits are still queued for upload.
@@ -445,6 +463,25 @@ enum RecordSerializer {
return record
}
+ /// Returns `true` when `incoming` reflects a server state at least as
+ /// recent as the modification date encoded in `existingFields`. Used by
+ /// the apply paths to drop fetched snapshots that arrive after our
+ /// writeback has already adopted a newer change tag — adopting them
+ /// would downgrade the local etag and the next save would OpLock-fail.
+ /// Defaults to `true` when either side lacks a modification date so a
+ /// first-time fetch can land.
+ static func incomingIsAtLeastAsFresh(
+ _ incoming: CKRecord,
+ existingFields: Data?
+ ) -> Bool {
+ guard let existingFields,
+ let existingRecord = decodeRecord(from: existingFields),
+ let existingDate = existingRecord.modificationDate,
+ let incomingDate = incoming.modificationDate
+ else { return true }
+ return incomingDate >= existingDate
+ }
+
// MARK: - Private helpers
/// Restores a `CKRecord` from archived system fields (preserving the
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1547,8 +1547,10 @@ actor SyncEngine {
req.fetchLimit = 1
let entity: PlayerEntity
+ let foundExisting: Bool
if let existing = try? ctx.fetch(req).first {
entity = existing
+ foundExisting = true
} else {
let game = RecordSerializer.ensureGameEntity(
forGameID: gameID,
@@ -1557,14 +1559,25 @@ actor SyncEngine {
)
entity = PlayerEntity(context: ctx)
entity.game = game
+ foundExisting = false
}
- // Always adopt the server's system fields — that's etag tracking and
- // is independent of which side has the freshest data. The value
- // fields, however, are only adopted when the incoming record is at
- // least as new as what we have locally; otherwise a stale-but-current
- // server record (e.g. our own pending writes haven't landed yet)
- // would clobber the user's live selection on every fetch.
+ // Drop fetched snapshots older than what we already have. After a
+ // successful push the writeback adopts the new etag; if a query
+ // that started before the push lands later, applying its older
+ // snapshot would downgrade our local etag and OpLock-fail the next
+ // save (see `applyMovesRecord` for the same guard).
+ if foundExisting,
+ !RecordSerializer.incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) {
+ return
+ }
+
+ // Adopt the server's system fields — that's etag tracking and is
+ // independent of which side has the freshest data. The value fields,
+ // however, are only adopted when the incoming record is at least as
+ // new as what we have locally; otherwise a stale-but-current server
+ // record (e.g. our own pending writes haven't landed yet) would
+ // clobber the user's live selection on every fetch.
entity.ckRecordName = ckName
entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record)
entity.authorID = authorID