Skip to content

fix(store): release blob refs when documents are deleted - #343

Merged
getappz merged 4 commits into
masterfrom
task/374
Jul 26, 2026
Merged

getappz merged 4 commits into
masterfrom
task/374

Conversation

@getappz

@getappz getappz commented Jul 26, 2026 •

Copy link
Copy Markdown
Owner

Step 1 of #339. Fixes the leak that issue turned out to be mostly about; the size cap, LRU and VACUUM work is deliberately not here.

What was wrong

Store::blob_unref is a working ref-counted GC — at zero refs it deletes the chunk rows and the on-disk file. Nothing called it on the document delete paths:

So every deleted blob-backed document orphaned its file under <root>/blobs permanently. That is where the bytes actually live for file-backed stores — store_blob_chunks is only used by in-memory ones — which makes it the dominant growth path behind #339, ahead of the database file itself. Measured on a live install: flare-docs.db 1.8 MB against ~/.agentflare/blobs at 5.7 MB across 365 files.

What changed

Both delete paths now release the reference after the row is soft-deleted, matching the ordering the asset MCP tool's delete path already documented: the row is the source of truth, so a failure leaks a file rather than stranding a live document with reclaimed content. Both do it outside the connection guard, since blob_unref takes the same non-reentrant mutex and opens its own transaction.

Re-deleting an already soft-deleted row no longer unrefs a second time — only the delete that actually transitions live to deleted owns a reference.

The second commit removes the explicit blob_unref in src/mcp_server/asset.rs. With doc_delete owning the reference, that path was dropping two refs per delete. This was caught by the existing asset_shared_storage_delete_safety test, not by inspection: deleting one of two assets sharing identical bytes reclaimed the file out from under the survivor.

Tests

Three new tests, all file-backed on purpose — an in-memory store keeps blob bytes in store_blob_chunks, so an in-memory version would pass while the real on-disk path kept leaking:

  • deleting the only referrer reclaims the disk file
  • a blob shared by two documents survives the first delete, survives a double-delete of the same document, and is reclaimed only when the last referrer goes
  • reconciling a stale blob-backed item away reclaims its file

Each was confirmed to fail before the fix. The flare-docs one passed on its first run, so the unref loop was temporarily stubbed out to verify the test actually fails without it, then restored.

cargo test --workspace: 824 passed, 0 failed. cargo clippy -p agentflare-store -p flare-docs --all-targets --all-features -- -D warnings: clean.

Known ceiling, not addressed here

A caller that resurrects a soft-deleted row via doc_upsert_with_opts without supplying a fresh blob_hash would point at reclaimed bytes. Every blob-backed caller today (assets, artifacts, flare-docs rustdoc) always passes one on upsert, so this is unreachable now; there is a comment at the unref site saying so.

Unrelated pre-existing failure

cargo clippy --workspace --all-targets --all-features -- -D warnings fails on 19 unsafe_code and 2 items_after_statements errors in agent-registry, src/paths.rs, src/agents.rs and others — all files this branch does not touch, so master fails identically under local stable 1.95.0. The repo pins no toolchain and CI resolves stable through a cached action, so CI is probably on an older stable that lacks these lints.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed document and asset deletion to reliably reclaim associated stored blob files when they’re no longer referenced.
    • Avoided double-decrement behavior on repeated deletes, ensuring shared files remain until the final referrer is removed.
    • Corrected reconciliation so stale documents’ unused stored files are cleaned up properly.
    • Ensured blob replacement behaves correctly with and without history tracking.
  • Tests
    • Added coverage for on-disk blob lifecycle (delete, shared ownership, and reconciliation) and blob replacement reclamation.

getappz added 2 commits July 26, 2026 15:27
doc_delete and flare-docs' stale-item reconciliation soft-deleted rows
without ever calling blob_unref, so every deleted blob-backed document
orphaned its file under <root>/blobs permanently. For file-backed stores
that is where the bytes actually live -- store_blob_chunks is only used
by in-memory stores -- which made it the dominant growth path behind
issue #339, ahead of the database file itself.

