Skip to content

fix(macos): bound usage history growth + debounced persistence#492

Merged
willwashburn merged 4 commits into
mainfrom
claude/zealous-maxwell-2aienr-history
Jul 3, 2026
Merged

fix(macos): bound usage history growth + debounced persistence#492
willwashburn merged 4 commits into
mainfrom
claude/zealous-maxwell-2aienr-history

Conversation

@willwashburn

@willwashburn willwashburn commented Jul 3, 2026

Copy link
Copy Markdown
Member

Stacked on #491.

UsageHistoryStore (apps/macos/Sources/Burn/UsageHistory.swift) keeps every usage sample in an in-memory dict mirrored to history.json, recording one sample per metric roughly every 60s. Three unbounded-growth / hot-path problems contributed to the reported BurnOSX memory growth and per-minute slowness.

Bugs

1. Nil-reset series were never pruned. key(provider:metric:) uses "none" as the trailing segment when metric.resetsAt == nil. pruneStaleWindows only handled numeric reset timestamps (Double(last)), so Double("none") failed the guard and those series accumulated samples forever.

2. Within-window series were unbounded. A weekly window sampled every ~60s is roughly 7 * 24 * 60 ≈ 10,080 samples with nothing thinning them — one series per window, kept for the whole window.

3. Persistence ran on the hot path. Every record() re-encoded the entire cache to JSON and wrote it to disk synchronously inside queue.sync, and record is called from the main actor during refresh. A full-file rewrite per minute whose cost grows with total history size — a main-thread hitch that gets worse over time.

Fixes

1. Prune nil-reset series by staleness. In pruneStaleWindows, …|none keys are dropped once their newest sample is older than 24h relative to reference. Timestamped keys keep the existing "reset > 1h ago" rule.

2. Cap samples per series. New internal constant maxSamplesPerSeries = 2_000. On append past the cap the series is decimated: the first sample (window origin) is always kept, the newest half is kept at full resolution, and the older half is thinned by dropping every second sample. Deterministic, and structured so repeated appends stay bounded without discarding recent, high-value data.

3. Move persistence off the hot path. Cache mutation stays synchronous (the queue.sync in record still returns the updated series), but the disk write is now a debounced, coalesced DispatchWorkItem scheduled ~2s out on the private queue — a burst of records produces one write. Writes are now atomic (.atomic) so a crash mid-write can't corrupt history.json. New internal flushForTesting() performs a pending write synchronously for deterministic tests. (Silent decode-failure fallback on init is unchanged — acceptable behavior.)

Burndown consumption (Burndown.swift) is unaffected: it reads samples by date/percentage, both preserved.

Tests

New apps/macos/Tests/BurnTests/UsageHistoryStoreTests.swift, each using init(fileURL:) on a unique temp file with injected dates (no sleeps):

  • nil-reset pruning (stale dropped after 25h, recent survives)
  • timestamped-window pruning (reset >1h ago dropped, 30m ago survives)
  • cap enforcement + decimation (count ≤ cap, first sample retained, newest date/percentage intact, dates strictly ascending)
  • duplicate collapse (<1s apart → one sample holding the newer value)
  • debounced persistence: file not written until flushForTesting(), then decode matches in-memory series, and a new store on the same file loads the roundtripped samples
  • loads an on-disk dict written in the current shape (guards against schema breakage)

No Swift toolchain in the authoring environment; the macos-app-tests workflow verifies on macos-14.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KJPzcAQdxufCWMnap8vTTL


Generated by Claude Code

Review in cubic

…tests

UsageHistoryStore held every usage sample in an in-memory dict mirrored
to history.json, with three unbounded-growth / hot-path problems:

- Nil-reset series (keyed "...|none") were never pruned: pruneStaleWindows
  only understood numeric reset timestamps, so Double("none") failed and the
  series accumulated forever.
- Within-window series were unbounded: a weekly window sampled every ~60s is
  ~10k samples with nothing to thin it.
- Every record() re-encoded the entire cache to JSON and wrote it to disk
  synchronously inside queue.sync, on the main-actor refresh path — a
  per-minute hitch that grows with history size.

Fixes:
- Prune nil-reset series once their newest sample is >24h old; keep the
  existing reset->1h rule for timestamped keys.
- Cap each series at maxSamplesPerSeries (2000): on overflow, keep the first
  sample and the newest half at full resolution, decimating the older half.
- Debounce persistence: cache mutation stays synchronous, but the disk write
  is a coalesced DispatchWorkItem ~2s out, written atomically. flushForTesting()
  performs a pending write synchronously for deterministic tests.

