Coverage for core / src / sensorkit / core / impl / program.py: 85%

190 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-02 00:03 +0000

1# SPDX-License-Identifier: Apache-2.0 

2from __future__ import annotations 

3 

4import asyncio 

5import contextlib 

6from _contextvars import ContextVar 

7from datetime import datetime 

8from typing import Any, Callable, ClassVar, override 

9 

10from intervaltree import IntervalTree 

11from loguru import logger 

12 

13from sensorkit.backend.base import Entity, KVError 

14from sensorkit.backend.event import Event 

15from sensorkit.backend.request import CallContext 

16from sensorkit.core.entity import EntityInfo 

17from sensorkit.core.executor import TaskFactoryFunc, TaskingLoop 

18from sensorkit.core.impl.entity import EntityImpl 

19from sensorkit.core.program import ( 

20 OfferInterval, 

21 ProgramActiveState, 

22 ProgramActiveStateRequest, 

23 ProgramEnableState, 

24 ProgramEnableStateRequest, 

25 ProgramInterface, 

26 ProgramOffering, 

27 ProgramState, 

28 ProgramTaskingState, 

29 set_active_state_request, 

30 set_enable_state_request, 

31) 

32from sensorkit.core.task import TaskContextMap, TaskExecution 

33 

34 

35class ProgramOffers: 

36 """Manages an IntervalTree of offer windows and signals updates to waiters.""" 

37 

38 def __init__(self): 

39 self._tree = IntervalTree() 

40 self._updated = asyncio.Event() 

41 

42 def wait(self): 

43 """Return an awaitable that resolves when the offer windows have been updated.""" 

44 return self._updated.wait() 

45 

46 def poll(self): 

47 """Return True and clear the update flag if a new update is pending, else False.""" 

48 if self._updated.is_set(): 

49 self._updated.clear() 

50 return True 

51 

52 return False 

53 

54 def get_offer_windows(self) -> list[OfferInterval]: 

55 """Return a sorted list of merged offer windows from the current interval tree.""" 

56 # Create a tree that *references* the offer tree data and merge overlapping intervals. This 

57 # avoids an extra copy, and we know this is safe because `merge_overlaps` builds a new set 

58 # of intervals and does not modify `all_intervals`. 

59 assert_no_change = self._tree.all_intervals.copy() 

60 ref = IntervalTree(self._tree.all_intervals) 

61 ref.merge_overlaps() 

62 assert self._tree.all_intervals == assert_no_change 

63 return sorted(ref) 

64 

65 def add(self, start: datetime, end: datetime, obj: Any = None): 

66 """Add an offer window to the interval tree and signal waiters.""" 

67 self._tree.addi( 

68 begin=start, 

69 end=end, 

70 data=obj, 

71 ) 

72 self._updated.set() 

73 

74 def remove(self, start: datetime, end: datetime, obj: Any = None): 

75 """Remove an offer window from the interval tree, logging a warning if it does not exist.""" 

76 try: 

77 self._tree.removei( 

78 begin=start, 

79 end=end, 

80 data=obj, 

81 ) 

82 self._updated.set() 

83 except ValueError: 

84 logger.warning(f"Nonexistent offer window removed: {start} -> {end} {obj=}") 

85 

86 def clear(self): 

87 if not self._tree.is_empty(): 

88 self._tree.clear() 

89 self._updated.set() 

90 

91 

92class ProgramImpl(EntityImpl, ProgramInterface): 

93 """Helper for implementing server-side functionality of a Program.""" 

94 

95 current: ClassVar[ContextVar[ProgramImpl | None]] = ContextVar("current_program", default=None) 

96 

97 def __init__(self, **kwargs): 

98 super().__init__(**kwargs) 

99 

100 # Store the user-supplied intervals that are to make up the offered operating windows. 

101 self._offers = ProgramOffers() 

102 

103 self._enable_hooks: list[Callable[[], None]] = [] 

104 self._disable_hooks: list[Callable[[], None]] = [] 

105 self._state = ProgramState( 

106 enable_state=ProgramEnableState(enabled=False), 

107 active_state=ProgramActiveState(active=False, origin="init"), 

108 tasking_state=ProgramTaskingState(), 

109 ) 

110 self._state_lock = asyncio.Lock() 

111 self._task_factory: TaskFactoryFunc | None = None 

112 self._task_loop: TaskingLoop | None = None 

113 

