Coverage for /usr/local/lib/python3.12/site-packages/prefect/server/models/flow_runs.py: 63%

221 statements  

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

1""" 

2Functions for interacting with flow run ORM objects. 

3Intended for internal use by the Prefect REST API. 

4""" 

5 

6import contextlib 

7import datetime 

8from itertools import chain 

9from typing import ( 

10 TYPE_CHECKING, 

11 Any, 

12 Dict, 

13 List, 

14 Optional, 

15 Sequence, 

16 Tuple, 

17 Type, 

18 TypeVar, 

19 Union, 

20 cast, 

21) 

22from uuid import UUID 

23 

24import sqlalchemy as sa 

25from sqlalchemy import delete, select 

26from sqlalchemy.ext.asyncio import AsyncSession 

27from sqlalchemy.orm import load_only, selectinload 

28from sqlalchemy.sql import Select 

29 

30import prefect.server.models as models 

31import prefect.server.schemas as schemas 

32from prefect.logging.loggers import get_logger 

33from prefect.server.database import PrefectDBInterface, db_injector, orm_models 

34from prefect.server.exceptions import ObjectNotFoundError 

35from prefect.server.orchestration.core_policy import MinimalFlowPolicy 

36from prefect.server.orchestration.global_policy import GlobalFlowPolicy 

37from prefect.server.orchestration.policies import ( 

38 FlowRunOrchestrationPolicy, 

39) 

40from prefect.server.orchestration.rules import FlowOrchestrationContext 

41from prefect.server.schemas.core import TaskRunResult 

42from prefect.server.schemas.graph import Graph 

43from prefect.server.schemas.responses import OrchestrationResult 

44from prefect.server.schemas.states import State 

45from prefect.server.utilities.schemas import PrefectBaseModel 

46from prefect.settings import ( 

47 PREFECT_API_MAX_FLOW_RUN_GRAPH_ARTIFACTS, 

48 PREFECT_API_MAX_FLOW_RUN_GRAPH_NODES, 

49) 

50from prefect.types import KeyValueLabels 

51from prefect.types._datetime import DateTime, earliest_possible_datetime, now 

52 

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

54 import logging 

55 

56logger: "logging.Logger" = get_logger("flow_runs") 

57 

58 

59T = TypeVar("T", bound=tuple[Any, ...]) 

60 

61 

62@db_injector 

63async def create_flow_run( 

64 db: PrefectDBInterface, 

65 session: AsyncSession, 

66 flow_run: schemas.core.FlowRun, 

67 orchestration_parameters: Optional[dict[str, Any]] = None, 

68) -> orm_models.FlowRun: 

69 """Creates a new flow run. 

70 

71 If the provided flow run has a state attached, it will also be created. 

72 

73 Args: 

74 session: a database session 

75 flow_run: a flow run model 

76 

77 Returns: 

78 orm_models.FlowRun: the newly-created flow run 

79 """ 

80 right_now = now("UTC") 

81 # model: Union[orm_models.FlowRun, None] = None 

82 

83 flow_run.labels = await with_system_labels_for_flow_run( 

84 session=session, flow_run=flow_run 

85 ) 

86 

87 flow_run_dict = dict( 

88 **flow_run.model_dump_for_orm( 

89 exclude={ 

90 "created", 

91 "state", 

92 "estimated_run_time", 

93 "estimated_start_time_delta", 

94 }, 

95 exclude_unset=True, 

96 ), 

97 created=right_now, 

98 ) 

99 

100 # if no idempotency key was provided, create the run directly 

101 if not flow_run.idempotency_key: 

102 model = db.FlowRun(**flow_run_dict) 

103 session.add(model) 

104 await session.flush() 

105 

106 # otherwise let the database take care of enforcing idempotency 

107 else: 

108 insert_stmt = ( 

109 db.queries.insert(db.FlowRun) 

110 .values(**flow_run_dict) 

111 .on_conflict_do_nothing( 

112 index_elements=db.orm.flow_run_unique_upsert_columns, 

113 ) 

114 ) 

115 await session.execute(insert_stmt) 

116 

117 # read the run to see if idempotency was applied or not 

