Skip to content

feat(store): bound the docs cache with retention, eviction, and page reclaim - #348

Merged
getappz merged 3 commits into
masterfrom
task/339
Jul 27, 2026
Merged

getappz merged 3 commits into
masterfrom
task/339

Conversation

@getappz

@getappz getappz commented Jul 27, 2026 •

Copy link
Copy Markdown
Owner

Closes #339.

The docs cache had no ceiling of any kind: soft-deleted rows (#334/#337) were never physically removed, no policy capped total size, and nothing ever handed freed pages back to the filesystem. Step 1 of this issue (blob release on delete, #343) stopped the leak on the write path; this bounds the cache itself.

What lands

agentflare-store::maintenance — Store::gc(project_id, GcOpts) -> GcReport, three phases:

  1. purge tombstones older than the retention window (7 days)
  2. evict the least recently updated live documents while the project is over budget (256 MB)
  3. reclaim the freed pages

agentflare-db-kit — auto_vacuum=INCREMENTAL, synchronous=NORMAL, and an explicit foreign_keys=ON.

flare-docs / MCP handler — DocsStore::gc, called on the fetch path, journaled to ~/.agentflare/audit/gc.jsonl.

Three things worth reading twice

Blob accounting differs by phase. A tombstone released its own blob reference when it was soft-deleted (doc_delete, upsert_batch_reconciled), so purging it must not release again — that would take a reference the row no longer holds and empty a blob a live document still points at. An evicted live document does still hold one. History snapshots hold references of their own either way, and are released as their rows go — the same leak class the soft-delete path closed earlier in this issue.

Size is live content plus referenced blobs, not the .db file. Blobs dominate a docs cache (measured 5.7 MB against a 1.8 MB database), so a cap watching only the file would never fire. The file also doesn't shrink at the moment rows are deleted, which makes it useless as the loop variable for eviction — pages come back at the reclaim step, after the decisions are already made.

The pragma ordering is load-bearing. auto_vacuum goes ahead of journal_mode: switching to WAL writes the header, after which the pragma is silently ignored. Worse, it reads back as INCREMENTAL on an empty file and only reverts once the first table lands — so the obvious test passes no matter which order you use. The test here opens, drops, and reopens for that reason. Databases created before this stay in NONE mode, so gc runs one full VACUUM to convert them (rebuilding the external-content FTS index behind it, per the note EXTERNAL_CONTENT_FTS_MIGRATION left for this work) and uses incremental_vacuum from then on.

Deliberate calls

  • updated_at order, not true LRU. A real last-accessed column costs a write on every read; for a cache that is refreshed rather than mutated the two orders barely differ.
  • No scheduler. Every route that grows the cache funnels through the fetch path, so that is where collection runs. Nothing owns a timer or a background task.
  • GC failures are swallowed. The caller asked for documentation; a cache that could not be trimmed still answers. Failing a fetch over housekeeping trades a working feature for a disk-space concern.
  • foreign_keys=ON changes nothing today — the bundled SQLite already defaults it on. It is set so the guarantee comes from the code rather than a libsqlite3-sys build flag, and pinned by a test asserting enforcement is live during migrations too (which is why a migration that removes parent rows has to clear their children first — the dedup migration already does).

Tests

Six new tests in maintenance, plus three in db-kit. Each maintenance test was verified to fail under a targeted mutation:

mutation tests killed
purge phase disabled expired_tombstones_are_purged_and_fresh_ones_kept, purging_a_document_releases_the_blobs_its_history_still_held
history blobs not unreffed purging_a_document_releases_the_blobs_its_history_still_held
eviction disabled eviction_drops_the_least_recently_updated_document_first, eviction_releases_the_evicted_documents_blob
evicted document's own blob not released eviction_releases_the_evicted_documents_blob

One honest gap, documented in the test itself: removing the post-VACUUM FTS rebuild kills nothing. The SQLite in use preserves rowids across VACUUM, so documents_stay_searchable_after_a_vacuuming_gc pins the outcome (index and base table still agree by rowid, search still answers) rather than the mechanism. The rebuild stays because SQLite documents renumbering as permitted for tables without an explicit INTEGER PRIMARY KEY — which store_documents, keyed on a TEXT id, is — and the failure mode if a future version exercises that permission is a search index that returns nothing, with no error anywhere.

cargo fmt --check, cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -A unsafe_code -A clippy::pedantic, and cargo test --workspace all pass locally.

Known hazard left in place

flare-docs.db and store.db share ~/.agentflare/blobs/ but keep separate store_blobs ref-count tables, so one store's unref could in principle delete a file the other still references. Near-zero probability, one-line fix (namespace the blobs directory by db stem) — out of scope here, worth its own issue.

Summary by CodeRabbit

  • New Features

    • Added configurable documentation-cache garbage collection based on retention periods and per-project size limits.
    • Added maintenance reporting (purged/evicted counts, blobs released, reclaimed bytes, and whether vacuuming occurred).
    • Added audit logging for cache cleanup activity.
    • Exposed cache GC through the docs store API.
    • Improved SQLite connection setup with incremental vacuuming defaults and foreign-key enforcement.
  • Bug Fixes

    • Enforced foreign-key constraints during migrations and at runtime.
    • Preserved blob/reference integrity when purging or evicting cached documents.
    • Kept search/FTS consistent after vacuuming GC.

…reclaim

The docs cache had no ceiling of any kind. Soft-deleted rows (#334/#337)
were never physically removed, no policy capped total size, and nothing
ever handed freed pages back to the filesystem -- a long-lived install
refreshing a rotating set of packages grows without bound (#339).

agentflare-store gains a maintenance module: Store::gc purges tombstones
past a 7-day retention, evicts the least recently updated live documents
while the project is over a 256 MB budget, then reclaims. Blob accounting
is the part worth reading twice -- a tombstone gave up its own blob
reference when it was soft-deleted, so purging must not release it again,
while an evicted live document still holds one. History snapshots hold
references of their own and are released as their rows go, the same leak
the soft-delete path closed earlier in this issue.

Size is measured as live content plus referenced blobs, not the .db file:
blobs dominate a docs cache (5.7 MB against a 1.8 MB database when
measured), and the file does not shrink at the moment rows are deleted,
which would make it useless as the loop variable for eviction.

db-kit now asks for auto_vacuum=INCREMENTAL and synchronous=NORMAL, and
sets foreign_keys=ON explicitly instead of inheriting it from the linked
SQLite build. The auto_vacuum pragma goes ahead of journal_mode and that
ordering is load-bearing: switching to WAL writes the header, after which
the setting is silently ignored -- and it reads back as INCREMENTAL until
the first table lands, so a test that checks too early passes either way.
Databases created before this stay in NONE mode, so gc runs one full
VACUUM to convert them, rebuilding the external-content FTS index behind
it, and uses incremental_vacuum from then on.

Collection runs on the flare-docs fetch path rather than a schedule --
every route that grows the cache funnels through it, so nothing has to
own a timer -- and non-empty runs are journaled to
~/.agentflare/audit/gc.jsonl beside the git shim's log.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds SQLite pragma and foreign-key enforcement, implements store cache garbage collection with blob cleanup and vacuuming, exposes it through DocsStore, and runs it after docs fetches with optional audit logging.

Changes

Docs cache maintenance

Layer / File(s) Summary
SQLite initialization and enforcement
crates/agentflare-db-kit/src/open.rs, crates/agentflare-db-kit/CHANGELOG.md
SQLite opening now applies pragmas in a defined order, enables foreign keys before migrations, and documents the updated behavior with tests.
Store garbage collection engine
crates/agentflare-store/src/lib.rs, crates/agentflare-store/src/maintenance.rs
Adds configurable retention and byte limits, tombstone purging, ordered eviction, blob unreferencing, vacuuming, FTS rebuilding, and unit tests.
Docs-store garbage-collection API
crates/flare-docs/src/lib.rs, crates/flare-docs/src/store.rs
Exports GcOpts and GcReport and adds project-scoped DocsStore::gc.
MCP cache cleanup integration
src/mcp_server.rs, src/mcp_server/flare_docs.rs
Shares the docs store handle, runs garbage collection after fetch/store operations, and appends non-empty reports to gc.jsonl when an audit path is available.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DocsMCP
  participant DocsStore
  participant Store
  participant SQLite
  participant AuditLog
  DocsMCP->>DocsStore: fetch and store documentation
  DocsMCP->>DocsStore: run cache garbage collection
  DocsStore->>Store: gc for fixed project
  Store->>SQLite: purge, evict, and reclaim pages
  Store-->>DocsStore: return GcReport
  DocsStore-->>DocsMCP: return fetch outcome
  DocsMCP->>AuditLog: append gc report when changes occurred
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: bounding the docs cache with retention, eviction, and page reclamation.
Description check ✅ Passed The description covers the change summary, rationale, tests, reviewer notes, and edge cases, though it doesn't use the exact template headings.
Linked Issues check ✅ Passed The PR implements the linked issue's purge, eviction, reclamation, and trigger-on-refresh objectives, including the follow-up project scoping fixes.
Out of Scope Changes check ✅ Passed No clearly unrelated code changes stand out; the extra API exports, MCP plumbing, and changelog updates support the cache-maintenance work.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/339

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/mcp_server/flare_docs.rs (1)

182-199: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a tracing line for the swallowed GC error.

Not failing the fetch is right, but the current shape leaves a permanently failing GC completely invisible — the audit journal only records successful, non-empty runs. A debug!/warn! on the error arm would make an unbounded cache diagnosable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mcp_server/flare_docs.rs` around lines 182 - 199, Add a tracing debug or
warning entry around the ignored result from flare_git_core::audit::log_event in
the GC reporting flow, recording failures while preserving the non-failing fetch
behavior. Keep successful audit logging and the report.purged + report.evicted
== 0 early return unchanged.
crates/agentflare-store/src/maintenance.rs (2)

124-127: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

One BEGIN IMMEDIATE per evicted document.

The purge phase batches all expired ids into a single transaction; eviction opens a separate immediate transaction (plus a separate blob_unref transaction set) per victim. Under a real over-budget run that is potentially thousands of exclusive-lock commits. release_own_blob is true for every victim, so the slice can go through in one call — you lose per-victim evicted accounting, but the count is just the slice length.

♻️ Batch the eviction phase
-        for id in self.eviction_candidates(project_id, opts.max_bytes)? {
-            report.blobs_released += self.hard_delete_docs(std::slice::from_ref(&id), true)?;
-            report.evicted += 1;
-        }
+        let victims = self.eviction_candidates(project_id, opts.max_bytes)?;
+        report.evicted = victims.len();
+        report.blobs_released += self.hard_delete_docs(&victims, true)?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-store/src/maintenance.rs` around lines 124 - 127, Batch all
eviction candidates into a single collection, then call hard_delete_docs once
with the full slice and release_own_blob enabled instead of deleting each
document inside the loop. Update report.evicted from the number of candidates
while preserving report.blobs_released from the batched deletion result.

205-212: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Hoist the history-hash statement out of the id loop.

tx.prepare runs once per id; prepare_cached (or preparing before the loop, as write_batch_items does in flare-docs) avoids re-parsing on every document — relevant once purge handles a week's worth of tombstones.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-store/src/maintenance.rs` around lines 205 - 212, Move
preparation of the history-hash query out of the per-id loop in the surrounding
maintenance operation, reusing a single prepared statement for each id lookup.
Prefer the transaction’s cached preparation mechanism or prepare it before the
loop, while preserving the existing query parameters, row collection, and
statement lifetime behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/agentflare-db-kit/src/open.rs`:
- Around line 34-40: Update the journal-mode configuration in the connection
setup to use conn.pragma_update_and_check for "journal_mode" instead of
conn.pragma_update, while preserving the existing auto_vacuum ordering and WAL
value.

In `@crates/agentflare-store/src/maintenance.rs`:
- Around line 79-93: Update MaintenanceStore::cache_bytes so the blob total is
scoped to the requested project by summing only blobs referenced by that
project’s live documents, using the existing document-to-blob relationship. Keep
the content calculation project-scoped and ensure the returned total reflects
only bytes attributable to project_id, preventing eviction from targeting more
than that project owns.

In `@src/mcp_server/flare_docs.rs`:
- Line 163: Update the fetch flow around gc_docs_cache so garbage collection
runs via spawn_blocking rather than inline on the async runtime. Await the
blocking task if completion is required, or detach it consistently with the
existing ignored-failure behavior, and apply the same change to the additional
GC call near the later cache path.

---

Nitpick comments:
In `@crates/agentflare-store/src/maintenance.rs`:
- Around line 124-127: Batch all eviction candidates into a single collection,
then call hard_delete_docs once with the full slice and release_own_blob enabled
instead of deleting each document inside the loop. Update report.evicted from
the number of candidates while preserving report.blobs_released from the batched
deletion result.
- Around line 205-212: Move preparation of the history-hash query out of the
per-id loop in the surrounding maintenance operation, reusing a single prepared
statement for each id lookup. Prefer the transaction’s cached preparation
mechanism or prepare it before the loop, while preserving the existing query
parameters, row collection, and statement lifetime behavior.

In `@src/mcp_server/flare_docs.rs`:
- Around line 182-199: Add a tracing debug or warning entry around the ignored
result from flare_git_core::audit::log_event in the GC reporting flow, recording
failures while preserving the non-failing fetch behavior. Keep successful audit
logging and the report.purged + report.evicted == 0 early return unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e20ad123-dcf3-4425-b995-b4dda555e818

📥 Commits

Reviewing files that changed from the base of the PR and between fc11f59 and d0d235b.

📒 Files selected for processing (7)
  • crates/agentflare-db-kit/CHANGELOG.md
  • crates/agentflare-db-kit/src/open.rs
  • crates/agentflare-store/src/lib.rs
  • crates/agentflare-store/src/maintenance.rs
  • crates/flare-docs/src/lib.rs
  • crates/flare-docs/src/store.rs
  • src/mcp_server/flare_docs.rs

Comment on lines +34 to +40
conn.pragma_update(None, "auto_vacuum", "INCREMENTAL")?;
conn.pragma_update(None, "journal_mode", "WAL")?;
// The durability level WAL is designed around: a crash can lose the last
// few transactions, a power loss still cannot corrupt the file. FULL
// fsyncs every commit, which for local agent stores buys nothing over
// WAL's own guarantees.
conn.pragma_update(None, "synchronous", "NORMAL")?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Any existing precedent in-repo for setting journal_mode?
rg -nP --type=rust 'journal_mode' -C2
# rusqlite version actually resolved
fd -H '^Cargo.lock$' --max-depth 2 --exec rg -n -A2 '^name = "rusqlite"$' {}

Repository: getappz/agentflare

Length of output: 156


🌐 Web query:

rusqlite pragma_update journal_mode WAL ExecuteReturnedResults

💡 Result:

In rusqlite, the ExecuteReturnedResults error occurs when a SQL statement returns rows (e.g., via a SELECT or a PRAGMA that returns a result set) while being executed by a method that expects no output, such as Connection::execute or execute_batch [1][2][3]. The PRAGMA journal_mode=WAL statement is a common source of this error because, in SQLite, this command returns the resulting journal mode [4]. If your rusqlite configuration includes the extra_check feature (which is often enabled or implied in certain environments or older versions), rusqlite validates that statements do not return unexpected rows and will panic or return the ExecuteReturnedResults error if they do [5][6]. To resolve this when setting the journal mode, you should use the pragma_update_and_check method instead of pragma_update [7][8]. This method is specifically designed to handle PRAGMAs that return data by providing a closure to process the result row [9][10]: conn.pragma_update_and_check(None, "journal_mode", &"WAL", |_| Ok()).unwrap; Alternatively, if you are not using extra_check and simply want to ensure the command executes without processing output, verify your use of Connection::execute_batch, though pragma_update_and_check remains the recommended, robust approach for handling pragmas that return values [7][11][10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== open.rs ==\n'
sed -n '1,120p' crates/agentflare-db-kit/src/open.rs

printf '\n== rusqlite pin in Cargo.lock ==\n'
rg -n -A4 -B2 '^name = "rusqlite"$|^version = ' Cargo.lock Cargo.toml crates/**/Cargo.lock

printf '\n== any local uses of pragma_update / pragma_update_and_check ==\n'
rg -n 'pragma_update(_and_check)?\(|journal_mode|synchronous|auto_vacuum' crates/ -g '*.rs'

Repository: getappz/agentflare

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== rusqlite version from Cargo.lock ==\n'
rg -n -A3 -B2 '^name = "rusqlite"$' Cargo.lock

printf '\n== rusqlite version from manifests ==\n'
rg -n 'rusqlite' Cargo.toml crates/*/Cargo.toml

printf '\n== local open.rs call sites ==\n'
rg -n 'pragma_update\(|pragma_update_and_check\(' crates/agentflare-db-kit/src/open.rs crates -g '*.rs' | head -n 40

Repository: getappz/agentflare

Length of output: 3096


🌐 Web query:

rusqlite 0.40.1 pragma_update journal_mode ExecuteReturnedResults pragma_update_and_check

💡 Result:

In rusqlite 0.40.1, the methods pragma_update and pragma_update_and_check are provided on the Connection and Transaction types to manage SQLite pragmas [1][2][3]. pragma_update The pragma_update method is used to set a new value for a specific pragma [3]. It executes the pragma command using execute_batch [3]. Because it is designed for simple updates, it does not retrieve results, and it will return an error if the underlying SQLite operation unexpectedly returns rows [3]. pragma_update_and_check The pragma_update_and_check method is intended for pragmas that return a value upon being updated (e.g., verifying that a journal_mode change was successful) [3]. It executes the pragma using query_row, allowing you to provide a closure (f) to process the returned row and verify the result [3]. ExecuteReturnedResults ExecuteReturnedResults is a variant of the rusqlite::Error enum [4]. It is returned when an operation called via execute (which expects no output) unexpectedly returns rows from the database [5][6]. This commonly occurs if you use execute on a SQL statement that produces a result set, or if a pragma operation that you expected to be silent actually returns data [5]. If you encounter this error, it serves as a suggestion to use a query-based method instead [5][6]. Example usage: // Using pragma_update for simple settings db.pragma_update(None, "user_version", 1)?; // Using pragma_update_and_check to verify a change db.pragma_update_and_check(None, "journal_mode", "OFF", |row| { let mode: String = row.get(0)?; if mode == "off" { Ok() } else { Err(rusqlite::Error::ExecuteReturnedResults) // Or a custom error } })?;

Citations:


Use pragma_update_and_check for journal_mode. PRAGMA journal_mode returns a row, so pragma_update can raise ExecuteReturnedResults here. Switch to pragma_update_and_check (or query_row) and keep the auto_vacuum ordering as-is.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-db-kit/src/open.rs` around lines 34 - 40, Update the
journal-mode configuration in the connection setup to use
conn.pragma_update_and_check for "journal_mode" instead of conn.pragma_update,
while preserving the existing auto_vacuum ordering and WAL value.

Comment thread crates/agentflare-store/src/maintenance.rs
Comment thread src/mcp_server/flare_docs.rs Outdated
…ictions

Two findings from review, both about eviction removing more than it should.

cache_bytes summed all of store_blobs while the content term was
project-scoped, so in a multi-project database another project's bytes
decided when this one evicted -- and since evicting this project's
documents never brought that total down, it emptied the project and
still finished over budget. Only blobs referenced by this project's live
documents count now.

Eviction also estimated each document's contribution up front and
deleted a whole batch against it. A blob two documents share is only
reclaimed when the second one goes, so the estimate under-counts what
the batch frees, and an estimate that can never reach its target walks
the candidate list to the end. Replaced with a loop that re-measures
after each eviction and stops the moment the project fits, bounded by
the candidate query returning None.

Cache maintenance also ran inline on the single-threaded MCP runtime,
which is what that function's own doc comment gives as the reason the
fetch uses spawn_blocking. A purge can remove thousands of rows, and the
first run against a pre-auto_vacuum database rewrites the whole file and
rebuilds the search index. It now goes to the blocking pool, detached --
the caller's answer does not depend on it.

Not changed: the journal_mode pragma flagged as needing
pragma_update_and_check. rusqlite 0.40.1's pragma_update runs the
statement through execute_batch, which discards returned rows, so
ExecuteReturnedResults cannot arise -- and the line predates this branch.
@getappz

getappz commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Both correctness findings fixed in 4f5593c; the pragma one is a false positive.

1. Global blob total in a project-scoped budget — fixed. cache_bytes now counts only blobs referenced by the project's own live documents. Test: another_projects_blobs_do_not_evict_this_projects_documents, verified to fail when the blob term is un-scoped.

         let blobs: i64 = conn.query_row(
-            "SELECT COALESCE(SUM(size), 0) FROM store_blobs",
-            [],
+            "SELECT COALESCE(SUM(b.size), 0) FROM store_blobs b
+             WHERE EXISTS (SELECT 1 FROM store_documents d
+                            WHERE d.blob_hash = b.hash
+                              AND d.project_id = ?1 AND d.deleted_at IS NULL)",
+            params![project_id],
             |row| row.get(0),
         )?;

Scoping alone was not enough, though. Two documents in the same project can share a blob, which is only reclaimed when the second one goes — so the up-front per-document estimate (ref_count = 1 or nothing) under-counts what the batch frees, and an estimate that can never reach its target still walks the candidate list to the end. Replaced the estimate-then-batch design with a loop that re-measures, bounded by the candidate query returning None:

-        for id in self.eviction_candidates(project_id, opts.max_bytes)? {
+        while self.cache_bytes(project_id)? > opts.max_bytes {
+            let Some(id) = self.next_eviction_candidate(project_id)? else {
+                break;
+            };
             report.blobs_released += self.hard_delete_docs(std::slice::from_ref(&id), true)?;
             report.evicted += 1;
         }

Test: eviction_re_measures_rather_than_estimating_a_shared_blob — two documents sharing a 2 KB blob plus a small newer one; one eviction is not enough, two are, the third must survive. Verified to fail when the total is measured once instead of per iteration (it evicts all three).

2. GC inline on the async runtime — fixed. Moved to the blocking pool and detached, for the reason that function's own doc comment already gives. flare_docs_store becomes an Arc so the handle can be moved without borrowing self.

     fn gc_docs_cache(&self) {
-        let Ok(Ok(report)) = self.with_flare_docs_store(|store| store.gc(GcOpts::default())) else {
-            return;
-        };
-        if report.purged + report.evicted == 0 {
+        if self.ensure_flare_docs_store().is_err() {
             return;
         }
-        if let Some(path) = flare_git_core::audit::default_path("gc.jsonl") {
+        let store = std::sync::Arc::clone(&self.flare_docs_store);
+        tokio::task::spawn_blocking(move || {
+            let Ok(guard) = store.lock() else { return };
+            let Some(store) = guard.as_ref() else { return };
+            let Ok(report) = store.gc(GcOpts::default()) else { return };
+            if report.purged + report.evicted == 0 {
+                return;
+            }
+            // ... journal to gc.jsonl
+        });
     }

3. pragma_update for journal_mode — not changed. ExecuteReturnedResults comes from Connection::execute, not from pragma_update. rusqlite 0.40.1's pragma_update ends in self.execute_batch(&sql) (pragma.rs), which discards returned rows — the web answer conflated the two methods. The line also predates this branch and WAL is demonstrably active on existing databases.

Full gate green after the fixes: cargo fmt --check, the exact CI clippy invocation, and cargo test --workspace.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mcp_server.rs`:
- Around line 106-108: Update flare_docs_store and its access paths, especially
gc_docs_cache and with_flare_docs_store, so MCP handlers do not synchronously
block on the store mutex while garbage collection runs. Use an async-awaitable
lock or separate GC ownership from request-path access, ensuring handlers yield
while waiting and the existing store operations remain correct.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 286e146b-da0d-4444-a56b-85eadd4594ca

📥 Commits

Reviewing files that changed from the base of the PR and between d0d235b and 4f5593c.

📒 Files selected for processing (3)
  • crates/agentflare-store/src/maintenance.rs
  • src/mcp_server.rs
  • src/mcp_server/flare_docs.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/mcp_server/flare_docs.rs

Comment thread src/mcp_server.rs Outdated
Detaching GC onto the blocking pool moved the work off the runtime but
not the contention: it holds the same std::sync::Mutex every docs handler
takes, so a request arriving mid-maintenance blocked the MCP runtime's
only thread for as long as a purge or a full VACUUM ran -- stalling every
other tool call, not just the docs ones.

The store now sits behind a tokio mutex. Handlers await it; the GC task,
already off the runtime, takes it with blocking_lock.
@getappz

getappz commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 2da7cde — you're right, detaching moved the work off the runtime but not the contention.

-    flare_docs_store: std::sync::Arc<std::sync::Mutex<Option<flare_docs::DocsStore>>>,
+    flare_docs_store: std::sync::Arc<tokio::sync::Mutex<Option<flare_docs::DocsStore>>>,

-    fn with_flare_docs_store<T>(
+    async fn with_flare_docs_store<T>(
         &self,
         f: impl FnOnce(&flare_docs::DocsStore) -> T,
     ) -> Result<T, ErrorData> {
-        self.ensure_flare_docs_store()?;
-        let guard = self
-            .flare_docs_store
-            .lock()
-            .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
+        self.ensure_flare_docs_store().await?;
+        let guard = self.flare_docs_store.lock().await;
         Ok(f(guard.as_ref().expect("ensure_flare_docs_store just initialized it")))
     }

The GC task, already off the runtime inside spawn_blocking, takes it the other way:

         tokio::task::spawn_blocking(move || {
-            let Ok(guard) = store.lock() else {
-                return;
-            };
+            // `blocking_lock`, not `lock().await`: this closure is already
+            // off the runtime, and a request waiting on the same mutex
+            // awaits rather than blocking behind it.
+            let guard = store.blocking_lock();

All 11 call sites now .await (they were already in async fns or #[tokio::test]s, so no signature churn beyond the two helpers). Poison handling drops out with it — tokio::sync::Mutex doesn't poison.

Gate green: cargo fmt --check, CI's clippy invocation, cargo test --workspace.

@getappz
getappz merged commit 745b404 into master Jul 27, 2026
15 checks passed
@getappz
getappz deleted the task/339 branch July 27, 2026 07:10
getappz added a commit that referenced this pull request Jul 27, 2026
…our's content (#351)

store.db and flare-docs.db share ~/.agentflare/ and so derived the same
blobs/ directory while keeping separate store_blobs ref-count tables.
Identical bytes in both stores collide by content address, and neither
side can see the other's references, so a reclaim in one silently
stranded the other's content. PR #348 made this routine by running GC on
every docs fetch.

Writes now go to <root>/blobs-<db stem>/, reads fall back to the legacy
shared <root>/blobs/, and deletes only ever unlink from the store's own
directory. Worst case leaks a legacy file nothing references; it can
never remove content another store still needs. No migration, so blobs
already on disk keep reading back.
getappz added a commit that referenced this pull request Oct 9, 2026
…(pr open wrapper, post-hoc stamp) and explicit takeover (#895)

* feat(pr-identity): agentflare pr open/owner/adopt, post-hoc stamp, doctor line (#348 phase 3)

Agentflare-Agent: claude-code
Agentflare-Branch: task/348-cross-instance-prs-phase-3-stamp-prs-age
Agentflare-Item: 348
Agentflare-Session: 62141f4c-1715-4319-89f6-57750848e4d0

* fix(doctor): list only PR numbers for other-instance PRs (CodeQL cleartext-logging alert)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Agentflare-Agent: claude-code
Agentflare-Branch: task/348-cross-instance-prs-phase-3-stamp-prs-age
Agentflare-Item: 348
Agentflare-Session: 04c55c4c-961f-4a8b-be6b-d5a951c8d91f

* fix(doctor): report only a count of other-instance PRs (CodeQL cleartext-logging)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Agentflare-Agent: claude-code
Agentflare-Branch: task/348-cross-instance-prs-phase-3-stamp-prs-age
Agentflare-Item: 348
Agentflare-Session: 04c55c4c-961f-4a8b-be6b-d5a951c8d91f

* fix(doctor): return a count from pr_owner so no client-derived text is printed (CodeQL)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Agentflare-Agent: claude-code
Agentflare-Branch: task/348-cross-instance-prs-phase-3-stamp-prs-age
Agentflare-Item: 348
Agentflare-Session: 04c55c4c-961f-4a8b-be6b-d5a951c8d91f

* fix(pr): pr adopt re-stamps the origin so ownership gates follow a takeover

resolve_owner honours the takeover comment but origin_allows/marks_item/find_by_item_marker read only the body stamp, so after adopt the previous owner still passed the gates and both instances could work the PR. adopt now rewrites the stamp to the adopter.

Agentflare-Agent: claude-code
Agentflare-Branch: task/348-cross-instance-prs-phase-3-stamp-prs-age
Agentflare-Item: 348
Agentflare-Session: 2e7fb763-e49d-4d67-b764-96b4a24c61c7
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.

flare-docs: no size cap, LRU eviction, or purge of soft-deleted rows for the docs cache

1 participant