Both sites now release the ref after the row is soft-deleted, matching
the ordering the asset MCP tool's delete path already documents, and
outside the connection guard since blob_unref takes the same
non-reentrant mutex. A re-delete of an already soft-deleted row no
longer unrefs a second time.

Tests are file-backed on purpose: an in-memory store keeps blob bytes in
store_blob_chunks, so an in-memory test would pass while the real
on-disk path kept leaking.
doc_delete releases the document's blob reference itself, so the asset
tool's explicit unref dropped two references for a single delete. The
existing asset_shared_storage_delete_safety test caught it: deleting one
of two assets sharing identical bytes reclaimed the file out from under
the surviving asset.
@coderabbitai

coderabbitai Bot commented Jul 26, 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

Blob lifecycle handling now releases superseded, deleted, and reconciled document blobs after durable database changes. History snapshots and shared references preserve needed files, while MCP asset deletion delegates cleanup to doc_delete. File-backed tests cover reclamation behavior.

Changes

Blob reference lifecycle cleanup

Layer / File(s) Summary
Blob replacement and history-aware release
crates/agentflare-store/src/documents.rs, crates/agentflare-store/src/blobs.rs
Upserts release superseded blobs after commit when history does not retain them; tests cover replacement and history snapshots.
Document deletion reference handling
crates/agentflare-store/src/documents.rs, crates/agentflare-store/src/blobs.rs, src/mcp_server/asset.rs
doc_delete performs one-time post-soft-delete unrefs, shared references remain alive, and MCP asset deletion relies on centralized cleanup.
Reconciliation blob reclamation
crates/flare-docs/src/store.rs
Reconciliation commits stale-document changes, then unreferences collected blob hashes; an integration test verifies file removal.

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

Sequence Diagram(s)

sequenceDiagram
  participant MCPAsset
  participant Store_doc_delete
  participant SQLite
  participant BlobStore
  MCPAsset->>Store_doc_delete: validate and delete asset document
  Store_doc_delete->>SQLite: soft-delete document
  Store_doc_delete->>BlobStore: unref blob once
  BlobStore-->>MCPAsset: reclaim file if final reference
Loading
sequenceDiagram
  participant DocsStore
  participant SQLite
  participant BlobStore
  DocsStore->>SQLite: find and soft-delete stale documents
  DocsStore->>SQLite: commit reconciliation changes
  DocsStore->>BlobStore: unref stale blob hashes
  BlobStore-->>DocsStore: remove unreferenced files
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: releasing blob refs when documents are deleted.
Description check ✅ Passed The description is detailed and covers summary, tests, risks, and edge cases, though it doesn't follow the repo's exact template.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/374

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: 2

🤖 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-store/src/documents.rs`:
- Around line 415-434: Update doc_upsert_with_opts to detect when an existing
row’s blob_hash is replaced and unref the previous blob after the row update
succeeds. Preserve reference counts when the hash is unchanged, and reuse the
existing blob_unref mechanism used by the delete path.

In `@crates/flare-docs/src/store.rs`:
- Around line 188-196: Update the stale-blob cleanup loop in the reconciliation
flow around stale_blobs and blob_unref so one unref failure does not abort
processing. Attempt blob_unref for every stale hash, retain the first error (or
equivalent aggregate error), and propagate it only after all hashes have been
processed.
🪄 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: 9ec019c4-cdb0-42bb-ae1d-ebd16322707d

📥 Commits

Reviewing files that changed from the base of the PR and between d8f5165 and 9373dff.

📒 Files selected for processing (4)
  • crates/agentflare-store/src/blobs.rs
  • crates/agentflare-store/src/documents.rs
  • crates/flare-docs/src/store.rs
  • src/mcp_server/asset.rs

Comment thread crates/agentflare-store/src/documents.rs
Comment thread crates/flare-docs/src/store.rs
getappz added 2 commits July 26, 2026 16:05
…rror

Addresses both CodeRabbit findings on #343.

A blob replaced by a new one on upsert was never released, the third
variant of the same leak this PR is about. It is only orphaned when
nothing else kept it, though: a history-tracking upsert has just
snapshotted the old hash into store_doc_history, which is what
doc_history and diff read the previous version's bytes back through.
Reclaiming unconditionally emptied artifact history -- two
agentflare-artifacts tests caught it. The release is now gated on
whether a snapshot row was written, bound once so the two decisions
cannot drift.

The flare-docs stale-blob loop used ? on the first unref, stranding the
rest of the batch as orphans. It now attempts every hash and reports the
first failure afterwards; the rows are already committed, so there is
nothing to roll back and nothing gained by stopping early.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/agentflare-store/src/documents.rs (2)

449-455: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not resurrect a released blob reference without reacquiring it.

After this delete path releases the last reference, doc_upsert_with_opts can reactivate the soft-deleted row with blob_hash: None. It preserves the old hash while clearing deleted_at, leaving a live document pointing to reclaimed bytes. Track prior deletion state in the upsert query and reject this restore or clear the stale hash unless the caller supplies a newly acquired blob reference. Add a file-backed delete-then-upsert regression test.

🤖 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/documents.rs` around lines 449 - 455, Update
doc_upsert_with_opts to track whether the existing row was soft-deleted; when
restoring a deleted row with blob_hash: None, do not preserve its released
hash—reject the restore or clear the stale hash, while retaining normal updates
with a newly acquired blob reference. Add a file-backed regression test covering
delete followed by upsert without a blob hash.