118 query = ( 

119 sa.select(db.FlowRun) 

120 .where( 

121 sa.and_( 

122 db.FlowRun.flow_id == flow_run.flow_id, 

123 db.FlowRun.idempotency_key == flow_run.idempotency_key, 

124 ) 

125 ) 

126 .limit(1) 

127 .execution_options(populate_existing=True) 

128 .options( 

129 selectinload(db.FlowRun.work_queue).selectinload(db.WorkQueue.work_pool) 

130 ) 

131 ) 

132 result = await session.execute(query) 

133 model = result.scalar_one() 

134 

135 # if the flow run was created in this function call then we need to set the 

136 # state. If it was created idempotently, the created time won't match. 

137 if model.created == right_now and flow_run.state: 

138 await models.flow_runs.set_flow_run_state( 

139 session=session, 

140 flow_run_id=model.id, 

141 state=flow_run.state, 

142 force=True, 

143 orchestration_parameters=orchestration_parameters, 

144 ) 

145 return model 

146 

147 

148@db_injector 

149async def update_flow_run( 

150 db: PrefectDBInterface, 

151 session: AsyncSession, 

152 flow_run_id: UUID, 

153 flow_run: schemas.actions.FlowRunUpdate, 

154) -> bool: 

155 """ 

156 Updates a flow run. 

157 

158 Args: 

159 session: a database session 

160 flow_run_id: the flow run id to update 

161 flow_run: a flow run model 

162 

163 Returns: 

164 bool: whether or not matching rows were found to update 

165 """ 

166 update_stmt = ( 

167 sa.update(db.FlowRun) 

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

169 # exclude_unset=True allows us to only update values provided by 

170 # the user, ignoring any defaults on the model 

171 .values(**flow_run.model_dump_for_orm(exclude_unset=True)) 

172 ) 

173 result = await session.execute(update_stmt) 

174 return result.rowcount > 0 

175 

176 

177@db_injector 

178async def read_flow_run( 

179 db: PrefectDBInterface, 

180 session: AsyncSession, 

181 flow_run_id: UUID, 

182 for_update: bool = False, 

183) -> Optional[orm_models.FlowRun]: 

184 """ 

185 Reads a flow run by id. 

186 

187 Args: 

188 session: A database session 

189 flow_run_id: a flow run id 

190 

191 Returns: 

192 orm_models.FlowRun: the flow run 

193 """ 

194 select = ( 

195 sa.select(db.FlowRun) 

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

197 .options( 

198 selectinload(db.FlowRun.work_queue).selectinload(db.WorkQueue.work_pool) 

199 ) 

200 ) 

201 

202 if for_update: 

203 select = select.with_for_update() 

204 

205 result = await session.execute(select) 

206 return result.scalar() 

207 

208 

209async def _apply_flow_run_filters( 

210 db: PrefectDBInterface, 

211 query: Select[T], 

212 flow_filter: Optional[schemas.filters.FlowFilter] = None, 

213 flow_run_filter: Optional[schemas.filters.FlowRunFilter] = None, 

214 task_run_filter: Optional[schemas.filters.TaskRunFilter] = None, 

215 deployment_filter: Optional[schemas.filters.DeploymentFilter] = None, 

216 work_pool_filter: Optional[schemas.filters.WorkPoolFilter] = None, 

217 work_queue_filter: Optional[schemas.filters.WorkQueueFilter] = None, 

218) -> Select[T]: 

219 """ 

220 Applies filters to a flow run query as a combination of EXISTS subqueries. 

221 """ 

222 

223 if flow_run_filter: 

224 query = query.where(flow_run_filter.as_sql_filter()) 

225 

226 if deployment_filter: 

227 deployment_exists_clause = select(db.Deployment).where( 

228 db.Deployment.id == db.FlowRun.deployment_id, 

229 deployment_filter.as_sql_filter(), 

230 ) 

231 query = query.where(deployment_exists_clause.exists()) 

232 

233 if work_pool_filter: 

234 work_pool_exists_clause = select(db.WorkPool).where( 

235 db.WorkQueue.id == db.FlowRun.work_queue_id, 

236 db.WorkPool.id == db.WorkQueue.work_pool_id, 

237 work_pool_filter.as_sql_filter(), 

238 ) 

