-
Notifications
You must be signed in to change notification settings - Fork 35
⚡ Bolt: optimize field officer image upload performance #726
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| """ | ||
|
|
||
| from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Response | ||
| from fastapi.concurrency import run_in_threadpool | ||
| from sqlalchemy.orm import Session | ||
| from sqlalchemy import func, case | ||
| from typing import List, Optional | ||
|
|
@@ -34,6 +35,7 @@ | |
| ) | ||
| from backend.cache import visit_last_hash_cache, visit_stats_cache | ||
| from backend.schemas import BlockchainVerificationResponse | ||
| from backend.utils import process_uploaded_image, save_processed_image | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -278,7 +280,8 @@ async def upload_visit_images( | |
| - **visit_id**: ID of the visit | ||
| - **images**: List of image files | ||
|
|
||
| Maximum 10 images per visit | ||
| Maximum 10 images per visit. | ||
| Optimized: Uses single-pass image processing (resize/strip EXIF) and non-blocking I/O. | ||
| """ | ||
| try: | ||
| visit = db.query(FieldOfficerVisit).filter(FieldOfficerVisit.id == visit_id).first() | ||
|
|
@@ -303,42 +306,41 @@ async def upload_visit_images( | |
| image_paths = [] | ||
|
|
||
| for idx, image in enumerate(images): | ||
| # Validate content_type is present | ||
| if not image.content_type: | ||
| raise HTTPException(status_code=400, detail="File must have a content type") | ||
| # Performance optimization: Use unified image processing pipeline | ||
| # This handles validation, resizing (1024px), and EXIF stripping in one pass. | ||
|
|
||
| # Validate file type | ||
| if not image.content_type.startswith('image/'): | ||
| raise HTTPException(status_code=400, detail=f"File must be an image, got {image.content_type}") | ||
|
|
||
| # Validate filename is present | ||
| # 1. Fast-fail: Validate filename and extension | ||
| if not image.filename: | ||
| raise HTTPException(status_code=400, detail="File must have a filename") | ||
|
|
||
| # Validate extension | ||
| extension = image.filename.split('.')[-1].lower() if '.' in image.filename else '' | ||
|
|
||
| extension = image.filename.split('.')[-1].lower() if '.' in image.filename else 'jpg' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extensionless files silently pass the extension check and may produce a format/extension mismatch. When 🐛 Proposed fix- extension = image.filename.split('.')[-1].lower() if '.' in image.filename else 'jpg'
- if extension not in ALLOWED_IMAGE_EXTENSIONS:
- raise HTTPException(
- status_code=400,
- detail=f"File extension '{extension}' not allowed."
- )
+ if '.' not in image.filename:
+ raise HTTPException(
+ status_code=400,
+ detail="File must have a valid image extension."
+ )
+ extension = image.filename.rsplit('.', 1)[-1].lower()
+ if extension not in ALLOWED_IMAGE_EXTENSIONS:
+ raise HTTPException(
+ status_code=400,
+ detail=f"File extension '{extension}' not allowed."
+ )🤖 Prompt for AI Agents |
||
| if extension not in ALLOWED_IMAGE_EXTENSIONS: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=f"File extension '{extension}' not allowed. Allowed: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}" | ||
| detail=f"File extension '{extension}' not allowed." | ||
|
Comment on lines
+316
to
+320
|
||
| ) | ||
|
Comment on lines
317
to
321
|
||
|
|
||
| # Read and validate file size | ||
| content = await image.read() | ||
| if len(content) > MAX_UPLOAD_SIZE: | ||
| raise HTTPException( | ||
|
|
||
| # 2. Fast-fail: Validate file size (10MB limit for field officer visits) | ||
| # Must check explicitly because process_uploaded_image uses a 20MB default. | ||
| image.file.seek(0, 2) | ||
| size = image.file.tell() | ||
| image.file.seek(0) | ||
|
Comment on lines
+325
to
+327
Comment on lines
+325
to
+327
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find and read the relevant file section
fd "field_officer.py" backend --exec wc -l {} \;Repository: RohanExploit/VishwaGuru Length of output: 104 🏁 Script executed: # Read the actual code around lines 325-327
sed -n '310,340p' backend/routers/field_officer.pyRepository: RohanExploit/VishwaGuru Length of output: 1575 🏁 Script executed: # Check for the bolt.md documentation
fd "bolt.md" -o find . -name "bolt.md" -type fRepository: RohanExploit/VishwaGuru Length of output: 195 🏁 Script executed: # Search for process_uploaded_image_sync
rg "process_uploaded_image_sync" -A 10 -B 2Repository: RohanExploit/VishwaGuru Length of output: 1404 🏁 Script executed: # Verify UploadFile API and understand the implementation
rg "class.*UploadFile" --type python -A 15Repository: RohanExploit/VishwaGuru Length of output: 98 🏁 Script executed: # Check the entire context of the endpoint to understand the flow
sed -n '280,350p' backend/routers/field_officer.pyRepository: RohanExploit/VishwaGuru Length of output: 3193 🏁 Script executed: # Search for bolt.md file
find . -type f -name "*bolt*"Repository: RohanExploit/VishwaGuru Length of output: 84 🏁 Script executed: # Check for constants like MAX_UPLOAD_SIZE and related imports
rg "MAX_UPLOAD_SIZE|MAX_FILE_SIZE" -B 2 -A 2Repository: RohanExploit/VishwaGuru Length of output: 3362 🏁 Script executed: # Look at imports in field_officer.py to understand the context
head -50 backend/routers/field_officer.pyRepository: RohanExploit/VishwaGuru Length of output: 1714 🏁 Script executed: # Verify the HTTP status code for payload too large (413)
rg "413|payload.*large" -iRepository: RohanExploit/VishwaGuru Length of output: 33936 🏁 Script executed: # Read bolt.md to verify the documented pattern
cat ./.jules/bolt.mdRepository: RohanExploit/VishwaGuru Length of output: 12072 🏁 Script executed: # Check the full context of process_uploaded_image
sed -n '1,100p' backend/utils.py | head -50Repository: RohanExploit/VishwaGuru Length of output: 1291 🏁 Script executed: # Look at how UploadFile is used and check if it has a .size attribute
rg "UploadFile" -B 2 -A 10 | head -80Repository: RohanExploit/VishwaGuru Length of output: 4427 🏁 Script executed: # Verify if UploadFile has a .size attribute by checking FastAPI versions and documentation
rg "\.size" backend --type python -B 2 -A 2 | grep -i uploadRepository: RohanExploit/VishwaGuru Length of output: 98 🏁 Script executed: # Check if there are any uses of UploadFile.size in the codebase
rg "\.size\s*=|image\.size|file\.size" backend -A 2 -B 2 | head -50Repository: RohanExploit/VishwaGuru Length of output: 1077 🏁 Script executed: # Verify the endpoint is async and confirm the seeking happens directly without threadpool
sed -n '280,340p' backend/routers/field_officer.py | grep -E "async|def|await|run_in_threadpool|seek|tell"Repository: RohanExploit/VishwaGuru Length of output: 322 🏁 Script executed: # Check FastAPI documentation references and how UploadFile.size is used
rg "UploadFile.size|file.size" . --type-list -A 1 -B 1 | head -40Repository: RohanExploit/VishwaGuru Length of output: 457 🏁 Script executed: # Verify if there are any imports of UploadFile in the field_officer.py file
head -30 backend/routers/field_officer.py | grep -i uploadRepository: RohanExploit/VishwaGuru Length of output: 184 🏁 Script executed: # Check the exact function signature of process_uploaded_image
rg "async def process_uploaded_image" -A 5Repository: RohanExploit/VishwaGuru Length of output: 426 Synchronous
Note also that The cleanest fixes (in order of preference):
Also use HTTP status 413 (Payload Too Large) instead of 400, consistent with RFC 7231 and this project's own ♻️ Option 1 — use UploadFile.size with threadpool fallback- # 2. Fast-fail: Validate file size (10MB limit for field officer visits)
- # Must check explicitly because process_uploaded_image uses a 20MB default.
- image.file.seek(0, 2)
- size = image.file.tell()
- image.file.seek(0)
- if size > MAX_UPLOAD_SIZE:
- raise HTTPException(
- status_code=400,
- detail=f"File exceeds maximum size of {MAX_UPLOAD_SIZE / 1024 / 1024:.1f} MB"
- )
+ # 2. Fast-fail: Validate file size (10MB limit for field officer visits)
+ # Must check explicitly because process_uploaded_image uses a 20MB default.
+ # Use UploadFile.size when available; otherwise measure in threadpool to avoid
+ # blocking the event loop with a synchronous seek on a disk-spooled temp file.
+ size = image.size
+ if size is None:
+ def _measure_size(f):
+ f.seek(0, 2); s = f.tell(); f.seek(0); return s
+ size = await run_in_threadpool(_measure_size, image.file)
+ if size > MAX_UPLOAD_SIZE:
+ raise HTTPException(
+ status_code=413,
+ detail=f"File exceeds maximum size of {MAX_UPLOAD_SIZE / 1024 / 1024:.1f} MB"
+ )🤖 Prompt for AI Agents |
||
| if size > MAX_UPLOAD_SIZE: | ||
| raise HTTPException( | ||
|
|
||
| status_code=400, | ||
| detail=f"File {image.filename} exceeds maximum size of {MAX_UPLOAD_SIZE / 1024 / 1024:.1f} MB" | ||
| detail=f"File exceeds maximum size of {MAX_UPLOAD_SIZE / 1024 / 1024:.1f} MB" | ||
| ) | ||
|
Comment on lines
+329
to
332
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use
🐛 Proposed fix- raise HTTPException(
- status_code=400,
- detail=f"File exceeds maximum size of {MAX_UPLOAD_SIZE / 1024 / 1024:.1f} MB"
+ raise HTTPException(
+ status_code=413,
+ detail=f"File exceeds maximum size of {MAX_UPLOAD_SIZE / 1024 / 1024:.1f} MB"
)🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| # 3. Process image (decode, resize, strip, encode) | ||
| _, image_bytes = await process_uploaded_image(image) | ||
|
|
||
| # Generate secure filename | ||
| timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') | ||
| safe_filename = f"visit_{visit_id}_{timestamp}_{idx}.{extension}" | ||
| file_path = os.path.join(VISIT_IMAGES_DIR, safe_filename) | ||
|
|
||
| # Save file | ||
| with open(file_path, 'wb') as f: | ||
| f.write(content) | ||
| # Save file using threadpool to avoid blocking the main event loop | ||
| await run_in_threadpool(save_processed_image, image_bytes, file_path) | ||
|
|
||
| # Store relative path | ||
| relative_path = os.path.join("data", "visit_images", safe_filename) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Defaulting files without an extension to
.jpgcan store non-JPEG bytes under a JPEG filename, causing incorrect content-type handling for uploaded visit images.Prompt for AI agents