⚡ [performance improvement] Async annotations read#172
Conversation
Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com>
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
Reviewer's guide (collapsed on small PRs)Reviewer's GuideThis PR replaces the synchronous annotations file reader with an asynchronous implementation using aiofiles and asyncio.to_thread, and updates the save_annotations route to use the new async reader while preserving caching and error-handling behavior. Sequence diagram for async annotations read in save_annotations routesequenceDiagram
participant Client
participant save_annotations
participant _read_annotations_async
participant aiofiles
participant asyncio_to_thread
Client->>save_annotations: POST /dashboard/save-annotations
save_annotations->>save_annotations: [ann_path exists]
save_annotations->>_read_annotations_async: await _read_annotations_async(path)
_read_annotations_async->>asyncio_to_thread: asyncio.to_thread(os.path.getmtime, path)
_read_annotations_async->>_read_annotations_async: [cache miss or mtime changed]
_read_annotations_async->>aiofiles: aiofiles.open(path, "r")
_read_annotations_async->>aiofiles: await f.read()
aiofiles-->>_read_annotations_async: content
_read_annotations_async->>asyncio_to_thread: asyncio.to_thread(json.loads, content)
asyncio_to_thread-->>_read_annotations_async: data
_read_annotations_async->>_read_annotations_async: update _annotations_cache
_read_annotations_async->>asyncio_to_thread: asyncio.to_thread(copy.deepcopy, cached_data)
asyncio_to_thread-->>_read_annotations_async: deep_copied_data
_read_annotations_async-->>save_annotations: existing annotations
save_annotations-->>Client: Response (annotations saved)
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Warning Review limit reached
Next review available in: 13 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
Hey - I've left some high level feedback:
- Given this is now a fully async path, consider protecting
_annotations_cacheupdates (e.g., with a per-path lock) to avoid subtle race conditions when multiple coroutines read/update the same file concurrently. - You might want to keep
_read_annotations_asyncfocused purely on I/O and move thecopy.deepcopycall to the caller, to avoid an extraasyncio.to_threadhop when the deep copy is not always needed or could be batched.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Given this is now a fully async path, consider protecting `_annotations_cache` updates (e.g., with a per-path lock) to avoid subtle race conditions when multiple coroutines read/update the same file concurrently.
- You might want to keep `_read_annotations_async` focused purely on I/O and move the `copy.deepcopy` call to the caller, to avoid an extra `asyncio.to_thread` hop when the deep copy is not always needed or could be batched.Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
|
waiting to trigger gemini review |
Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com>
|
@gemini review |
|
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Code Review
This pull request refactors the synchronous annotation reading logic into an asynchronous function using aiofiles and asyncio.to_thread. The review feedback suggests optimizing this implementation by combining the file reading and JSON parsing into a single synchronous function executed via asyncio.to_thread to minimize thread-switching overhead. Consequently, the newly added aiofiles import and the unused re import should be removed.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| import os | ||
| import aiofiles | ||
| import re |
| async def _read_annotations_async(path) -> dict: | ||
| import copy | ||
|
|
||
| # Do not swallow OSError if file doesn't exist to preserve original behavior. | ||
| # The caller (save_annotations) handles the exception when reading existing annotations. | ||
| current_mtime = os.path.getmtime(path) | ||
| current_mtime = await asyncio.to_thread(os.path.getmtime, path) | ||
|
|
||
| cache_entry = _annotations_cache.get(path) | ||
|
|
||
| if cache_entry is None or current_mtime != cache_entry["mtime"]: | ||
| with open(path, "r", encoding="utf-8") as f: | ||
| data = json.load(f) | ||
| async with aiofiles.open(path, "r", encoding="utf-8") as f: | ||
| # Read asynchronously, but parse in a thread pool to avoid blocking event loop | ||
| content = await f.read() | ||
| data = await asyncio.to_thread(json.loads, content) | ||
| _annotations_cache[path] = {"mtime": current_mtime, "data": data} | ||
|
|
||
| return copy.deepcopy(_annotations_cache[path]["data"]) | ||
| return await asyncio.to_thread(copy.deepcopy, _annotations_cache[path]["data"]) |
There was a problem hiding this comment.
The current implementation of _read_annotations_async uses aiofiles and multiple asyncio.to_thread calls, which introduces significant overhead due to multiple thread pool context switches (thread hops) and aiofiles' internal design (which dispatches every file operation to a thread pool individually).
We can optimize this by performing the file reading and JSON parsing together in a single synchronous function executed via asyncio.to_thread. This reduces the number of thread pool tasks from 3 to 1 and allows us to use json.load directly on the file stream, which is more memory-efficient than reading the entire file into memory as a string first.
| async def _read_annotations_async(path) -> dict: | |
| import copy | |
| # Do not swallow OSError if file doesn't exist to preserve original behavior. | |
| # The caller (save_annotations) handles the exception when reading existing annotations. | |
| current_mtime = os.path.getmtime(path) | |
| current_mtime = await asyncio.to_thread(os.path.getmtime, path) | |
| cache_entry = _annotations_cache.get(path) | |
| if cache_entry is None or current_mtime != cache_entry["mtime"]: | |
| with open(path, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| async with aiofiles.open(path, "r", encoding="utf-8") as f: | |
| # Read asynchronously, but parse in a thread pool to avoid blocking event loop | |
| content = await f.read() | |
| data = await asyncio.to_thread(json.loads, content) | |
| _annotations_cache[path] = {"mtime": current_mtime, "data": data} | |
| return copy.deepcopy(_annotations_cache[path]["data"]) | |
| return await asyncio.to_thread(copy.deepcopy, _annotations_cache[path]["data"]) | |
| async def _read_annotations_async(path) -> dict: | |
| import copy | |
| # Do not swallow OSError if file doesn't exist to preserve original behavior. | |
| # The caller (save_annotations) handles the exception when reading existing annotations. | |
| current_mtime = await asyncio.to_thread(os.path.getmtime, path) | |
| cache_entry = _annotations_cache.get(path) | |
| if cache_entry is None or current_mtime != cache_entry["mtime"]: | |
| def _load_json(): | |
| with open(path, "r", encoding="utf-8") as f: | |
| return json.load(f) | |
| data = await asyncio.to_thread(_load_json) | |
| _annotations_cache[path] = {"mtime": current_mtime, "data": data} | |
| return await asyncio.to_thread(copy.deepcopy, _annotations_cache[path]["data"]) |
Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com>
|
Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com>
|
Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com>
|
Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com>
|
Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com>
|
Co-authored-by: sheepdestroyer <1377479+sheepdestroyer@users.noreply.github.com>
…811104 # Conflicts: # host_agy_daemon.py # pod.yaml # router/main.py # router/tests/test_load_persisted_stats.py # router/tests/test_memory_mcp.py # start-stack.sh # test_read_annotations_sync.py
|
@gemini review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Code Review
This pull request refactors the synchronous annotation reading mechanism in router/main.py to be asynchronous using aiofiles and asyncio.to_thread, and updates the corresponding unit tests. Feedback on the changes highlights a path resolution issue in the test file that causes a ModuleNotFoundError, as well as a testing bug where mocking aiofiles.open with MagicMock instead of AsyncMock will lead to a TypeError when awaiting __aenter__.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| import sys | ||
| import os | ||
| import json | ||
| import asyncio | ||
|
|
||
| # Ensure the root directory is in the path | ||
| sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) |
There was a problem hiding this comment.
Since the test file has been moved from tests/test_read_annotations_sync.py to the root directory as test_read_annotations_async.py, the path manipulation os.path.join(os.path.dirname(__file__), "..") now points to the parent directory of the repository root instead of the root directory itself. This will cause import router.main to fail with a ModuleNotFoundError in environments where the root directory is not already in the Python path.
| sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) | |
| sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) |
There was a problem hiding this comment.
This is wrong, tests must be in test/
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
…tps://github.com/sheepdestroyer/LLM-Routing into chore/perf-async-annotations-4499495305587811104 # Conflicts: # tests/test_read_annotations_async.py
💡 What:
Replaced the synchronous
_read_annotations_syncfunction with_read_annotations_async. The new async function leveragesaiofilesfor asynchronous file reading, and usesasyncio.to_threadfor CPU-intensive operations such asjson.loadsandcopy.deepcopy. Thesave_annotationsroute was updated toawaitthis new function directly.🎯 Why:
The previous implementation performed synchronous file reading and JSON parsing (
json.load(f)) directly inside the event loop or within an unstructured execution thread, which blocks the asyncio event loop and limits the server's concurrent request capacity under high IO load.📊 Measured Improvement:
In local testing reading a massive annotations file (~500k entries):
Additionally, caching hit speeds improved using thread-isolated
deepcopy. Overall, this reduces the event loop blockage times and slightly increases file read throughput under heavy load.PR created automatically by Jules for task 4499495305587811104 started by @sheepdestroyer
Summary by Sourcery
Switch annotation reading to an asynchronous implementation to reduce event loop blocking and improve performance under high I/O load.
Enhancements: