Skip to content

Commit 5c65021

Browse files
committed
fix(backend): atomic check-and-set for indexing status to prevent TOCTOU race
- Add try_set_indexing_status() to supabase_service using .neq() filter - Add try_set_indexing() wrapper to repo_manager - Replace non-atomic status check in /index/async with atomic method - Returns 409 if repo already indexing, prevents duplicate indexing jobs - Error handler already sets status to 'error' so repo won't stay stuck
1 parent 4acb709 commit 5c65021

3 files changed

Lines changed: 31 additions & 9 deletions

File tree

backend/routes/repos.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -347,13 +347,6 @@ async def index_repository_async(
347347
try:
348348
repo = get_repo_or_404(repo_id, user_id)
349349

350-
# Check if already indexing
351-
if repo.get("status") == "indexing":
352-
raise HTTPException(
353-
status_code=409,
354-
detail="Repository is already being indexed"
355-
)
356-
357350
# Re-check size limits
358351
analysis = repo_validator.analyze_repo(repo["local_path"])
359352

@@ -382,8 +375,13 @@ async def index_repository_async(
382375
}
383376
)
384377

385-
# Mark as indexing immediately
386-
repo_manager.update_status(repo_id, "indexing")
378+
# Atomic check-and-set: only set 'indexing' if not already indexing
379+
# This prevents TOCTOU race where two requests both see status != 'indexing'
380+
if not repo_manager.try_set_indexing(repo_id):
381+
raise HTTPException(
382+
status_code=409,
383+
detail="Repository is already being indexed"
384+
)
387385

388386
# Schedule background task
389387
background_tasks.add_task(

backend/services/repo_manager.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ def update_status(self, repo_id: str, status: str):
135135
"""Update repository status"""
136136
self.db.update_repository_status(repo_id, status)
137137

138+
def try_set_indexing(self, repo_id: str) -> bool:
139+
"""
140+
Atomically set status to 'indexing' only if not already indexing.
141+
142+
Returns True if status was set, False if already indexing.
143+
Use this instead of checking status then updating to prevent race conditions.
144+
"""
145+
return self.db.try_set_indexing_status(repo_id)
146+
138147
def update_file_count(self, repo_id: str, count: int):
139148
"""Update file count"""
140149
self.db.update_file_count(repo_id, count)

backend/services/supabase_service.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ def update_repository_status(self, repo_id: str, status: str) -> None:
9494
"""Update repository status"""
9595
self.client.table("repositories").update({"status": status}).eq("id", repo_id).execute()
9696

97+
def try_set_indexing_status(self, repo_id: str) -> bool:
98+
"""
99+
Atomically set status to 'indexing' only if not already indexing.
100+
101+
Returns True if status was set, False if repo was already indexing.
102+
This prevents TOCTOU race conditions where two requests could both
103+
see status != 'indexing' and both start indexing.
104+
"""
105+
result = self.client.table("repositories").update(
106+
{"status": "indexing"}
107+
).eq("id", repo_id).neq("status", "indexing").execute()
108+
109+
# If result.data is empty, no rows matched (already indexing)
110+
return bool(result.data)
111+
97112
def update_file_count(self, repo_id: str, count: int) -> None:
98113
"""Update repository file count"""
99114
self.client.table("repositories").update({"file_count": count}).eq("id", repo_id).execute()

0 commit comments

Comments
 (0)