crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 1b77a5259412e99213de995d4226061ba4702783
parent 11effb7e619b71a9199f63901cd33c6de6f5555a
Author: Michael Camilleri <[email protected]>
Date:   Wed,  3 Jun 2026 04:37:42 +0900

Memoise the assembled replay timeline per game

Caching the contributors' journals in Core Data killed the CloudKit
round trip on re-entry, but the merge itself still re-ran every time:
PuzzleView holds the ReplayController as fresh @State, so rapid
finish-banner navigation tears the view down and recreates it, and each
new controller starts at .idle and re-assembles the whole timeline from
the cached rows. The controller's own load is already idempotent against
a ready/loading status, so this only bit across view recreation, never
within a stable view — and the cost is an in-memory sort plus per-cell
state rebuild, not a network call, so it was latent rather than visible.
A diagnostic showed the rebuild firing three times in six seconds during
quick navigation.

The assembled ReplayTimeline is now memoised per game on AppServices and
served verbatim on re-entry. Both construction paths — the local-only
ReplayTimeline(merging:) and the merged services.loadReplay — funnel
through one gate, ReplayAssembler.memoised, which returns a cached
timeline as .ready without re-running the merge and otherwise caches the
fresh result. Only a .ready assembly is cached; .waiting and
.unavailable stay uncached so retry() still re-checks when a
late-syncing contributor's journal finally lands. A 'served from
timeline memo' note on the hit path makes the skip visible in the
diagnostics stream.

