Run image decoding and resizing off the event loop #75

Merged
ryan merged 1 commits from perf/offload-image-processing into main 2026-10-12 23:06:41 -04:00
3 changed files with 23 additions and 5 deletions
+6 -4
View File
@@ -257,7 +257,9 @@ async def upload_image():
content_type = file.content_type or "image/jpeg" content_type = file.content_type or "image/jpeg"
try: try:
processed_bytes, output_content_type = process_image(file_bytes, content_type) processed_bytes, output_content_type = await asyncio.to_thread(
process_image, file_bytes, content_type
)
except ImageValidationError as e: except ImageValidationError as e:
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
@@ -311,8 +313,8 @@ async def upload_attachment():
if content_type in IMAGE_TYPES: if content_type in IMAGE_TYPES:
try: try:
processed_bytes, output_content_type = process_image( processed_bytes, output_content_type = await asyncio.to_thread(
file_bytes, content_type process_image, file_bytes, content_type
) )
except Exception as e: except Exception as e:
return jsonify({"error": f"Could not process image: {e}"}), 400 return jsonify({"error": f"Could not process image: {e}"}), 400
@@ -402,7 +404,7 @@ async def stream_query():
if image_key: if image_key:
try: try:
image_bytes, _ = await s3_get_image(image_key) image_bytes, _ = await s3_get_image(image_key)
image_block = image_content_block(image_bytes) image_block = await asyncio.to_thread(image_content_block, image_bytes)
except Exception as e: except Exception as e:
logging.error(f"Failed to load image {image_key}: {e}") logging.error(f"Failed to load image {image_key}: {e}")
query_text = f"{query_text or ''}\n\n[An image was sent but could not be loaded.]".strip() query_text = f"{query_text or ''}\n\n[An image was sent but could not be loaded.]".strip()
+1 -1
View File
@@ -327,7 +327,7 @@ async def _process_and_reply(
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(media_url, timeout=30) resp = await client.get(media_url, timeout=30)
resp.raise_for_status() resp.raise_for_status()
image_block = image_content_block(resp.content) image_block = await asyncio.to_thread(image_content_block, resp.content)
except Exception as e: except Exception as e:
logger.error(f"Failed to load iMessage image: {e}") logger.error(f"Failed to load iMessage image: {e}")
content = f"{content or ''}\n\n[An image was sent but could not be loaded.]".strip() content = f"{content or ''}\n\n[An image was sent but could not be loaded.]".strip()
+16
View File
@@ -1,6 +1,7 @@
"""Tests for conversation file attachments: extraction, /upload and stream-query file_key.""" """Tests for conversation file attachments: extraction, /upload and stream-query file_key."""
import io import io
import threading
from datetime import timedelta from datetime import timedelta
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -190,6 +191,21 @@ class TestUploadRoute:
s3.upload_image.assert_awaited_once() s3.upload_image.assert_awaited_once()
s3.upload_file.assert_not_awaited() s3.upload_file.assert_not_awaited()
async def test_image_processing_runs_off_the_event_loop(
self, app, headers, s3, monkeypatch
):
threads = []
real = conv.process_image
def recording(*args):
threads.append(threading.current_thread())
return real(*args)
monkeypatch.setattr(conv, "process_image", recording)
resp = await _upload(app, headers, _png_bytes(), "cat.png", "image/png")
assert resp.status_code == 200
assert threads and threads[0] is not threading.main_thread()
async def test_unsupported_type_is_415(self, app, headers, s3): async def test_unsupported_type_is_415(self, app, headers, s3):
resp = await _upload(app, headers, b"PK\x03\x04", "a.zip", "application/zip") resp = await _upload(app, headers, b"PK\x03\x04", "a.zip", "application/zip")
assert resp.status_code == 415 assert resp.status_code == 415