prefect 3.6.6__py3-none-any.whl → 3.6.7.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
prefect/_build_info.py CHANGED
@@ -1,5 +1,5 @@
1
1
  # Generated by versioningit
2
- __version__ = "3.6.6"
3
- __build_date__ = "2025-12-11 20:20:23.556858+00:00"
4
- __git_commit__ = "f7d4baf4e04fc31b7a7949b9bc6f2e59d848272b"
2
+ __version__ = "3.6.7.dev3"
3
+ __build_date__ = "2025-12-14 08:09:01.617063+00:00"
4
+ __git_commit__ = "96ab33fd33df46b6a16e3015b5bd1131e61f3ee1"
5
5
  __dirty__ = False
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import logging
11
11
  from dataclasses import dataclass
12
- from typing import Callable, Mapping, Optional
12
+ from typing import Any, Callable, Mapping, Optional
13
13
 
14
14
  import pluggy
15
15
 
@@ -83,3 +83,22 @@ class HookSpec:
83
83
  - May be async or sync
84
84
  - Exceptions are caught and logged unless required=True in strict mode
85
85
  """
86
+
87
+ @hookspec
88
+ def set_database_connection_params(
89
+ self, connection_url: str, settings: Any
90
+ ) -> Mapping[str, Any]:
91
+ """
92
+ Set additional database connection parameters.
93
+
94
+ This hook is called when creating a database engine. It allows plugins
95
+ to provide additional connection parameters, such as authentication
96
+ tokens or SSL configuration.
97
+
98
+ Args:
99
+ connection_url: The database connection URL
100
+ settings: The current Prefect settings
101
+
102
+ Returns:
103
+ Dictionary of connection parameters to merge into connect_args
104
+ """
@@ -14,10 +14,12 @@ from prefect.server.services.cancellation_cleanup import (
14
14
  cancel_child_task_runs,
15
15
  cancel_subflow_run,
16
16
  )
17
+ from prefect.server.services.late_runs import mark_flow_run_late
17
18
  from prefect.server.services.pause_expirations import fail_expired_pause
18
19
  from prefect.server.services.perpetual_services import (
19
20
  register_and_schedule_perpetual_services,
20
21
  )
22
+ from prefect.server.services.repossessor import revoke_expired_lease
21
23
 
22
24
  # Task functions to register with docket for background processing
23
25
  task_functions: list[Callable[..., Any]] = [
@@ -30,6 +32,8 @@ task_functions: list[Callable[..., Any]] = [
30
32
  cancel_child_task_runs,
31
33
  cancel_subflow_run,
32
34
  fail_expired_pause,
35
+ mark_flow_run_late,
36
+ revoke_expired_lease,
33
37
  ]
34
38
 
35
39
 
@@ -156,6 +156,7 @@ def _install_sqlite_locked_log_filter() -> None:
156
156
 
157
157
  filter_ = _SQLiteLockedOperationalErrorFilter()
158
158
  logging.getLogger("uvicorn.error").addFilter(filter_)
159
+ logging.getLogger("docket.worker").addFilter(filter_)
159
160
  _SQLITE_LOCKED_LOG_FILTER = filter_
160
161
 
161
162
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import sqlite3
4
5
  import ssl
5
6
  import traceback
@@ -25,6 +26,12 @@ from sqlalchemy.ext.asyncio import (
25
26
  from sqlalchemy.pool import ConnectionPoolEntry
26
27
  from typing_extensions import TypeAlias
27
28
 
29
+ from prefect._experimental.plugins import (
30
+ HookSpec,
31
+ build_manager,
32
+ call_async_hook,
33
+ load_entry_point_plugins,
34
+ )
28
35
  from prefect._internal.observability import configure_logfire
29
36
  from prefect.settings import (
30
37
  PREFECT_API_DATABASE_CONNECTION_TIMEOUT,
@@ -279,6 +286,33 @@ class AsyncPostgresConfiguration(BaseDatabaseConfiguration):
279
286
  pg_ctx.verify_mode = ssl.CERT_REQUIRED
280
287
  connect_args["ssl"] = pg_ctx
281
288
 
289
+ # Initialize plugin manager
290
+ if get_current_settings().experiments.plugins.enabled:
291
+ pm = build_manager(HookSpec)
292
+ load_entry_point_plugins(
293
+ pm,
294
+ allow=get_current_settings().experiments.plugins.allow,
295
+ deny=get_current_settings().experiments.plugins.deny,
296
+ logger=logging.getLogger("prefect.plugins"),
297
+ )
298
+
299
+ # Call set_database_connection_params hook
300
+ results = await call_async_hook(
301
+ pm,
302
+ "set_database_connection_params",
303
+ connection_url=self.connection_url,
304
+ settings=get_current_settings(),
305
+ )
306
+
307
+ for _, params, error in results:
308
+ if error:
309
+ # Log error but don't fail, other plugins might succeed
310
+ logging.getLogger("prefect.server.database").warning(
311
+ "Plugin failed to set database connection params: %s", error
312
+ )
313
+ elif params:
314
+ connect_args.update(params)
315
+
282
316
  if connect_args:
283
317
  kwargs["connect_args"] = connect_args
284
318
 
@@ -38,22 +38,14 @@ def _known_service_modules() -> list[ModuleType]:
38
38
  )
39
39
  from prefect.server.logs import stream as logs_stream
40
40
  from prefect.server.services import (
41
- foreman,
42
- late_runs,
43
- repossessor,
44
41
  scheduler,
45
42
  task_run_recorder,
46
- telemetry,
47
43
  )
48
44
 
49
45
  return [
50
46
  # Orchestration services
51
- foreman,
52
- late_runs,
53
- repossessor,
54
47
  scheduler,
55
48
  task_run_recorder,
56
- telemetry,
57
49
  # Events services
58
50
  event_logger,
59
51
  event_persister,
@@ -1,14 +1,18 @@
1
1
  """
