Coverage for /usr/local/lib/python3.12/site-packages/prefect/server/services/cancellation_cleanup.py: 48%

157 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 02:04 +0000

1""" 

2The CancellationCleanup service. Responsible for cancelling tasks and subflows that haven't finished. 

3""" 

4 

5import datetime 

6import logging 

7from typing import Annotated 

8from uuid import NAMESPACE_URL, UUID, uuid5 

9 

10import sqlalchemy as sa 

11from docket import CurrentDocket, Depends, Docket, Logged, Perpetual, Retry 

12 

13import prefect.server.models as models 

14from prefect._internal.uuid7 import uuid7 

15from prefect.client.schemas.worker_channel import CANCELLING_TIMEOUT_TEARDOWN 

16from prefect.logging import get_logger 

17from prefect.server.database import PrefectDBInterface, provide_database_interface 

18from prefect.server.exceptions import ObjectNotFoundError 

19from prefect.server.schemas import filters, responses, states 

20from prefect.server.services.perpetual_services import perpetual_service 

21from prefect.server.worker_communication.cleanup_queue import ( 

22 CleanupQueueMessage, 

23 WorkerCleanupQueue, 

24 get_worker_cleanup_queue, 

25) 

26from prefect.settings.context import get_current_settings 

27from prefect.types._datetime import now 

28 

29NON_TERMINAL_STATES = list(set(states.StateType) - states.TERMINAL_STATES) 

30CANCELLING_TIMEOUT_CANCELLED_MESSAGE = ( 

31 "Flow run cancellation timed out; marked this flow run as Cancelled." 

32) 

33CANCELLING_TIMEOUT_CHECK_KEY_PREFIX = "cancelling-timeout" 

34PUSH_WORK_POOL_TYPE_SUFFIX = ":push" 

35MANAGED_WORK_POOL_TYPE_SUFFIX = ":managed" 

36logger: logging.Logger = get_logger(__name__) 

37 

38_service_cleanup_queue: WorkerCleanupQueue | None = None 

39_service_cleanup_queue_storage: str | None = None 

40 

41 

42def _get_service_worker_cleanup_queue() -> WorkerCleanupQueue: 

43 global _service_cleanup_queue, _service_cleanup_queue_storage 

44 

45 storage = get_current_settings().server.worker_channel.cleanup_queue_storage 

46 if _service_cleanup_queue is None or _service_cleanup_queue_storage != storage: 

47 _service_cleanup_queue = get_worker_cleanup_queue() 

48 _service_cleanup_queue_storage = storage 

49 

50 return _service_cleanup_queue 

51 

52 

53def cancelling_timeout_check_key(flow_run_id: UUID) -> str: 

54 return f"{CANCELLING_TIMEOUT_CHECK_KEY_PREFIX}:{flow_run_id}" 

55 

56 

57async def schedule_cancelling_timeout_check( 

58 *, 

59 docket: Docket, 

60 flow_run_id: UUID, 

61 flow_run_state_id: UUID, 

62 when: datetime.datetime, 

63) -> None: 

64 await docket.replace( 

65 handle_cancelling_timeout, 

66 key=cancelling_timeout_check_key(flow_run_id), 

67 when=when, 

68 )( 

69 flow_run_id=flow_run_id, 

70 flow_run_state_id=flow_run_state_id, 

71 timeout_cancelled_state_id=uuid7(), 

72 ) 

73 

74 

75async def maybe_schedule_cancelling_timeout_check_for_state( 

76 *, 

77 docket: Docket, 

78 flow_run_id: UUID, 

79 state: states.State | None, 

80) -> None: 

81 settings = get_current_settings().server.services.cancellation_cleanup 

82 if ( 82 ↛ 91line 82 didn't jump to line 91 because the condition on line 82 was always true

83 not settings.enabled 

84 or state is None 

85 or state.type != states.StateType.CANCELLING 

86 or state.id is None 

87 or state.timestamp is None 

88 ): 

89 return 

90 

91 await schedule_cancelling_timeout_check( 

92 docket=docket, 

93 flow_run_id=flow_run_id, 

94 flow_run_state_id=state.id, 

95 when=state.timestamp 

96 + datetime.timedelta(seconds=settings.cancelling_timeout_seconds), 

97 ) 

98 

99 

