Skip to content

Commit 05b1f00

Browse files
Merkostclaude
andcommitted
fix(content-cache): real LRU + cleanup pass
Bugs / cleanups in this pass: 1. LRU was actually FIFO. trimIfNeeded sorted entries by fetchedAt and evicted the oldest, regardless of how often each was accessed. A heavily-reused file from week 1 would get evicted while a never-used file from week 2 stayed. Now meta.json carries lastAccessedAt alongside fetchedAt; lookup bumps it (rate-limited to once per 60s to avoid write storms); trim sorts by lastAccessedAt and evicts least-recently-used first. 2. Dead ternary in store: ext.isEmpty ? filename : "\(filename)" produced the same string in both branches. Replaced with plain filename. 3. sha256Base32 was misleadingly named — returned hex, not base32. Renamed to sha256Hex and hoisted to a static function alongside the identical pattern in ContentKey. 4. Extracted readMeta / writeMeta / computeEntrySize helpers so the five methods that read or scan the on-disk layout don't each re-implement the JSONDecoder + dateDecodingStrategy dance. 5/5 ContentCacheTests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4577083 commit 05b1f00

1 file changed

Lines changed: 53 additions & 48 deletions

File tree

Sources/FreeDroidContentCache/ContentCache.swift

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ private struct CacheEntryMeta: Codable {
55
let version: String
66
let keyDescription: String
77
let fetchedAt: Date
8+
var lastAccessedAt: Date
89
let size: Int64
910
let deviceID: String
1011
let path: String
@@ -36,12 +37,7 @@ public actor ContentCache {
3637
public func lookup(_ key: ContentKey) -> URL? {
3738
let entryDir = entryDirectory(for: key)
3839
let metaURL = entryDir.appendingPathComponent("meta.json")
39-
let decoder = JSONDecoder()
40-
decoder.dateDecodingStrategy = .secondsSince1970
41-
guard let metaData = try? Data(contentsOf: metaURL),
42-
let meta = try? decoder.decode(CacheEntryMeta.self, from: metaData) else {
43-
return nil
44-
}
40+
guard var meta = readMeta(at: metaURL) else { return nil }
4541
guard meta.deviceID == key.deviceID,
4642
meta.path == key.path,
4743
meta.mtimeUnix == key.mtimeUnix,
@@ -56,16 +52,33 @@ public actor ContentCache {
5652
guard let fileURL, fileManager.fileExists(atPath: fileURL.path) else {
5753
return nil
5854
}
55+
let now = Date()
56+
if now.timeIntervalSince(meta.lastAccessedAt) > 60 {
57+
meta.lastAccessedAt = now
58+
writeMeta(meta, to: metaURL)
59+
}
5960
return fileURL
6061
}
6162

63+
private func readMeta(at url: URL) -> CacheEntryMeta? {
64+
let decoder = JSONDecoder()
65+
decoder.dateDecodingStrategy = .secondsSince1970
66+
guard let data = try? Data(contentsOf: url) else { return nil }
67+
return try? decoder.decode(CacheEntryMeta.self, from: data)
68+
}
69+
70+
private func writeMeta(_ meta: CacheEntryMeta, to url: URL) {
71+
let encoder = JSONEncoder()
72+
encoder.dateEncodingStrategy = .secondsSince1970
73+
guard let data = try? encoder.encode(meta) else { return }
74+
try? data.write(to: url, options: [.atomic])
75+
}
76+
6277
public func store(_ source: URL, key: ContentKey, filename: String) async throws -> URL {
6378
let entryDir = entryDirectory(for: key)
6479
try fileManager.createDirectory(at: entryDir, withIntermediateDirectories: true)
6580

66-
let ext = (filename as NSString).pathExtension
67-
let destName = ext.isEmpty ? filename : "\(filename)"
68-
let destURL = entryDir.appendingPathComponent(destName)
81+
let destURL = entryDir.appendingPathComponent(filename)
6982

7083
if fileManager.fileExists(atPath: destURL.path) {
7184
try fileManager.removeItem(at: destURL)
@@ -77,21 +90,20 @@ public actor ContentCache {
7790
try fileManager.copyItem(at: source, to: destURL)
7891
}
7992

93+
let now = Date()
8094
let meta = CacheEntryMeta(
8195
version: "v1",
8296
keyDescription: "\(key.deviceID)|\(key.path)|\(key.mtimeUnix)|\(key.size)",
83-
fetchedAt: Date(),
97+
fetchedAt: now,
98+
lastAccessedAt: now,
8499
size: key.size,
85100
deviceID: key.deviceID,
86101
path: key.path,
87102
mtimeUnix: key.mtimeUnix,
88103
keySizeBytes: key.size
89104
)
90-
let encoder = JSONEncoder()
91-
encoder.dateEncodingStrategy = .secondsSince1970
92-
let metaData = try encoder.encode(meta)
93105
let metaURL = entryDir.appendingPathComponent("meta.json")
94-
try metaData.write(to: metaURL, options: [.atomic])
106+
writeMeta(meta, to: metaURL)
95107

96108
await trimIfNeeded()
97109

@@ -132,26 +144,20 @@ public actor ContentCache {
132144
}
133145

134146
private func entryDirectory(for key: ContentKey) -> URL {
135-
let deviceSlug = sha256Base32(key.deviceID)
136-
let keySlug = key.sha256Hex
137-
return rootURL
138-
.appendingPathComponent(deviceSlug, isDirectory: true)
139-
.appendingPathComponent(keySlug, isDirectory: true)
147+
rootURL
148+
.appendingPathComponent(Self.sha256Hex(key.deviceID), isDirectory: true)
149+
.appendingPathComponent(key.sha256Hex, isDirectory: true)
140150
}
141151

142-
private func sha256Base32(_ input: String) -> String {
143-
let digest = SHA256.hash(data: Data(input.utf8))
144-
return digest.map { String(format: "%02x", $0) }.joined()
152+
private static func sha256Hex(_ input: String) -> String {
153+
SHA256.hash(data: Data(input.utf8))
154+
.map { String(format: "%02x", $0) }
155+
.joined()
145156
}
146157

147158
private func readKey(from entryDir: URL) -> ContentKey? {
148159
let metaURL = entryDir.appendingPathComponent("meta.json")
149-
let decoder = JSONDecoder()
150-
decoder.dateDecodingStrategy = .secondsSince1970
151-
guard let data = try? Data(contentsOf: metaURL),
152-
let meta = try? decoder.decode(CacheEntryMeta.self, from: data) else {
153-
return nil
154-
}
160+
guard let meta = readMeta(at: metaURL) else { return nil }
155161
return ContentKey(
156162
deviceID: meta.deviceID,
157163
path: meta.path,
@@ -192,7 +198,7 @@ public actor ContentCache {
192198

193199
struct EntryRecord {
194200
let dir: URL
195-
let fetchedAt: Date
201+
let lastAccessedAt: Date
196202
let size: Int64
197203
}
198204

@@ -207,28 +213,13 @@ public actor ContentCache {
207213
includingPropertiesForKeys: nil
208214
)) ?? []
209215
for entryDir in entryDirs {
210-
let metaURL = entryDir.appendingPathComponent("meta.json")
211-
let metaDecoder = JSONDecoder()
212-
metaDecoder.dateDecodingStrategy = .secondsSince1970
213-
guard let data = try? Data(contentsOf: metaURL),
214-
let meta = try? metaDecoder.decode(CacheEntryMeta.self, from: data) else {
215-
continue
216-
}
217-
var entrySize: Int64 = 0
218-
let files = (try? fileManager.contentsOfDirectory(
219-
at: entryDir,
220-
includingPropertiesForKeys: [.fileSizeKey]
221-
)) ?? []
222-
for file in files {
223-
if let sz = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize {
224-
entrySize += Int64(sz)
225-
}
226-
}
227-
records.append(EntryRecord(dir: entryDir, fetchedAt: meta.fetchedAt, size: entrySize))
216+
guard let meta = readMeta(at: entryDir.appendingPathComponent("meta.json")) else { continue }
217+
let entrySize = computeEntrySize(at: entryDir)
218+
records.append(EntryRecord(dir: entryDir, lastAccessedAt: meta.lastAccessedAt, size: entrySize))
228219
}
229220
}
230221

231-
records.sort { $0.fetchedAt < $1.fetchedAt }
222+
records.sort { $0.lastAccessedAt < $1.lastAccessedAt }
232223
var freed: Int64 = 0
233224
let mustFree = total - capacityBytes
234225
for record in records {
@@ -237,4 +228,18 @@ public actor ContentCache {
237228
freed += record.size
238229
}
239230
}
231+
232+
private func computeEntrySize(at entryDir: URL) -> Int64 {
233+
var entrySize: Int64 = 0
234+
let files = (try? fileManager.contentsOfDirectory(
235+
at: entryDir,
236+
includingPropertiesForKeys: [.fileSizeKey]
237+
)) ?? []
238+
for file in files {
239+
if let sz = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize {
240+
entrySize += Int64(sz)
241+
}
242+
}
243+
return entrySize
244+
}
240245
}

0 commit comments

Comments
 (0)