Coverage for /usr/local/lib/python3.12/site-packages/prefect/server/api/workers.py: 43%

354 statements  

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

1""" 

2Routes for interacting with work queue objects. 

3""" 

4 

5from dataclasses import dataclass 

6from logging import Logger 

7from typing import TYPE_CHECKING, Any, List, Optional 

8from uuid import UUID 

9 

10import sqlalchemy as sa 

11from fastapi import ( 

12 Body, 

13 Depends, 

14 HTTPException, 

15 Path, 

16 WebSocket, 

17 status, 

18) 

19from packaging.version import Version 

20from pydantic import ValidationError 

21from sqlalchemy.ext.asyncio import AsyncSession 

22 

23import prefect.server.api.dependencies as dependencies 

24import prefect.server.models as models 

25import prefect.server.schemas as schemas 

26from prefect._internal.uuid7 import uuid7 

27from prefect.client.schemas.worker_channel import ( 

28 CLEANUP_DELIVERY_CAPABILITY, 

29 WORK_POOL_SNAPSHOT_CAPABILITY, 

30 WORKER_HEARTBEAT_CAPABILITY, 

31 WorkerChannelCapability, 

32 WorkerChannelCloseReason, 

33 WorkerChannelProtocolError, 

34 WorkerHelloFrame, 

35 WorkerReadyFrame, 

36 WorkPoolSnapshotPayload, 

37 select_worker_channel_version, 

38 validate_worker_channel_frame, 

39) 

40from prefect.logging import get_logger 

41from prefect.server.api.validation import validate_job_variable_defaults_for_work_pool 

42from prefect.server.database import PrefectDBInterface, provide_database_interface 

43from prefect.server.models.deployments import mark_deployments_ready 

44from prefect.server.models.work_queues import ( 

45 emit_work_queue_status_event, 

46 mark_work_queues_ready, 

47) 

48from prefect.server.models.workers import emit_work_pool_status_event 

49from prefect.server.schemas.statuses import WorkQueueStatus 

50from prefect.server.utilities import subscriptions 

51from prefect.server.utilities import worker_channel as worker_channel_utils 

52from prefect.server.utilities.server import PrefectRouter 

53from prefect.server.worker_communication.cleanup_queue import ( 

54 WorkerCleanupQueue, 

55 get_worker_cleanup_queue, 

56) 

57from prefect.types import DateTime 

58from prefect.types._datetime import now 

59 

60if TYPE_CHECKING: 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true

61 from prefect.server.database.orm_models import WorkPool as ORMWorkPool 

62 from prefect.server.database.orm_models import WorkQueue as ORMWorkQueue 

63 

64router: PrefectRouter = PrefectRouter( 

65 prefix="/work_pools", 

66 tags=["Work Pools"], 

67) 

68logger: Logger = get_logger("prefect.server.api.workers") 

69 

70_OSS_WORKER_CHANNEL_REQUIRED_CAPABILITIES: list[WorkerChannelCapability] = [ 

71 WORKER_HEARTBEAT_CAPABILITY, 

72 WORK_POOL_SNAPSHOT_CAPABILITY, 

73] 

74 

75 

76# ----------------------------------------------------- 

77# -- 

78# -- 

79# -- Utility functions & dependencies 

80# -- 

81# -- 

82# ----------------------------------------------------- 

83 

84 

85class WorkerLookups: 

86 async def _get_work_pool_id_from_name( 

87 self, session: AsyncSession, work_pool_name: str 

88 ) -> UUID: 

89 """ 

90 Given a work pool name, return its ID. Used for translating 

91 user-facing APIs (which are name-based) to internal ones (which are 

92 id-based). 

93 """ 

94 work_pool = await models.workers.read_work_pool_by_name( 

95 session=session, 

96 work_pool_name=work_pool_name, 

97 ) 

98 if not work_pool: 

99 raise HTTPException( 

100 status_code=status.HTTP_404_NOT_FOUND, 

101 detail=f'Work pool "{work_pool_name}" not found.', 

102 ) 

103 

104 return work_pool.id 

105 

106 async def _get_default_work_queue_id_from_work_pool_name( 

107 self, session: AsyncSession, work_pool_name: str 

108 ): 

109 """ 

110 Given a work pool name, return the ID of its default queue. 

111 Used for translating user-facing APIs (which are name-based) 

112 to internal ones (which are id-based). 

113 """ 

114 work_pool = await models.workers.read_work_pool_by_name( 

115 session=session, 

116 work_pool_name=work_pool_name, 

117 ) 

118 if not work_pool: 

119 raise HTTPException( 

120 status_code=status.HTTP_404_NOT_FOUND, 

121 detail=f'Work pool "{work_pool_name}" not found.', 

122 ) 

123 

124 return work_pool.default_queue_id 

125 

126 async def _get_work_queue_from_name( 

127 self, 

128 session: AsyncSession, 

129 work_pool_name: str, 

130 work_queue_name: str, 

131 create_queue_if_not_found: bool = False, 

132 ) -> "ORMWorkQueue": 

133 """ 

134 Given a work pool name and work pool queue name, return the ID of the 

135 queue. Used for translating user-facing APIs (which are name-based) to 

136 internal ones (which are id-based). 

137 """ 

138 work_queue = await models.workers.read_work_queue_by_name( 

139 session=session, 

140 work_pool_name=work_pool_name, 

141 work_queue_name=work_queue_name, 

142 ) 

143 if not work_queue: 

144 if not create_queue_if_not_found: 

145 raise HTTPException( 

146 status_code=status.HTTP_404_NOT_FOUND, 

147 detail=( 

148 f"Work pool queue '{work_pool_name}/{work_queue_name}' not" 

149 " found." 

150 ), 

151 ) 

152 work_pool_id = await self._get_work_pool_id_from_name( 

153 session=session, work_pool_name=work_pool_name 

154 ) 

155 work_queue = await models.workers.create_work_queue( 

156 session=session, 

157 work_pool_id=work_pool_id, 

158 work_queue=schemas.actions.WorkQueueCreate(name=work_queue_name), 

159 ) 

160 

161 return work_queue 

162 