2
- Foreman is a loop service designed to monitor workers.
2
+ The Foreman service. Monitors workers and marks stale resources as offline/not ready.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
7
+ import logging
5
8
  from datetime import timedelta
6
- from typing import Any, Optional
7
9
 
8
10
  import sqlalchemy as sa
11
+ from docket import Perpetual
9
12
 
13
+ from prefect.logging import get_logger
10
14
  from prefect.server import models
11
- from prefect.server.database import PrefectDBInterface, db_injector
15
+ from prefect.server.database import PrefectDBInterface, provide_database_interface
12
16
  from prefect.server.models.deployments import mark_deployments_not_ready
13
17
  from prefect.server.models.work_queues import mark_work_queues_not_ready
14
18
  from prefect.server.models.workers import emit_work_pool_status_event
@@ -18,224 +22,194 @@ from prefect.server.schemas.statuses import (
18
22
  WorkerStatus,
19
23
  WorkPoolStatus,
20
24
  )
21
- from prefect.server.services.base import LoopService
22
- from prefect.settings import (
23
- PREFECT_API_SERVICES_FOREMAN_DEPLOYMENT_LAST_POLLED_TIMEOUT_SECONDS,
24
- PREFECT_API_SERVICES_FOREMAN_FALLBACK_HEARTBEAT_INTERVAL_SECONDS,
25
- PREFECT_API_SERVICES_FOREMAN_INACTIVITY_HEARTBEAT_MULTIPLE,
26
- PREFECT_API_SERVICES_FOREMAN_LOOP_SECONDS,
27
- PREFECT_API_SERVICES_FOREMAN_WORK_QUEUE_LAST_POLLED_TIMEOUT_SECONDS,
28
- get_current_settings,
29
- )
30
- from prefect.settings.models.server.services import ServicesBaseSetting
25
+ from prefect.server.services.perpetual_services import perpetual_service
26
+ from prefect.settings.context import get_current_settings
31
27
  from prefect.types._datetime import now
32
28
 