100# Docket task function for cancelling child task runs of a cancelled flow run 

101async def cancel_child_task_runs( 

102 flow_run_id: Annotated[UUID, Logged], 

103 *, 

104 db: PrefectDBInterface = Depends(provide_database_interface), 

105) -> None: 

106 """Cancel child task runs of a cancelled flow run (docket task).""" 

107 async with db.session_context() as session: 

108 child_task_runs = await models.task_runs.read_task_runs( 

109 session, 

110 flow_run_filter=filters.FlowRunFilter( 

111 id=filters.FlowRunFilterId(any_=[flow_run_id]) 

112 ), 

113 task_run_filter=filters.TaskRunFilter( 

114 state=filters.TaskRunFilterState( 

115 type=filters.TaskRunFilterStateType(any_=NON_TERMINAL_STATES) 

116 ) 

117 ), 

118 limit=100, 

119 ) 

120 

121 for task_run in child_task_runs: 

122 async with db.session_context(begin_transaction=True) as session: 

123 await models.task_runs.set_task_run_state( 

124 session=session, 

125 task_run_id=task_run.id, 

126 state=states.Cancelled(message="The parent flow run was cancelled."), 

127 force=True, 

128 ) 

129 

130 

131# Docket task function for cancelling a subflow run whose parent was cancelled 

132async def cancel_subflow_run( 

133 subflow_run_id: Annotated[UUID, Logged], 

134 *, 

135 docket: Docket = CurrentDocket(), 

136 db: PrefectDBInterface = Depends(provide_database_interface), 

137) -> None: 

138 """Cancel a subflow run whose parent flow run was cancelled (docket task).""" 

139 async with db.session_context() as session: 

140 flow_run = await models.flow_runs.read_flow_run( 

141 session, flow_run_id=subflow_run_id 

142 ) 

143 

144 if not flow_run or not flow_run.parent_task_run_id or not flow_run.state: 

145 return 

146 

147 if flow_run.state.type in states.TERMINAL_STATES: 

148 return 

149 

150 parent_task_run = await models.task_runs.read_task_run( 

151 session, task_run_id=flow_run.parent_task_run_id 

152 ) 

153 

154 if not parent_task_run or not parent_task_run.flow_run_id: 154 ↛ 157line 154 didn't jump to line 157 because the condition on line 154 was always true

155 return 

156 

157 containing_flow_run = await models.flow_runs.read_flow_run( 

158 session, flow_run_id=parent_task_run.flow_run_id 

159 ) 

160 

161 if ( 161 ↛ 168line 161 didn't jump to line 168 because the condition on line 161 was always true

162 containing_flow_run 

163 and containing_flow_run.state 

164 and containing_flow_run.state.type != states.StateType.CANCELLED 

165 ): 

166 return 

167 

168 if ( 168 ↛ 174line 168 didn't jump to line 174 because the condition on line 168 was always true

169 flow_run.deployment_id 

170 and flow_run.state.type == states.StateType.CANCELLING 

171 ): 

172 return 

173 

174 if flow_run.deployment_id: 

175 state = states.Cancelling(message="The parent flow run was cancelled.") 

176 else: 

177 state = states.Cancelled(message="The parent flow run was cancelled.") 

178 

179 async with db.session_context(begin_transaction=True) as session: 

180 state_result = await models.flow_runs.set_flow_run_state( 

181 session=session, 

182 flow_run_id=subflow_run_id, 

183 state=state, 

184 ) 

185 

186 if state_result.status == responses.SetStateStatus.ACCEPT: 186 ↛ exitline 186 didn't return from function 'cancel_subflow_run' because the condition on line 186 was always true

187 await maybe_schedule_cancelling_timeout_check_for_state( 

188 docket=docket, 

189 flow_run_id=subflow_run_id, 

190 state=state_result.state, 

191 ) 

192 

193 

194async def handle_cancelling_timeout( 

195 flow_run_id: Annotated[UUID, Logged], 

196 flow_run_state_id: Annotated[UUID, Logged], 

197 timeout_cancelled_state_id: Annotated[UUID, Logged], 

198 *, 

199 db: PrefectDBInterface = Depends(provide_database_interface), 

200 cleanup_queue: WorkerCleanupQueue = Depends(_get_service_worker_cleanup_queue), 

201 retry: Retry = Retry.forever(delay=datetime.timedelta(seconds=0.5)), 

202) -> CleanupQueueMessage | None: 