163 async def _get_work_queue_id_from_name( 

164 self, 

165 session: AsyncSession, 

166 work_pool_name: str, 

167 work_queue_name: str, 

168 create_queue_if_not_found: bool = False, 

169 ) -> UUID: 

170 queue = await self._get_work_queue_from_name( 

171 session=session, 

172 work_pool_name=work_pool_name, 

173 work_queue_name=work_queue_name, 

174 create_queue_if_not_found=create_queue_if_not_found, 

175 ) 

176 return queue.id 

177 

178 

179class WorkerChannelSetupError(Exception): 

180 def __init__(self, close_reason: WorkerChannelCloseReason, detail: str): 

181 super().__init__(detail) 

182 self.close_reason = close_reason 

183 self.detail = detail 

184 

185 

186@dataclass(frozen=True) 

187class WorkerChannelWorkPoolUpdateEvent: 

188 work_pool_id: UUID 

189 changed_fields: dict[str, dict[str, Any]] 

190 

191 

192def _worker_requested_cleanup_delivery(hello: WorkerHelloFrame) -> bool: 

193 return ( 

194 CLEANUP_DELIVERY_CAPABILITY in hello.payload.requested_capabilities 

195 and bool(hello.payload.handled_cleanup_kinds) 

196 and hello.payload.max_cleanup_concurrency > 0 

197 ) 

198 

199 

200def _accepted_worker_channel_capabilities( 

201 hello: WorkerHelloFrame, 

202 *, 

203 cleanup_queue_available: bool, 

204) -> list[WorkerChannelCapability]: 

205 accepted = list(_OSS_WORKER_CHANNEL_REQUIRED_CAPABILITIES) 

206 if cleanup_queue_available and _worker_requested_cleanup_delivery(hello): 

207 accepted.append(CLEANUP_DELIVERY_CAPABILITY) 

208 

209 return accepted 

210 

211 

212async def _receive_worker_hello(websocket: WebSocket) -> WorkerHelloFrame: 

213 try: 

214 message = await websocket.receive_json() 

215 frame = validate_worker_channel_frame(message) 

216 except ValidationError as exc: 

217 raise WorkerChannelSetupError( 

218 WorkerChannelCloseReason.PROTOCOL_ERROR, 

219 "Worker channel received a malformed hello frame", 

220 ) from exc 

221 except ValueError as exc: 

222 raise WorkerChannelSetupError( 

223 WorkerChannelCloseReason.PROTOCOL_ERROR, 

224 "Worker channel received invalid JSON during setup", 

225 ) from exc 

226 

227 if not isinstance(frame, WorkerHelloFrame): 

228 raise WorkerChannelSetupError( 

229 WorkerChannelCloseReason.PROTOCOL_ERROR, 

230 "Expected worker.hello.v1 during worker channel setup", 

231 ) 

232 

233 return frame 

234 

235 

236async def _resolve_worker_channel_work_pool( 

237 session: AsyncSession, 

238 work_pool_name: str, 

239 hello: WorkerHelloFrame, 

240) -> "ORMWorkPool": 

241 work_pool = await models.workers.read_work_pool_by_name( 

242 session=session, 

243 work_pool_name=work_pool_name, 

244 ) 

245 

246 default_base_job_template = hello.payload.default_base_job_template 

247 if work_pool is None: 

248 if not hello.payload.create_pool_if_not_found: 

249 raise WorkerChannelSetupError( 

250 WorkerChannelCloseReason.AUTHORIZATION_FAILED, 

251 "work_pool_not_found", 

252 ) 

253 

254 if work_pool_name.lower().startswith("prefect"): 

255 raise WorkerChannelSetupError( 

256 WorkerChannelCloseReason.AUTHORIZATION_FAILED, 

257 "work_pool_creation_unauthorized", 

258 ) 

259 

260 await validate_job_variable_defaults_for_work_pool( 

261 session, work_pool_name, default_base_job_template 

262 ) 

263 try: 

264 async with session.begin_nested(): 

265 work_pool = await models.workers.create_work_pool( 

266 session=session, 

267 work_pool=schemas.actions.WorkPoolCreate( 

268 name=work_pool_name, 

269 type=hello.payload.worker_type, 

270 base_job_template=default_base_job_template, 

271 ), 

272 ) 

273 except sa.exc.IntegrityError: 

274 work_pool = await models.workers.read_work_pool_by_name( 

275 session=session, 

276 work_pool_name=work_pool_name, 

277 ) 

278 if work_pool is None: 

279 raise 

280 return work_pool 

281 

282 return work_pool 

283 

284 

285async def _resolve_worker_channel_work_queues( 

286 session: AsyncSession, 

287 work_pool_id: UUID, 

288 work_pool_name: str, 

289 work_queue_names: list[str], 

290) -> list["ORMWorkQueue"]: 

291 if not work_queue_names: 

292 return list( 

293 await models.workers.read_work_queues( 

294 session=session, work_pool_id=work_pool_id 

295 ) 

296 ) 

297 

298 work_queues = [] 

299 for work_queue_name in dict.fromkeys(work_queue_names): 

300 work_queue = await models.workers.read_work_queue_by_name( 

301 session=session, 

302 work_pool_name=work_pool_name, 

303 work_queue_name=work_queue_name, 

304 ) 

305 if work_queue is None: 

306 raise WorkerChannelSetupError( 

307 WorkerChannelCloseReason.AUTHORIZATION_FAILED, 

308 "work_queue_not_found", 

309 ) 

310 work_queues.append(work_queue) 

311 

312 return work_queues 

313 

314 

315async def _build_worker_ready_frame( 

316 session: AsyncSession, 

317 work_pool_name: str, 

318 hello: WorkerHelloFrame, 

319 cleanup_queue_available: bool, 

320) -> tuple[WorkerReadyFrame, WorkerChannelWorkPoolUpdateEvent | None]: 

321 try: 

322 selected_channel_version = select_worker_channel_version( 

323 hello.payload.supported_channel_versions 

324 ) 

325 except WorkerChannelProtocolError as exc: 

326 raise WorkerChannelSetupError(exc.close_reason, str(exc)) from exc 

327 