239 

240 query = query.where(work_pool_exists_clause.exists()) 

241 

242 if work_queue_filter: 

243 work_queue_exists_clause = select(db.WorkQueue).where( 

244 db.WorkQueue.id == db.FlowRun.work_queue_id, 

245 work_queue_filter.as_sql_filter(), 

246 ) 

247 query = query.where(work_queue_exists_clause.exists()) 

248 

249 if flow_filter or task_run_filter: 

250 flow_or_task_run_exists_clause: Union[ 

251 Select[Tuple[db.Flow]], 

252 Select[Tuple[db.TaskRun]], 

253 ] 

254 

255 if flow_filter: 

256 flow_or_task_run_exists_clause = select(db.Flow).where( 

257 db.Flow.id == db.FlowRun.flow_id, 

258 flow_filter.as_sql_filter(), 

259 ) 

260 

261 if task_run_filter: 

262 if not flow_filter: 

263 flow_or_task_run_exists_clause = select(db.TaskRun).where( 

264 db.TaskRun.flow_run_id == db.FlowRun.id 

265 ) 

266 else: 

267 flow_or_task_run_exists_clause = flow_or_task_run_exists_clause.join( 

268 db.TaskRun, 

269 db.TaskRun.flow_run_id == db.FlowRun.id, 

270 ) 

271 flow_or_task_run_exists_clause = flow_or_task_run_exists_clause.where( 

272 db.FlowRun.id == db.TaskRun.flow_run_id, 

273 task_run_filter.as_sql_filter(), 

274 ) 

275 

276 query = query.where(flow_or_task_run_exists_clause.exists()) 

277 

278 return query 

279 

280 

281@db_injector 

282async def read_flow_runs( 

283 db: PrefectDBInterface, 

284 session: AsyncSession, 

285 columns: Optional[list[str]] = None, 

286 flow_filter: Optional[schemas.filters.FlowFilter] = None, 

287 flow_run_filter: Optional[schemas.filters.FlowRunFilter] = None, 

288 task_run_filter: Optional[schemas.filters.TaskRunFilter] = None, 

289 deployment_filter: Optional[schemas.filters.DeploymentFilter] = None, 

290 work_pool_filter: Optional[schemas.filters.WorkPoolFilter] = None, 

291 work_queue_filter: Optional[schemas.filters.WorkQueueFilter] = None, 

292 offset: Optional[int] = None, 

293 limit: Optional[int] = None, 

294 sort: schemas.sorting.FlowRunSort = schemas.sorting.FlowRunSort.ID_DESC, 

295) -> Sequence[orm_models.FlowRun]: 

296 """ 

297 Read flow runs. 

298 

299 Args: 

300 session: a database session 

301 columns: a list of the flow run ORM columns to load, for performance 

302 flow_filter: only select flow runs whose flows match these filters 

303 flow_run_filter: only select flow runs match these filters 

304 task_run_filter: only select flow runs whose task runs match these filters 

305 deployment_filter: only select flow runs whose deployments match these filters 

306 offset: Query offset 

307 limit: Query limit 

308 sort: Query sort 

309 

310 Returns: 

311 List[orm_models.FlowRun]: flow runs 

312 """ 

313 query = ( 

314 select(db.FlowRun) 

315 .order_by(*sort.as_sql_sort()) 

316 .options( 

317 selectinload(db.FlowRun.work_queue).selectinload(db.WorkQueue.work_pool) 

318 ) 

319 ) 

320 

321 if columns: 

322 query = query.options(load_only(*columns)) 

323 

324 query = await _apply_flow_run_filters( 

325 db, 

326 query, 

327 flow_filter=flow_filter, 

328 flow_run_filter=flow_run_filter, 

329 task_run_filter=task_run_filter, 

330 deployment_filter=deployment_filter, 

331 work_pool_filter=work_pool_filter, 

332 work_queue_filter=work_queue_filter, 

333 ) 

334 

335 if offset is not None: 

336 query = query.offset(offset) 

337 

338 if limit is not None: 338 ↛ 341line 338 didn't jump to line 341 because the condition on line 338 was always true

