krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.9.0: Hutch/Networking/APICache.swift · raw

  1import CryptoKit
  2import Foundation
  3
  4enum CachePolicy: Sendable, Equatable {
  5    case networkOnly
  6    case cacheOnly
  7    case cacheFirstThenRefresh
  8    case refreshIgnoringCache
  9}
 10
 11enum CacheResourceType: String, Codable, Sendable {
 12    case repositoryDetail
 13    case repositoryList
 14    case repositoryTree
 15    case repositoryFile
 16    case repositoryReadme
 17    case ticketDetail
 18    case ticketList
 19    case buildDetail
 20    case buildList
 21    case buildLog
 22    case userProfile
 23    case status
 24    case pasteList
 25    case debug
 26}
 27
 28struct CacheEntryMetadata: Codable, Sendable, Equatable {
 29    let cacheKey: String
 30    let resourceType: CacheResourceType
 31    let fetchedAt: Date
 32    let expiresAt: Date
 33    var lastAccessedAt: Date
 34    let payloadHash: String
 35    let schemaVersion: Int
 36    let payloadSize: Int
 37
 38    nonisolated func isExpired(now: Date = Date()) -> Bool {
 39        expiresAt <= now
 40    }
 41}
 42
 43struct APICacheEntry: Sendable {
 44    var metadata: CacheEntryMetadata
 45    let payload: Data
 46}
 47
 48struct CachedValue<Value> {
 49    let value: Value
 50    let metadata: CacheEntryMetadata?
 51    let source: CacheValueSource
 52
 53    var isFromCache: Bool { source == .cache }
 54    var isStale: Bool { metadata?.isExpired() ?? false }
 55}
 56
 57enum CacheValueSource: Sendable, Equatable {
 58    case cache
 59    case network
 60}
 61
 62enum APICacheError: LocalizedError, Sendable {
 63    case miss
 64    case entryTooLarge(Int)
 65    case cacheTooLarge
 66
 67    var errorDescription: String? {
 68        switch self {
 69        case .miss:
 70            "No cached data is available."
 71        case .entryTooLarge(let bytes):
 72            "The response is too large to cache (\(bytes) bytes)."
 73        case .cacheTooLarge:
 74            "The cache size limit was exceeded."
 75        }
 76    }
 77}
 78
 79struct APICacheConfiguration: Sendable {
 80    var directory: URL
 81    var maxCacheSizeBytes: Int
 82    var maxEntrySizeBytes: Int
 83    var memoryEntryLimit: Int
 84    var schemaVersion: Int
 85
 86    nonisolated static func accountScoped(accountID: String) -> APICacheConfiguration {
 87        let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
 88            ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
 89        return APICacheConfiguration(
 90            directory: base
 91                .appendingPathComponent("Hutch", isDirectory: true)
 92                .appendingPathComponent("APICache", isDirectory: true)
 93                .appendingPathComponent(accountID, isDirectory: true),
 94            maxCacheSizeBytes: 50 * 1024 * 1024,
 95            maxEntrySizeBytes: 2 * 1024 * 1024,
 96            memoryEntryLimit: 64,
 97            schemaVersion: 1
 98        )
 99    }
100
101    nonisolated static func temporary(directory: URL) -> APICacheConfiguration {
102        APICacheConfiguration(
103            directory: directory,
104            maxCacheSizeBytes: 4 * 1024 * 1024,
105            maxEntrySizeBytes: 512 * 1024,
106            memoryEntryLimit: 16,
107            schemaVersion: 1
108        )
109    }
110}
111
112protocol APICache: Sendable {
113    func read(cacheKey: String) async throws -> APICacheEntry
114    func write(payload: Data, cacheKey: String, resourceType: CacheResourceType, ttl: TimeInterval) async throws -> CacheEntryMetadata
115    func remove(cacheKey: String) async
116    func removeByPrefix(_ prefix: String) async
117    func clearAll() async
118    func pruneExpired(now: Date) async
119    func pruneToSizeLimit() async
120}
121
122actor PersistentAPICache: APICache {
123    private struct StoredEntry: Codable, Sendable {
124        var metadata: CacheEntryMetadata
125        let payload: Data
126    }
127
128    private let configuration: APICacheConfiguration
129    private var memoryEntries: [String: APICacheEntry] = [:]
130    private var memoryOrder: [String] = []
131    private var knownMetadata: [String: CacheEntryMetadata] = [:]
132    private var writeCountSincePrune = 0
133
134    init(configuration: APICacheConfiguration) {
135        self.configuration = configuration
136    }
137
138    func read(cacheKey: String) async throws -> APICacheEntry {
139        if var entry = memoryEntries[cacheKey] {
140            entry.metadata.lastAccessedAt = Date()
141            memoryEntries[cacheKey] = entry
142            markMemoryUse(cacheKey)
143            try? persist(entry)
144            return entry
145        }
146
147        let url = fileURL(for: cacheKey)
148        guard FileManager.default.fileExists(atPath: url.path) else {
149            throw APICacheError.miss
150        }
151
152        var stored = try decodeEntry(from: url)
153        guard stored.metadata.schemaVersion == configuration.schemaVersion else {
154            try? FileManager.default.removeItem(at: url)
155            throw APICacheError.miss
156        }
157
158        stored.metadata.lastAccessedAt = Date()
159        let entry = APICacheEntry(metadata: stored.metadata, payload: stored.payload)
160        knownMetadata[cacheKey] = stored.metadata
161        remember(entry)
162        try? persist(entry)
163        return entry
164    }
165
166    func write(
167        payload: Data,
168        cacheKey: String,
169        resourceType: CacheResourceType,
170        ttl: TimeInterval
171    ) async throws -> CacheEntryMetadata {
172        guard payload.count <= configuration.maxEntrySizeBytes else {
173            throw APICacheError.entryTooLarge(payload.count)
174        }
175
176        try ensureDirectoryExists()
177        let now = Date()
178        let payloadHash = Self.payloadHash(payload)
179        if let existing = try? await read(cacheKey: cacheKey),
180           existing.metadata.payloadHash == payloadHash {
181            let metadata = CacheEntryMetadata(
182                cacheKey: cacheKey,
183                resourceType: resourceType,
184                fetchedAt: now,
185                expiresAt: now.addingTimeInterval(ttl),
186                lastAccessedAt: now,
187                payloadHash: payloadHash,
188                schemaVersion: configuration.schemaVersion,
189                payloadSize: payload.count
190            )
191            let entry = APICacheEntry(metadata: metadata, payload: payload)
192            remember(entry)
193            try persist(entry)
194            return metadata
195        }
196
197        let metadata = CacheEntryMetadata(
198            cacheKey: cacheKey,
199            resourceType: resourceType,
200            fetchedAt: now,
201            expiresAt: now.addingTimeInterval(ttl),
202            lastAccessedAt: now,
203            payloadHash: payloadHash,
204            schemaVersion: configuration.schemaVersion,
205            payloadSize: payload.count
206        )
207        let entry = APICacheEntry(metadata: metadata, payload: payload)
208        remember(entry)
209        try persist(entry)
210
211        writeCountSincePrune += 1
212        if writeCountSincePrune >= 12 {
213            writeCountSincePrune = 0
214            await pruneExpired(now: now)
215            await pruneToSizeLimit()
216        }
217        return metadata
218    }
219
220    func remove(cacheKey: String) async {
221        memoryEntries.removeValue(forKey: cacheKey)
222        memoryOrder.removeAll { $0 == cacheKey }
223        knownMetadata.removeValue(forKey: cacheKey)
224        try? FileManager.default.removeItem(at: fileURL(for: cacheKey))
225    }
226
227    func removeByPrefix(_ prefix: String) async {
228        await loadKnownMetadataIfNeeded()
229        for key in knownMetadata.keys where key.hasPrefix(prefix) {
230            await remove(cacheKey: key)
231        }
232    }
233
234    func clearAll() async {
235        memoryEntries.removeAll()
236        memoryOrder.removeAll()
237        knownMetadata.removeAll()
238        try? FileManager.default.removeItem(at: configuration.directory)
239    }
240
241    func pruneExpired(now: Date = Date()) async {
242        await loadKnownMetadataIfNeeded()
243        for metadata in knownMetadata.values where metadata.isExpired(now: now) {
244            await remove(cacheKey: metadata.cacheKey)
245        }
246    }
247
248    func pruneToSizeLimit() async {
249        await loadKnownMetadataIfNeeded()
250        var totalSize = knownMetadata.values.reduce(0) { $0 + $1.payloadSize }
251        guard totalSize > configuration.maxCacheSizeBytes else { return }
252
253        let victims = knownMetadata.values.sorted { $0.lastAccessedAt < $1.lastAccessedAt }
254        for metadata in victims {
255            await remove(cacheKey: metadata.cacheKey)
256            totalSize -= metadata.payloadSize
257            if totalSize <= configuration.maxCacheSizeBytes { break }
258        }
259    }
260
261    private func remember(_ entry: APICacheEntry) {
262        memoryEntries[entry.metadata.cacheKey] = entry
263        knownMetadata[entry.metadata.cacheKey] = entry.metadata
264        markMemoryUse(entry.metadata.cacheKey)
265        while memoryOrder.count > configuration.memoryEntryLimit, let evicted = memoryOrder.first {
266            memoryOrder.removeFirst()
267            memoryEntries.removeValue(forKey: evicted)
268        }
269    }
270
271    private func markMemoryUse(_ cacheKey: String) {
272        memoryOrder.removeAll { $0 == cacheKey }
273        memoryOrder.append(cacheKey)
274    }
275
276    private func persist(_ entry: APICacheEntry) throws {
277        try ensureDirectoryExists()
278        let stored = StoredEntry(metadata: entry.metadata, payload: entry.payload)
279        let data = try JSONEncoder.srhtCache.encode(stored)
280        try data.write(to: fileURL(for: entry.metadata.cacheKey), options: [.atomic])
281    }
282
283    private func decodeEntry(from url: URL) throws -> StoredEntry {
284        let data = try Data(contentsOf: url)
285        return try JSONDecoder.srhtCache.decode(StoredEntry.self, from: data)
286    }
287
288    private func loadKnownMetadataIfNeeded() async {
289        guard knownMetadata.isEmpty else { return }
290        guard let urls = try? FileManager.default.contentsOfDirectory(
291            at: configuration.directory,
292            includingPropertiesForKeys: nil
293        ) else { return }
294
295        for url in urls where url.pathExtension == "json" {
296            guard let stored = try? decodeEntry(from: url) else { continue }
297            knownMetadata[stored.metadata.cacheKey] = stored.metadata
298        }
299    }
300
301    private func ensureDirectoryExists() throws {
302        if !FileManager.default.fileExists(atPath: configuration.directory.path) {
303            try FileManager.default.createDirectory(
304                at: configuration.directory,
305                withIntermediateDirectories: true
306            )
307        }
308    }
309
310    private func fileURL(for cacheKey: String) -> URL {
311        configuration.directory
312            .appendingPathComponent(Self.payloadHash(Data(cacheKey.utf8)))
313            .appendingPathExtension("json")
314    }
315
316    nonisolated private static func payloadHash(_ data: Data) -> String {
317        SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
318    }
319}
320
321extension JSONEncoder {
322    nonisolated static var srhtCache: JSONEncoder {
323        let encoder = JSONEncoder()
324        encoder.dateEncodingStrategy = .iso8601
325        return encoder
326    }
327}
328
329extension JSONDecoder {
330    nonisolated static var srhtCache: JSONDecoder {
331        let decoder = JSONDecoder()
332        decoder.dateDecodingStrategy = .iso8601
333        return decoder
334    }
335}