Coverage for /usr/local/lib/python3.12/site-packages/prefect/server/events/triggers.py: 83%

447 statements  

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

1""" 

2The triggers consumer watches events streaming in from the event bus and decides whether 

3to act on them based on the automations that users have set up. 

4""" 

5 

6import asyncio 

7import fnmatch 

8from contextlib import AsyncExitStack, asynccontextmanager 

9from datetime import timedelta 

10from typing import ( 

11 TYPE_CHECKING, 

12 AsyncGenerator, 

13 Collection, 

14 Dict, 

15 List, 

16 Optional, 

17 Set, 

18 Tuple, 

19) 

20from uuid import UUID 

21 

22import orjson 

23import sqlalchemy as sa 

24from sqlalchemy.ext.asyncio import AsyncSession 

25from typing_extensions import Literal, TypeAlias 

26 

27import prefect.types._datetime 

28from prefect._internal.retries import retry_async_fn 

29from prefect.logging import get_logger 

30from prefect.server.database import PrefectDBInterface, db_injector 

31from prefect.server.events import messaging 

32from prefect.server.events.actions import ServerActionTypes 

33from prefect.server.events.models.automations import ( 

34 AUTOMATION_CHANGES_CHANNEL, 

35 AutomationChangeEvent, 

36 automations_session, 

37 read_automation, 

38) 

39from prefect.server.events.models.composite_trigger_child_firing import ( 

40 acquire_composite_trigger_lock, 

41 clear_child_firings, 

42 clear_old_child_firings, 

43 get_child_firings, 

44 upsert_child_firing, 

45) 

46from prefect.server.events.ordering import ( 

47 PRECEDING_EVENT_LOOKBACK, 

48 EventArrivedEarly, 

49 get_triggers_causal_ordering, 

50) 

51from prefect.server.events.schemas.automations import ( 

52 Automation, 

53 CompositeTrigger, 

54 EventTrigger, 

55 Firing, 

56 Posture, 

57 Trigger, 

58 TriggeredAction, 

59 TriggerState, 

60) 

61from prefect.server.events.schemas.events import ReceivedEvent 

62from prefect.server.utilities.messaging import Message, MessageHandler 

63from prefect.server.utilities.postgres_listener import ( 

64 get_pg_notify_connection, 

65 pg_listen, 

66) 

67from prefect.settings import PREFECT_EVENTS_EXPIRED_BUCKET_BUFFER 

68from prefect.settings.context import get_current_settings 

69 

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

71 import logging 

72 

73 from prefect.server.database.orm_models import ORMAutomationBucket 

74 

75 

76logger: "logging.Logger" = get_logger(__name__) 

77 

78AutomationID: TypeAlias = UUID 

79TriggerID: TypeAlias = UUID 

80AutomationStateSnapshot: TypeAlias = Tuple[ 

81 Optional[prefect.types._datetime.DateTime], int 

82] 

83 

84 

85AUTOMATION_BUCKET_BATCH_SIZE = 500 

86 

87 

88async def evaluate( 

89 session: AsyncSession, 

90 trigger: EventTrigger, 

91 bucket: "ORMAutomationBucket", 

92 now: prefect.types._datetime.DateTime, 

93 triggering_event: Optional[ReceivedEvent], 

94) -> "ORMAutomationBucket | None": 

95 """Evaluates an Automation, either triggered by a specific event or proactively 

96 on a time interval. Evaluating a Automation updates the associated counters for 

97 each automation, and will fire the associated action if it has met the threshold.""" 

98 automation = trigger.automation 

99 

100 logging_context = { 

101 "automation": automation.id, 

102 "trigger": trigger.id, 

103 "bucketing_key": bucket.bucketing_key, 

104 "bucket_start": bucket.start, 

105 "bucket_end": bucket.end, 

106 "bucket_initial_count": bucket.count, 

107 "bucket_last_operation": bucket.last_operation, 

108 "now": now, 

109 "triggering_event": ( 

110 { 

111 "id": triggering_event.id, 

112 "event": triggering_event.event, 

113 } 

114 if triggering_event 

115 else None 

116 ), 

117 } 

118 

119 # Implementation notes: 

120 # 

121 # This triggering algorithm maintains an invariant that there is exactly one 

122 # time-based "bucket" open and collecting events for each automation at a time. When 

123 # an event comes in that matches the automation, one of four things can happen: 

124 # 

125 # 1. The event would have matched an older bucket that has either expired or has 

126 # already filled up, and thus is no longer relevant; 

127 # 2. The event matches the current bucket, but the bucket does not meet its 

128 # threshold yet; 

129 # 3. The event matches the current bucket, causes it to meet the the threshold, so 

130 # we fire immediately and advance the bucket to the next time period. 

131 # 4. The event matches the current bucket, but the event is for a future time after 

132 # the current bucket has expired, so we will start the new bucket and re-evaluate 

133 # 

134 # Automations are also evaluated proactively without an event to see if they have 

135 # met their proactive threshold (where not enough events have happened in the time 

136 # period) 

137 

138 # If there was a triggering event, then we need to "spend" this count somewhere, 

139 # either in the currently open bucket or in the next time period's bucket 

140 count = 1 if triggering_event else 0 

141 

142 if now < bucket.start: 

143 # This is an older out-of-order message or a redundant event for a reactive 

144 # trigger that has has already fired, so it should not should not affect the 

145 # current bucket. We can safely ignore this event/timestamp entirely. Case #1 

146 # from the implementation notes above. 

147 logger.debug( 

148 "Automation %s (%r) trigger %s got a late event for keys (%r)", 

149 automation.id, 

150 automation.name, 

151 trigger.id, 

152 bucket.bucketing_key, 

153 extra=logging_context, 

154 ) 

155 return bucket 

156 

157 if count and (trigger.immediate or bucket.start <= now < bucket.end): 

158 # we are still within the automation time period, so spend the count in the 

159 # current bucket 

160 bucket = await increment_bucket(session, bucket, count, triggering_event) 

161 count = 0 

162 

163 # Reactive automations will fire "eagerly", meaning they will fire as _soon_ as the 

164 # threshold is reached, then wait for the rest of their time period before 

165 # firing again. This is done by creating a new bucket in the future after the 

166 # trigger has fired. 

167 

168 # Proactive automations must wait until the whole bucket has expired before they can 

169 # fire, because we can't know if we'll get one late event just in time to cause the 

170 # automation _not_ to fire. 

171 

172 ready_to_fire = trigger.posture == Posture.Reactive or bucket.end <= now 

173 meets_threshold = trigger.meets_threshold(bucket.count) 