Adds UsageHistoryStoreTests covering nil-reset pruning, timestamped-window
pruning, cap/decimation, duplicate collapse, debounced persistence + roundtrip,
and on-disk dict load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJPzcAQdxufCWMnap8vTTL
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@willwashburn, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 52cffb14-351f-400a-8329-271503151bee

📥 Commits

Reviewing files that changed from the base of the PR and between f797cdc and f36e8ef.

📒 Files selected for processing (3)
  • apps/macos/Sources/Burn/BurnLedger.swift
  • apps/macos/Sources/Burn/UsageHistory.swift
  • apps/macos/Tests/BurnTests/UsageHistoryStoreTests.swift
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/zealous-maxwell-2aienr-history

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces memory-growth limits and debounced disk persistence to UsageHistoryStore, along with a comprehensive test suite. It also simplifies test assertions in BurnLedgerParsingTests and removes the watchDesired reentrancy guard from BurnLedger. The review feedback highlights two critical issues: first, removing the watchDesired flag in BurnLedger introduces actor reentrancy bugs that can leak background processes or ignore stop requests; second, performing synchronous disk writes on the shared queue can block the main thread during synchronous record() calls. The reviewer suggests restoring the reentrancy guards and offloading disk writes to a dedicated background queue.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 235 to 245
private var watchProcess: Process?
/// True between start and stop requests. Actor methods are reentrant across
/// awaits, so a stop can interleave while start is suspended resolving the
/// binary; start rechecks this before spawning so the stop wins.
private var watchDesired = false