328 work_pool = await _resolve_worker_channel_work_pool( 

329 session=session, 

330 work_pool_name=work_pool_name, 

331 hello=hello, 

332 ) 

333 work_queues = await _resolve_worker_channel_work_queues( 

334 session=session, 

335 work_pool_id=work_pool.id, 

336 work_pool_name=work_pool_name, 

337 work_queue_names=hello.payload.work_queue_names, 

338 ) 

339 default_base_job_template = hello.payload.default_base_job_template 

340 work_pool_update_event = None 

341 if not work_pool.base_job_template and default_base_job_template: 

342 previous_base_job_template = work_pool.base_job_template 

343 await validate_job_variable_defaults_for_work_pool( 

344 session, work_pool_name, default_base_job_template 

345 ) 

346 updated = await models.workers.update_work_pool( 

347 session=session, 

348 work_pool_id=work_pool.id, 

349 work_pool=schemas.actions.WorkPoolUpdate( 

350 base_job_template=default_base_job_template 

351 ), 

352 emit_update_event=False, 

353 emit_status_change=emit_work_pool_status_event, 

354 ) 

355 if updated: 

356 work_pool_update_event = WorkerChannelWorkPoolUpdateEvent( 

357 work_pool_id=work_pool.id, 

358 changed_fields={ 

359 "base_job_template": { 

360 "from": previous_base_job_template, 

361 "to": default_base_job_template, 

362 } 

363 }, 

364 ) 

365 refreshed = await models.workers.read_work_pool( 

366 session=session, work_pool_id=work_pool.id 

367 ) 

368 assert refreshed is not None 

369 work_pool = refreshed 

370 

371 try: 

372 worker = await models.workers.record_worker_heartbeat( 

373 session=session, 

374 work_pool=work_pool, 

375 worker_name=hello.payload.worker_name, 

376 heartbeat_interval_seconds=hello.payload.heartbeat_interval_seconds, 

377 emit_status_change=emit_work_pool_status_event, 

378 return_worker=True, 

379 ) 

380 except Exception as exc: 

381 raise WorkerChannelSetupError( 

382 WorkerChannelCloseReason.HEARTBEAT_PERSISTENCE_FAILED, 

383 "worker_channel_initial_heartbeat_failed", 

384 ) from exc 

385 assert worker is not None 

386 

387 refreshed_work_pool = await models.workers.read_work_pool( 

388 session=session, work_pool_id=work_pool.id 

389 ) 

390 assert refreshed_work_pool is not None 

391 initial_snapshot = WorkPoolSnapshotPayload( 

392 snapshot_sequence=1, 

393 reason="initial", 

394 work_pool=await worker_channel_utils.build_worker_channel_work_pool_snapshot( 

395 session=session, 

396 work_pool=refreshed_work_pool, 

397 ), 

398 ) 

399 

400 requested_capabilities = list(dict.fromkeys(hello.payload.requested_capabilities)) 

401 accepted = _accepted_worker_channel_capabilities( 

402 hello, 

403 cleanup_queue_available=cleanup_queue_available, 

404 ) 

405 accepted_set = set(accepted) 

406 rejected = [ 

407 capability 

408 for capability in requested_capabilities 

409 if capability not in accepted_set 

410 ] 

411 

412 if rejected: 

413 logger.debug( 

414 "Worker channel capabilities rejected: " 

415 "work_pool=%s worker_name=%s rejected=%s", 

416 work_pool_name, 

417 hello.payload.worker_name, 

418 rejected, 

419 ) 

420 

421 return ( 

422 WorkerReadyFrame( 

423 type="worker.ready.v1", 

424 id=uuid7(), 

425 sent_at=now("UTC"), 

426 payload={ 

427 "consumer_id": hello.payload.consumer_id, 

428 "worker_id": None, 

429 "selected_channel_version": selected_channel_version, 

430 "effective_heartbeat_interval_seconds": ( 

431 hello.payload.heartbeat_interval_seconds 

432 ), 

433 "accepted_capabilities": accepted, 

434 "rejected_capabilities": rejected, 

435 "effective_max_cleanup_concurrency": ( 

436 hello.payload.max_cleanup_concurrency 

437 if CLEANUP_DELIVERY_CAPABILITY in accepted_set 

438 else 0 

439 ), 

440 "resolved_work_queues": [ 

441 {"id": work_queue.id, "name": work_queue.name} 

442 for work_queue in work_queues 

443 ], 

444 "initial_snapshot": initial_snapshot, 

445 }, 

446 ), 

447 work_pool_update_event, 

448 ) 

449 

450 

451# ----------------------------------------------------- 

452# -- 

453# -- 

454# -- Worker Pools 

455# -- 

456# -- 

457# ----------------------------------------------------- 

458 

459 

460@router.post("/", status_code=status.HTTP_201_CREATED) 

461async def create_work_pool( 

462 work_pool: schemas.actions.WorkPoolCreate, 

463 db: PrefectDBInterface = Depends(provide_database_interface), 

464 prefect_client_version: Optional[str] = Depends( 

465 dependencies.get_prefect_client_version 

466 ), 

467) -> schemas.responses.WorkPoolResponse: 

468 """ 

469 Creates a new work pool. If a work pool with the same 

470 name already exists, an error will be raised. 

471 

472 For more information, see https://docs.prefect.io/v3/concepts/work-pools. 

473 """ 

474 if work_pool.name.lower().startswith("prefect"): 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true

475 raise HTTPException( 

476 status_code=status.HTTP_403_FORBIDDEN, 

477 detail="Work pools starting with 'Prefect' are reserved for internal use.", 

478 ) 

479 

480 try: 

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

482 await validate_job_variable_defaults_for_work_pool( 

483 session, work_pool.name, work_pool.base_job_template 

484 ) 

485 model = await models.workers.create_work_pool( 

486 session=session, work_pool=work_pool 

487 ) 

488 

489 await emit_work_pool_status_event( 

490 event_id=uuid7(), 

491 occurred=now("UTC"), 

492 pre_update_work_pool=None, 

493 work_pool=model, 

494 ) 

495 

496 ret = schemas.responses.WorkPoolResponse.model_validate( 

497 model, from_attributes=True 

498 ) 

