prefect-client 2.20.4__py3-none-any.whl → 3.0.0__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/__init__.py +74 -110
- prefect/_internal/compatibility/deprecated.py +6 -115
- prefect/_internal/compatibility/experimental.py +4 -79
- prefect/_internal/compatibility/migration.py +166 -0
- prefect/_internal/concurrency/__init__.py +2 -2
- prefect/_internal/concurrency/api.py +1 -35
- prefect/_internal/concurrency/calls.py +0 -6
- prefect/_internal/concurrency/cancellation.py +0 -3
- prefect/_internal/concurrency/event_loop.py +0 -20
- prefect/_internal/concurrency/inspection.py +3 -3
- prefect/_internal/concurrency/primitives.py +1 -0
- prefect/_internal/concurrency/services.py +23 -0
- prefect/_internal/concurrency/threads.py +35 -0
- prefect/_internal/concurrency/waiters.py +0 -28
- prefect/_internal/integrations.py +7 -0
- prefect/_internal/pydantic/__init__.py +0 -45
- prefect/_internal/pydantic/annotations/pendulum.py +2 -2
- prefect/_internal/pydantic/v1_schema.py +21 -22
- prefect/_internal/pydantic/v2_schema.py +0 -2
- prefect/_internal/pydantic/v2_validated_func.py +18 -23
- prefect/_internal/pytz.py +1 -1
- prefect/_internal/retries.py +61 -0
- prefect/_internal/schemas/bases.py +45 -177
- prefect/_internal/schemas/fields.py +1 -43
- prefect/_internal/schemas/validators.py +47 -233
- prefect/agent.py +3 -695
- prefect/artifacts.py +173 -14
- prefect/automations.py +39 -4
- prefect/blocks/abstract.py +1 -1
- prefect/blocks/core.py +405 -153
- prefect/blocks/fields.py +2 -57
- prefect/blocks/notifications.py +43 -28
- prefect/blocks/redis.py +168 -0
- prefect/blocks/system.py +67 -20
- prefect/blocks/webhook.py +2 -9
- prefect/cache_policies.py +239 -0
- prefect/client/__init__.py +4 -0
- prefect/client/base.py +33 -27
- prefect/client/cloud.py +65 -20
- prefect/client/collections.py +1 -1
- prefect/client/orchestration.py +650 -442
- prefect/client/schemas/actions.py +115 -100
- prefect/client/schemas/filters.py +46 -52
- prefect/client/schemas/objects.py +228 -178
- prefect/client/schemas/responses.py +18 -36
- prefect/client/schemas/schedules.py +55 -36
- prefect/client/schemas/sorting.py +2 -0
- prefect/client/subscriptions.py +8 -7
- prefect/client/types/flexible_schedule_list.py +11 -0
- prefect/client/utilities.py +9 -6
- prefect/concurrency/asyncio.py +60 -11
- prefect/concurrency/context.py +24 -0
- prefect/concurrency/events.py +2 -2
- prefect/concurrency/services.py +46 -16
- prefect/concurrency/sync.py +51 -7
- prefect/concurrency/v1/asyncio.py +143 -0
- prefect/concurrency/v1/context.py +27 -0
- prefect/concurrency/v1/events.py +61 -0
- prefect/concurrency/v1/services.py +116 -0
- prefect/concurrency/v1/sync.py +92 -0
- prefect/context.py +246 -149
- prefect/deployments/__init__.py +33 -18
- prefect/deployments/base.py +10 -15
- prefect/deployments/deployments.py +2 -1048
- prefect/deployments/flow_runs.py +178 -0
- prefect/deployments/runner.py +72 -173
- prefect/deployments/schedules.py +31 -25
- prefect/deployments/steps/__init__.py +0 -1
- prefect/deployments/steps/core.py +7 -0
- prefect/deployments/steps/pull.py +15 -21
- prefect/deployments/steps/utility.py +2 -1
- prefect/docker/__init__.py +20 -0
- prefect/docker/docker_image.py +82 -0
- prefect/engine.py +15 -2475
- prefect/events/actions.py +17 -23
- prefect/events/cli/automations.py +20 -7
- prefect/events/clients.py +142 -80
- prefect/events/filters.py +14 -18
- prefect/events/related.py +74 -75
- prefect/events/schemas/__init__.py +0 -5
- prefect/events/schemas/automations.py +55 -46
- prefect/events/schemas/deployment_triggers.py +7 -197
- prefect/events/schemas/events.py +46 -65
- prefect/events/schemas/labelling.py +10 -14
- prefect/events/utilities.py +4 -5
- prefect/events/worker.py +23 -8
- prefect/exceptions.py +15 -0
- prefect/filesystems.py +30 -529
- prefect/flow_engine.py +827 -0
- prefect/flow_runs.py +379 -7
- prefect/flows.py +470 -360
- prefect/futures.py +382 -331
- prefect/infrastructure/__init__.py +5 -26
- prefect/infrastructure/base.py +3 -320
- prefect/infrastructure/provisioners/__init__.py +5 -3
- prefect/infrastructure/provisioners/cloud_run.py +13 -8
- prefect/infrastructure/provisioners/container_instance.py +14 -9
- prefect/infrastructure/provisioners/ecs.py +10 -8
- prefect/infrastructure/provisioners/modal.py +8 -5
- prefect/input/__init__.py +4 -0
- prefect/input/actions.py +2 -4
- prefect/input/run_input.py +9 -9
- prefect/logging/formatters.py +2 -4
- prefect/logging/handlers.py +9 -14
- prefect/logging/loggers.py +5 -5
- prefect/main.py +72 -0
- prefect/plugins.py +2 -64
- prefect/profiles.toml +16 -2
- prefect/records/__init__.py +1 -0
- prefect/records/base.py +223 -0
- prefect/records/filesystem.py +207 -0
- prefect/records/memory.py +178 -0
- prefect/records/result_store.py +64 -0
- prefect/results.py +577 -504
- prefect/runner/runner.py +117 -47
- prefect/runner/server.py +32 -34
- prefect/runner/storage.py +3 -12
- prefect/runner/submit.py +2 -10
- prefect/runner/utils.py +2 -2
- prefect/runtime/__init__.py +1 -0
- prefect/runtime/deployment.py +1 -0
- prefect/runtime/flow_run.py +40 -5
- prefect/runtime/task_run.py +1 -0
- prefect/serializers.py +28 -39
- prefect/server/api/collections_data/views/aggregate-worker-metadata.json +5 -14
- prefect/settings.py +209 -332
- prefect/states.py +160 -63
- prefect/task_engine.py +1478 -57
- prefect/task_runners.py +383 -287
- prefect/task_runs.py +240 -0
- prefect/task_worker.py +463 -0
- prefect/tasks.py +684 -374
- prefect/transactions.py +410 -0
- prefect/types/__init__.py +72 -86
- prefect/types/entrypoint.py +13 -0
- prefect/utilities/annotations.py +4 -3
- prefect/utilities/asyncutils.py +227 -148
- prefect/utilities/callables.py +137 -45
- prefect/utilities/collections.py +134 -86
- prefect/utilities/dispatch.py +27 -14
- prefect/utilities/dockerutils.py +11 -4
- prefect/utilities/engine.py +186 -32
- prefect/utilities/filesystem.py +4 -5
- prefect/utilities/importtools.py +26 -27
- prefect/utilities/pydantic.py +128 -38
- prefect/utilities/schema_tools/hydration.py +18 -1
- prefect/utilities/schema_tools/validation.py +30 -0
- prefect/utilities/services.py +35 -9
- prefect/utilities/templating.py +12 -2
- prefect/utilities/timeout.py +20 -5
- prefect/utilities/urls.py +195 -0
- prefect/utilities/visualization.py +1 -0
- prefect/variables.py +78 -59
- prefect/workers/__init__.py +0 -1
- prefect/workers/base.py +237 -244
- prefect/workers/block.py +5 -226
- prefect/workers/cloud.py +6 -0
- prefect/workers/process.py +265 -12
- prefect/workers/server.py +29 -11
- {prefect_client-2.20.4.dist-info → prefect_client-3.0.0.dist-info}/METADATA +28 -24
- prefect_client-3.0.0.dist-info/RECORD +201 -0
- {prefect_client-2.20.4.dist-info → prefect_client-3.0.0.dist-info}/WHEEL +1 -1
- prefect/_internal/pydantic/_base_model.py +0 -51
- prefect/_internal/pydantic/_compat.py +0 -82
- prefect/_internal/pydantic/_flags.py +0 -20
- prefect/_internal/pydantic/_types.py +0 -8
- prefect/_internal/pydantic/utilities/config_dict.py +0 -72
- prefect/_internal/pydantic/utilities/field_validator.py +0 -150
- prefect/_internal/pydantic/utilities/model_construct.py +0 -56
- prefect/_internal/pydantic/utilities/model_copy.py +0 -55
- prefect/_internal/pydantic/utilities/model_dump.py +0 -136
- prefect/_internal/pydantic/utilities/model_dump_json.py +0 -112
- prefect/_internal/pydantic/utilities/model_fields.py +0 -50
- prefect/_internal/pydantic/utilities/model_fields_set.py +0 -29
- prefect/_internal/pydantic/utilities/model_json_schema.py +0 -82
- prefect/_internal/pydantic/utilities/model_rebuild.py +0 -80
- prefect/_internal/pydantic/utilities/model_validate.py +0 -75
- prefect/_internal/pydantic/utilities/model_validate_json.py +0 -68
- prefect/_internal/pydantic/utilities/model_validator.py +0 -87
- prefect/_internal/pydantic/utilities/type_adapter.py +0 -71
- prefect/_vendor/fastapi/__init__.py +0 -25
- prefect/_vendor/fastapi/applications.py +0 -946
- prefect/_vendor/fastapi/background.py +0 -3
- prefect/_vendor/fastapi/concurrency.py +0 -44
- prefect/_vendor/fastapi/datastructures.py +0 -58
- prefect/_vendor/fastapi/dependencies/__init__.py +0 -0
- prefect/_vendor/fastapi/dependencies/models.py +0 -64
- prefect/_vendor/fastapi/dependencies/utils.py +0 -877
- prefect/_vendor/fastapi/encoders.py +0 -177
- prefect/_vendor/fastapi/exception_handlers.py +0 -40
- prefect/_vendor/fastapi/exceptions.py +0 -46
- prefect/_vendor/fastapi/logger.py +0 -3
- prefect/_vendor/fastapi/middleware/__init__.py +0 -1
- prefect/_vendor/fastapi/middleware/asyncexitstack.py +0 -25
- prefect/_vendor/fastapi/middleware/cors.py +0 -3
- prefect/_vendor/fastapi/middleware/gzip.py +0 -3
- prefect/_vendor/fastapi/middleware/httpsredirect.py +0 -3
- prefect/_vendor/fastapi/middleware/trustedhost.py +0 -3
- prefect/_vendor/fastapi/middleware/wsgi.py +0 -3
- prefect/_vendor/fastapi/openapi/__init__.py +0 -0
- prefect/_vendor/fastapi/openapi/constants.py +0 -2
- prefect/_vendor/fastapi/openapi/docs.py +0 -203
- prefect/_vendor/fastapi/openapi/models.py +0 -480
- prefect/_vendor/fastapi/openapi/utils.py +0 -485
- prefect/_vendor/fastapi/param_functions.py +0 -340
- prefect/_vendor/fastapi/params.py +0 -453
- prefect/_vendor/fastapi/py.typed +0 -0
- prefect/_vendor/fastapi/requests.py +0 -4
- prefect/_vendor/fastapi/responses.py +0 -40
- prefect/_vendor/fastapi/routing.py +0 -1331
- prefect/_vendor/fastapi/security/__init__.py +0 -15
- prefect/_vendor/fastapi/security/api_key.py +0 -98
- prefect/_vendor/fastapi/security/base.py +0 -6
- prefect/_vendor/fastapi/security/http.py +0 -172
- prefect/_vendor/fastapi/security/oauth2.py +0 -227
- prefect/_vendor/fastapi/security/open_id_connect_url.py +0 -34
- prefect/_vendor/fastapi/security/utils.py +0 -10
- prefect/_vendor/fastapi/staticfiles.py +0 -1
- prefect/_vendor/fastapi/templating.py +0 -3
- prefect/_vendor/fastapi/testclient.py +0 -1
- prefect/_vendor/fastapi/types.py +0 -3
- prefect/_vendor/fastapi/utils.py +0 -235
- prefect/_vendor/fastapi/websockets.py +0 -7
- prefect/_vendor/starlette/__init__.py +0 -1
- prefect/_vendor/starlette/_compat.py +0 -28
- prefect/_vendor/starlette/_exception_handler.py +0 -80
- prefect/_vendor/starlette/_utils.py +0 -88
- prefect/_vendor/starlette/applications.py +0 -261
- prefect/_vendor/starlette/authentication.py +0 -159
- prefect/_vendor/starlette/background.py +0 -43
- prefect/_vendor/starlette/concurrency.py +0 -59
- prefect/_vendor/starlette/config.py +0 -151
- prefect/_vendor/starlette/convertors.py +0 -87
- prefect/_vendor/starlette/datastructures.py +0 -707
- prefect/_vendor/starlette/endpoints.py +0 -130
- prefect/_vendor/starlette/exceptions.py +0 -60
- prefect/_vendor/starlette/formparsers.py +0 -276
- prefect/_vendor/starlette/middleware/__init__.py +0 -17
- prefect/_vendor/starlette/middleware/authentication.py +0 -52
- prefect/_vendor/starlette/middleware/base.py +0 -220
- prefect/_vendor/starlette/middleware/cors.py +0 -176
- prefect/_vendor/starlette/middleware/errors.py +0 -265
- prefect/_vendor/starlette/middleware/exceptions.py +0 -74
- prefect/_vendor/starlette/middleware/gzip.py +0 -113
- prefect/_vendor/starlette/middleware/httpsredirect.py +0 -19
- prefect/_vendor/starlette/middleware/sessions.py +0 -82
- prefect/_vendor/starlette/middleware/trustedhost.py +0 -64
- prefect/_vendor/starlette/middleware/wsgi.py +0 -147
- prefect/_vendor/starlette/py.typed +0 -0
- prefect/_vendor/starlette/requests.py +0 -328
- prefect/_vendor/starlette/responses.py +0 -347
- prefect/_vendor/starlette/routing.py +0 -933
- prefect/_vendor/starlette/schemas.py +0 -154
- prefect/_vendor/starlette/staticfiles.py +0 -248
- prefect/_vendor/starlette/status.py +0 -199
- prefect/_vendor/starlette/templating.py +0 -231
- prefect/_vendor/starlette/testclient.py +0 -804
- prefect/_vendor/starlette/types.py +0 -30
- prefect/_vendor/starlette/websockets.py +0 -193
- prefect/blocks/kubernetes.py +0 -119
- prefect/deprecated/__init__.py +0 -0
- prefect/deprecated/data_documents.py +0 -350
- prefect/deprecated/packaging/__init__.py +0 -12
- prefect/deprecated/packaging/base.py +0 -96
- prefect/deprecated/packaging/docker.py +0 -146
- prefect/deprecated/packaging/file.py +0 -92
- prefect/deprecated/packaging/orion.py +0 -80
- prefect/deprecated/packaging/serializers.py +0 -171
- prefect/events/instrument.py +0 -135
- prefect/infrastructure/container.py +0 -824
- prefect/infrastructure/kubernetes.py +0 -920
- prefect/infrastructure/process.py +0 -289
- prefect/manifests.py +0 -20
- prefect/new_flow_engine.py +0 -449
- prefect/new_task_engine.py +0 -423
- prefect/pydantic/__init__.py +0 -76
- prefect/pydantic/main.py +0 -39
- prefect/software/__init__.py +0 -2
- prefect/software/base.py +0 -50
- prefect/software/conda.py +0 -199
- prefect/software/pip.py +0 -122
- prefect/software/python.py +0 -52
- prefect/task_server.py +0 -322
- prefect_client-2.20.4.dist-info/RECORD +0 -294
- /prefect/{_internal/pydantic/utilities → client/types}/__init__.py +0 -0
- /prefect/{_vendor → concurrency/v1}/__init__.py +0 -0
- {prefect_client-2.20.4.dist-info → prefect_client-3.0.0.dist-info}/LICENSE +0 -0
- {prefect_client-2.20.4.dist-info → prefect_client-3.0.0.dist-info}/top_level.txt +0 -0
prefect/concurrency/services.py
CHANGED
@@ -2,39 +2,53 @@ import asyncio
|
|
2
2
|
import concurrent.futures
|
3
3
|
from contextlib import asynccontextmanager
|
4
4
|
from typing import (
|
5
|
+
TYPE_CHECKING,
|
6
|
+
AsyncGenerator,
|
5
7
|
FrozenSet,
|
6
8
|
Optional,
|
7
9
|
Tuple,
|
8
10
|
)
|
9
11
|
|
10
12
|
import httpx
|
11
|
-
from
|
13
|
+
from starlette import status
|
12
14
|
|
13
|
-
from prefect import get_client
|
14
15
|
from prefect._internal.concurrency import logger
|
15
16
|
from prefect._internal.concurrency.services import QueueService
|
16
|
-
from prefect.client.orchestration import
|
17
|
+
from prefect.client.orchestration import get_client
|
17
18
|
from prefect.utilities.timeout import timeout_async
|
18
19
|
|
20
|
+
if TYPE_CHECKING:
|
21
|
+
from prefect.client.orchestration import PrefectClient
|
22
|
+
|
19
23
|
|
20
24
|
class ConcurrencySlotAcquisitionService(QueueService):
|
21
25
|
def __init__(self, concurrency_limit_names: FrozenSet[str]):
|
22
26
|
super().__init__(concurrency_limit_names)
|
23
|
-
self._client: PrefectClient
|
27
|
+
self._client: "PrefectClient"
|
24
28
|
self.concurrency_limit_names = sorted(list(concurrency_limit_names))
|
25
29
|
|
26
30
|
@asynccontextmanager
|
27
|
-
async def _lifespan(self):
|
31
|
+
async def _lifespan(self) -> AsyncGenerator[None, None]:
|
28
32
|
async with get_client() as client:
|
29
33
|
self._client = client
|
30
34
|
yield
|
31
35
|
|
32
36
|
async def _handle(
|
33
|
-
self,
|
34
|
-
|
35
|
-
|
37
|
+
self,
|
38
|
+
item: Tuple[
|
39
|
+
int,
|
40
|
+
str,
|
41
|
+
Optional[float],
|
42
|
+
concurrent.futures.Future,
|
43
|
+
Optional[bool],
|
44
|
+
Optional[int],
|
45
|
+
],
|
46
|
+
) -> None:
|
47
|
+
occupy, mode, timeout_seconds, future, create_if_missing, max_retries = item
|
36
48
|
try:
|
37
|
-
response = await self.acquire_slots(
|
49
|
+
response = await self.acquire_slots(
|
50
|
+
occupy, mode, timeout_seconds, create_if_missing, max_retries
|
51
|
+
)
|
38
52
|
except Exception as exc:
|
39
53
|
# If the request to the increment endpoint fails in a non-standard
|
40
54
|
# way, we need to set the future's result so that the caller can
|
@@ -45,27 +59,41 @@ class ConcurrencySlotAcquisitionService(QueueService):
|
|
45
59
|
future.set_result(response)
|
46
60
|
|
47
61
|
async def acquire_slots(
|
48
|
-
self,
|
49
|
-
|
50
|
-
|
62
|
+
self,
|
63
|
+
slots: int,
|
64
|
+
mode: str,
|
65
|
+
timeout_seconds: Optional[float] = None,
|
66
|
+
create_if_missing: Optional[bool] = False,
|
67
|
+
max_retries: Optional[int] = None,
|
68
|
+
) -> httpx.Response:
|
69
|
+
with timeout_async(seconds=timeout_seconds):
|
51
70
|
while True:
|
52
71
|
try:
|
53
72
|
response = await self._client.increment_concurrency_slots(
|
54
|
-
names=self.concurrency_limit_names,
|
73
|
+
names=self.concurrency_limit_names,
|
74
|
+
slots=slots,
|
75
|
+
mode=mode,
|
76
|
+
create_if_missing=create_if_missing,
|
55
77
|
)
|
56
78
|
except Exception as exc:
|
57
79
|
if (
|
58
80
|
isinstance(exc, httpx.HTTPStatusError)
|
59
81
|
and exc.response.status_code == status.HTTP_423_LOCKED
|
60
82
|
):
|
83
|
+
if max_retries is not None and max_retries <= 0:
|
84
|
+
raise exc
|
61
85
|
retry_after = float(exc.response.headers["Retry-After"])
|
62
86
|
await asyncio.sleep(retry_after)
|
87
|
+
if max_retries is not None:
|
88
|
+
max_retries -= 1
|
63
89
|
else:
|
64
90
|
raise exc
|
65
91
|
else:
|
66
92
|
return response
|
67
93
|
|
68
|
-
def send(
|
94
|
+
def send(
|
95
|
+
self, item: Tuple[int, str, Optional[float], Optional[bool], Optional[int]]
|
96
|
+
) -> concurrent.futures.Future:
|
69
97
|
with self._lock:
|
70
98
|
if self._stopped:
|
71
99
|
raise RuntimeError("Cannot put items in a stopped service instance.")
|
@@ -73,7 +101,9 @@ class ConcurrencySlotAcquisitionService(QueueService):
|
|
73
101
|
logger.debug("Service %r enqueuing item %r", self, item)
|
74
102
|
future: concurrent.futures.Future = concurrent.futures.Future()
|
75
103
|
|
76
|
-
occupy, mode, timeout_seconds = item
|
77
|
-
self._queue.put_nowait(
|
104
|
+
occupy, mode, timeout_seconds, create_if_missing, max_retries = item
|
105
|
+
self._queue.put_nowait(
|
106
|
+
(occupy, mode, timeout_seconds, future, create_if_missing, max_retries)
|
107
|
+
)
|
78
108
|
|
79
109
|
return future
|
prefect/concurrency/sync.py
CHANGED
@@ -1,5 +1,15 @@
|
|
1
1
|
from contextlib import contextmanager
|
2
|
-
from typing import
|
2
|
+
from typing import (
|
3
|
+
Any,
|
4
|
+
Awaitable,
|
5
|
+
Callable,
|
6
|
+
Generator,
|
7
|
+
List,
|
8
|
+
Optional,
|
9
|
+
TypeVar,
|
10
|
+
Union,
|
11
|
+
cast,
|
12
|
+
)
|
3
13
|
|
4
14
|
import pendulum
|
5
15
|
|
@@ -22,13 +32,17 @@ from .events import (
|
|
22
32
|
_emit_concurrency_release_events,
|
23
33
|
)
|
24
34
|
|
35
|
+
T = TypeVar("T")
|
36
|
+
|
25
37
|
|
26
38
|
@contextmanager
|
27
39
|
def concurrency(
|
28
40
|
names: Union[str, List[str]],
|
29
41
|
occupy: int = 1,
|
30
42
|
timeout_seconds: Optional[float] = None,
|
31
|
-
|
43
|
+
create_if_missing: bool = True,
|
44
|
+
max_retries: Optional[int] = None,
|
45
|
+
) -> Generator[None, None, None]:
|
32
46
|
"""A context manager that acquires and releases concurrency slots from the
|
33
47
|
given concurrency limits.
|
34
48
|
|
@@ -37,6 +51,8 @@ def concurrency(
|
|
37
51
|
occupy: The number of slots to acquire and hold from each limit.
|
38
52
|
timeout_seconds: The number of seconds to wait for the slots to be acquired before
|
39
53
|
raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
|
54
|
+
create_if_missing: Whether to create the concurrency limits if they do not exist.
|
55
|
+
max_retries: The maximum number of retries to acquire the concurrency slots.
|
40
56
|
|
41
57
|
Raises:
|
42
58
|
TimeoutError: If the slots are not acquired within the given timeout.
|
@@ -54,10 +70,19 @@ def concurrency(
|
|
54
70
|
resource_heavy()
|
55
71
|
```
|
56
72
|
"""
|
73
|
+
if not names:
|
74
|
+
yield
|
75
|
+
return
|
76
|
+
|
57
77
|
names = names if isinstance(names, list) else [names]
|
58
78
|
|
59
79
|
limits: List[MinimalConcurrencyLimitResponse] = _call_async_function_from_sync(
|
60
|
-
_acquire_concurrency_slots,
|
80
|
+
_acquire_concurrency_slots,
|
81
|
+
names,
|
82
|
+
occupy,
|
83
|
+
timeout_seconds=timeout_seconds,
|
84
|
+
create_if_missing=create_if_missing,
|
85
|
+
max_retries=max_retries,
|
61
86
|
)
|
62
87
|
acquisition_time = pendulum.now("UTC")
|
63
88
|
emitted_events = _emit_concurrency_acquisition_events(limits, occupy)
|
@@ -75,7 +100,12 @@ def concurrency(
|
|
75
100
|
_emit_concurrency_release_events(limits, occupy, emitted_events)
|
76
101
|
|
77
102
|
|
78
|
-
def rate_limit(
|
103
|
+
def rate_limit(
|
104
|
+
names: Union[str, List[str]],
|
105
|
+
occupy: int = 1,
|
106
|
+
timeout_seconds: Optional[float] = None,
|
107
|
+
create_if_missing: Optional[bool] = True,
|
108
|
+
) -> None:
|
79
109
|
"""Block execution until an `occupy` number of slots of the concurrency
|
80
110
|
limits given in `names` are acquired. Requires that all given concurrency
|
81
111
|
limits have a slot decay.
|
@@ -83,19 +113,33 @@ def rate_limit(names: Union[str, List[str]], occupy: int = 1):
|
|
83
113
|
Args:
|
84
114
|
names: The names of the concurrency limits to acquire slots from.
|
85
115
|
occupy: The number of slots to acquire and hold from each limit.
|
116
|
+
timeout_seconds: The number of seconds to wait for the slots to be acquired before
|
117
|
+
raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
|
118
|
+
create_if_missing: Whether to create the concurrency limits if they do not exist.
|
86
119
|
"""
|
120
|
+
if not names:
|
121
|
+
return
|
122
|
+
|
87
123
|
names = names if isinstance(names, list) else [names]
|
124
|
+
|
88
125
|
limits = _call_async_function_from_sync(
|
89
|
-
_acquire_concurrency_slots,
|
126
|
+
_acquire_concurrency_slots,
|
127
|
+
names,
|
128
|
+
occupy,
|
129
|
+
mode="rate_limit",
|
130
|
+
timeout_seconds=timeout_seconds,
|
131
|
+
create_if_missing=create_if_missing,
|
90
132
|
)
|
91
133
|
_emit_concurrency_acquisition_events(limits, occupy)
|
92
134
|
|
93
135
|
|
94
|
-
def _call_async_function_from_sync(
|
136
|
+
def _call_async_function_from_sync(
|
137
|
+
fn: Callable[..., Awaitable[T]], *args: Any, **kwargs: Any
|
138
|
+
) -> T:
|
95
139
|
loop = get_running_loop()
|
96
140
|
call = create_call(fn, *args, **kwargs)
|
97
141
|
|
98
142
|
if loop is not None:
|
99
143
|
return from_sync.call_soon_in_loop_thread(call).result()
|
100
144
|
else:
|
101
|
-
return call()
|
145
|
+
return call() # type: ignore [return-value]
|
@@ -0,0 +1,143 @@
|
|
1
|
+
import asyncio
|
2
|
+
from contextlib import asynccontextmanager
|
3
|
+
from typing import AsyncGenerator, List, Optional, Union, cast
|
4
|
+
from uuid import UUID
|
5
|
+
|
6
|
+
import anyio
|
7
|
+
import httpx
|
8
|
+
import pendulum
|
9
|
+
|
10
|
+
from ...client.schemas.responses import MinimalConcurrencyLimitResponse
|
11
|
+
|
12
|
+
try:
|
13
|
+
from pendulum import Interval
|
14
|
+
except ImportError:
|
15
|
+
# pendulum < 3
|
16
|
+
from pendulum.period import Period as Interval # type: ignore
|
17
|
+
|
18
|
+
from prefect.client.orchestration import get_client
|
19
|
+
|
20
|
+
from .context import ConcurrencyContext
|
21
|
+
from .events import (
|
22
|
+
_emit_concurrency_acquisition_events,
|
23
|
+
_emit_concurrency_release_events,
|
24
|
+
)
|
25
|
+
from .services import ConcurrencySlotAcquisitionService
|
26
|
+
|
27
|
+
|
28
|
+
class ConcurrencySlotAcquisitionError(Exception):
|
29
|
+
"""Raised when an unhandlable occurs while acquiring concurrency slots."""
|
30
|
+
|
31
|
+
|
32
|
+
class AcquireConcurrencySlotTimeoutError(TimeoutError):
|
33
|
+
"""Raised when acquiring a concurrency slot times out."""
|
34
|
+
|
35
|
+
|
36
|
+
@asynccontextmanager
|
37
|
+
async def concurrency(
|
38
|
+
names: Union[str, List[str]],
|
39
|
+
task_run_id: UUID,
|
40
|
+
timeout_seconds: Optional[float] = None,
|
41
|
+
) -> AsyncGenerator[None, None]:
|
42
|
+
"""A context manager that acquires and releases concurrency slots from the
|
43
|
+
given concurrency limits.
|
44
|
+
|
45
|
+
Args:
|
46
|
+
names: The names of the concurrency limits to acquire slots from.
|
47
|
+
task_run_id: The name of the task_run_id that is incrementing the slots.
|
48
|
+
timeout_seconds: The number of seconds to wait for the slots to be acquired before
|
49
|
+
raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
|
50
|
+
|
51
|
+
Raises:
|
52
|
+
TimeoutError: If the slots are not acquired within the given timeout.
|
53
|
+
|
54
|
+
Example:
|
55
|
+
A simple example of using the async `concurrency` context manager:
|
56
|
+
```python
|
57
|
+
from prefect.concurrency.v1.asyncio import concurrency
|
58
|
+
|
59
|
+
async def resource_heavy():
|
60
|
+
async with concurrency("test", task_run_id):
|
61
|
+
print("Resource heavy task")
|
62
|
+
|
63
|
+
async def main():
|
64
|
+
await resource_heavy()
|
65
|
+
```
|
66
|
+
"""
|
67
|
+
if not names:
|
68
|
+
yield
|
69
|
+
return
|
70
|
+
|
71
|
+
names_normalized: List[str] = names if isinstance(names, list) else [names]
|
72
|
+
|
73
|
+
limits = await _acquire_concurrency_slots(
|
74
|
+
names_normalized,
|
75
|
+
task_run_id=task_run_id,
|
76
|
+
timeout_seconds=timeout_seconds,
|
77
|
+
)
|
78
|
+
acquisition_time = pendulum.now("UTC")
|
79
|
+
emitted_events = _emit_concurrency_acquisition_events(limits, task_run_id)
|
80
|
+
|
81
|
+
try:
|
82
|
+
yield
|
83
|
+
finally:
|
84
|
+
occupancy_period = cast(Interval, (pendulum.now("UTC") - acquisition_time))
|
85
|
+
try:
|
86
|
+
await _release_concurrency_slots(
|
87
|
+
names_normalized, task_run_id, occupancy_period.total_seconds()
|
88
|
+
)
|
89
|
+
except anyio.get_cancelled_exc_class():
|
90
|
+
# The task was cancelled before it could release the slots. Add the
|
91
|
+
# slots to the cleanup list so they can be released when the
|
92
|
+
# concurrency context is exited.
|
93
|
+
if ctx := ConcurrencyContext.get():
|
94
|
+
ctx.cleanup_slots.append(
|
95
|
+
(names_normalized, occupancy_period.total_seconds(), task_run_id)
|
96
|
+
)
|
97
|
+
|
98
|
+
_emit_concurrency_release_events(limits, emitted_events, task_run_id)
|
99
|
+
|
100
|
+
|
101
|
+
async def _acquire_concurrency_slots(
|
102
|
+
names: List[str],
|
103
|
+
task_run_id: UUID,
|
104
|
+
timeout_seconds: Optional[float] = None,
|
105
|
+
) -> List[MinimalConcurrencyLimitResponse]:
|
106
|
+
service = ConcurrencySlotAcquisitionService.instance(frozenset(names))
|
107
|
+
future = service.send((task_run_id, timeout_seconds))
|
108
|
+
response_or_exception = await asyncio.wrap_future(future)
|
109
|
+
|
110
|
+
if isinstance(response_or_exception, Exception):
|
111
|
+
if isinstance(response_or_exception, TimeoutError):
|
112
|
+
raise AcquireConcurrencySlotTimeoutError(
|
113
|
+
f"Attempt to acquire concurrency limits timed out after {timeout_seconds} second(s)"
|
114
|
+
) from response_or_exception
|
115
|
+
|
116
|
+
raise ConcurrencySlotAcquisitionError(
|
117
|
+
f"Unable to acquire concurrency limits {names!r}"
|
118
|
+
) from response_or_exception
|
119
|
+
|
120
|
+
return _response_to_concurrency_limit_response(response_or_exception)
|
121
|
+
|
122
|
+
|
123
|
+
async def _release_concurrency_slots(
|
124
|
+
names: List[str],
|
125
|
+
task_run_id: UUID,
|
126
|
+
occupancy_seconds: float,
|
127
|
+
) -> List[MinimalConcurrencyLimitResponse]:
|
128
|
+
async with get_client() as client:
|
129
|
+
response = await client.decrement_v1_concurrency_slots(
|
130
|
+
names=names,
|
131
|
+
task_run_id=task_run_id,
|
132
|
+
occupancy_seconds=occupancy_seconds,
|
133
|
+
)
|
134
|
+
return _response_to_concurrency_limit_response(response)
|
135
|
+
|
136
|
+
|
137
|
+
def _response_to_concurrency_limit_response(
|
138
|
+
response: httpx.Response,
|
139
|
+
) -> List[MinimalConcurrencyLimitResponse]:
|
140
|
+
data = response.json() or []
|
141
|
+
return [
|
142
|
+
MinimalConcurrencyLimitResponse.model_validate(limit) for limit in data if data
|
143
|
+
]
|
@@ -0,0 +1,27 @@
|
|
1
|
+
from contextvars import ContextVar
|
2
|
+
from typing import List, Tuple
|
3
|
+
from uuid import UUID
|
4
|
+
|
5
|
+
from prefect.client.orchestration import get_client
|
6
|
+
from prefect.context import ContextModel, Field
|
7
|
+
|
8
|
+
|
9
|
+
class ConcurrencyContext(ContextModel):
|
10
|
+
__var__: ContextVar = ContextVar("concurrency_v1")
|
11
|
+
|
12
|
+
# Track the limits that have been acquired but were not able to be released
|
13
|
+
# due to cancellation or some other error. These limits are released when
|
14
|
+
# the context manager exits.
|
15
|
+
cleanup_slots: List[Tuple[List[str], float, UUID]] = Field(default_factory=list)
|
16
|
+
|
17
|
+
def __exit__(self, *exc_info):
|
18
|
+
if self.cleanup_slots:
|
19
|
+
with get_client(sync_client=True) as client:
|
20
|
+
for names, occupancy_seconds, task_run_id in self.cleanup_slots:
|
21
|
+
client.decrement_v1_concurrency_slots(
|
22
|
+
names=names,
|
23
|
+
occupancy_seconds=occupancy_seconds,
|
24
|
+
task_run_id=task_run_id,
|
25
|
+
)
|
26
|
+
|
27
|
+
return super().__exit__(*exc_info)
|
@@ -0,0 +1,61 @@
|
|
1
|
+
from typing import Dict, List, Literal, Optional, Union
|
2
|
+
from uuid import UUID
|
3
|
+
|
4
|
+
from prefect.client.schemas.responses import MinimalConcurrencyLimitResponse
|
5
|
+
from prefect.events import Event, RelatedResource, emit_event
|
6
|
+
|
7
|
+
|
8
|
+
def _emit_concurrency_event(
|
9
|
+
phase: Union[Literal["acquired"], Literal["released"]],
|
10
|
+
primary_limit: MinimalConcurrencyLimitResponse,
|
11
|
+
related_limits: List[MinimalConcurrencyLimitResponse],
|
12
|
+
task_run_id: UUID,
|
13
|
+
follows: Union[Event, None] = None,
|
14
|
+
) -> Union[Event, None]:
|
15
|
+
resource: Dict[str, str] = {
|
16
|
+
"prefect.resource.id": f"prefect.concurrency-limit.v1.{primary_limit.id}",
|
17
|
+
"prefect.resource.name": primary_limit.name,
|
18
|
+
"limit": str(primary_limit.limit),
|
19
|
+
"task_run_id": str(task_run_id),
|
20
|
+
}
|
21
|
+
|
22
|
+
related = [
|
23
|
+
RelatedResource.model_validate(
|
24
|
+
{
|
25
|
+
"prefect.resource.id": f"prefect.concurrency-limit.v1.{limit.id}",
|
26
|
+
"prefect.resource.role": "concurrency-limit",
|
27
|
+
}
|
28
|
+
)
|
29
|
+
for limit in related_limits
|
30
|
+
if limit.id != primary_limit.id
|
31
|
+
]
|
32
|
+
|
33
|
+
return emit_event(
|
34
|
+
f"prefect.concurrency-limit.v1.{phase}",
|
35
|
+
resource=resource,
|
36
|
+
related=related,
|
37
|
+
follows=follows,
|
38
|
+
)
|
39
|
+
|
40
|
+
|
41
|
+
def _emit_concurrency_acquisition_events(
|
42
|
+
limits: List[MinimalConcurrencyLimitResponse],
|
43
|
+
task_run_id: UUID,
|
44
|
+
) -> Dict[UUID, Optional[Event]]:
|
45
|
+
events = {}
|
46
|
+
for limit in limits:
|
47
|
+
event = _emit_concurrency_event("acquired", limit, limits, task_run_id)
|
48
|
+
events[limit.id] = event
|
49
|
+
|
50
|
+
return events
|
51
|
+
|
52
|
+
|
53
|
+
def _emit_concurrency_release_events(
|
54
|
+
limits: List[MinimalConcurrencyLimitResponse],
|
55
|
+
events: Dict[UUID, Optional[Event]],
|
56
|
+
task_run_id: UUID,
|
57
|
+
) -> None:
|
58
|
+
for limit in limits:
|
59
|
+
_emit_concurrency_event(
|
60
|
+
"released", limit, limits, task_run_id, events[limit.id]
|
61
|
+
)
|
@@ -0,0 +1,116 @@
|
|
1
|
+
import asyncio
|
2
|
+
import concurrent.futures
|
3
|
+
from contextlib import asynccontextmanager
|
4
|
+
from json import JSONDecodeError
|
5
|
+
from typing import (
|
6
|
+
TYPE_CHECKING,
|
7
|
+
AsyncGenerator,
|
8
|
+
FrozenSet,
|
9
|
+
Optional,
|
10
|
+
Tuple,
|
11
|
+
)
|
12
|
+
from uuid import UUID
|
13
|
+
|
14
|
+
import httpx
|
15
|
+
from starlette import status
|
16
|
+
|
17
|
+
from prefect._internal.concurrency import logger
|
18
|
+
from prefect._internal.concurrency.services import QueueService
|
19
|
+
from prefect.client.orchestration import get_client
|
20
|
+
from prefect.utilities.timeout import timeout_async
|
21
|
+
|
22
|
+
if TYPE_CHECKING:
|
23
|
+
from prefect.client.orchestration import PrefectClient
|
24
|
+
|
25
|
+
|
26
|
+
class ConcurrencySlotAcquisitionServiceError(Exception):
|
27
|
+
"""Raised when an error occurs while acquiring concurrency slots."""
|
28
|
+
|
29
|
+
|
30
|
+
class ConcurrencySlotAcquisitionService(QueueService):
|
31
|
+
def __init__(self, concurrency_limit_names: FrozenSet[str]):
|
32
|
+
super().__init__(concurrency_limit_names)
|
33
|
+
self._client: "PrefectClient"
|
34
|
+
self.concurrency_limit_names = sorted(list(concurrency_limit_names))
|
35
|
+
|
36
|
+
@asynccontextmanager
|
37
|
+
async def _lifespan(self) -> AsyncGenerator[None, None]:
|
38
|
+
async with get_client() as client:
|
39
|
+
self._client = client
|
40
|
+
yield
|
41
|
+
|
42
|
+
async def _handle(
|
43
|
+
self,
|
44
|
+
item: Tuple[
|
45
|
+
UUID,
|
46
|
+
concurrent.futures.Future,
|
47
|
+
Optional[float],
|
48
|
+
],
|
49
|
+
) -> None:
|
50
|
+
task_run_id, future, timeout_seconds = item
|
51
|
+
try:
|
52
|
+
response = await self.acquire_slots(task_run_id, timeout_seconds)
|
53
|
+
except Exception as exc:
|
54
|
+
# If the request to the increment endpoint fails in a non-standard
|
55
|
+
# way, we need to set the future's result so that the caller can
|
56
|
+
# handle the exception and then re-raise.
|
57
|
+
future.set_result(exc)
|
58
|
+
raise exc
|
59
|
+
else:
|
60
|
+
future.set_result(response)
|
61
|
+
|
62
|
+
async def acquire_slots(
|
63
|
+
self,
|
64
|
+
task_run_id: UUID,
|
65
|
+
timeout_seconds: Optional[float] = None,
|
66
|
+
) -> httpx.Response:
|
67
|
+
with timeout_async(seconds=timeout_seconds):
|
68
|
+
while True:
|
69
|
+
try:
|
70
|
+
response = await self._client.increment_v1_concurrency_slots(
|
71
|
+
task_run_id=task_run_id,
|
72
|
+
names=self.concurrency_limit_names,
|
73
|
+
)
|
74
|
+
except Exception as exc:
|
75
|
+
if (
|
76
|
+
isinstance(exc, httpx.HTTPStatusError)
|
77
|
+
and exc.response.status_code == status.HTTP_423_LOCKED
|
78
|
+
):
|
79
|
+
retry_after = exc.response.headers.get("Retry-After")
|
80
|
+
if retry_after:
|
81
|
+
retry_after = float(retry_after)
|
82
|
+
await asyncio.sleep(retry_after)
|
83
|
+
else:
|
84
|
+
# We received a 423 but no Retry-After header. This
|
85
|
+
# should indicate that the server told us to abort
|
86
|
+
# because the concurrency limit is set to 0, i.e.
|
87
|
+
# effectively disabled.
|
88
|
+
try:
|
89
|
+
reason = exc.response.json()["detail"]
|
90
|
+
except (JSONDecodeError, KeyError):
|
91
|
+
logger.error(
|
92
|
+
"Failed to parse response from concurrency limit 423 Locked response: %s",
|
93
|
+
exc.response.content,
|
94
|
+
)
|
95
|
+
reason = "Concurrency limit is locked (server did not specify the reason)"
|
96
|
+
raise ConcurrencySlotAcquisitionServiceError(
|
97
|
+
reason
|
98
|
+
) from exc
|
99
|
+
|
100
|
+
else:
|
101
|
+
raise exc # type: ignore
|
102
|
+
else:
|
103
|
+
return response
|
104
|
+
|
105
|
+
def send(self, item: Tuple[UUID, Optional[float]]) -> concurrent.futures.Future:
|
106
|
+
with self._lock:
|
107
|
+
if self._stopped:
|
108
|
+
raise RuntimeError("Cannot put items in a stopped service instance.")
|
109
|
+
|
110
|
+
logger.debug("Service %r enqueuing item %r", self, item)
|
111
|
+
future: concurrent.futures.Future = concurrent.futures.Future()
|
112
|
+
|
113
|
+
task_run_id, timeout_seconds = item
|
114
|
+
self._queue.put_nowait((task_run_id, future, timeout_seconds))
|
115
|
+
|
116
|
+
return future
|
@@ -0,0 +1,92 @@
|
|
1
|
+
from contextlib import contextmanager
|
2
|
+
from typing import (
|
3
|
+
Generator,
|
4
|
+
List,
|
5
|
+
Optional,
|
6
|
+
TypeVar,
|
7
|
+
Union,
|
8
|
+
cast,
|
9
|
+
)
|
10
|
+
from uuid import UUID
|
11
|
+
|
12
|
+
import pendulum
|
13
|
+
|
14
|
+
from ...client.schemas.responses import MinimalConcurrencyLimitResponse
|
15
|
+
from ..sync import _call_async_function_from_sync
|
16
|
+
|
17
|
+
try:
|
18
|
+
from pendulum import Interval
|
19
|
+
except ImportError:
|
20
|
+
# pendulum < 3
|
21
|
+
from pendulum.period import Period as Interval # type: ignore
|
22
|
+
|
23
|
+
from .asyncio import (
|
24
|
+
_acquire_concurrency_slots,
|
25
|
+
_release_concurrency_slots,
|
26
|
+
)
|
27
|
+
from .events import (
|
28
|
+
_emit_concurrency_acquisition_events,
|
29
|
+
_emit_concurrency_release_events,
|
30
|
+
)
|
31
|
+
|
32
|
+
T = TypeVar("T")
|
33
|
+
|
34
|
+
|
35
|
+
@contextmanager
|
36
|
+
def concurrency(
|
37
|
+
names: Union[str, List[str]],
|
38
|
+
task_run_id: UUID,
|
39
|
+
timeout_seconds: Optional[float] = None,
|
40
|
+
) -> Generator[None, None, None]:
|
41
|
+
"""
|
42
|
+
A context manager that acquires and releases concurrency slots from the
|
43
|
+
given concurrency limits.
|
44
|
+
|
45
|
+
Args:
|
46
|
+
names: The names of the concurrency limits to acquire.
|
47
|
+
task_run_id: The task run ID acquiring the limits.
|
48
|
+
timeout_seconds: The number of seconds to wait to acquire the limits before
|
49
|
+
raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
|
50
|
+
|
51
|
+
Raises:
|
52
|
+
TimeoutError: If the limits are not acquired within the given timeout.
|
53
|
+
|
54
|
+
Example:
|
55
|
+
A simple example of using the sync `concurrency` context manager:
|
56
|
+
```python
|
57
|
+
from prefect.concurrency.v1.sync import concurrency
|
58
|
+
|
59
|
+
def resource_heavy():
|
60
|
+
with concurrency("test"):
|
61
|
+
print("Resource heavy task")
|
62
|
+
|
63
|
+
def main():
|
64
|
+
resource_heavy()
|
65
|
+
```
|
66
|
+
"""
|
67
|
+
if not names:
|
68
|
+
yield
|
69
|
+
return
|
70
|
+
|
71
|
+
names = names if isinstance(names, list) else [names]
|
72
|
+
|
73
|
+
limits: List[MinimalConcurrencyLimitResponse] = _call_async_function_from_sync(
|
74
|
+
_acquire_concurrency_slots,
|
75
|
+
names,
|
76
|
+
timeout_seconds=timeout_seconds,
|
77
|
+
task_run_id=task_run_id,
|
78
|
+
)
|
79
|
+
acquisition_time = pendulum.now("UTC")
|
80
|
+
emitted_events = _emit_concurrency_acquisition_events(limits, task_run_id)
|
81
|
+
|
82
|
+
try:
|
83
|
+
yield
|
84
|
+
finally:
|
85
|
+
occupancy_period = cast(Interval, pendulum.now("UTC") - acquisition_time)
|
86
|
+
_call_async_function_from_sync(
|
87
|
+
_release_concurrency_slots,
|
88
|
+
names,
|
89
|
+
task_run_id,
|
90
|
+
occupancy_period.total_seconds(),
|
91
|
+
)
|
92
|
+
_emit_concurrency_release_events(limits, emitted_events, task_run_id)
|