/// Starts a background `burn ingest --watch` if one isn't already running.
/// Only runs with the bundled native helper (a login-shell child can't be
/// cleanly managed); the live chart still polls either way.
func startIngestWatch() async {
guard watchProcess == nil else { return }
watchDesired = true
guard let url = await runner.bundledBinaryURL() else { return }
guard watchDesired, watchProcess == nil else { return }
let process = Process()
process.executableURL = url
process.arguments = ["ingest", "--watch", "--quiet"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Actor Reentrancy Bug

Removing the watchDesired flag and the post-await guard checks introduces a serious actor reentrancy bug.

Because Swift actor methods are reentrant, the actor can suspend at await runner.bundledBinaryURL(). During this suspension, other tasks can execute on the actor. This leads to two major issues:

  1. Duplicate Processes / Leaks: If startIngestWatch() is called multiple times concurrently, they will all pass the initial watchProcess == nil check, await the URL resolution, and then spawn multiple duplicate background processes. Only the last one will be stored in watchProcess, leaking the others to run indefinitely.
  2. Ignored Stop Requests: If stopIngestWatch() is called while startIngestWatch() is suspended, the stop request will be ignored because watchProcess is still nil at that moment. Once the suspension resumes, the process will be spawned anyway and left running.

We should restore the watchDesired state variable and the post-await guard checks to ensure safety.

    private var watchProcess: Process?
    /// True between start and stop requests. Actor methods are reentrant across
    /// awaits, so a stop can interleave while start is suspended resolving the
    /// binary; start rechecks this before spawning so the stop wins.
    private var watchDesired = false

    /// Starts a background `burn ingest --watch` if one isn't already running.
    /// Only runs with the bundled native helper (a login-shell child can't be
    /// cleanly managed); the live chart still polls either way.
    func startIngestWatch() async {
        guard watchProcess == nil else { return }
        watchDesired = true
        guard let url = await runner.bundledBinaryURL() else { return }
        guard watchDesired, watchProcess == nil else { return }
        let process = Process()
        process.executableURL = url
        process.arguments = ["ingest", "--watch", "--quiet"]

Comment on lines +256 to 260
/// Terminates the background watch process, if running.
func stopIngestWatch() {
watchDesired = false
watchProcess?.terminate()
watchProcess = nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Actor Reentrancy Bug (Stop Ingest)

To fully restore the reentrancy guard, we must also set watchDesired = false in stopIngestWatch() so that any pending start request is cancelled.

    /// Terminates the background watch process, if running, and cancels any
    /// start that is still resolving the binary.
    func stopIngestWatch() {
        watchDesired = false
        watchProcess?.terminate()
        watchProcess = nil
    }

Comment on lines 155 to 158
private func persist() {
guard let data = try? JSONEncoder().encode(cache) else { return }
try? data.write(to: fileURL)
try? data.write(to: fileURL, options: .atomic)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Main-Thread Blocking during Disk I/O

Although cache mutations are debounced and scheduled asynchronously on queue, the actual disk write (data.write(to:options:)) is a synchronous I/O operation performed on the same serial queue.

Because record() is called synchronously from the main actor using queue.sync, any call to record() while persist() is writing to disk will block the main thread until the disk write completes. This defeats the purpose of moving persistence off the hot path and can still cause UI hitches.

To completely eliminate main-thread blocking, we should perform the synchronous disk write on a dedicated background serial queue. We can also add a synchronously parameter to persist() so that flushForTesting() remains deterministic and synchronous.

    private static let writeQueue = DispatchQueue(label: "com.agentworkforce.burn.history.write", qos: .background)

    private func persist(synchronously: Bool = false) {
        guard let data = try? JSONEncoder().encode(cache) else { return }
        let url = fileURL
        let writeBlock = {
            try? data.write(to: url, options: .atomic)
        }
        if synchronously {
            writeBlock()
        } else {
            Self.writeQueue.async(execute: writeBlock)
        }
    }

Comment on lines +83 to +89
func flushForTesting() {
queue.sync {
pendingWrite?.cancel()
pendingWrite = nil
persist()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Synchronous Flush for Testing

Update flushForTesting() to call persist(synchronously: true) so that tests remain deterministic and wait for the disk write to complete.

Suggested change
func flushForTesting() {
queue.sync {
pendingWrite?.cancel()
pendingWrite = nil
persist()
}
}
func flushForTesting() {
queue.sync {
pendingWrite?.cancel()
pendingWrite = nil
persist(synchronously: true)
}
}

Comment on lines +142 to +151
private func schedulePersist() {
pendingWrite?.cancel()
let item = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingWrite = nil
self.persist()
}
pendingWrite = item
queue.asyncAfter(deadline: .now() + persistDebounce, execute: item)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Asynchronous Persist Scheduling

Update schedulePersist() to call persist(synchronously: false) to offload the disk write to the background queue.

Suggested change
private func schedulePersist() {
pendingWrite?.cancel()
let item = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingWrite = nil
self.persist()
}
pendingWrite = item
queue.asyncAfter(deadline: .now() + persistDebounce, execute: item)
}
private func schedulePersist() {
pendingWrite?.cancel()
let item = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingWrite = nil
self.persist(synchronously: false)
}
pendingWrite = item
queue.asyncAfter(deadline: .now() + persistDebounce, execute: item)
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 743f3d2027

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

watchDesired = true
guard let url = await runner.bundledBinaryURL() else { return }
guard watchDesired, watchProcess == nil else { return }
let process = Process()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck stop requests after resolving the helper

When the live view is opened and then closed while runner.bundledBinaryURL() is still suspended, stopIngestWatch() can run with watchProcess == nil and return; this code then resumes and spawns burn ingest --watch anyway. That leaves the watcher running after the UI has stopped until some later stop call happens. Keep a desired flag or recheck after the await before creating the process.

Useful? React with 👍 / 👎.


// Keep each series bounded so long-lived windows can't grow forever.
if samples.count > Self.maxSamplesPerSeries {
samples = decimated(samples)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the cap for already-oversized histories

For users who already have a bloated history.json, a single call to decimated only reduces the series to about 75% of its previous size because it keeps the whole newer half. A 10,080-sample weekly series becomes roughly 7,561 samples and is written back/returned still far above maxSamplesPerSeries, so the memory and full-file persistence cost remain high for several refreshes. Loop or sample directly to the target cap before assigning back to the cache.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/macos/Sources/Burn/BurnLedger.swift
Comment thread apps/macos/Sources/Burn/UsageHistory.swift Outdated
Comment thread apps/macos/Sources/Burn/UsageHistory.swift
Comment thread apps/macos/Sources/Burn/UsageHistory.swift Outdated
Base automatically changed from claude/zealous-maxwell-2aienr to main July 3, 2026 15:48
claude added 3 commits July 3, 2026 15:51
- Move the actual disk write off the store's serial queue onto a dedicated
  utility-QoS writeQueue: record() enters the store queue synchronously from
  the main actor, so an in-progress debounced write there could block the
  next refresh. Encoding stays on the store queue for thread safety;
  persist(synchronously:) lets flushForTesting() guarantee the file is on
  disk before returning (the serial writeQueue makes the sync flush wait for
  and supersede any in-flight async write).
- Enforce maxSamplesPerSeries with a loop: one decimation pass only trims to
  ~75%, so an oversized legacy history (e.g. 10k samples) stayed over the
  cap. Add a test seeding 10,000 samples on disk and asserting one record()
  bounds the series while keeping the first sample and the newest data.
- Nil-reset pruning now uses the series' max date instead of .last, so it
  stays correct if a series is ever not strictly chronological.
- Align BurnLedgerParsingTests with main: hoist awaits out of XCTUnwrap
  autoclosures (Swift rejects async calls there) and use the withLock helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJPzcAQdxufCWMnap8vTTL
@willwashburn willwashburn merged commit b21433d into main Jul 3, 2026
5 checks passed
@willwashburn willwashburn deleted the claude/zealous-maxwell-2aienr-history branch July 3, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants