-
Notifications
You must be signed in to change notification settings - Fork 0
⚡ [performance improvement] Async annotations read #172
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
Changes from all commits
ea02455
fd6cac5
9fb2573
11d89a3
5fa2dfb
07f22ef
d0b47e8
a1b8c57
24893ac
5f0b8fe
b2cfc3c
e518cc2
1958a08
66d8dae
0e9c0a3
f811cc0
0d3546a
50f3bd5
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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,5 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import aiofiles | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import sys | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -3384,21 +3385,23 @@ class AnnotationItem(BaseModel): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _annotations_cache = {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _read_annotations_sync(path) -> dict: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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"]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+3388
to
+3404
Contributor
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. The current implementation of We can optimize this by performing the file reading and JSON parsing together in a single synchronous function executed via
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @app.post("/dashboard/save-annotations") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def save_annotations(payload: Dict[str, AnnotationItem]): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -3451,7 +3454,7 @@ async def save_annotations(payload: Dict[str, AnnotationItem]): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async with annotations_lock: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ann_path.exists(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| existing = await asyncio.to_thread(_read_annotations_sync, str(ann_path)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| existing = await _read_annotations_async(str(ann_path)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception as read_err: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warning(f"Could not read existing annotations: {read_err}. Overwriting.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,81 +1,104 @@ | ||||||
| import pytest | ||||||
| from unittest.mock import patch, mock_open | ||||||
| from unittest.mock import patch, AsyncMock, MagicMock | ||||||
| import copy | ||||||
|
|
||||||
| # Mock config so router.main can be imported | ||||||
| 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__), ".."))) | ||||||
|
Contributor
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. Since the test file has been moved from
Suggested change
Owner
Author
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. This is wrong, tests must be in |
||||||
|
|
||||||
| # Need to set up environment or mock configuration depending on how it's done in the rest of tests | ||||||
| from router.main import _read_annotations_sync | ||||||
| import router.main | ||||||
| from router.main import _read_annotations_async | ||||||
|
|
||||||
| @pytest.fixture(autouse=True) | ||||||
| def clear_cache(): | ||||||
| router.main._annotations_cache.clear() | ||||||
| yield | ||||||
|
|
||||||
| def test_read_annotations_sync_initial_read(): | ||||||
| @pytest.mark.asyncio | ||||||
| async def test_read_annotations_async_initial_read(): | ||||||
| fake_path = "/tmp/annotations.json" | ||||||
| fake_data = {"annotation1": "data1"} | ||||||
|
|
||||||
| # Mock aiofiles.open | ||||||
| mock_file = AsyncMock() | ||||||
| mock_file.read.return_value = '{"annotation1": "data1"}' | ||||||
|
|
||||||
| mock_context_manager = MagicMock() | ||||||
| mock_context_manager.__aenter__ = AsyncMock(return_value=mock_file) | ||||||
| mock_context_manager.__aexit__ = AsyncMock(return_value=False) | ||||||
|
|
||||||
| mock_aiofiles_open = MagicMock(return_value=mock_context_manager) | ||||||
|
|
||||||
| with patch("os.path.getmtime", return_value=100.0) as mock_getmtime, \ | ||||||
| patch("builtins.open", mock_open(read_data='{"annotation1": "data1"}')) as mock_file, \ | ||||||
| patch("json.load", return_value=fake_data) as mock_json_load: | ||||||
| patch("aiofiles.open", mock_aiofiles_open) as mock_open: | ||||||
|
|
||||||
| result = _read_annotations_sync(fake_path) | ||||||
| result = await _read_annotations_async(fake_path) | ||||||
|
|
||||||
| mock_getmtime.assert_called_once_with(fake_path) | ||||||
| mock_file.assert_called_once_with(fake_path, "r", encoding="utf-8") | ||||||
| mock_open.assert_called_once_with(fake_path, "r", encoding="utf-8") | ||||||
| assert result == fake_data | ||||||
|
|
||||||
| # Verify cache is populated | ||||||
| assert fake_path in router.main._annotations_cache | ||||||
| assert router.main._annotations_cache[fake_path]["mtime"] == 100.0 | ||||||
| assert router.main._annotations_cache[fake_path]["data"] == fake_data | ||||||
|
|
||||||
| def test_read_annotations_sync_cache_hit(): | ||||||
| @pytest.mark.asyncio | ||||||
| async def test_read_annotations_async_cache_hit(): | ||||||
| fake_path = "/tmp/annotations.json" | ||||||
| fake_data = {"annotation1": "data1"} | ||||||
|
|
||||||
| # Pre-populate cache | ||||||
| router.main._annotations_cache[fake_path] = {"mtime": 100.0, "data": fake_data} | ||||||
|
|
||||||
| # Mock aiofiles.open (should NOT be called) | ||||||
| mock_aiofiles_open = MagicMock() | ||||||
|
|
||||||
| with patch("os.path.getmtime", return_value=100.0) as mock_getmtime, \ | ||||||
| patch("builtins.open", mock_open()) as mock_file: | ||||||
| patch("aiofiles.open", mock_aiofiles_open) as mock_open: | ||||||
|
|
||||||
| result = _read_annotations_sync(fake_path) | ||||||
| result = await _read_annotations_async(fake_path) | ||||||
|
|
||||||
| mock_getmtime.assert_called_once_with(fake_path) | ||||||
| mock_file.assert_not_called() | ||||||
| mock_open.assert_not_called() | ||||||
| assert result == fake_data | ||||||
|
|
||||||
| def test_read_annotations_sync_cache_invalidation(): | ||||||
| @pytest.mark.asyncio | ||||||
| async def test_read_annotations_async_cache_invalidation(): | ||||||
| fake_path = "/tmp/annotations.json" | ||||||
| fake_data_old = {"annotation1": "data1"} | ||||||
| fake_data_new = {"annotation2": "data2"} | ||||||
|
|
||||||
| # Pre-populate cache with old mtime | ||||||
| router.main._annotations_cache[fake_path] = {"mtime": 100.0, "data": fake_data_old} | ||||||
|
|
||||||
| mock_file = AsyncMock() | ||||||
| mock_file.read.return_value = '{"annotation2": "data2"}' | ||||||
|
|
||||||
| mock_context_manager = MagicMock() | ||||||
| mock_context_manager.__aenter__ = AsyncMock(return_value=mock_file) | ||||||
| mock_context_manager.__aexit__ = AsyncMock(return_value=False) | ||||||
|
|
||||||
| mock_aiofiles_open = MagicMock(return_value=mock_context_manager) | ||||||
|
|
||||||
| with patch("os.path.getmtime", return_value=200.0) as mock_getmtime, \ | ||||||
| patch("builtins.open", mock_open(read_data='{"annotation2": "data2"}')) as mock_file, \ | ||||||
| patch("json.load", return_value=fake_data_new) as mock_json_load: | ||||||
| patch("aiofiles.open", mock_aiofiles_open) as mock_open: | ||||||
|
|
||||||
| result = _read_annotations_sync(fake_path) | ||||||
| result = await _read_annotations_async(fake_path) | ||||||
|
|
||||||
| mock_getmtime.assert_called_once_with(fake_path) | ||||||
| mock_file.assert_called_once_with(fake_path, "r", encoding="utf-8") | ||||||
| mock_open.assert_called_once_with(fake_path, "r", encoding="utf-8") | ||||||
| assert result == fake_data_new | ||||||
|
|
||||||
| # Verify cache is updated | ||||||
| assert router.main._annotations_cache[fake_path]["mtime"] == 200.0 | ||||||
| assert router.main._annotations_cache[fake_path]["data"] == fake_data_new | ||||||
|
|
||||||
| def test_read_annotations_sync_deepcopy(): | ||||||
| @pytest.mark.asyncio | ||||||
| async def test_read_annotations_async_deepcopy(): | ||||||
| fake_path = "/tmp/annotations.json" | ||||||
| fake_data = {"annotation1": {"nested": "value"}} | ||||||
|
|
||||||
|
|
@@ -84,21 +107,22 @@ def test_read_annotations_sync_deepcopy(): | |||||
|
|
||||||
| with patch("os.path.getmtime", return_value=100.0): | ||||||
| # First read | ||||||
| result1 = _read_annotations_sync(fake_path) | ||||||
| result1 = await _read_annotations_async(fake_path) | ||||||
|
|
||||||
| # Mutate the result | ||||||
| result1["annotation1"]["nested"] = "mutated" | ||||||
|
|
||||||
| # Second read | ||||||
| result2 = _read_annotations_sync(fake_path) | ||||||
| result2 = await _read_annotations_async(fake_path) | ||||||
|
|
||||||
| # Verify second read returns original data, not mutated | ||||||
| assert result2["annotation1"]["nested"] == "value" | ||||||
| assert router.main._annotations_cache[fake_path]["data"]["annotation1"]["nested"] == "value" | ||||||
|
|
||||||
| def test_read_annotations_sync_file_not_found(): | ||||||
| @pytest.mark.asyncio | ||||||
| async def test_read_annotations_async_file_not_found(): | ||||||
| fake_path = "/tmp/annotations.json" | ||||||
|
|
||||||
| with patch("os.path.getmtime", side_effect=FileNotFoundError): | ||||||
| with pytest.raises(FileNotFoundError): | ||||||
| _read_annotations_sync(fake_path) | ||||||
| await _read_annotations_async(fake_path) | ||||||
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.
Since
aiofilesis no longer needed after optimizing the file reading process, andreis not used anywhere in this file, both of these imports can be safely removed to keep the imports clean.