RamshaAnwar commited on
Commit
cc78da3
·
unverified ·
1 Parent(s): b13c030

fix(documents): serialize process stages with atomic update (#11)

Browse files
app/main.py CHANGED
@@ -4,13 +4,10 @@ from fastapi import FastAPI, Request, Depends
4
  from fastapi.exceptions import RequestValidationError
5
  from starlette.exceptions import HTTPException as StarletteHTTPException
6
  from app.errors import AppError, error_response
7
- from app.routers import auth,hubs
8
  from app.deps import get_current_user
9
  from app.models import User
10
  from app.services.gemini import AiUnavailable
11
- from app.routers import auth, hubs, documents
12
-
13
-
14
 
15
  logger = logging.getLogger("uvicorn.error")
16
 
@@ -26,7 +23,6 @@ def create_app() -> FastAPI:
26
  app.include_router(hubs.router, prefix="/api/v1")
27
  app.include_router(documents.router, prefix="/api/v1")
28
 
29
-
30
  @app.middleware("http")
31
  async def catch_unhandled(request: Request, call_next):
32
  try:
@@ -66,7 +62,7 @@ def create_app() -> FastAPI:
66
  if exc.status_code == 405:
67
  return error_response(request, 405, "METHOD_NOT_ALLOWED", "Method not allowed for this route.")
68
  return error_response(request, exc.status_code, "HTTP_ERROR", str(exc.detail))
69
-
70
  AI_ERRORS = {
71
  "rate_limited": (429, "AI_RATE_LIMITED", "The AI provider's free quota is used up. Try again later."),
72
  "timeout": (504, "AI_TIMEOUT", "The AI provider took too long to answer."),
@@ -78,6 +74,7 @@ def create_app() -> FastAPI:
78
  status, code, message = AI_ERRORS[exc.kind]
79
  headers = {"Retry-After": "30"} if exc.kind == "rate_limited" else None
80
  return error_response(request, status, code, message, headers=headers)
 
81
  @app.get("/healthz", tags=["system"])
82
  def healthz() -> dict[str, str]:
83
  return {"status": "ok"}
 
4
  from fastapi.exceptions import RequestValidationError
5
  from starlette.exceptions import HTTPException as StarletteHTTPException
6
  from app.errors import AppError, error_response
7
+ from app.routers import auth, hubs, documents
8
  from app.deps import get_current_user
9
  from app.models import User
10
  from app.services.gemini import AiUnavailable
 
 
 
11
 
12
  logger = logging.getLogger("uvicorn.error")
13
 
 
23
  app.include_router(hubs.router, prefix="/api/v1")
24
  app.include_router(documents.router, prefix="/api/v1")
25
 
 
26
  @app.middleware("http")
27
  async def catch_unhandled(request: Request, call_next):
28
  try:
 
62
  if exc.status_code == 405:
63
  return error_response(request, 405, "METHOD_NOT_ALLOWED", "Method not allowed for this route.")
64
  return error_response(request, exc.status_code, "HTTP_ERROR", str(exc.detail))
65
+
66
  AI_ERRORS = {
67
  "rate_limited": (429, "AI_RATE_LIMITED", "The AI provider's free quota is used up. Try again later."),
68
  "timeout": (504, "AI_TIMEOUT", "The AI provider took too long to answer."),
 
74
  status, code, message = AI_ERRORS[exc.kind]
75
  headers = {"Retry-After": "30"} if exc.kind == "rate_limited" else None
76
  return error_response(request, status, code, message, headers=headers)
77
+
78
  @app.get("/healthz", tags=["system"])
79
  def healthz() -> dict[str, str]:
80
  return {"status": "ok"}
app/routers/documents.py CHANGED
@@ -27,40 +27,31 @@ async def upload_document(
27
  current_user: User = Depends(get_current_user),
28
  db: Session = Depends(get_db)
29
  ):
30
- # 1. Verify hub ownership
31
  hub = db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == current_user.id).first()
32
  if not hub:
33
  raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
34
 
35
- # 2. Validate MIME type and size
36
  allowed_mimes = ["application/pdf", "text/plain", "text/markdown"]
37
  if file.content_type not in allowed_mimes:
38
  raise AppError(415, "UNSUPPORTED_MIME_TYPE", "Only PDF, TXT, and MD are allowed.")
39
 
40
- # Read bytes (limit to 10MB)
41
  contents = await file.read()
42
  if len(contents) > 10 * 1024 * 1024:
43
  raise AppError(413, "DOCUMENT_TOO_LARGE", "File exceeds 10MB.")
44
 
45
- # 3. Calculate checksum
46
  checksum = hashlib.sha256(contents).hexdigest()
47
-
48
- # 4. Idempotency hash (hub_id + filename + size + checksum)
49
  hash_input = f"{hub_id}{file.filename}{len(contents)}{checksum}"
50
  req_hash = hashlib.sha256(hash_input.encode()).hexdigest()
51
 
52
- # 5. Claim key
53
  purge_stale(db, current_user.id)
54
  if not claim_key(db, current_user.id, key, req_hash, f"/api/v1/hubs/{hub_id}/documents"):
55
  return replay_or_conflict(db, current_user.id, key, req_hash)
56
 
57
- # 6. Check for duplicate upload
58
  existing = db.query(Document).filter(Document.hub_id == hub_id, Document.checksum_sha256 == checksum).first()
59
  if existing:
60
  release_claim(db, current_user.id, key)
61
  raise AppError(409, "DOCUMENT_ALREADY_EXISTS", "This file has already been uploaded to this hub.")
62
 
63
- # 7. Save document
64
  new_doc = Document(
65
  hub_id=hub_id, owner_id=current_user.id, origin="upload",
66
  filename=file.filename, mime_type=file.content_type, size_bytes=len(contents),
@@ -70,7 +61,6 @@ async def upload_document(
70
  db.commit()
71
  db.refresh(new_doc)
72
 
73
- # 8. Build response and complete key
74
  payload = DocumentResponse.model_validate(new_doc).model_dump(mode="json")
75
  complete_key(db, current_user.id, key, 202, payload)
76
  return payload
@@ -83,7 +73,6 @@ def process_document(
83
  current_user: User = Depends(get_current_user),
84
  db: Session = Depends(get_db)
85
  ):
86
- # 1. Fetch document with tenant isolation
87
  doc = db.query(Document).filter(
88
  Document.id == doc_id, Document.hub_id == hub_id, Document.owner_id == current_user.id
89
  ).first()
@@ -93,21 +82,31 @@ def process_document(
93
  if doc.status in ("ready", "failed"):
94
  raise AppError(409, "DOCUMENT_NOT_PROCESSABLE", "Document is already complete or failed.")
95
 
96
- # 2. Idempotency check: If the same key is used, return the current state without advancing
97
  if key and doc.process_claim and str(doc.process_claim) == str(key):
98
  return DocumentResponse.model_validate(doc)
99
 
100
- # 3. Claim the stage
101
  if key:
102
  doc.process_claim = key
103
- db.commit() # Commit the claim immediately so concurrent requests see it
104
 
105
- # 4. Advance state machine
106
  if doc.status == "pending":
107
- doc.status = "chunking"
 
 
108
  db.commit()
 
 
109
 
110
  elif doc.status == "chunking":
 
 
 
 
 
 
 
 
 
111
  try:
112
  if doc.mime_type == "application/pdf":
113
  reader = PdfReader(io.BytesIO(doc.file_bytes))
@@ -123,11 +122,10 @@ def process_document(
123
  db.add(Chunk(
124
  document_id=doc.id, hub_id=hub_id, owner_id=current_user.id,
125
  chunk_index=i, text=chunk_text_str, token_count=tokens,
126
- embedding=[0.0] * 768 # Placeholder
127
  ))
128
 
129
- doc.file_bytes = None # Delete raw bytes!
130
- doc.status = "embedding"
131
  db.commit()
132
  except Exception as e:
133
  doc.status = "failed"
@@ -135,6 +133,15 @@ def process_document(
135
  db.commit()
136
 
137
  elif doc.status == "embedding":
 
 
 
 
 
 
 
 
 
138
  chunks = db.query(Chunk).filter(Chunk.document_id == doc.id).order_by(Chunk.chunk_index).all()
139
  if not chunks:
140
  doc.status = "failed"
@@ -148,8 +155,6 @@ def process_document(
148
  for chunk, emb in zip(chunks, embeddings):
149
  chunk.embedding = emb
150
 
151
- doc.status = "ready"
152
- doc.process_claim = None # Clear claim on success
153
  db.commit()
154
  except Exception as e:
155
  doc.status = "failed"
 
27
  current_user: User = Depends(get_current_user),
28
  db: Session = Depends(get_db)
29
  ):
 
30
  hub = db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == current_user.id).first()
31
  if not hub:
32
  raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
33
 
 
34
  allowed_mimes = ["application/pdf", "text/plain", "text/markdown"]
35
  if file.content_type not in allowed_mimes:
36
  raise AppError(415, "UNSUPPORTED_MIME_TYPE", "Only PDF, TXT, and MD are allowed.")
37
 
 
38
  contents = await file.read()
39
  if len(contents) > 10 * 1024 * 1024:
40
  raise AppError(413, "DOCUMENT_TOO_LARGE", "File exceeds 10MB.")
41
 
 
42
  checksum = hashlib.sha256(contents).hexdigest()
 
 
43
  hash_input = f"{hub_id}{file.filename}{len(contents)}{checksum}"
44
  req_hash = hashlib.sha256(hash_input.encode()).hexdigest()
45
 
 
46
  purge_stale(db, current_user.id)
47
  if not claim_key(db, current_user.id, key, req_hash, f"/api/v1/hubs/{hub_id}/documents"):
48
  return replay_or_conflict(db, current_user.id, key, req_hash)
49
 
 
50
  existing = db.query(Document).filter(Document.hub_id == hub_id, Document.checksum_sha256 == checksum).first()
51
  if existing:
52
  release_claim(db, current_user.id, key)
53
  raise AppError(409, "DOCUMENT_ALREADY_EXISTS", "This file has already been uploaded to this hub.")
54
 
 
55
  new_doc = Document(
56
  hub_id=hub_id, owner_id=current_user.id, origin="upload",
57
  filename=file.filename, mime_type=file.content_type, size_bytes=len(contents),
 
61
  db.commit()
62
  db.refresh(new_doc)
63
 
 
64
  payload = DocumentResponse.model_validate(new_doc).model_dump(mode="json")
65
  complete_key(db, current_user.id, key, 202, payload)
66
  return payload
 
73
  current_user: User = Depends(get_current_user),
74
  db: Session = Depends(get_db)
75
  ):
 
76
  doc = db.query(Document).filter(
77
  Document.id == doc_id, Document.hub_id == hub_id, Document.owner_id == current_user.id
78
  ).first()
 
82
  if doc.status in ("ready", "failed"):
83
  raise AppError(409, "DOCUMENT_NOT_PROCESSABLE", "Document is already complete or failed.")
84
 
 
85
  if key and doc.process_claim and str(doc.process_claim) == str(key):
86
  return DocumentResponse.model_validate(doc)
87
 
 
88
  if key:
89
  doc.process_claim = key
90
+ db.commit()
91
 
 
92
  if doc.status == "pending":
93
+ rows = db.query(Document).filter(
94
+ Document.id == doc.id, Document.status == "pending"
95
+ ).update({"status": "chunking"}, synchronize_session=False)
96
  db.commit()
97
+ db.refresh(doc)
98
+ return DocumentResponse.model_validate(doc)
99
 
100
  elif doc.status == "chunking":
101
+ rows = db.query(Document).filter(
102
+ Document.id == doc.id, Document.status == "chunking"
103
+ ).update({"status": "embedding"}, synchronize_session=False)
104
+ db.commit()
105
+
106
+ if rows == 0:
107
+ db.refresh(doc)
108
+ return DocumentResponse.model_validate(doc)
109
+
110
  try:
111
  if doc.mime_type == "application/pdf":
112
  reader = PdfReader(io.BytesIO(doc.file_bytes))
 
122
  db.add(Chunk(
123
  document_id=doc.id, hub_id=hub_id, owner_id=current_user.id,
124
  chunk_index=i, text=chunk_text_str, token_count=tokens,
125
+ embedding=[0.0] * 768
126
  ))
127
 
128
+ doc.file_bytes = None
 
129
  db.commit()
130
  except Exception as e:
131
  doc.status = "failed"
 
133
  db.commit()
134
 
135
  elif doc.status == "embedding":
136
+ rows = db.query(Document).filter(
137
+ Document.id == doc.id, Document.status == "embedding"
138
+ ).update({"status": "ready"}, synchronize_session=False)
139
+ db.commit()
140
+
141
+ if rows == 0:
142
+ db.refresh(doc)
143
+ return DocumentResponse.model_validate(doc)
144
+
145
  chunks = db.query(Chunk).filter(Chunk.document_id == doc.id).order_by(Chunk.chunk_index).all()
146
  if not chunks:
147
  doc.status = "failed"
 
155
  for chunk, emb in zip(chunks, embeddings):
156
  chunk.embedding = emb
157
 
 
 
158
  db.commit()
159
  except Exception as e:
160
  doc.status = "failed"
app/schemas.py CHANGED
@@ -45,7 +45,12 @@ class ChangePasswordRequest(BaseModel):
45
  if len(v.encode("utf-8")) > 72:
46
  raise ValueError("must be at most 72 bytes when encoded as UTF-8")
47
  return v
48
- # ... (keep existing schemas) ...
 
 
 
 
 
49
 
50
  class DocumentSummary(BaseModel):
51
  model_config = ConfigDict(from_attributes=True)
@@ -68,14 +73,7 @@ class HubResponse(BaseModel):
68
  updated_at: datetime
69
 
70
  class HubDetail(HubResponse):
71
- documents: list[DocumentSummary] = []
72
-
73
- class HubCreate(BaseModel):
74
- model_config = ConfigDict(extra="forbid")
75
- name: str = Field(min_length=1, max_length=120)
76
- description: str | None = Field(default=None, max_length=500)
77
- content: str = Field(default="", max_length=20000)
78
- documents: list[DocumentSummary] = []
79
 
80
  class DocumentResponse(BaseModel):
81
  model_config = ConfigDict(from_attributes=True)
@@ -89,4 +87,4 @@ class DocumentResponse(BaseModel):
89
  error_message: str | None
90
  chunk_count: int = 0
91
  created_at: datetime
92
- updated_at: datetime
 
45
  if len(v.encode("utf-8")) > 72:
46
  raise ValueError("must be at most 72 bytes when encoded as UTF-8")
47
  return v
48
+
49
+ class HubCreate(BaseModel):
50
+ model_config = ConfigDict(extra="forbid")
51
+ name: str = Field(min_length=1, max_length=120)
52
+ description: str | None = Field(default=None, max_length=500)
53
+ content: str = Field(default="", max_length=20000)
54
 
55
  class DocumentSummary(BaseModel):
56
  model_config = ConfigDict(from_attributes=True)
 
73
  updated_at: datetime
74
 
75
  class HubDetail(HubResponse):
76
+ documents: list[DocumentSummary] = []
 
 
 
 
 
 
 
77
 
78
  class DocumentResponse(BaseModel):
79
  model_config = ConfigDict(from_attributes=True)
 
87
  error_message: str | None
88
  chunk_count: int = 0
89
  created_at: datetime
90
+ updated_at: datetime
tests/test_concurrency_ingestion.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import time
3
+ from uuid import uuid4
4
+ from unittest.mock import patch
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from fastapi.testclient import TestClient
7
+ from app.main import app
8
+ from app.models import Chunk
9
+ from app.chunking import chunk_text as original_chunk_text
10
+
11
+ def test_concurrent_process_locks(client, db_session):
12
+ client.post("/api/v1/auth/signup", json={"email": "race_doc@test.com", "password": "Password123"})
13
+ token = client.post("/api/v1/auth/login", json={"email": "race_doc@test.com", "password": "Password123"}).json()["access_token"]
14
+ headers = {"Authorization": f"Bearer {token}"}
15
+
16
+ hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "Race Hub"})
17
+ hub_id = hub_res.json()["id"]
18
+
19
+ files = {"file": ("test.txt", b"Test text for race condition.", "text/plain")}
20
+ upload_res = client.post(f"/api/v1/hubs/{hub_id}/documents", headers={**headers, "X-Idempotency-Key": str(uuid4())}, files=files)
21
+ doc_id = upload_res.json()["id"]
22
+
23
+ client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
24
+
25
+ def send_process(_):
26
+ with TestClient(app) as c:
27
+ return c.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
28
+
29
+ def slow_chunk_text(*args, **kwargs):
30
+ time.sleep(0.5)
31
+ return original_chunk_text(*args, **kwargs)
32
+
33
+ with patch("app.routers.documents.chunk_text", side_effect=slow_chunk_text) as mock_chunk:
34
+ with ThreadPoolExecutor(max_workers=2) as executor:
35
+ future1 = executor.submit(send_process, 1)
36
+ future2 = executor.submit(send_process, 2)
37
+ r1 = future1.result()
38
+ r2 = future2.result()
39
+
40
+ assert r1.status_code == 200
41
+ assert r2.status_code == 200
42
+ assert mock_chunk.call_count == 1
43
+ assert db_session.query(Chunk).count() > 0
tests/test_ingestion.py CHANGED
@@ -4,17 +4,14 @@ from unittest.mock import patch
4
  from app.models import Document, Chunk
5
 
6
  def test_upload_and_process_pdf(client, db_session):
7
- # Setup user and hub
8
  client.post("/api/v1/auth/signup", json={"email": "doc@test.com", "password": "Password123"})
9
  token = client.post("/api/v1/auth/login", json={"email": "doc@test.com", "password": "Password123"}).json()["access_token"]
10
  headers = {"Authorization": f"Bearer {token}"}
11
 
12
- # Create a hub
13
  key = str(uuid4())
14
  hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": key}, json={"name": "PDF Hub"})
15
  hub_id = hub_res.json()["id"]
16
 
17
- # Upload a fake text file
18
  file_content = b"This is a test document. It has multiple sentences to chunk."
19
  upload_key = str(uuid4())
20
  files = {"file": ("test.txt", file_content, "text/plain")}
@@ -24,24 +21,19 @@ def test_upload_and_process_pdf(client, db_session):
24
  doc_id = upload_res.json()["id"]
25
  assert upload_res.json()["status"] == "pending"
26
 
27
- # Process Stage 1 (chunking)
28
  with patch("app.routers.documents.embed_texts", return_value=[[0.1]*768]):
29
  res1 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
30
  assert res1.json()["status"] == "chunking"
31
 
32
- # Process Stage 2 (embedding)
33
  res2 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
34
  assert res2.json()["status"] == "embedding"
35
 
36
- # Process Stage 3 (ready)
37
  res3 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
38
  assert res3.json()["status"] == "ready"
39
 
40
- # Verify chunks exist in DB
41
  assert db_session.query(Chunk).count() > 0
42
 
43
  def test_process_is_idempotent(client, db_session):
44
- # Setup user and hub
45
  client.post("/api/v1/auth/signup", json={"email": "idem_doc@test.com", "password": "Password123"})
46
  token = client.post("/api/v1/auth/login", json={"email": "idem_doc@test.com", "password": "Password123"}).json()["access_token"]
47
  headers = {"Authorization": f"Bearer {token}"}
@@ -56,16 +48,12 @@ def test_process_is_idempotent(client, db_session):
56
  key1 = str(uuid4())
57
 
58
  with patch("app.routers.documents.embed_texts", return_value=[[0.1]*768]):
59
- # 1. Advance to chunking
60
  res1 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers={**headers, "X-Idempotency-Key": key1})
61
  assert res1.json()["status"] == "chunking"
62
 
63
- # 2. Retry the exact same request with key1
64
  res1_retry = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers={**headers, "X-Idempotency-Key": key1})
65
- # It should return "chunking", NOT "embedding"!
66
  assert res1_retry.json()["status"] == "chunking"
67
 
68
- # 3. Advance to embedding with a NEW key
69
  key2 = str(uuid4())
70
  res2 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers={**headers, "X-Idempotency-Key": key2})
71
- assert res2.json()["status"] == "embedding"
 
4
  from app.models import Document, Chunk
5
 
6
  def test_upload_and_process_pdf(client, db_session):
 
7
  client.post("/api/v1/auth/signup", json={"email": "doc@test.com", "password": "Password123"})
8
  token = client.post("/api/v1/auth/login", json={"email": "doc@test.com", "password": "Password123"}).json()["access_token"]
9
  headers = {"Authorization": f"Bearer {token}"}
10
 
 
11
  key = str(uuid4())
12
  hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": key}, json={"name": "PDF Hub"})
13
  hub_id = hub_res.json()["id"]
14
 
 
15
  file_content = b"This is a test document. It has multiple sentences to chunk."
16
  upload_key = str(uuid4())
17
  files = {"file": ("test.txt", file_content, "text/plain")}
 
21
  doc_id = upload_res.json()["id"]
22
  assert upload_res.json()["status"] == "pending"
23
 
 
24
  with patch("app.routers.documents.embed_texts", return_value=[[0.1]*768]):
25
  res1 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
26
  assert res1.json()["status"] == "chunking"
27
 
 
28
  res2 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
29
  assert res2.json()["status"] == "embedding"
30
 
 
31
  res3 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
32
  assert res3.json()["status"] == "ready"
33
 
 
34
  assert db_session.query(Chunk).count() > 0
35
 
36
  def test_process_is_idempotent(client, db_session):
 
37
  client.post("/api/v1/auth/signup", json={"email": "idem_doc@test.com", "password": "Password123"})
38
  token = client.post("/api/v1/auth/login", json={"email": "idem_doc@test.com", "password": "Password123"}).json()["access_token"]
39
  headers = {"Authorization": f"Bearer {token}"}
 
48
  key1 = str(uuid4())
49
 
50
  with patch("app.routers.documents.embed_texts", return_value=[[0.1]*768]):
 
51
  res1 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers={**headers, "X-Idempotency-Key": key1})
52
  assert res1.json()["status"] == "chunking"
53
 
 
54
  res1_retry = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers={**headers, "X-Idempotency-Key": key1})
 
55
  assert res1_retry.json()["status"] == "chunking"
56
 
 
57
  key2 = str(uuid4())
58
  res2 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers={**headers, "X-Idempotency-Key": key2})
59
+ assert res2.json()["status"] == "embedding"