feat: add hot-reload support for config and i18n (multi-process safe)

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.
This commit is contained in:
yumoqing 2026-06-01 16:10:35 +08:00
parent 8abb83e4e3
commit 31d66aa91b
3 changed files with 207 additions and 4 deletions

View File

@ -2,6 +2,7 @@ import os,sys
from sys import platform from sys import platform
import time import time
import ssl import ssl
import asyncio
from socket import * from socket import *
from aiohttp import web from aiohttp import web
@ -21,6 +22,7 @@ from .serverenv import ServerEnv
from .filestorage import TmpFileRecord from .filestorage import TmpFileRecord
from .loadplugins import load_plugins from .loadplugins import load_plugins
from .real_ip import real_ip_middleware from .real_ip import real_ip_middleware
from .hotreload import HotReloader, get_i18n_paths, hot_reload_task
startup_coros = [] startup_coros = []
cleanupctx_coros = [] cleanupctx_coros = []
@ -110,17 +112,48 @@ class ConfiguredServer:
if config.website.ssl: if config.website.ssl:
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain(config.website.ssl.crtfile, ssl_context.load_cert_chain(config.website.ssl.crtfile,
config.website.ssl.keyfile) config.website.ssl.keyfile)
reuse_port = None reuse_port = None
if platform != 'win32': if platform != 'win32':
reuse_port = True reuse_port = True
print('reuse_port=', reuse_port) print('reuse_port=', reuse_port)
[ self.app.on_startup.append(c) for c in startup_coros ] [ self.app.on_startup.append(c) for c in startup_coros ]
[ self.app.cleanup_ctx.append(c) for c in cleanupctx_coros ] [ self.app.cleanup_ctx.append(c) for c in cleanupctx_coros ]
# Hot reload setup
hot_reload_cfg = config.get('hot_reload', False)
if hot_reload_cfg:
interval = 2
if isinstance(hot_reload_cfg, dict):
if not hot_reload_cfg.get('enabled', True):
hot_reload_cfg = False
else:
interval = hot_reload_cfg.get('interval', 2)
if hot_reload_cfg:
workdir = self.workdir or os.getcwd()
config_path = os.path.join(workdir, 'conf', 'config.json')
i18n_paths = get_i18n_paths(workdir)
reloader = HotReloader(config_path, i18n_paths)
reloader.set_interval(interval)
async def _hot_reload_startup(app):
task = asyncio.create_task(hot_reload_task(app, reloader))
app['hot_reload_task'] = task
async def _hot_reload_cleanup(app):
task = app.get('hot_reload_task')
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
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')
web.run_app(self.build_app(),host=config.website.host or '0.0.0.0', web.run_app(self.build_app(),host=config.website.host or '0.0.0.0',
port=port, port=port,
reuse_port=reuse_port, reuse_port=reuse_port,
ssl_context=ssl_context) ssl_context=ssl_context)
def configPath(self,config): def configPath(self,config):
for p,prefix in config.website.paths: for p,prefix in config.website.paths:

170
ahserver/hotreload.py Normal file
View File

@ -0,0 +1,170 @@
"""
ahserver hot-reload module.
Watches file mtimes and triggers reload of cached resources:
- config.json (JsonConfig singleton)
- i18n files (MiniI18N singleton)
- Jinja2 template cache (auto_reload already handles this)
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.
Usage in conf/config.json:
{
"hot_reload": {
"enabled": true,
"interval": 2
}
}
Or simply:
{
"hot_reload": true
}
"""
import os
import time
import asyncio
from appPublic.log import info, debug, warning
class FileWatcher:
"""Track file mtimes and detect changes."""
def __init__(self):
self._mtimes = {}
def check(self, paths):
"""Check if any of the given paths have changed.
Args:
paths: list of absolute file paths to watch
Returns:
list of changed file paths (empty if none changed)
"""
changed = []
for path in paths:
try:
mtime = os.path.getmtime(path)
except OSError:
continue
old_mtime = self._mtimes.get(path)
if old_mtime is None:
self._mtimes[path] = mtime
elif mtime > 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