Coverage for qreward / utils / scheduler / config_watcher.py: 97.97%
106 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
1"""Configuration watcher for hot-reloading schedule parameters at runtime."""
3import json
4import logging
5import os
6import threading
7import time
8from typing import Any, Callable, Optional
10logger = logging.getLogger(__name__)
12_ENV_PREFIX = "QREWARD_SCHEDULE_"
13_UPDATABLE_FIELDS = frozenset({
14 "timeout", "retry_times", "retry_interval", "limit_size", "limit_window",
15 "hedged_request_time", "hedged_request_proportion",
16 "hedged_request_max_times", "speed_up_max_multiply", "debug",
17 "adaptive_limit", "adaptive_limit_min", "adaptive_limit_max",
18 "adaptive_error_threshold", "adaptive_latency_threshold",
19 "adaptive_window_seconds", "priority",
20})
23class ConfigWatcher:
24 """Watches a configuration source and hot-reloads ScheduleConfig changes.
26 Supports three source modes:
27 - **file**: Watches a JSON file for changes (polls by mtime).
28 - **env**: Reads environment variables with ``QREWARD_SCHEDULE_`` prefix.
29 - **callback**: Invokes a user-supplied callable that returns a dict.
31 Example:
32 >>> watcher = ConfigWatcher(
33 ... config, source="file", file_path="config.json"
34 ... )
35 >>> watcher.start()
36 >>> # ... later ...
37 >>> watcher.stop()
38 """
40 def __init__(
41 self,
42 config: Any,
43 source: str = "callback",
44 file_path: Optional[str] = None,
45 callback: Optional[Callable[[], dict[str, Any]]] = None,
46 poll_interval: float = 5.0,
47 cooldown: float = 5.0,
48 ) -> None:
49 """Initialize the config watcher.
51 Args:
52 config: The ScheduleConfig instance to update.
53 source: Source mode - "file", "env", or "callback".
54 file_path: Path to JSON config file (required for "file" mode).
55 callback: Callable returning config dict (required for
56 "callback" mode).
57 poll_interval: Seconds between polls.
58 cooldown: Minimum seconds between successive updates.
59 """
60 if source not in ("file", "env", "callback"):
61 raise ValueError(
62 f"source must be 'file', 'env', or 'callback', got {source!r}"
63 )
64 if source == "file" and not file_path:
65 raise ValueError("file_path is required for 'file' source")
66 if source == "callback" and callback is None:
67 raise ValueError("callback is required for 'callback' source")
69 self._config = config
70 self._source = source
71 self._file_path = file_path
72 self._callback = callback
73 self._poll_interval = poll_interval
74 self._cooldown = cooldown
75 self._last_update_time: float = -float("inf")
76 self._last_mtime: float = 0
77 self._stop_event = threading.Event()
78 self._thread: Optional[threading.Thread] = None
80 def start(self) -> None:
81 """Start the watcher daemon thread."""
82 if self._thread is not None and self._thread.is_alive():
83 return
84 self._stop_event.clear()
85 self._thread = threading.Thread(target=self._poll_loop, daemon=True)
86 self._thread.start()
87 logger.debug("ConfigWatcher started (source=%s)", self._source)
89 def stop(self) -> None:
90 """Stop the watcher thread."""
91 self._stop_event.set()
92 if self._thread is not None: 92 ↛ 95line 92 didn't jump to line 95 because the condition on line 92 was always true
93 self._thread.join(timeout=self._poll_interval + 1)
94 self._thread = None
95 logger.debug("ConfigWatcher stopped")
97 @property
98 def is_running(self) -> bool:
99 """Check if the watcher thread is alive."""
100 return self._thread is not None and self._thread.is_alive()
102 def poll_once(self) -> bool:
103 """Perform a single poll and update if changed.
105 Returns:
106 True if config was updated, False otherwise.
107 """
108 now = time.monotonic()
109 if now - self._last_update_time < self._cooldown:
110 return False
112 new_values = self._read_source()
113 if not new_values:
114 return False
116 filtered = {
117 k: v
118 for k, v in new_values.items()
119 if k in _UPDATABLE_FIELDS
120 }
121 if not filtered:
122 return False
124 self._config.update(**filtered)
125 self._last_update_time = now
126 logger.debug("Config updated: %s", filtered)
127 return True
129 def _poll_loop(self) -> None:
130 """Background polling loop."""
131 while not self._stop_event.is_set():
132 try:
133 self.poll_once()
134 except Exception as exc:
135 logger.warning("ConfigWatcher poll error: %s", exc)
136 self._stop_event.wait(self._poll_interval)
138 def _read_source(self) -> Optional[dict[str, Any]]:
139 """Read configuration from the configured source."""
140 if self._source == "file":
141 return self._read_file()
142 elif self._source == "env":
143 return self._read_env()
144 elif self._source == "callback": 144 ↛ 146line 144 didn't jump to line 146 because the condition on line 144 was always true
145 return self._read_callback()
146 return None
148 def _read_file(self) -> Optional[dict[str, Any]]:
149 """Read config from a JSON file, only if mtime changed."""
150 if not self._file_path or not os.path.isfile(self._file_path):
151 return None
152 mtime = os.path.getmtime(self._file_path)
153 if mtime == self._last_mtime:
154 return None
155 self._last_mtime = mtime
156 with open(self._file_path, "r") as fh:
157 return json.load(fh)
159 def _read_env(self) -> Optional[dict[str, Any]]:
160 """Read config from environment variables with QREWARD_SCHEDULE_
161 prefix."""
162 result: dict[str, Any] = {}
163 for key, value in os.environ.items():
164 if not key.startswith(_ENV_PREFIX):
165 continue
166 field_name = key[len(_ENV_PREFIX):].lower()
167 if field_name not in _UPDATABLE_FIELDS:
168 continue
169 result[field_name] = _coerce_value(field_name, value)
170 return result or None
172 def _read_callback(self) -> Optional[dict[str, Any]]:
173 """Read config from user callback."""
174 if self._callback is None:
175 return None
176 return self._callback()
179def _coerce_value(field_name: str, raw: str) -> Any:
180 """Coerce a string environment variable value to the correct type."""
181 bool_fields = {"debug", "adaptive_limit"}
182 int_fields = {
183 "retry_times", "limit_size", "hedged_request_max_times",
184 "speed_up_max_multiply", "adaptive_limit_min",
185 "adaptive_limit_max", "priority",
186 }
187 float_fields = {
188 "timeout", "retry_interval", "limit_window",
189 "hedged_request_time", "hedged_request_proportion",
190 "adaptive_error_threshold", "adaptive_latency_threshold",
191 "adaptive_window_seconds",
192 }
193 if field_name in bool_fields:
194 return raw.lower() in ("true", "1", "yes", "on")
195 if field_name in int_fields:
196 return int(raw)
197 if field_name in float_fields:
198 return float(raw)
199 return raw