Skip to content
This repository was archived by the owner on Mar 30, 2026. It is now read-only.

fix: reduce excessive file writes from account state updates#459

Closed
mrm007 wants to merge 1 commit intoNoeFabris:mainfrom
mrm007:fix/excessive-file-writes
Closed

fix: reduce excessive file writes from account state updates#459
mrm007 wants to merge 1 commit intoNoeFabris:mainfrom
mrm007:fix/excessive-file-writes

Conversation

@mrm007
Copy link
Copy Markdown

@mrm007 mrm007 commented Feb 17, 2026

Fixes #436

Problem

saveAccounts() writes antigravity-accounts.json far too frequently during normal operation. Each write acquires a proper-lockfile lock (mkdir/rmdir cycle), writes a temp file, and renames it — producing 4+ FS events per save. During active use with rate limit cycling, this fires dozens of times per minute, thrashing the config directory and overwhelming file watchers.

Changes

  1. Remove unconditional requestSaveToDisk() on every API request — the call at line 1702 fired on every request iteration even when no persistent state changed (only in-memory toast state was updated). Moved the save trigger to after markAccountUsed() on success, where lastUsed is actually persisted.

  2. Add snapshot deduplicationexecuteSave() now compares a JSON snapshot of the current storage state against the last saved snapshot, skipping the write entirely when nothing changed. Uses the same buildStorageState() method as saveToDisk() to avoid duplication.

  3. Increase debounce from 1s → 5s — rate limit reset times and quota cache are ephemeral state; a 5s window dramatically reduces writes during rate-limit storms while still being timely enough for persistence.

- Remove unconditional requestSaveToDisk() on every API request (line 1702)
- Add snapshot deduplication to skip no-op writes
- Increase debounce from 1s to 5s for rate-limit storm resilience
- Move save trigger to after markAccountUsed() where lastUsed is persisted

Fixes #436

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c6dae-36aa-7524-849b-d8c515ba0cd1
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 17, 2026

No actionable comments were generated in the recent review. 🎉

📜 Recent review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between f7e0c50 and 4153865.

📒 Files selected for processing (3)
  • src/plugin.ts
  • src/plugin/accounts.test.ts
  • src/plugin/accounts.ts
🧰 Additional context used
🧬 Code graph analysis (2)
src/plugin/accounts.test.ts (1)
src/plugin/storage.ts (1)
  • AccountStorageV4 (217-225)
src/plugin/accounts.ts (1)
src/plugin/storage.ts (2)
  • AccountStorageV4 (217-225)
  • saveAccounts (693-719)
🔇 Additional comments (4)
src/plugin/accounts.test.ts (2)

1090-1142: Debounce timing updates look consistent.
The 5.5s/6s advances match the 5s debounce window and should keep these tests deterministic.


1149-1174: Nice coverage for snapshot-based skip write.
This test directly validates the dedupe path when state is unchanged.

src/plugin.ts (1)

2331-2332: Persisting lastUsed after success is a good fit.
Scheduling the save right after markAccountUsed lines up with the new debounced persistence behavior.

src/plugin/accounts.ts (1)

315-316: Storage snapshot + deduped save flow looks solid.
Centralizing serialization and comparing snapshots should effectively suppress redundant writes, and the 5s debounce aligns with the PR goal.

Also applies to: 977-1044


Walkthrough

The changes implement debouncing and optimistic saving to reduce excessive disk writes during account state mutations. The requestSaveToDisk delay is increased from 1 second to 5 seconds, and a new snapshot comparison mechanism prevents writing when state is unchanged. Storage serialization is refactored into a dedicated method, and new public APIs are added for fingerprint management and quota cache operations. Test timing is updated to reflect the new 5-second window.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Key observations

Core optimization: The lastSavedSnapshot field tracks the previously serialized storage state. The executeSave method now compares the current state snapshot against this cached value before writing to disk, skipping writes when state is identical. This directly addresses the issue of redundant persists when only timestamp or transient fields change.

Timing change: The delay before triggering disk write increases from 1,000 ms to 5,500 ms, allowing more state mutations to coalesce into a single write operation within the debounce window.

Storage refactoring: The new buildStorageState() private method centralizes how the in-memory account state is serialized to the storage format, making the serialization logic more testable and maintainable.

New public APIs: Eight new public methods are added to AccountManager for fingerprint history management (regenerateAccountFingerprint, restoreAccountFingerprint, getAccountFingerprintHistory) and quota cache utilities (updateQuotaCache, isAccountOverSoftQuota, getAccountsForQuotaCheck, getOldestQuotaCacheAge, areAllAccountsOverSoftQuota, getMinWaitTimeForSoftQuota). These extend the public surface for external callers but do not remove existing APIs.

