Skip to content

feat: FTS5 lazy migration + CRUD sync + nuclear rebuild swap - #167

Merged
lyonzin merged 2 commits into
masterfrom
feat/fts5-migration-crud-sync
Aug 10, 2026
Merged

feat: FTS5 lazy migration + CRUD sync + nuclear rebuild swap#167
lyonzin merged 2 commits into
masterfrom
feat/fts5-migration-crud-sync

Conversation

@lyonzin

@lyonzin lyonzin commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Task 05 wires the FTS5 fast-path to real production data - before this PR, the module (task 01) and dispatch (task 03) existed but the index was always empty.

Fase 4 of FTS5 Lexical Fast-Path plan (workflow artifacts local-only, gitignored).

What ships

  • Lazy migration: KnowledgeOrchestrator._ensure_fts5_index spawns a background thread on first access if fts5_index.db is empty. First query does not block - falls back to hybrid gracefully while rebuild runs.
  • CRUD sync: _index_document, remove_document_by_path, update_document_content now also update FTS5 (guarded by config.fts5_enabled, idempotent when FTS5 empty).
  • nuclear_rebuild swap: FTS5 file rename atomic with ChromaDB collection swap (integrates with v4.8.0 zero-downtime staging pattern).
  • Standalone script: scripts/build_fts5_index.py for manual migration when ops needs to force a rebuild without daemon restart.
  • Ops runbook: docs/runbooks/fts5_migration.md.
  • 18 new tests in tests/test_fts5_migration.py + 24 lines in test_e2e_fts5.py.

What does NOT ship

  • No public API change - search_knowledge signature intact.
  • No opt-in flip - feature still off by default (Fase 5 handles gate + release).

LEI 1 preserved

Zero change to the 13 frozen MCP tools. Internal helpers _index_document, remove_document_by_path, update_document_content gain FTS5 sync but their signatures are internal - the MCP tools add_document, remove_document, update_document that call them are unchanged.

Local validation notes

Rollback

git revert <sha> - feature is opt-in default-off, CRUD hooks are guarded by config.fts5_enabled, revert is safe.

Summary by CodeRabbit

  • New Features
    • Added resumable background migration for the FTS5 lexical index with progress tracking and monitoring metrics.
    • Added automatic synchronization for document additions, updates, and removals.
    • Added a command-line tool for rebuilding the lexical index with batching, progress output, and restart support.
    • Added safe fallback behavior and clear readiness errors during migration.
  • Documentation
    • Added migration operations guidance, troubleshooting steps, and an Unreleased changelog entry.
  • Tests
    • Expanded migration, synchronization, failure recovery, lifecycle, and end-to-end coverage.

Greptile Summary

The PR connects the existing FTS5 fast path to production data through lazy migration, CRUD synchronization, rebuild integration, operational tooling, and migration metrics.

  • Adds resumable background population and migration state tracking.
  • Synchronizes document additions, updates, and removals into FTS5.
  • Recreates FTS5 after destructive and swap-based nuclear rebuilds.
  • Adds a standalone rebuild script, runbook, and migration-focused tests.

Confidence Score: 2/5

The PR is not yet safe to merge because committed migration batches can be duplicated after interruption, concurrent CRUD can be overwritten by stale migration data, and an obsolete worker can corrupt replacement migration state.

Three previously reported lifecycle defects remain in the current code: checkpoint persistence is not atomic with batch commits, migration snapshots are not coordinated with CRUD synchronization, and rebuild reset does not stop the active migration worker before replacing its index and marker.

Files Needing Attention: mcp_server/fts5_index.py and mcp_server/server.py

Important Files Changed

Filename Overview
mcp_server/fts5_index.py Adds FTS5 CRUD methods, migration batching, persistent lifecycle state, progress callbacks, and background-worker execution.
mcp_server/server.py Wires lazy migration, CRUD synchronization, migration metrics, and FTS5 rebuilding into orchestrator lifecycle operations.
scripts/build_fts5_index.py Adds an operator-facing command that rebuilds the FTS5 index synchronously from ChromaDB.
tests/test_fts5_migration.py Adds unit and integration coverage for migration state, progress, errors, CRUD synchronization, and concurrent writes.

Sequence Diagram

sequenceDiagram
    participant O as KnowledgeOrchestrator
    participant C as ChromaDB
    participant M as FTS5 migration worker
    participant F as FTS5 index
    O->>C: Count and read corpus
    O->>M: Start background migration
    loop Batches
        M->>F: Insert chunk rows
        M->>F: Persist progress marker
    end
    M->>F: Mark migration complete
    O->>F: Synchronize later CRUD mutations
    O->>F: Reset and rebuild after nuclear swap
Loading

Reviews (2): Last reviewed commit: "fix(scripts): add missing type hints to ..." | Re-trigger Greptile

Task 05 - makes FTS5 fast-path actually work in production.