339 query = query.limit(limit) 

340 

341 result = await session.execute(query) 

342 return result.scalars().unique().all() 

343 

344 

345async def cleanup_flow_run_concurrency_slots( 

346 session: AsyncSession, 

347 flow_run: orm_models.FlowRun, 

348) -> None: 

349 """ 

350 Cleanup flow run related resources, such as releasing concurrency slots. 

351 All operations should be idempotent and safe to call multiple times. 

352 IMPORTANT: This run may no longer exist in the database when this operation occurs. 

353 """ 

354 

355 if ( 355 ↛ 365line 355 didn't jump to line 365 because the condition on line 355 was never true

356 flow_run.deployment_id 

357 and flow_run.state 

358 and flow_run.state.type 

359 in ( 

360 schemas.states.StateType.PENDING, 

361 schemas.states.StateType.RUNNING, 

362 schemas.states.StateType.CANCELLING, 

363 ) 

364 ): 

365 deployment = await models.deployments.read_deployment( 

366 session, flow_run.deployment_id 

367 ) 

368 if deployment and deployment.concurrency_limit_id: 

369 await models.concurrency_limits_v2.bulk_decrement_active_slots( 

370 session, [deployment.concurrency_limit_id], 1 

371 ) 

372 

373 

374class DependencyResult(PrefectBaseModel): 

375 id: UUID 

376 name: str 

377 upstream_dependencies: List[TaskRunResult] 

378 state: Optional[State] 

379 expected_start_time: Optional[datetime.datetime] 

380 start_time: Optional[datetime.datetime] 

381 end_time: Optional[datetime.datetime] 

382 total_run_time: Optional[datetime.timedelta] 

383 estimated_run_time: Optional[datetime.timedelta] 

384 untrackable_result: bool 

385 

386 

387async def read_task_run_dependencies( 

388 session: AsyncSession, 

389 flow_run_id: UUID, 

390) -> List[DependencyResult]: 

391 """ 

392 Get a task run dependency map for a given flow run. 

393 """ 

394 flow_run = await models.flow_runs.read_flow_run( 

395 session=session, flow_run_id=flow_run_id 

396 ) 

397 if not flow_run: 

398 raise ObjectNotFoundError(f"Flow run with id {flow_run_id} not found") 

399 

400 task_runs = await models.task_runs.read_task_runs( 

401 session=session, 

402 flow_run_filter=schemas.filters.FlowRunFilter( 

403 id=schemas.filters.FlowRunFilterId(any_=[flow_run_id]) 

404 ), 

405 ) 

406 

407 dependency_graph = [] 

408 

409 for task_run in task_runs: 

410 inputs = list(set(chain(*task_run.task_inputs.values()))) 

411 untrackable_result_status = ( 

412 False 

413 if task_run.state is None 

414 else task_run.state.state_details.untrackable_result 

415 ) 

416 dependency_graph.append( 

417 DependencyResult( 

418 id=task_run.id, 

419 upstream_dependencies=inputs, 

420 state=task_run.state, 

421 expected_start_time=task_run.expected_start_time, 

422 name=task_run.name, 

423 start_time=task_run.start_time, 

424 end_time=task_run.end_time, 

425 total_run_time=task_run.total_run_time, 

426 estimated_run_time=task_run.estimated_run_time, 

427 untrackable_result=untrackable_result_status, 

428 ) 

429 ) 

430 

431 return dependency_graph 

432 

433 

434@db_injector 

435async def count_flow_runs( 

436 db: PrefectDBInterface, 

437 session: AsyncSession, 

438 flow_filter: Optional[schemas.filters.FlowFilter] = None, 

439 flow_run_filter: Optional[schemas.filters.FlowRunFilter] = None, 

440 task_run_filter: Optional[schemas.filters.TaskRunFilter] = None, 

441 deployment_filter: Optional[schemas.filters.DeploymentFilter] = None, 

442 work_pool_filter: Optional[schemas.filters.WorkPoolFilter] = None, 

443 work_queue_filter: Optional[schemas.filters.WorkQueueFilter] = None, 

444) -> int: 

