commit 85511b54f005889749233a9592860403fe00f2e5
parent 8eb5441bae067f42ef9b10625a7d3cec9f959873
Author: Michael Camilleri <[email protected]>
Date: Fri, 26 Jun 2026 22:26:24 +0900
Expand diagnostic logging for summary banners
Diffstat:
3 files changed, 108 insertions(+), 3 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -774,6 +774,10 @@ private struct PuzzleDisplayView: View {
// whole board (the leave/background path below stamps it).
guard let since = services.gameViewedStore.lastViewed(forGame: gameID)
else { return [:] }
+ services.syncMonitor.note(
+ "recent changes[\(gameID.uuidString.prefix(8))] open diag: "
+ + store.recentChangesDiagnosticSummary(forGame: gameID, since: since)
+ )
return store.recentlyChangedCells(forGame: gameID, since: since)
},
markPuzzleViewed: { stampPuzzleViewed() }
@@ -1058,6 +1062,10 @@ private struct PuzzleDisplayView: View {
guard let session, session.mutator.isShared,
let since = services.gameViewedStore.lastViewed(forGame: gameID)
else { return }
+ services.syncMonitor.note(
+ "recent changes[\(gameID.uuidString.prefix(8))] recapture diag: "
+ + store.recentChangesDiagnosticSummary(forGame: gameID, since: since)
+ )
session.recentChanges = store.recentlyChangedCells(forGame: gameID, since: since)
}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -523,6 +523,7 @@ final class GameStore {
/// argument: builds never overlap, so the upsert-by-position in
/// `updatePeerChangeLedger` can't duplicate a row.
private var ledgerRequests: AsyncStream<Set<UUID>>.Continuation?
+ private var peerChangeLedgerBuildSerial = 0
/// Fire-and-forget request to refresh the peer-change ledger for `gameIDs`.
/// Returns immediately — the inbound-moves hot path must never wait on this
@@ -530,6 +531,9 @@ final class GameStore {
/// single consumer applies it when it gets there.
func enqueuePeerChangeLedgerUpdate(for gameIDs: Set<UUID>) {
guard !gameIDs.isEmpty else { return }
+ eventLog?.note(
+ "peer ledger enqueue: games=[\(gameIDs.map { String($0.uuidString.prefix(8)) }.sorted().joined(separator: ","))]"
+ )
if ledgerRequests == nil {
let (stream, continuation) = AsyncStream<Set<UUID>>.makeStream()
ledgerRequests = continuation
@@ -566,14 +570,24 @@ final class GameStore {
/// and `await` it for determinism.
func updatePeerChangeLedger(for gameIDs: Set<UUID>) async {
guard !gameIDs.isEmpty else { return }
+ peerChangeLedgerBuildSerial += 1
+ let serial = peerChangeLedgerBuildSerial
+ let startedAt = Date()
+ eventLog?.note(
+ "peer ledger build #\(serial) start: games=[\(gameIDs.map { String($0.uuidString.prefix(8)) }.sorted().joined(separator: ","))]"
+ )
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
+ var diagnostics: [String] = []
await ctx.perform {
for gameID in gameIDs {
let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
gameReq.fetchLimit = 1
- guard let game = try? ctx.fetch(gameReq).first else { continue }
+ guard let game = try? ctx.fetch(gameReq).first else {
+ diagnostics.append("\(gameID.uuidString.prefix(8)) missing-game")
+ continue
+ }
let ledgerReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity")
ledgerReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg)
@@ -586,6 +600,9 @@ final class GameStore {
// late peer move can still arrive for a finished game.
if game.completedAt != nil {
for row in existingRows { ctx.delete(row) }
+ diagnostics.append(
+ "\(gameID.uuidString.prefix(8)) completed existing=\(existingRows.count) deleted"
+ )
continue
}
@@ -606,6 +623,12 @@ final class GameStore {
recorded: recorded,
seeding: recorded.isEmpty
)
+ diagnostics.append(
+ "\(gameID.uuidString.prefix(8)) existing=\(existingRows.count) "
+ + "moves=\(values.count) current=\(current.count) "
+ + "seeding=\(recorded.isEmpty) upserts=\(upserts.count) "
+ + Self.peerChangeSampleSummary(upserts)
+ )
for change in upserts {
let row = rowByPosition[change.position] ?? PeerChangeEntity(context: ctx)
row.gameID = gameID
@@ -627,6 +650,11 @@ final class GameStore {
}
}
}
+ let elapsed = Date().timeIntervalSince(startedAt)
+ eventLog?.note(
+ "peer ledger build #\(serial) end: elapsed=\(String(format: "%.3f", elapsed))s "
+ + diagnostics.joined(separator: " | ")
+ )
}
/// Updates `latestOtherMoveAt` for each game whose Moves record was just
@@ -1938,6 +1966,37 @@ final class GameStore {
return RecentChanges.changes(in: entries, since: since, excludingAuthor: localAuthorID)
}
+ /// Diagnostic snapshot for the catch-up banner and border-highlight reads.
+ /// This is intentionally read-only: it reports the ledger as it stands at
+ /// the instant the UI asks for recent changes, so a stale/asynchronous build
+ /// can be distinguished from a bad reduction.
+ func recentChangesDiagnosticSummary(forGame gameID: UUID, since: Date) -> String {
+ let localAuthorID = authorIDProvider()
+ let allReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity")
+ allReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg)
+ let allRows = (try? context.fetch(allReq)) ?? []
+
+ let newerRows = allRows.filter { ($0.changedAt ?? .distantPast) > since }
+ let entries = newerRows.map { Self.peerChange(from: $0) }
+ let changes = localAuthorID.map {
+ RecentChanges.changes(in: entries, since: since, excludingAuthor: $0)
+ } ?? .empty
+ let counted = changes.counts.values.reduce(0) { $0 + $1.added + $1.cleared }
+ let byAuthor = changes.counts.keys.sorted().map { authorID in
+ let count = changes.counts[authorID] ?? RecentChanges.Count(added: 0, cleared: 0)
+ return "\(authorID.prefix(8))=+\(count.added)/-\(count.cleared)"
+ }.joined(separator: ",")
+ let newest = allRows.compactMap(\.changedAt).max()
+
+ return "since=\(since.ISO8601Format()) "
+ + "local=\(Self.shortAuthorID(localAuthorID)) "
+ + "ledgerRows=\(allRows.count) newer=\(newerRows.count) "
+ + "counted=\(counted) cells=\(changes.cells.count) "
+ + "byAuthor=[\(byAuthor)] "
+ + "newest=\(newest?.ISO8601Format() ?? "nil") "
+ + Self.peerChangeEntitySampleSummary(newerRows)
+ }
+
/// Sender-side measurements describing *why* the pause-push counts for
/// `(gameID, authorID)` came out as they did. Mirrors the set the count
/// path (`mergedAuthorCells`) iterates, then breaks it down against the
@@ -2298,6 +2357,34 @@ final class GameStore {
)
}
+ private nonisolated static func peerChangeSampleSummary(_ changes: [PeerChange]) -> String {
+ guard !changes.isEmpty else { return "sample=[]" }
+ let sample = changes
+ .sorted { lhs, rhs in
+ if lhs.changedAt != rhs.changedAt { return lhs.changedAt < rhs.changedAt }
+ if lhs.position.row != rhs.position.row { return lhs.position.row < rhs.position.row }
+ return lhs.position.col < rhs.position.col
+ }
+ .prefix(5)
+ .map {
+ "r\($0.position.row)c\($0.position.col):"
+ + "\($0.letter.isEmpty ? "-" : $0.letter)"
+ + "@\($0.changedAt.ISO8601Format())"
+ + "#\(Self.shortAuthorID($0.authorID))"
+ }
+ .joined(separator: ",")
+ return "sample=[\(sample)]"
+ }
+
+ private nonisolated static func peerChangeEntitySampleSummary(_ rows: [PeerChangeEntity]) -> String {
+ peerChangeSampleSummary(rows.map { peerChange(from: $0) })
+ }
+
+ private nonisolated static func shortAuthorID(_ authorID: String?) -> String {
+ guard let authorID else { return "nil" }
+ return String(authorID.prefix(8))
+ }
+
private func inferredObservedCompletionAuthorID(for id: UUID) -> String? {
let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift
@@ -682,9 +682,19 @@ final class SessionCoordinator {
// No baseline means a first-ever open: stay silent rather than flag the
// whole grid, exactly as the border highlights do — the two surfaces
// read this one cutoff so they always agree.
- guard let since = gameViewedStore.lastViewed(forGame: gameID) else { return }
+ guard let since = gameViewedStore.lastViewed(forGame: gameID) else {
+ syncMonitor.note("session summary[\(gameID.uuidString.prefix(8))] \(reason): skipped (no baseline)")
+ return
+ }
+ syncMonitor.note(
+ "session summary[\(gameID.uuidString.prefix(8))] \(reason) diag: "
+ + store.recentChangesDiagnosticSummary(forGame: gameID, since: since)
+ )
let summaries = sessionMonitor.summaries(for: gameID, since: since)
- guard !summaries.isEmpty else { return }
+ guard !summaries.isEmpty else {
+ syncMonitor.note("session summary[\(gameID.uuidString.prefix(8))] \(reason): skipped (no changes)")
+ return
+ }
let detail = summaries.map { summary -> String in
let who = summary.playerName.isEmpty
? String(summary.authorID.prefix(8))