174 

175 if ready_to_fire and meets_threshold: 

176 logger.debug( 

177 ( 

178 "Automation %s (%r) trigger %s triggered for keys (%r) %s, " 

179 "having occurred %s times between %s and %s" 

180 ), 

181 automation.id, 

182 automation.name, 

183 trigger.id, 

184 bucket.bucketing_key, 

185 "reactively" if triggering_event else "proactively", 

186 bucket.count, 

187 bucket.start, 

188 bucket.end, 

189 extra=logging_context, 

190 ) 

191 

192 firing = Firing( 

193 trigger=trigger, 

194 trigger_states={TriggerState.Triggered}, 

195 triggered=prefect.types._datetime.now("UTC"), 

196 triggering_labels={ 

197 label: value 

198 for label, value in zip(sorted(trigger.for_each), bucket.bucketing_key) 

199 }, 

200 triggering_event=triggering_event or bucket.last_event, 

201 ) 

202 

203 await fire(session, firing) 

204 

205 # when acting, remove the current bucket from the database immediately to avoid 

206 # potentially double-acting in the case of a crash between now and the next 

207 # time we backup buckets to the database 

208 await remove_bucket(session, bucket) 

209 

210 elif now < bucket.end: 210 ↛ 255line 210 didn't jump to line 255 because the condition on line 210 was always true

211 # We didn't fire this time, and also the bucket still has more time, so leave 

212 # before setting up a new future bucket. Case #2 from the implementation notes 

213 # above. 

214 logger.debug( 

215 "Automation %s (%r) trigger %s has more time for keys (%r)", 

216 automation.id, 

217 automation.name, 

218 trigger.id, 

219 bucket.bucketing_key, 

220 extra={ 

221 **logging_context, 

222 "ready_to_fire": ready_to_fire, 

223 "meets_threshold": meets_threshold, 

224 }, 

225 ) 

226 

227 # Special case of a proactive trigger for which the same event satisfies 

228 # both `after` and `expect`. For example, using flow run heartbeats for crash 

229 # detection, after the first heartbeat we expect a subsequent heartbeat or 

230 # terminal state within a given time. 

231 # 

232 # If we've already reached the proactive threshold, we need to remove the 

233 # current bucket and start a new one for the latest event. 

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

235 triggering_event 

236 and trigger.posture == Posture.Proactive 

237 and not meets_threshold 

238 and trigger.starts_after(triggering_event.event) 

239 and trigger.expects(triggering_event.event) 

240 ): 

241 await remove_bucket(session, bucket) 

242 return await start_new_bucket( 

243 session, 

244 trigger, 

245 bucketing_key=bucket.bucketing_key, 

246 start=triggering_event.occurred, 

247 end=triggering_event.occurred + trigger.within, 

248 count=0, 

249 last_event=triggering_event, 

250 ) 

251 

252 return bucket 

253 else: 

254 # Case #2 from the implementation notes above. 

255 logger.debug( 

256 "Automation %s (%r) trigger %s not ready to fire for keys (%r)", 

257 automation.id, 

258 automation.name, 

259 trigger.id, 

260 bucket.bucketing_key, 

261 extra={ 

262 **logging_context, 

263 "ready_to_fire": ready_to_fire, 

264 "meets_threshold": meets_threshold, 

265 "bucket_current_count": bucket.count, 

266 }, 

267 ) 

268 

269 # We are now outside of the automation's time period or we triggered this 

270 # time. That means it's time to start a new bucket for the next possible time 

271 # window (if this automation does not require an event to start `after`): 

272 if trigger.after: 272 ↛ 275line 272 didn't jump to line 275 because the condition on line 272 was never true

273 # remove the bucket because it should only get re-created if we see another 

274 # appropriate starting event 

275 return await remove_bucket(session, bucket) 

276 else: 

277 if trigger.within == timedelta(seconds=0): 277 ↛ 280line 277 didn't jump to line 280 because the condition on line 277 was always true

278 return None 

279 

280 start = prefect.types._datetime.create_datetime_instance(max(bucket.end, now)) 

281 end = start + trigger.within 

282 

283 # If we're processing a reactive trigger and leaving the function with a count 

284 # that we've just spent in the bucket for the next time window, it means that we 

285 # just processed an event that was in the future. It's possible that this event 

286 # was sufficient enough to cause the trigger to fire, so we need to evaluate one 

287 # more time to see if that's the case. This is case #4 from the implementation 

288 # notes above. 

289 if trigger.posture == Posture.Reactive and count > 0: 

290 bucket = await start_new_bucket( 

291 session, 

292 trigger, 

293 bucketing_key=tuple(bucket.bucketing_key), 

294 start=start, 

295 end=end, 

296 count=0, 

297 ) 

298 return await evaluate(session, trigger, bucket, now, triggering_event) 

299 else: 

300 return await start_new_bucket( 

301 session, 

302 trigger, 

303 bucketing_key=tuple(bucket.bucketing_key), 

304 start=start, 

305 end=end, 

306 count=count, 

307 ) 

308 

309 

310async def fire(session: AsyncSession, firing: Firing) -> None: 

311 if isinstance(firing.trigger.parent, Automation): 

312 await act(firing) 

313 elif isinstance(firing.trigger.parent, CompositeTrigger): 313 ↛ 316line 313 didn't jump to line 316 because the condition on line 313 was always true

314 await evaluate_composite_trigger(session, firing) 

315 else: 

316 raise NotImplementedError( 

317 f"Cannot fire {firing} with parent trigger type {type(firing.trigger.parent)}" 

318 ) 

319 

320 

321async def evaluate_composite_trigger(session: AsyncSession, firing: Firing) -> None: 

322 automation = firing.trigger.automation 

323 

324 assert isinstance(firing.trigger.parent, CompositeTrigger) 

325 trigger: CompositeTrigger = firing.trigger.parent 

326 

327 # If we only need to see 1 child firing, 

328 # then the parent trigger can fire immediately. 

329 if trigger.num_expected_firings == 1: 329 ↛ 358line 329 didn't jump to line 358 because the condition on line 329 was always true

330 logger.info( 

331 "Automation %s (%r) %s trigger %s fired (shortcut)", 

332 automation.id, 

333 automation.name, 

334 trigger.type, 

335 trigger.id, 

336 extra={ 

337 "automation": automation.id, 

338 "trigger": trigger.id, 

339 "trigger_type": trigger.type, 

340 "firings": firing.id, 

341 }, 

342 ) 

