prefect-client 3.1.14__py3-none-any.whl → 3.1.15__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/__main__.py +4 -0
- prefect/_experimental/lineage.py +40 -22
- prefect/_internal/concurrency/services.py +1 -1
- prefect/_version.py +3 -3
- prefect/artifacts.py +408 -105
- prefect/blocks/core.py +1 -1
- prefect/blocks/notifications.py +5 -0
- prefect/client/schemas/actions.py +11 -1
- prefect/client/schemas/schedules.py +4 -4
- prefect/deployments/base.py +13 -16
- prefect/deployments/runner.py +117 -4
- prefect/events/clients.py +39 -0
- prefect/flow_engine.py +119 -0
- prefect/flows.py +252 -9
- prefect/runtime/task_run.py +37 -9
- prefect/tasks.py +8 -6
- prefect/utilities/render_swagger.py +1 -1
- prefect/utilities/templating.py +7 -0
- {prefect_client-3.1.14.dist-info → prefect_client-3.1.15.dist-info}/METADATA +1 -1
- {prefect_client-3.1.14.dist-info → prefect_client-3.1.15.dist-info}/RECORD +23 -22
- {prefect_client-3.1.14.dist-info → prefect_client-3.1.15.dist-info}/LICENSE +0 -0
- {prefect_client-3.1.14.dist-info → prefect_client-3.1.15.dist-info}/WHEEL +0 -0
- {prefect_client-3.1.14.dist-info → prefect_client-3.1.15.dist-info}/top_level.txt +0 -0
prefect/blocks/core.py
CHANGED
@@ -669,7 +669,7 @@ class Block(BaseModel, ABC):
|
|
669
669
|
module_str = ".".join(qualified_name.split(".")[:-1])
|
670
670
|
origin = cls.__pydantic_generic_metadata__.get("origin") or cls
|
671
671
|
class_name = origin.__name__
|
672
|
-
block_variable_name = f
|
672
|
+
block_variable_name = f"{cls.get_block_type_slug().replace('-', '_')}_block"
|
673
673
|
|
674
674
|
return dedent(
|
675
675
|
f"""\
|
prefect/blocks/notifications.py
CHANGED
@@ -570,6 +570,10 @@ class MattermostWebhook(AbstractAppriseNotificationBlock):
|
|
570
570
|
description="The hostname of your Mattermost server.",
|
571
571
|
examples=["Mattermost.example.com"],
|
572
572
|
)
|
573
|
+
secure: bool = Field(
|
574
|
+
default=False,
|
575
|
+
description="Whether to use secure https connection.",
|
576
|
+
)
|
573
577
|
|
574
578
|
token: SecretStr = Field(
|
575
579
|
default=...,
|
@@ -621,6 +625,7 @@ class MattermostWebhook(AbstractAppriseNotificationBlock):
|
|
621
625
|
channels=self.channels,
|
622
626
|
include_image=self.include_image,
|
623
627
|
port=self.port,
|
628
|
+
secure=self.secure,
|
624
629
|
).url() # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] incomplete type hints in apprise
|
625
630
|
)
|
626
631
|
self._start_apprise_client(url)
|
@@ -1,5 +1,5 @@
|
|
1
1
|
from copy import deepcopy
|
2
|
-
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
|
2
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union
|
3
3
|
from uuid import UUID, uuid4
|
4
4
|
|
5
5
|
import jsonschema
|
@@ -93,6 +93,16 @@ class DeploymentScheduleCreate(ActionBaseModel):
|
|
93
93
|
description="The maximum number of scheduled runs for the schedule.",
|
94
94
|
)
|
95
95
|
|
96
|
+
@field_validator("active", mode="wrap")
|
97
|
+
@classmethod
|
98
|
+
def validate_active(cls, v: Any, handler: Callable[[Any], Any]) -> bool:
|
99
|
+
try:
|
100
|
+
return handler(v)
|
101
|
+
except Exception:
|
102
|
+
raise ValueError(
|
103
|
+
f"active must be able to be parsed as a boolean, got {v!r} of type {type(v)}"
|
104
|
+
)
|
105
|
+
|
96
106
|
@field_validator("max_scheduled_runs")
|
97
107
|
@classmethod
|
98
108
|
def validate_max_scheduled_runs(cls, v: Optional[int]) -> Optional[int]:
|
@@ -145,7 +145,7 @@ class CronSchedule(PrefectBaseModel):
|
|
145
145
|
|
146
146
|
@field_validator("timezone")
|
147
147
|
@classmethod
|
148
|
-
def valid_timezone(cls, v: Optional[str]) -> str:
|
148
|
+
def valid_timezone(cls, v: Optional[str]) -> Optional[str]:
|
149
149
|
return default_timezone(v)
|
150
150
|
|
151
151
|
@field_validator("cron")
|
@@ -276,7 +276,7 @@ class RRuleSchedule(PrefectBaseModel):
|
|
276
276
|
kwargs.update(
|
277
277
|
until=until.replace(tzinfo=timezone),
|
278
278
|
)
|
279
|
-
return rrule.replace(**kwargs)
|
279
|
+
return rrule.replace(**kwargs) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] missing type hints
|
280
280
|
|
281
281
|
# update rrules
|
282
282
|
localized_rrules: list[dateutil.rrule.rrule] = []
|
@@ -286,7 +286,7 @@ class RRuleSchedule(PrefectBaseModel):
|
|
286
286
|
kwargs: dict[str, Any] = dict(dtstart=dtstart.replace(tzinfo=timezone))
|
287
287
|
if until := _rrule_dt(rr, "_until"):
|
288
288
|
kwargs.update(until=until.replace(tzinfo=timezone))
|
289
|
-
localized_rrules.append(rr.replace(**kwargs))
|
289
|
+
localized_rrules.append(rr.replace(**kwargs)) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] missing type hints
|
290
290
|
setattr(rrule, "_rrule", localized_rrules)
|
291
291
|
|
292
292
|
# update exrules
|
@@ -297,7 +297,7 @@ class RRuleSchedule(PrefectBaseModel):
|
|
297
297
|
kwargs = dict(dtstart=dtstart.replace(tzinfo=timezone))
|
298
298
|
if until := _rrule_dt(exr, "_until"):
|
299
299
|
kwargs.update(until=until.replace(tzinfo=timezone))
|
300
|
-
localized_exrules.append(exr.replace(**kwargs))
|
300
|
+
localized_exrules.append(exr.replace(**kwargs)) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] missing type hints
|
301
301
|
setattr(rrule, "_exrule", localized_exrules)
|
302
302
|
|
303
303
|
# update rdates
|
prefect/deployments/base.py
CHANGED
@@ -10,12 +10,11 @@ from __future__ import annotations
|
|
10
10
|
import os
|
11
11
|
from copy import deepcopy
|
12
12
|
from pathlib import Path
|
13
|
-
from typing import Any, Dict, List, Optional
|
13
|
+
from typing import Any, Dict, List, Optional
|
14
14
|
|
15
15
|
import yaml
|
16
16
|
from ruamel.yaml import YAML
|
17
17
|
|
18
|
-
from prefect.client.schemas.actions import DeploymentScheduleCreate
|
19
18
|
from prefect.client.schemas.objects import ConcurrencyLimitStrategy
|
20
19
|
from prefect.client.schemas.schedules import IntervalSchedule
|
21
20
|
from prefect.utilities._git import get_git_branch, get_git_remote_origin_url
|
@@ -207,8 +206,8 @@ def initialize_project(
|
|
207
206
|
|
208
207
|
|
209
208
|
def _format_deployment_for_saving_to_prefect_file(
|
210
|
-
deployment:
|
211
|
-
) ->
|
209
|
+
deployment: dict[str, Any],
|
210
|
+
) -> dict[str, Any]:
|
212
211
|
"""
|
213
212
|
Formats a deployment into a templated deploy config for saving to prefect.yaml.
|
214
213
|
|
@@ -227,10 +226,8 @@ def _format_deployment_for_saving_to_prefect_file(
|
|
227
226
|
deployment.pop("flow_name", None)
|
228
227
|
|
229
228
|
if deployment.get("schedules"):
|
230
|
-
schedules = []
|
231
|
-
for deployment_schedule in
|
232
|
-
List[DeploymentScheduleCreate], deployment["schedules"]
|
233
|
-
):
|
229
|
+
schedules: list[dict[str, Any]] = []
|
230
|
+
for deployment_schedule in deployment["schedules"]:
|
234
231
|
if isinstance(deployment_schedule.schedule, IntervalSchedule):
|
235
232
|
schedule_config = _interval_schedule_to_dict(
|
236
233
|
deployment_schedule.schedule
|
@@ -257,7 +254,7 @@ def _format_deployment_for_saving_to_prefect_file(
|
|
257
254
|
return deployment
|
258
255
|
|
259
256
|
|
260
|
-
def _interval_schedule_to_dict(schedule: IntervalSchedule) ->
|
257
|
+
def _interval_schedule_to_dict(schedule: IntervalSchedule) -> dict[str, Any]:
|
261
258
|
"""
|
262
259
|
Converts an IntervalSchedule to a dictionary.
|
263
260
|
|
@@ -265,7 +262,7 @@ def _interval_schedule_to_dict(schedule: IntervalSchedule) -> Dict:
|
|
265
262
|
- schedule (IntervalSchedule): the schedule to convert
|
266
263
|
|
267
264
|
Returns:
|
268
|
-
-
|
265
|
+
- dict[str, Any]: the schedule as a dictionary
|
269
266
|
"""
|
270
267
|
schedule_config = schedule.model_dump()
|
271
268
|
schedule_config["interval"] = schedule_config["interval"].total_seconds()
|
@@ -275,12 +272,12 @@ def _interval_schedule_to_dict(schedule: IntervalSchedule) -> Dict:
|
|
275
272
|
|
276
273
|
|
277
274
|
def _save_deployment_to_prefect_file(
|
278
|
-
deployment:
|
279
|
-
build_steps:
|
280
|
-
push_steps:
|
281
|
-
pull_steps:
|
282
|
-
triggers:
|
283
|
-
sla:
|
275
|
+
deployment: dict[str, Any],
|
276
|
+
build_steps: list[dict[str, Any]] | None = None,
|
277
|
+
push_steps: list[dict[str, Any]] | None = None,
|
278
|
+
pull_steps: list[dict[str, Any]] | None = None,
|
279
|
+
triggers: list[dict[str, Any]] | None = None,
|
280
|
+
sla: list[dict[str, Any]] | None = None,
|
284
281
|
prefect_file: Path = Path("prefect.yaml"),
|
285
282
|
):
|
286
283
|
"""
|
prefect/deployments/runner.py
CHANGED
@@ -49,6 +49,7 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, track
|
|
49
49
|
from rich.table import Table
|
50
50
|
|
51
51
|
from prefect._experimental.sla.objects import SlaTypes
|
52
|
+
from prefect._internal.compatibility.async_dispatch import async_dispatch
|
52
53
|
from prefect._internal.concurrency.api import create_call, from_async
|
53
54
|
from prefect._internal.schemas.validators import (
|
54
55
|
reconcile_paused_deployment,
|
@@ -82,7 +83,7 @@ from prefect.settings import (
|
|
82
83
|
)
|
83
84
|
from prefect.types import ListOfNonEmptyStrings
|
84
85
|
from prefect.types.entrypoint import EntrypointType
|
85
|
-
from prefect.utilities.asyncutils import sync_compatible
|
86
|
+
from prefect.utilities.asyncutils import run_coro_as_sync, sync_compatible
|
86
87
|
from prefect.utilities.callables import ParameterSchema, parameter_schema
|
87
88
|
from prefect.utilities.collections import get_from_dict, isiterable
|
88
89
|
from prefect.utilities.dockerutils import (
|
@@ -717,8 +718,7 @@ class RunnerDeployment(BaseModel):
|
|
717
718
|
return deployment
|
718
719
|
|
719
720
|
@classmethod
|
720
|
-
|
721
|
-
async def from_storage(
|
721
|
+
async def afrom_storage(
|
722
722
|
cls,
|
723
723
|
storage: RunnerStorage,
|
724
724
|
entrypoint: str,
|
@@ -742,7 +742,7 @@ class RunnerDeployment(BaseModel):
|
|
742
742
|
work_queue_name: Optional[str] = None,
|
743
743
|
job_variables: Optional[dict[str, Any]] = None,
|
744
744
|
_sla: Optional[Union[SlaTypes, list[SlaTypes]]] = None, # experimental
|
745
|
-
):
|
745
|
+
) -> "RunnerDeployment":
|
746
746
|
"""
|
747
747
|
Create a RunnerDeployment from a flow located at a given entrypoint and stored in a
|
748
748
|
local storage location.
|
@@ -831,6 +831,119 @@ class RunnerDeployment(BaseModel):
|
|
831
831
|
|
832
832
|
return deployment
|
833
833
|
|
834
|
+
@classmethod
|
835
|
+
@async_dispatch(afrom_storage)
|
836
|
+
def from_storage(
|
837
|
+
cls,
|
838
|
+
storage: RunnerStorage,
|
839
|
+
entrypoint: str,
|
840
|
+
name: str,
|
841
|
+
flow_name: Optional[str] = None,
|
842
|
+
interval: Optional[
|
843
|
+
Union[Iterable[Union[int, float, timedelta]], int, float, timedelta]
|
844
|
+
] = None,
|
845
|
+
cron: Optional[Union[Iterable[str], str]] = None,
|
846
|
+
rrule: Optional[Union[Iterable[str], str]] = None,
|
847
|
+
paused: Optional[bool] = None,
|
848
|
+
schedules: Optional["FlexibleScheduleList"] = None,
|
849
|
+
concurrency_limit: Optional[Union[int, ConcurrencyLimitConfig, None]] = None,
|
850
|
+
parameters: Optional[dict[str, Any]] = None,
|
851
|
+
triggers: Optional[List[Union[DeploymentTriggerTypes, TriggerTypes]]] = None,
|
852
|
+
description: Optional[str] = None,
|
853
|
+
tags: Optional[List[str]] = None,
|
854
|
+
version: Optional[str] = None,
|
855
|
+
enforce_parameter_schema: bool = True,
|
856
|
+
work_pool_name: Optional[str] = None,
|
857
|
+
work_queue_name: Optional[str] = None,
|
858
|
+
job_variables: Optional[dict[str, Any]] = None,
|
859
|
+
_sla: Optional[Union[SlaTypes, list[SlaTypes]]] = None, # experimental
|
860
|
+
) -> "RunnerDeployment":
|
861
|
+
"""
|
862
|
+
Create a RunnerDeployment from a flow located at a given entrypoint and stored in a
|
863
|
+
local storage location.
|
864
|
+
|
865
|
+
Args:
|
866
|
+
entrypoint: The path to a file containing a flow and the name of the flow function in
|
867
|
+
the format `./path/to/file.py:flow_func_name`.
|
868
|
+
name: A name for the deployment
|
869
|
+
flow_name: The name of the flow to deploy
|
870
|
+
storage: A storage object to use for retrieving flow code. If not provided, a
|
871
|
+
URL must be provided.
|
872
|
+
interval: An interval on which to execute the current flow. Accepts either a number
|
873
|
+
or a timedelta object. If a number is given, it will be interpreted as seconds.
|
874
|
+
cron: A cron schedule of when to execute runs of this flow.
|
875
|
+
rrule: An rrule schedule of when to execute runs of this flow.
|
876
|
+
triggers: A list of triggers that should kick of a run of this flow.
|
877
|
+
parameters: A dictionary of default parameter values to pass to runs of this flow.
|
878
|
+
description: A description for the created deployment. Defaults to the flow's
|
879
|
+
description if not provided.
|
880
|
+
tags: A list of tags to associate with the created deployment for organizational
|
881
|
+
purposes.
|
882
|
+
version: A version for the created deployment. Defaults to the flow's version.
|
883
|
+
enforce_parameter_schema: Whether or not the Prefect API should enforce the
|
884
|
+
parameter schema for this deployment.
|
885
|
+
work_pool_name: The name of the work pool to use for this deployment.
|
886
|
+
work_queue_name: The name of the work queue to use for this deployment's scheduled runs.
|
887
|
+
If not provided the default work queue for the work pool will be used.
|
888
|
+
job_variables: Settings used to override the values specified default base job template
|
889
|
+
of the chosen work pool. Refer to the base job template of the chosen work pool for
|
890
|
+
available settings.
|
891
|
+
_sla: (Experimental) SLA configuration for the deployment. May be removed or modified at any time. Currently only supported on Prefect Cloud.
|
892
|
+
"""
|
893
|
+
from prefect.flows import load_flow_from_entrypoint
|
894
|
+
|
895
|
+
constructed_schedules = cls._construct_deployment_schedules(
|
896
|
+
interval=interval,
|
897
|
+
cron=cron,
|
898
|
+
rrule=rrule,
|
899
|
+
schedules=schedules,
|
900
|
+
)
|
901
|
+
|
902
|
+
if isinstance(concurrency_limit, ConcurrencyLimitConfig):
|
903
|
+
concurrency_options = {
|
904
|
+
"collision_strategy": concurrency_limit.collision_strategy
|
905
|
+
}
|
906
|
+
concurrency_limit = concurrency_limit.limit
|
907
|
+
else:
|
908
|
+
concurrency_options = None
|
909
|
+
|
910
|
+
job_variables = job_variables or {}
|
911
|
+
|
912
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
913
|
+
storage.set_base_path(Path(tmpdir))
|
914
|
+
run_coro_as_sync(storage.pull_code())
|
915
|
+
|
916
|
+
full_entrypoint = str(storage.destination / entrypoint)
|
917
|
+
flow = load_flow_from_entrypoint(full_entrypoint)
|
918
|
+
|
919
|
+
deployment = cls(
|
920
|
+
name=Path(name).stem,
|
921
|
+
flow_name=flow_name or flow.name,
|
922
|
+
schedules=constructed_schedules,
|
923
|
+
concurrency_limit=concurrency_limit,
|
924
|
+
concurrency_options=concurrency_options,
|
925
|
+
paused=paused,
|
926
|
+
tags=tags or [],
|
927
|
+
triggers=triggers or [],
|
928
|
+
parameters=parameters or {},
|
929
|
+
description=description,
|
930
|
+
version=version,
|
931
|
+
entrypoint=entrypoint,
|
932
|
+
enforce_parameter_schema=enforce_parameter_schema,
|
933
|
+
storage=storage,
|
934
|
+
work_pool_name=work_pool_name,
|
935
|
+
work_queue_name=work_queue_name,
|
936
|
+
job_variables=job_variables,
|
937
|
+
)
|
938
|
+
deployment._sla = _sla
|
939
|
+
deployment._path = str(storage.destination).replace(
|
940
|
+
tmpdir, "$STORAGE_BASE_PATH"
|
941
|
+
)
|
942
|
+
|
943
|
+
cls._set_defaults_from_flow(deployment, flow)
|
944
|
+
|
945
|
+
return deployment
|
946
|
+
|
834
947
|
|
835
948
|
@sync_compatible
|
836
949
|
async def deploy(
|
prefect/events/clients.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
import abc
|
2
2
|
import asyncio
|
3
3
|
import os
|
4
|
+
import ssl
|
4
5
|
from types import TracebackType
|
5
6
|
from typing import (
|
6
7
|
TYPE_CHECKING,
|
@@ -37,6 +38,7 @@ from prefect.events import Event
|
|
37
38
|
from prefect.logging import get_logger
|
38
39
|
from prefect.settings import (
|
39
40
|
PREFECT_API_KEY,
|
41
|
+
PREFECT_API_TLS_INSECURE_SKIP_VERIFY,
|
40
42
|
PREFECT_API_URL,
|
41
43
|
PREFECT_CLOUD_API_URL,
|
42
44
|
PREFECT_DEBUG_MODE,
|
@@ -120,6 +122,13 @@ class WebsocketProxyConnect(Connect):
|
|
120
122
|
self._host = host
|
121
123
|
self._port = port
|
122
124
|
|
125
|
+
if PREFECT_API_TLS_INSECURE_SKIP_VERIFY:
|
126
|
+
# Create an unverified context for insecure connections
|
127
|
+
ctx = ssl.create_default_context()
|
128
|
+
ctx.check_hostname = False
|
129
|
+
ctx.verify_mode = ssl.CERT_NONE
|
130
|
+
self._kwargs.setdefault("ssl", ctx)
|
131
|
+
|
123
132
|
async def _proxy_connect(self: Self) -> WebSocketClientProtocol:
|
124
133
|
if self._proxy:
|
125
134
|
sock = await self._proxy.connect(
|
@@ -348,16 +357,26 @@ class PrefectEventsClient(EventsClient):
|
|
348
357
|
await self._connect.__aexit__(exc_type, exc_val, exc_tb)
|
349
358
|
return await super().__aexit__(exc_type, exc_val, exc_tb)
|
350
359
|
|
360
|
+
def _log_debug(self, message: str, *args, **kwargs) -> None:
|
361
|
+
message = f"EventsClient(id={id(self)}): " + message
|
362
|
+
logger.debug(message, *args, **kwargs)
|
363
|
+
|
351
364
|
async def _reconnect(self) -> None:
|
365
|
+
logger.debug("Reconnecting websocket connection.")
|
366
|
+
|
352
367
|
if self._websocket:
|
353
368
|
self._websocket = None
|
354
369
|
await self._connect.__aexit__(None, None, None)
|
370
|
+
logger.debug("Cleared existing websocket connection.")
|
355
371
|
|
356
372
|
try:
|
373
|
+
logger.debug("Opening websocket connection.")
|
357
374
|
self._websocket = await self._connect.__aenter__()
|
358
375
|
# make sure we have actually connected
|
376
|
+
logger.debug("Pinging to ensure websocket connected.")
|
359
377
|
pong = await self._websocket.ping()
|
360
378
|
await pong
|
379
|
+
logger.debug("Pong received. Websocket connected.")
|
361
380
|
except Exception as e:
|
362
381
|
# The client is frequently run in a background thread
|
363
382
|
# so we log an additional warning to ensure
|
@@ -375,11 +394,13 @@ class PrefectEventsClient(EventsClient):
|
|
375
394
|
raise
|
376
395
|
|
377
396
|
events_to_resend = self._unconfirmed_events
|
397
|
+
logger.debug("Resending %s unconfirmed events.", len(events_to_resend))
|
378
398
|
# Clear the unconfirmed events here, because they are going back through emit
|
379
399
|
# and will be added again through the normal checkpointing process
|
380
400
|
self._unconfirmed_events = []
|
381
401
|
for event in events_to_resend:
|
382
402
|
await self.emit(event)
|
403
|
+
logger.debug("Finished resending unconfirmed events.")
|
383
404
|
|
384
405
|
async def _checkpoint(self, event: Event) -> None:
|
385
406
|
assert self._websocket
|
@@ -387,11 +408,20 @@ class PrefectEventsClient(EventsClient):
|
|
387
408
|
self._unconfirmed_events.append(event)
|
388
409
|
|
389
410
|
unconfirmed_count = len(self._unconfirmed_events)
|
411
|
+
|
412
|
+
logger.debug(
|
413
|
+
"Added event id=%s to unconfirmed events list. "
|
414
|
+
"There are now %s unconfirmed events.",
|
415
|
+
event.id,
|
416
|
+
unconfirmed_count,
|
417
|
+
)
|
390
418
|
if unconfirmed_count < self._checkpoint_every:
|
391
419
|
return
|
392
420
|
|
421
|
+
logger.debug("Pinging to checkpoint unconfirmed events.")
|
393
422
|
pong = await self._websocket.ping()
|
394
423
|
await pong
|
424
|
+
self._log_debug("Pong received. Events checkpointed.")
|
395
425
|
|
396
426
|
# once the pong returns, we know for sure that we've sent all the messages
|
397
427
|
# we had enqueued prior to that. There could be more that came in after, so
|
@@ -401,7 +431,9 @@ class PrefectEventsClient(EventsClient):
|
|
401
431
|
EVENT_WEBSOCKET_CHECKPOINTS.labels(self.client_name).inc()
|
402
432
|
|
403
433
|
async def _emit(self, event: Event) -> None:
|
434
|
+
self._log_debug("Emitting event id=%s.", event.id)
|
404
435
|
for i in range(self._reconnection_attempts + 1):
|
436
|
+
self._log_debug("Emit reconnection attempt %s.", i)
|
405
437
|
try:
|
406
438
|
# If we're here and the websocket is None, then we've had a failure in a
|
407
439
|
# previous reconnection attempt.
|
@@ -410,14 +442,18 @@ class PrefectEventsClient(EventsClient):
|
|
410
442
|
# from a ConnectionClosed, so reconnect now, resending any unconfirmed
|
411
443
|
# events before we send this one.
|
412
444
|
if not self._websocket or i > 0:
|
445
|
+
self._log_debug("Attempting websocket reconnection.")
|
413
446
|
await self._reconnect()
|
414
447
|
assert self._websocket
|
415
448
|
|
449
|
+
self._log_debug("Sending event id=%s.", event.id)
|
416
450
|
await self._websocket.send(event.model_dump_json())
|
451
|
+
self._log_debug("Checkpointing event id=%s.", event.id)
|
417
452
|
await self._checkpoint(event)
|
418
453
|
|
419
454
|
return
|
420
455
|
except ConnectionClosed:
|
456
|
+
self._log_debug("Got ConnectionClosed error.")
|
421
457
|
if i == self._reconnection_attempts:
|
422
458
|
# this was our final chance, raise the most recent error
|
423
459
|
raise
|
@@ -426,6 +462,9 @@ class PrefectEventsClient(EventsClient):
|
|
426
462
|
# let the first two attempts happen quickly in case this is just
|
427
463
|
# a standard load balancer timeout, but after that, just take a
|
428
464
|
# beat to let things come back around.
|
465
|
+
logger.debug(
|
466
|
+
"Sleeping for 1 second before next reconnection attempt."
|
467
|
+
)
|
429
468
|
await asyncio.sleep(1)
|
430
469
|
|
431
470
|
|
prefect/flow_engine.py
CHANGED
@@ -2,10 +2,13 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import asyncio
|
4
4
|
import logging
|
5
|
+
import multiprocessing
|
6
|
+
import multiprocessing.context
|
5
7
|
import os
|
6
8
|
import time
|
7
9
|
from contextlib import ExitStack, asynccontextmanager, contextmanager, nullcontext
|
8
10
|
from dataclasses import dataclass, field
|
11
|
+
from functools import wraps
|
9
12
|
from typing import (
|
10
13
|
Any,
|
11
14
|
AsyncGenerator,
|
@@ -37,9 +40,12 @@ from prefect.concurrency.v1.context import ConcurrencyContext as ConcurrencyCont
|
|
37
40
|
from prefect.context import (
|
38
41
|
AsyncClientContext,
|
39
42
|
FlowRunContext,
|
43
|
+
SettingsContext,
|
40
44
|
SyncClientContext,
|
41
45
|
TagsContext,
|
46
|
+
get_settings_context,
|
42
47
|
hydrated_context,
|
48
|
+
serialize_context,
|
43
49
|
)
|
44
50
|
from prefect.exceptions import (
|
45
51
|
Abort,
|
@@ -62,6 +68,8 @@ from prefect.results import (
|
|
62
68
|
should_persist_result,
|
63
69
|
)
|
64
70
|
from prefect.settings import PREFECT_DEBUG_MODE
|
71
|
+
from prefect.settings.context import get_current_settings
|
72
|
+
from prefect.settings.models.root import Settings
|
65
73
|
from prefect.states import (
|
66
74
|
Failed,
|
67
75
|
Pending,
|
@@ -83,6 +91,7 @@ from prefect.utilities.annotations import NotSet
|
|
83
91
|
from prefect.utilities.asyncutils import run_coro_as_sync
|
84
92
|
from prefect.utilities.callables import (
|
85
93
|
call_with_parameters,
|
94
|
+
cloudpickle_wrapped_call,
|
86
95
|
get_call_parameters,
|
87
96
|
parameters_to_args_kwargs,
|
88
97
|
)
|
@@ -1533,3 +1542,113 @@ def _flow_parameters(
|
|
1533
1542
|
parameters = flow_run.parameters if flow_run else {}
|
1534
1543
|
call_args, call_kwargs = parameters_to_args_kwargs(flow.fn, parameters)
|
1535
1544
|
return get_call_parameters(flow.fn, call_args, call_kwargs)
|
1545
|
+
|
1546
|
+
|
1547
|
+
def run_flow_in_subprocess(
|
1548
|
+
flow: "Flow[..., Any]",
|
1549
|
+
flow_run: "FlowRun | None" = None,
|
1550
|
+
parameters: dict[str, Any] | None = None,
|
1551
|
+
wait_for: Iterable[PrefectFuture[Any]] | None = None,
|
1552
|
+
context: dict[str, Any] | None = None,
|
1553
|
+
) -> multiprocessing.context.SpawnProcess:
|
1554
|
+
"""
|
1555
|
+
Run a flow in a subprocess.
|
1556
|
+
|
1557
|
+
Note the result of the flow will only be accessible if the flow is configured to
|
1558
|
+
persist its result.
|
1559
|
+
|
1560
|
+
Args:
|
1561
|
+
flow: The flow to run.
|
1562
|
+
flow_run: The flow run object containing run metadata.
|
1563
|
+
parameters: The parameters to use when invoking the flow.
|
1564
|
+
wait_for: The futures to wait for before starting the flow.
|
1565
|
+
context: A serialized context to hydrate before running the flow. If not provided,
|
1566
|
+
the current context will be used. A serialized context should be provided if
|
1567
|
+
this function is called in a separate memory space from the parent run (e.g.
|
1568
|
+
in a subprocess or on another machine).
|
1569
|
+
|
1570
|
+
Returns:
|
1571
|
+
A multiprocessing.context.SpawnProcess representing the process that is running the flow.
|
1572
|
+
"""
|
1573
|
+
from prefect.flow_engine import run_flow
|
1574
|
+
|
1575
|
+
@wraps(run_flow)
|
1576
|
+
def run_flow_with_env(
|
1577
|
+
*args: Any,
|
1578
|
+
env: dict[str, str] | None = None,
|
1579
|
+
**kwargs: Any,
|
1580
|
+
):
|
1581
|
+
"""
|
1582
|
+
Wrapper function to update environment variables and settings before running the flow.
|
1583
|
+
"""
|
1584
|
+
engine_logger = logging.getLogger("prefect.engine")
|
1585
|
+
|
1586
|
+
os.environ.update(env or {})
|
1587
|
+
settings_context = get_settings_context()
|
1588
|
+
# Create a new settings context with a new settings object to pick up the updated
|
1589
|
+
# environment variables
|
1590
|
+
with SettingsContext(
|
1591
|
+
profile=settings_context.profile,
|
1592
|
+
settings=Settings(),
|
1593
|
+
):
|
1594
|
+
try:
|
1595
|
+
maybe_coro = run_flow(*args, **kwargs)
|
1596
|
+
if asyncio.iscoroutine(maybe_coro):
|
1597
|
+
# This is running in a brand new process, so there won't be an existing
|
1598
|
+
# event loop.
|
1599
|
+
asyncio.run(maybe_coro)
|
1600
|
+
except Abort as abort_signal:
|
1601
|
+
abort_signal: Abort
|
1602
|
+
if flow_run:
|
1603
|
+
msg = f"Execution of flow run '{flow_run.id}' aborted by orchestrator: {abort_signal}"
|
1604
|
+
else:
|
1605
|
+
msg = f"Execution aborted by orchestrator: {abort_signal}"
|
1606
|
+
engine_logger.info(msg)
|
1607
|
+
exit(0)
|
1608
|
+
except Pause as pause_signal:
|
1609
|
+
pause_signal: Pause
|
1610
|
+
if flow_run:
|
1611
|
+
msg = f"Execution of flow run '{flow_run.id}' is paused: {pause_signal}"
|
1612
|
+
else:
|
1613
|
+
msg = f"Execution is paused: {pause_signal}"
|
1614
|
+
engine_logger.info(msg)
|
1615
|
+
exit(0)
|
1616
|
+
except Exception:
|
1617
|
+
if flow_run:
|
1618
|
+
msg = f"Execution of flow run '{flow_run.id}' exited with unexpected exception"
|
1619
|
+
else:
|
1620
|
+
msg = "Execution exited with unexpected exception"
|
1621
|
+
engine_logger.error(msg, exc_info=True)
|
1622
|
+
exit(1)
|
1623
|
+
except BaseException:
|
1624
|
+
if flow_run:
|
1625
|
+
msg = f"Execution of flow run '{flow_run.id}' interrupted by base exception"
|
1626
|
+
else:
|
1627
|
+
msg = "Execution interrupted by base exception"
|
1628
|
+
engine_logger.error(msg, exc_info=True)
|
1629
|
+
# Let the exit code be determined by the base exception type
|
1630
|
+
raise
|
1631
|
+
|
1632
|
+
ctx = multiprocessing.get_context("spawn")
|
1633
|
+
|
1634
|
+
context = context or serialize_context()
|
1635
|
+
|
1636
|
+
process = ctx.Process(
|
1637
|
+
target=cloudpickle_wrapped_call(
|
1638
|
+
run_flow_with_env,
|
1639
|
+
env=get_current_settings().to_environment_variables(exclude_unset=True)
|
1640
|
+
| os.environ
|
1641
|
+
| {
|
1642
|
+
# TODO: make this a thing we can pass into the engine
|
1643
|
+
"PREFECT__ENABLE_CANCELLATION_AND_CRASHED_HOOKS": "false",
|
1644
|
+
},
|
1645
|
+
flow=flow,
|
1646
|
+
flow_run=flow_run,
|
1647
|
+
parameters=parameters,
|
1648
|
+
wait_for=wait_for,
|
1649
|
+
context=context,
|
1650
|
+
),
|
1651
|
+
)
|
1652
|
+
process.start()
|
1653
|
+
|
1654
|
+
return process
|