413-441: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Make the live-to-deleted transition atomic before releasing the blob.

Line 429 unconditionally updates the row after a non-transactional already_deleted read. Two Store instances on the same file can both observe false and both call blob_unref; the second release can reclaim a blob still referenced by another live document. Use a BEGIN IMMEDIATE transaction for the read/update/FTS delete, and release only when a conditional WHERE deleted_at IS NULL update affects one row.

Suggested direction
-            let conn = self.conn();
+            let conn = self.conn();
+            let tx = rusqlite::Transaction::new_unchecked(
+                &conn,
+                rusqlite::TransactionBehavior::Immediate,
+            )?;
...
-            conn.execute(
-                "UPDATE store_documents SET deleted_at = ?1 WHERE id = ?2",
+            let transitioned = tx.execute(
+                "UPDATE store_documents
+                 SET deleted_at = ?1
+                 WHERE id = ?2 AND deleted_at IS NULL",
                 params![now, id],
             )?;
...
-            (true, if already_deleted { None } else { blob_hash })
+            tx.commit()?;
+            (true, if transitioned == 1 { blob_hash } else { None })
🤖 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/documents.rs` around lines 413 - 441, Make the
delete flow atomic by wrapping the document read, conditional soft-delete
update, and FTS-row deletion in a BEGIN IMMEDIATE transaction. In the relevant
delete method, update store_documents with WHERE id = ? and deleted_at IS NULL,
use the affected-row count to determine whether this call performed the
live-to-deleted transition, and only return the blob hash for that transition
before committing; preserve the no-document result and avoid blob_unref for
already-deleted rows.
🤖 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.

Outside diff comments:
In `@crates/agentflare-store/src/documents.rs`:
- Around line 449-455: Update doc_upsert_with_opts to track whether the existing
row was soft-deleted; when restoring a deleted row with blob_hash: None, do not
preserve its released hash—reject the restore or clear the stale hash, while
retaining normal updates with a newly acquired blob reference. Add a file-backed
regression test covering delete followed by upsert without a blob hash.
- Around line 413-441: Make the delete flow atomic by wrapping the document
read, conditional soft-delete update, and FTS-row deletion in a BEGIN IMMEDIATE
transaction. In the relevant delete method, update store_documents with WHERE id
= ? and deleted_at IS NULL, use the affected-row count to determine whether this
call performed the live-to-deleted transition, and only return the blob hash for
that transition before committing; preserve the no-document result and avoid
blob_unref for already-deleted rows.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 98b3984e-1458-4d41-9730-5cb1a52a39ac

📥 Commits

Reviewing files that changed from the base of the PR and between 9373dff and c0f229c.

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

@getappz
getappz merged commit b21047a into master Jul 26, 2026
16 checks passed
@getappz
getappz deleted the task/374 branch July 26, 2026 10:49
getappz added a commit that referenced this pull request Jul 26, 2026
Addresses CodeRabbit's review of the dedup migration.

Discarding a duplicate document is not a single DELETE, because two things
outlive the row:

* `store_doc_history.doc_id` references `store_documents(id)`. This
  connection never enables `PRAGMA foreign_keys` -- `db_kit::open_file` sets
  busy_timeout and WAL only -- so the delete does not fail as the review
  supposed. It silently strands history rows naming a document that no
  longer exists, and `DELETE FROM store_doc_history` appears nowhere in the
  codebase, so nothing ever collects them.
* `store_blobs.ref_count` is a stored counter, not a live scan over
  referencing rows (see `blob_unref`). A row dropped without decrementing
  pins its blob at a positive count permanently -- the same leak class
  #343 fixed for the ordinary delete path.

So: release the references, then the history, then the rows. The decrement
counts references removed rather than distinct hashes, because several
discarded rows can point at one blob and a per-hash decrement would subtract
one where it owes several.

Blob files whose count reaches zero are left on disk. Reclaiming them means
filesystem I/O from a SQL migration; disk-level GC is issue #339's scope.

The test schema now carries the real dependent tables rather than a
documents-only stub. Foreign keys stay at SQLite's default (off) on purpose:
that is how the production database is opened, so enabling them would test a
configuration that never runs.
getappz added a commit that referenced this pull request Jul 26, 2026
* fix(store): dedup documents before creating the unique index

`CREATE UNIQUE INDEX idx_docs_project_path ON store_documents(project_id, path)`
was added as a bare migration. Uniqueness on that pair had until then been
enforced only by the single-writer application path, so a database written
before the index existed can already hold duplicate rows -- and the index
creation fails outright against those.

That failure is not cosmetic. The migration is rolled back, `user_version`
never advances, and the index never comes into existence. `write_batch_items`
upserts via `ON CONFLICT(project_id, path) DO UPDATE`, which requires that
index, so every per-item batch write fails too: the store is unusable, not
merely un-migrated.

Dedup first, keeping the most recently written row per (project_id, path),
then create the index. Databases that already migrated successfully had no
duplicates and are unaffected; databases stuck on the failure re-run the
migration and recover, precisely because the failed attempt rolled back.

The SQL is a named constant rather than inlined so the dedup behaviour can be
tested against a minimal schema, without replaying the full production
migration sequence to reach it.

Also hoist `write_batch_items`' four statements out of its loop onto
`prepare_cached`. Per-item indexing means one crate contributes hundreds of
items per batch, each previously re-preparing the same four statements.

* fix(store): release history and blob refs when dedup discards a row

Addresses CodeRabbit's review of the dedup migration.

Discarding a duplicate document is not a single DELETE, because two things
outlive the row:

* `store_doc_history.doc_id` references `store_documents(id)`. This
  connection never enables `PRAGMA foreign_keys` -- `db_kit::open_file` sets
  busy_timeout and WAL only -- so the delete does not fail as the review
  supposed. It silently strands history rows naming a document that no
  longer exists, and `DELETE FROM store_doc_history` appears nowhere in the
  codebase, so nothing ever collects them.
* `store_blobs.ref_count` is a stored counter, not a live scan over
  referencing rows (see `blob_unref`). A row dropped without decrementing
  pins its blob at a positive count permanently -- the same leak class
  #343 fixed for the ordinary delete path.

So: release the references, then the history, then the rows. The decrement
counts references removed rather than distinct hashes, because several
discarded rows can point at one blob and a per-hash decrement would subtract
one where it owes several.

Blob files whose count reaches zero are left on disk. Reclaiming them means
filesystem I/O from a SQL migration; disk-level GC is issue #339's scope.

The test schema now carries the real dependent tables rather than a
documents-only stub. Foreign keys stay at SQLite's default (off) on purpose:
that is how the production database is opened, so enabling them would test a
configuration that never runs.
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.

1 participant