Skip to content

Make indexing progress observable and stuck indexes recoverable - #8

Merged
ShreeBohara merged 1 commit into
mainfrom
feat/durable-indexing-progress
Aug 11, 2026
Merged

Make indexing progress observable and stuck indexes recoverable#8
ShreeBohara merged 1 commit into
mainfrom
feat/durable-indexing-progress

Conversation

@ShreeBohara

Copy link
Copy Markdown
Owner

Two bugs from the audit whose failure mode was silence, not an error.

1. Progress was write-only

IndexingService wrote progress into a per-instance dict. The SSE endpoint builds its own IndexingService per request, so it never saw those writes and always fell through to the database branch — which hardcodes current_step="Unknown" and a percentage of 0 or 100. The per-file progress the indexer computes could not reach any client.

A per-process dict wouldn't fix it either: indexing runs in a background task and, with more than one worker, in a different process from the reader.

core/progress.py adds a ProgressStore over a capped Redis stream per repository (progress:{repo_id}, MAXLEN 500) with a process-wide in-memory fallback. A stream rather than a key because the SSE endpoint wants history — a client connecting late can replay what it missed via XRANGE instead of seeing only the current value.

Proof it actually fixes the reported bug — a fresh service instance with an empty dict, which is exactly what the SSE route constructs:

reader instance _progress dict: {}
get_progress -> {'status': 'parsing', 'current_step': 'Parsing widget.tsx...',
                 'progress_percent': 57.5, 'files_processed': 23}

Publishing never raises. Progress is telemetry; a Redis blip must not fail a healthy index. There's a test using a Redis double that raises on every call, asserting the event still lands in memory.

2. A killed index was unrecoverable

Only FAILED and COMPLETED are terminal, and every self-healing path (repos.py, seed_demo.py) keys on FAILED — so a container killed during CLONING/PARSING/EMBEDDING left the row stuck forever with no way to retry through the API. In demo mode that bricked the deployment.

reap_stuck_indexing runs at startup, where anything still in a transient state provably has no live indexer, because the process that owned it is gone.

Two deliberate choices, both tested:

  • PENDING is left alone. A repository queued but not yet started is not stuck, and failing it would break the normal import path.
  • An existing indexing_error is preserved. A real failure reason ("rate limited by provider") beats the generic interruption note.

Also: the SSE route was unbounded

It looped forever with no client check and no ceiling, so a repository stuck mid-index held an open response and its database session indefinitely — once per browser tab. Now it checks request.is_disconnected(), emits only on change instead of re-sending an identical payload every second, and stops after 15 minutes with a reconnect hint.

Why Redis Streams and not Kafka

This is the durability primitive the tech-fit research recommended over Kafka, and the reasoning holds: Kafka's max.poll.interval.ms defaults to 5 minutes while index_repository is a single minutes-long call, so a slow index triggers a rebalance and indexes the repo twice. Streams give at-least-once delivery and XAUTOCLAIM-based recovery on the redis service already in docker-compose.yml — no new broker.

Verification

Gate Result
New tests 17
Full suite 159 passed, 0 failed (was 142)
ruff check src tests clean
Environment .env moved aside; OPENAI_API_KEY, AZURE_OPENAI_API_KEY, ANTHROPIC_API_KEY, REDIS_URL all unset

The Redis paths are exercised with fakeredis, which implements streams, consumer groups and XAUTOCLAIM — so unlike the Neo4j PR, this code is genuinely executed, not mocked.

🤖 Generated with Claude Code

Two bugs whose failure mode was silence, both from the audit.

1. PROGRESS WAS WRITE-ONLY
IndexingService wrote progress into a per-instance dict. The SSE endpoint builds its own
IndexingService per request, so it never saw those writes and always fell through to the
database branch, which hardcodes current_step="Unknown" and a percentage of 0 or 100. The
per-file progress the indexer computes could not reach any client. A per-process dict
would not fix it either: indexing runs in a background task and, with more than one
worker, in a different process from the reader.

core/progress.py adds a ProgressStore over a capped Redis stream per repository
(progress:{repo_id}, MAXLEN 500) with a process-wide in-memory fallback for the no-Redis
case. A stream rather than a key because the SSE endpoint wants history: a client that
connects late can replay what it missed via XRANGE instead of seeing only the current
value. get_progress now reads the shared store first, then the instance dict, then the
database.

Publishing never raises. Progress is telemetry, and a Redis blip must not fail an
otherwise healthy index, so a publish failure degrades to memory and the caller sees
nothing. There is a test with a Redis double that raises on every call.

2. A KILLED INDEX WAS UNRECOVERABLE
Only FAILED and COMPLETED are terminal, and every self-healing path (repos.py,
seed_demo.py) keys on FAILED -- so a container killed during CLONING, PARSING or EMBEDDING
left the row in that state forever with no way to retry through the API. In demo mode that
bricked the deployment. reap_stuck_indexing runs at startup, where anything still in a
transient state provably has no live indexer because the process that owned it is gone.
PENDING is deliberately left alone: a repository queued but not yet started is not stuck,
and failing it would break the normal import path. An existing indexing_error is
preserved, since a real failure reason beats the generic interruption note.

Also hardened the SSE route, which looped forever with no client check and no ceiling: a
repository stuck mid-index held an open response and its database session indefinitely,
once per browser tab. It now checks request.is_disconnected(), emits only on change
instead of re-sending an identical payload every second, and stops after 15 minutes with
a reconnect hint.

Verified: 17 new tests, 159 total (was 142), ruff clean, run with apps/api/.env moved
aside and OPENAI_API_KEY / AZURE_OPENAI_API_KEY / ANTHROPIC_API_KEY / REDIS_URL all unset.
The Redis paths are exercised with fakeredis, which implements streams, consumer groups
and XAUTOCLAIM, so they are genuinely executed rather than mocked. End-to-end check: a
fresh IndexingService with an empty _progress dict -- the exact object the SSE route
builds -- now returns 57.5% and "Parsing widget.tsx..." where it previously returned
"Unknown" at 0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
codebaseqa-web Ready Ready Preview Aug 11, 2026 6:28pm

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34c7490240

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"UPDATE repositories SET status = 'FAILED', "
"indexing_error = COALESCE(indexing_error, "
"'Indexing was interrupted before completing. Re-import to retry.') "
f"WHERE LOWER(status) IN ({placeholders})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cast the enum before lowercasing it

In deployments that use PostgreSQL for DATABASE_URL, SQLEnum(IndexingStatus) is a native enum column, and LOWER(status) is not defined for enum values. Because this reaper runs during FastAPI lifespan startup, the API will fail to boot as soon as it hits a database with any repositories table; cast the column to text or compare against enum literals without LOWER().

Useful? React with 👍 / 👎.

try:
from src.dependencies import get_progress_store

event = await get_progress_store().latest(repo_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check the DB before returning persisted progress

When Redis is enabled and the previous process dies mid-index, the Redis stream can still have parsing/embedding as its latest event while startup reaps the repository row to FAILED. Since this path returns the persisted progress before consulting the database, /api/repos/{id}/progress keeps reporting the stale in-progress state until timeout instead of the recoverable failure; clear/publish progress during reaping or reconcile the DB status before trusting the stream.

Useful? React with 👍 / 👎.

@ShreeBohara
ShreeBohara merged commit 263efa0 into main Aug 11, 2026
4 checks passed
@ShreeBohara
ShreeBohara deleted the feat/durable-indexing-progress branch August 11, 2026 19:58
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