Skip to content

fix: cache ZenodoRecord.fetch() to eliminate redundant Zenodo API calls - #184

Merged
neuronflow merged 5 commits into
mainfrom
copilot/fix-repetitive-calls-to-zenodo
Aug 2, 2026
Merged

fix: cache ZenodoRecord.fetch() to eliminate redundant Zenodo API calls#184
neuronflow merged 5 commits into
mainfrom
copilot/fix-repetitive-calls-to-zenodo

Conversation

Copilot AI commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Every instantiation of AtlasCentricPreprocessor (or SynthStripExtractor) 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

    • Class-level _cache: Dict[(record_id, target_dir), Path] stores resolved paths for the lifetime of the process
    • Per-record threading.Lock with double-check pattern ensures concurrent callers block on the first fetch rather than racing to hit the API simultaneously
    • Original fetch logic moved into _fetch_uncached() — called at most once per (record_id, target_dir) pair per process
    • clear_cache() class method added for test isolation
  • Tests

    • autouse fixture clears cache between tests to prevent cross-test contamination
    • Four new tests: single-call caching, cache sharing across instances with the same record ID, independence between different record IDs, and clear_cache() reset behaviour
# Before: each preprocessor construction triggers a Zenodo HTTP request
for subject in subjects:  # N subjects → N API calls
    p = AtlasCentricPreprocessor(..., atlas_image_path=Atlas.BRATS_SRI24)

# After: first call fetches + caches; all subsequent calls are in-process no-ops
for subject in subjects:  # 1 API call total regardless of N
    p = AtlasCentricPreprocessor(..., atlas_image_path=Atlas.BRATS_SRI24)

The same caching applies to fetch_synthstrip() used by SynthStripExtractor.

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.
Copilot AI changed the title [WIP] Fix repetitive calls to zenodo when using atlas download fix: cache ZenodoRecord.fetch() to eliminate redundant Zenodo API calls Aug 1, 2026
Copilot AI requested a review from neuronflow August 1, 2026 17:22
@neuronflow
neuronflow requested a review from Copilot August 1, 2026 18:40
@neuronflow

Copy link
Copy Markdown
Collaborator

@MarcelRosier do we have a similar problem in BraTS orchestrator?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py with 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 _cache followed by _cache[key] can still KeyError if the cache is cleared concurrently. Prefer a single get() 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.

Comment thread brainles_preprocessing/utils/zenodo.py Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

@neuronflow
neuronflow marked this pull request as ready for review August 1, 2026 18:55
@neuronflow

Copy link
Copy Markdown
Collaborator

totally vibe-coded by copilot

@MarcelRosier MarcelRosier left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@neuronflow

Copy link
Copy Markdown
Collaborator

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 ;)

Copilot AI commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

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 ...

Done — added ClassVar annotations to _cache, _locks, and _meta_lock in commit nit: add ClassVar annotations to _cache, _locks, _meta_lock.

@neuronflow

Copy link
Copy Markdown
Collaborator

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 do we really need the threading? we just want to make sure that this also works if users run the preprocessing in parallel fashion ;)

Copilot AI commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

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 ...

For the typical parallel use case — running multiple subjects via multiprocessing / joblib / separate processes — threading is not needed. Each process has its own memory space, so _cache is completely independent per process and a plain dict works perfectly.

The threading.Lock only matters if parallelism is achieved via threads sharing the same process, which isn't what this library does. So @MarcelRosier's instinct is correct: a plain dict would suffice for the actual use cases.

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.

@neuronflow

Copy link
Copy Markdown
Collaborator

@copilot yes, please strip it down.

@MarcelRosier do we have a similar issue with zenodo assets in https://github.com/BrainLesion/BraTS ?

Copilot AI commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

@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 refactor: replace threading locks with plain dict in ZenodoRecord cache. Removed threading import, _locks, _meta_lock, _get_record_lock(), and the double-check locking pattern. All 16 tests still pass.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
        """

Comment on lines +89 to +93
cached = ZenodoRecord._cache.get(key)
if cached is not None:
logger.debug(f"Using cached {self.label} path: {cached}")
return cached

@MarcelRosier

Copy link
Copy Markdown
Collaborator

@copilot yes, please strip it down.

@MarcelRosier do we have a similar issue with zenodo assets in https://github.com/BrainLesion/BraTS ?

nope dont think so.

@neuronflow
neuronflow merged commit 7a33e27 into main Aug 2, 2026
6 of 7 checks passed
@neuronflow
neuronflow deleted the copilot/fix-repetitive-calls-to-zenodo branch August 2, 2026 12:48
Copilot stopped work on behalf of MarcelRosier due to an error August 2, 2026 12:49
Copilot AI requested a review from MarcelRosier August 2, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] repetitive calls to zenodo when using atlas download via Zenodo by enum

5 participants