Skip to content

fix: upsert-before-delete-stale and per-service reindex lock#91

Merged
GoodbyePlanet merged 1 commit into
mainfrom
fix/reindex-atomic-upsert-and-lock
Jul 14, 2026
Merged

fix: upsert-before-delete-stale and per-service reindex lock#91
GoodbyePlanet merged 1 commit into
mainfrom
fix/reindex-atomic-upsert-and-lock

Conversation

@GoodbyePlanet

Copy link
Copy Markdown
Owner

Summary

Fixes #69 — reindexing had two related races:

  • Non-atomic delete-before-upsert: IndexPipeline.index_service called delete_by_file then upsert_chunks as two separate Qdrant calls. A search landing between them (or a crash mid-update) would see zero symbols for that file.
  • No reindex lock: /reindex, /reindex-history, and the MCP reindex tool all spin up a fresh pipeline per call with nothing preventing two overlapping runs for the same service from racing each other's delete/upsert calls.

Changes

  • server/store/qdrant.py: added get_point_ids_by_file and delete_by_ids; upsert_chunks now returns the ids it wrote.
  • server/indexer/pipeline.py: for each changed file, upsert the new/changed symbols first, then delete only the ids that dropped out of the new set (point ids are deterministic uuid5(service:file:symbol:line), so this diff is exact) — no window where the file has zero indexed symbols.
  • server/state.py: added get_reindex_lock(key), a registry of asyncio.Lock per key.
  • server/indexer/pipeline.py / server/indexer/git_history.py: wrapped index_service bodies in get_reindex_lock(f"code:{service}") / get_reindex_lock(f"history:{service}") so overlapping reindex requests for the same service serialize (queue) instead of interleaving, regardless of entry point (HTTP or MCP tool). Different services still index fully concurrently.
  • tests/test_pipeline.py: updated the two tests that asserted the old delete-then-upsert ordering, and added regression tests for upsert-before-delete-stale ordering and for same-service reindex serialization.

Note: the lock blocks and queues overlapping requests rather than rejecting them — a second reindex call for the same service will simply wait for the first to finish rather than failing or racing it.

Test plan

  • uv run pytest — 265 passed
  • uvx ruff check . / uvx ruff format --check . — clean

Reindexing deleted a file's symbols before upserting the replacements,
leaving a window where search returned zero results for that file if a
query landed between the two calls or the process died mid-update.
Concurrent reindex requests for the same service also had no
serialization, so overlapping runs could race each other's
delete/upsert calls against Qdrant.

Point ids are deterministic (uuid5 of service/file/symbol/line), so
upsert the new/changed symbols first and only delete the ones that
dropped out of the new set. Add a per-service asyncio.Lock (keyed by
"code:{service}" / "history:{service}") around IndexPipeline and
GitHistoryPipeline's index_service so overlapping reindex requests for
the same service queue instead of interleaving.

Fixes #69

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

Copy link
Copy Markdown
Owner Author

Verified locally, it all works.

@GoodbyePlanet GoodbyePlanet reopened this Jul 14, 2026
@GoodbyePlanet GoodbyePlanet merged commit aba7632 into main Jul 14, 2026
4 checks passed
@GoodbyePlanet GoodbyePlanet deleted the fix/reindex-atomic-upsert-and-lock branch July 14, 2026 06:24
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.

Non-atomic delete-before-upsert and no reindex lock

1 participant