203 """Handle a scheduled CANCELLING timeout check for a single flow run.""" 

204 settings = get_current_settings().server.services.cancellation_cleanup 

205 

206 async with db.session_context(begin_transaction=True) as session: 

207 flow_run_result = await session.execute( 

208 sa.select( 

209 db.FlowRun, 

210 db.WorkQueue.work_pool_id, 

211 db.WorkPool.type, 

212 ) 

213 .outerjoin(db.WorkQueue, db.FlowRun.work_queue_id == db.WorkQueue.id) 

214 .outerjoin(db.WorkPool, db.WorkQueue.work_pool_id == db.WorkPool.id) 

215 .where(db.FlowRun.id == flow_run_id) 

216 .with_for_update(of=db.FlowRun) 

217 ) 

218 row = flow_run_result.first() 

219 if row is None: 

220 logger.info( 

221 "Flow run %s no longer exists, skipping CANCELLING timeout", 

222 flow_run_id, 

223 ) 

224 return None 

225 

226 flow_run, work_pool_id, work_pool_type = row 

227 timeout_cancelled_state_already_committed = ( 

228 flow_run.state_type == states.StateType.CANCELLED 

229 and flow_run.state_id == timeout_cancelled_state_id 

230 ) 

231 if not timeout_cancelled_state_already_committed: 

232 if flow_run.state_id != flow_run_state_id: 

233 logger.info( 

234 "Flow run %s is in a new state, skipping CANCELLING timeout", 

235 flow_run.id, 

236 ) 

237 return None 

238 

239 if flow_run.state_type != states.StateType.CANCELLING: 

240 logger.info( 

241 "Flow run %s is no longer CANCELLING, skipping timeout", 

242 flow_run.id, 

243 ) 

244 return None 

245 

246 if flow_run.state_timestamp is None: 

247 logger.info( 

248 "Flow run %s has no CANCELLING state timestamp, skipping timeout", 

249 flow_run.id, 

250 ) 

251 return None 

252 

253 deadline = flow_run.state_timestamp + datetime.timedelta( 

254 seconds=settings.cancelling_timeout_seconds 

255 ) 

256 if deadline > now("UTC"): 

257 logger.info( 

258 "Flow run %s has not reached the CANCELLING timeout, rescheduling", 

259 flow_run.id, 

260 ) 

261 retry.at(deadline) 

262 return None 

263 

264 try: 

265 from prefect.server.orchestration.core_policy import CoreFlowPolicy 

266 

267 state_result = await models.flow_runs.set_flow_run_state( 

268 session=session, 

269 flow_run_id=flow_run.id, 

270 state=states.Cancelled( 

271 id=timeout_cancelled_state_id, 

272 message=CANCELLING_TIMEOUT_CANCELLED_MESSAGE, 

273 ), 

274 flow_policy=CoreFlowPolicy, 

275 ) 

276 except ObjectNotFoundError: 

277 logger.info( 

278 "Flow run %s was removed during CANCELLING timeout, skipping", 

279 flow_run.id, 

280 ) 

281 return None 

282 

283 if state_result.status != responses.SetStateStatus.ACCEPT: 

284 logger.info( 

285 "CANCELLING timeout state transition for flow run %s was not " 

286 "accepted", 

287 flow_run.id, 

288 extra={ 

289 "status": state_result.status.value, 

290 "reason": getattr(state_result.details, "reason", None), 

291 }, 

292 ) 

293 return None 

294 

295 if ( 

296 state_result.state is None 

297 or state_result.state.id != timeout_cancelled_state_id 

298 or state_result.state.type != states.StateType.CANCELLED 

299 ): 

300 return None 

301 

302 if ( 

303 flow_run.state_type != states.StateType.CANCELLED 

304 or flow_run.state_id != timeout_cancelled_state_id 

305 ): 

306 logger.info( 

307 "Flow run %s is no longer in the timeout Cancelled state, skipping " 

308 "CANCELLING timeout cleanup", 

309 flow_run.id, 

310 ) 

311 return None 

312 

313 if flow_run.work_queue_id is None or work_pool_id is None: 313 ↛ 322line 313 didn't jump to line 322 because the condition on line 313 was always true