114 @override 

115 async def init_impl(self): 

116 try: 

117 # Restore state. 

118 async with self._state_lock: 

119 self._state = await self.kv_get_model(ProgramState) 

120 logger.debug(f"restored program state: {self._state}") 

121 except KVError: 

122 logger.debug("initializing program state") 

123 await self.kv_put_model(self._state) 

124 

125 if self._state.active_state.active: 

126 # Our last known state was tasking. We aren't going to automatically start tasking, 

127 # but we can emit an event indicating the previous tasking state stopped, giving 

128 # observers an opportunity to react themselves. 

129 logger.debug("ending lingering tasking active state") 

130 await self._update_state( 

131 "active_state", 

132 ProgramActiveState(active=False, origin="init"), 

133 ) 

134 

135 @override 

136 async def attach_impl(self): 

137 if self._state.enable_state.enabled: 

138 await self._call_with_context(self._enable_hooks) 

139 

140 await self.handle_request(set_enable_state_request, self._set_enable_state) 

141 await self.handle_request(set_active_state_request, self._set_active_state) 

142 

143 @override 

144 def on_enable(self, func: Callable[[], None]): 

145 self._enable_hooks.append(func) 

146 return func 

147 

148 @override 

149 def on_disable(self, func: Callable[[], None]): 

150 self._disable_hooks.append(func) 

151 return func 

152 

153 @override 

154 def task_factory(self, func: TaskFactoryFunc): 

155 self._task_factory = func 

156 return func 

157 

158 async def _start_loop(self, contexts: TaskContextMap): 

159 if self._task_loop is not None: 

160 return 

161 

162 logger.debug(f"starting tasking loop with {contexts=}") 

163 

164 async def _on_task_change(execution: TaskExecution | None): 

165 # Publish the program's current tasking state as the loop starts/finishes each task. 

166 await self._update_state( 

167 "tasking_state", ProgramTaskingState(executing_task=execution) 

168 ) 

169 

170 # Create the tasking loop. 

171 self._task_loop = TaskingLoop( 

172 controller=self.sensorkit().controller( 

173 Entity.at(self._state.enable_state.controller) 

174 ), 

175 factory_func=self._task_factory, 

176 contexts=contexts, 

177 task_group=self.task_group, 

178 on_task_change=_on_task_change, 

179 ) 

180 

181 # Start a background task to make sure the end states are properly handled whether the 

182 # loop ends by request or by error. We take pains to do this before starting the loop 

183 # itself to eliminate data races and ensure event ordering. 

184 queue = asyncio.Queue() 

185 

186 async def _finalize_loop(): 

187 aio_task = await queue.get() 

188 

189 try: 

190 await aio_task 

191 finally: 

192 # Clear any lingering tasking state: the loop may have exited mid-task (error or 

193 # abort), where clearing is intentionally deferred to teardown here. 

194 if self._state.tasking_state.executing_task is not None: 

195 await self._update_state("tasking_state", ProgramTaskingState()) 

196 

197 await self._update_state( 

198 "active_state", 

199 ProgramActiveState(active=False, origin="request") 

200 if self._task_loop.stop_requested 

201 else ProgramActiveState(active=False, origin="error"), 

202 ) 

203 logger.info(f"Tasking loop exiting by {self._state.active_state.origin}") 

204 self._task_loop = None 

205 self._task_finalizer = None 

206 

207 self._task_finalizer = self.task_group.create_task(_finalize_loop()) 

208 

209 # Finally, we can start the tasking loop and update our state. 

210 with self.enter_context(): 

211 logger.info("Starting tasking loop") 

212 aio_task = self._task_loop.start() 

213 

214 await self._update_state( 

215 "active_state", 

216 ProgramActiveState(active=True, origin="request"), 

217 ) 

218 

219 # Tell the finalizer about the loop task. 

220 queue.put_nowait(aio_task) 

221 

222 async def _stop_loop(self, *, timeout=None): 

223 if self._task_loop is None: 

224 return 

225 

226 await self._update_state( 

227 "active_state", 

228 ProgramActiveState(active=True, origin=self._state.active_state.origin, stopping=True), 

229 ) 

230 

231 # Due to the await above, we have to check again whether the loop is still running, as it 

232 # may have been concurrently stopped. 

233 if self._task_loop is None: 

234 return 

235 

236 try: 

237 await self._task_loop.stop(timeout=timeout) 