499 if ret.concurrency_limit is not None: 

500 ret.active_slots = 0 

501 if prefect_client_version and Version(prefect_client_version) <= Version( 

502 "3.3.7" 

503 ): 

504 # Client versions 3.3.7 and below do not support the default_result_storage_block_id field and will error 

505 # when receiving it. 

506 del ret.storage_configuration.default_result_storage_block_id 

507 return ret 

508 

509 except sa.exc.IntegrityError: 

510 raise HTTPException( 

511 status_code=status.HTTP_409_CONFLICT, 

512 detail="A work pool with this name already exists.", 

513 ) 

514 

515 

516@router.get("/{name}") 

517async def read_work_pool( 

518 work_pool_name: str = Path(..., description="The work pool name", alias="name"), 

519 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

520 db: PrefectDBInterface = Depends(provide_database_interface), 

521 prefect_client_version: Optional[str] = Depends( 

522 dependencies.get_prefect_client_version 

523 ), 

524) -> schemas.responses.WorkPoolResponse: 

525 """ 

526 Read a work pool by name 

527 """ 

528 

529 async with db.session_context() as session: 

530 work_pool_id = await worker_lookups._get_work_pool_id_from_name( 

531 session=session, work_pool_name=work_pool_name 

532 ) 

533 orm_work_pool = await models.workers.read_work_pool( 

534 session=session, work_pool_id=work_pool_id 

535 ) 

536 work_pool = schemas.responses.WorkPoolResponse.model_validate( 

537 orm_work_pool, from_attributes=True 

538 ) 

539 

540 if work_pool.concurrency_limit is not None: 

541 work_pool.active_slots = await models.workers.count_work_pool_active_slots( 

542 session=session, work_pool_id=work_pool_id 

543 ) 

544 

545 if prefect_client_version and Version(prefect_client_version) <= Version( 

546 "3.3.7" 

547 ): 

548 # Client versions 3.3.7 and below do not support the default_result_storage_block_id field and will error 

549 # when receiving it. 

550 del work_pool.storage_configuration.default_result_storage_block_id 

551 

552 return work_pool 

553 

554 

555@router.post("/filter") 

556async def read_work_pools( 

557 work_pools: Optional[schemas.filters.WorkPoolFilter] = None, 

558 limit: int = dependencies.LimitBody(), 

559 offset: int = Body(0, ge=0), 

560 db: PrefectDBInterface = Depends(provide_database_interface), 

561 prefect_client_version: Optional[str] = Depends( 

562 dependencies.get_prefect_client_version 

563 ), 

564) -> List[schemas.responses.WorkPoolResponse]: 

565 """ 

566 Read multiple work pools 

567 """ 

568 async with db.session_context() as session: 

569 orm_work_pools = await models.workers.read_work_pools( 

570 session=session, 

571 work_pool_filter=work_pools, 

572 offset=offset, 

573 limit=limit, 

574 ) 

575 ret = [ 

576 schemas.responses.WorkPoolResponse.model_validate(w, from_attributes=True) 

577 for w in orm_work_pools 

578 ] 

579 pools_with_limit = [wp for wp in ret if wp.concurrency_limit is not None] 

580 if pools_with_limit: 

581 slot_counts = await models.workers.count_work_pool_active_slots_bulk( 

582 session=session, 

583 work_pool_ids=[wp.id for wp in pools_with_limit], 

584 ) 

585 for work_pool in pools_with_limit: 

586 work_pool.active_slots = slot_counts.get(work_pool.id, 0) 

587 if prefect_client_version and Version(prefect_client_version) <= Version( 

588 "3.3.7" 

589 ): 

590 # Client versions 3.3.7 and below do not support the default_result_storage_block_id field and will error 

591 # when receiving it. 

592 for work_pool in ret: 

593 del work_pool.storage_configuration.default_result_storage_block_id 

594 return ret 

595 

596 

597@router.post("/count") 

598async def count_work_pools( 

599 work_pools: Optional[schemas.filters.WorkPoolFilter] = Body(None, embed=True), 

600 db: PrefectDBInterface = Depends(provide_database_interface), 

601) -> int: 

602 """ 

603 Count work pools 

604 """ 

605 async with db.session_context() as session: 

606 return await models.workers.count_work_pools( 

607 session=session, work_pool_filter=work_pools 

608 ) 

609 

610 

611@router.patch("/{name}", status_code=status.HTTP_204_NO_CONTENT) 

612async def update_work_pool( 

613 work_pool: schemas.actions.WorkPoolUpdate, 

614 work_pool_name: str = Path(..., description="The work pool name", alias="name"), 

615 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

616 db: PrefectDBInterface = Depends(provide_database_interface), 

617) -> None: 

618 """ 

619 Update a work pool 

620 """ 

621 

622 # Reserved pools can only updated pause / concurrency 

623 update_values = work_pool.model_dump(exclude_unset=True) 

624 if work_pool_name.lower().startswith("prefect") and ( 624 ↛ 627line 624 didn't jump to line 627 because the condition on line 624 was never true

625 set(update_values).difference({"is_paused", "concurrency_limit"}) 

626 ): 

627 raise HTTPException( 

628 status_code=status.HTTP_403_FORBIDDEN, 

629 detail=( 

630 "Work pools starting with 'Prefect' are reserved for internal use " 

631 "and can only be updated to set concurrency limits or pause." 

632 ), 

633 ) 

634 

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

636 work_pool_id = await worker_lookups._get_work_pool_id_from_name( 

637 session=session, work_pool_name=work_pool_name 

638 ) 

639 updated = await models.workers.update_work_pool( 

640 session=session, 

641 work_pool_id=work_pool_id, 

642 work_pool=work_pool, 

643 emit_status_change=emit_work_pool_status_event, 

644 ) 

645 

646 if updated and worker_channel_utils.work_pool_update_triggers_snapshot( 646 ↛ 649line 646 didn't jump to line 649 because the condition on line 646 was never true

647 update_values 

648 ): 

649 await worker_channel_utils.publish_snapshot_invalidation( 

650 worker_channel_utils.WorkerChannelSnapshotInvalidation( 

651 work_pool_id=work_pool_id, 

652 reason="work_pool_updated", 

653 ) 

654 ) 