314 logger.warning( 

315 "Skipping CANCELLING timeout cleanup for unroutable flow run: " 

316 "flow_run_id=%s work_queue_id=%s", 

317 flow_run.id, 

318 flow_run.work_queue_id, 

319 ) 

320 return None 

321 

322 work_pool_type = str(work_pool_type) if work_pool_type else None 

323 if work_pool_type and work_pool_type.endswith( 

324 (PUSH_WORK_POOL_TYPE_SUFFIX, MANAGED_WORK_POOL_TYPE_SUFFIX) 

325 ): 

326 logger.info( 

327 "Skipping CANCELLING timeout cleanup for workerless work pool flow " 

328 "run: flow_run_id=%s work_queue_id=%s work_pool_id=%s " 

329 "work_pool_type=%s", 

330 flow_run.id, 

331 flow_run.work_queue_id, 

332 work_pool_id, 

333 work_pool_type, 

334 ) 

335 return None 

336 

337 cleanup_enqueue_parameters = { 

338 "message_id": uuid5( 

339 NAMESPACE_URL, 

340 "prefect:" 

341 f"{CANCELLING_TIMEOUT_TEARDOWN}:" 

342 f"{flow_run.id}:{timeout_cancelled_state_id}", 

343 ), 

344 "idempotency_key": ( 

345 f"{CANCELLING_TIMEOUT_TEARDOWN}:" 

346 f"{flow_run.id}:{timeout_cancelled_state_id}" 

347 ), 

348 "work_pool_id": work_pool_id, 

349 "work_queue_id": flow_run.work_queue_id, 

350 "kind": CANCELLING_TIMEOUT_TEARDOWN, 

351 "target": { 

352 "flow_run_id": str(flow_run.id), 

353 "infrastructure_pid": flow_run.infrastructure_pid, 

354 }, 

355 } 

356 

357 message = await cleanup_queue.enqueue(**cleanup_enqueue_parameters) 

358 

359 logger.info( 

360 "Enqueued CANCELLING timeout cleanup message: flow_run_id=%s message_id=%s", 

361 flow_run_id, 

362 message.message_id, 

363 ) 

364 return message 

365 

366 

367@perpetual_service( 

368 enabled_getter=lambda: ( 

369 get_current_settings().server.services.cancellation_cleanup.enabled 

370 ), 

371) 

372async def ensure_cancelling_timeout_checks( 

373 docket: Docket = CurrentDocket(), 

374 db: PrefectDBInterface = Depends(provide_database_interface), 

375 perpetual: Perpetual = Perpetual( 

376 automatic=True, 

377 every=datetime.timedelta( 

378 seconds=get_current_settings().server.services.cancellation_cleanup.loop_seconds 

379 ), 

380 ), 

381) -> None: 

382 """Seed Docket timeout checks for flow runs already in CANCELLING.""" 

383 

384 settings = get_current_settings().server.services.cancellation_cleanup 

385 batch_size = 200 

386 last_state_timestamp: datetime.datetime | None = None 

387 last_flow_run_id: UUID | None = None 

388 

389 while True: 

390 query_conditions = [ 

391 db.FlowRun.state_type == states.StateType.CANCELLING, 

392 db.FlowRun.state_id.is_not(None), 

393 db.FlowRun.state_timestamp.is_not(None), 

394 ] 

395 if last_state_timestamp is not None and last_flow_run_id is not None: 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true

396 query_conditions.append( 

397 sa.or_( 

398 db.FlowRun.state_timestamp > last_state_timestamp, 

399 sa.and_( 

400 db.FlowRun.state_timestamp == last_state_timestamp, 

401 db.FlowRun.id > last_flow_run_id, 

402 ), 

403 ) 

404 ) 

405 

406 cancelling_flow_query = ( 

407 sa.select( 

408 db.FlowRun.id, 

409 db.FlowRun.state_id, 

410 db.FlowRun.state_timestamp, 

411 ) 

412 .where(*query_conditions) 

413 .order_by(db.FlowRun.state_timestamp, db.FlowRun.id) 

414 .limit(batch_size) 

415 ) 

416 

417 async with db.session_context() as session: 

418 result = await session.execute(cancelling_flow_query) 

419 cancelling_flow_runs = result.all() 

420 