29
+ logger: logging.Logger = get_logger(__name__)
30
+
31
+
32
+ @perpetual_service(
33
+ enabled_getter=lambda: get_current_settings().server.services.foreman.enabled,
34
+ )
35
+ async def monitor_worker_health(
36
+ perpetual: Perpetual = Perpetual(
37
+ automatic=False,
38
+ every=timedelta(
39
+ seconds=get_current_settings().server.services.foreman.loop_seconds
40
+ ),
41
+ ),
42
+ ) -> None:
43
+ """
44
+ Monitor workers and mark stale resources as offline/not ready.
33
45
 
34
- class Foreman(LoopService):
46
+ Iterates over workers currently marked as online. Marks workers as offline
47
+ if they have an old last_heartbeat_time. Marks work pools as not ready
48
+ if they do not have any online workers and are currently marked as ready.
49
+ Marks deployments as not ready if they have a last_polled time that is
50
+ older than the configured deployment last polled timeout.
35
51
  """
36
- Monitors the status of workers and their associated work pools
52
+ settings = get_current_settings().server.services.foreman
53
+ db = provide_database_interface()
54
+
55
+ await _mark_online_workers_without_recent_heartbeat_as_offline(
56
+ db=db,
57
+ inactivity_heartbeat_multiple=settings.inactivity_heartbeat_multiple,
58
+ fallback_heartbeat_interval_seconds=settings.fallback_heartbeat_interval_seconds,
59
+ )
60
+ await _mark_work_pools_as_not_ready(db=db)
61
+ await _mark_deployments_as_not_ready(
62
+ db=db,
63
+ deployment_last_polled_timeout_seconds=settings.deployment_last_polled_timeout_seconds,
64
+ )
65
+ await _mark_work_queues_as_not_ready(
66
+ db=db,
67
+ work_queue_last_polled_timeout_seconds=settings.work_queue_last_polled_timeout_seconds,
68
+ )
69
+
70
+
71
+ async def _mark_online_workers_without_recent_heartbeat_as_offline(
72
+ db: PrefectDBInterface,
73
+ inactivity_heartbeat_multiple: int,
74
+ fallback_heartbeat_interval_seconds: int,
75
+ ) -> None:
37
76
  """
77
+ Updates the status of workers that have an old last heartbeat time to OFFLINE.
38
78
 
39
- @classmethod
40
- def service_settings(cls) -> ServicesBaseSetting:
41
- return get_current_settings().server.services.foreman
42
-
43
- def __init__(
44
- self,
45
- loop_seconds: Optional[float] = None,
46
- inactivity_heartbeat_multiple: Optional[int] = None,
47
- fallback_heartbeat_interval_seconds: Optional[int] = None,
48
- deployment_last_polled_timeout_seconds: Optional[int] = None,
49
- work_queue_last_polled_timeout_seconds: Optional[int] = None,
50
- **kwargs: Any,
51
- ):
52
- super().__init__(
53
- loop_seconds=loop_seconds
54
- or PREFECT_API_SERVICES_FOREMAN_LOOP_SECONDS.value(),
55
- **kwargs,
56
- )
57
- self._inactivity_heartbeat_multiple = (
58
- PREFECT_API_SERVICES_FOREMAN_INACTIVITY_HEARTBEAT_MULTIPLE.value()
59
- if inactivity_heartbeat_multiple is None
60
- else inactivity_heartbeat_multiple
61
- )
62
- self._fallback_heartbeat_interval_seconds = (
63
- PREFECT_API_SERVICES_FOREMAN_FALLBACK_HEARTBEAT_INTERVAL_SECONDS.value()
64
- if fallback_heartbeat_interval_seconds is None
65
- else fallback_heartbeat_interval_seconds
66
- )
67
- self._deployment_last_polled_timeout_seconds = (
68
- PREFECT_API_SERVICES_FOREMAN_DEPLOYMENT_LAST_POLLED_TIMEOUT_SECONDS.value()
69
- if deployment_last_polled_timeout_seconds is None
70
- else deployment_last_polled_timeout_seconds
79
+ An old heartbeat is one that is more than the worker's heartbeat interval
80
+ multiplied by the inactivity_heartbeat_multiple seconds ago.
81
+ """
82
+ async with db.session_context(begin_transaction=True) as session:
83
+ worker_update_stmt = (
84
+ sa.update(db.Worker)
85
+ .values(status=WorkerStatus.OFFLINE)
86
+ .where(
87
+ sa.func.date_diff_seconds(db.Worker.last_heartbeat_time)
88
+ > (
89
+ sa.func.coalesce(
90
+ db.Worker.heartbeat_interval_seconds,
91
+ sa.bindparam("default_interval", sa.Integer),
92
+ )
93
+ * sa.bindparam("multiplier", sa.Integer)
94
+ ),
95
+ db.Worker.status == WorkerStatus.ONLINE,
96
+ )
71
97
  )