This is correct only because a finished game's journals are frozen by
edit-lockout, so a once-assembled timeline can never go stale; the cache
therefore needs no invalidation and lives for the session. loadReplay is
reachable only from the solved-only Success Panel, so a non-finished
game never reaches the memo.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 58+++++++++++++++++++++++++++++++++++++---------------------
MCrossmate/Persistence/JournalReplay.swift | 25+++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 20++++++++++++++++++++
MTests/Unit/JournalReplayTests.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 131 insertions(+), 21 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -461,31 +461,47 @@ private struct PuzzleDisplayView: View { }, onDelete: { try store.deleteGame(id: gameID) }, loadReplay: { - // Local-first: if no *other* device wrote into this - // game, this device's journal is the whole history, so - // replay needs no CloudKit. `contributingDevices` reads - // the per-device MovesEntity rows — the device-level - // signal the author-keyed roster can't give, so it sees - // this account's own second device, not just other - // people. Any other contributor → merged fetch, which - // gates on every contributing device's journal. - let entries = store.localJournalEntries(for: gameID) - let localDeviceID = RecordSerializer.localDeviceID - let otherDevices = store.contributingDevices(for: gameID) - .filter { $0.deviceID != localDeviceID } let short = gameID.uuidString.prefix(8) - if otherDevices.isEmpty { + // Finished-game timelines are immutable (edit-lockout), + // so a cached assembly is reused verbatim on re-entry — + // this is what stops rapid nav from re-running the merge + // each time a fresh `ReplayController` calls in. + return await ReplayAssembler.memoised( + cached: services.cachedReplayTimeline(gameID: gameID), + onHit: { cached in + services.syncMonitor.note( + "replay[\(short)]: served from timeline memo " + + "(steps=\(cached.count))" + ) + }, + store: { services.cacheReplayTimeline($0, gameID: gameID) } + ) { + // Local-first: if no *other* device wrote into this + // game, this device's journal is the whole history, + // so replay needs no CloudKit. `contributingDevices` + // reads the per-device MovesEntity rows — the + // device-level signal the author-keyed roster can't + // give, so it sees this account's own second device, + // not just other people. Any other contributor → + // merged fetch, which gates on every contributing + // device's journal. + let entries = store.localJournalEntries(for: gameID) + let localDeviceID = RecordSerializer.localDeviceID + let otherDevices = store.contributingDevices(for: gameID) + .filter { $0.deviceID != localDeviceID } + if otherDevices.isEmpty { + services.syncMonitor.note( + "replay[\(short)]: local-only path " + + "(no other contributing devices), localEntries=\(entries.count)" + ) + return .ready(ReplayTimeline(merging: [entries])) + } services.syncMonitor.note( - "replay[\(short)]: local-only path " + - "(no other contributing devices), localEntries=\(entries.count)" + "replay[\(short)]: merged path, " + + "otherDevices=\(otherDevices.count), localEntries=\(entries.count)" ) - return .ready(ReplayTimeline(merging: [entries])) + return await services.loadReplay(gameID: gameID) } - services.syncMonitor.note( - "replay[\(short)]: merged path, " + - "otherDevices=\(otherDevices.count), localEntries=\(entries.count)" - ) - return await services.loadReplay(gameID: gameID) } ) } else if let loadError { diff --git a/Crossmate/Persistence/JournalReplay.swift b/Crossmate/Persistence/JournalReplay.swift @@ -154,4 +154,29 @@ enum ReplayAssembler { } return .ready(ReplayTimeline(merging: Array(byDevice.values))) } + + /// Gates assembly on a per-game memo so re-entry doesn't re-run the merge. + /// A `cached` timeline short-circuits to `.ready` (a finished game's + /// journals are frozen, so it can never go stale); otherwise `assemble` + /// runs and *only* a `.ready` result is handed to `store` to cache — + /// `.waiting`/`.unavailable` stay uncached so they remain retryable. Pure + /// orchestration with its IO injected, so it's testable without + /// `AppServices`. `onHit` is a logging-only side hook for the cache-hit path. + @MainActor + static func memoised( + cached: ReplayTimeline?, + onHit: (ReplayTimeline) -> Void = { _ in }, + store: (ReplayTimeline) -> Void, + assemble: () async -> JournalReplayResult + ) async -> JournalReplayResult { + if let cached { + onHit(cached) + return .ready(cached) + } + let result = await assemble() + if case .ready(let timeline) = result { + store(timeline) + } + return result + } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -2731,6 +2731,26 @@ final class AppServices { } } + /// Assembled replay timelines, keyed by game. A finished game's journals are + /// frozen (edit-lockout), so its timeline never changes once built — caching + /// it here lets a `ReplayController` recreated by rapid finish-banner nav + /// re-entry skip re-running `ReplayAssembler.assemble`. Only `.ready` results + /// land here; `.waiting`/`.unavailable` stay retryable. + private var replayTimelineCache: [UUID: ReplayTimeline] = [:] + + /// A previously assembled timeline for `gameID`, if one was cached this + /// session. + func cachedReplayTimeline(gameID: UUID) -> ReplayTimeline? { + replayTimelineCache[gameID] + } + + /// Caches a fully assembled timeline so re-entry skips the re-merge. Safe + /// because the caller only ever passes a finished game's `.ready` result, + /// whose journals are frozen. + func cacheReplayTimeline(_ timeline: ReplayTimeline, gameID: UUID) { + replayTimelineCache[gameID] = timeline + } + /// Loads a finished game's replay: fetches every device's journal from /// CloudKit, overlays this device's live log, and gates on strict /// completeness. `.ready` carries a merged timeline; `.waiting(missing:)` diff --git a/Tests/Unit/JournalReplayTests.swift b/Tests/Unit/JournalReplayTests.swift @@ -203,4 +203,53 @@ struct JournalReplayTests { } #expect(timeline.count == 2) // live two-entry log, not the stale one } + + // MARK: - Per-game memo + + @Test("a cached timeline short-circuits without re-assembling") + func memoServesCache() async { + let cached = ReplayTimeline(merging: [[entry(seq: 0, at: 10, row: 0, col: 0, letter: "A")]]) + var assembleCalls = 0 + var hits = 0 + + let result = await ReplayAssembler.memoised( + cached: cached, + onHit: { _ in hits += 1 }, + store: { _ in Issue.record("must not cache on a hit") } + ) { + assembleCalls += 1 + return .unavailable + } + + #expect(result == .ready(cached)) + #expect(assembleCalls == 0) // never re-ran the merge + #expect(hits == 1) + } + + @Test("a ready assembly is cached; waiting/unavailable are not") + func memoCachesOnlyReady() async { + // Ready → stored. + var storedReady: ReplayTimeline? + let ready = ReplayTimeline(merging: [[entry(seq: 0, at: 10, row: 0, col: 0, letter: "A")]]) + _ = await ReplayAssembler.memoised(cached: nil, store: { storedReady = $0 }) { .ready(ready) } + #expect(storedReady == ready) + + // Waiting → never stored, stays retryable. + var storedWaiting = false + let waiting = await ReplayAssembler.memoised( + cached: nil, + store: { _ in storedWaiting = true } + ) { .waiting(missing: 2) } + #expect(waiting == .waiting(missing: 2)) + #expect(!storedWaiting) + + // Unavailable → never stored. + var storedUnavailable = false + let unavailable = await ReplayAssembler.memoised( + cached: nil, + store: { _ in storedUnavailable = true } + ) { .unavailable } + #expect(unavailable == .unavailable) + #expect(!storedUnavailable) + } }