Skip to content

fix(providers): make database list refresh a non-blocking background task - #67

Merged
avsolatorio merged 2 commits into
worldbank:devfrom
rafmacalaba:fix/background-db-sync
Apr 25, 2026
Merged

fix(providers): make database list refresh a non-blocking background task#67
avsolatorio merged 2 commits into
worldbank:devfrom
rafmacalaba:fix/background-db-sync

Conversation

@rafmacalaba

Copy link
Copy Markdown
Collaborator

Summary

Addresses issue #66: the previous DatabaseManager.get_mapping() performed a
live 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() is
now a pure in-memory dict lookup that never touches the network. All API fetches
are owned by a background asyncio task that runs for the lifetime of the
server process (persistent on Azure App Service).

Root Cause

get_mapping() held an asyncio.Lock, called _fetch_all() inline, and only
returned 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.py

  • get_mapping() — stripped down to two lines:
    1. _ensure_background_sync() — spawns the background loop if not running.
    2. return self._cache — instant return from in-memory dict.
  • _ensure_background_sync() — creates an asyncio.Task for the sync
    loop if one is not already running or has stopped.
  • _background_sync_loop() — infinite loop that:
    • Checks elapsed time against the TTL.
    • Calls _fetch_all() and updates self._cache if TTL has expired.
    • Sleeps for the remaining TTL before the next check.
    • On fetch failure, logs the error and keeps the existing cache intact.
  • Removed asyncio.Lock — no longer needed since only the background
    task writes to self._cache.

Startup behaviour

On boot, databases.json is loaded into self._cache immediately (same as
before). self._last_fetched = 0.0, so the background task detects the cache
as 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

pytest tests/ -q   # 178 passed

No new tests were required — the existing mock_database_mapping autouse
fixture patches get_mapping directly, so no background task is spawned during
the test run.

Checklist

  • get_mapping() never blocks on network I/O
  • Background task is auto-respawned if it crashes (_bg_task.done() check)
  • Fetch failure keeps the existing JSON-seeded cache intact
  • asyncio.Lock removed (single writer, no race condition)
  • All 178 existing tests pass
  • Pre-commit hooks pass
  • Closes Make the database list refresh a background task #66

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 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.Lock and synchronous refresh-on-TTL-expiry behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/data360/providers.py
Comment on lines +82 to +87
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)

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

@rafmacalaba

Copy link
Copy Markdown
Collaborator Author

@avsolatorio requesting for your review. Thanks!

@avsolatorio avsolatorio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

one comment below.

Comment thread src/data360/providers.py Outdated
Comment thread src/data360/providers.py
Comment thread src/data360/providers.py
@rafmacalaba

rafmacalaba commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator Author

@avsolatorio for re-review.

I have pushed an update that implements a 5-minute backoff upon complete fetch failure.

By removing the finally block, we no longer reset _last_fetched to the current time unconditionally. Instead, on failure, we artificially offset it so the loop calculates a sleep_for of 300 seconds. This prevents tight-looping while ensuring the system recovers quickly once the API is back online, rather than waiting a full 24-hour TTL.

@avsolatorio avsolatorio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm!

@avsolatorio
avsolatorio merged commit a3d42e5 into worldbank:dev Apr 25, 2026
rafmacalaba added a commit that referenced this pull request Apr 26, 2026
- 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
avsolatorio pushed a commit that referenced this pull request Apr 27, 2026
- 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
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.

Make the database list refresh a background task

3 participants