From 31d66aa91bf17e8a712b9271a662723f56289e0a Mon Sep 17 00:00:00 2001 From: yumoqing Date: Mon, 1 Jun 2026 16:10:35 +0800 Subject: [PATCH] feat: add hot-reload support for config and i18n (multi-process safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module: ahserver/hotreload.py - FileWatcher: mtime-based file change detection - HotReloader: clears JsonConfig and MiniI18N singletons on change - Background task runs independently per process (no cross-process coord needed) - Each process detects changes via stat() on shared filesystem Config (conf/config.json): "hot_reload": true — enable with default 2s interval "hot_reload": {"enabled": true, "interval": 5} — custom interval omit or false — disabled (production default) What gets hot-reloaded: - config.json → JsonConfig singleton cleared, next getConfig() reloads - i18n/*/msg.txt → MiniI18N singleton + ServerEnv.myi18n cleared Already hot-reloads without this feature: - .dspy files (read from disk every request) - .md files (read from disk every request) - .tmpl/.ui files (Jinja2 auto_reload checks mtime) Multi-process (reuse_port=True): each worker process runs its own HotReloader. Since all share the filesystem, mtime-based detection works independently without Redis/signals/pub-sub. --- .../configuredServer.cpython-310.pyc | Bin 5534 -> 6601 bytes ahserver/configuredServer.py | 41 ++++- ahserver/hotreload.py | 170 ++++++++++++++++++ 3 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 ahserver/hotreload.py diff --git a/ahserver/__pycache__/configuredServer.cpython-310.pyc b/ahserver/__pycache__/configuredServer.cpython-310.pyc index d11c27ef01d62a86ecddeacc1e3c88de67716f43..689be3dac7520934bd82a560cc4f6f64fc4a8d57 100644 GIT binary patch delta 2955 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 s7x<{DKnEIhZzq8{#a+9>oM{3w? old_mtime: + self._mtimes[path] = mtime + changed.append(path) + return changed + + +class HotReloader: + """Hot-reload cached resources when source files change. + + Each process runs its own HotReloader instance. Since all processes + share the same filesystem, mtime-based detection works independently + in each process without any cross-process communication. + """ + + def __init__(self, config_path, i18n_paths=None): + self._watcher = FileWatcher() + self._config_path = config_path + self._i18n_paths = i18n_paths or [] + self._last_check = 0 + self._interval = 2 # seconds between checks + + def set_interval(self, interval): + self._interval = interval + + def _should_check(self): + now = time.time() + if now - self._last_check < self._interval: + return False + self._last_check = now + return True + + def check_and_reload(self): + """Check for file changes and reload if needed. + + Returns: + dict with keys 'config', 'i18n' indicating what was reloaded + """ + if not self._should_check(): + return {} + + reloaded = {} + + # Check config.json + config_changed = self._watcher.check([self._config_path]) + if config_changed: + self._reload_config() + reloaded['config'] = True + + # Check i18n files + if self._i18n_paths: + i18n_changed = self._watcher.check(self._i18n_paths) + if i18n_changed: + self._reload_i18n() + reloaded['i18n'] = True + + return reloaded + + def _reload_config(self): + """Clear JsonConfig singleton so next getConfig() call reloads from disk.""" + try: + from appPublic.jsonConfig import JsonConfig + # SingletonDecorator stores instance as .instance + JsonConfig.instance = None + info('[hot_reload] config.json changed, cache cleared') + except Exception as e: + warning(f'[hot_reload] failed to reload config: {e}') + + def _reload_i18n(self): + """Clear MiniI18N singleton and ServerEnv cache.""" + try: + from appPublic.i18n import MiniI18N + MiniI18N.instance = None + # Clear cached i18n on ServerEnv + try: + from .serverenv import ServerEnv + g = ServerEnv() + if hasattr(g, 'myi18n'): + del g.myi18n + except Exception: + pass + info('[hot_reload] i18n files changed, cache cleared') + except Exception as e: + warning(f'[hot_reload] failed to reload i18n: {e}') + + +def get_i18n_paths(workdir=None): + """Collect all i18n msg.txt file paths for watching.""" + if workdir is None: + workdir = os.getcwd() + i18n_dir = os.path.join(workdir, 'i18n') + paths = [] + if os.path.isdir(i18n_dir): + for lang in os.listdir(i18n_dir): + msg_file = os.path.join(i18n_dir, lang, 'msg.txt') + if os.path.isfile(msg_file): + paths.append(msg_file) + return paths + + +async def hot_reload_task(app, reloader): + """Background task that periodically checks for file changes. + + Added to app.on_startup when hot_reload is enabled. + """ + info(f'[hot_reload] started, interval={reloader._interval}s') + try: + while True: + await asyncio.sleep(reloader._interval) + reloaded = reloader.check_and_reload() + if reloaded: + info(f'[hot_reload] reloaded: {list(reloaded.keys())}') + except asyncio.CancelledError: + info('[hot_reload] stopped') + raise