Skip to content

fix(store): dedup documents before creating the unique index - #346

Merged
getappz merged 2 commits into
masterfrom
fix/doc-index-dedup-migration
Jul 26, 2026
Merged

fix(store): dedup documents before creating the unique index#346
getappz merged 2 commits into
masterfrom
fix/doc-index-dedup-migration

Conversation

@getappz

@getappz getappz commented Jul 26, 2026

Copy link
Copy Markdown
Owner

What

Two fixes to the document store, both recovered from uncommitted work sitting in the task/343 worktree — found while auditing open issues against local branches, unreleated to any of them, and at risk of being lost when the worktree got reclaimed.

1. The unique-index migration can brick a store

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 index creation fails outright against those.

That failure is not cosmetic:

  • the migration rolls back, so user_version never advances and the index never exists;
  • 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.

Per-item indexing (#343) makes this reachable: one crate contributes hundreds of docsrs/<crate>/latest/item/... paths, so the write volume that could produce a stray duplicate is ~10^3 per package rather than ~1.

Fix: dedup first — keep MAX(rowid) per (project_id, path) — then create the index.

Editing the migration's SQL in place is the correct remedy here rather than appending a new one, and specifically because the failed attempt rolled back:

  • a database that already applied it successfully had no duplicates, so the added DELETE is a no-op it will never run anyway;
  • a database stuck on the failure never advanced user_version, so it re-runs this migration with the dedup step and recovers.

Appending a follow-up migration could not fix the stuck case, since migration N+1 is never reached while N keeps failing.

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

2. write_batch_items re-prepared its statements per item

Four statements — select / upsert / FTS delete / FTS insert — were prepared inside the per-item loop. Hoisted onto prepare_cached outside it, in a block so their borrow of tx is released before commit(). Same reasoning as above: batches are hundreds of items, not a handful.

Verification

  • cargo test --workspace — 834 bin tests + every crate suite, 0 failures
  • cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -A unsafe_code -A clippy::pedantic (CI's exact invocation) — clean
  • cargo fmt --all — clean
  • New test migrations::tests::dedup_migration_keeps_newest_row_and_enables_the_unique_index confirmed green by name: seeds two duplicate rows, asserts the migration SQL applies without error, keeps the newest row, and that the index then actually rejects a fresh duplicate.

Summary by CodeRabbit

  • Bug Fixes

    • Duplicate documents are now consolidated automatically, keeping the most recently stored version.
    • Duplicate document paths within the same project are prevented from being re-added; associated history and blob reference counts are cleaned up correctly.
    • The database now enforces uniqueness for (project, path) consistently after consolidation.
  • Performance

    • Batch document writes now reuse prepared database operations, improving throughput while keeping FTS/index updates in sync.
  • Tests

    • Added coverage for deduplication behavior, including reference counting and no-duplicate no-op cases.

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

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f44d2e4-333a-4fdd-bf92-d512a11c1a8e

📥 Commits

Reviewing files that changed from the base of the PR and between 31b9e59 and 03eba4b.

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

📝 Walkthrough

Walkthrough

The PR adds a migration that releases references and removes duplicate documents before enforcing (project_id, path) uniqueness, and refactors batch document writes to reuse prepared SQLite statements for content, upsert, and FTS operations.

Changes

Document storage

Layer / File(s) Summary
Deduplicate documents and enforce uniqueness
crates/agentflare-store/src/migrations.rs
The migration retains the newest duplicate, releases discarded document and history blob references, deletes obsolete rows, creates the unique index, wires it into migrations, and tests duplicate, reference-count, history, and no-duplicate cases.
Reuse prepared statements in batch writes
crates/flare-docs/src/store.rs
write_batch_items caches document and FTS statements, scopes their borrows before commit, and preserves write-count and content-change behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

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: deduplicating documents before creating the unique index.
Description check ✅ Passed The description covers the change, verification, and reviewer concerns, though it doesn't follow the template headings exactly.
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 fix/doc-index-dedup-migration

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: 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 `@crates/agentflare-store/src/migrations.rs`:
- Around line 13-16: Update the migration’s duplicate-document cleanup to
reconcile or explicitly delete dependent store_doc_history rows before removing
discarded store_documents records, preserving the surviving document’s history
where applicable. Extend the migration test to use the actual store_doc_history
foreign-key relationship and verify no orphaned history remains.
🪄 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: f7cd744a-0124-4723-af92-e6974023f2d1

📥 Commits

Reviewing files that changed from the base of the PR and between 3541288 and 31b9e59.

📒 Files selected for processing (2)
  • crates/agentflare-store/src/migrations.rs
  • crates/flare-docs/src/store.rs

Comment thread crates/agentflare-store/src/migrations.rs
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

getappz commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

Addressed in 03eba4b.

Verified the finding before implementing it — it was half right, and the other half was worse than described.

Not reproducible as stated: "this delete either fails with foreign keys enabled". agentflare-store opens via db_kit::open_file, which sets busy_timeout and journal_mode=WAL and nothing else — SQLite defaults foreign_keys to off. The only pragma_update(..., "foreign_keys", ...) calls in the repo are in agentflare-backend/src/db.rs and src/memory/*, on different databases. So the delete does not fail. I did not enable FKs in the test either, since that would assert a configuration that never runs in production.

Confirmed, and understated: the orphaning is real and permanentDELETE FROM store_doc_history appears nowhere in the codebase, so nothing ever collects stranded rows. Beyond that, store_blobs.ref_count is a stored counter, not a live scan over referencing rows (blob_unref decrements it explicitly), so a row dropped without decrementing pins its blob at a positive count forever. That is the same leak class PR #343 existed to fix for the ordinary delete path, and it applies to the discarded document rows themselves, not just their history.

Fix: release references, then history, then rows. The decrement counts references removed rather than distinct hashes — several discarded rows can point at one blob, and a WHERE hash IN (...) decrement would subtract one where it owes several.

Deliberately out of scope: blob files whose count reaches zero stay on disk. Reclaiming them means filesystem I/O from a SQL migration, and disk-level GC is issue #339. The doc comment states this rather than leaving it implied.

Tests — schema now carries the real store_doc_history (with its actual FK declaration) and store_blobs; three added, all green:

  • dedup_migration_drops_history_of_discarded_rows_and_keeps_the_survivor_s — zero orphans, and the survivor keeps its own history
  • dedup_migration_releases_blob_refs_held_by_discarded_rows — one blob referenced 4x, expects exactly 2 released; this is the assertion that fails under a per-distinct-hash decrement
  • dedup_migration_is_a_no_op_on_a_store_without_duplicates — the common case comes through byte-identical, blob counts included

cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -A unsafe_code -A clippy::pedantic clean; cargo test --workspace green apart from a pre-existing flake in flare-git-core::worktree::tests::run_output_timeout_kills_the_child_not_just_abandons_it, which passes 3/3 in isolation on this branch and on master and is untouched by this diff (0 lines changed under crates/flare-git-core/).

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

🧹 Nitpick comments (1)
crates/agentflare-store/src/migrations.rs (1)

31-58: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Good fix for the orphaned-history issue; consider reducing repeated computation and full-table rewrite.

This correctly resolves the previously flagged critical issue — history is now deleted before documents, in the order the doc comment describes, and test coverage confirms zero orphaned store_doc_history rows remain.

Two related efficiency concerns in the SQL itself:

  • The "discarded rows" subquery (SELECT MAX(rowid) FROM store_documents GROUP BY project_id, path combined with rowid NOT IN (...)) is repeated 4 times across the UPDATE and two DELETEs. Since CTEs are scoped to a single statement, this can't be deduplicated with a WITH clause across the batch, but a temp table computed once would remove the duplication and the risk of the four copies drifting apart in future edits.
  • The UPDATE store_blobs has no WHERE clause, so it rewrites every row in store_blobs even when the computed delta is 0 (i.e., even on a store with zero duplicates). For a large blob table this is unnecessary write amplification on every startup migration run.
♻️ Proposed refactor using a temp table
 pub(crate) const DEDUP_AND_UNIQUE_INDEX_MIGRATION: &str = "
-    UPDATE store_blobs
-       SET ref_count = MAX(0, ref_count
-           - (SELECT COUNT(*) FROM store_documents d
-               WHERE d.blob_hash = store_blobs.hash
-                 AND d.rowid NOT IN (
-                     SELECT MAX(rowid) FROM store_documents GROUP BY project_id, path))
-           - (SELECT COUNT(*) FROM store_doc_history h
-                JOIN store_documents d ON d.id = h.doc_id
-               WHERE h.blob_hash = store_blobs.hash
-                 AND d.rowid NOT IN (
-                     SELECT MAX(rowid) FROM store_documents GROUP BY project_id, path)));
-
-    DELETE FROM store_doc_history
-    WHERE doc_id IN (
-        SELECT id FROM store_documents
-        WHERE rowid NOT IN (
-            SELECT MAX(rowid) FROM store_documents GROUP BY project_id, path
-        )
-    );
-
-    DELETE FROM store_documents
-    WHERE rowid NOT IN (
-        SELECT MAX(rowid) FROM store_documents GROUP BY project_id, path
-    );
+    CREATE TEMP TABLE _dedup_discarded AS
+    SELECT rowid, id, blob_hash FROM store_documents
+    WHERE rowid NOT IN (SELECT MAX(rowid) FROM store_documents GROUP BY project_id, path);
+
+    UPDATE store_blobs
+       SET ref_count = MAX(0, ref_count
+           - (SELECT COUNT(*) FROM _dedup_discarded d WHERE d.blob_hash = store_blobs.hash)
+           - (SELECT COUNT(*) FROM store_doc_history h
+                JOIN _dedup_discarded d ON d.id = h.doc_id
+               WHERE h.blob_hash = store_blobs.hash))
+     WHERE hash IN (
+         SELECT blob_hash FROM _dedup_discarded WHERE blob_hash IS NOT NULL
+         UNION
+         SELECT h.blob_hash FROM store_doc_history h
+           JOIN _dedup_discarded d ON d.id = h.doc_id WHERE h.blob_hash IS NOT NULL
+     );
+
+    DELETE FROM store_doc_history WHERE doc_id IN (SELECT id FROM _dedup_discarded);
+
+    DELETE FROM store_documents WHERE rowid IN (SELECT rowid FROM _dedup_discarded);
+
+    DROP TABLE _dedup_discarded;

     CREATE UNIQUE INDEX IF NOT EXISTS idx_docs_project_path ON store_documents(project_id, path);
 ";

Separately, since the correlated subqueries filter on blob_hash/doc_id, please confirm supporting indexes exist on store_documents.blob_hash and store_doc_history.doc_id in the production schema — without them this migration does a full scan per blob row on large tables.

🤖 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/migrations.rs` around lines 31 - 58, Refactor
DEDUP_AND_UNIQUE_INDEX_MIGRATION to compute discarded document row IDs once in a
temporary table, then reuse it in the blob UPDATE and both document/history
DELETE statements. Restrict the store_blobs UPDATE to rows whose reference count
actually changes, and verify the production schema defines supporting indexes
for store_documents.blob_hash and store_doc_history.doc_id.
🤖 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.

Nitpick comments:
In `@crates/agentflare-store/src/migrations.rs`:
- Around line 31-58: Refactor DEDUP_AND_UNIQUE_INDEX_MIGRATION to compute
discarded document row IDs once in a temporary table, then reuse it in the blob
UPDATE and both document/history DELETE statements. Restrict the store_blobs
UPDATE to rows whose reference count actually changes, and verify the production
schema defines supporting indexes for store_documents.blob_hash and
store_doc_history.doc_id.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f7d83745-86d2-4ab9-83fc-b62898bd1b58

📥 Commits

Reviewing files that changed from the base of the PR and between 31b9e59 and 03eba4b.

📒 Files selected for processing (1)
  • crates/agentflare-store/src/migrations.rs

@getappz

getappz commented Jul 26, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai resume

@coderabbitai review

Please re-review 03eba4b, which responds to the earlier finding on the dedup migration. Two points I would specifically like checked, since one of them disagrees with the original comment:

  1. The finding said the delete "either fails with foreign keys enabled or leaves orphaned history otherwise". I claim the first branch cannot occur here: agentflare-store opens through db_kit::open_file, which sets only busy_timeout and journal_mode=WAL, so foreign_keys stays at SQLite's default of off. Is there a path that opens this database with foreign keys on that I have missed?

  2. The new UPDATE store_blobs decrements ref_count once per reference removed (documents plus their history), not once per distinct hash. Please check the arithmetic against the case where several discarded rows share one blob, and against a database with no duplicates at all, where it must be a strict no-op.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Target commit and changed files =='
git show --no-ext-diff --format=fuller --stat 03eba4b
git diff-tree --no-commit-id --name-only -r 03eba4b

printf '%s\n' '== Relevant module outlines =='
ast-grep outline crates/agentflare-store/src/migrations.rs --items all || true
fd -t f -e rs . crates | while read -r f; do
  if rg -q 'db_kit::open_file|open_file\s*\(|foreign_keys|DEDUP_AND_UNIQUE_INDEX_MIGRATION|store_blobs|store_doc_history' "$f"; then
    echo "--- $f"
    rg -n -C 4 'db_kit::open_file|(?:\bopen_file\s*\()|foreign_keys|DEDUP_AND_UNIQUE_INDEX_MIGRATION|store_blobs|store_doc_history' "$f"
  fi
done

printf '%s\n' '== Migration diff =='
git show --no-ext-diff --format= --unified=100 03eba4b -- crates/agentflare-store/src/migrations.rs

printf '%s\n' '== Store module outline and DB-opening contexts =='
ast-grep outline crates/agentflare-store/src --items all || true
rg -n -C 6 'db_kit::open_file|(?:\bopen_file\s*\()|Connection::open|foreign_keys' crates/agentflare-store

Length of output: 65052


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== db-kit connection helpers =='
ast-grep outline crates/agentflare-db-kit/src/open.rs --items all
cat -n crates/agentflare-db-kit/src/open.rs

printf '%s\n' '== Store constructors =='
sed -n '1,100p' crates/agentflare-store/src/lib.rs

printf '%s\n' '== Production blob reference accounting and document lifecycle =='
rg -n -C 10 'fn blob_ref\b|fn blob_unref\b|blob_ref\s*\(|blob_unref\s*\(|blob_hash|fn doc_delete\b|DELETE FROM store_documents|INSERT INTO store_doc_history' \
  crates/agentflare-store/src/blobs.rs crates/agentflare-store/src/documents.rs

printf '%s\n' '== All uses of agentflare-store migrations and Store constructors =='
rg -n -C 4 'agentflare_store::migrations|agentflare_store::Store::(?:open_file|open_memory)|Store::(?:open_file|open_memory)|migrations::migrations\s*\(' \
  --glob '*.rs' .

printf '%s\n' '== Foreign-key configuration, including PRAGMA SQL literals =='
rg -n -i -C 3 'pragma_update\s*\([^;]*foreign_keys|PRAGMA\s+foreign_keys|foreign_keys\s*=' \
  --glob '*.rs' --glob '*.sql' .

printf '%s\n' '== db-kit Cargo features that could change SQLite defaults =='
fd -t f 'Cargo.toml' crates/agentflare-db-kit -x sh -c 'echo "--- $1"; cat -n "$1"' sh {}

Length of output: 91403


@​getappz Re-review complete — no remaining finding on the dedup migration.

  1. Foreign keys: confirmed. Every in-repository agentflare_store::Store::{open_file, open_memory} path reaches crates/agentflare-db-kit/src/open.rs, which sets only the busy timeout and WAL mode before running migrations. There is no foreign_keys enablement for the document store. The foreign_keys = ON call sites belong to separate backend and memory-schema databases.

    So the former “fails with foreign keys enabled” branch does not apply to this repository’s document-store opening path. In any event, the revised order—delete dependent history before documents—also makes the migration compatible with an FK-enabled custom connection.

  2. Reference arithmetic: confirmed. For each blob, the migration subtracts:

    • the number of discarded store_documents rows with that hash; plus
    • the number of store_doc_history rows with that hash whose owning document is discarded.

    Thus, in the shared-hash test, the calculation is exactly 4 - 1 (discarded document) - 1 (its history) = 2; it is not a per-distinct-hash decrement. This matches the explicit store_blobs.ref_count lifecycle, which is maintained through blob_ref/blob_unref, not derived from a live-row scan.

    For a database without duplicates, both decrement subqueries return zero, the history delete matches nothing, and the document delete matches nothing. The test correctly verifies preservation of document count, history count, and blob count.

✅ Action performed

Reviews resumed.


Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@getappz
getappz merged commit 5dfb4a5 into master Jul 26, 2026
17 checks passed
@getappz
getappz deleted the fix/doc-index-dedup-migration branch July 26, 2026 18:13
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