- Lazy migration: background thread rebuilds FTS5 index from ChromaDB
  on first access if fts5_index.db is empty; first query does not block
  and falls back to hybrid gracefully while rebuild runs.
- CRUD sync: _index_document / remove_document_by_path /
  update_document_content now update FTS5 incrementally, guarded by
  config.fts5_enabled.
- nuclear_rebuild swap: FTS5 file swap atomic with ChromaDB collection
  swap (integrates with v4.8.0 zero-downtime staging pattern).
- Standalone script scripts/build_fts5_index.py for manual migration.
- Ops runbook docs/runbooks/fts5_migration.md.
- 18 new tests in tests/test_fts5_migration.py + 24 lines in
  test_e2e_fts5.py.

Test count baseline 308 -> 314 (+6).
LEI 1 preserved - internal helpers only, no MCP tool signature change.

Fase 4 of FTS5 Lexical Fast-Path plan.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

FTS5 now supports resumable background migration, progress metrics, synchronized document CRUD, rebuild workflows, and readiness handling. The server integrates migration with ChromaDB operations, and tests cover lifecycle, failure, resume, synchronization, and forced-search behavior.

Changes

FTS5 migration lifecycle

Layer / File(s) Summary
FTS5 migration and CRUD core
.github/api-surface-baseline.json, mcp_server/fts5_index.py, mcp_server/metrics.py
Fts5LexicalIndex adds typed CRUD methods, resumable background migration, batching, progress logging, migration-state persistence, and metric constants.
Runtime orchestration and synchronization
mcp_server/server.py
KnowledgeOrchestrator starts migration, reads sorted ChromaDB rows, publishes progress metrics, synchronizes document changes, and rebuilds FTS5 after index resets.
Manual rebuild workflow
scripts/build_fts5_index.py, docs/runbooks/fts5_migration.md, README.md
The rebuild CLI supports reset, synchronous migration, progress reporting, empty-corpus handling, and completion state. Documentation describes operation and recovery procedures.
Migration and CRUD validation
tests/test_fts5_migration.py, tests/test_e2e_fts5.py, .github/test-count-baseline.txt
Tests cover migration completion, batching, failure persistence, resume behavior, CRUD synchronization, metrics, disabled features, concurrency, and forced FTS5 errors. The test-count baseline increases to 314.

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

Sequence Diagram(s)

sequenceDiagram
  participant KnowledgeOrchestrator
  participant ChromaDB
  participant Fts5LexicalIndex
  participant PrometheusGauges
  KnowledgeOrchestrator->>ChromaDB: read document count and sorted chunk rows
  KnowledgeOrchestrator->>Fts5LexicalIndex: start migration with checkpoint
  Fts5LexicalIndex->>ChromaDB: iterate chunk rows
  Fts5LexicalIndex->>Fts5LexicalIndex: insert batches and persist checkpoints
  Fts5LexicalIndex->>PrometheusGauges: publish migration progress
  Fts5LexicalIndex-->>KnowledgeOrchestrator: mark migration complete and ready
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main FTS5 migration, CRUD synchronization, and rebuild swap changes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fts5-migration-crud-sync

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

Comment thread mcp_server/fts5_index.py
Comment thread mcp_server/fts5_index.py
Comment on lines +404 to +413
def _populate_batch(self, rows: Sequence[ChunkRow]) -> None:
"""Insert a batch under ``_fts5_lock``. Raises on SQL failure."""
if self._conn is None:
raise Fts5MigrationError("FTS5 connection is closed during migration")
with self._fts5_lock:
self._conn.executemany(
"INSERT INTO fts5_documents (chunk_id, content, filename, category) VALUES (?, ?, ?, ?)",
rows,
)
self._conn.commit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stale snapshots overwrite CRUD state

If a document is updated or removed after migration materializes its Chroma snapshot, the later unconditional batch insert restores the stale FTS5 row after CRUD synchronization, causing obsolete terms, duplicate rankings, or empty results for deleted chunk IDs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: mcp_server/fts5_index.py
Line: 404-413

Comment:
**Stale snapshots overwrite CRUD state**

If a document is updated or removed after migration materializes its Chroma snapshot, the later unconditional batch insert restores the stale FTS5 row after CRUD synchronization, causing obsolete terms, duplicate rankings, or empty results for deleted chunk IDs.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Comment thread mcp_server/server.py

@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: 14

🧹 Nitpick comments (1)
mcp_server/server.py (1)

2394-2430: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a public readiness API instead of three private accesses.

This method reaches into _write_state, _fts5_lock, and _ready with three noqa: SLF001 suppressions. scripts/build_fts5_index.py performs the same private access. A small public method on Fts5LexicalIndex, for example mark_empty_corpus_complete(), would encapsulate the marker write and the readiness flip in one place and remove the duplicated invariant from two callers.