445 """ 

446 Count flow runs. 

447 

448 Args: 

449 session: a database session 

450 flow_filter: only count flow runs whose flows match these filters 

451 flow_run_filter: only count flow runs that match these filters 

452 task_run_filter: only count flow runs whose task runs match these filters 

453 deployment_filter: only count flow runs whose deployments match these filters 

454 work_pool_filter: only count flow runs whose work pool matches these filters 

455 work_queue_filter: only count flow runs whose work queue matches these filters 

456 

457 Returns: 

458 int: count of flow runs 

459 """ 

460 

461 query = select(sa.func.count(None)).select_from(db.FlowRun) 

462 

463 # Fast path: JOIN instead of correlated EXISTS for the four FK filter dimensions. 

464 # Safe because deployment_id, work_queue_id, and flow_id are single-valued scalar 

465 # FK columns on FlowRun — each JOIN adds at most one row per flow_run, so 

466 # COUNT(*) is not inflated. NULL FK rows are excluded by the INNER JOIN, which 

467 # matches EXISTS semantics. task_run_filter is excluded: TaskRun.flow_run_id is 

468 # the 1-to-N side, so a TaskRun JOIN would produce multiple rows per flow_run 

469 # and corrupt the count. Any new filter added here must be N:1 to FlowRun. 

470 if ( 

471 deployment_filter or work_pool_filter or work_queue_filter or flow_filter 

472 ) and not task_run_filter: 

473 if flow_run_filter: 

474 query = query.where(flow_run_filter.as_sql_filter()) 

475 

476 if deployment_filter: 

477 query = query.join( 

478 db.Deployment, db.Deployment.id == db.FlowRun.deployment_id 

479 ) 

480 query = query.where(deployment_filter.as_sql_filter()) 

481 

482 if work_queue_filter or work_pool_filter: 

483 # WorkPool is not directly joined to FlowRun; go through WorkQueue 

484 # as the bridge: FlowRun → WorkQueue → WorkPool. 

485 query = query.join( 

486 db.WorkQueue, db.WorkQueue.id == db.FlowRun.work_queue_id 

487 ) 

488 if work_queue_filter: 

489 query = query.where(work_queue_filter.as_sql_filter()) 

490 if work_pool_filter: 

491 query = query.join( 

492 db.WorkPool, db.WorkPool.id == db.WorkQueue.work_pool_id 

493 ) 

494 query = query.where(work_pool_filter.as_sql_filter()) 

495 

496 if flow_filter: 

497 query = query.join(db.Flow, db.Flow.id == db.FlowRun.flow_id) 

498 query = query.where(flow_filter.as_sql_filter()) 

499 

500 else: 

501 query = await _apply_flow_run_filters( 

502 db, 

503 query, 

504 flow_filter=flow_filter, 

505 flow_run_filter=flow_run_filter, 

506 task_run_filter=task_run_filter, 

507 deployment_filter=deployment_filter, 

508 work_pool_filter=work_pool_filter, 

509 work_queue_filter=work_queue_filter, 

510 ) 

511 

512 result = await session.execute(query) 

513 return result.scalar_one() 

514 

515 

516@db_injector 

517async def delete_flow_run( 

518 db: PrefectDBInterface, session: AsyncSession, flow_run_id: UUID 

519) -> bool: 

520 """ 

521 Delete a flow run by flow_run_id, handling concurrency limits if applicable. 

522 

523 Args: 

524 session: A database session 

525 flow_run_id: a flow run id 

526 

527 Returns: 

528 bool: whether or not the flow run was deleted 

529 """ 

530 flow_run = await read_flow_run(session, flow_run_id) 

531 if not flow_run: 

532 return False 

533 

534 deployment_id = flow_run.deployment_id 

535 

536 if deployment_id: 

537 await cleanup_flow_run_concurrency_slots(session=session, flow_run=flow_run) 

538 

539 # Delete the flow run 

540 result = await session.execute( 

541 delete(db.FlowRun).where(db.FlowRun.id == flow_run_id) 

542 ) 

543 

544 return result.rowcount > 0 

545 

546 

547@db_injector 

548async def delete_flow_runs( 

549 db: PrefectDBInterface, 

550 session: AsyncSession, 

551 flow_run_ids: List[UUID], 

552) -> List[UUID]: 