72
- self._work_queue_last_polled_timeout_seconds = (
73
- PREFECT_API_SERVICES_FOREMAN_WORK_QUEUE_LAST_POLLED_TIMEOUT_SECONDS.value()
74
- if work_queue_last_polled_timeout_seconds is None
75
- else work_queue_last_polled_timeout_seconds
98
+
99
+ result = await session.execute(
100
+ worker_update_stmt,
101
+ {
102
+ "multiplier": inactivity_heartbeat_multiple,
103
+ "default_interval": fallback_heartbeat_interval_seconds,
104
+ },
76
105
  )
77
106
 
78
- @db_injector
79
- async def run_once(self, db: PrefectDBInterface) -> None:
80
- """
81
- Iterate over workers current marked as online. Mark workers as offline
82
- if they have an old last_heartbeat_time. Marks work pools as not ready
83
- if they do not have any online workers and are currently marked as ready.
84
- Mark deployments as not ready if they have a last_polled time that is
85
- older than the configured deployment last polled timeout.
86
- """
87
- await self._mark_online_workers_without_a_recent_heartbeat_as_offline()
88
- await self._mark_work_pools_as_not_ready()
89
- await self._mark_deployments_as_not_ready()
90
- await self._mark_work_queues_as_not_ready()
91
-
92
- @db_injector
93
- async def _mark_online_workers_without_a_recent_heartbeat_as_offline(
94
- self, db: PrefectDBInterface
95
- ) -> None:
96
- """
97
- Updates the status of workers that have an old last heartbeat time
98
- to OFFLINE.
99
-
100
- An old heartbeat last heartbeat that is one more than
101
- their heartbeat interval multiplied by the
102
- INACTIVITY_HEARTBEAT_MULTIPLE seconds ago.
103
-
104
- Args:
105
- session (AsyncSession): The session to use for the database operation.
106
- """
107
- async with db.session_context(begin_transaction=True) as session:
108
- worker_update_stmt = (
109
- sa.update(db.Worker)
110
- .values(status=WorkerStatus.OFFLINE)
111
- .where(
112
- sa.func.date_diff_seconds(db.Worker.last_heartbeat_time)
113
- > (
114
- sa.func.coalesce(
115
- db.Worker.heartbeat_interval_seconds,
116
- sa.bindparam("default_interval", sa.Integer),
117
- )
118
- * sa.bindparam("multiplier", sa.Integer)
119
- ),
120
- db.Worker.status == WorkerStatus.ONLINE,
121
- )
122
- )
107
+ if result.rowcount:
108
+ logger.info(f"Marked {result.rowcount} workers as offline.")
109
+
123
110
 
124
- result = await session.execute(
125
- worker_update_stmt,
126
- {
127
- "multiplier": self._inactivity_heartbeat_multiple,
128
- "default_interval": self._fallback_heartbeat_interval_seconds,
129
- },
111
+ async def _mark_work_pools_as_not_ready(db: PrefectDBInterface) -> None:
112
+ """
113
+ Marks work pools as not ready if they have no online workers.
114
+
115
+ Emits an event and updates any bookkeeping fields on the work pool.
116
+ """
117
+ async with db.session_context(begin_transaction=True) as session:
118
+ work_pools_select_stmt = (
119
+ sa.select(db.WorkPool)
120
+ .filter(db.WorkPool.status == "READY")
121
+ .outerjoin(
122
+ db.Worker,
123
+ sa.and_(
124
+ db.Worker.work_pool_id == db.WorkPool.id,
125
+ db.Worker.status == "ONLINE",
126
+ ),
130
127
  )
128
+ .group_by(db.WorkPool.id)
129
+ .having(sa.func.count(db.Worker.id) == 0)
130
+ )
131
131
 
132
- if result.rowcount:
133
- self.logger.info(f"Marked {result.rowcount} workers as offline.")
134
-
135
- @db_injector
136
- async def _mark_work_pools_as_not_ready(self, db: PrefectDBInterface):
137
- """
138
- Marks a work pool as not ready.
139
-
140
- Emits and event and updates any bookkeeping fields on the work pool.
141
-
142
- Args:
143
- work_pool (db.WorkPool): The work pool to mark as not ready.
144
- """
145
- async with db.session_context(begin_transaction=True) as session:
146
- work_pools_select_stmt = (
147
- sa.select(db.WorkPool)
148
- .filter(db.WorkPool.status == "READY")
149
- .outerjoin(
150
- db.Worker,
151
- sa.and_(
152
- db.Worker.work_pool_id == db.WorkPool.id,
153
- db.Worker.status == "ONLINE",
154
- ),
155
- )
156
- .group_by(db.WorkPool.id)
157
- .having(sa.func.count(db.Worker.id) == 0)
132
+ result = await session.execute(work_pools_select_stmt)
133
+ work_pools = result.scalars().all()
134
+
135
+ for work_pool in work_pools:
136
+ await models.workers.update_work_pool(
137
+ session=session,
138
+ work_pool_id=work_pool.id,
139
+ work_pool=InternalWorkPoolUpdate(status=WorkPoolStatus.NOT_READY),
140
+ emit_status_change=emit_work_pool_status_event,
158
141
  )
159
142
 
160
- result = await session.execute(work_pools_select_stmt)
161
- work_pools = result.scalars().all()
143
+ logger.info(f"Marked work pool {work_pool.id} as NOT_READY.")
162
144
 
163
- for work_pool in work_pools:
164
- await models.workers.update_work_pool(
165
- session=session,
166
- work_pool_id=work_pool.id,
167
- work_pool=InternalWorkPoolUpdate(status=WorkPoolStatus.NOT_READY),
168
- emit_status_change=emit_work_pool_status_event,
169
- )
170
145
 
171
- self.logger.info(f"Marked work pool {work_pool.id} as NOT_READY.")
172
-
173
- @db_injector
174
- async def _mark_deployments_as_not_ready(self, db: PrefectDBInterface) -> None:
175
- """
176
- Marks a deployment as NOT_READY and emits a deployment status event.
177
- Emits an event and updates any bookkeeping fields on the deployment.
178
- Args:
179
- session (AsyncSession): The session to use for the database operation.
180
- """
181
- async with db.session_context(begin_transaction=True) as session:
182
- status_timeout_threshold = now("UTC") - timedelta(
183
- seconds=self._deployment_last_polled_timeout_seconds
184
- )
185
- deployment_id_select_stmt = (
186
- sa.select(db.Deployment.id)
187
- .outerjoin(db.WorkQueue, db.WorkQueue.id == db.Deployment.work_queue_id)
188
- .filter(db.Deployment.status == DeploymentStatus.READY)
189
- .filter(db.Deployment.last_polled.isnot(None))
190
- .filter(
191
- sa.or_(
192
- # if work_queue.last_polled doesn't exist, use only deployment's
193
- # last_polled
194
- sa.and_(
195
- db.WorkQueue.last_polled.is_(None),
196
- db.Deployment.last_polled < status_timeout_threshold,
197
- ),
198
- # if work_queue.last_polled exists, both times should be less than
199
- # the threshold
200
- sa.and_(
201
- db.WorkQueue.last_polled.isnot(None),
202
- db.Deployment.last_polled < status_timeout_threshold,
203
- db.WorkQueue.last_polled < status_timeout_threshold,
204
- ),
205
- )
146
+ async def _mark_deployments_as_not_ready(
147
+ db: PrefectDBInterface,
148
+ deployment_last_polled_timeout_seconds: int,
149
+ ) -> None:
150
+ """
151
+ Marks deployments as NOT_READY based on their last_polled field.
152
+
153
+ Emits an event and updates any bookkeeping fields on the deployment.
154
+ """
155
+ async with db.session_context(begin_transaction=True) as session:
156
+ status_timeout_threshold = now("UTC") - timedelta(
157
+ seconds=deployment_last_polled_timeout_seconds
158
+ )
159
+ deployment_id_select_stmt = (
160
+ sa.select(db.Deployment.id)
161
+ .outerjoin(db.WorkQueue, db.WorkQueue.id == db.Deployment.work_queue_id)
162
+ .filter(db.Deployment.status == DeploymentStatus.READY)
163
+ .filter(db.Deployment.last_polled.isnot(None))
164
+ .filter(
165
+ sa.or_(
166
+ # if work_queue.last_polled doesn't exist, use only deployment's
167
+ # last_polled
168
+ sa.and_(
169
+ db.WorkQueue.last_polled.is_(None),
170
+ db.Deployment.last_polled < status_timeout_threshold,
171
+ ),
172
+ # if work_queue.last_polled exists, both times should be less than
173
+ # the threshold
174
+ sa.and_(
175
+ db.WorkQueue.last_polled.isnot(None),
176
+ db.Deployment.last_polled < status_timeout_threshold,
177
+ db.WorkQueue.last_polled < status_timeout_threshold,
178
+ ),
206
179
  )
207
180
  )
208
- result = await session.execute(deployment_id_select_stmt)
181
+ )
182
+ result = await session.execute(deployment_id_select_stmt)
209
183
 
210
- deployment_ids_to_mark_unready = result.scalars().all()
184
+ deployment_ids_to_mark_unready = result.scalars().all()
211
185
 
212
- await mark_deployments_not_ready(
213
- deployment_ids=deployment_ids_to_mark_unready,
214
- )
186
+ await mark_deployments_not_ready(
187
+ deployment_ids=deployment_ids_to_mark_unready,
188
+ )
215
189
 
216
- @db_injector
217
- async def _mark_work_queues_as_not_ready(self, db: PrefectDBInterface):
218
- """
219
- Marks work queues as NOT_READY based on their last_polled field.
220
-
221
- Args:
222
- session (AsyncSession): The session to use for the database operation.
223
- """
224
- async with db.session_context(begin_transaction=True) as session:
225
- status_timeout_threshold = now("UTC") - timedelta(
226
- seconds=self._work_queue_last_polled_timeout_seconds
227
- )
228
- id_select_stmt = (
229
- sa.select(db.WorkQueue.id)
230
- .outerjoin(db.WorkPool, db.WorkPool.id == db.WorkQueue.work_pool_id)
231
- .filter(db.WorkQueue.status == "READY")
232
- .filter(db.WorkQueue.last_polled.isnot(None))
233
- .filter(db.WorkQueue.last_polled < status_timeout_threshold)
234
- .order_by(db.WorkQueue.last_polled.asc())
235
- )
236
- result = await session.execute(id_select_stmt)
237
- unready_work_queue_ids = result.scalars().all()
238
190
 
239
- await mark_work_queues_not_ready(
240
- work_queue_ids=unready_work_queue_ids,
191
+ async def _mark_work_queues_as_not_ready(
192
+ db: PrefectDBInterface,
193
+ work_queue_last_polled_timeout_seconds: int,
194
+ ) -> None:
195
+ """
196
+ Marks work queues as NOT_READY based on their last_polled field.
197
+ """
198
+ async with db.session_context(begin_transaction=True) as session:
199
+ status_timeout_threshold = now("UTC") - timedelta(
200
+ seconds=work_queue_last_polled_timeout_seconds
241
201
  )
202
+ id_select_stmt = (
203
+ sa.select(db.WorkQueue.id)
204
+ .outerjoin(db.WorkPool, db.WorkPool.id == db.WorkQueue.work_pool_id)
205
+ .filter(db.WorkQueue.status == "READY")
206
+ .filter(db.WorkQueue.last_polled.isnot(None))
207
+ .filter(db.WorkQueue.last_polled < status_timeout_threshold)
208
+ .order_by(db.WorkQueue.last_polled.asc())
209
+ )
210
+ result = await session.execute(id_select_stmt)
211
+ unready_work_queue_ids = result.scalars().all()
212
+
213
+ await mark_work_queues_not_ready(
214
+ work_queue_ids=unready_work_queue_ids,
215
+ )