🤖 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 `@mcp_server/server.py` around lines 2394 - 2430, add a public Fts5LexicalIndex
method such as mark_empty_corpus_complete() that atomically writes the complete
empty-corpus state and marks the index ready. Update _maybe_start_fts5_migration
and scripts/build_fts5_index.py to call this method instead of accessing
_write_state, _fts5_lock, or _ready directly, removing the SLF001 suppressions.
🤖 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 `@docs/runbooks/fts5_migration.md`:
- Around line 55-60: The failed-marker recovery description in
docs/runbooks/fts5_migration.md must state that daemon startup begins a fresh
background migration when status is "failed"; retain manual rebuild only as an
optional recovery path for corruption or operator-controlled rebuilds.
README.md:1417-1417 requires no direct change because its release note already
matches runtime behavior.

In `@mcp_server/fts5_index.py`:
- Around line 259-300: Move the self._conn None checks inside _fts5_lock in
add_document, remove_document, and update_document, then bind the validated
connection to a local variable and use it for every SQL statement and commit.
Apply the same locking and local-connection pattern in mcp_server/fts5_index.py
lines 404-413 within _populate_batch, preserving Fts5MigrationError there.
- Around line 359-391: Make _run_migration_batches replay-safe by removing
existing FTS5 rows for each row’s chunk_id before _populate_batch inserts the
batch, or otherwise enforce equivalent idempotent insertion. Preserve progress
accounting and callbacks, and leave _maybe_start_fts5_migration unchanged; the
earlier migration-start path at mcp_server/fts5_index.py lines 328-357 requires
no direct change once replayed inserts are idempotent.
- Around line 310-326: Update start_migration_background to accurately describe
the non-daemon migration thread and retain the created thread on the owning
object instead of discarding it. Add shutdown handling that cancels migration
when supported and joins the stored fts5-migration thread with a bounded wait,
ensuring interpreter shutdown is not blocked indefinitely.

In `@mcp_server/server.py`:
- Around line 2408-2421: Update the empty-corpus branch in the FTS5 migration
flow to write both marker timestamps using timezone-aware UTC datetimes,
matching Fts5LexicalIndex._migration_worker and _run_migration_sync. Import
timezone alongside datetime and replace the naive datetime.now() calls passed to
_write_state, preserving the existing completion behavior.
- Around line 2505-2526: Update the FTS5 migration orchestration around
_maybe_start_fts5_migration to retain the worker thread when it is started, then
have _fts5_reset_and_rebuild stop starting over until any prior migration worker
has been joined. Ensure the old worker finishes before deleting or recreating
the shared state file and assigning the new Fts5LexicalIndex, while preserving
the existing reset and migration-start behavior.
- Around line 3202-3210: Update _fts5_sync_add_from_doc to derive FTS5 ids,
content, and metadata from the deduplicated chunks returned or used by
_index_document, rather than iterating over every doc.chunks entry. Reuse the
ids and associated metadata that were actually written to ChromaDB, preserving
the existing FTS5 guard and no-op behavior when no chunks survive deduplication.
- Around line 2439-2464: Update _iter_chroma_chunks_for_fts5 to avoid loading
the entire corpus at once: fetch and sort IDs first, then retrieve documents and
metadata using bounded batches of IDs and yield each batch in sorted-ID order.
Preserve the existing tuple fields, empty-collection behavior, and count-error
handling while removing any corpus-sized buffering.

In `@scripts/build_fts5_index.py`:
- Around line 145-153: Update the rebuild flow around _drop_existing,
_open_collection, and the empty-corpus branch so every invocation clears the
existing target index before population, regardless of args.force. When
_iter_chroma_chunks returns no rows, persist the index’s complete zero-row state
before returning, including when --force is used.
- Around line 73-94: Update _iter_chroma_chunks to avoid calling ids.index for
each chunk. Build aligned (id, document, metadata) tuples and sort those tuples
once by ID, then construct rows from the sorted tuples while preserving
document/metadata alignment and existing fallback handling.
- Around line 65-70: Update the cleanup loop in the build/rebuild flow to
terminate with a non-zero error when path.unlink() raises OSError. Preserve the
existing diagnostic output, then raise or otherwise propagate the failure so no
indexing or insertion proceeds after cleanup fails.
- Around line 148-157: Serialize manual FTS5 rebuilds with live CRUD: in
scripts/build_fts5_index.py lines 148-157, acquire the daemon’s exclusive
CRUD/rebuild lock before _open_collection snapshot extraction and hold it
through _run_migration_sync, or otherwise enforce an atomic index build-and-swap
while CRUD is paused; in docs/runbooks/fts5_migration.md lines 62-98, document
that the daemon must be stopped or CRUD exclusively locked before manual
rebuilds, particularly when using --force.
- Around line 105-111: Update _open_collection to retrieve the configured
collection with get_collection(name=config.collection_name) instead of
get_or_create_collection, ensuring a missing collection raises an explicit error
rather than creating an empty one.

In `@tests/test_fts5_migration.py`:
- Around line 443-486: Update _build_sync_orch to accept pytest’s request
fixture and register a finalizer with request.addfinalizer that stops the config
patcher and closes real_index. Remove the __teardown__ attribute and any
ineffective autouse scanning, then update every caller to pass request; remove
the pytest import only if no other fixture uses it.

