summaryrefslogtreecommitdiff
path: root/Hutch/Networking/APICache.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-05-06 20:30:34 -0500
committerChristian Cleberg <[email protected]>2026-05-06 20:30:34 -0500
commit57e4f34b4613c09beb0cb757ac2ba2b43cc04daf (patch)
tree4c83a914f36919a5d1164fa39e7e0d5915c8730a /Hutch/Networking/APICache.swift
parentfba49d0955a6030406956b2bb1c2a60d184cfda6 (diff)
downloadhutch-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/APICache.swift')
-rw-r--r--Hutch/Networking/APICache.swift337
1 files changed, 337 insertions, 0 deletions
diff --git a/Hutch/Networking/APICache.swift b/Hutch/Networking/APICache.swift
new file mode 100644
index 0000000..9c1a262
--- /dev/null
+++ b/Hutch/Networking/APICache.swift
@@ -0,0 +1,337 @@
+import CryptoKit
+import Foundation
+
+enum CachePolicy: Sendable, Equatable {
+ case networkOnly
+ case cacheOnly
+ case cacheFirstThenRefresh
+ case refreshIgnoringCache
+}
+
+enum CacheResourceType: String, Codable, Sendable {
+ case repositoryDetail
+ case repositoryList
+ case repositoryTree
+ case repositoryFile
+ case repositoryReadme
+ case ticketDetail
+ case ticketList
+ case buildDetail
+ case buildList
+ case buildLog
+ case userProfile
+ case status
+ case pasteList
+ case debug
+}
+
+struct CacheEntryMetadata: Codable, Sendable, Equatable {
+ let cacheKey: String
+ let resourceType: CacheResourceType
+ let fetchedAt: Date
+ let expiresAt: Date
+ var lastAccessedAt: Date
+ let payloadHash: String
+ let schemaVersion: Int
+ let payloadSize: Int
+
+ func isExpired(now: Date = Date()) -> Bool {
+ expiresAt <= now
+ }
+}
+
+struct APICacheEntry: Sendable {
+ var metadata: CacheEntryMetadata
+ let payload: Data
+}
+
+struct CachedValue<Value> {
+ let value: Value
+ let metadata: CacheEntryMetadata?
+ let source: CacheValueSource
+
+ var isFromCache: Bool { source == .cache }
+ var isStale: Bool { metadata?.isExpired() ?? false }
+}
+
+enum CacheValueSource: Sendable, Equatable {
+ case cache
+ case network
+}
+
+enum APICacheError: LocalizedError, Sendable {
+ case miss
+ case entryTooLarge(Int)
+ case cacheTooLarge
+
+ var errorDescription: String? {
+ switch self {
+ case .miss:
+ "No cached data is available."
+ case .entryTooLarge(let bytes):
+ "The response is too large to cache (\(bytes) bytes)."
+ case .cacheTooLarge:
+ "The cache size limit was exceeded."
+ }
+ }
+}
+
+struct APICacheConfiguration: Sendable {
+ var directory: URL
+ var maxCacheSizeBytes: Int
+ var maxEntrySizeBytes: Int
+ var memoryEntryLimit: Int
+ var schemaVersion: Int
+
+ static func accountScoped(accountID: String) -> APICacheConfiguration {
+ let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
+ ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ return APICacheConfiguration(
+ directory: base
+ .appendingPathComponent("Hutch", isDirectory: true)
+ .appendingPathComponent("APICache", isDirectory: true)
+ .appendingPathComponent(accountID, isDirectory: true),
+ maxCacheSizeBytes: 50 * 1024 * 1024,
+ maxEntrySizeBytes: 2 * 1024 * 1024,
+ memoryEntryLimit: 64,
+ schemaVersion: 1
+ )
+ }
+
+ static func temporary(directory: URL) -> APICacheConfiguration {
+ APICacheConfiguration(
+ directory: directory,
+ maxCacheSizeBytes: 4 * 1024 * 1024,
+ maxEntrySizeBytes: 512 * 1024,
+ memoryEntryLimit: 16,
+ schemaVersion: 1
+ )
+ }
+}
+
+protocol APICache: Sendable {
+ func read(cacheKey: String) async throws -> APICacheEntry
+ func write(payload: Data, cacheKey: String, resourceType: CacheResourceType, ttl: TimeInterval) async throws -> CacheEntryMetadata
+ func remove(cacheKey: String) async
+ func removeByPrefix(_ prefix: String) async
+ func clearAll() async
+ func pruneExpired(now: Date) async
+ func pruneToSizeLimit() async
+}
+
+actor PersistentAPICache: APICache {
+ private struct StoredEntry: Codable, Sendable {
+ var metadata: CacheEntryMetadata
+ let payload: Data
+ }
+
+ private let configuration: APICacheConfiguration
+ private let fileManager: FileManager
+ private var memoryEntries: [String: APICacheEntry] = [:]
+ private var memoryOrder: [String] = []
+ private var knownMetadata: [String: CacheEntryMetadata] = [:]
+ private var writeCountSincePrune = 0
+
+ init(configuration: APICacheConfiguration, fileManager: FileManager = .default) {
+ self.configuration = configuration
+ self.fileManager = fileManager
+ }
+
+ func read(cacheKey: String) async throws -> APICacheEntry {
+ if var entry = memoryEntries[cacheKey] {
+ entry.metadata.lastAccessedAt = Date()
+ memoryEntries[cacheKey] = entry
+ markMemoryUse(cacheKey)
+ try? persist(entry)
+ return entry
+ }
+
+ let url = fileURL(for: cacheKey)
+ guard fileManager.fileExists(atPath: url.path) else {
+ throw APICacheError.miss
+ }
+
+ var stored = try decodeEntry(from: url)
+ guard stored.metadata.schemaVersion == configuration.schemaVersion else {
+ try? fileManager.removeItem(at: url)
+ throw APICacheError.miss
+ }
+
+ stored.metadata.lastAccessedAt = Date()
+ let entry = APICacheEntry(metadata: stored.metadata, payload: stored.payload)
+ knownMetadata[cacheKey] = stored.metadata
+ remember(entry)
+ try? persist(entry)
+ return entry
+ }
+
+ func write(
+ payload: Data,
+ cacheKey: String,
+ resourceType: CacheResourceType,
+ ttl: TimeInterval
+ ) async throws -> CacheEntryMetadata {
+ guard payload.count <= configuration.maxEntrySizeBytes else {
+ throw APICacheError.entryTooLarge(payload.count)
+ }
+
+ try ensureDirectoryExists()
+ let now = Date()
+ let payloadHash = Self.payloadHash(payload)
+ if let existing = try? await read(cacheKey: cacheKey),
+ existing.metadata.payloadHash == payloadHash {
+ let metadata = CacheEntryMetadata(
+ cacheKey: cacheKey,
+ resourceType: resourceType,
+ fetchedAt: now,
+ expiresAt: now.addingTimeInterval(ttl),
+ lastAccessedAt: now,
+ payloadHash: payloadHash,
+ schemaVersion: configuration.schemaVersion,
+ payloadSize: payload.count
+ )
+ let entry = APICacheEntry(metadata: metadata, payload: payload)
+ remember(entry)
+ try persist(entry)
+ return metadata
+ }
+
+ let metadata = CacheEntryMetadata(
+ cacheKey: cacheKey,
+ resourceType: resourceType,
+ fetchedAt: now,
+ expiresAt: now.addingTimeInterval(ttl),
+ lastAccessedAt: now,
+ payloadHash: payloadHash,
+ schemaVersion: configuration.schemaVersion,
+ payloadSize: payload.count
+ )
+ let entry = APICacheEntry(metadata: metadata, payload: payload)
+ remember(entry)
+ try persist(entry)
+
+ writeCountSincePrune += 1
+ if writeCountSincePrune >= 12 {
+ writeCountSincePrune = 0
+ await pruneExpired(now: now)
+ await pruneToSizeLimit()
+ }
+ return metadata
+ }
+
+ func remove(cacheKey: String) async {
+ memoryEntries.removeValue(forKey: cacheKey)
+ memoryOrder.removeAll { $0 == cacheKey }
+ knownMetadata.removeValue(forKey: cacheKey)
+ try? fileManager.removeItem(at: fileURL(for: cacheKey))
+ }
+
+ func removeByPrefix(_ prefix: String) async {
+ await loadKnownMetadataIfNeeded()
+ for key in knownMetadata.keys where key.hasPrefix(prefix) {
+ await remove(cacheKey: key)
+ }
+ }
+
+ func clearAll() async {
+ memoryEntries.removeAll()
+ memoryOrder.removeAll()
+ knownMetadata.removeAll()
+ try? fileManager.removeItem(at: configuration.directory)
+ }
+
+ func pruneExpired(now: Date = Date()) async {
+ await loadKnownMetadataIfNeeded()
+ for metadata in knownMetadata.values where metadata.isExpired(now: now) {
+ await remove(cacheKey: metadata.cacheKey)
+ }
+ }
+
+ func pruneToSizeLimit() async {
+ await loadKnownMetadataIfNeeded()
+ var totalSize = knownMetadata.values.reduce(0) { $0 + $1.payloadSize }
+ guard totalSize > configuration.maxCacheSizeBytes else { return }
+
+ let victims = knownMetadata.values.sorted { $0.lastAccessedAt < $1.lastAccessedAt }
+ for metadata in victims {
+ await remove(cacheKey: metadata.cacheKey)
+ totalSize -= metadata.payloadSize
+ if totalSize <= configuration.maxCacheSizeBytes { break }
+ }
+ }
+
+ private func remember(_ entry: APICacheEntry) {
+ memoryEntries[entry.metadata.cacheKey] = entry
+ knownMetadata[entry.metadata.cacheKey] = entry.metadata
+ markMemoryUse(entry.metadata.cacheKey)
+ while memoryOrder.count > configuration.memoryEntryLimit, let evicted = memoryOrder.first {
+ memoryOrder.removeFirst()
+ memoryEntries.removeValue(forKey: evicted)
+ }
+ }
+
+ private func markMemoryUse(_ cacheKey: String) {
+ memoryOrder.removeAll { $0 == cacheKey }
+ memoryOrder.append(cacheKey)
+ }
+
+ private func persist(_ entry: APICacheEntry) throws {
+ try ensureDirectoryExists()
+ let stored = StoredEntry(metadata: entry.metadata, payload: entry.payload)
+ let data = try JSONEncoder.srhtCache.encode(stored)
+ try data.write(to: fileURL(for: entry.metadata.cacheKey), options: [.atomic])
+ }
+
+ private func decodeEntry(from url: URL) throws -> StoredEntry {
+ let data = try Data(contentsOf: url)
+ return try JSONDecoder.srhtCache.decode(StoredEntry.self, from: data)
+ }
+
+ private func loadKnownMetadataIfNeeded() async {
+ guard knownMetadata.isEmpty else { return }
+ guard let urls = try? fileManager.contentsOfDirectory(
+ at: configuration.directory,
+ includingPropertiesForKeys: nil
+ ) else { return }
+
+ for url in urls where url.pathExtension == "json" {
+ guard let stored = try? decodeEntry(from: url) else { continue }
+ knownMetadata[stored.metadata.cacheKey] = stored.metadata
+ }
+ }
+
+ private func ensureDirectoryExists() throws {
+ if !fileManager.fileExists(atPath: configuration.directory.path) {
+ try fileManager.createDirectory(
+ at: configuration.directory,
+ withIntermediateDirectories: true
+ )
+ }
+ }
+
+ private func fileURL(for cacheKey: String) -> URL {
+ configuration.directory
+ .appendingPathComponent(Self.payloadHash(Data(cacheKey.utf8)))
+ .appendingPathExtension("json")
+ }
+
+ private static func payloadHash(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+}
+
+extension JSONEncoder {
+ static var srhtCache: JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ return encoder
+ }
+}
+
+extension JSONDecoder {
+ static var srhtCache: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}