655 

656 

657@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT) 

658async def delete_work_pool( 

659 work_pool_name: str = Path(..., description="The work pool name", alias="name"), 

660 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

661 db: PrefectDBInterface = Depends(provide_database_interface), 

662) -> None: 

663 """ 

664 Delete a work pool 

665 """ 

666 

667 if work_pool_name.lower().startswith("prefect"): 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true

668 raise HTTPException( 

669 status_code=status.HTTP_403_FORBIDDEN, 

670 detail=( 

671 "Work pools starting with 'Prefect' are reserved for internal use and" 

672 " can not be deleted." 

673 ), 

674 ) 

675 

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

677 work_pool_id = await worker_lookups._get_work_pool_id_from_name( 

678 session=session, work_pool_name=work_pool_name 

679 ) 

680 

681 deleted = await models.workers.delete_work_pool( 

682 session=session, work_pool_id=work_pool_id 

683 ) 

684 

685 if deleted: 685 ↛ exitline 685 didn't return from function 'delete_work_pool' because the condition on line 685 was always true

686 await worker_channel_utils.publish_snapshot_invalidation( 

687 worker_channel_utils.WorkerChannelSnapshotInvalidation( 

688 work_pool_id=work_pool_id, 

689 reason="work_pool_deleted", 

690 work_pool_deleted=True, 

691 ) 

692 ) 

693 

694 

695@router.post("/{name}/concurrency_status") 

696async def read_work_pool_concurrency_status( 

697 work_pool_name: str = Path(..., description="The work pool name", alias="name"), 

698 page: int = Body(1, ge=1), 

699 limit: int = dependencies.LimitBody(), 

700 flow_run_limit: int = Body(10, ge=0, le=200, description="Max flow runs per queue"), 

701 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

702 db: PrefectDBInterface = Depends(provide_database_interface), 

703) -> schemas.responses.WorkPoolConcurrencyStatus: 

704 """ 

705 Read concurrency status for a work pool, including per-queue breakdown 

706 with flow run summaries. Queues are paginated; flow runs per queue are 

707 capped by flow_run_limit. 

708 """ 

709 import asyncio 

710 

711 from prefect.types._datetime import now as prefect_now 

712 

713 queue_offset = (page - 1) * limit 

714 

715 async with db.session_context() as session: 

716 work_pool = await models.workers.read_work_pool_by_name( 

717 session=session, work_pool_name=work_pool_name 

718 ) 

719 if not work_pool: 

720 raise HTTPException( 

721 status_code=status.HTTP_404_NOT_FOUND, 

722 detail=f"Work pool {work_pool_name!r} not found.", 

723 ) 

724 

725 # Paginate queues in the DB and get total count + active slots 

726 # concurrently 

727 ( 

728 work_queues_page, 

729 total_queue_count, 

730 total_active, 

731 counts_by_queue, 

732 ) = await asyncio.gather( 

733 models.workers.read_work_queues( 

734 session=session, 

735 work_pool_id=work_pool.id, 

736 offset=queue_offset, 

737 limit=limit, 

738 ), 

739 models.workers.count_work_queues( 

740 session=session, 

741 work_pool_id=work_pool.id, 

742 ), 

743 models.workers.count_work_pool_slot_holders( 

744 session=session, 

745 work_pool_id=work_pool.id, 

746 ), 

747 models.workers.count_work_pool_slot_holders_by_queue( 

748 session=session, 

749 work_pool_id=work_pool.id, 

750 ), 

751 ) 

752 

753 # Only fetch flow run details for the queues on this page 

754 page_queue_ids = [wq.id for wq in work_queues_page] 

755 slot_holders = await models.workers.get_work_pool_slot_holders( 

756 session=session, 

757 work_pool_id=work_pool.id, 

758 work_queue_ids=page_queue_ids, 

759 flow_run_limit=flow_run_limit, 

760 ) 

761 

762 current_time = prefect_now("UTC") 

763 

764 # Group flow runs by work queue id 

765 runs_by_queue: dict[UUID, list[tuple]] = {} 

766 for run, slot_acquired_at in slot_holders: 766 ↛ 767line 766 didn't jump to line 767 because the loop on line 766 never started

767 queue_id = run.work_queue_id 

768 if queue_id is not None: 

769 runs_by_queue.setdefault(queue_id, []).append((run, slot_acquired_at)) 

770 

771 def _build_summary(run, slot_acquired_at) -> schemas.responses.FlowRunSlotSummary: 

772 state_ts = run.state_timestamp 

773 return schemas.responses.FlowRunSlotSummary( 

774 id=run.id, 

775 name=run.name, 

776 state_type=run.state_type if run.state_type else None, 

777 state_name=run.state_name if run.state_name else None, 

778 start_time=run.start_time, 

779 state_timestamp=state_ts, 

780 time_in_current_state=((current_time - state_ts) if state_ts else None), 

781 ) 

782 

783 queue_details = [] 

784 for wq in work_queues_page: 

785 display_tuples = runs_by_queue.get(wq.id, []) 

786 active = counts_by_queue.get(wq.id, 0) 

787 queue_details.append( 

788 schemas.responses.WorkQueueConcurrencyStatusDetail( 

789 queue_id=wq.id, 

790 queue_name=wq.name, 

791 active_slots=active, 

792 concurrency_limit=wq.concurrency_limit, 

793 flow_runs=[_build_summary(r, sa) for r, sa in display_tuples], 

794 flow_run_count=active, 

795 ) 

796 ) 

797 

798 return schemas.responses.WorkPoolConcurrencyStatus( 

799 active_slots=total_active, 

800 concurrency_limit=work_pool.concurrency_limit, 

801 queues=queue_details, 

802 count=total_queue_count, 

803 limit=limit, 

804 pages=(total_queue_count + limit - 1) // limit if limit > 0 else 0, 

805 page=page, 

806 ) 

807 

808 

809@router.post("/{name}/get_scheduled_flow_runs") 

