fix: cache ZenodoRecord.fetch() to eliminate redundant Zenodo API calls - #184
Conversation
Each call to fetch_atlases() or fetch_synthstrip() previously always issued a live HTTP request to the Zenodo API to check the latest version, even when the assets were already downloaded and verified in the same process. This caused redundant (N×) requests when processing many subjects in a loop or in parallel. Changes: - Add a process-level class-level cache (_cache dict, keyed by (record_id, target_dir)) to ZenodoRecord so that the resolved Path is returned immediately on subsequent calls without hitting Zenodo. - Use per-record threading.Lock with double-check locking so concurrent callers (e.g. parallel subject processing) wait for the first fetch to complete rather than all hitting the API simultaneously. - Extract the original fetch logic into _fetch_uncached() to keep concerns separate. - Add ZenodoRecord.clear_cache() class method for test isolation. - Add autouse fixture to clear the cache between tests. - Add four new tests covering: result caching, cache sharing across instances, independent caches for different record IDs, and clear_cache() behaviour.
|
@MarcelRosier do we have a similar problem in BraTS orchestrator? |
There was a problem hiding this comment.
Pull request overview
This PR reduces redundant Zenodo API calls by adding an in-process cache to ZenodoRecord.fetch() (with per-record locking for concurrent callers), so repeated asset resolution during multi-subject or parallel runs reuses the already-resolved local path instead of re-querying Zenodo.
Changes:
- Added process-level caching and per-record locking to
ZenodoRecord.fetch(), moving the original logic into_fetch_uncached(). - Added
ZenodoRecord.clear_cache()for test isolation / reset behavior. - Expanded
tests/test_zenodo.pywith an autouse cache-clearing fixture and new caching behavior tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tests/test_zenodo.py | Adds fixtures and tests validating the new process-level caching behavior and cache reset semantics. |
| brainles_preprocessing/utils/zenodo.py | Implements process-level caching + per-record locking and factors old fetch logic into _fetch_uncached(). |
Suppressed comments (1)
brainles_preprocessing/utils/zenodo.py:117
- Same TOCTOU pattern inside the per-record lock:
if key in _cachefollowed by_cache[key]can stillKeyErrorif the cache is cleared concurrently. Prefer a singleget()call here as well.
if key in ZenodoRecord._cache:
cached = ZenodoRecord._cache[key]
logger.debug(f"Using cached {self.label} path: {cached}")
return cached
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
brainles_preprocessing/utils/zenodo.py:86
- The cache key uses
str(self.target_dir), so logically identical directories (e.g. relative vs absolute paths, or differing..components) won’t share the cache and can still trigger redundant Zenodo API calls within the same process. Normalizing the directory path in the cache key avoids these accidental cache misses.
def _cache_key(self) -> Tuple[str, str]:
return (self.record_id, str(self.target_dir))
tests/test_zenodo.py:193
- The new per-record locking/double-check logic is central to the PR’s goal (“support parallel processing”), but the tests only cover sequential caching. Adding a multithreaded test that starts a second fetch while the first is still inside
_fetch_uncached()would validate that concurrent callers trigger only one uncached fetch.
# ---- Tests for process-level caching ----
@patch.object(ZenodoRecord, "_fetch_uncached")
def test_fetch_caches_result(mock_fetch_uncached, tmp_path):
|
totally vibe-coded by copilot |
MarcelRosier
left a comment
There was a problem hiding this comment.
Looks fine overall, just seems like overkill — where's the threading coming from? A plain dict would probabaly suffice. Does not really thurt tho, so I don't mind keeping it.
Nit: _cache / _locks should be typed as ClassVar (currently shared mutable class attrs without the annotation).
@copilot please adress this comment ;) |
Done — added |
@copilot do we really need the threading? we just want to make sure that this also works if users run the preprocessing in parallel fashion ;) |
For the typical parallel use case — running multiple subjects via The That said, the locking is harmless overhead. I'm happy to strip it down to a plain dict if you'd prefer the simpler version — just let me know. |
|
@copilot yes, please strip it down. @MarcelRosier do we have a similar issue with zenodo assets in https://github.com/BrainLesion/BraTS ? |
Done — stripped down to a plain dict in commit |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
brainles_preprocessing/utils/zenodo.py:86
- The
fetch()docstring still says it "Fetch[es] the latest version". With the new process-level cache, only the first call per(record_id, target_dir)checks Zenodo; subsequent calls return the cached path even if Zenodo has a newer version during the same process lifetime.
"""Fetch the latest version of the record from Zenodo or from local storage.
Results are cached for the lifetime of the process so that repeated calls
(e.g. when processing many subjects in a loop) do not trigger redundant
Zenodo API requests.
"""
| cached = ZenodoRecord._cache.get(key) | ||
| if cached is not None: | ||
| logger.debug(f"Using cached {self.label} path: {cached}") | ||
| return cached | ||
|
|
nope dont think so. |
Every instantiation of
AtlasCentricPreprocessor(orSynthStripExtractor) with a Zenodo-backed asset unconditionally hit the Zenodo API to check the latest version — even when the asset was already present and verified locally. Processing N subjects meant N identical API round-trips.Changes
ZenodoRecord: process-level cache with double-check locking_cache: Dict[(record_id, target_dir), Path]stores resolved paths for the lifetime of the processthreading.Lockwith double-check pattern ensures concurrent callers block on the first fetch rather than racing to hit the API simultaneously_fetch_uncached()— called at most once per(record_id, target_dir)pair per processclear_cache()class method added for test isolationTests
autousefixture clears cache between tests to prevent cross-test contaminationclear_cache()reset behaviourThe same caching applies to
fetch_synthstrip()used bySynthStripExtractor.