421 if not cancelling_flow_runs: 421 ↛ 424line 421 didn't jump to line 424 because the condition on line 421 was always true

422 break 

423 

424 for flow_run_id, flow_run_state_id, state_timestamp in cancelling_flow_runs: 

425 await docket.add( 

426 handle_cancelling_timeout, 

427 key=cancelling_timeout_check_key(flow_run_id), 

428 when=state_timestamp 

429 + datetime.timedelta(seconds=settings.cancelling_timeout_seconds), 

430 )( 

431 flow_run_id=flow_run_id, 

432 flow_run_state_id=flow_run_state_id, 

433 timeout_cancelled_state_id=uuid7(), 

434 ) 

435 

436 last_flow_run_id, _, last_state_timestamp = cancelling_flow_runs[-1] 

437 if len(cancelling_flow_runs) < batch_size: 

438 break 

439 

440 

441# Perpetual monitor for cancelled flow runs with child tasks (find and flood pattern) 

442@perpetual_service( 

443 enabled_getter=lambda: ( 

444 get_current_settings().server.services.cancellation_cleanup.enabled 

445 ), 

446) 

447async def monitor_cancelled_flow_runs( 

448 docket: Docket = CurrentDocket(), 

449 db: PrefectDBInterface = Depends(provide_database_interface), 

450 perpetual: Perpetual = Perpetual( 

451 automatic=True, 

452 every=datetime.timedelta( 

453 seconds=get_current_settings().server.services.cancellation_cleanup.loop_seconds 

454 ), 

455 ), 

456) -> None: 

457 """Monitor for cancelled flow runs and schedule child task cancellation.""" 

458 

459 batch_size = 200 

460 cancelled_flow_query = ( 

461 sa.select(db.FlowRun.id) 

462 .where( 

463 db.FlowRun.state_type == states.StateType.CANCELLED, 

464 db.FlowRun.end_time.is_not(None), 

465 db.FlowRun.end_time >= (now("UTC") - datetime.timedelta(days=1)), 

466 ) 

467 .order_by(db.FlowRun.id) 

468 .limit(batch_size) 

469 ) 

470 

471 async with db.session_context() as session: 

472 flow_run_result = await session.execute(cancelled_flow_query) 

473 flow_run_ids = flow_run_result.scalars().all() 

474 

475 for flow_run_id in flow_run_ids: 475 ↛ 476line 475 didn't jump to line 476 because the loop on line 475 never started

476 await docket.add(cancel_child_task_runs)(flow_run_id) 

477 

478 

479# Perpetual monitor for subflow runs that need cancellation (find and flood pattern) 

480@perpetual_service( 

481 enabled_getter=lambda: ( 

482 get_current_settings().server.services.cancellation_cleanup.enabled 

483 ), 

484) 

485async def monitor_subflow_runs( 

486 docket: Docket = CurrentDocket(), 

487 db: PrefectDBInterface = Depends(provide_database_interface), 

488 perpetual: Perpetual = Perpetual( 

489 automatic=True, 

490 every=datetime.timedelta( 

491 seconds=get_current_settings().server.services.cancellation_cleanup.loop_seconds 

492 ), 

493 ), 

494) -> None: 

495 """Monitor for subflow runs that need to be cancelled.""" 

496 

497 batch_size = 200 

498 subflow_query = ( 

499 sa.select(db.FlowRun.id) 

500 .where( 

501 sa.or_( 

502 db.FlowRun.state_type == states.StateType.PENDING, 

503 db.FlowRun.state_type == states.StateType.SCHEDULED, 

504 db.FlowRun.state_type == states.StateType.RUNNING, 

505 db.FlowRun.state_type == states.StateType.PAUSED, 

506 db.FlowRun.state_type == states.StateType.CANCELLING, 

507 ), 

508 db.FlowRun.parent_task_run_id.is_not(None), 

509 ) 

510 .order_by(db.FlowRun.id) 

511 .limit(batch_size) 

512 ) 

513 

514 async with db.session_context() as session: 

515 subflow_run_result = await session.execute(subflow_query) 

516 subflow_run_ids = subflow_run_result.scalars().all() 

517 

518 for subflow_run_id in subflow_run_ids: 518 ↛ 519line 518 didn't jump to line 519 because the loop on line 518 never started

519 await docket.add(cancel_subflow_run)(subflow_run_id)