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

1"""Configuration watcher for hot-reloading schedule parameters at runtime.""" 

2 

3import json 

4import logging 

5import os 

6import threading 

7import time 

8from typing import Any, Callable, Optional 

9 

10logger = logging.getLogger(__name__) 

11 

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}) 

21 

22 

23class ConfigWatcher: 

24 """Watches a configuration source and hot-reloads ScheduleConfig changes. 

25 

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. 

30 

31 Example: 

32 >>> watcher = ConfigWatcher( 

33 ... config, source="file", file_path="config.json" 

34 ... ) 

35 >>> watcher.start() 

36 >>> # ... later ... 

37 >>> watcher.stop() 

38 """ 

39 

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. 

50 

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") 

68 

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 

79 

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) 

88 

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") 

96 

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() 

101 

102 def poll_once(self) -> bool: 

103 """Perform a single poll and update if changed. 

104 

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 

111 

112 new_values = self._read_source() 

113 if not new_values: 

114 return False 

115 

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 

123 

124 self._config.update(**filtered) 

125 self._last_update_time = now 

126 logger.debug("Config updated: %s", filtered) 

127 return True 

128 

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) 

137 

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 

147 

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) 

158 

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 

171 

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() 

177 

178 

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