commit aaa307c74147183db6acbe1b44e90006bec65be1
parent 8c2630b5a4fabef764622f2fff078df86452fb2d
Author: Michael Camilleri <[email protected]>
Date: Thu, 23 Jul 2026 15:26:23 +0900
Keep completed-game paging bounded
The Game List could reread the first Chronicle page indefinitely when
more than 50 records fell inside its initial window. Fresh discovery
could also hydrate historical completed zones before that bounded list
had loaded.
This commit advances every returned Chronicle cursor and serialises
overlapping refresh and 'Load More' requests. It classifies completed
Game roots using scalar metadata, keeps old zones out of eager sync and
discovery, then migrates missing history sequentially after the initial
seven-day list has returned.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
5 files changed, 260 insertions(+), 27 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -420,6 +420,12 @@ final class AppServices {
private var isHandlingPrivateRemoteNotification = false
private var isHandlingSharedRemoteNotification = false
private var gameListFreshenTask: Task<Void, Never>?
+ /// Serialises Chronicle/Game metadata paging. Main-actor methods are
+ /// reentrant across CloudKit awaits, so notification and pull-to-refresh
+ /// requests must not reset the shared cursor during a Load More operation.
+ private var completedPageTask: Task<GameArchiver.CompletedPage, Never>?
+ private var completedPageTaskID: UUID?
+ private var hasStartedRemoteCompletedMigration = false
private var isFresheningPrivateGameList = false
private var isFresheningSharedGameList = false
/// The archive backstop can scan many completed shared games. Run it once
@@ -1429,14 +1435,49 @@ final class AppServices {
guard await ensureICloudSyncStarted() else {
return .init(oldestCompletedAt: nil, hasMore: false)
}
- return await gameArchiver.loadRecentCompleted(since: cutoff)
+ let predecessor = completedPageTask
+ let task = Task { @MainActor in
+ if let predecessor { _ = await predecessor.value }
+ return await gameArchiver.loadRecentCompleted(since: cutoff)
+ }
+ let taskID = UUID()
+ completedPageTask = task
+ completedPageTaskID = taskID
+ let page = await task.value
+ if completedPageTaskID == taskID {
+ completedPageTask = nil
+ completedPageTaskID = nil
+ }
+ beginRemoteCompletedMigrationIfNeeded()
+ return page
}
func loadMoreCompleted() async -> GameArchiver.CompletedPage {
guard await ensureICloudSyncStarted() else {
return .init(oldestCompletedAt: nil, hasMore: false)
}
- return await gameArchiver.loadMoreCompleted()
+ let predecessor = completedPageTask
+ let task = Task { @MainActor in
+ if let predecessor { _ = await predecessor.value }
+ return await gameArchiver.loadMoreCompleted()
+ }
+ let taskID = UUID()
+ completedPageTask = task
+ completedPageTaskID = taskID
+ let page = await task.value
+ if completedPageTaskID == taskID {
+ completedPageTask = nil
+ completedPageTaskID = nil
+ }
+ return page
+ }
+
+ private func beginRemoteCompletedMigrationIfNeeded() {
+ guard !hasStartedRemoteCompletedMigration else { return }
+ hasStartedRemoteCompletedMigration = true
+ Task { @MainActor [weak self] in
+ await self?.gameArchiver.migrateMissingCompletedGames()
+ }
}
/// Runs `work` to completion under a `UIApplication` background-execution
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -565,6 +565,7 @@ extension SyncEngine {
let records: [CKRecord]
let hasGame: Bool
}
+ let completedCutoff = Date().addingTimeInterval(-7 * 24 * 60 * 60)
let perZoneResults = await withTaskGroup(of: PerZoneResult.self) { group in
for zoneID in candidates {
group.addTask { [weak self] in
@@ -572,16 +573,30 @@ extension SyncEngine {
return PerZoneResult(records: [], hasGame: false)
}
do {
- let games = try await self.queryLiveRecords(
+ let metadata = try await self.queryLiveRecords(
type: "Game",
database: database,
zoneID: zoneID,
since: nil,
- desiredKeys: RecordSerializer.gameDesiredKeys
+ desiredKeys: ["completedAt"]
)
- guard !games.isEmpty else {
+ guard let root = metadata.first else {
return PerZoneResult(records: [], hasGame: false)
}
+ // Old completions are discovered but not hydrated into
+ // the initial Game List. The completed-game pager or
+ // background migration selects them explicitly.
+ if let completedAt = root["completedAt"] as? Date,
+ completedAt < completedCutoff {
+ return PerZoneResult(records: [], hasGame: true)
+ }
+ async let games = try await self.queryLiveRecords(
+ type: "Game",
+ database: database,
+ zoneID: zoneID,
+ since: nil,
+ desiredKeys: RecordSerializer.gameDesiredKeys
+ )
async let moves = try await self.queryLiveRecords(
type: "Moves",
database: database,
@@ -596,8 +611,8 @@ extension SyncEngine {
since: nil,
desiredKeys: RecordSerializer.playerDesiredKeys
)
- let (m, p) = try await (moves, players)
- return PerZoneResult(records: games + m + p, hasGame: true)
+ let (g, m, p) = try await (games, moves, players)
+ return PerZoneResult(records: g + m + p, hasGame: true)
} catch {
await self.trace(
"\(label) zone discovery: zone \(zoneID.zoneName) failed: " +
diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift
@@ -2,6 +2,44 @@ import CloudKit
import CoreData
import Foundation
+enum CompletedMetadataPageWalker {
+ struct Result<Item, Cursor> {
+ let selected: [Item]
+ let buffered: [Item]
+ let cursor: Cursor?
+ }
+
+ /// Walks ordered metadata pages until the first item outside the initial
+ /// date window. Passing the returned cursor into every subsequent fetch is
+ /// the invariant that prevents page one from being re-read indefinitely.
+ @MainActor
+ static func recent<Item, Cursor>(
+ cutoff: Date,
+ completedAt: (Item) -> Date,
+ fetch: @MainActor (Cursor?) async throws -> (
+ records: [Item],
+ cursor: Cursor?
+ )
+ ) async rethrows -> Result<Item, Cursor> {
+ var cursor: Cursor?
+ var selected: [Item] = []
+ var buffered: [Item] = []
+ repeat {
+ let page = try await fetch(cursor)
+ cursor = page.cursor
+ if let firstOlder = page.records.firstIndex(where: {
+ completedAt($0) < cutoff
+ }) {
+ selected.append(contentsOf: page.records[..<firstOlder])
+ buffered.append(contentsOf: page.records[firstOlder...])
+ break
+ }
+ selected.append(contentsOf: page.records)
+ } while cursor != nil
+ return Result(selected: selected, buffered: buffered, cursor: cursor)
+ }
+}
+
/// Compacts completed games into the account's private archive zone and retires
/// their multi-record live zones once every participant has archived, or once
/// the hard retention deadline expires.
@@ -404,20 +442,14 @@ final class GameArchiver {
chronicleCursor = nil
bufferedCompleted = []
- var selectedChronicles: [CompletedMetadata] = []
do {
- repeat {
- let page = try await fetchChronicleMetadataPage()
- chronicleCursor = page.cursor
- if let firstOlder = page.records.firstIndex(where: {
- $0.completedAt < cutoff
- }) {
- selectedChronicles.append(contentsOf: page.records[..<firstOlder])
- bufferedCompleted.append(contentsOf: page.records[firstOlder...])
- break
- }
- selectedChronicles.append(contentsOf: page.records)
- } while chronicleCursor != nil
+ let walk = try await CompletedMetadataPageWalker.recent(
+ cutoff: cutoff,
+ completedAt: \.completedAt,
+ fetch: fetchChronicleMetadataPage(continuing:)
+ )
+ chronicleCursor = walk.cursor
+ bufferedCompleted.append(contentsOf: walk.buffered)
let gameMetadata = try await fetchCompletedGameMetadata()
let recentGames = gameMetadata.filter { $0.completedAt >= cutoff }
@@ -426,7 +458,7 @@ final class GameArchiver {
)
bufferedCompleted = mergedMetadata(bufferedCompleted)
- let selected = mergedMetadata(selectedChronicles + recentGames)
+ let selected = mergedMetadata(walk.selected + recentGames)
await hydrateCompleted(selected)
await trimMaterializedChronicles(before: cutoff)
return CompletedPage(
@@ -475,6 +507,47 @@ final class GameArchiver {
}
}
+ /// One-launch migration backstop for completed live zones that this device
+ /// has never materialised. It begins only after the bounded Game List load
+ /// has returned, so old payloads cannot delay or expand the initial list.
+ /// Each fetched game is immediately compacted; later launches skip it
+ /// because the retained live row is already local while retirement settles.
+ func migrateMissingCompletedGames() async {
+ do {
+ let metadata = try await fetchCompletedGameMetadata()
+ let ctx = persistence.container.newBackgroundContext()
+ var localLiveIDs: Set<UUID> = await ctx.perform {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(
+ format: "completedAt != nil AND isAccessRevoked == NO " +
+ "AND ckRecordName BEGINSWITH %@",
+ "game-"
+ )
+ return Set(((try? ctx.fetch(req)) ?? []).compactMap(\.id))
+ }
+
+ for item in mergedMetadata(metadata) {
+ guard !localLiveIDs.contains(item.originalGameID),
+ case .game(let gameID, let zoneID, let scope) = item.source
+ else { continue }
+ do {
+ guard try await syncEngine.fetchCompletedGameDirect(
+ gameID: gameID,
+ zoneID: zoneID,
+ scope: scope
+ ) else { continue }
+ localLiveIDs.insert(gameID)
+ await archiveIfNeeded(gameID: gameID)
+ } catch {
+ syncMonitor?.recordError("migrate completed game", error)
+ }
+ }
+ await reconcileUnarchived()
+ } catch {
+ syncMonitor?.recordError("scan completed-game migration", error)
+ }
+ }
+
private func fetchChronicleMetadataPage(
continuing cursor: CKQueryOperation.Cursor? = nil
) async throws -> (records: [CompletedMetadata], cursor: CKQueryOperation.Cursor?) {
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -171,6 +171,11 @@ actor SyncEngine {
/// log can distinguish push-driven fetches from polls / foreground / etc.
/// `nil` means CKSyncEngine drove the fetch itself (its internal scheduler).
private var currentFetchSource: String?
+ /// Game zones whose root metadata says they completed before the initial
+ /// seven-day Game List window. CKSyncEngine must not eagerly hydrate these;
+ /// the metadata pager or the separate migration path fetches them directly.
+ private var privateCompletedFetchExclusions: Set<CKRecordZone.ID>?
+ private var sharedCompletedFetchExclusions: Set<CKRecordZone.ID>?
/// One-shot flag — set the first time we observe shared-DB content
/// arriving via a push-triggered fetch. Confirms the silent-push path is
/// actually wired up end-to-end.
@@ -1595,6 +1600,11 @@ actor SyncEngine {
"\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions"
)
await noteRoundTripSuccess()
+ if isPrivate {
+ privateCompletedFetchExclusions = nil
+ } else {
+ sharedCompletedFetchExclusions = nil
+ }
if isPrivate,
event.modifications.contains(where: {
@@ -2646,6 +2656,63 @@ actor SyncEngine {
entity.journalUploaded = true
}
+ private func completedFetchExclusions(
+ isPrivate: Bool
+ ) async -> Set<CKRecordZone.ID> {
+ if isPrivate, let privateCompletedFetchExclusions {
+ return privateCompletedFetchExclusions
+ }
+ if !isPrivate, let sharedCompletedFetchExclusions {
+ return sharedCompletedFetchExclusions
+ }
+
+ let database = isPrivate
+ ? container.privateCloudDatabase
+ : container.sharedCloudDatabase
+ do {
+ let zoneIDs = try await database.allRecordZones()
+ .map(\.zoneID)
+ .filter {
+ RecordSerializer.gameID(fromGameRecordName: $0.zoneName) != nil
+ }
+ let recordIDs = zoneIDs.map {
+ CKRecord.ID(recordName: $0.zoneName, zoneID: $0)
+ }
+ let cutoff = Date().addingTimeInterval(-7 * 24 * 60 * 60)
+ var exclusions = Set<CKRecordZone.ID>()
+ for start in stride(from: 0, to: recordIDs.count, by: 200) {
+ let end = min(start + 200, recordIDs.count)
+ let results = try await database.records(
+ for: Array(recordIDs[start..<end]),
+ desiredKeys: ["completedAt"]
+ )
+ for (recordID, result) in results {
+ guard let record = try? result.get(),
+ let completedAt = record["completedAt"] as? Date,
+ completedAt < cutoff
+ else { continue }
+ exclusions.insert(recordID.zoneID)
+ }
+ }
+ if isPrivate {
+ privateCompletedFetchExclusions = exclusions
+ } else {
+ sharedCompletedFetchExclusions = exclusions
+ }
+ await trace(
+ "\(isPrivate ? "private" : "shared") completed fetch exclusions: " +
+ "\(exclusions.count) zone(s)"
+ )
+ return exclusions
+ } catch {
+ await trace(
+ "\(isPrivate ? "private" : "shared") completed fetch exclusions failed: " +
+ describe(error)
+ )
+ return []
+ }
+ }
+
}
// MARK: - CKSyncEngineDelegate
@@ -2656,22 +2723,25 @@ extension SyncEngine: CKSyncEngineDelegate {
syncEngine: CKSyncEngine
) async -> CKSyncEngine.FetchChangesOptions {
var options = context.options
- guard syncEngine === privateEngine else { return options }
+ let isPrivate = syncEngine === privateEngine
+ var excluded = await completedFetchExclusions(isPrivate: isPrivate)
+ if isPrivate { excluded.insert(Archive.zoneID) }
switch options.scope {
case .all:
- options.scope = .allExcluding([Archive.zoneID])
+ options.scope = .allExcluding(Array(excluded))
case .allExcluding(var zoneIDs):
- if !zoneIDs.contains(where: { $0.zoneName == Archive.zoneName }) {
- zoneIDs.append(Archive.zoneID)
+ let existing = Set(zoneIDs)
+ for zoneID in excluded where !existing.contains(zoneID) {
+ zoneIDs.append(zoneID)
}
options.scope = .allExcluding(zoneIDs)
case .zoneIDs(let zoneIDs):
options.scope = .zoneIDs(zoneIDs.filter {
- $0.zoneName != Archive.zoneName
+ !excluded.contains($0)
})
@unknown default:
- options.scope = .allExcluding([Archive.zoneID])
+ options.scope = .allExcluding(Array(excluded))
}
return options
}
diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift
@@ -33,6 +33,40 @@ struct ArchiveTests {
D3. Down 3 ~ CEH
"""
+ @Test("recent Chronicle paging advances through the returned cursor")
+ func recentPagingAdvancesCursor() async throws {
+ let now = Date()
+ let cutoff = now.addingTimeInterval(-7 * 24 * 60 * 60)
+ var requestedCursors: [Int?] = []
+
+ let result = try await CompletedMetadataPageWalker.recent(
+ cutoff: cutoff,
+ completedAt: { $0 }
+ ) { cursor in
+ requestedCursors.append(cursor)
+ switch cursor {
+ case nil:
+ return (
+ records: [now, now.addingTimeInterval(-60)],
+ cursor: 1
+ )
+ case 1:
+ return (
+ records: [cutoff.addingTimeInterval(-1)],
+ cursor: 2
+ )
+ default:
+ Issue.record("pager continued after finding an older record")
+ return (records: [], cursor: nil)
+ }
+ }
+
+ #expect(requestedCursors == [nil, 1])
+ #expect(result.selected.count == 2)
+ #expect(result.buffered.count == 1)
+ #expect(result.cursor == 2)
+ }
+
private func journalValue(
seq: Int64,
row: Int,