diff --git a/ahserver/__pycache__/configuredServer.cpython-310.pyc b/ahserver/__pycache__/configuredServer.cpython-310.pyc index 689be3d..574d7fa 100644 Binary files a/ahserver/__pycache__/configuredServer.cpython-310.pyc and b/ahserver/__pycache__/configuredServer.cpython-310.pyc differ diff --git a/ahserver/configuredServer.py b/ahserver/configuredServer.py index 42589bf..770e1ca 100644 --- a/ahserver/configuredServer.py +++ b/ahserver/configuredServer.py @@ -22,7 +22,7 @@ from .serverenv import ServerEnv from .filestorage import TmpFileRecord from .loadplugins import load_plugins from .real_ip import real_ip_middleware -from .hotreload import HotReloader, get_i18n_paths, hot_reload_task +from .hotreload import HotReloader, get_i18n_paths, hot_reload_task, hot_reload_handler, invalidate_all_caches startup_coros = [] cleanupctx_coros = [] @@ -135,6 +135,8 @@ class ConfiguredServer: i18n_paths = get_i18n_paths(workdir) reloader = HotReloader(config_path, i18n_paths) reloader.set_interval(interval) + # Register HTTP endpoint for manual cache flush + self.app.router.add_get('/__hot_reload__', hot_reload_handler) async def _hot_reload_startup(app): task = asyncio.create_task(hot_reload_task(app, reloader)) app['hot_reload_task'] = task @@ -148,7 +150,7 @@ class ConfiguredServer: pass self.app.on_startup.append(_hot_reload_startup) self.app.cleanup_ctx.append(_hot_reload_cleanup) - print(f'hot_reload enabled, interval={interval}s, pid will check independently') + print(f'hot_reload enabled, interval={interval}s, endpoint=GET /__hot_reload__') web.run_app(self.build_app(),host=config.website.host or '0.0.0.0', port=port, diff --git a/ahserver/hotreload.py b/ahserver/hotreload.py index 8716b4c..38a364d 100644 --- a/ahserver/hotreload.py +++ b/ahserver/hotreload.py @@ -6,6 +6,10 @@ Watches file mtimes and triggers reload of cached resources: - i18n files (MiniI18N singleton) - Jinja2 template cache (auto_reload already handles this) +Module caches (rbac/pricing/uapi/llmage) can be cleared via: +- HTTP endpoint: GET /__hot_reload__ +- Automatic when config.json changes + Multi-process safe: each process independently checks mtimes via stat(), no cross-process coordination needed. When a file changes on disk, all processes detect it on their next check cycle. @@ -112,12 +116,16 @@ class HotReloader: return reloaded def _reload_config(self): - """Clear JsonConfig singleton so next getConfig() call reloads from disk.""" + """Clear JsonConfig singleton so next getConfig() call reloads from disk. + Also clear all module caches since config changes may affect them. + """ try: from appPublic.jsonConfig import JsonConfig # SingletonDecorator stores instance as .instance JsonConfig.instance = None info('[hot_reload] config.json changed, cache cleared') + # Also clear module caches since config may affect module_cache settings + invalidate_all_caches() except Exception as e: warning(f'[hot_reload] failed to reload config: {e}') @@ -168,3 +176,73 @@ async def hot_reload_task(app, reloader): except asyncio.CancelledError: info('[hot_reload] stopped') raise + + +def invalidate_all_caches(): + """Clear all module caches (rbac/pricing/uapi/llmage). + + Called automatically when config.json changes, or manually via + GET /__hot_reload__ endpoint. + + Each module cache is cleared independently with try/except to + prevent one module's failure from blocking others. + """ + cleared = [] + + # rbac: UserPermissions singleton with LRU caches + try: + from rbac.userperm import UserPermissions + up = UserPermissions() + up.ur_caches.clear() + up.invalidate_rp_cache() + cleared.append('rbac') + except Exception as e: + debug(f'[hot_reload] rbac cache clear skipped: {e}') + + # pricing: PricingProgram class-level pricing_data dict + try: + from pricing.pricing import PricingProgram + PricingProgram.pricing_data.clear() + cleared.append('pricing') + except Exception as e: + debug(f'[hot_reload] pricing cache clear skipped: {e}') + + # uapi: UAPIData singleton with apidata and org_users dicts + try: + from uapi.apidata import UAPIData + ud = UAPIData() + ud.apidata.clear() + ud.org_users.clear() + cleared.append('uapi') + except Exception as e: + debug(f'[hot_reload] uapi cache clear skipped: {e}') + + # llmage: module-level _uapi_cache and _uapiio_cache + try: + from llmage.utils import invalidate_uapi_cache + invalidate_uapi_cache() # clears both _uapi_cache and _uapiio_cache + cleared.append('llmage') + except Exception as e: + debug(f'[hot_reload] llmage cache clear skipped: {e}') + + if cleared: + info(f'[hot_reload] cleared caches: {cleared}') + return cleared + + +async def hot_reload_handler(request): + """HTTP endpoint handler for GET /__hot_reload__. + + Manually triggers cache invalidation. Useful for development + when database changes aren't detected automatically. + + Returns JSON with list of cleared caches. + """ + from aiohttp import web + cleared = invalidate_all_caches() + return web.json_response({ + 'status': 'ok', + 'cleared': cleared, + 'timestamp': time.time() + }) +