810async def get_scheduled_flow_runs( 

811 docket: dependencies.Docket, 

812 work_pool_name: str = Path(..., description="The work pool name", alias="name"), 

813 work_queue_names: List[str] = Body( 

814 None, description="The names of work pool queues" 

815 ), 

816 scheduled_before: DateTime = Body( 

817 None, description="The maximum time to look for scheduled flow runs" 

818 ), 

819 scheduled_after: DateTime = Body( 

820 None, description="The minimum time to look for scheduled flow runs" 

821 ), 

822 limit: int = dependencies.LimitBody(), 

823 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

824 db: PrefectDBInterface = Depends(provide_database_interface), 

825) -> List[schemas.responses.WorkerFlowRunResponse]: 

826 """ 

827 Load scheduled runs for a worker 

828 """ 

829 async with db.session_context() as session: 

830 work_pool_id = await worker_lookups._get_work_pool_id_from_name( 

831 session=session, work_pool_name=work_pool_name 

832 ) 

833 

834 if not work_queue_names: 

835 work_queues = list( 

836 await models.workers.read_work_queues( 

837 session=session, work_pool_id=work_pool_id 

838 ) 

839 ) 

840 # None here instructs get_scheduled_flow_runs to use the default behavior 

841 # of just operating on all work queues of the pool 

842 work_queue_ids = None 

843 else: 

844 work_queues = [ 

845 await worker_lookups._get_work_queue_from_name( 

846 session=session, 

847 work_pool_name=work_pool_name, 

848 work_queue_name=name, 

849 ) 

850 for name in work_queue_names 

851 ] 

852 work_queue_ids = [wq.id for wq in work_queues] 

853 

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

855 queue_response = await models.workers.get_scheduled_flow_runs( 

856 session=session, 

857 work_pool_ids=[work_pool_id], 

858 work_queue_ids=work_queue_ids, 

859 scheduled_before=scheduled_before, 

860 scheduled_after=scheduled_after, 

861 limit=limit, 

862 ) 

863 

864 await docket.add( 

865 mark_work_queues_ready, 

866 key=f"mark_work_queues_ready:work_pool:{work_pool_id}", 

867 )( 

868 polled_work_queue_ids=[ 

869 wq.id for wq in work_queues if wq.status != WorkQueueStatus.NOT_READY 

870 ], 

871 ready_work_queue_ids=[ 

872 wq.id for wq in work_queues if wq.status == WorkQueueStatus.NOT_READY 

873 ], 

874 ) 

875 

876 await docket.add( 

877 mark_deployments_ready, 

878 key=f"mark_deployments_ready:work_pool:{work_pool_id}", 

879 )( 

880 work_queue_ids=[wq.id for wq in work_queues], 

881 ) 

882 

883 return queue_response 

884 

885 

886# ----------------------------------------------------- 

887# -- 

888# -- 

889# -- Work Pool Queues 

890# -- 

891# -- 

892# ----------------------------------------------------- 

893 

894 

895@router.post("/{work_pool_name}/queues", status_code=status.HTTP_201_CREATED) 

896async def create_work_queue( 

897 work_queue: schemas.actions.WorkQueueCreate, 

898 work_pool_name: str = Path(..., description="The work pool name"), 

899 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

900 db: PrefectDBInterface = Depends(provide_database_interface), 

901) -> schemas.responses.WorkQueueResponse: 

902 """ 

903 Creates a new work pool queue. If a work pool queue with the same 

904 name already exists, an error will be raised. 

905 

906 For more information, see https://docs.prefect.io/v3/concepts/work-pools#work-queues. 

907 """ 

908 

909 try: 

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

911 work_pool_id = await worker_lookups._get_work_pool_id_from_name( 

912 session=session, 

913 work_pool_name=work_pool_name, 

914 ) 

915 

916 model = await models.workers.create_work_queue( 

917 session=session, 

918 work_pool_id=work_pool_id, 

919 work_queue=work_queue, 

920 ) 

921 

922 response = schemas.responses.WorkQueueResponse.model_validate( 

923 model, from_attributes=True 

924 ) 

925 if response.concurrency_limit is not None: 

926 response.active_slots = 0 

927 except sa.exc.IntegrityError: 

928 raise HTTPException( 

929 status_code=status.HTTP_409_CONFLICT, 

930 detail=( 

931 "A work queue with this name already exists in work pool" 

932 " {work_pool_name!r}." 

933 ), 

934 ) 

935 

936 return response 

937 

938 

939@router.get("/{work_pool_name}/queues/{name}") 

940async def read_work_queue( 

941 work_pool_name: str = Path(..., description="The work pool name"), 

942 work_queue_name: str = Path( 

943 ..., description="The work pool queue name", alias="name" 

944 ), 

945 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

946 db: PrefectDBInterface = Depends(provide_database_interface), 

947) -> schemas.responses.WorkQueueResponse: 

948 """ 

949 Read a work pool queue 

950 """ 

951 

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

953 work_queue_id = await worker_lookups._get_work_queue_id_from_name( 

954 session=session, 

955 work_pool_name=work_pool_name, 

956 work_queue_name=work_queue_name, 

957 ) 

958 

959 model = await models.workers.read_work_queue( 

960 session=session, work_queue_id=work_queue_id 

961 ) 

962 

963 response = schemas.responses.WorkQueueResponse.model_validate( 

964 model, from_attributes=True 

965 ) 

966 

967 if response.concurrency_limit is not None: 

968 response.active_slots = await models.workers.count_work_queue_active_slots( 

969 session=session, work_queue_id=work_queue_id 

970 ) 

971 

972 return response 

973 

974 

975@router.post("/{work_pool_name}/queues/filter") 

976async def read_work_queues( 

977 work_pool_name: str = Path(..., description="The work pool name"), 

978 work_queues: schemas.filters.WorkQueueFilter = None, 

979 limit: int = dependencies.LimitBody(), 

980 offset: int = Body(0, ge=0), 

981 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

982 db: PrefectDBInterface = Depends(provide_database_interface), 

983) -> List[schemas.responses.WorkQueueResponse]: 

984 """ 

985 Read all work pool queues 

986 """ 

987 async with db.session_context() as session: 

988 work_pool_id = await worker_lookups._get_work_pool_id_from_name( 

989 session=session, 

990 work_pool_name=work_pool_name, 

991 ) 

