diff options
Diffstat (limited to 'Hutch/Networking/APICache.swift')
| -rw-r--r-- | Hutch/Networking/APICache.swift | 337 |
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 + } +} |
