diff --git a/wrolpi/common.py b/wrolpi/common.py index c9695d15..de392d04 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -1183,6 +1183,9 @@ async def import_all_db_configs() -> dict[str, bool]: Returns a dict mapping config name to success status. Used by main.py startup and disaster recovery tests. + This runs from a worker's after_server_start while the worker is already accepting requests, + so no import may block the event loop. + Import order matters: 1. Tags - no dependencies 2. Downloads - no dependencies @@ -1200,7 +1203,7 @@ async def import_all_db_configs() -> dict[str, bool]: # Tags first (channels/domains depend on tags existing) try: - tags.import_tags_config() + await asyncio.to_thread(tags.import_tags_config) results['tags'] = True logger.debug('tags config imported') except Exception as e: @@ -1218,7 +1221,7 @@ async def import_all_db_configs() -> dict[str, bool]: # Channels (uses tags, links to downloads) try: - import_channels_config() + await asyncio.to_thread(import_channels_config) results['channels'] = True logger.debug('channels config imported') except Exception as e: @@ -1227,7 +1230,7 @@ async def import_all_db_configs() -> dict[str, bool]: # Domains (uses tags) try: - import_domains_config() + await asyncio.to_thread(import_domains_config) results['domains'] = True logger.debug('domains config imported') except Exception as e: @@ -1236,7 +1239,7 @@ async def import_all_db_configs() -> dict[str, bool]: # Inventories (config-only, no DB). Migrates any legacy inventories.yaml, then loads per-inventory files. try: - import_inventories_config() + await asyncio.to_thread(import_inventories_config) results['inventories'] = True logger.debug('inventories config imported') except Exception as e: @@ -1249,7 +1252,7 @@ async def import_all_db_configs() -> dict[str, bool]: # user-curated with no other config home, so they own playlists.yaml. try: from wrolpi.collections.config import playlists_config - playlists_config.import_config() + await asyncio.to_thread(playlists_config.import_config) results['playlists'] = True logger.debug('playlists config imported') except Exception as e: @@ -1259,7 +1262,7 @@ async def import_all_db_configs() -> dict[str, bool]: # Map pins (YAML-only, no DB) try: from modules.map.pins import get_map_pins_config - get_map_pins_config().import_config() + await asyncio.to_thread(get_map_pins_config().import_config) results['map_pins'] = True logger.debug('map pins config imported') except Exception as e: @@ -1269,7 +1272,7 @@ async def import_all_db_configs() -> dict[str, bool]: # Flasher saved firmware configurations (YAML-only, no DB) try: from modules.flasher.config import get_flasher_config - get_flasher_config().import_config() + await asyncio.to_thread(get_flasher_config().import_config) results['flasher'] = True logger.debug('flasher config imported') except Exception as e: diff --git a/wrolpi/downloader.py b/wrolpi/downloader.py index a0fae8de..b6e937a6 100644 --- a/wrolpi/downloader.py +++ b/wrolpi/downloader.py @@ -2381,7 +2381,9 @@ async def import_downloads_config(): logger.warning(f'Refusing to import downloads config when DB is not up.') return - get_download_manager_config().import_config() + # The import upserts every download on the calling thread; keep it off the event loop (this runs + # from a worker's after_server_start while the worker is already accepting requests). + await asyncio.to_thread(get_download_manager_config().import_config) def parse_feed(url: str) -> FeedParserDict: diff --git a/wrolpi/test/test_cross_config_disaster_recovery.py b/wrolpi/test/test_cross_config_disaster_recovery.py index 2d0a0c83..cd4b4834 100644 --- a/wrolpi/test/test_cross_config_disaster_recovery.py +++ b/wrolpi/test/test_cross_config_disaster_recovery.py @@ -19,11 +19,14 @@ Import Order (from import_all_db_configs): 4. Domains 5. Inventories """ +import asyncio +import contextlib import pathlib import shutil import pytest import yaml +from unittest import mock from modules.archive.lib import get_domains_config from modules.videos.lib import get_channels_config @@ -576,3 +579,56 @@ class TestMissingTagsConfig: assert domains[0].name == 'tagged-domain.com' # Tag reference should resolve to None assert domains[0].tag is None + + +@pytest.mark.asyncio +async def test_import_all_db_configs_does_not_block_event_loop(test_session, test_directory): + """A slow config import must not freeze the API worker that runs startup. + + Large tags/channels/downloads configs can take minutes to import. They run in a Sanic worker's + after_server_start listener while that worker is already accepting connections, so a blocking + import makes every request routed to that worker hang. The imports must run off the loop.""" + import time + + sleep_seconds = 0.2 + + def slow_import(*args, **kwargs): + time.sleep(sleep_seconds) # deliberately synchronous, like a real large import + + # Every import import_all_db_configs performs, including the sync import behind the async + # downloads wrapper. Each is replaced with the same blocking sleep. + patched = [ + 'wrolpi.tags.import_tags_config', + 'wrolpi.downloader.DownloadManagerConfig.import_config', + 'modules.videos.lib.import_channels_config', + 'modules.archive.lib.import_domains_config', + 'modules.inventory.common.import_inventories_config', + 'wrolpi.collections.config.PlaylistsConfig.import_config', + 'modules.map.pins.MapPinsConfig.import_config', + 'modules.flasher.config.FlasherConfig.import_config', + ] + + ticks = 0 + tick_seconds = 0.05 + + async def ticker(): + nonlocal ticks + while True: + await asyncio.sleep(tick_seconds) + ticks += 1 + + ticker_task = asyncio.create_task(ticker()) + try: + with contextlib.ExitStack() as stack: + for target in patched: + stack.enter_context(mock.patch(target, slow_import)) + await import_all_db_configs() + finally: + ticker_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await ticker_task + + # A responsive loop ticks through the whole sleep budget; require most of it so that any one + # import moving back onto the loop (losing its share of ticks) fails. + expected = len(patched) * sleep_seconds / tick_seconds + assert ticks >= expected * 0.9, f'event loop was blocked during import ({ticks=}, {expected=})'