diff --git a/.jules/bolt.md b/.jules/bolt.md index 58ed3de2..191736f9 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -73,3 +73,7 @@ ## 2026-04-17 - ORM Counting vs func.count().scalar() **Learning:** Using `db.query(Model).filter(...).count()` can be slower and have more ORM overhead than `db.query(func.count(Model.id)).filter(...).scalar() or 0` or doing an early `.first()` exit. **Action:** When counting records or verifying existence, prefer early `.first()` exits combined with `func.count().scalar()` for performance in high-traffic APIs. + +## 2026-04-20 - Async File I/O in Voice Submission +**Learning:** Saving audio recordings (up to 10MB) synchronously in a FastAPI async endpoint blocks the main event loop, significantly increasing tail latency for all concurrent users during high-traffic periods. +**Action:** Wrap blocking synchronous File I/O operations like `f.write()` in `run_in_threadpool` to offload them to a separate thread, keeping the event loop responsive for other requests. diff --git a/backend/routers/voice.py b/backend/routers/voice.py index 5ec6e385..a908f614 100644 --- a/backend/routers/voice.py +++ b/backend/routers/voice.py @@ -11,10 +11,12 @@ import logging import os import uuid +import hashlib from datetime import datetime, timezone from backend.database import get_db from backend.models import Issue +from backend.cache import blockchain_last_hash_cache from backend.schemas import ( VoiceTranscriptionResponse, TextTranslationRequest, @@ -248,12 +250,44 @@ async def submit_voice_issue( audio_filename = f"{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex}{file_extension}" audio_file_path = os.path.join(AUDIO_STORAGE_DIR, audio_filename) - with open(audio_file_path, 'wb') as f: - f.write(audio_content) - - # Store relative path for portability - relative_audio_path = os.path.join("data", "audio_recordings", audio_filename) + # Performance optimization: Wrap blocking synchronous File I/O in threadpool + def _save_audio_file(): + with open(audio_file_path, 'wb') as f: + f.write(audio_content) + + def _delete_audio_file_best_effort(): + try: + if os.path.exists(audio_file_path): + os.remove(audio_file_path) + except Exception as cleanup_error: + logger.warning("Failed to delete orphaned audio file %s: %s", audio_file_path, cleanup_error) + + await run_in_threadpool(_save_audio_file) + try: + # Store relative path for portability + relative_audio_path = os.path.join("data", "audio_recordings", audio_filename) + + # Blockchain feature: calculate integrity hash for the report + # Performance Boost: Use thread-safe cache to eliminate DB query for last hash + prev_hash = blockchain_last_hash_cache.get("last_hash") + if prev_hash is None: + # Cache miss: Fetch only the last hash from DB + prev_issue = await run_in_threadpool( + lambda: db.query(Issue.integrity_hash).order_by(Issue.id.desc()).first() + ) + prev_hash = prev_issue[0] if prev_issue and prev_issue[0] else "" + blockchain_last_hash_cache.set(data=prev_hash, key="last_hash") + + # Simple but effective SHA-256 chaining + # Format must match backend/routers/issues.py for a consistent chain + hash_content = f"{final_description}|{issue_category.value}|{prev_hash}" + except Exception: + db.rollback() + await run_in_threadpool(_delete_audio_file_best_effort) + raise + integrity_hash = hashlib.sha256(hash_content.encode()).hexdigest() + # Create issue in database reference_id = generate_reference_id() @@ -267,6 +301,8 @@ async def submit_voice_issue( location=location, source='voice', status='open', + integrity_hash=integrity_hash, + previous_integrity_hash=prev_hash, # Voice-specific fields submission_type='voice', original_language=voice_result.get('source_language'), @@ -280,6 +316,9 @@ async def submit_voice_issue( db.commit() db.refresh(new_issue) + # Update cache for next report AFTER successful DB commit + blockchain_last_hash_cache.set(data=integrity_hash, key="last_hash") + logger.info(f"Voice issue created: ID={new_issue.id}, Language={voice_result.get('source_language')}, Confidence={voice_result.get('confidence')}") return IssueCreateResponse(