343 await fire( 

344 session, 

345 Firing( 

346 trigger=trigger, 

347 trigger_states={TriggerState.Triggered}, 

348 triggered=prefect.types._datetime.now("UTC"), 

349 triggering_firings=[firing], 

350 triggering_event=firing.triggering_event, 

351 ), 

352 ) 

353 return 

354 

355 # Acquire an advisory lock to serialize concurrent evaluations for this 

356 # compound trigger. This prevents a race condition where multiple child 

357 # triggers fire concurrently and neither transaction sees both firings. 

358 await acquire_composite_trigger_lock(session, trigger) 

359 

360 # If we're only looking within a certain time horizon, remove any older firings that 

361 # should no longer be considered as satisfying this trigger 

362 if trigger.within is not None: 

363 await clear_old_child_firings( 

364 session, trigger, firing.triggered - trigger.within 

365 ) 

366 

367 # Otherwise we need N child firings. We'll upsert this firing and then check 

368 # what the current state of the world is. If we have enough firings, we'll 

369 # fire the parent trigger. 

370 await upsert_child_firing(session, firing) 

371 firings: list[Firing] = [ 

372 cf.child_firing for cf in await get_child_firings(session, trigger) 

373 ] 

374 firing_ids: set[UUID] = {f.id for f in firings} 

375 

376 # If our current firing no longer exists when we read firings 

377 # another firing has superseded it, and we should defer to that one 

378 if firing.id not in firing_ids: 378 ↛ anywhereline 378 didn't jump anywhere: it always raised an exception.

379 return 

380 

381 if trigger.ready_to_fire(firings): 381 ↛ exitline 381 didn't return from function 'evaluate_composite_trigger' because the condition on line 381 was always true

382 logger.info( 

383 "Automation %s (%r) %s trigger %s fired", 

384 automation.id, 

385 automation.name, 

386 trigger.type, 

387 trigger.id, 

388 extra={ 

389 "automation": automation.id, 

390 "trigger": trigger.id, 

391 "trigger_type": trigger.type, 

392 "firings": ",".join(str(f.id) for f in firings), 

393 }, 

394 ) 

395 

396 # Clear by firing id, and only proceed if we won the race to claim them. 

397 # This prevents double-firing when multiple workers evaluate concurrently. 

398 deleted_ids = await clear_child_firings( 

399 session, trigger, firing_ids=list(firing_ids) 

400 ) 

401 

402 if deleted_ids != firing_ids: 

403 logger.debug( 

404 "Composite trigger %s skipped fire; expected to delete %s firings, " 

405 "actually deleted %s (another worker likely claimed them)", 

406 trigger.id, 

407 len(firing_ids), 

408 len(deleted_ids), 

409 extra={ 

410 "automation": automation.id, 

411 "trigger": trigger.id, 

412 "expected_firing_ids": sorted(str(f) for f in firing_ids), 

413 "deleted_firing_ids": sorted(str(f) for f in deleted_ids), 

414 }, 

415 ) 

416 return 

417 

418 await fire( 

419 session, 

420 Firing( 

421 trigger=trigger, 

422 trigger_states={TriggerState.Triggered}, 

423 triggered=prefect.types._datetime.now("UTC"), 

424 triggering_firings=firings, 

425 triggering_event=firing.triggering_event, 

426 ), 

427 ) 

428 

429 

430async def act(firing: Firing) -> None: 

431 """Given a Automation that has been triggered, the triggering labels and event 

432 (if there was one), publish an action for the `actions` service to process.""" 

433 automation = firing.trigger.automation 

434 

435 state_change_events: Dict[TriggerState, ReceivedEvent] = { 

436 trigger_state: firing.trigger.create_automation_state_change_event( 

437 firing=firing, 

438 trigger_state=trigger_state, 

439 ) 

440 for trigger_state in sorted(firing.trigger_states, key=list(TriggerState).index) 

441 } 

442 await messaging.publish(state_change_events.values()) 

443 

444 # Determine the primary state change event ID for linking action events back to 

445 # the automation.triggered or automation.resolved event. Prefer Triggered over 

446 # Resolved when both are present. 

447 primary_state_change_event = state_change_events.get( 

448 TriggerState.Triggered 

449 ) or state_change_events.get(TriggerState.Resolved) 

450 primary_state_change_event_id = ( 

451 primary_state_change_event.id if primary_state_change_event else None 

452 ) 

453 

454 # By default, all `automation.actions` are fired. Each tuple contains: 

455 # (triggering_event, action, automation_triggered_event_id) 

456 source_actions: List[ 

457 Tuple[Optional[ReceivedEvent], ServerActionTypes, UUID | None] 

458 ] = [ 

459 (firing.triggering_event, action, primary_state_change_event_id) 

460 for action in automation.actions 

461 ] 

462 

463 # Conditionally add in actions that fire on specific trigger states 

464 if TriggerState.Triggered in firing.trigger_states: 464 ↛ 471line 464 didn't jump to line 471 because the condition on line 464 was always true

465 triggered_event = state_change_events[TriggerState.Triggered] 

466 source_actions += [ 

467 (triggered_event, action, triggered_event.id) 

468 for action in automation.actions_on_trigger 

469 ] 

470 

471 if TriggerState.Resolved in firing.trigger_states: 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true

472 resolved_event = state_change_events[TriggerState.Resolved] 

473 source_actions += [ 

474 (resolved_event, action, resolved_event.id) 

475 for action in automation.actions_on_resolve 

476 ] 

477 

478 actions = [ 

479 TriggeredAction( 

480 automation=automation, 

481 firing=firing, 

482 triggered=firing.triggered, 

483 triggering_labels=firing.triggering_labels, 

484 triggering_event=action_triggering_event, 

485 action=action, 

486 action_index=index, 

487 automation_triggered_event_id=automation_triggered_event_id, 

488 ) 

489 for index, ( 

490 action_triggering_event, 

491 action, 

492 automation_triggered_event_id, 

493 ) in enumerate(source_actions) 

494 ] 

495 

496 async with messaging.create_actions_publisher() as publisher: 

497 for action in actions: 

498 await publisher.publish_data(action.model_dump_json().encode(), {}) 

499 

500 

501__events_clock_lock: Optional[asyncio.Lock] = None 

502_events_clock: Optional[float] = None 

503_events_clock_updated: Optional[float] = None 

504 

505 

506def _events_clock_lock() -> asyncio.Lock: 

507 global __events_clock_lock 

508 if __events_clock_lock is None: 

509 __events_clock_lock = asyncio.Lock() 

510 return __events_clock_lock 

511 

512 

513async def update_events_clock(event: ReceivedEvent) -> None: 

514 global _events_clock, _events_clock_updated 