Test updates: Debounce window in tests increases from 1,500 ms to 5,500 ms. A new test validates that requestSaveToDisk is skipped when account state has not changed since the last save.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main objective: reducing excessive file writes from account state updates.
Description check ✅ Passed The description is related to the changeset, explaining the problem, the changes made, and how they address the issue of excessive file writes.
Linked Issues check ✅ Passed The PR successfully addresses issue #436 by implementing the three key changes: removing unconditional saves, adding snapshot deduplication, and increasing debounce from 1s to 5s, all aligned with the suggested fix.
Out of Scope Changes check ✅ Passed All changes are directly related to reducing file write frequency: save timing, deduplication logic, debounce timing, and quota-related APIs supporting the optimization goal. No out-of-scope changes detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


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 and usage tips.

@mrm007 mrm007 closed this by deleting the head repository Feb 17, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 17, 2026

Greptile Summary

This PR reduces excessive saveAccounts() writes by combining three optimizations: (1) moving requestSaveToDisk() to fire only after a successful API response (where lastUsed actually changes) instead of on every request iteration, (2) adding snapshot-based deduplication in executeSave() to skip writes when account state hasn't changed, and (3) increasing the debounce window from 1s to 5s to batch writes during rate-limit storms.

Key changes and observations:

  • The buildStorageState() extraction is a clean refactor — saveToDisk() and the snapshot comparison now share the same serialization logic.
  • The snapshot deduplication has a subtle ordering issue: lastSavedSnapshot stores the snapshot taken before await saveToDisk() is called. Since saveToDisk() calls buildStorageState() independently, any state mutation during the async lockfile/rename cycle would cause the stored snapshot to diverge from what's on disk, potentially leading to a skipped write on the next cycle.
  • The missing semicolon at plugin.ts:2331 is a minor style inconsistency introduced in the PR.
  • The 5s debounce is a reasonable trade-off but increases the window during which a crash would lose lastUsed and rateLimitResetTimes updates — acceptable given these are non-critical fields.

Confidence Score: 3/5

  • Safe to merge with caution — the snapshot deduplication logic has a subtle stale-snapshot issue that could cause missed writes under concurrent state mutations during the async save.
  • The overall direction is correct and the write-reduction goals are sound. The main concern is that lastSavedSnapshot stores the snapshot taken before the async saveToDisk() call, while saveToDisk() independently calls buildStorageState() again. If account state mutates during the async lockfile/rename cycle, the stored snapshot diverges from what was actually persisted, potentially skipping a future write that should have happened. This is not a data corruption risk (best-effort persistence is already the stated goal), but it undermines the correctness guarantee of the deduplication. The 5s debounce is also a meaningful behavior change that increases the crash-loss window for lastUsed and rate-limit state.
  • Pay close attention to src/plugin/accounts.ts — specifically the executeSave method (lines 1035–1054) and the snapshot/write ordering.

Important Files Changed

Filename Overview
src/plugin/accounts.ts Core logic changes: refactored saveToDisk into buildStorageState + saveToDisk, added snapshot deduplication in executeSave, and increased debounce from 1s to 5s. The snapshot deduplication has a subtle stale-snapshot bug where lastSavedSnapshot stores the pre-save state rather than post-save state, and the snapshot is taken before the actual write which calls buildStorageState() again.
src/plugin.ts Moved requestSaveToDisk() from before auth token handling to after markAccountUsed() on success path. Minor style issue: missing semicolon on requestSaveToDisk() call at line 2331. Logic is correct - the save is now triggered only when lastUsed actually changes.
src/plugin/accounts.test.ts Tests correctly updated to reflect 5s debounce (1500ms → 5500ms). New test for snapshot deduplication added, but it mocks saveToDisk which bypasses the double-buildStorageState call, so the stale snapshot scenario is not tested.

Sequence Diagram

sequenceDiagram
    participant Plugin as plugin.ts
    participant AM as AccountManager
    participant FS as FileSystem

    Note over Plugin, FS: Before this PR (excessive writes)
    Plugin->>AM: markToastShown()
    Plugin->>AM: requestSaveToDisk() ❌ (every request)
    AM->>FS: saveAccounts() after 1s debounce

    Note over Plugin, FS: After this PR (optimized writes)
    Plugin->>AM: markToastShown()
    Note over Plugin: No save triggered here

    Plugin->>AM: markAccountUsed(index)
    Plugin->>AM: requestSaveToDisk() ✅ (only on success)

    AM->>AM: executeSave() after 5s debounce
    AM->>AM: snapshot = JSON.stringify(buildStorageState())
    alt snapshot !== lastSavedSnapshot
        AM->>AM: saveToDisk() → buildStorageState() again
        AM->>FS: saveAccounts(state)
        AM->>AM: lastSavedSnapshot = snapshot
    else snapshot == lastSavedSnapshot
        AM-->>AM: skip write (deduplication)
    end

    Note over AM, FS: flushSaveToDisk() resolvers notified in finally block
Loading

Last reviewed commit: 4153865

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

