feat(config): harden file watcher hot-reload flow and add test coverage
refactor FileWatcher to subscription-based dispatch with path/change filters add callback timeout, failure cooldown, auto-retry loop, and runtime stats strengthen ConfigManager hot-reload with throttling, timeout guard, and watcher stats logging add pytest suites for watcher behavior and config hot-reload edge cases
This commit is contained in:
104
pytests/config_test/test_config_manager_hot_reload.py
Normal file
104
pytests/config_test/test_config_manager_hot_reload.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from pathlib import Path
|
||||
|
||||
from watchfiles import Change
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from src.config.config import ConfigManager
|
||||
from src.config.file_watcher import FileChange, FileWatcherStats
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_file_changes_throttles_reload():
|
||||
manager = ConfigManager()
|
||||
manager._hot_reload_min_interval_s = 100.0
|
||||
|
||||
called = 0
|
||||
|
||||
async def reload_stub() -> bool:
|
||||
nonlocal called
|
||||
called += 1
|
||||
return True
|
||||
|
||||
manager.reload_config = reload_stub # type: ignore[method-assign]
|
||||
changes = [FileChange(change_type=Change.modified, path=Path("/tmp/bot_config.toml"))]
|
||||
|
||||
await manager._handle_file_changes(changes)
|
||||
await manager._handle_file_changes(changes)
|
||||
|
||||
assert called == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_file_changes_timeout_logged(caplog):
|
||||
manager = ConfigManager()
|
||||
manager._hot_reload_min_interval_s = 0.0
|
||||
manager._hot_reload_timeout_s = 0.01
|
||||
|
||||
async def reload_stub() -> bool:
|
||||
await asyncio.sleep(0.05)
|
||||
return True
|
||||
|
||||
manager.reload_config = reload_stub # type: ignore[method-assign]
|
||||
changes = [FileChange(change_type=Change.modified, path=Path("/tmp/model_config.toml"))]
|
||||
|
||||
with caplog.at_level("ERROR"):
|
||||
await manager._handle_file_changes(changes)
|
||||
|
||||
assert "配置热重载超时" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_file_changes_empty_skips_reload():
|
||||
manager = ConfigManager()
|
||||
|
||||
called = 0
|
||||
|
||||
async def reload_stub() -> bool:
|
||||
nonlocal called
|
||||
called += 1
|
||||
return True
|
||||
|
||||
manager.reload_config = reload_stub # type: ignore[method-assign]
|
||||
|
||||
await manager._handle_file_changes([])
|
||||
|
||||
assert called == 0
|
||||
|
||||
|
||||
class _FakeWatcher:
|
||||
def __init__(self):
|
||||
self.unsubscribe_called_with: str | None = None
|
||||
self.stop_called = False
|
||||
self.stats = FileWatcherStats(
|
||||
batches_seen=1,
|
||||
changes_seen=2,
|
||||
callbacks_succeeded=3,
|
||||
callbacks_failed=4,
|
||||
callbacks_timed_out=5,
|
||||
callbacks_skipped_cooldown=6,
|
||||
restart_count=7,
|
||||
)
|
||||
|
||||
def unsubscribe(self, subscription_id: str) -> bool:
|
||||
self.unsubscribe_called_with = subscription_id
|
||||
return True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stop_called = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_file_watcher_cleans_state():
|
||||
manager = ConfigManager()
|
||||
fake_watcher = _FakeWatcher()
|
||||
manager._file_watcher = fake_watcher # type: ignore[assignment]
|
||||
manager._file_watcher_subscription_id = "sub-1"
|
||||
|
||||
await manager.stop_file_watcher()
|
||||
|
||||
assert fake_watcher.unsubscribe_called_with == "sub-1"
|
||||
assert fake_watcher.stop_called is True
|
||||
assert manager._file_watcher is None
|
||||
assert manager._file_watcher_subscription_id is None
|
||||
105
pytests/config_test/test_file_watcher.py
Normal file
105
pytests/config_test/test_file_watcher.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from pathlib import Path
|
||||
|
||||
from watchfiles import Change
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from src.config.file_watcher import FileChange, FileWatcher
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_changes_with_path_and_change_type_filters(tmp_path: Path):
|
||||
watcher = FileWatcher(paths=[tmp_path])
|
||||
target_file = (tmp_path / "bot_config.toml").resolve()
|
||||
|
||||
received: list[list[FileChange]] = []
|
||||
|
||||
async def callback(changes):
|
||||
received.append(list(changes))
|
||||
|
||||
watcher.subscribe(callback, paths=[target_file], change_types=[Change.modified])
|
||||
|
||||
await watcher._dispatch_changes(
|
||||
[
|
||||
FileChange(change_type=Change.added, path=target_file),
|
||||
FileChange(change_type=Change.modified, path=target_file),
|
||||
FileChange(change_type=Change.modified, path=(tmp_path / "other.toml").resolve()),
|
||||
]
|
||||
)
|
||||
|
||||
assert len(received) == 1
|
||||
assert len(received[0]) == 1
|
||||
assert received[0][0].change_type == Change.modified
|
||||
assert received[0][0].path == target_file
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_callback_supported(tmp_path: Path):
|
||||
watcher = FileWatcher(paths=[tmp_path])
|
||||
target_file = (tmp_path / "model_config.toml").resolve()
|
||||
|
||||
received_paths: list[Path] = []
|
||||
|
||||
def sync_callback(changes):
|
||||
received_paths.extend(change.path for change in changes)
|
||||
|
||||
watcher.subscribe(sync_callback, paths=[target_file])
|
||||
|
||||
await watcher._dispatch_changes([FileChange(change_type=Change.modified, path=target_file)])
|
||||
|
||||
assert received_paths == [target_file]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_timeout_and_cooldown(tmp_path: Path):
|
||||
watcher = FileWatcher(
|
||||
paths=[tmp_path],
|
||||
callback_timeout_s=0.05,
|
||||
callback_failure_threshold=2,
|
||||
callback_cooldown_s=0.2,
|
||||
)
|
||||
target_file = (tmp_path / "bot_config.toml").resolve()
|
||||
|
||||
async def slow_callback(changes):
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
watcher.subscribe(slow_callback, paths=[target_file])
|
||||
|
||||
await watcher._dispatch_changes([FileChange(change_type=Change.modified, path=target_file)])
|
||||
await watcher._dispatch_changes([FileChange(change_type=Change.modified, path=target_file)])
|
||||
|
||||
stats_after_failures = watcher.stats
|
||||
assert stats_after_failures.callbacks_timed_out == 2
|
||||
assert stats_after_failures.callbacks_failed == 2
|
||||
|
||||
await watcher._dispatch_changes([FileChange(change_type=Change.modified, path=target_file)])
|
||||
stats_after_cooldown_skip = watcher.stats
|
||||
assert stats_after_cooldown_skip.callbacks_skipped_cooldown >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_requires_subscription(tmp_path: Path):
|
||||
watcher = FileWatcher(paths=[tmp_path])
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await watcher.start()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_stops_dispatch(tmp_path: Path):
|
||||
watcher = FileWatcher(paths=[tmp_path])
|
||||
target_file = (tmp_path / "bot_config.toml").resolve()
|
||||
|
||||
calls = 0
|
||||
|
||||
async def callback(changes):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
|
||||
subscription_id = watcher.subscribe(callback, paths=[target_file])
|
||||
assert watcher.unsubscribe(subscription_id) is True
|
||||
|
||||
await watcher._dispatch_changes([FileChange(change_type=Change.modified, path=target_file)])
|
||||
|
||||
assert calls == 0
|
||||
Reference in New Issue
Block a user