Coverage for qreward / utils / scheduler / decorator.py: 100.00%
102 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"""Main schedule decorator implementation."""
3import atexit
4import concurrent.futures
5import functools
6import inspect
7import threading
8from typing import (
9 Any, Callable, List, Optional, Tuple, Type, Union,
10)
12from .adaptive_limiter import AdaptiveRateLimiter
13from .async_runner import AsyncRunner
14from .circuit_breaker import CircuitBreaker
15from .config import ScheduleConfig, _sentinel_none
16from .context import ExecutionContext
17from .sync_runner import SyncRunner
18from .limiter import LimiterPool
19from .metrics import ScheduleMetrics
20from .pools import RunningTaskPool
21from .telemetry import TelemetryExporter
23# Global registry for ThreadPoolExecutors created by sync schedule decorators.
24# Protected by a lock to ensure thread safety during registration.
25_executor_registry_lock = threading.Lock()
26_executor_registry: List[concurrent.futures.ThreadPoolExecutor] = []
27_atexit_registered = False
30def _shutdown_executors() -> None:
31 """Shutdown all registered ThreadPoolExecutors.
33 Called automatically at process exit via atexit to prevent thread leaks.
34 Uses wait=False to avoid blocking process shutdown.
35 """
36 with _executor_registry_lock:
37 for executor in _executor_registry:
38 executor.shutdown(wait=False)
39 _executor_registry.clear()
42def _register_executor(
43 executor: concurrent.futures.ThreadPoolExecutor
44) -> None:
45 """Register a ThreadPoolExecutor for cleanup at process exit.
47 Args:
48 executor: The ThreadPoolExecutor to register.
49 """
50 global _atexit_registered
51 with _executor_registry_lock:
52 _executor_registry.append(executor)
53 if not _atexit_registered:
54 atexit.register(_shutdown_executors)
55 _atexit_registered = True
58def schedule(
59 timeout: float = 0,
60 hedged_request_time: float = 0,
61 hedged_request_proportion: float = 0.05,
62 hedged_request_max_times: int = 2,
63 speed_up_max_multiply: int = 5,
64 retry_times: int = 0,
65 retry_interval: float = 1,
66 limit_size: int = 0,
67 limit_window: float = 1.0,
68 key_func: object = _sentinel_none,
69 exception_types: Union[
70 Type[BaseException],
71 Tuple[Type[BaseException], ...],
72 ] = BaseException,
73 default_result: object = _sentinel_none,
74 debug: bool = False,
75 metrics_callback: Optional[Callable[[ScheduleMetrics], None]] = None,
76 circuit_breaker_threshold: int = 0,
77 circuit_breaker_recovery: float = 30.0,
78 adaptive_limit: bool = False,
79 adaptive_limit_min: int = 10,
80 adaptive_limit_max: int = 500,
81 adaptive_error_threshold: float = 0.3,
82 adaptive_latency_threshold: float = 5.0,
83 adaptive_window_seconds: float = 10.0,
84 priority: int = 5,
85 telemetry_exporter: Optional[TelemetryExporter] = None,
86):
87 """Decorator for scheduling function execution with retry, hedging,
88 rate limiting, etc.
90 Args:
91 timeout: Timeout in seconds, 0 means no timeout
92 hedged_request_time: Time to trigger hedged requests
93 hedged_request_proportion: Max proportion for hedged requests
94 hedged_request_max_times: Max number of hedged requests
95 speed_up_max_multiply: Max speed up multiplier
96 retry_times: Max retry times (not including first call)
97 retry_interval: Retry interval in seconds
98 limit_size: Rate limit size
99 limit_window: Rate limit window in seconds
100 key_func: Function to generate pool key
101 exception_types: Exceptions to catch and retry
102 default_result: Default result on failure
103 debug: Enable debug logging
104 metrics_callback: Optional callback invoked after each execution
105 with a ScheduleMetrics instance containing execution statistics
106 circuit_breaker_threshold: Failure threshold to trip the circuit
107 breaker. 0 disables the circuit breaker.
108 circuit_breaker_recovery: Seconds before the breaker transitions
109 from OPEN to HALF_OPEN for probe requests.
110 adaptive_limit: Enable adaptive rate limiting.
111 adaptive_limit_min: Minimum rate limit floor.
112 adaptive_limit_max: Maximum rate limit ceiling.
113 adaptive_error_threshold: Error rate threshold to trigger slowdown.
114 adaptive_latency_threshold: Avg latency threshold to trigger slowdown.
115 adaptive_window_seconds: Sliding window duration for statistics.
116 priority: Task priority (0-9, lower = higher). Default 5 (NORMAL).
117 telemetry_exporter: Optional TelemetryExporter for OTel metrics export.
119 Returns:
120 Decorated function
121 """
122 config = ScheduleConfig(
123 timeout=timeout,
124 hedged_request_time=hedged_request_time,
125 hedged_request_proportion=hedged_request_proportion,
126 hedged_request_max_times=hedged_request_max_times,
127 speed_up_max_multiply=speed_up_max_multiply,
128 retry_times=retry_times,
129 retry_interval=retry_interval,
130 limit_size=limit_size,
131 limit_window=limit_window,
132 key_func=key_func,
133 exception_types=exception_types,
134 default_result=default_result,
135 debug=debug,
136 adaptive_limit=adaptive_limit,
137 adaptive_limit_min=adaptive_limit_min,
138 adaptive_limit_max=adaptive_limit_max,
139 adaptive_error_threshold=adaptive_error_threshold,
140 adaptive_latency_threshold=adaptive_latency_threshold,
141 adaptive_window_seconds=adaptive_window_seconds,
142 priority=priority,
143 )
145 # Create adaptive rate limiter if enabled
146 adaptive_limiter: Optional[AdaptiveRateLimiter] = None
147 if adaptive_limit and limit_size > 0:
148 adaptive_limiter = AdaptiveRateLimiter(
149 initial_limit=limit_size,
150 limit_min=adaptive_limit_min,
151 limit_max=adaptive_limit_max,
152 error_threshold=adaptive_error_threshold,
153 latency_threshold=adaptive_latency_threshold,
154 window_seconds=adaptive_window_seconds,
155 )
157 # Create circuit breaker if threshold > 0
158 breaker: Optional[CircuitBreaker] = None
159 if circuit_breaker_threshold > 0:
160 breaker = CircuitBreaker(
161 failure_threshold=circuit_breaker_threshold,
162 recovery_timeout=circuit_breaker_recovery,
163 )
165 def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
166 if inspect.iscoroutinefunction(func):
167 # Async function
168 runner = AsyncRunner()
170 @functools.wraps(func)
171 async def async_wrapper(*args, **kwargs):
172 # Generate key
173 key = func.__qualname__
174 if key_func is not _sentinel_none and callable(key_func):
175 key = f"{func.__qualname__}.{key_func(*args, **kwargs)}"
177 # Get pools
178 if timeout <= 0:
179 running_task = RunningTaskPool.get_pool(key)
180 else:
181 running_task = RunningTaskPool.get_pool(
182 key, window_interval=timeout
183 )
184 limiter = LimiterPool.get_pool(key, limit_size, limit_window)
186 # Check circuit breaker before execution
187 if breaker is not None and not breaker.allow_request():
188 raise RuntimeError(
189 f"Circuit breaker is open for {func.__qualname__}"
190 )
192 # Create context
193 context = ExecutionContext(
194 func, config, key, running_task, limiter
195 )
196 running_task.add(1)
198 # Execute
199 result = await runner.execute_impl(
200 context, config, *args, **kwargs
201 )
203 # Record to adaptive limiter
204 if adaptive_limiter is not None:
205 metrics = context.build_metrics()
206 adaptive_limiter.record(
207 latency_seconds=metrics.total_latency_ms / 1000.0,
208 success=context.result_exception is None,
209 )
211 # Update circuit breaker based on execution outcome
212 if breaker is not None:
213 if context.result_exception is not None:
214 breaker.record_failure()
215 else:
216 breaker.record_success()
218 # Invoke metrics callback
219 built_metrics = context.build_metrics()
220 if metrics_callback is not None:
221 metrics_callback(built_metrics)
222 if telemetry_exporter is not None:
223 built_metrics.export_to_otel(telemetry_exporter)
225 return result
227 return async_wrapper
229 else:
230 # Sync function
231 runner = SyncRunner()
232 executor = concurrent.futures.ThreadPoolExecutor()
233 _register_executor(executor)
235 @functools.wraps(func)
236 def sync_wrapper(*args, **kwargs):
237 # Generate key
238 key = func.__qualname__
239 if key_func is not _sentinel_none and callable(key_func):
240 key = f"{func.__qualname__}.{key_func(*args, **kwargs)}"
242 # Get pools
243 if timeout <= 0:
244 running_task = RunningTaskPool.get_pool(key)
245 else:
246 running_task = RunningTaskPool.get_pool(
247 key, window_interval=timeout
248 )
249 limiter = LimiterPool.get_pool(key, limit_size, limit_window)
251 # Check circuit breaker before execution
252 if breaker is not None and not breaker.allow_request():
253 raise RuntimeError(
254 f"Circuit breaker is open for {func.__qualname__}"
255 )
257 # Create context and execute with shared executor
258 runner.set_executor(executor)
259 context = ExecutionContext(
260 func, config, key, running_task, limiter
261 )
262 running_task.add(1)
264 result = runner.execute_impl(
265 context, config, *args, **kwargs
266 )
268 # Record to adaptive limiter
269 if adaptive_limiter is not None:
270 metrics = context.build_metrics()
271 adaptive_limiter.record(
272 latency_seconds=metrics.total_latency_ms / 1000.0,
273 success=context.result_exception is None,
274 )
276 # Update circuit breaker based on execution outcome
277 if breaker is not None:
278 if context.result_exception is not None:
279 breaker.record_failure()
280 else:
281 breaker.record_success()
283 # Invoke metrics callback
284 built_metrics = context.build_metrics()
285 if metrics_callback is not None:
286 metrics_callback(built_metrics)
287 if telemetry_exporter is not None:
288 built_metrics.export_to_otel(telemetry_exporter)
290 return result
292 return sync_wrapper
294 return decorator