992 wqs = await models.workers.read_work_queues( 

993 session=session, 

994 work_pool_id=work_pool_id, 

995 work_queue_filter=work_queues, 

996 limit=limit, 

997 offset=offset, 

998 ) 

999 

1000 ret = [ 

1001 schemas.responses.WorkQueueResponse.model_validate(wq, from_attributes=True) 

1002 for wq in wqs 

1003 ] 

1004 queues_with_limit = [wq for wq in ret if wq.concurrency_limit is not None] 

1005 if queues_with_limit: 

1006 slot_counts = await models.workers.count_work_queue_active_slots_bulk( 

1007 session=session, 

1008 work_queue_ids=[wq.id for wq in queues_with_limit], 

1009 ) 

1010 for wq_response in queues_with_limit: 

1011 wq_response.active_slots = slot_counts.get(wq_response.id, 0) 

1012 

1013 return ret 

1014 

1015 

1016@router.patch("/{work_pool_name}/queues/{name}", status_code=status.HTTP_204_NO_CONTENT) 

1017async def update_work_queue( 

1018 work_queue: schemas.actions.WorkQueueUpdate, 

1019 work_pool_name: str = Path(..., description="The work pool name"), 

1020 work_queue_name: str = Path( 

1021 ..., description="The work pool queue name", alias="name" 

1022 ), 

1023 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

1024 db: PrefectDBInterface = Depends(provide_database_interface), 

1025) -> None: 

1026 """ 

1027 Update a work pool queue 

1028 """ 

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

1030 await worker_lookups._get_work_pool_id_from_name( 

1031 session=session, 

1032 work_pool_name=work_pool_name, 

1033 ) 

1034 work_queue_id = await worker_lookups._get_work_queue_id_from_name( 

1035 work_pool_name=work_pool_name, 

1036 work_queue_name=work_queue_name, 

1037 session=session, 

1038 ) 

1039 

1040 await models.workers.update_work_queue( 

1041 session=session, 

1042 work_queue_id=work_queue_id, 

1043 work_queue=work_queue, 

1044 emit_status_change=emit_work_queue_status_event, 

1045 ) 

1046 

1047 

1048@router.delete( 

1049 "/{work_pool_name}/queues/{name}", status_code=status.HTTP_204_NO_CONTENT 

1050) 

1051async def delete_work_queue( 

1052 work_pool_name: str = Path(..., description="The work pool name"), 

1053 work_queue_name: str = Path( 

1054 ..., description="The work pool queue name", alias="name" 

1055 ), 

1056 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

1057 db: PrefectDBInterface = Depends(provide_database_interface), 

1058) -> None: 

1059 """ 

1060 Delete a work pool queue 

1061 """ 

1062 

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

1064 await worker_lookups._get_work_pool_id_from_name( 

1065 session=session, 

1066 work_pool_name=work_pool_name, 

1067 ) 

1068 work_queue_id = await worker_lookups._get_work_queue_id_from_name( 

1069 session=session, 

1070 work_pool_name=work_pool_name, 

1071 work_queue_name=work_queue_name, 

1072 ) 

1073 

1074 await models.workers.delete_work_queue( 

1075 session=session, work_queue_id=work_queue_id 

1076 ) 

1077 

1078 

1079# ----------------------------------------------------- 

1080# -- 

1081# -- 

1082# -- Workers 

1083# -- 

1084# -- 

1085# ----------------------------------------------------- 

1086 

1087 

1088@router.websocket("/{work_pool_name}/workers/connect") 

1089async def worker_channel_connect( 

1090 websocket: WebSocket, 

1091 work_pool_name: str = Path(..., description="The work pool name"), 

1092 db: PrefectDBInterface = Depends(provide_database_interface), 

1093) -> None: 

1094 websocket = await subscriptions.accept_prefect_socket( 

1095 websocket, 

1096 require_prefect_subprotocol=True, 

1097 authentication_failed_reason=WorkerChannelCloseReason.AUTHENTICATION_FAILED.value, 

1098 ) 

1099 if not websocket: 

1100 return 

1101 

1102 try: 

1103 hello = await _receive_worker_hello(websocket) 

1104 cleanup_queue: WorkerCleanupQueue | None = None 

1105 if _worker_requested_cleanup_delivery(hello): 

1106 try: 

1107 cleanup_queue = get_worker_cleanup_queue() 

1108 except Exception: 

1109 logger.exception( 

1110 "Worker cleanup delivery queue initialization failed; " 

1111 "rejecting cleanup delivery capability" 

1112 ) 

1113 

1114 async with worker_channel_utils.messaging.ephemeral_subscription( 

1115 worker_channel_utils.WORKER_CHANNEL_SNAPSHOT_TOPIC, 

1116 ) as consumer_kwargs: 

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

1118 ready, work_pool_update_event = await _build_worker_ready_frame( 

1119 session=session, 

1120 work_pool_name=work_pool_name, 

1121 hello=hello, 

1122 cleanup_queue_available=cleanup_queue is not None, 

1123 ) 

1124 

1125 if work_pool_update_event is not None: 

1126 async with db.session_context() as session: 

1127 work_pool = await models.workers.read_work_pool( 

1128 session=session, 

1129 work_pool_id=work_pool_update_event.work_pool_id, 

1130 ) 

1131 assert work_pool is not None 

1132 await models.workers.emit_work_pool_updated_event( 

1133 session=session, 

1134 work_pool=work_pool, 

1135 changed_fields=work_pool_update_event.changed_fields, 

1136 ) 

1137 await worker_channel_utils.publish_snapshot_invalidation( 

1138 worker_channel_utils.WorkerChannelSnapshotInvalidation( 

1139 work_pool_id=work_pool_update_event.work_pool_id, 

1140 reason="work_pool_updated", 

1141 ) 

1142 ) 

1143 