---

Nitpick comments:
In `@mcp_server/server.py`:
- Around line 2394-2430: add a public Fts5LexicalIndex method such as
mark_empty_corpus_complete() that atomically writes the complete empty-corpus
state and marks the index ready. Update _maybe_start_fts5_migration and
scripts/build_fts5_index.py to call this method instead of accessing
_write_state, _fts5_lock, or _ready directly, removing the SLF001 suppressions.
🪄 Autofix

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 4113d2fd-b74d-4d0e-968e-0451696fc378

📥 Commits

Reviewing files that changed from the base of the PR and between 3733dce and 3be6111.

📒 Files selected for processing (10)
  • .github/api-surface-baseline.json
  • .github/test-count-baseline.txt
  • README.md
  • docs/runbooks/fts5_migration.md
  • mcp_server/fts5_index.py
  • mcp_server/metrics.py
  • mcp_server/server.py
  • scripts/build_fts5_index.py
  • tests/test_e2e_fts5.py
  • tests/test_fts5_migration.py

Comment on lines +55 to +60
- `status: "complete"` → fast-path is live, queries dispatch to FTS5.
- `status: "in_progress"` → migration still running (or was interrupted).
The daemon resumes from `docs_indexed` on the next restart — it never
rebuilds from zero.
- `status: "failed"` → see `error` field for the exception class + message.
Queries fall back permanently until you rebuild manually.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline mcp_server/server.py --items all --match 'migration|fts5'
rg -n -C 8 'maybe_start_fts5_migration|start_migration_background|failed|in_progress|docs_indexed' mcp_server/server.py

Repository: lyonzin/knowledge-rag

Length of output: 30947


🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -i 'fts5_index|fts5_migration' .
printf '\n--- migration startup and worker implementation ---\n'
sed -n '2368,2432p' mcp_server/server.py
printf '\n--- FTS5 migration state handling ---\n'
rg -n -C 10 'class .*State|state|start_migration_background|status|docs_indexed|failed|complete|in_progress' mcp_server/fts5_index.py
printf '\n--- runbook recovery section ---\n'
sed -n '45,105p' docs/runbooks/fts5_migration.md

Repository: lyonzin/knowledge-rag

Length of output: 16525


Align failed-marker recovery documentation

A failed marker starts a fresh background migration at daemon startup. Update the runbook to state this behavior. Keep manual rebuild as an optional recovery path for corruption or operator-controlled rebuilds. The README release note matches the runtime behavior and needs no change.

📍 Affects 2 files
  • docs/runbooks/fts5_migration.md#L55-L60 (this comment)
  • README.md#L1417-L1417
🤖 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 `@docs/runbooks/fts5_migration.md` around lines 55 - 60, The failed-marker
recovery description in docs/runbooks/fts5_migration.md must state that daemon
startup begins a fresh background migration when status is "failed"; retain
manual rebuild only as an optional recovery path for corruption or
operator-controlled rebuilds. README.md:1417-1417 requires no direct change
because its release note already matches runtime behavior.

Comment thread mcp_server/fts5_index.py
Comment on lines +259 to +300

# -----------------------------------------------------------------
# CRUD sync (Task 05, ADR-008). SQL nativo incremental — diverge do
# BM25 full-rebuild pattern porque FTS5 tem INSERT/DELETE O(1) e o
# full rebuild custaria segundos por mutation em corpus 3865 docs.
# Todos os writes acquire ``_fts5_lock`` (RLock, Q2 do TechSpec) e
# sao serializados pelo WAL SQLite (ADR-001).
# -----------------------------------------------------------------

def add_document(self, chunk_id: str, content: str, filename: str, category: str) -> None:
"""Insert one chunk row via ``INSERT`` (ADR-008)."""
if self._conn is None:
raise Fts5CorruptError("FTS5 connection is closed")
with self._fts5_lock:
self._conn.execute(
"INSERT INTO fts5_documents (chunk_id, content, filename, category) VALUES (?, ?, ?, ?)",
(chunk_id, content, filename, category),
)
self._conn.commit()

def remove_document(self, chunk_id: str) -> None:
"""Delete every row matching ``chunk_id`` (ADR-008)."""
if self._conn is None:
raise Fts5CorruptError("FTS5 connection is closed")
with self._fts5_lock:
self._conn.execute(
"DELETE FROM fts5_documents WHERE chunk_id = ?",
(chunk_id,),
)
self._conn.commit()

def update_document(self, chunk_id: str, content: str, filename: str, category: str) -> None:
"""DELETE + INSERT atomico — FTS5 nao tem UPDATE efficient em virtual table."""
if self._conn is None:
raise Fts5CorruptError("FTS5 connection is closed")
with self._fts5_lock:
self._conn.execute("DELETE FROM fts5_documents WHERE chunk_id = ?", (chunk_id,))
self._conn.execute(
"INSERT INTO fts5_documents (chunk_id, content, filename, category) VALUES (?, ?, ?, ?)",
(chunk_id, content, filename, category),
)
self._conn.commit()

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

