Skip to content

Commit 2fa9600

Browse files
authored
🩺 fix: Concurrent Upload Isolation, MongoDB Connection Leaks, and Validation Crashes (#256)
* Fix race condition in embed_file_upload temp file path Concurrent uploads with the same filename would write to the same temp path because embed_file_upload used the filename directly under RAG_UPLOAD_DIR, unlike embed_file which namespaces under user_id. Two users uploading "report.pdf" simultaneously would corrupt each other's data. Add user_id subdirectory to match embed_file's behavior. * Fix connection leak and event loop blocking in mongo_health_check Every health check call created a MongoClient, opened a connection pool, and never closed it. Since health checks run frequently, this leaked connections continuously. Also, MongoClient() and admin.command() are blocking I/O calls that were running directly in an async function, blocking the event loop. Fix both: wrap blocking calls in asyncio.to_thread() and always close the client in a finally block. * Fix validation_exception_handler crash and body echo request.body() re-reads the entire upload into memory inside the error handler, and body.decode() raises UnicodeDecodeError on binary/non-ASCII content, turning a 422 into a 500. The response also echoed potentially large or sensitive upload content back to the caller. Remove the body read entirely — the validation errors already contain all useful info. * Remove dead exception branch in generate_digest str.encode("utf-8") on a Python str never raises UnicodeEncodeError for any valid Unicode string. The except branch handled surrogate characters with an encode-decode-encode round-trip that produces the same result as encode("utf-8", "ignore") directly. Replace with a single expression. * Enable EMBEDDING_BATCH_SIZE by default (500) The batch processing pipeline is fully built but disabled by default (EMBEDDING_BATCH_SIZE=0). This means large documents embed all chunks in one blocking call. Set default to 500 to enable streaming embeddings into the DB while the rest are still being computed — the highest- leverage memory improvement with zero new code. * Stream CSV encoding conversion instead of reading entire file Non-UTF-8 CSV files were read entirely into a single Python string before writing to the temp file. For large files (e.g. 100MB UTF-16), this causes unnecessary peak memory allocation. Stream in 64KB chunks instead. * Close asyncpg connection pool on shutdown The pool was created during startup but never closed on shutdown. Rolling deploys or OOM-killed pods leave connections in idle state until TCP timeout, consuming PostgreSQL's finite connection limit. Close the pool cleanly in the lifespan teardown. * Address review findings: unique temp paths, guarded shutdown, tests Finding 1: Wrap close_pool() in try/except so an exception doesn't skip thread_pool.shutdown(). Finding 2+5: Use UUID-suffixed temp filenames via _make_unique_temp_path in all three upload endpoints (embed, embed-upload, text). This prevents same-user concurrent upload collisions (not just cross-user). Also moves save_upload_file_async inside the try/finally block so temp files are cleaned up even when the save itself fails. Finding 3+6: Restore tuning guidance comment for EMBEDDING_BATCH_SIZE explaining the default of 500 and when to increase. Finding 4: Add tests for _make_unique_temp_path (uniqueness, user isolation, extension preservation, traversal rejection) and generate_digest (normal strings, unicode, surrogates, determinism). * Clean up remaining nits from review - Drop redundant second validate_file_path call in _make_unique_temp_path; the UUID suffix is [0-9a-f]{32} with no path separators, so it cannot escape the already-validated directory. Resolve the path directly. - Fold os.makedirs into the try/finally block in all three upload endpoints so an OSError (permissions, disk full) is caught by the existing error handler instead of propagating as an unhandled 500. - Fix hardcoded Unix separator in test assertion; use Path.parent.name instead of string containment check.
1 parent d8f640d commit 2fa9600

7 files changed

Lines changed: 149 additions & 53 deletions

File tree

‎app/config.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,11 @@ def get_env_variable(
7777
# Trade-offs:
7878
# - Smaller batch size = lower memory, more DB round trips
7979
# - Larger batch size = higher memory, fewer DB round trips
80-
# - 0 = disable batching, process all at once (original behavior)
80+
# - 0 = disable batching, process all at once
8181
#
82-
# Recommended: 750 for text-embedding-3-small (good balance of speed and memory)
83-
EMBEDDING_BATCH_SIZE = int(get_env_variable("EMBEDDING_BATCH_SIZE", "0"))
82+
# Default of 500 is conservative and works well for most embedding providers.
83+
# Increase to 750 for higher throughput at the cost of higher peak memory.
84+
EMBEDDING_BATCH_SIZE = int(get_env_variable("EMBEDDING_BATCH_SIZE", "500"))
8485

8586
# Maximum number of batches to buffer in memory during async processing.
8687
# Higher values allow more parallelism but use more memory.

‎app/routes/document_routes.py‎

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# app/routes/document_routes.py
22
import os
3+
import uuid
34
from pathlib import Path
45
import hashlib
56
import traceback
@@ -123,6 +124,20 @@ def validate_file_path(base_dir: str, file_path: str) -> Optional[str]:
123124
return None
124125

125126

127+
def _make_unique_temp_path(user_id: str, filename: str) -> Optional[str]:
128+
"""Build a unique temp file path under RAG_UPLOAD_DIR/{user_id}/ to prevent
129+
concurrent upload collisions. Returns a validated absolute path, or None if
130+
the raw filename would escape RAG_UPLOAD_DIR (path traversal rejection)."""
131+
# Validate the raw filename to reject traversal attempts
132+
if validate_file_path(RAG_UPLOAD_DIR, os.path.join(user_id, filename)) is None:
133+
return None
134+
# unique_name is stem + "_" + [0-9a-f]{32} + suffix — no path separators,
135+
# so it cannot escape the directory validated above.
136+
p = Path(filename)
137+
unique_name = f"{p.stem}_{uuid.uuid4().hex}{p.suffix}"
138+
return str(Path(RAG_UPLOAD_DIR, user_id, unique_name).resolve())
139+
140+
126141
async def load_file_content(
127142
filename: str, content_type: str, file_path: str, executor
128143
) -> tuple:
@@ -612,14 +627,8 @@ async def _process_documents_batched_sync(
612627
return all_ids
613628

614629

615-
def generate_digest(page_content: str):
616-
try:
617-
hash_obj = hashlib.md5(page_content.encode("utf-8"))
618-
except UnicodeEncodeError:
619-
hash_obj = hashlib.md5(
620-
page_content.encode("utf-8", "ignore").decode("utf-8").encode("utf-8")
621-
)
622-
return hash_obj.hexdigest()
630+
def generate_digest(page_content: str) -> str:
631+
return hashlib.md5(page_content.encode("utf-8", "ignore")).hexdigest()
623632

624633

625634
def _prepare_documents_sync(
@@ -793,9 +802,7 @@ async def embed_file(
793802
known_type = None
794803

795804
user_id = get_user_id(request, entity_id)
796-
validated_file_path = validate_file_path(
797-
RAG_UPLOAD_DIR, os.path.join(user_id, file.filename)
798-
)
805+
validated_file_path = _make_unique_temp_path(user_id, file.filename)
799806

800807
if validated_file_path is None:
801808
logger.warning("Path validation failed for embed: %s", file.filename)
@@ -804,11 +811,9 @@ async def embed_file(
804811
detail=ERROR_MESSAGES.DEFAULT("Invalid request"),
805812
)
806813

807-
os.makedirs(os.path.dirname(validated_file_path), exist_ok=True)
808-
809-
await save_upload_file_async(file, validated_file_path)
810-
811814
try:
815+
os.makedirs(os.path.dirname(validated_file_path), exist_ok=True)
816+
await save_upload_file_async(file, validated_file_path)
812817
data, known_type, file_ext = await load_file_content(
813818
file.filename,
814819
file.content_type,
@@ -931,9 +936,7 @@ async def embed_file_upload(
931936
):
932937
user_id = get_user_id(request, entity_id)
933938

934-
validated_temp_file_path = validate_file_path(
935-
RAG_UPLOAD_DIR, uploaded_file.filename
936-
)
939+
validated_temp_file_path = _make_unique_temp_path(user_id, uploaded_file.filename)
937940

938941
if validated_temp_file_path is None:
939942
logger.warning(
@@ -944,9 +947,9 @@ async def embed_file_upload(
944947
detail=ERROR_MESSAGES.DEFAULT("Invalid request"),
945948
)
946949

947-
await save_upload_file_async(uploaded_file, validated_temp_file_path)
948-
949950
try:
951+
os.makedirs(os.path.dirname(validated_temp_file_path), exist_ok=True)
952+
await save_upload_file_async(uploaded_file, validated_temp_file_path)
950953
data, known_type, file_ext = await load_file_content(
951954
uploaded_file.filename,
952955
uploaded_file.content_type,
@@ -1053,9 +1056,7 @@ async def extract_text_from_file(
10531056
Returns the raw text content for text parsing purposes.
10541057
"""
10551058
user_id = get_user_id(request, entity_id)
1056-
validated_temp_file_path = validate_file_path(
1057-
RAG_UPLOAD_DIR, os.path.join(user_id, file.filename)
1058-
)
1059+
validated_temp_file_path = _make_unique_temp_path(user_id, file.filename)
10591060

10601061
if validated_temp_file_path is None:
10611062
logger.warning("Path validation failed for text extraction: %s", file.filename)
@@ -1064,12 +1065,9 @@ async def extract_text_from_file(
10641065
detail=ERROR_MESSAGES.DEFAULT("Invalid request"),
10651066
)
10661067

1067-
# create base directory only if the file path is valid
1068-
os.makedirs(os.path.dirname(validated_temp_file_path), exist_ok=True)
1069-
1070-
await save_upload_file_async(file, validated_temp_file_path)
1071-
10721068
try:
1069+
os.makedirs(os.path.dirname(validated_temp_file_path), exist_ok=True)
1070+
await save_upload_file_async(file, validated_temp_file_path)
10731071
data, known_type, file_ext = await load_file_content(
10741072
file.filename,
10751073
file.content_type,
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
# app/services/mongo_client.py
2+
import asyncio
23
import logging
34
from pymongo import MongoClient
45
from pymongo.errors import PyMongoError
56
from app.config import ATLAS_MONGO_DB_URI
67

78
logger = logging.getLogger(__name__)
89

10+
911
async def mongo_health_check() -> bool:
12+
client = None
1013
try:
11-
client = MongoClient(ATLAS_MONGO_DB_URI)
12-
client.admin.command("ping")
14+
client = await asyncio.to_thread(MongoClient, ATLAS_MONGO_DB_URI)
15+
await asyncio.to_thread(client.admin.command, "ping")
1316
return True
1417
except PyMongoError as e:
15-
logger.error(f"MongoDB health check failed: {e}")
16-
return False
18+
logger.error("MongoDB health check failed: %s", e)
19+
return False
20+
finally:
21+
if client is not None:
22+
try:
23+
await asyncio.to_thread(client.close)
24+
except Exception as e:
25+
logger.debug("Failed to close health check client: %s", e)

‎app/utils/document_loader.py‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,29 +82,27 @@ def get_loader(filename: str, file_content_type: str, filepath: str):
8282
encoding = detect_file_encoding(filepath)
8383

8484
if encoding != "utf-8":
85-
# For non-UTF-8 encodings, we need to convert the file first
86-
# Create a temporary UTF-8 file
85+
# For non-UTF-8 encodings, convert to UTF-8 using streaming
86+
# to avoid holding the entire file in memory as a single string
8787
temp_file = None
8888
try:
8989
with tempfile.NamedTemporaryFile(
9090
mode="w", encoding="utf-8", suffix=".csv", delete=False
9191
) as temp_file:
92-
# Read the original file with detected encoding
9392
with open(
9493
filepath, "r", encoding=encoding, errors="replace"
9594
) as original_file:
96-
content = original_file.read()
97-
temp_file.write(content)
95+
while True:
96+
chunk = original_file.read(64 * 1024)
97+
if not chunk:
98+
break
99+
temp_file.write(chunk)
98100

99101
temp_filepath = temp_file.name
100102

101-
# Use the temporary UTF-8 file with CSVLoader
102103
loader = CSVLoader(temp_filepath)
103-
104-
# Store the temp file path for cleanup
105104
loader._temp_filepath = temp_filepath
106105
except Exception as e:
107-
# If temp file was created but there was an error, clean it up
108106
if temp_file and os.path.exists(temp_file.name):
109107
os.unlink(temp_file.name)
110108
raise e

‎docker-compose.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ services:
1515
environment:
1616
- DB_HOST=db
1717
- DB_PORT=5432
18+
- EMBEDDING_BATCH_SIZE=${EMBEDDING_BATCH_SIZE:-500}
1819
ports:
1920
- "8000:8000"
2021
volumes:

‎main.py‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ async def lifespan(app: FastAPI):
4747
yield
4848

4949
# Cleanup logic
50+
if VECTOR_DB_TYPE == VectorDBType.PGVECTOR:
51+
try:
52+
logger.info("Closing asyncpg connection pool")
53+
await PSQLDatabase.close_pool()
54+
logger.info("asyncpg connection pool closed")
55+
except Exception as e:
56+
logger.warning("Failed to close asyncpg pool: %s", e)
57+
5058
logger.info("Shutting down thread pool")
5159
app.state.thread_pool.shutdown(wait=True)
5260
logger.info("Thread pool shutdown complete")
@@ -79,17 +87,10 @@ async def lifespan(app: FastAPI):
7987

8088
@app.exception_handler(RequestValidationError)
8189
async def validation_exception_handler(request: Request, exc: RequestValidationError):
82-
body = await request.body()
83-
logger.debug(f"Validation error occurred")
84-
logger.debug(f"Raw request body: {body.decode()}")
85-
logger.debug(f"Validation errors: {exc.errors()}")
90+
logger.debug("Validation error: %s", exc.errors())
8691
return JSONResponse(
8792
status_code=422,
88-
content={
89-
"detail": exc.errors(),
90-
"body": body.decode(),
91-
"message": "Request validation failed",
92-
},
93+
content={"detail": exc.errors(), "message": "Request validation failed"},
9394
)
9495

9596

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Tests for upload temp file isolation and generate_digest correctness.
3+
4+
Validates:
5+
- _make_unique_temp_path produces unique paths per call (no concurrent collisions)
6+
- _make_unique_temp_path isolates users into separate subdirectories
7+
- _make_unique_temp_path rejects path traversal filenames
8+
- generate_digest is consistent for all string inputs including surrogates
9+
"""
10+
11+
import hashlib
12+
import os
13+
from pathlib import Path
14+
import pytest
15+
16+
from app.routes.document_routes import _make_unique_temp_path, generate_digest
17+
18+
19+
class TestMakeUniqueTempPath:
20+
"""Ensure temp file paths are unique and user-isolated."""
21+
22+
def test_two_calls_same_filename_produce_different_paths(
23+
self, monkeypatch, tmp_path
24+
):
25+
monkeypatch.setattr("app.routes.document_routes.RAG_UPLOAD_DIR", str(tmp_path))
26+
path_a = _make_unique_temp_path("user1", "report.pdf")
27+
path_b = _make_unique_temp_path("user1", "report.pdf")
28+
assert path_a != path_b, "Same user+filename must produce unique paths"
29+
30+
def test_different_users_produce_different_directories(self, monkeypatch, tmp_path):
31+
monkeypatch.setattr("app.routes.document_routes.RAG_UPLOAD_DIR", str(tmp_path))
32+
path_a = _make_unique_temp_path("user1", "report.pdf")
33+
path_b = _make_unique_temp_path("user2", "report.pdf")
34+
assert os.path.dirname(path_a) != os.path.dirname(path_b)
35+
assert Path(path_a).parent.name == "user1"
36+
assert Path(path_b).parent.name == "user2"
37+
38+
def test_preserves_file_extension(self, monkeypatch, tmp_path):
39+
monkeypatch.setattr("app.routes.document_routes.RAG_UPLOAD_DIR", str(tmp_path))
40+
path = _make_unique_temp_path("user1", "data.csv")
41+
assert path.endswith(".csv")
42+
43+
def test_path_stays_within_upload_dir(self, monkeypatch, tmp_path):
44+
monkeypatch.setattr("app.routes.document_routes.RAG_UPLOAD_DIR", str(tmp_path))
45+
path = _make_unique_temp_path("user1", "file.txt")
46+
assert path.startswith(str(tmp_path))
47+
48+
@pytest.mark.parametrize(
49+
"malicious_filename",
50+
[
51+
"../../etc/passwd",
52+
"../../../etc/shadow",
53+
"/etc/passwd",
54+
],
55+
)
56+
def test_rejects_path_traversal(self, monkeypatch, tmp_path, malicious_filename):
57+
monkeypatch.setattr("app.routes.document_routes.RAG_UPLOAD_DIR", str(tmp_path))
58+
result = _make_unique_temp_path("user1", malicious_filename)
59+
assert result is None
60+
61+
62+
class TestGenerateDigest:
63+
"""Ensure generate_digest is correct for all inputs."""
64+
65+
def test_normal_string(self):
66+
content = "hello world"
67+
expected = hashlib.md5(content.encode("utf-8")).hexdigest()
68+
assert generate_digest(content) == expected
69+
70+
def test_empty_string(self):
71+
expected = hashlib.md5(b"").hexdigest()
72+
assert generate_digest("") == expected
73+
74+
def test_unicode_content(self):
75+
content = "café résumé naïve"
76+
expected = hashlib.md5(content.encode("utf-8")).hexdigest()
77+
assert generate_digest(content) == expected
78+
79+
def test_surrogate_characters(self):
80+
"""Surrogate chars are stripped by encode('utf-8', 'ignore')."""
81+
content = "hello\ud800world"
82+
expected = hashlib.md5(content.encode("utf-8", "ignore")).hexdigest()
83+
assert generate_digest(content) == expected
84+
assert len(generate_digest(content)) == 32
85+
86+
def test_deterministic(self):
87+
content = "same input"
88+
assert generate_digest(content) == generate_digest(content)

0 commit comments

Comments
 (0)