515 async with _events_clock_lock(): 

516 # we want the offset to be negative to represent that we are always 

517 # processing events behind realtime... 

518 now = prefect.types._datetime.now("UTC").timestamp() 

519 event_timestamp = event.occurred.timestamp() 

520 offset = event_timestamp - now 

521 

522 # ...and we should clamp this value to zero so we don't inadvertently look like 

523 # we are processing the future 

524 if offset > 0.0: 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true

525 event_timestamp = now 

526 

527 if not _events_clock or event_timestamp >= _events_clock: 

528 _events_clock = event_timestamp 

529 

530 _events_clock_updated = now 

531 

532 

533async def get_events_clock() -> Optional[float]: 

534 global _events_clock 

535 return _events_clock 

536 

537 

538async def get_events_clock_offset() -> float: 

539 """Calculate the current clock offset. This takes into account both the `occurred` 

540 of the last event, as well as the time we _saw_ the last event. This helps to 

541 ensure that in low volume environments, we don't end up getting huge offsets.""" 

542 global _events_clock, _events_clock_updated 

543 

544 async with _events_clock_lock(): 

545 if _events_clock is None or _events_clock_updated is None: 

546 return 0.0 

547 

548 now: float = prefect.types._datetime.now("UTC").timestamp() 

549 offset = (_events_clock - now) + (now - _events_clock_updated) 

550 

551 return offset 

552 

553 

554async def reset_events_clock() -> None: 

555 global _events_clock, _events_clock_updated 

556 async with _events_clock_lock(): 

557 _events_clock = None 

558 _events_clock_updated = None 

559 

560 

561async def reactive_evaluation(event: ReceivedEvent, depth: int = 0) -> None: 

562 """ 

563 Evaluate all automations that may apply to this event. 

564 

565 Args: 

566 event (ReceivedEvent): The event to evaluate. This object contains all the necessary information 

567 about the event, including its type, associated resources, and metadata. 

568 depth (int, optional): The current recursion depth. This is used to prevent infinite recursion 

569 due to cyclic event dependencies. Defaults to 0 and is incremented with 

570 each recursive call. 

571 

572 """ 

573 async with AsyncExitStack() as stack: 

574 await update_events_clock(event) 

575 await stack.enter_async_context( 

576 get_triggers_causal_ordering().preceding_event_confirmed( 

577 reactive_evaluation, event, depth 

578 ) 

579 ) 

580 

581 interested_triggers = find_interested_triggers(event) 

582 if not interested_triggers: 

583 return 

584 

585 for trigger in interested_triggers: 

586 logger.info( 

587 "Automation %s, trigger %s covers event %r (%s) for %r at %r", 

588 trigger.automation.id, 

589 trigger.id, 

590 event.event, 

591 event.id, 

592 event.resource.id, 

593 event.occurred.isoformat(), 

594 ) 

595 

596 bucketing_key = trigger.bucketing_key(event) 

597 

598 async with automations_session(begin_transaction=True) as session: 

599 try: 

600 bucket: Optional["ORMAutomationBucket"] = None 

601 

602 if trigger.after and trigger.starts_after(event.event): 602 ↛ 618line 602 didn't jump to line 618 because the condition on line 602 was never true

603 # When an event matches both the after and expect, each event 

604 # can both start a new bucket and increment the bucket that was 

605 # started by the previous event. Here we offset the bucket to 

606 # start at -1 so that the first event will leave the bucket at 0 

607 # after evaluation. See the tests: 

608 # 

609 # test_same_event_in_expect_and_after_never_reacts_immediately 

610 # test_same_event_in_expect_and_after_reacts_after_threshold_is_met 

611 # test_same_event_in_expect_and_after_proactively_does_not_fire 

612 # test_same_event_in_expect_and_after_proactively_fires 

613 # 

614 # in test_triggers_regressions.py for examples of how we expect 

615 # this to behave. 

616 # 

617 # https://github.com/PrefectHQ/nebula/issues/4201 

618 initial_count = -1 if trigger.expects(event.event) else 0 

619 bucket = await ensure_bucket( 

620 session, 

621 trigger, 

622 bucketing_key, 

623 start=event.occurred, 

624 end=event.occurred + trigger.within, 

625 last_event=event, 

626 initial_count=initial_count, 

627 ) 

628 

629 if ( 

630 not bucket 

631 and not trigger.after 

632 and trigger.expects(event.event) 

633 ): 

634 # When ensuring a bucket and _creating it for the first time_, 

635 # use an old time so that we can catch any other events flowing 

636 # through the system at the same time even if they are out of 

637 # order. After the trigger fires and creates its next bucket, 

638 # time will start from that point forward. We'll use our 

639 # preceding event lookback variable as the horizon that we'll 

640 # accept these older events. 

641 # 

642 # https://github.com/PrefectHQ/nebula/issues/7230 

643 start = event.occurred - PRECEDING_EVENT_LOOKBACK 

644 

645 bucket = await ensure_bucket( 

646 session, 

647 trigger, 

648 bucketing_key=bucketing_key, 

649 start=start, 

650 end=event.occurred + trigger.within, 

651 last_event=event, 

652 ) 

653 

654 if not trigger.expects(event.event): 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true

655 continue 

656 

657 if not bucket: 

658 bucket = await read_bucket(session, trigger, bucketing_key) 

659 if not bucket: 659 ↛ 662line 659 didn't jump to line 662 because the condition on line 659 was always true

660 continue 

661 

662 await evaluate( 

663 session, 

664 trigger, 

665 bucket, 

666 event.occurred, 

667 triggering_event=event, 

668 ) 

669 finally: 

670 await session.commit() 

671 

672 

673# retry on operational errors to account for db flakiness with sqlite 

674@retry_async_fn(max_attempts=3, retry_on_exceptions=(sa.exc.OperationalError,)) 

675async def get_lost_followers() -> List[ReceivedEvent]: 

676 """Get followers that have been sitting around longer than our lookback""" 

677 return await get_triggers_causal_ordering().get_lost_followers() 

678 

679 

680async def periodic_evaluation(now: prefect.types._datetime.DateTime) -> None: 

681 """Periodic tasks that should be run regularly, but not as often as every event""" 

682 offset = await get_events_clock_offset() 

683 as_of = now + timedelta(seconds=offset) 

684 

685 logger.debug("Running periodic evaluation as of %s (offset %ss)", as_of, offset) 

686 

687 await reconcile_automations() 

688 

689 # Any followers that have been sitting around longer than our lookback are never 

690 # going to see their leader event (maybe it was lost or took too long to arrive). 

691 # These events can just be evaluated now in the order they occurred. 