Unlocked self._conn check in every FTS5 write path. All four write methods test self._conn is None before they acquire _fts5_lock, then dereference self._conn inside the lock. close() holds the same lock and sets self._conn = None, so a concurrent close between the check and the acquisition raises AttributeError on None instead of the documented Fts5CorruptError or Fts5MigrationError. _fts5_reset_and_rebuild in mcp_server/server.py calls close() while the migration worker can still be active, so the window is reachable.

  • mcp_server/fts5_index.py#L259-L300: move the self._conn is None check inside with self._fts5_lock in add_document, remove_document, and update_document, and bind the connection to a local variable for all statements.
  • mcp_server/fts5_index.py#L404-L413: apply the same pattern in _populate_batch, keeping Fts5MigrationError as the raised type.
📍 Affects 1 file
  • mcp_server/fts5_index.py#L259-L300 (this comment)
  • mcp_server/fts5_index.py#L404-L413
🤖 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 `@mcp_server/fts5_index.py` around lines 259 - 300, Move the self._conn None
checks inside _fts5_lock in add_document, remove_document, and update_document,
then bind the validated connection to a local variable and use it for every SQL
statement and commit. Apply the same locking and local-connection pattern in
mcp_server/fts5_index.py lines 404-413 within _populate_batch, preserving
Fts5MigrationError there.

Comment thread mcp_server/fts5_index.py
Comment on lines +310 to +326
def start_migration_background(
self,
chunk_iter_factory: ChunkIterFactory,
docs_total: int,
*,
resume_from: int = 0,
on_progress: Optional[ProgressCallback] = None,
) -> threading.Thread:
"""Launch the migration daemon thread. Returns the started thread."""
thread = threading.Thread(
target=self._migration_worker,
args=(chunk_iter_factory, docs_total, resume_from, on_progress),
name="fts5-migration",
daemon=False,
)
thread.start()
return thread

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 | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find shutdown/atexit handling and any join of the fts5-migration thread.
rg -nP -C4 '(atexit|signal\.signal|SIGTERM|SIGINT|\.join\(|fts5-migration|_migration_thread)' --type=py -g '!tests/**'

Repository: lyonzin/knowledge-rag

Length of output: 159


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- migration implementation and call sites ---'
rg -n -C6 'start_migration_background|_migration_worker|fts5-migration|daemon\s*=|shutdown|close\(|stop\(' --glob '*.py' .
printf '%s\n' '--- target file ---'
sed -n '270,350p' mcp_server/fts5_index.py
printf '%s\n' '--- project entrypoints and lifecycle references ---'
rg -n -C4 'FastMCP|mcp\.run|asyncio|atexit|signal|SIGTERM|SIGINT|join\(' --glob '*.py' . --glob '!tests/**'

Repository: lyonzin/knowledge-rag

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- migration startup context ---'
sed -n '2380,2450p' mcp_server/server.py
printf '%s\n' '--- server lifecycle and shutdown code ---'
rg -n -C8 'def (close|shutdown|run|main)|with .*instance|instance_lock|fts5_index\.close|observer\.stop|observer\.join|mcp\.run|uvicorn|serve' mcp_server/server.py mcp_server/instance_lock.py
printf '%s\n' '--- all production references to the returned migration thread ---'
rg -n 'start_migration_background|fts5-migration|migration_thread|_migration_thread|Thread\(' mcp_server scripts --glob '*.py'

Repository: lyonzin/knowledge-rag

Length of output: 41160


Add migration shutdown handling and correct the docstring.

The server discards the non-daemon fts5-migration thread and has no shutdown join or cancellation path. A normal interpreter shutdown can therefore wait for the migration to finish. Store the thread and join it, or add cancellation with a bounded shutdown wait. Change the docstring from “daemon thread” to match daemon=False.

🤖 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 `@mcp_server/fts5_index.py` around lines 310 - 326, Update
start_migration_background to accurately describe the non-daemon migration
thread and retain the created thread on the owning object instead of discarding
it. Add shutdown handling that cancels migration when supported and joins the
stored fts5-migration thread with a bounded wait, ensuring interpreter shutdown
is not blocked indefinitely.