238 except (Exception, asyncio.CancelledError) as e: 

239 logger.warning(f"Error while stopping tasking loop: {e}") 

240 

241 # Make sure the end state has been updated by the finalizer. 

242 if self._task_finalizer is not None: 

243 with contextlib.suppress(asyncio.CancelledError): 

244 await self._task_finalizer 

245 

246 async def _set_enable_state(self, request: ProgramEnableStateRequest): 

247 if not request.enable and not self._state.enable_state.enabled: 

248 return 

249 

250 enablement_changed = request.enable ^ self._state.enable_state.enabled 

251 controller_changed = request.controller != self._state.enable_state.controller 

252 

253 if not enablement_changed and not controller_changed: 

254 return 

255 

256 logger.info( 

257 f"{'Enabling' if request.enable else 'Disabling'} " 

258 f"{self.entity} for target {request.controller}" 

259 ) 

260 

261 await self._update_state( 

262 "enable_state", 

263 ProgramEnableState(enabled=request.enable, controller=request.controller), 

264 ) 

265 

266 if request.enable: 

267 # Call the enable hook. 

268 await self._call_with_context(self._enable_hooks) 

269 else: 

270 # Disabling the program implies setting the active state low. We consider this an abort 

271 # case; if the caller wants a graceful stop, they can explicitly stop tasking first. 

272 await self._stop_loop(timeout=0) 

273 

274 # Call the disable hook. 

275 await self._call_with_context(self._disable_hooks) 

276 

277 async def _set_active_state( 

278 self, 

279 request: ProgramActiveStateRequest, 

280 call: CallContext[None, None], 

281 ): 

282 with self.enter_context(): 

283 logger.info(f"Requested to {request.action} the tasking loop") 

284 

285 if request.action == "start": 

286 # Cannot activate tasking if we aren't enabled with a target Controller configured. 

287 if not self._state.enable_state.enabled or not self._state.enable_state.controller: 

288 call.reject(response=None) 

289 return 

290 

291 if ( 

292 self._state.active_state.active 

293 and request.contexts != self._state.active_state.contexts 

294 ): 

295 # Don't support restart-with-different-context in a single request. 

296 call.reject(response=None) 

297 return 

298 

299 call.accept(response=None) 

300 

301 if request.active_state() == self._state.active_state.active: 

302 # We are already in the requested state, so claim success. 

303 await call.succeed(result=None) 

304 return 

305 

306 try: 

307 match request.action: 

308 case "start": 

309 # Start a new tasking loop. 

310 with self.enter_context(): 

311 await self._start_loop(request.contexts) 

312 case "stop": 

313 # Stop the tasking loop gracefully. 

314 # TODO: add backstop timeout reflecting maximum task time 

315 await call.progress_from_task( 

316 self.task_group.create_task(self._stop_loop()), 

317 cadence=6, 

318 ttl=10, 

319 ) 

320 case "abort": 

321 # Abort the tasking loop immediately. 

322 with self.enter_context(): 

323 await self._stop_loop(timeout=0) 

324 except Exception as e: 

325 with self.enter_context(): 

326 logger.exception("Error setting active state") 

327 

328 await call.fail(f"{type(e).__name__} setting active state ({e})") 

329 else: 

330 await call.succeed(result=None) 

331 

332 async def _update_state(self, key: str, event: Event): 

333 async with self._state_lock: 

334 setattr(self._state, key, event) 

335 await self.emit_event(event) 

336 await self.kv_put_model(self._state) 

337 

338 @override 

339 def get_offers(self): 

340 return self._offers.get_offer_windows() 

341 

342 @override 

343 async def publish_offers(self): 

344 """Publish the current set of offers if they have changed.""" 

345 if self._offers.poll(): 

346 await self.publish( 

347 ProgramOffering(offer_windows=self._offers.get_offer_windows()) 

348 ) 

349 

350 @override 

351 def add_offer(self, start: datetime, end: datetime, obj: Any = None): 

352 self._offers.add(start, end, obj) 

353 

354 @override 

355 def remove_offer(self, start: datetime, end: datetime, obj: Any = None): 

356 self._offers.remove(start, end, obj) 

357 

358 @override 

359 def clear_offers(self): 

360 self._offers.clear() 

361 

362 @override 

363 def entity_info(self) -> EntityInfo: 

364 return EntityInfo(entity_type="program", details=None)