diff --git a/.jules/bolt.md b/.jules/bolt.md index 1d2f017a..e4d0a7f3 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -63,3 +63,6 @@ ## 2024-07-30 - Avoid chained string replace when checking character sets **Learning:** Using chained `.replace(a, "").replace(b, "")` to check if a string consists entirely of specific characters requires intermediate string allocations for every call. In benchmarks, using `.strip("ab")` is ~30% faster and avoids multiple allocations in the hot path. **Action:** When checking if a string is solely composed of specific characters, use `.strip(chars)` instead of chained `.replace()` calls to improve performance. +## 2026-07-16 - [FastAPI Upload Chunk Size Optimization] +**Learning:** In FastAPI, using the default 8KB chunk size for `await file.read()` when handling large uploads (e.g. 20MB) causes excessive asynchronous loop iterations, leading to high CPU overhead and slow upload speeds due to SpooledTemporaryFile underlying mechanics. +**Action:** For endpoints that accept large file uploads, increase the chunk size (e.g., to 1MB) in the `file.read(chunk_size)` loop to drastically reduce iteration overhead while maintaining bounded memory usage. diff --git a/src/newsdom_api/main.py b/src/newsdom_api/main.py index 4efdad56..25732270 100644 --- a/src/newsdom_api/main.py +++ b/src/newsdom_api/main.py @@ -36,6 +36,7 @@ from .service import parse_pdf MAX_PARSE_UPLOAD_BYTES = 20 * 1024 * 1024 +UPLOAD_READ_CHUNK_BYTES = 1024 * 1024 UNSUPPORTED_MEDIA_DETAIL = "Unsupported Media Type" PAYLOAD_TOO_LARGE_DETAIL = "Payload Too Large" INVALID_PARSE_PARAMS_DETAIL = "Invalid parse parameters" @@ -250,7 +251,8 @@ async def parse( tmp.write(header) bytes_read = len(header) - while chunk := await file.read(8192): + # ⚡ Bolt: Increase chunk size to 1MB to significantly reduce async loop iteration overhead + while chunk := await file.read(UPLOAD_READ_CHUNK_BYTES): bytes_read += len(chunk) if bytes_read > MAX_PARSE_UPLOAD_BYTES: LOGGER.warning( diff --git a/tests/test_parse_endpoint.py b/tests/test_parse_endpoint.py index 1491ada0..882a872e 100644 --- a/tests/test_parse_endpoint.py +++ b/tests/test_parse_endpoint.py @@ -9,6 +9,7 @@ from newsdom_api import mineru_runner from newsdom_api.main import ( MAX_PARSE_UPLOAD_BYTES, + UPLOAD_READ_CHUNK_BYTES, app, parse, _validate_pdf_structure, @@ -555,3 +556,16 @@ def spy_unlink(self, missing_ok=False): # We should have unlinked exactly one file, which should be in the temp directory assert len(unlinked_paths) == 1 assert "tmp" in unlinked_paths[0].lower() or "temp" in unlinked_paths[0].lower() + + +@pytest.mark.asyncio +async def test_parse_endpoint_reads_with_correct_chunk_size(monkeypatch): + upload = _ReadTrackingUpload(b"%PDF-" + (b"x" * 1024 * 1024)) + monkeypatch.setattr("newsdom_api.main.parse_pdf", lambda *args, **kwargs: None) + monkeypatch.setattr("newsdom_api.main._validate_pdf_structure", lambda _: None) + + await parse(upload) + + assert len(upload.read_sizes) >= 2 + assert upload.read_sizes[0] == 5 + assert upload.read_sizes[1] == UPLOAD_READ_CHUNK_BYTES