Comment thread mcp_server/fts5_index.py
Comment on lines +359 to +391
def _run_migration_batches(
self,
chunk_iter_factory: ChunkIterFactory,
docs_total: int,
docs_indexed: int,
started_at: str,
on_progress: Optional[ProgressCallback],
) -> int:
"""Consume the iterator batch-by-batch. Returns the final ``docs_indexed``."""
resume_from = docs_indexed
seen = 0
batch: List[ChunkRow] = []
last_percent_logged = -10
for row in chunk_iter_factory():
if seen < resume_from:
seen += 1
continue
seen += 1
batch.append(row)
if len(batch) >= 100:
self._populate_batch(batch)
docs_indexed += len(batch)
batch = []
self._write_state("in_progress", docs_total, docs_indexed, started_at, None, None)
if on_progress is not None:
on_progress(docs_indexed, docs_total)
last_percent_logged = self._maybe_log_progress(docs_indexed, docs_total, last_percent_logged)
if batch:
self._populate_batch(batch)
docs_indexed += len(batch)
if on_progress is not None:
on_progress(docs_indexed, docs_total)
return docs_indexed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Migration inserts are not idempotent, so any rerun duplicates rows. _populate_batch issues a plain INSERT, and the FTS5 schema declares chunk_id UNINDEXED with no uniqueness constraint. Every path that replays rows already written therefore creates duplicate entries for the same chunk_id, which inflates BM25 scores and returns duplicate hits from search.

  • mcp_server/fts5_index.py#L359-L391: make replay safe. Either delete by chunk_id before each batch insert, or resume from a persisted chunk_id watermark instead of the positional resume_from count, which shifts when the corpus changes between runs.
  • mcp_server/fts5_index.py#L328-L357: no change is needed here once inserts are idempotent. Note that a failed marker makes _maybe_start_fts5_migration restart at resume_from=0, so the rows already written by the failed run are re-inserted today.
📍 Affects 1 file
  • mcp_server/fts5_index.py#L359-L391 (this comment)
  • mcp_server/fts5_index.py#L328-L357
🤖 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 `@mcp_server/fts5_index.py` around lines 359 - 391, Make _run_migration_batches
replay-safe by removing existing FTS5 rows for each row’s chunk_id before
_populate_batch inserts the batch, or otherwise enforce equivalent idempotent
insertion. Preserve progress accounting and callbacks, and leave
_maybe_start_fts5_migration unchanged; the earlier migration-start path at
mcp_server/fts5_index.py lines 328-357 requires no direct change once replayed
inserts are idempotent.

Comment thread mcp_server/server.py
Comment thread scripts/build_fts5_index.py Outdated
Comment on lines +73 to +94
def _iter_chroma_chunks(collection) -> list[tuple[str, str, str, str]]:
count = collection.count()
if count == 0:
return []
fetched = collection.get(include=["documents", "metadatas"], limit=count)
ids = fetched.get("ids") or []
docs = fetched.get("documents") or []
metas = fetched.get("metadatas") or []
rows: list[tuple[str, str, str, str]] = []
for chunk_id, content, meta in zip(sorted(ids), docs, metas):
# Preserve alignment with sorted ids
idx = ids.index(chunk_id)
meta_i = metas[idx] or {}
rows.append(
(
str(chunk_id),
str(docs[idx] or ""),
str(meta_i.get("filename", "")),
str(meta_i.get("category", "")),
)
)
return rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Remove the quadratic ID lookup.

ids.index(chunk_id) scans ids for every row. A 100,000-row rebuild becomes O(n²) before indexing starts. Sort aligned rows once instead.

