diff options
| author | Christian Cleberg <[email protected]> | 2026-05-06 20:41:49 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-05-06 20:41:49 -0500 |
| commit | 205cf2687ce48f3d004792ff223422a86cc6bdbc (patch) | |
| tree | 7edb78cede354e79aa8abd764ed9c5047e0ee168 /Hutch/Views/Repositories | |
| parent | 57e4f34b4613c09beb0cb757ac2ba2b43cc04daf (diff) | |
| download | hutch-205cf2687ce48f3d004792ff223422a86cc6bdbc.tar.gz hutch-205cf2687ce48f3d004792ff223422a86cc6bdbc.tar.bz2 hutch-205cf2687ce48f3d004792ff223422a86cc6bdbc.zip | |
feat(cache): persist read-only API responsesv3.3.1
Add a bounded stale-while-revalidate cache at the Sourcehut API boundary with
stable keys, centralized TTLs, request coalescing, payload hashing, and LRU disk
pruning.
Cache high-value read-only repo, build, ticket, project, profile, paste, and
Home/Work Queue data while keeping mutations network-only and invalidating
related prefixes after successful writes.
Add focused cache tests and implementation notes.
Diffstat (limited to 'Hutch/Views/Repositories')
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryListViewModel.swift | 96 |
1 files changed, 61 insertions, 35 deletions
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift index 61053a0..695abf8 100644 --- a/Hutch/Views/Repositories/RepositoryListViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -155,7 +155,7 @@ final class RepositoryListViewModel { // Only use cache for non-search, initial loads if !isSearch, repositories.isEmpty { - loadFromCache() + await loadFromCache() } // During search, never show the full-screen loading overlay (which @@ -282,6 +282,8 @@ final class RepositoryListViewModel { ) repository = result.createRepository.repositorySummary(service: .hg) } + await client.invalidateCache(prefix: APICacheKeys.prefix(repository.service.rawValue, "repositories")) + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) repositories.insert(repository, at: 0) insertIntoSearchIndex(repository) scheduleBuildStatusRefresh() @@ -469,13 +471,17 @@ final class RepositoryListViewModel { if useCache && cursor == nil { if service == .hg { let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } - let result = try await client.executeAndCache( + let cached = try await client.executeCached( service: service, query: Self.hgQuery, variables: hgVariables, responseType: HGRepositoriesResponse.self, - cacheKey: cacheKey(for: service) + cacheKey: cacheKey(for: service), + resourceType: .repositoryList, + ttl: APICacheTTLs.repositoryList, + policy: .cacheFirstThenRefresh ) + let result = cached.value return Page( results: result.repositories?.results.map { RepositoryPayload( @@ -492,14 +498,17 @@ final class RepositoryListViewModel { cursor: result.repositories?.cursor ) } - let result = try await client.executeAndCache( + let cached = try await client.executeCached( service: service, query: Self.gitQuery, variables: variables.isEmpty ? nil : variables, responseType: RepositoriesResponse.self, - cacheKey: cacheKey(for: service) + cacheKey: cacheKey(for: service), + resourceType: .repositoryList, + ttl: APICacheTTLs.repositoryList, + policy: .cacheFirstThenRefresh ) - return result.repositories ?? Self.emptyPage + return cached.value.repositories ?? Self.emptyPage } else { if service == .hg { let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } @@ -535,31 +544,14 @@ final class RepositoryListViewModel { } } - private func loadFromCache() { - let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in - guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .srhtFlexible - switch service { - case .git: - if let response = try? decoder.decode( - GraphQLResponse<RepositoriesResponse>.self, - from: data - ), let repos = response.data?.repositories { - return repos.results.map { $0.repositorySummary(service: service) } - } - case .hg: - if let response = try? decoder.decode( - GraphQLResponse<HGRepositoriesResponse>.self, - from: data - ), let repos = response.data?.repositories { - return repos.results.map { $0.repositorySummary(service: service) } - } - default: - break + private func loadFromCache() async { + var persistedRepositories: [RepositorySummary] = [] + for service in [SRHTService.git, .hg] { + if let data = await client.cachedPayload(forKey: cacheKey(for: service)) { + persistedRepositories.append(contentsOf: Self.decodeCachedRepositories(data, service: service)) } - return [] } + let cachedRepositories = persistedRepositories.isEmpty ? legacyCachedRepositories() : persistedRepositories if !cachedRepositories.isEmpty { let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) repositories = sortedRepositories @@ -568,6 +560,37 @@ final class RepositoryListViewModel { } } + private func legacyCachedRepositories() -> [RepositorySummary] { + [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in + guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] } + return Self.decodeCachedRepositories(data, service: service) + } + } + + private static func decodeCachedRepositories(_ data: Data, service: SRHTService) -> [RepositorySummary] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .srhtFlexible + switch service { + case .git: + if let response = try? decoder.decode( + GraphQLResponse<RepositoriesResponse>.self, + from: data + ), let repos = response.data?.repositories { + return repos.results.map { $0.repositorySummary(service: service) } + } + case .hg: + if let response = try? decoder.decode( + GraphQLResponse<HGRepositoriesResponse>.self, + from: data + ), let repos = response.data?.repositories { + return repos.results.map { $0.repositorySummary(service: service) } + } + default: + break + } + return [] + } + private func scheduleBuildStatusRefresh(force: Bool = false) { // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh // passes force: true to bypass this check. @@ -640,14 +663,17 @@ final class RepositoryListViewModel { } if useCache && cursor == nil { - let result = try await client.executeAndCache( + let cached = try await client.executeCached( service: .builds, query: Self.buildsQuery, variables: variables.isEmpty ? nil : variables, responseType: BuildJobsResponse.self, - cacheKey: Self.buildsCacheKey + cacheKey: APICacheKeys.builds(cursor: cursor, filter: "repository-status"), + resourceType: .buildList, + ttl: APICacheTTLs.activeBuild, + policy: .cacheFirstThenRefresh ) - return result.jobs + return cached.value.jobs } let result = try await client.execute( @@ -681,11 +707,11 @@ final class RepositoryListViewModel { private func cacheKey(for service: SRHTService) -> String { switch service { case .git: - Self.gitCacheKey + APICacheKeys.repositories(service: .git) case .hg: - Self.hgCacheKey + APICacheKeys.repositories(service: .hg) default: - "\(service.rawValue).repositories" + APICacheKeys.repositories(service: service) } } |
