diff options
| author | Christian Cleberg <[email protected]> | 2026-05-06 20:30:34 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-05-06 20:30:34 -0500 |
| commit | 57e4f34b4613c09beb0cb757ac2ba2b43cc04daf (patch) | |
| tree | 4c83a914f36919a5d1164fa39e7e0d5915c8730a /Hutch/Networking/SRHTClient.swift | |
| parent | fba49d0955a6030406956b2bb1c2a60d184cfda6 (diff) | |
| download | hutch-57e4f34b4613c09beb0cb757ac2ba2b43cc04daf.tar.gz hutch-57e4f34b4613c09beb0cb757ac2ba2b43cc04daf.tar.bz2 hutch-57e4f34b4613c09beb0cb757ac2ba2b43cc04daf.zip | |
feat: add persistent stale-while-revalidate API cache
Introduce an actor-backed persistent cache layer at the SRHTClient boundary
for read-only SourceHut data. Cache entries now store stable metadata
including key, resource type, fetched/expires/access timestamps, payload
hash, schema version, and payload size, with bounded memory and disk usage.
Add centralized cache key builders and TTL defaults for repository, file,
ticket, build, log, profile, status, and list-style resources. Support
networkOnly, cacheOnly, cacheFirstThenRefresh, and refreshIgnoringCache
policies, plus request coalescing for duplicate in-flight cache keys.
Integrate first-pass caching into high-value low-risk read paths:
- build detail and completed/active build logs
- ticket detail
- README lookup
- repository tree, blob, and linked file reads
Keep mutation paths network-only and add simple prefix invalidation after
ticket and build mutations. Add compact cached/stale UI status rows and a
Settings action to clear the persistent cache.
Add focused cache tests covering round trips, expiration, stale fallback,
policy behavior, request coalescing, prefix invalidation, size limits, LRU
pruning, expired pruning, and mutation bypass behavior. Document storage,
key, TTL, invalidation, limitations, and next recommended targets.
Diffstat (limited to 'Hutch/Networking/SRHTClient.swift')
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 299 |
1 files changed, 298 insertions, 1 deletions
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index b4f8ee0..37fd6b8 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import os @@ -20,6 +21,8 @@ final class SRHTClient: Sendable { private let session: URLSession private let decoder: JSONDecoder private let encoder: JSONEncoder + private let cache: any APICache + private let requestCoalescer = RequestCoalescer() /// The personal access token used for `Authorization: Bearer` headers. /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``. @@ -32,12 +35,19 @@ final class SRHTClient: Sendable { tokenLock.withLock { $0 != nil } } - init(session: URLSession = .shared, token: String? = nil) { + init( + session: URLSession = .shared, + token: String? = nil, + cache: (any APICache)? = nil + ) { self.session = session self.decoder = JSONDecoder() self.decoder.dateDecodingStrategy = .srhtFlexible self.encoder = JSONEncoder() self.tokenLock = OSAllocatedUnfairLock(initialState: token) + self.cache = cache ?? PersistentAPICache( + configuration: .accountScoped(accountID: token.map { Self.tokenCacheScope($0) } ?? "anonymous") + ) } /// Update the stored token (e.g. after the user saves a new one in Keychain). @@ -156,6 +166,130 @@ final class SRHTClient: Sendable { return result } + func executeCached<T: Decodable>( + service: SRHTService, + query: String, + variables: [String: any Sendable]? = nil, + responseType _: T.Type, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval, + policy: CachePolicy = .cacheFirstThenRefresh + ) async throws -> CachedValue<T> { + switch policy { + case .networkOnly: + let data = try await performGraphQLRequest( + service: service, + query: query, + variables: variables + ) + let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables) + return CachedValue(value: value, metadata: nil, source: .network) + + case .cacheOnly: + let entry = try await cache.read(cacheKey: cacheKey) + let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables) + return CachedValue(value: value, metadata: entry.metadata, source: .cache) + + case .cacheFirstThenRefresh: + if let entry = try? await cache.read(cacheKey: cacheKey) { + let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables) + if entry.metadata.isExpired() { + Task.detached { [self] in + _ = try? await self.fetchAndCacheGraphQLData( + service: service, + query: query, + variables: variables, + cacheKey: cacheKey, + resourceType: resourceType, + ttl: ttl + ) + } + } + return CachedValue(value: value, metadata: entry.metadata, source: .cache) + } + + let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL( + service: service, + query: query, + variables: variables, + cacheKey: cacheKey, + resourceType: resourceType, + ttl: ttl + ) + return CachedValue(value: value, metadata: metadata, source: .network) + + case .refreshIgnoringCache: + let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL( + service: service, + query: query, + variables: variables, + cacheKey: cacheKey, + resourceType: resourceType, + ttl: ttl + ) + return CachedValue(value: value, metadata: metadata, source: .network) + } + } + + func fetchCachedText( + url: URL, + cacheKey: String, + resourceType: CacheResourceType = .buildLog, + ttl: TimeInterval, + policy: CachePolicy = .cacheFirstThenRefresh + ) async throws -> CachedValue<String> { + switch policy { + case .networkOnly: + let text = try await fetchText(url: url) + return CachedValue(value: text, metadata: nil, source: .network) + case .cacheOnly: + let entry = try await cache.read(cacheKey: cacheKey) + guard let text = String(data: entry.payload, encoding: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Cached text is not UTF-8")) + ) + } + return CachedValue(value: text, metadata: entry.metadata, source: .cache) + case .cacheFirstThenRefresh: + if let entry = try? await cache.read(cacheKey: cacheKey), + let text = String(data: entry.payload, encoding: .utf8) { + if entry.metadata.isExpired() { + Task.detached { [self] in + _ = try? await self.fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + } + } + return CachedValue(value: text, metadata: entry.metadata, source: .cache) + } + let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return CachedValue(value: text, metadata: metadata, source: .network) + case .refreshIgnoringCache: + let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return CachedValue(value: text, metadata: metadata, source: .network) + } + } + + func cachedPayload(forKey cacheKey: String) async -> Data? { + if let entry = try? await cache.read(cacheKey: cacheKey) { + return entry.payload + } + return responseCache.get(forKey: cacheKey) + } + + func invalidateCache(prefix: String) async { + await cache.removeByPrefix(prefix) + } + + func removeCachedValue(forKey cacheKey: String) async { + await cache.remove(cacheKey: cacheKey) + responseCache.remove(forKey: cacheKey) + } + + func clearPersistentCache() async { + await cache.clearAll() + responseCache.clear() + } + // MARK: - Multipart Upload /// Execute a GraphQL mutation with a file upload using the @@ -614,6 +748,152 @@ final class SRHTClient: Sendable { // MARK: - Data Helper private extension SRHTClient { + func performGraphQLRequest( + service: SRHTService, + query: String, + variables: [String: any Sendable]? + ) async throws -> Data { + guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + + var request = URLRequest(url: service.url) + request.httpMethod = "POST" + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = GraphQLRequestBody( + query: query, + variables: variables?.mapValues { AnyCodable($0) } + ) + request.httpBody = try encoder.encode(body) + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + try throwGraphQLErrorsIfPresent(in: data) + throw SRHTError.httpError(http.statusCode) + } + } + + try throwGraphQLErrorsIfPresent(in: data) + return data + } + + func decodeGraphQLData<T: Decodable>( + _ data: Data, + service: SRHTService, + query: String, + variables: [String: any Sendable]? + ) throws -> T { + let graphQLResponse: GraphQLResponse<T> + do { + graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) + } catch { + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>" + logger.error( + """ + Decoding failed for \(String(describing: T.self), privacy: .public) + service: \(service.rawValue, privacy: .public) + query: + \(query, privacy: .public) + variables: + \(String(describing: variables), privacy: .public) + error: + \(String(describing: error), privacy: .public) + response: + \(responseBody, privacy: .public) + """ + ) + #endif + throw SRHTError.decodingError(error) + } + + if let errors = graphQLResponse.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + + guard let result = graphQLResponse.data else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) + ) + } + return result + } + + func fetchAndCacheGraphQL<T: Decodable>( + service: SRHTService, + query: String, + variables: [String: any Sendable]?, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> (T, CacheEntryMetadata?) { + let data = try await requestCoalescer.value(for: cacheKey) { + try await self.performGraphQLRequest(service: service, query: query, variables: variables) + } + let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables) + responseCache.set(data, forKey: cacheKey) + let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return (value, metadata) + } + + func fetchAndCacheGraphQLData( + service: SRHTService, + query: String, + variables: [String: any Sendable]?, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> CacheEntryMetadata? { + let data = try await requestCoalescer.value(for: cacheKey) { + try await self.performGraphQLRequest(service: service, query: query, variables: variables) + } + responseCache.set(data, forKey: cacheKey) + return try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + } + + func fetchAndCacheText( + url: URL, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> (String, CacheEntryMetadata?) { + let data = try await requestCoalescer.value(for: cacheKey) { + let text = try await self.fetchText(url: url) + guard let data = text.data(using: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Text could not be encoded as UTF-8")) + ) + } + return data + } + guard let text = String(data: data, encoding: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text")) + ) + } + responseCache.set(data, forKey: cacheKey) + let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return (text, metadata) + } + + static func tokenCacheScope(_ token: String) -> String { + let digest = SHA256.hash(data: Data(token.utf8)) + return digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } + func throwGraphQLErrorsIfPresent(in data: Data) throws { if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), let errors = envelope.errors, @@ -632,6 +912,23 @@ private extension SRHTClient { } } +private actor RequestCoalescer { + private var tasks: [String: Task<Data, Error>] = [:] + + func value(for key: String, operation: @Sendable @escaping () async throws -> Data) async throws -> Data { + if let task = tasks[key] { + return try await task.value + } + + let task = Task { + try await operation() + } + tasks[key] = task + defer { tasks.removeValue(forKey: key) } + return try await task.value + } +} + private extension Data { mutating func append(_ string: String) { if let data = string.data(using: .utf8) { |