Proposed fix
-    for chunk_id, content, meta in zip(sorted(ids), docs, metas):
-        # Preserve alignment with sorted ids
-        idx = ids.index(chunk_id)
-        meta_i = metas[idx] or {}
+    for chunk_id, content, meta in sorted(zip(ids, docs, metas), key=lambda row: str(row[0])):
+        meta_i = meta or {}
         rows.append(
             (
                 str(chunk_id),
-                str(docs[idx] or ""),
+                str(content or ""),
                 str(meta_i.get("filename", "")),
                 str(meta_i.get("category", "")),
             )
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _iter_chroma_chunks(collection) -> list[tuple[str, str, str, str]]:
count = collection.count()
if count == 0:
return []
fetched = collection.get(include=["documents", "metadatas"], limit=count)
ids = fetched.get("ids") or []
docs = fetched.get("documents") or []
metas = fetched.get("metadatas") or []
rows: list[tuple[str, str, str, str]] = []
for chunk_id, content, meta in zip(sorted(ids), docs, metas):
# Preserve alignment with sorted ids
idx = ids.index(chunk_id)
meta_i = metas[idx] or {}
rows.append(
(
str(chunk_id),
str(docs[idx] or ""),
str(meta_i.get("filename", "")),
str(meta_i.get("category", "")),
)
)
return rows
def _iter_chroma_chunks(collection) -> list[tuple[str, str, str, str]]:
count = collection.count()
if count == 0:
return []
fetched = collection.get(include=["documents", "metadatas"], limit=count)
ids = fetched.get("ids") or []
docs = fetched.get("documents") or []
metas = fetched.get("metadatas") or []
rows: list[tuple[str, str, str, str]] = []
for chunk_id, content, meta in sorted(zip(ids, docs, metas), key=lambda row: str(row[0])):
meta_i = meta or {}
rows.append(
(
str(chunk_id),
str(content or ""),
str(meta_i.get("filename", "")),
str(meta_i.get("category", "")),
)
)
return rows
🤖 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 `@scripts/build_fts5_index.py` around lines 73 - 94, Update _iter_chroma_chunks
to avoid calling ids.index for each chunk. Build aligned (id, document,
metadata) tuples and sort those tuples once by ID, then construct rows from the
sorted tuples while preserving document/metadata alignment and existing fallback
handling.

Comment thread scripts/build_fts5_index.py Outdated
Comment on lines +105 to +111
def _open_collection(data_dir: Path):
import chromadb

from mcp_server.config import config

client = chromadb.PersistentClient(path=str(config.chroma_dir))
return client.get_or_create_collection(name=config.collection_name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For ChromaDB version 1.4.0, does PersistentClient.get_or_create_collection(name=...) create a new empty collection when no collection exists? Cite the official ChromaDB documentation.

💡 Result:

Yes, in ChromaDB version 1.4.0, the PersistentClient.get_or_create_collection(name=...) method creates a new, empty collection if a collection with the specified name does not already exist [1][2]. According to the official ChromaDB documentation, the method functions as follows: - If the collection does not exist, it is created with the provided parameters (such as metadata) [1][3]. - If the collection already exists, the method simply returns the existing collection, and any additional parameters provided (like metadata) are ignored [1][4][3]. This behavior ensures that the requested collection is available for use regardless of its prior existence [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
sed -n '1,180p' scripts/build_fts5_index.py
printf '%s\n' '--- ChromaDB version/config references ---'
rg -n --glob '!node_modules' --glob '!dist' --glob '!build' 'chromadb|collection_name|chroma_dir' .

Repository: lyonzin/knowledge-rag

Length of output: 16585


Use get_collection() for rebuilds

If the configured collection does not exist, get_or_create_collection() creates an empty collection and the script reports a successful empty rebuild. Use get_collection(name=config.collection_name) so configuration errors fail explicitly.

🤖 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 `@scripts/build_fts5_index.py` around lines 105 - 111, Update _open_collection
to retrieve the configured collection with
get_collection(name=config.collection_name) instead of get_or_create_collection,
ensuring a missing collection raises an explicit error rather than creating an
empty one.

Comment on lines +145 to +153
if args.force:
_drop_existing(data_dir)

start = time.time()
collection = _open_collection(data_dir)
rows = _iter_chroma_chunks(collection)
if not rows:
print("[BUILD-FTS5] corpus is empty — nothing to index")
return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make every rebuild replace the target index state.

Without --force, the script preserves existing FTS5 rows and _populate_batch() appends duplicate chunk IDs. When the corpus is empty, this early return preserves stale rows without --force and leaves no complete zero-row marker with --force. Require a clean target before population, and write a complete zero-row state for an empty corpus.

🤖 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 `@scripts/build_fts5_index.py` around lines 145 - 153, Update the rebuild flow
around _drop_existing, _open_collection, and the empty-corpus branch so every
invocation clears the existing target index before population, regardless of
args.force. When _iter_chroma_chunks returns no rows, persist the index’s
complete zero-row state before returning, including when --force is used.

Comment on lines +148 to +157
start = time.time()
collection = _open_collection(data_dir)
rows = _iter_chroma_chunks(collection)
if not rows:
print("[BUILD-FTS5] corpus is empty — nothing to index")
return 0

index = _open_index(data_dir)
try:
_run_migration_sync(index, rows, args.verbose)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize manual rebuilds with FTS5 CRUD.

The script reads a ChromaDB snapshot before it writes FTS5 rows. A daemon-side update_document or remove_document after that snapshot can complete first, then the rebuild inserts the old row and restores stale content. Require exclusive rebuild access or build a separate index and swap it while CRUD is paused.

  • scripts/build_fts5_index.py#L148-L157: prevent live CRUD during snapshot extraction and FTS5 population.
  • docs/runbooks/fts5_migration.md#L62-L98: require the daemon to be stopped or CRUD to be exclusively locked before a manual rebuild, especially with --force.
📍 Affects 2 files
  • scripts/build_fts5_index.py#L148-L157 (this comment)
  • docs/runbooks/fts5_migration.md#L62-L98
🤖 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 `@scripts/build_fts5_index.py` around lines 148 - 157, Serialize manual FTS5
rebuilds with live CRUD: in scripts/build_fts5_index.py lines 148-157, acquire
the daemon’s exclusive CRUD/rebuild lock before _open_collection snapshot
extraction and hold it through _run_migration_sync, or otherwise enforce an
atomic index build-and-swap while CRUD is paused; in
docs/runbooks/fts5_migration.md lines 62-98, document that the daemon must be
stopped or CRUD exclusively locked before manual rebuilds, particularly when
using --force.

Comment on lines +443 to +486
def _build_sync_orch(tmp_path, *, fts5_enabled: bool = True):
"""Stub orchestrator with the real CRUD-sync helpers bound to a capture index."""
import mcp_server.server as srv
from mcp_server.server import KnowledgeOrchestrator

Fts5MigrationState(tmp_path / "fts5_migration.state").write(
{
"status": "complete",
"docs_total": 0,
"docs_indexed": 0,
"started_at": "2026-08-07T12:00:00Z",
"completed_at": "2026-08-07T12:00:00Z",
"error": None,
}
)
real_index = Fts5LexicalIndex(
db_path=tmp_path / "fts5_index.db",
state_path=tmp_path / "fts5_migration.state",
)
capture = _CaptureIndex(real_index)

orch = object.__new__(KnowledgeOrchestrator)
orch.fts5_index = capture # helpers accept the duck-typed wrapper
orch.collection = types.SimpleNamespace(get=lambda where, include: {"ids": []})

# Bind the real hooks so behaviour under test is production-shaped.
orch._fts5_sync_add = types.MethodType( # noqa: SLF001
KnowledgeOrchestrator._fts5_sync_add, orch
)
orch._fts5_sync_remove_by_doc_id = types.MethodType( # noqa: SLF001
KnowledgeOrchestrator._fts5_sync_remove_by_doc_id, orch
)

# Patch config toggles for the duration of the test.
patcher = patch.object(srv.config, "fts5_enabled", fts5_enabled)
patcher.start()

def _teardown():
patcher.stop()
real_index.close()

# Register teardown via a finalizer bound to the orchestrator stub.
orch.__teardown__ = _teardown # noqa: SLF001
return orch, capture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

The teardown fixture never runs, so config.fts5_enabled leaks across tests.

_build_sync_orch attaches _teardown to the orchestrator stub and returns that stub. Each test binds it to a local variable. The autouse fixture then scans request.node.__dict__ for an attribute that holds an object with __teardown__. The stub is never assigned to request.node, so the scan finds nothing.

Two effects follow:

  1. patcher.stop() never runs. test_it_crud_004_feature_off_skips_fts5_calls patches srv.config.fts5_enabled to False and leaves it there for the rest of the session. Any later test that depends on the FTS5 path then silently takes the disabled branch, and the failure appears in an unrelated test file depending on collection order.
  2. real_index.close() never runs. The SQLite connection stays open. The module docstring states that Windows CI is sensitive to SQLite locking, and tmp_path cleanup can fail for that reason.

Convert the helper into a fixture-driven builder that registers cleanup through request.addfinalizer.

💚 Proposed fix
-def _build_sync_orch(tmp_path, *, fts5_enabled: bool = True):
+def _build_sync_orch(tmp_path, request, *, fts5_enabled: bool = True):
     """Stub orchestrator with the real CRUD-sync helpers bound to a capture index."""
@@
     patcher = patch.object(srv.config, "fts5_enabled", fts5_enabled)
     patcher.start()
 
-    def _teardown():
-        patcher.stop()
-        real_index.close()
-
-    # Register teardown via a finalizer bound to the orchestrator stub.
-    orch.__teardown__ = _teardown  # noqa: SLF001
+    request.addfinalizer(patcher.stop)
+    request.addfinalizer(real_index.close)
     return orch, capture
-
-
-@pytest.fixture(autouse=True)
-def _cleanup_sync_orch(request):
-    """Run any ``__teardown__`` recorded by ``_build_sync_orch``."""
-    yield
-    for name, obj in list(request.node.__dict__.items()):
-        if hasattr(obj, "__teardown__"):
-            try:
-                obj.__teardown__()
-            except Exception:  # noqa: BLE001
-                pass

Each caller then passes the built-in request fixture, for example:

def test_it_crud_004_feature_off_skips_fts5_calls(self, tmp_path, request):
    orch, capture = _build_sync_orch(tmp_path, request, fts5_enabled=False)

Removing the pytest import becomes possible only if no other fixture remains in the file.

Also applies to: 489-498

🤖 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 `@tests/test_fts5_migration.py` around lines 443 - 486, Update _build_sync_orch
to accept pytest’s request fixture and register a finalizer with
request.addfinalizer that stops the config patcher and closes real_index. Remove
the __teardown__ attribute and any ineffective autouse scanning, then update
every caller to pass request; remove the pytest import only if no other fixture
uses it.

CI Pillar 7 (mypy strict) flagged 4 functions in the standalone
migration script without type annotations. Standardize with the rest
of the codebase (Rules Python DevStyle requires type hints on all
function parameters and returns).

- Added typing.TYPE_CHECKING + Any imports
- _iter_chroma_chunks: collection: Any
- _open_index: -> Fts5LexicalIndex (forward ref via TYPE_CHECKING)
- _open_collection: -> Any (chromadb Collection, avoid top-level import)
- _run_migration_sync: index: Fts5LexicalIndex
@lyonzin
lyonzin merged commit fe9dd38 into master Aug 10, 2026
38 checks passed
@lyonzin
lyonzin deleted the feat/fts5-migration-crud-sync branch August 10, 2026 20:46
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