692 for event in await get_lost_followers(): 692 ↛ 693line 692 didn't jump to line 693 because the loop on line 692 never started

693 await reactive_evaluation(event) 

694 

695 async with automations_session() as session: 

696 await sweep_closed_buckets( 

697 session, 

698 as_of - PREFECT_EVENTS_EXPIRED_BUCKET_BUFFER.value(), 

699 ) 

700 await session.commit() 

701 

702 

703async def evaluate_periodically(periodic_granularity: timedelta) -> None: 

704 """Runs periodic evaluation on the given interval""" 

705 logger.debug( 

706 "Starting periodic evaluation task every %s seconds", 

707 periodic_granularity.total_seconds(), 

708 ) 

709 while True: 

710 try: 

711 await periodic_evaluation(prefect.types._datetime.now("UTC")) 

712 except Exception: 

713 logger.exception("Error running periodic evaluation") 

714 finally: 

715 await asyncio.sleep(periodic_granularity.total_seconds()) 

716 

717 

718# The currently loaded automations for this shard, organized both by ID and by 

719# account and workspace 

720automations_by_id: Dict[UUID, Automation] = {} 

721triggers: Dict[TriggerID, EventTrigger] = {} 

722# Index from expect/after pattern → trigger IDs, so find_interested_triggers 

723# checks a handful of patterns instead of scanning all triggers. 

724_triggers_by_expect: Dict[str, Set[TriggerID]] = {} 

725next_proactive_runs: Dict[TriggerID, prefect.types._datetime.DateTime] = {} 

726automation_state_snapshot: Optional[AutomationStateSnapshot] = None 

727 

728# This lock governs any changes to the set of loaded automations; any routine that will 

729# add/remove automations must be holding this lock when it does so. It's best to use 

730# the methods below to access the loaded set of automations. 

731__automations_lock: Optional[asyncio.Lock] = None 

732 

733 

734def _automations_lock() -> asyncio.Lock: 

735 global __automations_lock 

736 if __automations_lock is None: 

737 __automations_lock = asyncio.Lock() 

738 return __automations_lock 

739 

740 

741def find_interested_triggers(event: ReceivedEvent) -> Collection[EventTrigger]: 

742 candidate_ids: set[TriggerID] = set() 

743 for expect_pattern, trigger_ids in _triggers_by_expect.items(): 

744 if fnmatch.fnmatchcase(event.event, expect_pattern): 

745 candidate_ids.update(trigger_ids) 

746 candidates = [triggers[tid] for tid in candidate_ids if tid in triggers] 

747 return [trigger for trigger in candidates if trigger.covers(event)] 

748 

749 

750def clear_loaded_automations() -> None: 

751 automations_by_id.clear() 

752 triggers.clear() 

753 _triggers_by_expect.clear() 

754 next_proactive_runs.clear() 

755 

756 

757def _index_keys_for(trigger: EventTrigger) -> set[str]: 

758 # An empty `expect` means "match any event" (event_pattern is `.+`), so the 

759 # trigger must be reachable for every event regardless of its `after` set. 

760 if not trigger.expect: 

761 return {"*"} 

762 return trigger.expect | trigger.after 

763 

764 

765def _index_trigger(trigger: EventTrigger) -> None: 

766 for key in _index_keys_for(trigger): 

767 _triggers_by_expect.setdefault(key, set()).add(trigger.id) 

768 

769 

770def _unindex_trigger(trigger: EventTrigger) -> None: 

771 for key in _index_keys_for(trigger): 

772 if key in _triggers_by_expect: 772 ↛ 771line 772 didn't jump to line 771 because the condition on line 772 was always true

773 _triggers_by_expect[key].discard(trigger.id) 

774 if not _triggers_by_expect[key]: 

775 del _triggers_by_expect[key] 

776 

777 

778def load_automation(automation: Optional[Automation]) -> None: 

779 """Loads the given automation into memory so that it is available for evaluations""" 

780 if not automation: 780 ↛ 781line 780 didn't jump to line 781 because the condition on line 780 was never true

781 return 

782 

783 event_triggers = automation.triggers_of_type(EventTrigger) 

784 

785 if not automation.enabled or not event_triggers: 

786 forget_automation(automation.id) 

787 return 

788 

789 automations_by_id[automation.id] = automation 

790 

791 for trigger in event_triggers: 

792 triggers[trigger.id] = trigger 

793 next_proactive_runs.pop(trigger.id, None) 

794 _index_trigger(trigger) 

795 

796 

797def forget_automation(automation_id: UUID) -> None: 

798 """Unloads the given automation from memory""" 

799 if automation := automations_by_id.pop(automation_id, None): 

800 for trigger in automation.triggers(): 

801 triggers.pop(trigger.id, None) 

802 next_proactive_runs.pop(trigger.id, None) 

803 for trigger in automation.triggers_of_type(EventTrigger): 

804 _unindex_trigger(trigger) 

805 

806 

807async def automation_changed( 

808 automation_id: UUID, 

809 event: Literal["automation__created", "automation__updated", "automation__deleted"], 

810) -> None: 

811 global automation_state_snapshot 

812 

813 async with _automations_lock(): 

814 if event in ("automation__deleted", "automation__updated"): 

815 forget_automation(automation_id) 

816 

817 async with automations_session() as session: 

818 if event in ("automation__created", "automation__updated"): 

819 automation = await read_automation(session, automation_id) 

820 load_automation(automation) 

821 automation_state_snapshot = await read_automation_state_snapshot(session) 

822 

823 

824@db_injector 

825async def load_automations(db: PrefectDBInterface, session: AsyncSession): 

826 """Loads all automations for the given set of accounts""" 

827 query = sa.select(db.Automation) 

828 

829 logger.debug("Loading automations") 

830 

831 result = await session.execute(query) 

832 for automation in result.scalars().all(): 

833 load_automation(Automation.model_validate(automation, from_attributes=True)) 

834 

835 logger.debug( 

836 "Loaded %s automations with %s triggers", len(automations_by_id), len(triggers) 

837 ) 

838 

839 

840@db_injector 

841async def read_automation_state_snapshot( 

842 db: PrefectDBInterface, session: AsyncSession 

843) -> AutomationStateSnapshot: 

844 query = sa.select( 

845 sa.func.max(db.Automation.updated), 

846 sa.func.count(db.Automation.id), 

847 ).select_from(db.Automation) 

848 

849 latest_updated, count = (await session.execute(query)).one() 

850 

851 return ( 

852 prefect.types._datetime.create_datetime_instance(latest_updated) 

853 if latest_updated 

854 else None, 

855 count or 0, 

856 ) 