3 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +1040 to +1043
const snapshot = JSON.stringify(this.buildStorageState());
if (snapshot !== this.lastSavedSnapshot) {
await this.saveToDisk();
this.lastSavedSnapshot = snapshot;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale snapshot stored as last-saved state

lastSavedSnapshot is set to the snapshot taken before saveToDisk() is awaited, but saveToDisk() internally calls buildStorageState() again. If any state mutation happens during the await saveAccounts(...) call (e.g., a rate-limit update arriving while the lockfile/rename cycle is in progress), the snapshot stored in lastSavedSnapshot will diverge from what was actually written to disk. The next executeSave call will then compare against a snapshot that doesn't match the file on disk, potentially skipping a write that should have happened.

A safer approach is to capture the snapshot once, pass it to saveToDisk, and only update lastSavedSnapshot after a confirmed successful write of that exact snapshot:

const snapshot = JSON.stringify(this.buildStorageState());
if (snapshot !== this.lastSavedSnapshot) {
  await saveAccounts(JSON.parse(snapshot) as AccountStorageV4);
  this.lastSavedSnapshot = snapshot;
}

This ensures the snapshot stored is exactly what was written, not a concurrent re-read.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugin/accounts.ts
Line: 1040:1043

Comment:
**Stale snapshot stored as last-saved state**

`lastSavedSnapshot` is set to the snapshot taken *before* `saveToDisk()` is awaited, but `saveToDisk()` internally calls `buildStorageState()` again. If any state mutation happens during the `await saveAccounts(...)` call (e.g., a rate-limit update arriving while the lockfile/rename cycle is in progress), the snapshot stored in `lastSavedSnapshot` will diverge from what was actually written to disk. The next `executeSave` call will then compare against a snapshot that doesn't match the file on disk, potentially skipping a write that should have happened.

A safer approach is to capture the snapshot once, pass it to `saveToDisk`, and only update `lastSavedSnapshot` after a confirmed successful write of that exact snapshot:

```
const snapshot = JSON.stringify(this.buildStorageState());
if (snapshot !== this.lastSavedSnapshot) {
  await saveAccounts(JSON.parse(snapshot) as AccountStorageV4);
  this.lastSavedSnapshot = snapshot;
}
```

This ensures the snapshot stored is exactly what was written, not a concurrent re-read.

How can I resolve this? If you propose a fix, please make it concise.

getHealthTracker().recordSuccess(account.index);
accountManager.markAccountUsed(account.index);

accountManager.requestSaveToDisk()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing semicolon

The call to requestSaveToDisk() is missing a trailing semicolon, inconsistent with the rest of the codebase style.

Suggested change
accountManager.requestSaveToDisk()
accountManager.requestSaveToDisk();

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugin.ts
Line: 2331:2331

Comment:
**Missing semicolon**

The call to `requestSaveToDisk()` is missing a trailing semicolon, inconsistent with the rest of the codebase style.

```suggestion
                  accountManager.requestSaveToDisk();
```

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +1149 to +1174
it("skips write when account state has not changed", async () => {
vi.useFakeTimers();

const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
};

const manager = new AccountManager(undefined, stored);
const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue();

// First save — should write
manager.requestSaveToDisk();
await vi.advanceTimersByTimeAsync(5500);
expect(saveSpy).toHaveBeenCalledTimes(1);

// Second save with no state change — should skip
manager.requestSaveToDisk();
await vi.advanceTimersByTimeAsync(5500);
expect(saveSpy).toHaveBeenCalledTimes(1);

saveSpy.mockRestore();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deduplication test mocks saveToDisk, bypassing the real snapshot-write path

The new test mocks saveToDisk via vi.spyOn(...).mockResolvedValue(). This means executeSave takes a snapshot, sees a diff from null, calls the mock (which does nothing), then stores the snapshot as lastSavedSnapshot. The second call correctly sees equality and skips.

However, this doesn't test the case where the real saveToDisk (which calls buildStorageState() again independently) could write a state that differs from the comparison snapshot. Consider adding a test that exercises state mutation during the debounce window to confirm the snapshot reflects what's actually on disk.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugin/accounts.test.ts
Line: 1149:1174

Comment:
**Deduplication test mocks saveToDisk, bypassing the real snapshot-write path**

The new test mocks `saveToDisk` via `vi.spyOn(...).mockResolvedValue()`. This means `executeSave` takes a snapshot, sees a diff from `null`, calls the mock (which does nothing), then stores the snapshot as `lastSavedSnapshot`. The second call correctly sees equality and skips.

However, this doesn't test the case where the *real* `saveToDisk` (which calls `buildStorageState()` again independently) could write a state that differs from the comparison snapshot. Consider adding a test that exercises state mutation *during* the debounce window to confirm the snapshot reflects what's actually on disk.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Excessive file writes from account state updates — lock file thrashing

1 participant