prefect-client 3.7.2.dev2__py3-none-any.whl → 3.7.2.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.7.2.dev2"
3
- __build_date__ = "2026-05-19 09:17:54.056516+00:00"
4
- __git_commit__ = "68244724cf7a168f14f81bdf032689c912a404de"
2
+ __version__ = "3.7.2.dev3"
3
+ __build_date__ = "2026-05-20 09:16:00.764815+00:00"
4
+ __git_commit__ = "30ca141845030af77328e2ebe495e200db2fc679"
5
5
  __dirty__ = False
prefect/automations.py CHANGED
@@ -11,6 +11,7 @@ from prefect.events.actions import (
11
11
  CancelFlowRun,
12
12
  ChangeFlowRunState,
13
13
  DeclareIncident,
14
+ DeleteFlowRun,
14
15
  DoNothing,
15
16
  PauseAutomation,
16
17
  PauseDeployment,
@@ -61,6 +62,7 @@ __all__ = [
61
62
  "PauseDeployment",
62
63
  "ResumeDeployment",
63
64
  "CancelFlowRun",
65
+ "DeleteFlowRun",
64
66
  "ChangeFlowRunState",
65
67
  "PauseWorkQueue",
66
68
  "ResumeWorkQueue",
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  import hashlib
12
12
  import logging
13
13
  import shutil
14
+ import struct
14
15
  import tempfile
15
16
  import zipfile
16
17
  from dataclasses import dataclass
@@ -32,7 +33,8 @@ class ZipResult:
32
33
 
33
34
  Attributes:
34
35
  zip_path: Path to the temporary zip file.
35
- sha256_hash: Lowercase hex digest of the zip content.
36
+ sha256_hash: SHA256 hex digest of the canonical bundled file contents
37
+ (paths, modes, and bytes), not the raw zip archive bytes.
36
38
  storage_key: Content-addressed storage key in format "files/{hash}.zip".
37
39
  size_bytes: Size of the zip file in bytes.
38
40
  """
@@ -95,16 +97,40 @@ class ZipBuilder:
95
97
  self._temp_dir = tempfile.mkdtemp(prefix="prefect-zip-")
96
98
  zip_path = Path(self._temp_dir) / "files.zip"
97
99
 
98
- # Build the zip with DEFLATED compression
100
+ # Build the zip and compute the content hash in a single pass so
101
+ # that every file is read exactly once, avoiding race conditions
102
+ # where a source file could change between the zip write and the
103
+ # hash computation.
104
+ hasher = hashlib.sha256()
105
+ hasher.update(b"prefect-bundle-files-v1\0")
106
+
99
107
  with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
100
108
  for file_path in sorted_files:
101
- # Compute relative path with forward slashes
102
109
  rel_path = file_path.relative_to(self.base_dir)
103
110
  arcname = str(rel_path).replace("\\", "/")
104
- zf.write(file_path, arcname)
105
111
 
106
- # Compute SHA256 hash using chunked reading
107
- sha256_hash = self._compute_hash(zip_path)
112
+ file_stat = file_path.stat()
113
+
114
+ arcname_bytes = arcname.encode("utf-8")
115
+ hasher.update(struct.pack(">I", len(arcname_bytes)))
116
+ hasher.update(arcname_bytes)
117
+ hasher.update(struct.pack(">I", file_stat.st_mode & 0xFFFF))
118
+ hasher.update(struct.pack(">Q", file_stat.st_size))
119
+
120
+ zip_info = zipfile.ZipInfo.from_file(file_path, arcname)
121
+ zip_info.compress_type = zipfile.ZIP_DEFLATED
122
+ with (
123
+ file_path.open("rb") as src,
124
+ zf.open(zip_info, "w") as dest,
125
+ ):
126
+ while True:
127
+ chunk = src.read(HASH_CHUNK_SIZE)
128
+ if not chunk:
129
+ break
130
+ dest.write(chunk)
131
+ hasher.update(chunk)
132
+
133
+ sha256_hash = hasher.hexdigest()
108
134
 
109
135
  # Get file size
110
136
  size_bytes = zip_path.stat().st_size
@@ -123,25 +149,6 @@ class ZipBuilder:
123
149
  size_bytes=size_bytes,
124
150
  )
125
151
 
126
- def _compute_hash(self, zip_path: Path) -> str:
127
- """
128
- Compute SHA256 hash of a file using chunked reading.
129
-
130
- Args:
131
- zip_path: Path to the file to hash.
132
-
133
- Returns:
134
- Lowercase hex digest of the SHA256 hash.
135
- """
136
- hasher = hashlib.sha256()
137
- with open(zip_path, "rb") as f:
138
- while True:
139
- chunk = f.read(HASH_CHUNK_SIZE)
140
- if not chunk:
141
- break
142
- hasher.update(chunk)
143
- return hasher.hexdigest()
144
-
145
152
  def _emit_size_warning(
146
153
  self, zip_path: Path, files: list[Path], size_bytes: int
147
154
  ) -> None:
@@ -31,6 +31,7 @@ from .actions import (
31
31
  ResumeDeployment,
32
32
  ChangeFlowRunState,
33
33
  CancelFlowRun,
34
+ DeleteFlowRun,
34
35
  SuspendFlowRun,
35
36
  CallWebhook,
36
37
  SendNotification,
@@ -78,6 +79,7 @@ __all__ = [
78
79
  "ResumeDeployment",
79
80
  "ChangeFlowRunState",
80
81
  "CancelFlowRun",
82
+ "DeleteFlowRun",
81
83
  "SuspendFlowRun",
82
84
  "CallWebhook",
83
85
  "SendNotification",
prefect/events/actions.py CHANGED
@@ -126,6 +126,12 @@ class CancelFlowRun(Action):
126
126
  type: Literal["cancel-flow-run"] = "cancel-flow-run"
127
127
 
128
128
 
129
+ class DeleteFlowRun(Action):
130
+ """Deletes a flow run associated with the trigger"""
131
+
132
+ type: Literal["delete-flow-run"] = "delete-flow-run"
133
+
134
+
129
135
  class ResumeFlowRun(Action):
130
136
  """Resumes a flow run associated with the trigger"""
131
137
 
@@ -294,6 +300,7 @@ ActionTypes: TypeAlias = Union[
294
300
  ResumeDeployment,
295
301
  ResumeFlowRun,
296
302
  CancelFlowRun,
303
+ DeleteFlowRun,
297
304
  ChangeFlowRunState,
298
305
  PauseWorkQueue,
299
306
  ResumeWorkQueue,
@@ -93,6 +93,9 @@ class OrchestrationClient(BaseClient):
93
93
  async def read_flow_run_raw(self, flow_run_id: UUID) -> Response:
94
94
  return await self._http_client.get(f"/flow_runs/{flow_run_id}")
95
95
 
96
+ async def delete_flow_run(self, flow_run_id: UUID) -> Response:
97
+ return await self._http_client.delete(f"/flow_runs/{flow_run_id}")
98
+
96
99
  async def read_task_run_raw(self, task_run_id: UUID) -> Response:
97
100
  return await self._http_client.get(f"/task_runs/{task_run_id}")
98
101
 
@@ -2,7 +2,9 @@
2
2
  Routes for interacting with work queue objects.
3
3
  """
4
4
 
5
- from typing import TYPE_CHECKING, List, Optional
5
+ from dataclasses import dataclass
6
+ from logging import Logger
7
+ from typing import TYPE_CHECKING, Any, List, Optional
6
8
  from uuid import UUID
7
9
 
8
10
  import sqlalchemy as sa
@@ -11,15 +13,32 @@ from fastapi import (
11
13
  Depends,
12
14
  HTTPException,
13
15
  Path,
16
+ WebSocket,
14
17
  status,
15
18
  )
16
19
  from packaging.version import Version
20
+ from pydantic import ValidationError
17
21
  from sqlalchemy.ext.asyncio import AsyncSession
18
22
 
19
23
  import prefect.server.api.dependencies as dependencies
20
24
  import prefect.server.models as models
21
25
  import prefect.server.schemas as schemas
22
26
  from prefect._internal.uuid7 import uuid7
27
+ from prefect.client.schemas.worker_channel import (
28
+ WORK_POOL_SNAPSHOT_CAPABILITY,
29
+ WORKER_CHANNEL_CLOSE_POLICIES,
30
+ WORKER_HEARTBEAT_CAPABILITY,
31
+ WorkerChannelCloseReason,
32
+ WorkerChannelProtocolError,
33
+ WorkerHeartbeatFrame,
34
+ WorkerHelloFrame,
35
+ WorkerReadyFrame,
36
+ WorkPoolSnapshot,
37
+ WorkPoolSnapshotPayload,
38
+ select_worker_channel_version,
39
+ validate_worker_channel_frame,
40
+ )
41
+ from prefect.logging import get_logger
23
42
  from prefect.server.api.validation import validate_job_variable_defaults_for_work_pool
24
43
  from prefect.server.database import PrefectDBInterface, provide_database_interface
25
44
  from prefect.server.models.deployments import mark_deployments_ready
@@ -29,17 +48,25 @@ from prefect.server.models.work_queues import (
29
48
  )
30
49
  from prefect.server.models.workers import emit_work_pool_status_event
31
50
  from prefect.server.schemas.statuses import WorkQueueStatus
51
+ from prefect.server.utilities import subscriptions
32
52
  from prefect.server.utilities.server import PrefectRouter
33
53
  from prefect.types import DateTime
34
54
  from prefect.types._datetime import now
35
55
 
36
56
  if TYPE_CHECKING:
37
- from prefect.server.database.orm_models import ORMWorkQueue
57
+ from prefect.server.database.orm_models import WorkPool as ORMWorkPool
58
+ from prefect.server.database.orm_models import WorkQueue as ORMWorkQueue
38
59
 
39
60
  router: PrefectRouter = PrefectRouter(
40
61
  prefix="/work_pools",
41
62
  tags=["Work Pools"],
42
63
  )
64
+ logger: Logger = get_logger("prefect.server.api.workers")
65
+
66
+ _OSS_WORKER_CHANNEL_ACCEPTED_CAPABILITIES = [
67
+ WORKER_HEARTBEAT_CAPABILITY,
68
+ WORK_POOL_SNAPSHOT_CAPABILITY,
69
+ ]
43
70
 
44
71
 
45
72
  # -----------------------------------------------------
@@ -145,6 +172,288 @@ class WorkerLookups:
145
172
  return queue.id
146
173
 
147
174
 
175
+ class WorkerChannelSetupError(Exception):
176
+ def __init__(self, close_reason: WorkerChannelCloseReason, detail: str):
177
+ super().__init__(detail)
178
+ self.close_reason = close_reason
179
+ self.detail = detail
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class WorkerChannelWorkPoolUpdateEvent:
184
+ work_pool_id: UUID
185
+ changed_fields: dict[str, dict[str, Any]]
186
+
187
+
188
+ async def _close_worker_channel(
189
+ websocket: WebSocket, close_reason: WorkerChannelCloseReason
190
+ ) -> None:
191
+ policy = WORKER_CHANNEL_CLOSE_POLICIES[close_reason]
192
+ await websocket.close(code=policy.websocket_code, reason=close_reason.value)
193
+
194
+
195
+ async def _receive_worker_hello(websocket: WebSocket) -> WorkerHelloFrame:
196
+ try:
197
+ message = await websocket.receive_json()
198
+ frame = validate_worker_channel_frame(message)
199
+ except ValidationError as exc:
200
+ raise WorkerChannelSetupError(
201
+ WorkerChannelCloseReason.PROTOCOL_ERROR,
202
+ "Worker channel received a malformed hello frame",
203
+ ) from exc
204
+ except ValueError as exc:
205
+ raise WorkerChannelSetupError(
206
+ WorkerChannelCloseReason.PROTOCOL_ERROR,
207
+ "Worker channel received invalid JSON during setup",
208
+ ) from exc
209
+
210
+ if not isinstance(frame, WorkerHelloFrame):
211
+ raise WorkerChannelSetupError(
212
+ WorkerChannelCloseReason.PROTOCOL_ERROR,
213
+ "Expected worker.hello.v1 during worker channel setup",
214
+ )
215
+
216
+ return frame
217
+
218
+
219
+ async def _resolve_worker_channel_work_pool(
220
+ session: AsyncSession,
221
+ work_pool_name: str,
222
+ hello: WorkerHelloFrame,
223
+ ) -> "ORMWorkPool":
224
+ work_pool = await models.workers.read_work_pool_by_name(
225
+ session=session,
226
+ work_pool_name=work_pool_name,
227
+ )
228
+
229
+ default_base_job_template = hello.payload.default_base_job_template
230
+ if work_pool is None:
231
+ if not hello.payload.create_pool_if_not_found:
232
+ raise WorkerChannelSetupError(
233
+ WorkerChannelCloseReason.AUTHORIZATION_FAILED,
234
+ "work_pool_not_found",
235
+ )
236
+
237
+ if work_pool_name.lower().startswith("prefect"):
238
+ raise WorkerChannelSetupError(
239
+ WorkerChannelCloseReason.AUTHORIZATION_FAILED,
240
+ "work_pool_creation_unauthorized",
241
+ )
242
+
243
+ await validate_job_variable_defaults_for_work_pool(
244
+ session, work_pool_name, default_base_job_template
245
+ )
246
+ try:
247
+ async with session.begin_nested():
248
+ work_pool = await models.workers.create_work_pool(
249
+ session=session,
250
+ work_pool=schemas.actions.WorkPoolCreate(
251
+ name=work_pool_name,
252
+ type=hello.payload.worker_type,
253
+ base_job_template=default_base_job_template,
254
+ ),
255
+ )
256
+ except sa.exc.IntegrityError:
257
+ work_pool = await models.workers.read_work_pool_by_name(
258
+ session=session,
259
+ work_pool_name=work_pool_name,
260
+ )
261
+ if work_pool is None:
262
+ raise
263
+ return work_pool
264
+
265
+ return work_pool
266
+
267
+
268
+ async def _resolve_worker_channel_work_queues(
269
+ session: AsyncSession,
270
+ work_pool_id: UUID,
271
+ work_pool_name: str,
272
+ work_queue_names: list[str],
273
+ ) -> list["ORMWorkQueue"]:
274
+ if not work_queue_names:
275
+ return list(
276
+ await models.workers.read_work_queues(
277
+ session=session, work_pool_id=work_pool_id
278
+ )
279
+ )
280
+
281
+ work_queues = []
282
+ for work_queue_name in dict.fromkeys(work_queue_names):
283
+ work_queue = await models.workers.read_work_queue_by_name(
284
+ session=session,
285
+ work_pool_name=work_pool_name,
286
+ work_queue_name=work_queue_name,
287
+ )
288
+ if work_queue is None:
289
+ raise WorkerChannelSetupError(
290
+ WorkerChannelCloseReason.AUTHORIZATION_FAILED,
291
+ "work_queue_not_found",
292
+ )
293
+ work_queues.append(work_queue)
294
+
295
+ return work_queues
296
+
297
+
298
+ async def _build_worker_channel_work_pool_snapshot(
299
+ session: AsyncSession,
300
+ work_pool: "ORMWorkPool",
301
+ ) -> WorkPoolSnapshot:
302
+ work_pool_response = schemas.responses.WorkPoolResponse.model_validate(
303
+ work_pool, from_attributes=True
304
+ )
305
+
306
+ if work_pool_response.concurrency_limit is not None:
307
+ work_pool_response.active_slots = (
308
+ await models.workers.count_work_pool_active_slots(
309
+ session=session,
310
+ work_pool_id=work_pool.id,
311
+ )
312
+ )
313
+
314
+ return WorkPoolSnapshot.model_validate(work_pool_response.model_dump(mode="json"))
315
+
316
+
317
+ async def _build_worker_ready_frame(
318
+ session: AsyncSession,
319
+ work_pool_name: str,
320
+ hello: WorkerHelloFrame,
321
+ ) -> tuple[WorkerReadyFrame, WorkerChannelWorkPoolUpdateEvent | None]:
322
+ try:
323
+ selected_channel_version = select_worker_channel_version(
324
+ hello.payload.supported_channel_versions
325
+ )
326
+ except WorkerChannelProtocolError as exc:
327
+ raise WorkerChannelSetupError(exc.close_reason, str(exc)) from exc
328
+
329
+ work_pool = await _resolve_worker_channel_work_pool(
330
+ session=session,
331
+ work_pool_name=work_pool_name,
332
+ hello=hello,
333
+ )
334
+ work_queues = await _resolve_worker_channel_work_queues(
335
+ session=session,
336
+ work_pool_id=work_pool.id,
337
+ work_pool_name=work_pool_name,
338
+ work_queue_names=hello.payload.work_queue_names,
339
+ )
340
+ default_base_job_template = hello.payload.default_base_job_template
341
+ work_pool_update_event = None
342
+ if not work_pool.base_job_template and default_base_job_template:
343
+ previous_base_job_template = work_pool.base_job_template
344
+ await validate_job_variable_defaults_for_work_pool(
345
+ session, work_pool_name, default_base_job_template
346
+ )
347
+ updated = await models.workers.update_work_pool(
348
+ session=session,
349
+ work_pool_id=work_pool.id,
350
+ work_pool=schemas.actions.WorkPoolUpdate(
351
+ base_job_template=default_base_job_template
352
+ ),
353
+ emit_update_event=False,
354
+ emit_status_change=emit_work_pool_status_event,
355
+ )
356
+ if updated:
357
+ work_pool_update_event = WorkerChannelWorkPoolUpdateEvent(
358
+ work_pool_id=work_pool.id,
359
+ changed_fields={
360
+ "base_job_template": {
361
+ "from": previous_base_job_template,
362
+ "to": default_base_job_template,
363
+ }
364
+ },
365
+ )
366
+ refreshed = await models.workers.read_work_pool(
367
+ session=session, work_pool_id=work_pool.id
368
+ )
369
+ assert refreshed is not None
370
+ work_pool = refreshed
371
+
372
+ try:
373
+ worker = await models.workers.record_worker_heartbeat(
374
+ session=session,
375
+ work_pool=work_pool,
376
+ worker_name=hello.payload.worker_name,
377
+ heartbeat_interval_seconds=hello.payload.heartbeat_interval_seconds,
378
+ emit_status_change=emit_work_pool_status_event,
379
+ return_worker=True,
380
+ )
381
+ except Exception as exc:
382
+ raise WorkerChannelSetupError(
383
+ WorkerChannelCloseReason.HEARTBEAT_PERSISTENCE_FAILED,
384
+ "worker_channel_initial_heartbeat_failed",
385
+ ) from exc
386
+ assert worker is not None
387
+
388
+ refreshed_work_pool = await models.workers.read_work_pool(
389
+ session=session, work_pool_id=work_pool.id
390
+ )
391
+ assert refreshed_work_pool is not None
392
+ initial_snapshot = WorkPoolSnapshotPayload(
393
+ snapshot_sequence=1,
394
+ reason="initial",
395
+ work_pool=await _build_worker_channel_work_pool_snapshot(
396
+ session=session,
397
+ work_pool=refreshed_work_pool,
398
+ ),
399
+ )
400
+
401
+ requested_capabilities = list(dict.fromkeys(hello.payload.requested_capabilities))
402
+ accepted = _OSS_WORKER_CHANNEL_ACCEPTED_CAPABILITIES
403
+ accepted_set = set(accepted)
404
+ rejected = [
405
+ capability
406
+ for capability in requested_capabilities
407
+ if capability not in accepted_set
408
+ ]
409
+
410
+ return (
411
+ WorkerReadyFrame(
412
+ type="worker.ready.v1",
413
+ id=uuid7(),
414
+ sent_at=now("UTC"),
415
+ payload={
416
+ "consumer_id": hello.payload.consumer_id,
417
+ "worker_id": worker.id,
418
+ "selected_channel_version": selected_channel_version,
419
+ "effective_heartbeat_interval_seconds": (
420
+ hello.payload.heartbeat_interval_seconds
421
+ ),
422
+ "accepted_capabilities": accepted,
423
+ "rejected_capabilities": rejected,
424
+ "effective_max_cleanup_concurrency": 0,
425
+ "resolved_work_queues": [
426
+ {"id": work_queue.id, "name": work_queue.name}
427
+ for work_queue in work_queues
428
+ ],
429
+ "initial_snapshot": initial_snapshot,
430
+ },
431
+ ),
432
+ work_pool_update_event,
433
+ )
434
+
435
+
436
+ async def _persist_worker_channel_heartbeat(
437
+ session: AsyncSession,
438
+ work_pool_name: str,
439
+ frame: WorkerHeartbeatFrame,
440
+ ) -> None:
441
+ work_pool = await models.workers.read_work_pool_by_name(
442
+ session=session,
443
+ work_pool_name=work_pool_name,
444
+ )
445
+ if work_pool is None:
446
+ raise RuntimeError("Worker channel work pool no longer exists")
447
+
448
+ await models.workers.record_worker_heartbeat(
449
+ session=session,
450
+ work_pool=work_pool,
451
+ worker_name=frame.payload.worker_name,
452
+ heartbeat_interval_seconds=frame.payload.heartbeat_interval_seconds,
453
+ emit_status_change=emit_work_pool_status_event,
454
+ )
455
+
456
+
148
457
  # -----------------------------------------------------
149
458
  # --
150
459
  # --
@@ -756,6 +1065,104 @@ async def delete_work_queue(
756
1065
  # -----------------------------------------------------
757
1066
 
758
1067
 
1068
+ @router.websocket("/{work_pool_name}/workers/connect")
1069
+ async def worker_channel_connect(
1070
+ websocket: WebSocket,
1071
+ work_pool_name: str = Path(..., description="The work pool name"),
1072
+ db: PrefectDBInterface = Depends(provide_database_interface),
1073
+ ) -> None:
1074
+ websocket = await subscriptions.accept_prefect_socket(
1075
+ websocket,
1076
+ require_prefect_subprotocol=True,
1077
+ authentication_failed_reason=WorkerChannelCloseReason.AUTHENTICATION_FAILED.value,
1078
+ )
1079
+ if not websocket:
1080
+ return
1081
+
1082
+ try:
1083
+ hello = await _receive_worker_hello(websocket)
1084
+ async with db.session_context(begin_transaction=True) as session:
1085
+ ready, work_pool_update_event = await _build_worker_ready_frame(
1086
+ session=session,
1087
+ work_pool_name=work_pool_name,
1088
+ hello=hello,
1089
+ )
1090
+
1091
+ if work_pool_update_event is not None:
1092
+ async with db.session_context() as session:
1093
+ work_pool = await models.workers.read_work_pool(
1094
+ session=session,
1095
+ work_pool_id=work_pool_update_event.work_pool_id,
1096
+ )
1097
+ assert work_pool is not None
1098
+ await models.workers.emit_work_pool_updated_event(
1099
+ session=session,
1100
+ work_pool=work_pool,
1101
+ changed_fields=work_pool_update_event.changed_fields,
1102
+ )
1103
+
1104
+ await websocket.send_json(ready.model_dump(mode="json"))
1105
+
1106
+ while True:
1107
+ try:
1108
+ message = await websocket.receive_json()
1109
+ frame = validate_worker_channel_frame(message)
1110
+ except ValidationError:
1111
+ await _close_worker_channel(
1112
+ websocket, WorkerChannelCloseReason.PROTOCOL_ERROR
1113
+ )
1114
+ return
1115
+ except ValueError:
1116
+ await _close_worker_channel(
1117
+ websocket, WorkerChannelCloseReason.PROTOCOL_ERROR
1118
+ )
1119
+ return
1120
+
1121
+ if not isinstance(frame, WorkerHeartbeatFrame):
1122
+ await _close_worker_channel(
1123
+ websocket, WorkerChannelCloseReason.PROTOCOL_ERROR
1124
+ )
1125
+ return
1126
+
1127
+ if (
1128
+ frame.payload.consumer_id != hello.payload.consumer_id
1129
+ or frame.payload.worker_name != hello.payload.worker_name
1130
+ ):
1131
+ await _close_worker_channel(
1132
+ websocket, WorkerChannelCloseReason.PROTOCOL_ERROR
1133
+ )
1134
+ return
1135
+
1136
+ try:
1137
+ async with db.session_context(begin_transaction=True) as session:
1138
+ await _persist_worker_channel_heartbeat(
1139
+ session=session,
1140
+ work_pool_name=work_pool_name,
1141
+ frame=frame,
1142
+ )
1143
+ except Exception:
1144
+ logger.exception("Worker channel heartbeat persistence failed")
1145
+ await _close_worker_channel(
1146
+ websocket,
1147
+ WorkerChannelCloseReason.HEARTBEAT_PERSISTENCE_FAILED,
1148
+ )
1149
+ return
1150
+
1151
+ except WorkerChannelSetupError as exc:
1152
+ logger.info("Worker channel setup failed: %s", exc.detail)
1153
+ await _close_worker_channel(websocket, exc.close_reason)
1154
+ except HTTPException as exc:
1155
+ logger.info("Worker channel setup failed HTTP validation: %s", exc.detail)
1156
+ await _close_worker_channel(websocket, WorkerChannelCloseReason.PROTOCOL_ERROR)
1157
+ except subscriptions.NORMAL_DISCONNECT_EXCEPTIONS:
1158
+ return
1159
+ except Exception:
1160
+ logger.exception("Worker channel setup failed due to a transient server error")
1161
+ await _close_worker_channel(
1162
+ websocket, WorkerChannelCloseReason.TRANSIENT_SERVER_ERROR
1163
+ )
1164
+
1165
+
759
1166
  @router.post(
760
1167
  "/{work_pool_name}/workers/heartbeat",
761
1168
  status_code=status.HTTP_204_NO_CONTENT,
@@ -780,23 +1187,14 @@ async def worker_heartbeat(
780
1187
  detail=f'Work pool "{work_pool_name}" not found.',
781
1188
  )
782
1189
 
783
- await models.workers.worker_heartbeat(
1190
+ await models.workers.record_worker_heartbeat(
784
1191
  session=session,
785
- work_pool_id=work_pool.id,
1192
+ work_pool=work_pool,
786
1193
  worker_name=name,
787
1194
  heartbeat_interval_seconds=heartbeat_interval_seconds,
1195
+ emit_status_change=emit_work_pool_status_event,
788
1196
  )
789
1197
 
790
- if work_pool.status == schemas.statuses.WorkPoolStatus.NOT_READY:
791
- await models.workers.update_work_pool(
792
- session=session,
793
- work_pool_id=work_pool.id,
794
- work_pool=schemas.internal.InternalWorkPoolUpdate(
795
- status=schemas.statuses.WorkPoolStatus.READY
796
- ),
797
- emit_status_change=emit_work_pool_status_event,
798
- )
799
-
800
1198
 
801
1199
  @router.post("/{work_pool_name}/workers/filter")
802
1200
  async def read_workers(
@@ -14,6 +14,7 @@ from typing import (
14
14
  overload,
15
15
  )
16
16
 
17
+ import prefect.exceptions
17
18
  from prefect.client.utilities import inject_client
18
19
  from prefect.logging.loggers import get_logger
19
20
  from prefect.utilities.annotations import NotSet
@@ -348,9 +349,19 @@ async def resolve_block_document_references(
348
349
  )
349
350
  block_type_slug, block_document_name, *value_keypath = parts
350
351
 
351
- block_document = await client.read_block_document_by_name(
352
- name=block_document_name, block_type_slug=block_type_slug
353
- )
352
+ try:
353
+ block_document = await client.read_block_document_by_name(
354
+ name=block_document_name, block_type_slug=block_type_slug
355
+ )
356
+ except prefect.exceptions.ObjectNotFound as exc:
357
+ raise prefect.exceptions.ObjectNotFound(
358
+ http_exc=exc.http_exc,
359
+ help_message=(
360
+ f"Block not found: '{block_document_name}' of type '{block_type_slug}'. "
361
+ f"This block was referenced in your deployment or work pool configuration but no longer exists. "
362
+ f"It may have been deleted. Please check your configuration or create a new block."
363
+ ),
364
+ ) from exc
354
365
 
355
366
  data = block_document.data
356
367
  value: Any = data
@@ -377,7 +388,17 @@ async def resolve_block_document_references(
377
388
  if isinstance(template, dict):
378
389
  block_document_id = template.get("$ref", {}).get("block_document_id")
379
390
  if block_document_id:
380
- block_document = await client.read_block_document(block_document_id)
391
+ try:
392
+ block_document = await client.read_block_document(block_document_id)
393
+ except prefect.exceptions.ObjectNotFound as exc:
394
+ raise prefect.exceptions.ObjectNotFound(
395
+ http_exc=exc.http_exc,
396
+ help_message=(
397
+ f"Block not found: ID '{block_document_id}'. "
398
+ f"This block was referenced in your deployment or work pool configuration but no longer exists. "
399
+ f"It may have been deleted. Please check your configuration or create a new block."
400
+ ),
401
+ ) from exc
381
402
  return block_document.data
382
403
  updated_template: dict[str, Any] = {}
383
404
  for key, value in template.items():
@@ -93,6 +93,10 @@ class WorkerChannelProtocolHandler:
93
93
  def work_pool_snapshots_available(self) -> bool:
94
94
  return self._work_pool_snapshots.snapshots_available
95
95
 
96
+ @property
97
+ def work_pool_snapshot_sequence(self) -> int | None:
98
+ return self._work_pool_snapshots.last_applied_sequence
99
+
96
100
  async def handshake(
97
101
  self, websocket: websockets.asyncio.client.ClientConnection
98
102
  ) -> WorkerReadyFrame:
@@ -103,6 +103,7 @@ class WorkPoolWorkerChannel:
103
103
  )
104
104
  self._websocket_started = False
105
105
  self._run_scope: anyio.CancelScope | None = None
106
+ self._stopped = False
106
107
 
107
108
  @property
108
109
  def url(self) -> str | None:
@@ -128,13 +129,11 @@ class WorkPoolWorkerChannel:
128
129
  self._client = client
129
130
 
130
131
  def stop(self) -> None:
132
+ self._stopped = True
131
133
  if self._run_scope is not None:
132
134
  self._run_scope.cancel()
133
135
 
134
136
  async def sync(self, task_group: anyio.abc.TaskGroup | None) -> None:
135
- if self.state.healthy and self.snapshots_available:
136
- return
137
-
138
137
  if task_group is not None:
139
138
  channel_started = await self._start_websocket(task_group)
140
139
  else:
@@ -142,10 +141,9 @@ class WorkPoolWorkerChannel:
142
141
  if self.url is None:
143
142
  self.state.mark_terminal("endpoint_unavailable")
144
143
 
145
- if channel_started and self.snapshots_available:
146
- return
144
+ if not (channel_started and self.snapshots_available):
145
+ await self._sync_rest_work_pool()
147
146
 
148
- await self._sync_rest_work_pool()
149
147
  await self._send_rest_worker_heartbeat()
150
148
 
151
149
  async def _start_websocket(self, task_group: anyio.abc.TaskGroup) -> bool:
@@ -239,6 +237,8 @@ class WorkPoolWorkerChannel:
239
237
  async def _run(self, initial_session: WorkerChannelSession | None = None) -> None:
240
238
  with anyio.CancelScope() as scope:
241
239
  self._run_scope = scope
240
+ if self._stopped:
241
+ scope.cancel()
242
242
  try:
243
243
  async with AsyncExitStack() as stack:
244
244
  if self._cleanup_executor is not None:
@@ -296,6 +296,7 @@ class WorkPoolWorkerChannel:
296
296
  self._run_scope = None
297
297
 
298
298
  async def _sync_rest_work_pool(self) -> None:
299
+ initial_snapshot_sequence = self._protocol.work_pool_snapshot_sequence
299
300
  try:
300
301
  work_pool = await self._client.read_work_pool(
301
302
  work_pool_name=self.work_pool_name
@@ -334,7 +335,10 @@ class WorkPoolWorkerChannel:
334
335
  )
335
336
  return
336
337
 
337
- if self.state.healthy and self.snapshots_available:
338
+ if (
339
+ self.state.healthy
340
+ and self._protocol.work_pool_snapshot_sequence != initial_snapshot_sequence
341
+ ):
338
342
  self._logger.debug(
339
343
  "Skipping REST work pool sync because the worker channel applied a "
340
344
  "snapshot while REST sync was in flight."
@@ -347,7 +351,10 @@ class WorkPoolWorkerChannel:
347
351
  )
348
352
  work_pool.base_job_template = self.default_base_job_template
349
353
 
350
- if self.state.healthy and self.snapshots_available:
354
+ if (
355
+ self.state.healthy
356
+ and self._protocol.work_pool_snapshot_sequence != initial_snapshot_sequence
357
+ ):
351
358
  self._logger.debug(
352
359
  "Skipping REST work pool snapshot because the worker channel applied "
353
360
  "a snapshot while REST sync was in flight."
prefect/workers/base.py CHANGED
@@ -11,6 +11,7 @@ import uuid
11
11
  import warnings
12
12
  from contextlib import AsyncExitStack
13
13
  from functools import partial
14
+ from importlib.metadata import distributions
14
15
  from typing import (
15
16
  TYPE_CHECKING,
16
17
  Any,
@@ -26,9 +27,6 @@ from zoneinfo import ZoneInfo
26
27
  import anyio
27
28
  import anyio.abc
28
29
  from exceptiongroup import BaseExceptionGroup, ExceptionGroup
29
- from importlib_metadata import (
30
- distributions, # type: ignore[reportUnknownVariableType] incomplete typing
31
- )
32
30
  from pydantic import BaseModel, Field, PrivateAttr, field_validator
33
31
  from pydantic.json_schema import GenerateJsonSchema
34
32
  from typing_extensions import Literal, Self, TypeVar
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prefect-client
3
- Version: 3.7.2.dev2
3
+ Version: 3.7.2.dev3
4
4
  Summary: Workflow orchestration and management.
5
5
  Project-URL: Changelog, https://github.com/PrefectHQ/prefect/releases
6
6
  Project-URL: Documentation, https://docs.prefect.io
@@ -2,11 +2,11 @@ prefect/.prefectignore,sha256=awSprvKT0vI8a64mEOLrMxhxqcO-b0ERQeYpA2rNKVQ,390
2
2
  prefect/AGENTS.md,sha256=rOU4L6B0ZdvnVmLr_eL394QlwfJkPh1OZswHppAjUN4,10063
3
3
  prefect/__init__.py,sha256=4e69hAJtYPoEeRxc8iNjzp5WIkmXP0i2SNiN3c97_Go,6703
4
4
  prefect/__main__.py,sha256=WFjw3kaYJY6pOTA7WDOgqjsz8zUEUZHCcj3P5wyVa-g,66
5
- prefect/_build_info.py,sha256=AHkrkaCbyqiZK5RthlP9QpdWA0STowHOiJKlYYABweI,185
5
+ prefect/_build_info.py,sha256=D7OiFa_wqNlew0FeFafW9ZPD-g41AtRn_SNU2gkqMNQ,185
6
6
  prefect/_flow_run_suspension.py,sha256=5zTTB7ZIBHzoS0pVrhNn23-9hK51qZ3CQA6C-azluC0,4144
7
7
  prefect/agent.py,sha256=dPvG1jDGD5HSH7aM2utwtk6RaJ9qg13XjkA0lAIgQmY,287
8
8
  prefect/artifacts.py,sha256=ZdMLJeJGK82hibtRzbsVa-g95dMa0D2UP1LiESoXmf4,23951
9
- prefect/automations.py,sha256=ZzPxn2tINdlXTQo805V4rIlbXuNWxd7cdb3gTJxZIeY,12567
9
+ prefect/automations.py,sha256=OxfOezLuHHOB_S-ohyRBoTg6yL9MtbrlAyQq8zGkF9A,12607
10
10
  prefect/cache_policies.py,sha256=K0cymkMbEQBuwY1fSehefllN4Y4RblpNzYsh-LtjdXM,13156
11
11
  prefect/context.py,sha256=arDksENXHs3O1yOP8Iiq3CaerbsT6ixxM4BIrIuE2tM,38026
12
12
  prefect/engine.py,sha256=F0YQlbewnvQGJZyB-wDePQxm_9b89D0_9S1PhMvuULI,6002
@@ -146,7 +146,7 @@ prefect/bundles/__init__.py,sha256=3LiZeX-QG-5NnKq2FaQZrPOI_2Ta-2bOUir4F9-tEV0,3
146
146
  prefect/bundles/_file_collector.py,sha256=15v9l1vtbSwSfgA9tHF3jJzJxf2vWCCEJRvFIR89OSw,21222
147
147
  prefect/bundles/_ignore_filter.py,sha256=4p7Ku8h6LOVmagC0lLlK3SBo-FLrz2JXoJexFto5nKo,11067
148
148
  prefect/bundles/_path_resolver.py,sha256=-3VCDZxD7aDFJlucOdTK4PneW4XEfxpFo1MSfLJe5Xk,18366
149
- prefect/bundles/_zip_builder.py,sha256=KfPiuuTXUV9d7B0zLp53NMuWiZAUNBsPk34jcSeYfBU,5799
149
+ prefect/bundles/_zip_builder.py,sha256=-agAOPVSaW5mEFyGcmAFgVj_K2sdtaWF-bPt0PGQLCo,6360
150
150
  prefect/bundles/_zip_extractor.py,sha256=aFU2PWH6Ar-6HMOljsSvdSruSS6QUlHt5rjdCpIWvNU,4637
151
151
  prefect/bundles/execute.py,sha256=KubXt8jNr9Xq7hf44oxi5fZEh0QcLHQO7ivtSElrQq0,1736
152
152
  prefect/client/AGENTS.md,sha256=16krPZUqEyiEaKk6jsLRK7MJHKlshe1mWnKvtvZiMco,3079
@@ -230,8 +230,8 @@ prefect/docker/__init__.py,sha256=gX5MJ5yHIK7WwpoOR3LpcR-vydv5SxrkOWdYQcgUL5o,71
230
230
  prefect/docker/_buildx.py,sha256=sVh0v12RZ3TkqzdawuIzfj1d2GTgpvT2WM5khrGXsqA,519
231
231
  prefect/docker/docker_image.py,sha256=afAOYLv8pIGBV-rDuMbzfnnfGlu7qMUb6l3jXGCAe_k,6962
232
232
  prefect/events/AGENTS.md,sha256=ZCrjaA67BQGdDzO5R7yJB7HO5Ahb8z7A3sT8yuKQ6V0,3049
233
- prefect/events/__init__.py,sha256=kBeikKnDbUpVGuBJAGGCI8Jj3qaZDleJ2eZddLRuakc,2162
234
- prefect/events/actions.py,sha256=7SXrnc_I72IrUjQRg7W3WTH6olS0r26SAPwusRTtffE,9620
233
+ prefect/events/__init__.py,sha256=e3aIcHzTfseOmq_A3hM_jSPNYP_OdndJErzjcdrMuAc,2202
234
+ prefect/events/actions.py,sha256=Ii5P4FX10Xu1nzU0e7okA1KC1eVIHhzExubFurrOr_Q,9785
235
235
  prefect/events/clients.py,sha256=m0KN7LpulgatjCVX5-3f8kzQKI_7u7Y3Nk6V948tTyc,33339
236
236
  prefect/events/filters.py,sha256=_vweQbeRxK8KiyKIPb6fYEtA5crbaUxlYPkWorFmMm4,9201
237
237
  prefect/events/related.py,sha256=CTeexYUmmA93V4gsR33GIFmw-SS-X_ouOpRg-oeq-BU,6672
@@ -304,7 +304,7 @@ prefect/server/api/block_capabilities.py,sha256=0x1vtC2CtSRVcCWydgNbylmfv_LnJHIo
304
304
  prefect/server/api/block_documents.py,sha256=srU5Jmn-Fguhut6PtV2N4XWZLJa2enwZ7ShcbrDRR6w,6034
305
305
  prefect/server/api/block_schemas.py,sha256=H9kxnKPlTvz62SMcX1ssHnQ8KACU2728ebCtSp2iLcs,5508
306
306
  prefect/server/api/block_types.py,sha256=DdKesZhnpGlTJtIidGoBSLEFFcAwFp-_XEA-SUOlhAM,8343
307
- prefect/server/api/clients.py,sha256=jP7N62_NUxOp2aUQhl6FFTNYPP7X33XcT00vNQLOEM0,9054
307
+ prefect/server/api/clients.py,sha256=6bOdrsgn7jyM_YD89opjzrg9cJn6uUTHbs1P6wFotRI,9198
308
308
  prefect/server/api/collections.py,sha256=RI7cjdM8RYFyAk2rgb35vqh06PWGXAamTvwThl83joY,2454
309
309
  prefect/server/api/concurrency_limits.py,sha256=nJKOiKZwQvxFfAMBaqyeAWHjm6hrpErfnQIMuhhszoA,24503
310
310
  prefect/server/api/concurrency_limits_v2.py,sha256=-Wwk7eWe0stLt738wQslHZQxYUiMIiTodMWU1P6BuLk,17817
@@ -328,7 +328,7 @@ prefect/server/api/templates.py,sha256=EW5aJOuvSXBeShd5VIygI1f9W0uTUpGb32ADrL9LG
328
328
  prefect/server/api/validation.py,sha256=DPofXjLFejs_qusUQgzRkG89X2EFoviAy9J08pnNvyM,14543
329
329
  prefect/server/api/variables.py,sha256=8Ursuf20R5zXUS015ZexCH72K7R6Yv76ehkr_l3Xt0k,6186
330
330
  prefect/server/api/work_queues.py,sha256=BQQgiBWytrMgPhbHbOR_6Vx8T1V7JDIgeoZ4_LhmvCo,11276
331
- prefect/server/api/workers.py,sha256=SukmrFKvZro4OHHhiJE2z7iUvQSqlOPHgLXQCnJSNfM,30075
331
+ prefect/server/api/workers.py,sha256=WnlQHlD41DS6ZH4NCr9Rx7dOXNWPTTFNaoH3TkHPNn4,44174
332
332
  prefect/server/api/collections_data/views/aggregate-worker-metadata.json,sha256=otKGMKJUh_ySkQyNBFCe84E6VRbuR-Z7_676D21YKsw,81525
333
333
  prefect/server/api/static/prefect-logo-mark-gradient.png,sha256=ylRjJkI_JHCw8VbQasNnXQHwZW-sH-IQiUGSD3aWP1E,73430
334
334
  prefect/server/api/ui/__init__.py,sha256=TCXO4ZUZCqCbm2QoNvWNTErkzWiX2nSACuO-0Tiomvg,93
@@ -422,23 +422,23 @@ prefect/utilities/schema_tools/__init__.py,sha256=At3rMHd2g_Em2P3_dFQlFgqR_EpBwr
422
422
  prefect/utilities/schema_tools/hydration.py,sha256=446SKv-W65VlX_5UGY3YvGYPQ7aYgb6dPy85EueU8FM,9671
423
423
  prefect/utilities/schema_tools/validation.py,sha256=UhRLWStdT9__u4yz-mnc3lcdt-VJmRGJBGV_f_9um-Y,10749
424
424
  prefect/utilities/templating/AGENTS.md,sha256=vkflyqkm9snY5sR-Wfxr855F55yJQTfSBEt5ThoSUF4,2097
425
- prefect/utilities/templating/__init__.py,sha256=8JR-OpK1Pblxp0AGpbLp48R5KDSBmUhWSIZs8nfkYbs,18091
425
+ prefect/utilities/templating/__init__.py,sha256=-GGrp9CRHVPddmc_9z3BTBkMO7Vwqg-yevkJFaFHI2I,19248
426
426
  prefect/workers/AGENTS.md,sha256=rNcd5PutF7TUQJerXezNX3kemXR7VII_5CnuYq0l0vs,4282
427
427
  prefect/workers/__init__.py,sha256=EaM1F0RZ-XIJaGeTKLsXDnfOPHzVWk5bk0_c4BVS44M,64
428
428
  prefect/workers/_cleanup.py,sha256=U0tc0vExmsVpXaTo9H9CGHWnJOoykZiQVKI-cn5TJao,23675
429
429
  prefect/workers/_cleanup_handlers.py,sha256=dVy2qi5qjazjusunWcRZro5Hiy-stp8NqSeMNthjOnk,5169
430
- prefect/workers/base.py,sha256=zJWxpfwWxL21y3HpVdSdp-ephTdGCbt3qGyP_Op-ZlA,78395
430
+ prefect/workers/base.py,sha256=cDxZY3jCZ5sm7xB_EXpxuxe6XIUdMqQ0OrcSNiwEscY,78325
431
431
  prefect/workers/block.py,sha256=dPvG1jDGD5HSH7aM2utwtk6RaJ9qg13XjkA0lAIgQmY,287
432
432
  prefect/workers/cloud.py,sha256=dPvG1jDGD5HSH7aM2utwtk6RaJ9qg13XjkA0lAIgQmY,287
433
433
  prefect/workers/process.py,sha256=QA9p3Z3br77PGVL_McPgYyeF2zABXlGTF1qLAsTHYwY,12820
434
434
  prefect/workers/server.py,sha256=bWnYfMfJf5_IO3y3aJOpia7p9lFKC3ZZjiMvHox-UKY,1992
435
435
  prefect/workers/utilities.py,sha256=-re_0s1Jr98W5xhJr4Xvvz34OxN3T4ypSupxdBiC8aA,2633
436
436
  prefect/workers/_worker_channel/__init__.py,sha256=vEc7NvC1sjxnCn5thQv5lSU1bDKRaT1sV7AA_6VRoCM,709
437
- prefect/workers/_worker_channel/_protocol.py,sha256=Wo5QuQ9h7gJlq1FLzrHHGbqTRgPS7mJA31svSBkfHBY,15699
437
+ prefect/workers/_worker_channel/_protocol.py,sha256=GpCNh1o3qmmqHA_UOOTge1QVC6IRvWP2RdpAEBqXPs0,15834
438
438
  prefect/workers/_worker_channel/_state.py,sha256=eQTFZtAVDZH1vVWps3SdeY6aW3qu2wx1UKYQXK3AyuE,5369
439
- prefect/workers/_worker_channel/_sync.py,sha256=4VPYQbnhHeB2W4mJr-4qb-XR-YnaigUKjdAvJQqV0iw,14708
439
+ prefect/workers/_worker_channel/_sync.py,sha256=G5G8_UaQYbeLebi5Mb1Z_KgGfXfyjXo5uT9la5_zwqY,14984
440
440
  prefect/workers/_worker_channel/_transport.py,sha256=cgrtAENawDpIPB8gwILd62y2VduykUCmg1NaO1pL-tg,9021
441
- prefect_client-3.7.2.dev2.dist-info/METADATA,sha256=Wy-hi6qLcnTmx0g139Ic9N6ArEO0VYVs5eXdOAkz3ec,7502
442
- prefect_client-3.7.2.dev2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
443
- prefect_client-3.7.2.dev2.dist-info/licenses/LICENSE,sha256=MCxsn8osAkzfxKC4CC_dLcUkU8DZLkyihZ8mGs3Ah3Q,11357
444
- prefect_client-3.7.2.dev2.dist-info/RECORD,,
441
+ prefect_client-3.7.2.dev3.dist-info/METADATA,sha256=30GTP0tqIsf673WM91EeD7fFTwH5bpIw8m1fekn7s9M,7502
442
+ prefect_client-3.7.2.dev3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
443
+ prefect_client-3.7.2.dev3.dist-info/licenses/LICENSE,sha256=MCxsn8osAkzfxKC4CC_dLcUkU8DZLkyihZ8mGs3Ah3Q,11357
444
+ prefect_client-3.7.2.dev3.dist-info/RECORD,,