Spaces:
Runtime error
Runtime error
fix(documents): serialize process stages with atomic update (#11)
Browse files- app/main.py +3 -6
- app/routers/documents.py +26 -21
- app/schemas.py +8 -10
- tests/test_concurrency_ingestion.py +43 -0
- tests/test_ingestion.py +1 -13
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()
|
| 104 |
|
| 105 |
-
# 4. Advance state machine
|
| 106 |
if doc.status == "pending":
|
| 107 |
-
|
|
|
|
|
|
|
| 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
|
| 127 |
))
|
| 128 |
|
| 129 |
-
doc.file_bytes = None
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|