553 """ 

554 Delete multiple flow runs by their IDs, handling concurrency limits. 

555 

556 Args: 

557 session: A database session 

558 flow_run_ids: a list of flow run ids to delete 

559 

560 Returns: 

561 List[UUID]: the IDs of the flow runs that were deleted 

562 """ 

563 if not flow_run_ids: 

564 return [] 

565 

566 # Read all flow runs to handle concurrency cleanup 

567 flow_runs = await session.execute( 

568 select(db.FlowRun).where(db.FlowRun.id.in_(flow_run_ids)) 

569 ) 

570 flow_run_list = flow_runs.scalars().all() 

571 

572 if not flow_run_list: 

573 return [] 

574 

575 # Cleanup concurrency slots for each flow run that has a deployment 

576 for flow_run in flow_run_list: 

577 if flow_run.deployment_id: 

578 await cleanup_flow_run_concurrency_slots(session=session, flow_run=flow_run) 

579 

580 # Get the IDs of flow runs that exist 

581 existing_ids = [fr.id for fr in flow_run_list] 

582 

583 # Delete all flow runs in one query 

584 await session.execute(delete(db.FlowRun).where(db.FlowRun.id.in_(existing_ids))) 

585 

586 return existing_ids 

587 

588 

589async def set_flow_run_state( 

590 session: AsyncSession, 

591 flow_run_id: UUID, 

592 state: schemas.states.State, 

593 force: bool = False, 

594 flow_policy: Optional[Type[FlowRunOrchestrationPolicy]] = None, 

595 orchestration_parameters: Optional[Dict[str, Any]] = None, 

596 client_version: Optional[str] = None, 

597) -> OrchestrationResult: 

598 """ 

599 Creates a new orchestrated flow run state. 

600 

601 Setting a new state on a run is the one of the principal actions that is governed by 

602 Prefect's orchestration logic. Setting a new run state will not guarantee creation, 

603 but instead trigger orchestration rules to govern the proposed `state` input. If 

604 the state is considered valid, it will be written to the database. Otherwise, a 

605 it's possible a different state, or no state, will be created. A `force` flag is 

606 supplied to bypass a subset of orchestration logic. 

607 

608 Args: 

609 session: a database session 

610 flow_run_id: the flow run id 

611 state: a flow run state model 

612 force: if False, orchestration rules will be applied that may alter or prevent 

613 the state transition. If True, orchestration rules are not applied. 

614 

615 Returns: 

616 OrchestrationResult object 

617 """ 

618 

619 # load the flow run 

620 run = await models.flow_runs.read_flow_run( 

621 session=session, 

622 flow_run_id=flow_run_id, 

623 # Lock the row to prevent orchestration race conditions 

624 for_update=True, 

625 ) 

626 

627 if not run: 

628 raise ObjectNotFoundError(f"Flow run with id {flow_run_id} not found") 

629 

630 initial_state = run.state.as_state() if run.state else None 

631 initial_state_type = initial_state.type if initial_state else None 

632 proposed_state_type = state.type if state else None 

633 intended_transition = (initial_state_type, proposed_state_type) 

634 

635 if force or flow_policy is None: 

636 flow_policy = MinimalFlowPolicy 

637 

638 orchestration_rules = flow_policy.compile_transition_rules(*intended_transition) # type: ignore 

639 global_rules = GlobalFlowPolicy.compile_transition_rules(*intended_transition) 

640 

641 context = FlowOrchestrationContext( 

642 session=session, 

643 run=run, 

644 initial_state=initial_state, 

645 proposed_state=state, 

646 client_version=client_version, 

647 ) 

648 

649 if orchestration_parameters is not None: 

650 context.parameters = orchestration_parameters 

651 

652 # apply orchestration rules and create the new flow run state 

653 async with contextlib.AsyncExitStack() as stack: 

654 for rule in orchestration_rules: 654 ↛ 659line 654 didn't jump to line 659 because the loop on line 654 didn't complete

655 context = await stack.enter_async_context( 

656 rule(context, *intended_transition) 

657 ) 

658 

659 for rule in global_rules: 