857 

858 

859async def reconcile_automations(force: bool = False) -> bool: 

860 global automation_state_snapshot 

861 

862 async with _automations_lock(): 

863 async with automations_session() as session: 

864 current_snapshot = await read_automation_state_snapshot(session) 

865 if not force and current_snapshot == automation_state_snapshot: 

866 return False 

867 

868 previous_automations = automations_by_id.copy() 

869 previous_triggers = triggers.copy() 

870 previous_triggers_by_expect = { 

871 k: v.copy() for k, v in _triggers_by_expect.items() 

872 } 

873 previous_next_proactive_runs = next_proactive_runs.copy() 

874 

875 clear_loaded_automations() 

876 

877 try: 

878 await load_automations(session) 

879 except Exception: 

880 clear_loaded_automations() 

881 automations_by_id.update(previous_automations) 

882 triggers.update(previous_triggers) 

883 _triggers_by_expect.update(previous_triggers_by_expect) 

884 next_proactive_runs.update(previous_next_proactive_runs) 

885 raise 

886 

887 automation_state_snapshot = current_snapshot 

888 return True 

889 

890 

891@db_injector 

892async def remove_buckets_exceeding_threshold( 

893 db: PrefectDBInterface, session: AsyncSession, trigger: EventTrigger 

894): 

895 """Deletes bucket where the count has already exceeded the threshold""" 

896 assert isinstance(trigger, EventTrigger), repr(trigger) 

897 await session.execute( 

898 sa.delete(db.AutomationBucket).where( 

899 db.AutomationBucket.automation_id == trigger.automation.id, 

900 db.AutomationBucket.trigger_id == trigger.id, 

901 db.AutomationBucket.count >= trigger.threshold, 

902 ) 

903 ) 

904 

905 

906@db_injector 

907async def read_buckets_for_automation( 

908 db: PrefectDBInterface, 

909 session: AsyncSession, 

910 trigger: Trigger, 

911 batch_size: int = AUTOMATION_BUCKET_BATCH_SIZE, 

912) -> AsyncGenerator["ORMAutomationBucket", None]: 

913 """Yields buckets for the given automation and trigger in batches.""" 

914 offset = 0 

915 

916 while True: 

917 query = ( 

918 sa.select(db.AutomationBucket) 

919 .where( 

920 db.AutomationBucket.automation_id == trigger.automation.id, 

921 db.AutomationBucket.trigger_id == trigger.id, 

922 ) 

923 .order_by(db.AutomationBucket.start) 

924 .limit(batch_size) 

925 .offset(offset) 

926 ) 

927 

928 result = await session.execute(query) 

929 buckets = result.scalars().all() 

930 

931 if not buckets: 

932 break 

933 

934 for bucket in buckets: 

935 yield bucket 

936 

937 offset += batch_size 

938 

939 

940@db_injector 

941async def read_bucket( 

942 db: PrefectDBInterface, 

943 session: AsyncSession, 

944 trigger: Trigger, 

945 bucketing_key: Tuple[str, ...], 

946) -> Optional["ORMAutomationBucket"]: 

947 """Gets the bucket this event would fall into for the given Automation, if there is 

948 one currently""" 

949 return await read_bucket_by_trigger_id( 

950 session, 

951 trigger.automation.id, 

952 trigger.id, 

953 bucketing_key, 

954 ) 

955 

956 

957@db_injector 

958async def read_bucket_by_trigger_id( 

959 db: PrefectDBInterface, 

960 session: AsyncSession, 

961 automation_id: UUID, 

962 trigger_id: UUID, 

963 bucketing_key: Tuple[str, ...], 

964) -> "ORMAutomationBucket | None": 

965 """Gets the bucket this event would fall into for the given Automation, if there is 

966 one currently""" 

967 query = sa.select(db.AutomationBucket).where( 

968 db.AutomationBucket.automation_id == automation_id, 

969 db.AutomationBucket.trigger_id == trigger_id, 

970 db.AutomationBucket.bucketing_key == bucketing_key, 

971 ) 

972 result = await session.execute(query) 

973 bucket = result.scalars().first() 

974 if bucket: 

975 await session.refresh(bucket) 

976 

977 return bucket 

978 

979 

980@db_injector 

981async def increment_bucket( 

982 db: PrefectDBInterface, 

983 session: AsyncSession, 

984 bucket: "ORMAutomationBucket", 

985 count: int, 

986 last_event: Optional[ReceivedEvent], 

987) -> "ORMAutomationBucket": 

988 """Adds the given count to the bucket, returning the new bucket""" 

989 additional_updates: dict[str, ReceivedEvent] = ( 

990 {"last_event": last_event} if last_event else {} 

991 ) 

992 await session.execute( 

993 db.queries.insert(db.AutomationBucket) 

994 .values( 

995 automation_id=bucket.automation_id, 

996 trigger_id=bucket.trigger_id, 

997 bucketing_key=bucket.bucketing_key, 

998 start=bucket.start, 

999 end=bucket.end, 

1000 count=count, 

1001 last_operation="increment_bucket[insert]", 

1002 ) 

1003 .on_conflict_do_update( 

1004 index_elements=[ 

1005 db.AutomationBucket.automation_id, 

1006 db.AutomationBucket.trigger_id, 

1007 db.AutomationBucket.bucketing_key, 

1008 ], 

1009 set_=dict( 

1010 count=db.AutomationBucket.count + count, 

1011 last_operation="increment_bucket[update]", 

1012 updated=prefect.types._datetime.now("UTC"), 

1013 **additional_updates, 

1014 ), 

1015 ) 

1016 ) 

1017 

1018 read_bucket = await read_bucket_by_trigger_id( 

1019 session, 

1020 bucket.automation_id, 

1021 bucket.trigger_id, 

1022 tuple(bucket.bucketing_key), 

1023 ) 

1024 

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

1026 assert read_bucket is not None 

1027 

1028 return read_bucket 

1029 

1030 

1031@db_injector 

1032async def start_new_bucket( 

1033 db: PrefectDBInterface, 

1034 session: AsyncSession, 

1035 trigger: EventTrigger, 

1036 bucketing_key: Tuple[str, ...], 

1037 start: prefect.types._datetime.DateTime, 

1038 end: prefect.types._datetime.DateTime, 

1039 count: int, 

1040 triggered_at: Optional[prefect.types._datetime.DateTime] = None, 

1041 last_event: Optional[ReceivedEvent] = None, 

1042) -> "ORMAutomationBucket": 

1043 """Ensures that a bucket with the given start and end exists with the given count, 

1044 returning the new bucket""" 