1144 connection = worker_channel_utils.WorkerChannelConnection( 

1145 websocket=websocket, 

1146 db=db, 

1147 work_pool_name=work_pool_name, 

1148 work_pool_id=ready.payload.initial_snapshot.work_pool.id, 

1149 consumer_id=hello.payload.consumer_id, 

1150 worker_name=hello.payload.worker_name, 

1151 work_pool_updated=ready.payload.initial_snapshot.work_pool.updated, 

1152 cleanup_queue=( 

1153 cleanup_queue 

1154 if CLEANUP_DELIVERY_CAPABILITY 

1155 in ready.payload.accepted_capabilities 

1156 else None 

1157 ), 

1158 cleanup_kinds=tuple(hello.payload.handled_cleanup_kinds), 

1159 cleanup_work_queue_ids=tuple( 

1160 work_queue.id for work_queue in ready.payload.resolved_work_queues 

1161 ), 

1162 max_cleanup_concurrency=ready.payload.effective_max_cleanup_concurrency, 

1163 ) 

1164 await connection.run(ready, consumer_kwargs) 

1165 

1166 except WorkerChannelSetupError as exc: 

1167 logger.debug( 

1168 "Worker channel setup failed: work_pool=%s reason=%s detail=%s", 

1169 work_pool_name, 

1170 exc.close_reason.value, 

1171 exc.detail, 

1172 ) 

1173 if worker_channel_utils.WORKER_CHANNEL_CONNECTIONS is not None: 

1174 worker_channel_utils.WORKER_CHANNEL_CONNECTIONS.labels( 

1175 event="setup_failed" 

1176 ).inc() 

1177 await worker_channel_utils.close_worker_channel(websocket, exc.close_reason) 

1178 except HTTPException as exc: 

1179 logger.debug( 

1180 "Worker channel setup failed HTTP validation: work_pool=%s detail=%s", 

1181 work_pool_name, 

1182 exc.detail, 

1183 ) 

1184 if worker_channel_utils.WORKER_CHANNEL_CONNECTIONS is not None: 

1185 worker_channel_utils.WORKER_CHANNEL_CONNECTIONS.labels( 

1186 event="setup_failed" 

1187 ).inc() 

1188 await worker_channel_utils.close_worker_channel( 

1189 websocket, WorkerChannelCloseReason.PROTOCOL_ERROR 

1190 ) 

1191 except subscriptions.NORMAL_DISCONNECT_EXCEPTIONS: 

1192 return 

1193 except Exception: 

1194 logger.exception( 

1195 "Worker channel setup failed due to a transient server error: work_pool=%s", 

1196 work_pool_name, 

1197 ) 

1198 if worker_channel_utils.WORKER_CHANNEL_CONNECTIONS is not None: 

1199 worker_channel_utils.WORKER_CHANNEL_CONNECTIONS.labels( 

1200 event="setup_failed" 

1201 ).inc() 

1202 await worker_channel_utils.close_worker_channel( 

1203 websocket, WorkerChannelCloseReason.TRANSIENT_SERVER_ERROR 

1204 ) 

1205 

1206 

1207@router.post( 

1208 "/{work_pool_name}/workers/heartbeat", 

1209 status_code=status.HTTP_204_NO_CONTENT, 

1210) 

1211async def worker_heartbeat( 

1212 work_pool_name: str = Path(..., description="The work pool name"), 

1213 name: str = Body(..., description="The worker process name", embed=True), 

1214 heartbeat_interval_seconds: Optional[int] = Body( 

1215 None, description="The worker's heartbeat interval in seconds", embed=True 

1216 ), 

1217 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

1218 db: PrefectDBInterface = Depends(provide_database_interface), 

1219) -> None: 

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

1221 work_pool = await models.workers.read_work_pool_by_name( 

1222 session=session, 

1223 work_pool_name=work_pool_name, 

1224 ) 

1225 if not work_pool: 

1226 raise HTTPException( 

1227 status_code=status.HTTP_404_NOT_FOUND, 

1228 detail=f'Work pool "{work_pool_name}" not found.', 

1229 ) 

1230 

1231 await models.workers.record_worker_heartbeat( 

1232 session=session, 

1233 work_pool=work_pool, 

1234 worker_name=name, 

1235 heartbeat_interval_seconds=heartbeat_interval_seconds, 

1236 emit_status_change=emit_work_pool_status_event, 

1237 ) 

1238 

1239 

1240@router.post("/{work_pool_name}/workers/filter") 

1241async def read_workers( 

1242 work_pool_name: str = Path(..., description="The work pool name"), 

1243 workers: Optional[schemas.filters.WorkerFilter] = None, 

1244 limit: int = dependencies.LimitBody(), 

1245 offset: int = Body(0, ge=0), 

1246 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

1247 db: PrefectDBInterface = Depends(provide_database_interface), 

1248) -> List[schemas.responses.WorkerResponse]: 

1249 """ 

1250 Read all worker processes 

1251 """ 

1252 async with db.session_context() as session: 

1253 work_pool_id = await worker_lookups._get_work_pool_id_from_name( 

1254 session=session, work_pool_name=work_pool_name 

1255 ) 

1256 return await models.workers.read_workers( 

1257 session=session, 

1258 work_pool_id=work_pool_id, 

1259 worker_filter=workers, 

1260 limit=limit, 

1261 offset=offset, 

1262 ) 

1263 

1264 

1265@router.delete( 

1266 "/{work_pool_name}/workers/{name}", status_code=status.HTTP_204_NO_CONTENT 

1267) 

1268async def delete_worker( 

1269 work_pool_name: str = Path(..., description="The work pool name"), 

1270 worker_name: str = Path( 

1271 ..., description="The work pool's worker name", alias="name" 

1272 ), 

1273 worker_lookups: WorkerLookups = Depends(WorkerLookups), 

1274 db: PrefectDBInterface = Depends(provide_database_interface), 

1275) -> None: 

1276 """ 

1277 Delete a work pool's worker 

1278 """ 

1279 

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

1281 work_pool_id = await worker_lookups._get_work_pool_id_from_name( 

1282 session=session, work_pool_name=work_pool_name 

1283 ) 

1284 deleted = await models.workers.delete_worker( 

1285 session=session, work_pool_id=work_pool_id, worker_name=worker_name 

1286 ) 

1287 if not deleted: 

1288 raise HTTPException( 

1289 status_code=status.HTTP_404_NOT_FOUND, detail="Worker not found." 

1290 )