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:
parent
8abb83e4e3
commit
31d66aa91b
Binary file not shown.
@ -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
170
ahserver/hotreload.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user