660 context = await stack.enter_async_context( 

661 rule(context, *intended_transition) 

662 ) 

663 

664 await context.validate_proposed_state() 

665 

666 if context.orchestration_error is not None: 666 ↛ 669line 666 didn't jump to line 669 because the condition on line 666 was always true

667 raise context.orchestration_error 

668 

669 result = OrchestrationResult( 

670 state=context.validated_state, 

671 status=context.response_status, 

672 details=context.response_details, 

673 ) 

674 

675 return result 

676 

677 

678@db_injector 

679async def read_flow_run_graph( 

680 db: PrefectDBInterface, 

681 session: AsyncSession, 

682 flow_run_id: UUID, 

683 since: datetime.datetime = earliest_possible_datetime(), 

684) -> Graph: 

685 """Given a flow run, return the graph of it's task and subflow runs. If a `since` 

686 datetime is provided, only return items that may have changed since that time.""" 

687 if isinstance(since, str): 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true

688 since = DateTime.fromisoformat(since) 

689 

690 return await db.queries.flow_run_graph_v2( 

691 session=session, 

692 flow_run_id=flow_run_id, 

693 since=since, 

694 max_nodes=PREFECT_API_MAX_FLOW_RUN_GRAPH_NODES.value(), 

695 max_artifacts=PREFECT_API_MAX_FLOW_RUN_GRAPH_ARTIFACTS.value(), 

696 ) 

697 

698 

699async def with_system_labels_for_flow_run( 

700 session: AsyncSession, 

701 flow_run: Union[schemas.core.FlowRun, schemas.actions.FlowRunCreate], 

702) -> schemas.core.KeyValueLabels: 

703 """Augment user supplied labels with system default labels for a flow 

704 run.""" 

705 

706 user_supplied_labels = flow_run.labels or {} 

707 

708 # `deployment_id` is deprecated on `schemas.actions.FlowRunCreate`. Only 

709 # check `deployment_id` if given an instance of a `schemas.core.FlowRun`. 

710 if isinstance(flow_run, schemas.core.FlowRun) and flow_run.deployment_id: 

711 deployment = await models.deployments.read_deployment( 

712 session, deployment_id=flow_run.deployment_id 

713 ) 

714 if deployment: 

715 # Use the deployment flow run utility for consistent label generation 

716 return await models.deployments.with_system_labels_for_deployment_flow_run( 

717 session=session, 

718 deployment=deployment, 

719 user_supplied_labels=user_supplied_labels, 

720 ) 

721 

722 # If the flow run is not part of a deployment, generate basic flow labels 

723 default_labels = cast( 

724 schemas.core.KeyValueLabels, 

725 { 

726 "prefect.flow.id": str(flow_run.flow_id), 

727 }, 

728 ) 

729 

730 parent_labels = await models.flows.read_flow_labels(session, flow_run.flow_id) or {} 

731 

732 return parent_labels | default_labels | user_supplied_labels 

733 

734 

735@db_injector 

736async def update_flow_run_labels( 

737 db: PrefectDBInterface, 

738 session: AsyncSession, 

739 flow_run_id: UUID, 

740 labels: KeyValueLabels, 

741) -> bool: 

742 """ 

743 Update flow run labels by patching existing labels with new values. 

744 Args: 

745 session: A database session 

746 flow_run_id: the flow run id to update 

747 labels: the new labels to patch into existing labels 

748 Returns: 

749 bool: whether the update was successful 

750 """ 

751 # First read the existing flow run to get current labels 

752 flow_run: Optional[orm_models.FlowRun] = await read_flow_run(session, flow_run_id) 

753 if not flow_run: 

754 raise ObjectNotFoundError(f"Flow run with id {flow_run_id} not found") 

755 

756 # Merge existing labels with new labels 

757 current_labels = flow_run.labels or {} 

758 updated_labels = {**current_labels, **labels} 

759 

760 try: 

761 # Update the flow run with merged labels 

762 result = await session.execute( 

763 sa.update(db.FlowRun) 

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

765 .values(labels=updated_labels) 

766 ) 

767 success = result.rowcount > 0 

768 if success: 

769 await session.commit() # Explicitly commit 

770 return success 

771 except Exception: 

772 raise