crossmate

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

commit 8ca71fefae01e3d7676c29cb16e2c0eebb5fa80a
parent a0b2c29d8cde71f65d574d2ac5f813b977c73b16
Author: Michael Camilleri <[email protected]>
Date:   Sun,  7 Jun 2026 16:31:26 +0900

Bound archive replay retries to cold launch

This commit limits the completed-game archive backstop so it runs once
after the cold-launch game-list freshen instead of scanning unfinished
archives on every foreground, manual refresh, or remote-triggered
freshen. The archive sweep can touch many old shared games, and legacy
development data without replay journals was causing repeated
fetchReplay work long after those games could ever converge.

Incomplete archives keep retrying for 14 days after completedAt.
After that window expires, GameArchiver finalizes the best available
snapshot and marks the game archived so legacy or permanently-missing
journals stop re-entering the sweep.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 16+++++++++++-----
MCrossmate/Sync/GameArchiver.swift | 33++++++++++++++++++++++++++-------
MTests/Unit/ArchiveTests.swift | 13+++++++++++++
3 files changed, 50 insertions(+), 12 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -139,6 +139,10 @@ final class AppServices { private var gameListFreshenTask: Task<Void, Never>? private var isFresheningPrivateGameList = false private var isFresheningSharedGameList = false + /// The archive backstop can scan many completed shared games. Run it once + /// after the first cold-launch game-list freshen, not on every foreground, + /// manual refresh, or remote-triggered refresh. + private var shouldRunColdLaunchArchiveReconcile = true private var claimedPingRecordNames: Set<String> = [] private var claimedPingRecordNameOrder: [String] = [] private let claimedPingRecordNameCap = 200 @@ -1635,11 +1639,13 @@ final class AppServices { ) await refreshSnapshot() await reconcilePendingJournalUploads() - // Level-triggered backstop for the private-DB archive, mirroring the - // journal sweep above: re-attempts any completed participant game whose - // archive never landed — a completion the app was killed during, or one - // that predates this feature. - await gameArchiver.reconcileUnarchived() + if shouldRunColdLaunchArchiveReconcile { + shouldRunColdLaunchArchiveReconcile = false + // Startup-only backstop for the private-DB archive: re-attempts any + // completed participant game whose archive never landed, without + // repeating the scan on every foreground/manual/remote refresh. + await gameArchiver.reconcileUnarchived() + } } /// Level-triggered backstop for replay journal uploads. The upload is diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift @@ -16,6 +16,8 @@ import Foundation /// still exists, hydrated into a completed owned game where it doesn't. @MainActor final class GameArchiver { + nonisolated static let archiveRetryWindow: TimeInterval = 14 * 24 * 60 * 60 + private let container: CKContainer private let persistence: PersistenceController private let syncEngine: SyncEngine @@ -47,9 +49,10 @@ final class GameArchiver { /// they upload at *their* own completion — so the first pass usually captures /// only this device's log. Each later call (driven by the reconcile sweep) /// re-fetches the shared zone, folds in whatever has since uploaded, and - /// force-overwrites the archive. `archivedAt` is set only once the log is - /// complete (one journal per device that wrote grid state), which is what - /// stops the sweep from reconsidering the game. + /// force-overwrites the archive. `archivedAt` is set once the log is + /// complete (one journal per device that wrote grid state), or once the + /// 14-day retry window has elapsed and we deliberately settle for the best + /// available snapshot. func archiveIfNeeded(gameID: UUID) async { let ctx = persistence.container.newBackgroundContext() let local: Archive.Snapshot? = ctx.performAndWait { @@ -73,22 +76,31 @@ final class GameArchiver { // and let a later sweep settle it. let present = Set(snapshot.journal.map(\.key)) let isComplete = fetch.map { $0.expectedDevices.subtracting(present).isEmpty } ?? false + let retryExpired = Self.hasArchiveRetryExpired(completedAt: local.completedAt) + let shouldFinalize = isComplete || retryExpired + if retryExpired && !isComplete { + syncMonitor?.note( + "archive \(local.originalGameID.uuidString.prefix(8)): " + + "retry window expired; finalizing incomplete archive" + ) + } // Skip a redundant overwrite (and the push churn it causes) when the // cloud archive already holds every device we'd write — only the // completeness marker might still need flipping. if let existing, present.isSubset(of: Set(existing.journal.map(\.key))) { - if isComplete { markArchived(originalGameID: local.originalGameID) } + if shouldFinalize { markArchived(originalGameID: local.originalGameID) } return } - await write(snapshot, markComplete: isComplete) + await write(snapshot, markComplete: shouldFinalize) } /// Re-attempts (and converges) the archive for any completed participant game /// not yet marked complete — covering a completion that happened while /// offline, one whose peers hadn't uploaded their journals yet, or a game - /// completed before this feature shipped. Driven by the foreground freshen - /// sweep. + /// completed before this feature shipped. Driven by the cold-launch freshen + /// sweep; after 14 days from completion, `archiveIfNeeded` finalizes the + /// best available archive so legacy/incomplete games stop retrying forever. func reconcileUnarchived() async { let ctx = persistence.container.newBackgroundContext() let ids: [UUID] = ctx.performAndWait { @@ -103,6 +115,13 @@ final class GameArchiver { } } + nonisolated static func hasArchiveRetryExpired( + completedAt: Date, + now: Date = Date() + ) -> Bool { + now.timeIntervalSince(completedAt) >= archiveRetryWindow + } + private nonisolated func shouldArchive(gameID: UUID, in ctx: NSManagedObjectContext) -> Bool { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -281,6 +281,19 @@ struct ArchiveTests { #expect(cached.reduce(0) { $0 + $1.entries.count } == 3) } + @Test("archive retry window expires 14 days after completion") + func archiveRetryWindowExpiresAfterFourteenDays() { + let completedAt = Date(timeIntervalSince1970: 1_700_000_000) + #expect(!GameArchiver.hasArchiveRetryExpired( + completedAt: completedAt, + now: completedAt.addingTimeInterval(GameArchiver.archiveRetryWindow - 1) + )) + #expect(GameArchiver.hasArchiveRetryExpired( + completedAt: completedAt, + now: completedAt.addingTimeInterval(GameArchiver.archiveRetryWindow) + )) + } + @Test("materialize is idempotent — a second application creates no duplicate") func materializeIdempotent() throws { let persistence = makeTestPersistence()