Skip to content

feat(index): reclaim superseded index versions, and refuse to delete the live one#108

Merged
fvadicamo merged 3 commits into
developfrom
feat/debt-032-index-version-cleanup
Jul 14, 2026
Merged

feat(index): reclaim superseded index versions, and refuse to delete the live one#108
fvadicamo merged 3 commits into
developfrom
feat/debt-032-index-version-cleanup

Conversation

@fvadicamo

@fvadicamo fvadicamo commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes DEBT-032. REQ-064 spells out "cleanup of old version afterwards" as part of the requirement. Nothing ever implemented it: reindex writes a second copy of every chunk under the target version and leaves both in the store forever, while reindex.py's docstring told the operator it could "trigger cleanup of old-version chunks". There was nothing to trigger.

The gap only became live when BUG-023 was fixed (#102). Until reindex actually wrote chunks, there was never an old version to clean up. The moment it did, every reindex started doubling a namespace's storage permanently, and the only exit was a hand-written delete against the store.

What changed

Layer Change
Protocol VectorStoreProvider.delete_index_version(namespace, index_version) -> int
Providers Implemented by QdrantVectorStoreProvider and PgvectorProvider (+ VectorStoreServiceAdapter)
API DELETE /api/v1/index-versions/{version} (admin scope)
Script scripts/reindex.sh --cleanup OLD_VERSION [NAMESPACE]
Docs docs/reference/api.md step 5 replaces the manual store-level delete with the endpoint

The guard, and where it lives

The failure mode this exists for is not "an old version survives", it is "the live index is emptied". The hand-written delete it replaces (a Qdrant filter delete, or DELETE FROM document_chunks WHERE index_version = N) has no idea which version is live, and is run by an operator at exactly the moment they are least sure.

So the refusal sits in the providers, not in the endpoint: a store is the only component that knows which version it reads. It raises ActiveIndexVersionError (-> 409) rather than trusting each caller to check first, which means the guard also covers callers that are not this endpoint.

Two consequences worth calling out:

  • The cleanup cannot be one run with the reindex. The switch (VEKTRA_ACTIVE_INDEX_VERSION + restart) sits between them, and before it the API correctly refuses. scripts/reindex.sh is therefore two invocations, on purpose, and says so.
  • Namespace binding is enforced as on DELETE /documents/{id}: 403 on mismatch, not a silent retarget. This endpoint deletes by filter, so a silently retargeted namespace would drop an entire version of a namespace the caller never named.

Idempotent: a second call returns chunks_removed: 0, which is how an operator confirms the old version is really gone.

Proof the guard holds

Removing the guard was tried on purpose, and the tests go red:

  • delete the Qdrant guard -> test_endpoint_refuses_to_delete_the_version_being_served fails with assert 200 == 409 (i.e. the live index would have been emptied), plus the provider-level test.
  • delete the pgvector guard -> its provider test fails (the DELETE would have executed).

Endpoint tests wire a real provider over a stubbed client, not an AsyncMock store: a mocked guard proves nothing about the guard.

Verified on the live dev stack (Qdrant, 1323 points)

Step Result
DELETE /index-versions/1 while v1 is active 409, nothing deleted (12 points still there)
Reindex default -> v2 12 chunks written; namespace now 12 + 12 = doubled (the DEBT-032 symptom)
--cleanup 1 before the switch refused, script exits 1
Switch to v2, restart live search byte-identical: same scores, same text, new chunk ids
--cleanup 1 after the switch 12 removed; v1=0, v2=12; collection back to 1323
Same call again chunks_removed: 0 (idempotent)
The other 6 namespaces (all at version 1) untouched - 223/105/562/97/97/227, none lost a point
Guard after the switch now refuses v2, i.e. it follows what is actually being served

Stack restored to its exact initial state: 1323 points, all at version 1, original chunk ids, .env byte-identical.

Notes for the reviewer

  • The 409/400 on this endpoint use FastAPI's bare {"detail": ...} shape, matching the rest of the reindex router. That is DEBT-033, queued next, which will convert the whole family to the REQ-010 envelope at once. The 403 already uses the envelope (it reuses _namespace_scope_violation).
  • No migration: the pgvector delete is a plain filtered DELETE, Qdrant is a filtered points delete.

Checks

  • make lint - clean (ruff, mypy on 76 files, 8/8 import-linter contracts)
  • make test - 765 passed, 3 skipped
  • shellcheck scripts/*.sh - clean (run in a container; same check CI runs)

Refs: REQ-064, ARCH-045, ADR-0026, BUG-023 (whose fix made this live)

Summary by CodeRabbit

  • New Features

    • Added an API and command-line option to safely reclaim storage used by superseded index versions.
    • Cleanup reports the number of removed chunks and is safe to repeat.
    • Attempts to delete the currently active version are refused.
  • Documentation

    • Clarified the reindex workflow, including manual version switching, restart requirements, cleanup ordering, and storage implications.
    • Added guidance to verify that reindexing produced chunks before completing the process.

…the live one

REQ-064 asks for "cleanup of old version afterwards". Nothing implemented it:
reindex wrote a second copy of every chunk under the target version and left
both in the store forever, while reindex.py's docstring told the operator it
could "trigger cleanup of old-version chunks". There was nothing to trigger.
The gap became live only when BUG-023 was fixed: until reindex actually wrote,
there was never an old version to clean up.

VectorStoreProvider gains delete_index_version(namespace, index_version),
implemented by Qdrant and pgvector and exposed as
DELETE /api/v1/index-versions/{version} (admin scope).

The guard sits in the providers, not the endpoint. A store is the only
component that knows which version it reads, so it raises
ActiveIndexVersionError (409) rather than trusting each caller to check first.
The failure mode this exists for is not "an old version survives", it is "the
live index is emptied": the hand-written delete it replaces has no idea which
version is live and is run at the moment the operator is least sure.

scripts/reindex.sh --cleanup OLD_VERSION completes the lifecycle as a second
run. It cannot be one run: the switch sits between reindex and cleanup, and
before it the API correctly refuses.

Namespace binding is enforced as on DELETE /documents/{id} (403 on mismatch,
not a silent retarget): this deletes by filter, so a retargeted namespace would
drop an entire version of a namespace the caller never named.

Verified on the dev stack (Qdrant): reindexing `default` to v2 doubled it
12 -> 24 points, the switch left live search byte-identical, cleanup reclaimed
exactly the 12 old points, deleting the *active* version was refused with 409,
and the six other namespaces (all at version 1) lost nothing.

Refs: REQ-064, ARCH-045, ADR-0026, DEBT-032
@fvadicamo fvadicamo added documentation Improvements or additions to documentation enhancement New feature or request component:shared vektra-shared component component:index vektra-index component labels Jul 14, 2026
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@fvadicamo, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0f32b5a0-cd2d-483b-936b-be58c7b726a5

📥 Commits

Reviewing files that changed from the base of the PR and between 6918584 and af1312f.

⛔ Files ignored due to path filters (1)
  • .s2s/BACKLOG.md is excluded by !.s2s/**
📒 Files selected for processing (7)
  • CHANGELOG.md
  • docs/reference/api.md
  • docs/reference/error-codes.md
  • scripts/reindex.sh
  • vektra-index/src/vektra_index/reindex.py
  • vektra-index/tests/test_index_version_cleanup.py
  • vektra-shared/src/vektra_shared/errors.py
📝 Walkthrough

Walkthrough

Adds guarded deletion of superseded index versions through storage providers, an admin API endpoint, CLI cleanup mode, tests, and updated reindex documentation.

Changes

Index version cleanup

Layer / File(s) Summary
Cleanup contract and provider implementations
vektra-shared/src/vektra_shared/errors.py, vektra-shared/src/vektra_shared/protocols.py, vektra-index/src/vektra_index/providers/*, vektra-index/src/vektra_index/adapters.py, vektra-index/tests/test_index_version_cleanup.py
Defines delete_index_version, rejects active-version deletion, scopes removal by namespace and version, and tests provider behavior.
Admin cleanup endpoint
vektra-index/src/vektra_index/reindex.py, vektra-index/tests/test_index_version_cleanup.py
Adds DELETE /api/v1/index-versions/{index_version} with admin authorization, version validation, namespace binding, 409 conflict handling, and response details.
CLI reindex and cleanup flow
scripts/reindex.sh
Adds --cleanup and dry-run modes, parses cleanup results, rejects zero-chunk reindexes, and prints ordered follow-up instructions.
Operator documentation
docs/reference/api.md, vektra-index/README.md, vektra-index/src/vektra_index/reindex.py
Documents manual activation, cleanup ordering, endpoint behavior, idempotency, and the chunks_reindexed check.

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

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant reindex.sh
  participant IndexVersionAPI
  participant VectorStoreProvider
  Operator->>reindex.sh: Run reindex or --cleanup
  reindex.sh->>IndexVersionAPI: DELETE superseded index version
  IndexVersionAPI->>VectorStoreProvider: delete_index_version(namespace, version)
  VectorStoreProvider-->>IndexVersionAPI: chunks_removed or active-version error
  IndexVersionAPI-->>reindex.sh: HTTP response
  reindex.sh-->>Operator: Cleanup result and next-step guidance
Loading

Possibly related PRs

Suggested labels: component:admin

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.16% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: cleanup of superseded index versions with protection against deleting the live one.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/debt-032-index-version-cleanup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements the cleanup of superseded index versions (DEBT-032 / REQ-064) by adding a delete_index_version method to the vector store providers (Qdrant and pgvector) and exposing it via a new admin endpoint DELETE /api/v1/index-versions/{index_version}. The providers correctly refuse to delete the active index version being served, raising an ActiveIndexVersionError (409). Additionally, scripts/reindex.sh has been updated with a --cleanup option, and comprehensive unit tests have been added. The review feedback correctly identifies that the new endpoint raises bare HTTPExceptions with string details, violating the repository style guide which mandates using ErrorResponse.to_envelope() for the detail field.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread vektra-index/src/vektra_index/reindex.py Outdated
Comment thread vektra-index/src/vektra_index/reindex.py Outdated

@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

🧹 Nitpick comments (3)
docs/reference/api.md (1)

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

Use 'afterward' instead of 'afterwards'.

As indicated by static analysis, "afterward" is the preferred variant in American English.

📝 Proposed fix
-The switch is manual: there is **no** API to activate a version, it is an env var plus a restart. The cleanup afterwards is an API call, `DELETE /api/v1/index-versions/{version}` (see [Step 5](`#step-5-clean-up-the-old-version`)), which refuses to delete whichever version is currently being served.
+The switch is manual: there is **no** API to activate a version, it is an env var plus a restart. The cleanup afterward is an API call, `DELETE /api/v1/index-versions/{version}` (see [Step 5](`#step-5-clean-up-the-old-version`)), which refuses to delete whichever version is currently being served.
🤖 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/reference/api.md` at line 944, Update the documentation sentence in the
version-switching procedure to use the American English adverb “afterward”
instead of “afterwards,” without changing the surrounding API or cleanup
instructions.

Source: Linters/SAST tools

vektra-index/src/vektra_index/reindex.py (1)

433-433: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Safely retrieve the provider registry and vector store.

request.app.state.registry may raise an AttributeError if it is not set, and registry.get() may return None if the provider isn't registered. Similar to the existing trigger_reindex function, safely retrieve these objects and raise an explicit 500 error if they are missing to prevent unhandled exception tracebacks.

🛡️ Proposed fixes for safe retrieval
-    vector_store = request.app.state.registry.get("vector_store", "default")
+    registry = getattr(request.app.state, "registry", None)
+    if registry is None:
+        raise HTTPException(status_code=500, detail="ProviderRegistry is not configured")
+    
+    vector_store = registry.get("vector_store", "default")
+    if vector_store is None:
+        raise HTTPException(status_code=500, detail="No vector store registered")
🤖 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 `@vektra-index/src/vektra_index/reindex.py` at line 433, Update the code around
the vector_store retrieval to safely access request.app.state.registry and
handle a missing registry or unregistered vector store. Follow the existing
trigger_reindex function’s retrieval pattern, and raise an explicit HTTP 500
error when either dependency is unavailable instead of allowing AttributeError
or None to propagate.
vektra-index/tests/test_index_version_cleanup.py (1)

160-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pgvector test coverage gap: no namespace-scoping or idempotent-cleanup assertion.

Unlike TestQdrantDeleteIndexVersion, which asserts the delete filter pins both namespace_id and index_version (via _selector_conditions) and has a dedicated idempotent second-cleanup-returns-zero test, TestPgvectorDeleteIndexVersion only checks session.execute.await_count == 2 and doesn't verify the WHERE clause scoping or add a parity test for the "second call returns 0" behavior called out in the PR objectives. Given namespace isolation is the core safety invariant this feature depends on, the pgvector suite should mirror that coverage.

♻️ Suggested additions
     `@pytest.mark.asyncio`
     async def test_refuses_the_active_version_and_deletes_nothing(self) -> None:
         from vektra_index.providers.pgvector import PgvectorProvider

         session = self._make_session()
         provider = PgvectorProvider(active_index_version=1)

         with pytest.raises(ActiveIndexVersionError):
             await provider.delete_index_version(session, "default", 1)

         session.execute.assert_not_awaited()
+
+    `@pytest.mark.asyncio`
+    async def test_second_cleanup_reports_nothing_left(self) -> None:
+        from vektra_index.providers.pgvector import PgvectorProvider
+
+        session = self._make_session(count=0)
+        provider = PgvectorProvider(active_index_version=2)
+
+        assert await provider.delete_index_version(session, "default", 1) == 0

Verifying the WHERE clause pins namespace_id (not just index_version) would require inspecting the compiled statement passed to session.execute, since the fake session here doesn't capture the SQL construct the way the Qdrant fake captures the filter object.

🤖 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 `@vektra-index/tests/test_index_version_cleanup.py` around lines 160 - 193,
Expand TestPgvectorDeleteIndexVersion to inspect the compiled statements passed
to session.execute and assert the delete WHERE clause scopes both namespace_id
and index_version, matching the existing _selector_conditions coverage in
TestQdrantDeleteIndexVersion. Add an idempotent cleanup test that performs a
second deletion and verifies it returns 0, while preserving the active-version
refusal behavior.

Source: Path instructions

🤖 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 `@scripts/reindex.sh`:
- Line 125: In scripts/reindex.sh at lines 125-125 and 191-197, replace direct
Python dictionary-key access with explicit JSON parsing and contract validation
for chunks_removed, chunks_reindexed, and source_index_version. Validate that
the response is valid JSON, each required field exists and has the expected
value type, and fail loudly with a clear field-specific error message when
validation fails; apply the same handling at both sites.

---

Nitpick comments:
In `@docs/reference/api.md`:
- Line 944: Update the documentation sentence in the version-switching procedure
to use the American English adverb “afterward” instead of “afterwards,” without
changing the surrounding API or cleanup instructions.

In `@vektra-index/src/vektra_index/reindex.py`:
- Line 433: Update the code around the vector_store retrieval to safely access
request.app.state.registry and handle a missing registry or unregistered vector
store. Follow the existing trigger_reindex function’s retrieval pattern, and
raise an explicit HTTP 500 error when either dependency is unavailable instead
of allowing AttributeError or None to propagate.

In `@vektra-index/tests/test_index_version_cleanup.py`:
- Around line 160-193: Expand TestPgvectorDeleteIndexVersion to inspect the
compiled statements passed to session.execute and assert the delete WHERE clause
scopes both namespace_id and index_version, matching the existing
_selector_conditions coverage in TestQdrantDeleteIndexVersion. Add an idempotent
cleanup test that performs a second deletion and verifies it returns 0, while
preserving the active-version refusal behavior.
🪄 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

Run ID: 3c4b76c1-1849-4112-abe1-34268017dfda

📥 Commits

Reviewing files that changed from the base of the PR and between 61cb8f3 and 6918584.

⛔ Files ignored due to path filters (1)
  • .s2s/BACKLOG.md is excluded by !.s2s/**
📒 Files selected for processing (10)
  • docs/reference/api.md
  • scripts/reindex.sh
  • vektra-index/README.md
  • vektra-index/src/vektra_index/adapters.py
  • vektra-index/src/vektra_index/providers/pgvector.py
  • vektra-index/src/vektra_index/providers/qdrant.py
  • vektra-index/src/vektra_index/reindex.py
  • vektra-index/tests/test_index_version_cleanup.py
  • vektra-shared/src/vektra_shared/errors.py
  • vektra-shared/src/vektra_shared/protocols.py

Comment thread scripts/reindex.sh
… (review)

Gemini flagged that the 400 and 409 raised bare HTTPException strings while the
repo style guide requires ErrorResponse.to_envelope(). It was right about the
smell: the endpoint was internally inconsistent (its 403 already used the
envelope, via _namespace_scope_violation) and, being new code, it was adding to
DEBT-033 rather than staying clear of it.

It was wrong about the fix. Its suggestion constructed ERR-INDEX-001/002 inline,
and those codes did not exist: not in the errors.py registry, not in the status
map, not in error-codes.md. Registering them is the actual work.

- ERR-INDEX-001 (409): refused, that version is the one being served
- ERR-INDEX-002 (400): index version below 1

Both are declared in vektra_shared.errors with their status overrides, and
documented in docs/reference/error-codes.md. The 409 carries the refused
namespace and index_version in details: an operator tool has to tell "wrong
version, nothing happened" apart from "the store is down", and a bare string
does not let it.

DEBT-033's scope shrinks accordingly: this endpoint is already enveloped, so
what remains is the pre-existing set (POST /reindex, GET status, conversations,
admin conversation turns). Noted in its backlog entry.

scripts/reindex.sh now prints the refusal message out of the envelope instead of
dumping the raw JSON, falling back to the raw body if the shape is unexpected
rather than dying on a KeyError and hiding why the delete was refused.

Verified against the live stack: the 409 and 400 return the documented envelope,
and the script renders the refusal as one readable line.

Refs: #108, REQ-010, REQ-011, DEBT-032, DEBT-033
@fvadicamo fvadicamo merged commit da7208b into develop Jul 14, 2026
23 checks passed
@fvadicamo fvadicamo deleted the feat/debt-032-index-version-cleanup branch July 14, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component:index vektra-index component component:shared vektra-shared component documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant