Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions Sources/PokeTokenBar/Core/LocalUsageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,25 @@ import Foundation
actor LocalUsageCache {
static let shared = LocalUsageCache()

private struct Blob: Codable { let mtime: Date; let size: Int; let entries: [LocalUsageReader.Entry] }
private struct Blob: Codable {
let mtime: Date
let size: Int
let entries: [LocalUsageReader.Entry]
/// Bytes already parsed, for append-only logs. Nil on blobs from older cache files.
let offset: Int?

init(mtime: Date, size: Int, entries: [LocalUsageReader.Entry], offset: Int? = nil) {
self.mtime = mtime; self.size = size; self.entries = entries; self.offset = offset
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
mtime = try c.decode(Date.self, forKey: .mtime)
size = try c.decode(Int.self, forKey: .size)
entries = try c.decode([LocalUsageReader.Entry].self, forKey: .entries)
offset = try c.decodeIfPresent(Int.self, forKey: .offset)
}
}
private struct CodexBlob: Codable {
let mtime: Date
let size: Int
Expand Down Expand Up @@ -165,7 +183,10 @@ actor LocalUsageCache {
let roots = claudeRoots ?? claudeRoot.map { [$0] } ?? LocalUsageReader.claudeProjectRoots
var all: [LocalUsageReader.Entry] = []
for root in roots {
all += collect(root: root, since: modifiedSince, cache: &claudeCache) {
all += collect(root: root, since: modifiedSince, cache: &claudeCache,
incremental: { url, offset in
LocalUsageReader.parseClaudeFile(url, fromOffset: offset, fmt: fmt)
}) {
LocalUsageReader.parseClaudeFile($0, fmt: fmt)
}
}
Expand Down Expand Up @@ -266,8 +287,12 @@ actor LocalUsageCache {

/// `include` 는 blob 캐시 조회 **전에** 평가된다 — 파일 밖 상태(옆 파일 등)에 의존하는 판정을
/// 캐시에 굳히지 않기 위해서다.
/// `incremental` parses from a byte offset for append-only logs. When the cached blob has an
/// offset and the file grew, only the new tail is read and merged with the cached entries;
/// otherwise the file is parsed from the start and the resulting offset recorded.
private func collect(root: URL, since: Date, cache: inout [String: Blob],
allowJSON: Bool = false, include: ((URL) -> Bool)? = nil,
incremental: ((URL, Int) -> (entries: [LocalUsageReader.Entry], newOffset: Int)?)? = nil,
parse: (URL) -> [LocalUsageReader.Entry]?) -> [LocalUsageReader.Entry] {
let fm = FileManager.default
guard let en = fm.enumerator(
Expand All @@ -286,6 +311,21 @@ actor LocalUsageCache {
let key = url.path
if let blob = cache[key], blob.mtime == mtime, blob.size == size {
result.append(contentsOf: blob.entries) // 변경 없음 → 재파싱 안 함
} else if let incremental {
let prior = cache[key]
// Tail-parse only when the file strictly grew; a same-size rewrite or a
// truncation is re-read from the start.
let from = prior.flatMap { p in size > p.size ? p.offset : nil } ?? 0
if let tail = incremental(url, from) {
let entries = from > 0
? LocalUsageReader.dedupKeepMax((prior?.entries ?? []) + tail.entries)
: tail.entries
cache[key] = Blob(mtime: mtime, size: size, entries: entries, offset: tail.newOffset)
dirty = true
result.append(contentsOf: entries)
} else if let prior {
result.append(contentsOf: prior.entries)
}
} else if let entries = parse(url) {
cache[key] = Blob(mtime: mtime, size: size, entries: entries)
dirty = true
Expand Down
48 changes: 43 additions & 5 deletions Sources/PokeTokenBar/Core/LocalUsageReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -368,17 +368,55 @@ enum LocalUsageReader {

/// Claude 파일 하나를 파싱(파일 내 dedup). 캐시가 파일 단위로 호출.
static func parseClaudeFile(_ url: URL, fmt: DateFormatter) -> [Entry] {
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [] }
parseClaudeFile(url, fromOffset: 0, fmt: fmt)?.entries ?? []
}

/// Parses only the bytes after `fromOffset`. Session logs are append-only and the active
/// one can be hundreds of MB, so the cache re-reads just the new tail each refresh instead
/// of the whole file. `newOffset` stops at the last complete line so a half-written record
/// is retried next time. Nil means the file could not be read.
static func parseClaudeFile(_ url: URL, fromOffset: Int, fmt: DateFormatter)
-> (entries: [Entry], newOffset: Int)?
{
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
defer { try? handle.close() }
guard (try? handle.seek(toOffset: UInt64(fromOffset))) != nil,
let data = try? handle.readToEnd() else { return nil }
let parsed = parseClaudeData(data, fmt: fmt)
return (parsed.entries, fromOffset + parsed.consumed)
}

private static let usageNeedle = Data("\"usage\"".utf8)
private static let assistantNeedle = Data("\"assistant\"".utf8)

/// Byte-level line scan: no whole-file String materialization or per-line Substring
/// allocation. `consumed` is the byte count up to and including the last newline.
static func parseClaudeData(_ data: Data, fmt: DateFormatter) -> (entries: [Entry], consumed: Int) {
var out: [Entry] = []
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
guard line.contains("\"usage\""), line.contains("\"assistant\"") else { continue }
var consumed = 0
var lineStart = data.startIndex
func parse(_ line: Data.SubSequence) {
guard !line.isEmpty,
line.range(of: usageNeedle) != nil,
line.range(of: assistantNeedle) != nil,
let text = String(data: line, encoding: .utf8) else { return }
// 라인마다 autoreleasepool — JSONSerialization 이 만드는 autoreleased NSDictionary/NSString 가
// 수천 파일·수만 라인에 걸쳐 배출 없이 누적돼 콜드 파싱 피크를 키우던 것을 즉시 배출.
autoreleasepool {
if let e = parseClaudeLine(String(line), fmt: fmt) { out.append(e) }
if let e = parseClaudeLine(text, fmt: fmt) { out.append(e) }
}
}
return dedupKeepMax(out)
while lineStart < data.endIndex {
guard let nl = data[lineStart...].firstIndex(of: 0x0A) else { break }
parse(data[lineStart..<nl])
consumed = nl - data.startIndex + 1
lineStart = nl + 1
}
// A trailing line with no newline is either a finished file's last record or a record
// still being written. Parse it now but leave it out of `consumed` so an incremental
// re-read picks it up again; dedup by id makes the repeat harmless.
if lineStart < data.endIndex { parse(data[lineStart...]) }
return (dedupKeepMax(out), consumed)
}

/// `modifiedSince` 이후 파일에서 Claude 사용 엔트리(전역 dedup) — 테스트/캐시 미사용 경로.
Expand Down
50 changes: 35 additions & 15 deletions Sources/PokeTokenBar/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -566,23 +566,43 @@ struct ProviderSnapshot: Sendable, Identifiable {

enum ISO8601Parser {
/// resets_at 은 마이크로초("...034464+00:00") 또는 밀리초("....303Z") 형태 — 둘 다 처리.
/// ISO8601DateFormatter 는 non-Sendable 이라 호출마다 생성 (파싱 빈도 낮음).
static func date(from string: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = fractional.date(from: string) { return d }
// 소수점 자릿수가 3자리가 아니면 3자리로 절단 후 재시도
if let dotIndex = string.firstIndex(of: ".") {
let afterDot = string.index(after: dotIndex)
if let tzIndex = string[afterDot...].firstIndex(where: { $0 == "+" || $0 == "-" || $0 == "Z" }) {
let frac = String(string[afterDot..<tzIndex]).prefix(3)
let padded = String(frac).padding(toLength: 3, withPad: "0", startingAt: 0)
let rebuilt = String(string[..<dotIndex]) + "." + padded + String(string[tzIndex...])
if let d = fractional.date(from: rebuilt) { return d }
shared.date(from: string)
}

private static let shared = LockedParser()

/// This is also the per-line timestamp parser for every local session log, so it runs
/// millions of times on a large history. Allocating an `ISO8601DateFormatter` per call was
/// the dominant cost of a cold scan. The formatters are non-Sendable and callers (the
/// provider caches) run concurrently, so one shared pair is kept behind a lock.
private final class LockedParser: @unchecked Sendable {
private let lock = NSLock()
private let fractional: ISO8601DateFormatter
private let plain: ISO8601DateFormatter

init() {
fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
}

func date(from string: String) -> Date? {
lock.lock()
defer { lock.unlock() }
if let d = fractional.date(from: string) { return d }
// 소수점 자릿수가 3자리가 아니면 3자리로 절단 후 재시도
if let dotIndex = string.firstIndex(of: ".") {
let afterDot = string.index(after: dotIndex)
if let tzIndex = string[afterDot...].firstIndex(where: { $0 == "+" || $0 == "-" || $0 == "Z" }) {
let frac = String(string[afterDot..<tzIndex]).prefix(3)
let padded = String(frac).padding(toLength: 3, withPad: "0", startingAt: 0)
let rebuilt = String(string[..<dotIndex]) + "." + padded + String(string[tzIndex...])
if let d = fractional.date(from: rebuilt) { return d }
}
}
return plain.date(from: string)
}
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
return plain.date(from: string)
}
}
80 changes: 80 additions & 0 deletions Tests/PokeTokenBarTests/LocalUsageCacheTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,86 @@ final class LocalUsageCacheTests: XCTestCase {
XCTAssertEqual(second.map(\.output), [999])
}

// MARK: Incremental (append-only) Claude parsing

private func writeRaw(_ name: String, _ text: String, mtime: Date) throws {
let url = root.appendingPathComponent(name)
try text.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.modificationDate: mtime], ofItemAtPath: url.path)
}

/// The active session log is appended to constantly and can be hundreds of MB. When a
/// cached file has only grown, just the new tail is parsed. Proven the same way as the
/// unchanged-file test: the already-parsed prefix is rewritten in place at the same length
/// and must NOT be picked up, while the appended line must be.
func testAppendedFileParsesOnlyTheNewTail() async throws {
let t0 = Date(timeIntervalSince1970: floor(Date().timeIntervalSince1970) - 3600)
try writeRaw("a.jsonl", claudeLine(id: "1", output: 111) + "\n", mtime: t0)
let cache = makeCache()
let first = await cache.claudeEntries(modifiedSince: since)
XCTAssertEqual(first.map(\.output), [111])

try writeRaw("a.jsonl",
claudeLine(id: "1", output: 222) + "\n" + claudeLine(id: "2", output: 333) + "\n",
mtime: t0.addingTimeInterval(10))
let second = await cache.claudeEntries(modifiedSince: since)
XCTAssertEqual(Set(second.map(\.output)), [111, 333],
"prefix must come from the cache (111, not 222); only the tail is parsed")
}

/// A shrunk file is not append-only growth: it is re-read from the start.
func testTruncatedFileIsReparsedFromStart() async throws {
let t0 = Date(timeIntervalSince1970: floor(Date().timeIntervalSince1970) - 3600)
try writeRaw("a.jsonl",
claudeLine(id: "1", output: 111) + "\n" + claudeLine(id: "2", output: 222) + "\n",
mtime: t0)
let cache = makeCache()
_ = await cache.claudeEntries(modifiedSince: since)

try writeRaw("a.jsonl", claudeLine(id: "3", output: 333) + "\n", mtime: t0.addingTimeInterval(10))
let second = await cache.claudeEntries(modifiedSince: since)
XCTAssertEqual(second.map(\.output), [333])
}

/// A record still being written (no trailing newline) is parsed if complete, but the offset
/// stops before it so the next refresh re-reads it once it is finished.
func testHalfWrittenTrailingLineIsRetriedOnNextRefresh() async throws {
let t0 = Date(timeIntervalSince1970: floor(Date().timeIntervalSince1970) - 3600)
let full = claudeLine(id: "2", output: 222)
let partial = String(full.prefix(full.count / 2))
try writeRaw("a.jsonl", claudeLine(id: "1", output: 111) + "\n" + partial, mtime: t0)
let cache = makeCache()
let first = await cache.claudeEntries(modifiedSince: since)
XCTAssertEqual(first.map(\.output), [111], "a truncated JSON line is not an entry")

try writeRaw("a.jsonl", claudeLine(id: "1", output: 111) + "\n" + full + "\n",
mtime: t0.addingTimeInterval(10))
let second = await cache.claudeEntries(modifiedSince: since)
XCTAssertEqual(Set(second.map(\.output)), [111, 222])
}

/// Cache files written before `offset` existed still load; those blobs fall back to a full
/// parse the first time their file changes.
func testBlobWithoutOffsetStillDecodes() async throws {
let t0 = Date(timeIntervalSince1970: floor(Date().timeIntervalSince1970) - 3600)
try writeRaw("a.jsonl", claudeLine(id: "1", output: 111) + "\n", mtime: t0)
let cache = makeCache()
_ = await cache.claudeEntries(modifiedSince: since) // first save is never debounced

// Strip the offset from every Claude blob on disk, as an older release would have written.
let raw = try Data(contentsOf: cacheFile)
let decompressed = (try? (raw as NSData).decompressed(using: .zlib) as Data) ?? raw
var json = try XCTUnwrap(JSONSerialization.jsonObject(with: decompressed) as? [String: Any])
var claude = try XCTUnwrap(json["claude"] as? [String: [String: Any]])
for (k, var blob) in claude { blob.removeValue(forKey: "offset"); claude[k] = blob }
json["claude"] = claude
try JSONSerialization.data(withJSONObject: json).write(to: cacheFile, options: .atomic)

let reloaded = makeCache()
let entries = await reloaded.claudeEntries(modifiedSince: since)
XCTAssertEqual(entries.map(\.output), [111])
}

func testCodexCacheDropsForkedReplayBurst() async throws {
try writeFile("rollout-child.jsonl", lines: forkedCodexLines())

Expand Down
Loading