1045 automation = trigger.automation 

1046 

1047 await session.execute( 

1048 db.queries.insert(db.AutomationBucket) 

1049 .values( 

1050 automation_id=automation.id, 

1051 trigger_id=trigger.id, 

1052 bucketing_key=bucketing_key, 

1053 start=start, 

1054 end=end, 

1055 count=count, 

1056 last_operation="start_new_bucket[insert]", 

1057 triggered_at=triggered_at, 

1058 last_event=last_event, 

1059 ) 

1060 .on_conflict_do_update( 

1061 index_elements=[ 

1062 db.AutomationBucket.automation_id, 

1063 db.AutomationBucket.trigger_id, 

1064 db.AutomationBucket.bucketing_key, 

1065 ], 

1066 set_=dict( 

1067 start=start, 

1068 end=end, 

1069 count=count, 

1070 last_operation="start_new_bucket[update]", 

1071 updated=prefect.types._datetime.now("UTC"), 

1072 triggered_at=triggered_at, 

1073 last_event=last_event, 

1074 ), 

1075 ) 

1076 ) 

1077 

1078 read_bucket = await read_bucket_by_trigger_id( 

1079 session, 

1080 automation.id, 

1081 trigger.id, 

1082 tuple(bucketing_key), 

1083 ) 

1084 

1085 if TYPE_CHECKING: 

1086 assert read_bucket is not None 

1087 

1088 return read_bucket 

1089 

1090 

1091@db_injector 

1092async def ensure_bucket( 

1093 db: PrefectDBInterface, 

1094 session: AsyncSession, 

1095 trigger: EventTrigger, 

1096 bucketing_key: Tuple[str, ...], 

1097 start: prefect.types._datetime.DateTime, 

1098 end: prefect.types._datetime.DateTime, 

1099 last_event: Optional[ReceivedEvent], 

1100 initial_count: int = 0, 

1101) -> "ORMAutomationBucket": 

1102 """Ensures that a bucket has been started for the given automation and key, 

1103 returning the current bucket. Will not modify the existing bucket.""" 

1104 automation = trigger.automation 

1105 additional_updates: dict[str, ReceivedEvent] = ( 

1106 {"last_event": last_event} if last_event else {} 

1107 ) 

1108 await session.execute( 

1109 db.queries.insert(db.AutomationBucket) 

1110 .values( 

1111 automation_id=automation.id, 

1112 trigger_id=trigger.id, 

1113 bucketing_key=bucketing_key, 

1114 last_event=last_event, 

1115 start=start, 

1116 end=end, 

1117 count=initial_count, 

1118 last_operation="ensure_bucket[insert]", 

1119 ) 

1120 .on_conflict_do_update( 

1121 index_elements=[ 

1122 db.AutomationBucket.automation_id, 

1123 db.AutomationBucket.trigger_id, 

1124 db.AutomationBucket.bucketing_key, 

1125 ], 

1126 set_=dict( 

1127 # no-op, but this counts as an update so the query returns a row 

1128 count=db.AutomationBucket.count, 

1129 **additional_updates, 

1130 ), 

1131 ) 

1132 ) 

1133 

1134 read_bucket = await read_bucket_by_trigger_id( 

1135 session, automation.id, trigger.id, tuple(bucketing_key) 

1136 ) 

1137 

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

1139 assert read_bucket is not None 

1140 

1141 return read_bucket 

1142 

1143 

1144@db_injector 

1145async def remove_bucket( 

1146 db: PrefectDBInterface, session: AsyncSession, bucket: "ORMAutomationBucket" 

1147): 

1148 """Removes the given bucket from the database""" 

1149 await session.execute( 

1150 sa.delete(db.AutomationBucket).where( 

1151 db.AutomationBucket.automation_id == bucket.automation_id, 

1152 db.AutomationBucket.trigger_id == bucket.trigger_id, 

1153 db.AutomationBucket.bucketing_key == bucket.bucketing_key, 

1154 ) 

1155 ) 

1156 

1157 

1158@db_injector 

1159async def sweep_closed_buckets( 

1160 db: PrefectDBInterface, 

1161 session: AsyncSession, 

1162 older_than: prefect.types._datetime.DateTime, 

1163) -> None: 

1164 await session.execute( 

1165 sa.delete(db.AutomationBucket).where(db.AutomationBucket.end <= older_than) 

1166 ) 

1167 

1168 

1169async def reset() -> None: 

1170 """Resets the in-memory state of the service""" 

1171 global automation_state_snapshot 

1172 

1173 await reset_events_clock() 

1174 clear_loaded_automations() 

1175 automation_state_snapshot = None 

1176 

1177 

1178async def listen_for_automation_changes() -> None: 

1179 """ 

1180 Listens for any changes to automations via PostgreSQL NOTIFY/LISTEN, 

1181 and applies those changes to the set of loaded automations. 

1182 """ 

1183 logger.info("Starting automation change listener") 

1184 

1185 while True: 

1186 conn = None 

1187 try: 

1188 conn = await get_pg_notify_connection() 

1189 if not conn: 1189 ↛ 1190line 1189 didn't jump to line 1190 because the condition on line 1189 was never true

1190 logger.debug( 

1191 "PostgreSQL NOTIFY/LISTEN not available (not using PostgreSQL). " 

1192 "Automation changes will not be synchronized across servers." 

1193 ) 

1194 return 

1195 

1196 logger.info( 

1197 f"Listening for automation changes on {AUTOMATION_CHANGES_CHANNEL}" 

1198 ) 

1199 

1200 await reconcile_automations() 

1201 

1202 async for payload in pg_listen( 1202 ↛ 1244line 1202 didn't jump to line 1244 because the loop on line 1202 didn't complete

1203 conn, 

1204 AUTOMATION_CHANGES_CHANNEL, 

1205 heartbeat_interval=get_current_settings().server.services.triggers.pg_notify_heartbeat_interval_seconds, 

1206 ): 

1207 try: 

1208 data = orjson.loads(payload) 

1209 automation_id = UUID(data["automation_id"]) 

1210 event_type = data["event_type"] 

1211 

1212 logger.info( 

1213 f"Received automation change notification: {event_type} for {automation_id}" 

1214 ) 

1215 

1216 event_map: dict[str, AutomationChangeEvent] = { 

1217 "created": "automation__created", 

1218 "updated": "automation__updated", 

1219 "deleted": "automation__deleted", 

1220 } 

1221 

1222 if event_type in event_map: 1222 ↛ 1225line 1222 didn't jump to line 1225 because the condition on line 1222 was always true

