commit 362b43d4f5756e85eda0a7312eb5817c06cf68c0
parent 35e9512562f55c9f2748b6c74344de57585d49aa
Author: Michael Camilleri <[email protected]>
Date: Thu, 11 Jun 2026 00:12:00 +0900
Ignore unchanged self Player echoes and log read-cursor adoptions
In a recent log, every puzzle open minted the presence lease twice
(paired 'lease MINT' lines, each with its own Player save and
accountSeen push). The applier's etag guard drops snapshots older than
what are held, but a catch-up query racing the device's in-flight lease
save returns the pre-save record with the same etag, so it was admitted,
re-applied the identical old readAt, rewound the just-minted lease via
noteIncomingReadCursor, and tripped the incoming-cursor drain's
re-assert — a redundant second mint, save and push.
RecordApplier now captures the row's previous readAt/sessionSnapshot and
only fires onReadCursor when one of them actually changed. An unchanged
self-row echo carries no new account-horizon information; a genuine
sibling write (different value) still flows through last-writer-wins
untouched, so the documented sibling-collapse → re-assert self-heal is
preserved.
Cross-device cursor convergence was also invisible in the device log —
attributing a surprising readAt meant extracting a second device's log.
noteIncomingReadCursor now returns the prior cursor value and whether
the write was adopted, and both call sites log it: the sync path as
'cursor ADOPT[…] src=sync readAt=… was=…' and the accountSeen sibling
push by extending its existing line.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
3 files changed, 33 insertions(+), 6 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -995,8 +995,15 @@ final class GameStore {
/// foreground device marks inbound peer moves read on arrival regardless.
/// Representing "A left while C is still here" without that collapse would
/// require per-device Player rows, which do not exist (one row per author).
- func noteIncomingReadCursor(gameID: UUID, readAt: Date) {
- _ = setReadCursor(gameID: gameID, readAt: readAt)
+ /// Returns the cursor value that was in place before the write and whether
+ /// the inbound value was actually adopted, so callers can log the
+ /// adoption — cross-device cursor convergence is otherwise invisible in
+ /// the device log.
+ @discardableResult
+ func noteIncomingReadCursor(gameID: UUID, readAt: Date) -> (previous: Date?, adopted: Bool) {
+ let previous = fetchGameEntity(id: gameID)?.lastReadOtherMoveAt
+ let adopted = setReadCursor(gameID: gameID, readAt: readAt)
+ return (previous, adopted)
}
/// Sets the per-account **presence lease** for `gameID` — the forward-dated
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -499,7 +499,13 @@ final class AppServices {
await syncEngine.setOnIncomingReadCursor { [weak self, store, sessionMonitor] pairs in
let now = Date()
for (gameID, readAt, seenBaselineData) in pairs {
- store.noteIncomingReadCursor(gameID: gameID, readAt: readAt)
+ let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, readAt: readAt)
+ self?.syncMonitor.note(
+ "cursor ADOPT[\(gameID.uuidString.prefix(8))] src=sync " +
+ "readAt=\(readAt.ISO8601Format()) " +
+ "was=\(previous?.ISO8601Format() ?? "—")" +
+ (adopted ? "" : " (no-op)")
+ )
// A sibling device shipped the per-peer baseline it saw on its
// own `Player.sessionSnapshot`; adopt it directly so we converge
// on what the account actually saw rather than recomputing from
@@ -1338,8 +1344,13 @@ final class AppServices {
syncMonitor.note("push(accountSeen): ignored (no readAt)")
return true
}
- syncMonitor.note("push(accountSeen): sibling saw \(gameID.uuidString.prefix(8))")
- store.noteIncomingReadCursor(gameID: gameID, readAt: readAt)
+ let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, readAt: readAt)
+ syncMonitor.note(
+ "push(accountSeen): sibling saw \(gameID.uuidString.prefix(8)) " +
+ "readAt=\(readAt.ISO8601Format()) " +
+ "was=\(previous?.ISO8601Format() ?? "—")" +
+ (adopted ? "" : " (no-op)")
+ )
// The catch-up baseline is no longer recomputed here — it arrives,
// accurate, on the sibling's `Player.sessionSnapshot` via the
// record sync this push's companion DB change triggers. This fast
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -252,12 +252,21 @@ extension SyncEngine {
// inbound peer moves read on arrival regardless. Keeping "A left
// while C is still here" representable without the dip would need
// per-device Player rows, which don't exist (one row per author).
+ let previousReadAt = entity.readAt
+ let previousSessionSnapshot = entity.sessionSnapshot
let incomingReadAt = RecordSerializer.parsePlayerReadAt(from: record)
entity.readAt = incomingReadAt
let incomingReadThrough = RecordSerializer.parsePlayerReadThrough(from: record)
entity.readThrough = incomingReadThrough
entity.sessionSnapshot = RecordSerializer.parsePlayerSessionSnapshot(from: record)
- if authorID == localAuthorID, let readAt = incomingReadAt {
+ // Only surface the cursor when the row actually changed. A re-application
+ // of values we already hold — e.g. a catch-up query snapshot racing this
+ // device's in-flight lease save shares the old etag, so the freshness
+ // guard above admits it — carries no new account-horizon information,
+ // and adopting it would rewind the just-minted lease and trigger a
+ // redundant re-assert save (the open-time double mint).
+ if authorID == localAuthorID, let readAt = incomingReadAt,
+ readAt != previousReadAt || entity.sessionSnapshot != previousSessionSnapshot {
onReadCursor(gameID, readAt, entity.sessionSnapshot)
}
// Our own record coming back from another of this account's devices