Coverage for qreward / utils / scheduler / async_runner.py: 100.00%

59 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-23 19:32 +0000

1"""Async runner implementation for asynchronous task execution.""" 

2 

3import asyncio 

4import logging 

5from typing import Any, Callable, List, Optional, Sequence 

6 

7from .base import BaseRunner, _CancelledErrorGroups 

8from .config import ScheduleConfig 

9from .context import ExecutionContext 

10 

11logger = logging.getLogger(__name__) 

12 

13 

14class AsyncRunner(BaseRunner): 

15 """Async runner implementation.""" 

16 

17 def create_task( 

18 self, func: Callable, *args, **kwargs 

19 ) -> asyncio.Task: 

20 """Create an asyncio task.""" 

21 return asyncio.create_task(func(*args, **kwargs)) 

22 

23 def get_task_result( 

24 self, task: asyncio.Task 

25 ) -> Any: 

26 """Get result from a completed asyncio task.""" 

27 return task.result() 

28 

29 def get_task_exception( 

30 self, task: asyncio.Task 

31 ) -> Optional[BaseException]: 

32 """Get exception from a completed asyncio task.""" 

33 return task.exception() 

34 

35 def is_task_cancelled( 

36 self, task: asyncio.Task 

37 ) -> bool: 

38 """Check if an asyncio task was cancelled.""" 

39 return task.cancelled() 

40 

41 async def _cancel_tasks( 

42 self, 

43 pending: Sequence[asyncio.Task], 

44 done: List[asyncio.Task], 

45 retry_interval: float, 

46 ) -> None: # pragma: no cover 

47 """Cancel pending tasks and consume done tasks. 

48 

49 Args: 

50 pending: Pending tasks to cancel. 

51 done: Done tasks to consume. 

52 retry_interval: Time to wait for cancellation. 

53 """ 

54 while done: 

55 try: 

56 done.pop() 

57 except _CancelledErrorGroups: 

58 pass 

59 

60 if pending: 

61 for task in pending: 

62 if not task.done(): 

63 task.cancel() 

64 try: 

65 await asyncio.wait_for( 

66 asyncio.gather(*pending, return_exceptions=True), 

67 timeout=retry_interval if retry_interval > 0 else None, 

68 ) 

69 except _CancelledErrorGroups: 

70 pass 

71 

72 async def execute_impl( 

73 self, 

74 context: ExecutionContext, 

75 config: ScheduleConfig, 

76 *args: Any, 

77 **kwargs: Any, 

78 ) -> Any: 

79 """Async execution implementation. 

80 

81 Args: 

82 context: Execution context. 

83 config: Schedule configuration. 

84 *args: Function arguments. 

85 **kwargs: Function keyword arguments. 

86 

87 Returns: 

88 Function result. 

89 """ 

90 run_tasks: List[asyncio.Task] = [] 

91 

92 try: 

93 while not context.finish and ( 

94 context.cur_times <= config.retry_times or len(run_tasks) > 0 

95 ): 

96 # 1. Submit new tasks if allowed 

97 if context.can_submit_task(len(run_tasks)): 

98 limiter_timeout = context.get_limiter_timeout( 

99 len(run_tasks) 

100 ) 

101 

102 if not context.limiter or await ( 

103 context.limiter.async_allow( 

104 limiter_timeout if limiter_timeout > 0 else None 

105 ) 

106 ): 

107 if context.is_hedge_submit( 

108 len(run_tasks) 

109 ): # pragma: no cover 

110 context.record_hedge() 

111 elif ( 

112 context.result_exception is not None 

113 ): # pragma: no cover 

114 context.record_exception(context.result_exception) 

115 context.result_exception = None 

116 

117 run_tasks.append( 

118 asyncio.create_task(context.func(*args, **kwargs)) 

119 ) 

120 context.mark_task_submitted() 

121 

122 # 2. Wait for tasks 

123 done, pending = set(), set() 

124 if run_tasks: # pragma: no branch 

125 cur_timeout = context.compute_timeout(len(run_tasks)) 

126 if cur_timeout > 0: 

127 done, pending = await asyncio.wait( 

128 run_tasks, 

129 timeout=cur_timeout, 

130 return_when=asyncio.FIRST_COMPLETED, 

131 ) 

132 else: # pragma: no cover 

133 done, pending = await asyncio.wait( 

134 run_tasks, return_when=asyncio.FIRST_COMPLETED 

135 ) 

136 

137 # 3. Process results 

138 can_add_speed_up = context.can_increase_speed() 

139 cancel_wait = self._compute_cancel_wait(config, context) 

140 

141 for finished in list(done): 

142 if finished.cancelled(): # pragma: no cover 

143 run_tasks.remove(finished) 

144 continue 

145 

146 exception = finished.exception() 

147 if exception is None: 

148 # Success 

149 await self._cancel_tasks( 

150 list(pending), list(done), cancel_wait 

151 ) 

152 context.result = finished.result() 

153 context.result_exception = None 

154 context.finish = True 

155 run_tasks.remove(finished) 

156 return context.result 

157 

158 # Handle exception 

159 run_tasks.remove(finished) 

160 retryable, increased = self._handle_exception( 

161 context, config, finished, can_add_speed_up 

162 ) 

163 if increased: 

164 can_add_speed_up = False 

165 

166 if retryable: 

167 await asyncio.sleep(cancel_wait) 

168 break 

169 else: 

170 await self._cancel_tasks( 

171 list(pending), list(done), cancel_wait 

172 ) 

173 context.finish = True 

174 break 

175 

176 # 4. Check timeout 

177 if config.timeout > 0 and context.elapsed > config.timeout: 

178 context.result_exception = asyncio.TimeoutError( 

179 f"execute more than {config.timeout} seconds" 

180 ) 

181 await self._cancel_tasks( 

182 list(pending) + run_tasks, [], cancel_wait 

183 ) 

184 context.finish = True 

185 

186 # 5. Return result 

187 return self._return_result(context, config, args, kwargs) 

188 

189 finally: 

190 context.running_task_pool.add(-1) 

191 if config.debug: 

192 self._log_finish(context)