fix(providers): make database list refresh a non-blocking background task - #67
Conversation
…h via background asyncio task
There was a problem hiding this comment.
Pull request overview
This PR moves Data360 database mapping refreshes off the request hot path by making DatabaseManager.get_mapping() a pure in-memory lookup and shifting API refresh behavior into a long-lived background asyncio task.
Changes:
- Refactors
DatabaseManager.get_mapping()to return cached data immediately and trigger background sync startup. - Adds background refresh loop (
_background_sync_loop) and task management (_ensure_background_sync) to periodically refresh cache based on TTL. - Removes the request-path
asyncio.Lockand synchronous refresh-on-TTL-expiry behavior.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| except Exception as e: | ||
| _logger.error("Background database fetch failed: %s", e) | ||
| # Keep existing cache; retry after the next full TTL cycle. | ||
|
|
||
| sleep_for = max(0.0, self._ttl - (time.monotonic() - self._last_fetched)) | ||
| await asyncio.sleep(sleep_for) |
There was a problem hiding this comment.
In _background_sync_loop, if _fetch_all() raises or returns an empty mapping, _last_fetched is not updated. Because sleep_for is computed from _last_fetched, this can become 0.0, causing the loop to wake immediately and retry in a tight loop (potentially hammering the Data360 API and burning CPU). Consider tracking a separate last_attempt/last_refresh timestamp, or updating _last_fetched (or a new _last_attempted) even on failure/empty results, and ensure a minimum sleep/backoff before the next retry.
There was a problem hiding this comment.
@copilot apply changes based on this feedback
|
@avsolatorio requesting for your review. Thanks! |
|
@avsolatorio for re-review. I have pushed an update that implements a 5-minute backoff upon complete fetch failure. By removing the |
- Mirrors DatabaseManager's background-sync pattern (PR #67) - 7-day TTL; FMR failures backed off 5 min (VPN-restricted resource) - SDMX parsing extracted into static parse_name_map / parse_hierarchy methods so build_ref_area_groups.py can import them instead of duplicating the logic - Event-loop guard in _ensure_background_sync() keeps sync contexts (tests, imports) safe — bundled JSON is the always-available fallback - Lazy _load() preserved so _DATA_FILE can be overridden in tests
- Mirrors DatabaseManager's background-sync pattern (PR #67) - 7-day TTL; FMR failures backed off 5 min (VPN-restricted resource) - SDMX parsing extracted into static parse_name_map / parse_hierarchy methods so build_ref_area_groups.py can import them instead of duplicating the logic - Event-loop guard in _ensure_background_sync() keeps sync contexts (tests, imports) safe — bundled JSON is the always-available fallback - Lazy _load() preserved so _DATA_FILE can be overridden in tests
Summary
Addresses issue #66: the previous
DatabaseManager.get_mapping()performed alive API fetch inline when the 24-hour TTL expired, adding ~1–2 seconds of
latency to the first user request following every refresh cycle.
This patch decouples the refresh entirely from the hot path.
get_mapping()isnow a pure in-memory dict lookup that never touches the network. All API fetches
are owned by a background
asynciotask that runs for the lifetime of theserver process (persistent on Azure App Service).
Root Cause
get_mapping()held anasyncio.Lock, called_fetch_all()inline, and onlyreturned to the caller after the full paginated API fetch completed. Any user
request that arrived at or after the TTL expiry paid that latency.
Changes
src/data360/providers.pyget_mapping()— stripped down to two lines:_ensure_background_sync()— spawns the background loop if not running.return self._cache— instant return from in-memory dict._ensure_background_sync()— creates anasyncio.Taskfor the syncloop if one is not already running or has stopped.
_background_sync_loop()— infinite loop that:_fetch_all()and updatesself._cacheif TTL has expired.asyncio.Lock— no longer needed since only the backgroundtask writes to
self._cache.Startup behaviour
On boot,
databases.jsonis loaded intoself._cacheimmediately (same asbefore).
self._last_fetched = 0.0, so the background task detects the cacheas stale on its very first iteration and performs the initial live fetch in
the background, while any concurrent user requests return the JSON-seeded data
instantly.
Testing
No new tests were required — the existing
mock_database_mappingautousefixture patches
get_mappingdirectly, so no background task is spawned duringthe test run.
Checklist
get_mapping()never blocks on network I/O_bg_task.done()check)asyncio.Lockremoved (single writer, no race condition)