-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Bound Codex cost cache persistence size (#2637) #2646
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
dd2fa7d
2fb4d4c
88aa138
7883c72
2ad965d
8cd629e
e1894f2
1ed0514
98a640d
0c26743
f8acd53
9aac621
bc4ad2d
2f80534
530eb6d
13fb17e
c97ac24
71e2743
1f104be
af49fd9
3970a96
a0c7b2d
959b466
9fdc1a7
c6004b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. | ||
|
|
||
| enum CodexParserHash { | ||
| static let value = "843ca061c36bbea1" | ||
| static let value = "3c03b1d5045174e2" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,17 @@ | ||
| import Foundation | ||
|
|
||
| enum CostUsageCacheIO { | ||
| /// Persistence budgets for the Codex cost cache. The artifact holds one entry per | ||
| /// scanned session file plus per-file detail (rows, turn IDs, token snapshots) and is | ||
| /// decoded and encoded as a single JSON document on every scan, so an unbounded corpus | ||
| /// can otherwise grow it to multiple gigabytes. These bounds mirror the scan-side byte | ||
| /// budgets; in-window data is never dropped, so reports stay complete. | ||
| static let maxCacheFileBytes: Int = 256 * 1024 * 1024 | ||
| static let maxCacheFileEntries: Int = 25000 | ||
| /// Artifacts above this size are refused at load time and rebuilt by the bounded | ||
| /// scanner instead of being decoded in one shot. | ||
| static let maxCacheLoadBytes: Int = 1024 * 1024 * 1024 | ||
|
|
||
| /// Producer keys from older parser hashes whose caches are still valid under the current | ||
| /// delta semantics. Cleared for #2037: interleave containment changed how cumulative | ||
| /// totals are counted, so every earlier cache must be rebuilt. | ||
|
|
@@ -36,7 +47,8 @@ enum CostUsageCacheIO { | |
| provider: UsageProvider, | ||
| cacheRoot: URL? = nil, | ||
| producerKey: String? = nil, | ||
| calendar: Calendar? = nil) -> CostUsageCache | ||
| calendar: Calendar? = nil, | ||
| maxCacheBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) -> CostUsageCache | ||
| { | ||
| let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) | ||
| let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) | ||
|
|
@@ -46,7 +58,8 @@ enum CostUsageCacheIO { | |
| if let decoded = self.loadCache( | ||
| at: url, | ||
| expectedProducerKey: expectedProducerKey, | ||
| compatibleProducerKeys: compatibleProducerKeys) | ||
| compatibleProducerKeys: compatibleProducerKeys, | ||
| maxBytes: maxCacheBytes) | ||
| { | ||
| if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { | ||
| return CostUsageCache() | ||
|
|
@@ -59,10 +72,11 @@ enum CostUsageCacheIO { | |
| static func loadCodexForMigration( | ||
| cacheRoot: URL? = nil, | ||
| producerKey: String? = nil, | ||
| calendar: Calendar? = nil) -> CostUsageCodexCacheLoadResult | ||
| calendar: Calendar? = nil, | ||
| maxCacheBytes: Int = CostUsageCacheIO.maxCacheLoadBytes) -> CostUsageCodexCacheLoadResult | ||
| { | ||
| let url = self.cacheFileURL(provider: .codex, cacheRoot: cacheRoot) | ||
| guard let decoded = self.decodeCache(at: url) else { | ||
| guard let decoded = self.decodeCache(at: url, maxBytes: maxCacheBytes) else { | ||
| return CostUsageCodexCacheLoadResult(cache: CostUsageCache(), incompatibleCache: nil) | ||
| } | ||
| if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { | ||
|
|
@@ -89,9 +103,10 @@ enum CostUsageCacheIO { | |
| private static func loadCache( | ||
| at url: URL, | ||
| expectedProducerKey: String?, | ||
| compatibleProducerKeys: Set<String>) -> CostUsageCache? | ||
| compatibleProducerKeys: Set<String>, | ||
| maxBytes: Int) -> CostUsageCache? | ||
| { | ||
| guard let decoded = self.decodeCache(at: url) else { return nil } | ||
| guard let decoded = self.decodeCache(at: url, maxBytes: maxBytes) else { return nil } | ||
| if let expectedProducerKey { | ||
| guard decoded.producerKey == expectedProducerKey | ||
| || decoded.producerKey.map(compatibleProducerKeys.contains) == true | ||
|
|
@@ -100,7 +115,10 @@ enum CostUsageCacheIO { | |
| return decoded | ||
| } | ||
|
|
||
| private static func decodeCache(at url: URL) -> CostUsageCache? { | ||
| private static func decodeCache(at url: URL, maxBytes: Int) -> CostUsageCache? { | ||
| let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? | ||
| .int64Value ?? 0 | ||
| guard fileSize <= maxBytes else { return nil } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When active-window or resumable entries alone encode above 1 GiB, save deliberately cannot prune those entries and still writes the oversized artifact, but this guard discards it on the next load. The bounded scanner then starts from an empty cache, eventually recreates the same oversized active cache, and repeats the rebuild cycle, leaving large-corpus users with recurring catch-up work and incomplete/stale reports. The load policy needs to remain compatible with artifacts that the save policy cannot reduce, or save must enforce a representation that stays below the load cap while preserving resume state. Useful? React with 👍 / 👎. |
||
| guard let data = try? Data(contentsOf: url) else { return nil } | ||
| guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data) | ||
| else { return nil } | ||
|
|
@@ -113,7 +131,10 @@ enum CostUsageCacheIO { | |
| cache: CostUsageCache, | ||
| cacheRoot: URL? = nil, | ||
| producerKey: String? = nil, | ||
| calendar: Calendar = .current) | ||
| calendar: Calendar = .current, | ||
| requestedScanWindow: (sinceKey: String, untilKey: String)? = nil, | ||
| maxCacheBytes: Int = CostUsageCacheIO.maxCacheFileBytes, | ||
| maxCacheEntries: Int = CostUsageCacheIO.maxCacheFileEntries) | ||
| { | ||
| let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) | ||
| let dir = url.deletingLastPathComponent() | ||
|
|
@@ -123,10 +144,86 @@ enum CostUsageCacheIO { | |
| cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) | ||
| cache.timeZoneIdentifier = calendar.timeZone.identifier | ||
|
|
||
| if provider == .codex { | ||
| Self.pruneCodexCacheForBudget( | ||
| &cache, | ||
| requestedScanWindow: requestedScanWindow, | ||
| maxCacheBytes: maxCacheBytes, | ||
| maxCacheEntries: maxCacheEntries, | ||
| previousArtifactBytes: Self.fileSize(at: url)) | ||
| } | ||
|
|
||
| let data = (try? JSONEncoder().encode(cache)) ?? Data() | ||
| try? data.write(to: url, options: [.atomic]) | ||
| } | ||
|
|
||
| /// Bounds the Codex cache artifact when the corpus has outgrown the persistence budget. | ||
| /// The all-time accumulation lives in per-file entries whose usage days fall outside the | ||
| /// current scan window; the current report never reads those entries, and dropping them | ||
| /// (with the same day-aggregate subtraction the scanner uses) keeps the artifact from | ||
| /// growing without limit. Entries that are still resuming or that in-window forks depend | ||
| /// on are preserved so bounded scans and fork baselines keep making progress. Priority | ||
| /// turn IDs outside the window are only consulted for in-window rows, so they are trimmed | ||
| /// to the window as well. | ||
| private static func pruneCodexCacheForBudget( | ||
| _ cache: inout CostUsageCache, | ||
| requestedScanWindow: (sinceKey: String, untilKey: String)?, | ||
| maxCacheBytes: Int, | ||
| maxCacheEntries: Int, | ||
| previousArtifactBytes: Int64?) | ||
| { | ||
| // Prune against the active requested scan window (what the current report reads), | ||
| // not the historically widened retained union persisted in the cache. | ||
| let sinceKey = requestedScanWindow?.sinceKey ?? cache.scanSinceKey | ||
| let untilKey = requestedScanWindow?.untilKey ?? cache.scanUntilKey | ||
| guard let sinceKey, let untilKey else { return } | ||
| let overBudget = cache.files.count > maxCacheEntries | ||
| || (previousArtifactBytes ?? 0) > Int64(maxCacheBytes) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the existing cache is below 256 MiB but one refresh adds enough rows or token snapshots to cross that limit, this condition remains false as long as there are at most 25,000 entries because it checks only Useful? React with 👍 / 👎. |
||
| guard overBudget else { return } | ||
|
|
||
| let neededParentSessionIDs = Set(cache.files.values.compactMap(\.forkedFromId)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The revised dependency calculation includes Useful? React with 👍 / 👎. |
||
| let outOfWindowKeys = cache.files.keys.filter { key in | ||
| guard let usage = cache.files[key] else { return false } | ||
| if usage.touchesCodexScanWindow(sinceKey: sinceKey, untilKey: untilKey) { return false } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On normal rolling-window refreshes, Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the cache is over budget, a completed session in the currently requested partition that produces no usage rows—such as a suppressed subagent or idless copied prefix—fails Useful? React with 👍 / 👎. |
||
| if usage.codexScanComplete == false { return false } | ||
| if usage.codexJSONLResumeState != nil { return false } | ||
| if usage.hasBufferedCodexForkRetryLines { return false } | ||
| if let sessionId = usage.sessionId, neededParentSessionIDs.contains(sessionId) { | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
| for key in outOfWindowKeys { | ||
| guard let old = cache.files.removeValue(forKey: key) else { continue } | ||
| CostUsageScanner.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) | ||
|
Comment on lines
+279
to
+285
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When budget pruning removes stale Useful? React with 👍 / 👎. |
||
| } | ||
| if !outOfWindowKeys.isEmpty, requestedScanWindow != nil { | ||
| // Entries outside the requested window are gone; narrow persisted coverage so a | ||
| // later refresh does not treat them as in-window again. | ||
| cache.scanSinceKey = requestedScanWindow?.sinceKey ?? cache.scanSinceKey | ||
| cache.scanUntilKey = requestedScanWindow?.untilKey ?? cache.scanUntilKey | ||
| } | ||
|
|
||
| let inWindow: (String) -> Bool = { key in | ||
| CostUsageScanner.CostUsageDayRange.isInRange( | ||
| dayKey: key, | ||
| since: sinceKey, | ||
| until: untilKey) | ||
| } | ||
| if let idsByDay = cache.codexPriorityTurnIDsByDay { | ||
| let trimmed = idsByDay.filter { inWindow($0.key) } | ||
| cache.codexPriorityTurnIDsByDay = trimmed.isEmpty ? nil : trimmed | ||
| } | ||
| if let turnKeys = cache.codexPriorityTurnKeys { | ||
| let trimmed = turnKeys.filter { inWindow($0.key) } | ||
| cache.codexPriorityTurnKeys = trimmed.isEmpty ? nil : trimmed | ||
| } | ||
| } | ||
|
|
||
| private static func fileSize(at url: URL) -> Int64? { | ||
| (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value | ||
| } | ||
|
|
||
| static func currentProducerKey( | ||
| provider: UsageProvider, | ||
| parserHash: String = CodexParserHash.value) -> String? | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The size guard is shared by every
CostUsageCacheIO.loadcall, but the inspected Claude path inloadClaudeDailyuses an unbounded recursive scanner andsaveprunes only whenprovider == .codex. Once a Claude or Vertex cache exceeds 1 GiB, each load now returns an empty cache, the provider performs a full corpus rebuild, and then writes another oversized artifact that will be rejected again on the next refresh. Either scope this refusal to Codex or add equivalent bounded persistence/rebuild behavior for the other providers.Useful? React with 👍 / 👎.