1223 await automation_changed(automation_id, event_map[event_type]) 

1224 else: 

1225 logger.warning(f"Unknown automation event type: {event_type}") 

1226 

1227 except Exception as e: 

1228 logger.error( 

1229 f"Error processing automation change notification: {e}", 

1230 exc_info=True, 

1231 ) 

1232 

1233 except asyncio.CancelledError: 

1234 logger.info("Automation change listener cancelled") 

1235 break 

1236 except Exception as e: 

1237 reconnect_seconds = get_current_settings().server.services.triggers.pg_notify_reconnect_interval_seconds 

1238 logger.error( 

1239 f"Error in automation change listener: {e}. Reconnecting in {reconnect_seconds}s...", 

1240 exc_info=True, 

1241 ) 

1242 await asyncio.sleep(reconnect_seconds) 

1243 finally: 

1244 if conn and not conn.is_closed(): 1244 ↛ 1185line 1244 didn't jump to line 1185 because the condition on line 1244 was always true

1245 await conn.close() 

1246 

1247 

1248@asynccontextmanager 

1249async def consumer( 

1250 periodic_granularity: timedelta = timedelta(seconds=5), 

1251) -> AsyncGenerator[MessageHandler, None]: 

1252 """The `triggers.consumer` processes all Events arriving on the event bus to 

1253 determine if they meet the automation criteria, queuing up a corresponding 

1254 `TriggeredAction` for the `actions` service if the automation criteria is met.""" 

1255 # Start the automation change listener task 

1256 sync_task = asyncio.create_task(listen_for_automation_changes()) 

1257 

1258 await reconcile_automations(force=True) 

1259 

1260 proactive_task = asyncio.create_task(evaluate_periodically(periodic_granularity)) 

1261 

1262 ordering = get_triggers_causal_ordering() 

1263 

1264 async def message_handler(message: Message): 

1265 if not message.data: 1265 ↛ 1266line 1265 didn't jump to line 1266 because the condition on line 1265 was never true

1266 logger.warning("Message had no data") 

1267 

1268 return 

1269 

1270 if not message.attributes: 1270 ↛ 1271line 1270 didn't jump to line 1271 because the condition on line 1270 was never true

1271 logger.warning("Message had no attributes") 

1272 

1273 return 

1274 

1275 if message.attributes.get("event") == "prefect.log.write": 1275 ↛ 1276line 1275 didn't jump to line 1276 because the condition on line 1275 was never true

1276 return 

1277 

1278 try: 

1279 event_id = UUID(message.attributes["id"]) 

1280 except (KeyError, ValueError, TypeError): 

1281 logger.warning( 

1282 "Unable to get event ID from message attributes: %s", 

1283 repr(message.attributes), 

1284 ) 

1285 return 

1286 

1287 if await ordering.event_has_been_seen(event_id): 1287 ↛ 1288line 1287 didn't jump to line 1288 because the condition on line 1287 was never true

1288 return 

1289 

1290 event = ReceivedEvent.model_validate_json(message.data) 

1291 

1292 try: 

1293 await reactive_evaluation(event) 

1294 except EventArrivedEarly: 

1295 pass # it's fine to ACK this message, since it is safe in the DB 

1296 

1297 try: 

1298 logger.debug("Starting reactive evaluation task") 

1299 yield message_handler 

1300 finally: 

1301 sync_task.cancel() 

1302 proactive_task.cancel() 

1303 # Wait for tasks to finish 

1304 await asyncio.gather(sync_task, proactive_task, return_exceptions=True) 

1305 

1306 

1307async def proactive_evaluation( 

1308 trigger: EventTrigger, as_of: prefect.types._datetime.DateTime 

1309) -> prefect.types._datetime.DateTime: 

1310 """The core proactive evaluation operation for a single Automation""" 

1311 assert isinstance(trigger, EventTrigger), repr(trigger) 

1312 automation = trigger.automation 

1313 

1314 offset = await get_events_clock_offset() 

1315 as_of += timedelta(seconds=offset) 

1316 

1317 logger.debug( 

1318 "Evaluating automation %s trigger %s proactively as of %s (offset %ss)", 

1319 automation.id, 

1320 trigger.id, 

1321 as_of, 

1322 offset, 

1323 ) 

1324 

1325 # By default, the next run will come after the full trigger window, but it 

1326 # may be sooner based on the state of the buckets 

1327 run_again_at = as_of + trigger.within 

1328 

1329 async with automations_session() as session: 

1330 try: 

1331 if not trigger.for_each: 1331 ↛ 1343line 1331 didn't jump to line 1343 because the condition on line 1331 was always true

1332 await ensure_bucket( 

1333 session, 

1334 trigger, 

1335 bucketing_key=tuple(), 

1336 start=as_of, 

1337 end=as_of + trigger.within, 

1338 last_event=None, 

1339 ) 

1340 

1341 # preemptively delete buckets where possible without 

1342 # evaluating them in memory 

1343 await remove_buckets_exceeding_threshold(session, trigger) 

1344 

1345 async for bucket in read_buckets_for_automation(session, trigger): 

1346 next_bucket = await evaluate( 

1347 session, trigger, bucket, as_of, triggering_event=None 

1348 ) 

1349 if next_bucket and as_of < next_bucket.end < run_again_at: 

1350 run_again_at = prefect.types._datetime.create_datetime_instance( 

1351 next_bucket.end 

1352 ) 

1353 

1354 return run_again_at 

1355 finally: 

1356 await session.commit() 

1357 

1358 

1359async def evaluate_proactive_triggers() -> None: 

1360 for trigger in list(triggers.values()): 

1361 if trigger.posture != Posture.Proactive: 

1362 continue 

1363 

1364 next_run = next_proactive_runs.get( 

1365 trigger.id, prefect.types._datetime.now("UTC") 

1366 ) 

1367 if next_run > prefect.types._datetime.now("UTC"): 1367 ↛ 1368line 1367 didn't jump to line 1368 because the condition on line 1367 was never true

1368 continue 

1369 

1370 try: 

1371 run_again_at = await proactive_evaluation( 

1372 trigger, prefect.types._datetime.now("UTC") 

1373 ) 

1374 logger.debug( 

1375 "Automation %s trigger %s will run again at %s", 

1376 trigger.automation.id, 

1377 trigger.id, 

1378 run_again_at, 

1379 ) 

1380 next_proactive_runs[trigger.id] = run_again_at 

1381 except Exception: 

1382 logger.exception( 

1383 "Error evaluating automation %s trigger %s proactively", 

1384 trigger.automation.id, 

1385 trigger.id, 

1386 )