prefect-client 3.0.0rc12__py3-none-any.whl → 3.0.0rc14__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/blocks/core.py +132 -4
- prefect/blocks/notifications.py +26 -3
- prefect/client/base.py +30 -24
- prefect/client/orchestration.py +121 -47
- prefect/client/utilities.py +4 -4
- prefect/concurrency/asyncio.py +48 -7
- prefect/concurrency/context.py +24 -0
- prefect/concurrency/services.py +24 -8
- prefect/concurrency/sync.py +30 -3
- prefect/context.py +83 -23
- prefect/events/clients.py +59 -4
- prefect/events/worker.py +9 -2
- prefect/flow_engine.py +6 -3
- prefect/flows.py +166 -8
- prefect/futures.py +84 -2
- prefect/profiles.toml +13 -2
- prefect/runner/runner.py +6 -1
- prefect/settings.py +35 -7
- prefect/task_engine.py +870 -291
- prefect/task_runs.py +24 -1
- prefect/task_worker.py +27 -16
- prefect/utilities/callables.py +5 -3
- prefect/utilities/importtools.py +138 -58
- prefect/utilities/schema_tools/validation.py +30 -0
- prefect/utilities/services.py +32 -0
- {prefect_client-3.0.0rc12.dist-info → prefect_client-3.0.0rc14.dist-info}/METADATA +2 -1
- {prefect_client-3.0.0rc12.dist-info → prefect_client-3.0.0rc14.dist-info}/RECORD +30 -29
- {prefect_client-3.0.0rc12.dist-info → prefect_client-3.0.0rc14.dist-info}/LICENSE +0 -0
- {prefect_client-3.0.0rc12.dist-info → prefect_client-3.0.0rc14.dist-info}/WHEEL +0 -0
- {prefect_client-3.0.0rc12.dist-info → prefect_client-3.0.0rc14.dist-info}/top_level.txt +0 -0
prefect/concurrency/asyncio.py
CHANGED
@@ -2,6 +2,7 @@ import asyncio
|
|
2
2
|
from contextlib import asynccontextmanager
|
3
3
|
from typing import AsyncGenerator, List, Literal, Optional, Union, cast
|
4
4
|
|
5
|
+
import anyio
|
5
6
|
import httpx
|
6
7
|
import pendulum
|
7
8
|
|
@@ -14,6 +15,7 @@ except ImportError:
|
|
14
15
|
from prefect.client.orchestration import get_client
|
15
16
|
from prefect.client.schemas.responses import MinimalConcurrencyLimitResponse
|
16
17
|
|
18
|
+
from .context import ConcurrencyContext
|
17
19
|
from .events import (
|
18
20
|
_emit_concurrency_acquisition_events,
|
19
21
|
_emit_concurrency_release_events,
|
@@ -34,6 +36,7 @@ async def concurrency(
|
|
34
36
|
names: Union[str, List[str]],
|
35
37
|
occupy: int = 1,
|
36
38
|
timeout_seconds: Optional[float] = None,
|
39
|
+
create_if_missing: Optional[bool] = True,
|
37
40
|
) -> AsyncGenerator[None, None]:
|
38
41
|
"""A context manager that acquires and releases concurrency slots from the
|
39
42
|
given concurrency limits.
|
@@ -43,6 +46,7 @@ async def concurrency(
|
|
43
46
|
occupy: The number of slots to acquire and hold from each limit.
|
44
47
|
timeout_seconds: The number of seconds to wait for the slots to be acquired before
|
45
48
|
raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
|
49
|
+
create_if_missing: Whether to create the concurrency limits if they do not exist.
|
46
50
|
|
47
51
|
Raises:
|
48
52
|
TimeoutError: If the slots are not acquired within the given timeout.
|
@@ -60,9 +64,17 @@ async def concurrency(
|
|
60
64
|
await resource_heavy()
|
61
65
|
```
|
62
66
|
"""
|
67
|
+
if not names:
|
68
|
+
yield
|
69
|
+
return
|
70
|
+
|
63
71
|
names = names if isinstance(names, list) else [names]
|
72
|
+
|
64
73
|
limits = await _acquire_concurrency_slots(
|
65
|
-
names,
|
74
|
+
names,
|
75
|
+
occupy,
|
76
|
+
timeout_seconds=timeout_seconds,
|
77
|
+
create_if_missing=create_if_missing,
|
66
78
|
)
|
67
79
|
acquisition_time = pendulum.now("UTC")
|
68
80
|
emitted_events = _emit_concurrency_acquisition_events(limits, occupy)
|
@@ -71,13 +83,28 @@ async def concurrency(
|
|
71
83
|
yield
|
72
84
|
finally:
|
73
85
|
occupancy_period = cast(Interval, (pendulum.now("UTC") - acquisition_time))
|
74
|
-
|
75
|
-
|
76
|
-
|
86
|
+
try:
|
87
|
+
await _release_concurrency_slots(
|
88
|
+
names, occupy, occupancy_period.total_seconds()
|
89
|
+
)
|
90
|
+
except anyio.get_cancelled_exc_class():
|
91
|
+
# The task was cancelled before it could release the slots. Add the
|
92
|
+
# slots to the cleanup list so they can be released when the
|
93
|
+
# concurrency context is exited.
|
94
|
+
if ctx := ConcurrencyContext.get():
|
95
|
+
ctx.cleanup_slots.append(
|
96
|
+
(names, occupy, occupancy_period.total_seconds())
|
97
|
+
)
|
98
|
+
|
77
99
|
_emit_concurrency_release_events(limits, occupy, emitted_events)
|
78
100
|
|
79
101
|
|
80
|
-
async def rate_limit(
|
102
|
+
async def rate_limit(
|
103
|
+
names: Union[str, List[str]],
|
104
|
+
occupy: int = 1,
|
105
|
+
timeout_seconds: Optional[float] = None,
|
106
|
+
create_if_missing: Optional[bool] = True,
|
107
|
+
) -> None:
|
81
108
|
"""Block execution until an `occupy` number of slots of the concurrency
|
82
109
|
limits given in `names` are acquired. Requires that all given concurrency
|
83
110
|
limits have a slot decay.
|
@@ -85,9 +112,22 @@ async def rate_limit(names: Union[str, List[str]], occupy: int = 1) -> None:
|
|
85
112
|
Args:
|
86
113
|
names: The names of the concurrency limits to acquire slots from.
|
87
114
|
occupy: The number of slots to acquire and hold from each limit.
|
115
|
+
timeout_seconds: The number of seconds to wait for the slots to be acquired before
|
116
|
+
raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
|
117
|
+
create_if_missing: Whether to create the concurrency limits if they do not exist.
|
88
118
|
"""
|
119
|
+
if not names:
|
120
|
+
return
|
121
|
+
|
89
122
|
names = names if isinstance(names, list) else [names]
|
90
|
-
|
123
|
+
|
124
|
+
limits = await _acquire_concurrency_slots(
|
125
|
+
names,
|
126
|
+
occupy,
|
127
|
+
mode="rate_limit",
|
128
|
+
timeout_seconds=timeout_seconds,
|
129
|
+
create_if_missing=create_if_missing,
|
130
|
+
)
|
91
131
|
_emit_concurrency_acquisition_events(limits, occupy)
|
92
132
|
|
93
133
|
|
@@ -96,9 +136,10 @@ async def _acquire_concurrency_slots(
|
|
96
136
|
slots: int,
|
97
137
|
mode: Union[Literal["concurrency"], Literal["rate_limit"]] = "concurrency",
|
98
138
|
timeout_seconds: Optional[float] = None,
|
139
|
+
create_if_missing: Optional[bool] = True,
|
99
140
|
) -> List[MinimalConcurrencyLimitResponse]:
|
100
141
|
service = ConcurrencySlotAcquisitionService.instance(frozenset(names))
|
101
|
-
future = service.send((slots, mode, timeout_seconds))
|
142
|
+
future = service.send((slots, mode, timeout_seconds, create_if_missing))
|
102
143
|
response_or_exception = await asyncio.wrap_future(future)
|
103
144
|
|
104
145
|
if isinstance(response_or_exception, Exception):
|
@@ -0,0 +1,24 @@
|
|
1
|
+
from contextvars import ContextVar
|
2
|
+
from typing import List, Tuple
|
3
|
+
|
4
|
+
from prefect.client.orchestration import get_client
|
5
|
+
from prefect.context import ContextModel, Field
|
6
|
+
|
7
|
+
|
8
|
+
class ConcurrencyContext(ContextModel):
|
9
|
+
__var__: ContextVar = ContextVar("concurrency")
|
10
|
+
|
11
|
+
# Track the slots that have been acquired but were not able to be released
|
12
|
+
# due to cancellation or some other error. These slots are released when
|
13
|
+
# the context manager exits.
|
14
|
+
cleanup_slots: List[Tuple[List[str], int, float]] = Field(default_factory=list)
|
15
|
+
|
16
|
+
def __exit__(self, *exc_info):
|
17
|
+
if self.cleanup_slots:
|
18
|
+
with get_client(sync_client=True) as client:
|
19
|
+
for names, occupy, occupancy_seconds in self.cleanup_slots:
|
20
|
+
client.release_concurrency_slots(
|
21
|
+
names=names, slots=occupy, occupancy_seconds=occupancy_seconds
|
22
|
+
)
|
23
|
+
|
24
|
+
return super().__exit__(*exc_info)
|
prefect/concurrency/services.py
CHANGED
@@ -34,11 +34,16 @@ class ConcurrencySlotAcquisitionService(QueueService):
|
|
34
34
|
yield
|
35
35
|
|
36
36
|
async def _handle(
|
37
|
-
self,
|
37
|
+
self,
|
38
|
+
item: Tuple[
|
39
|
+
int, str, Optional[float], concurrent.futures.Future, Optional[bool]
|
40
|
+
],
|
38
41
|
) -> None:
|
39
|
-
occupy, mode, timeout_seconds, future = item
|
42
|
+
occupy, mode, timeout_seconds, future, create_if_missing = item
|
40
43
|
try:
|
41
|
-
response = await self.acquire_slots(
|
44
|
+
response = await self.acquire_slots(
|
45
|
+
occupy, mode, timeout_seconds, create_if_missing
|
46
|
+
)
|
42
47
|
except Exception as exc:
|
43
48
|
# If the request to the increment endpoint fails in a non-standard
|
44
49
|
# way, we need to set the future's result so that the caller can
|
@@ -49,13 +54,20 @@ class ConcurrencySlotAcquisitionService(QueueService):
|
|
49
54
|
future.set_result(response)
|
50
55
|
|
51
56
|
async def acquire_slots(
|
52
|
-
self,
|
57
|
+
self,
|
58
|
+
slots: int,
|
59
|
+
mode: str,
|
60
|
+
timeout_seconds: Optional[float] = None,
|
61
|
+
create_if_missing: Optional[bool] = False,
|
53
62
|
) -> httpx.Response:
|
54
63
|
with timeout_async(seconds=timeout_seconds):
|
55
64
|
while True:
|
56
65
|
try:
|
57
66
|
response = await self._client.increment_concurrency_slots(
|
58
|
-
names=self.concurrency_limit_names,
|
67
|
+
names=self.concurrency_limit_names,
|
68
|
+
slots=slots,
|
69
|
+
mode=mode,
|
70
|
+
create_if_missing=create_if_missing,
|
59
71
|
)
|
60
72
|
except Exception as exc:
|
61
73
|
if (
|
@@ -69,7 +81,9 @@ class ConcurrencySlotAcquisitionService(QueueService):
|
|
69
81
|
else:
|
70
82
|
return response
|
71
83
|
|
72
|
-
def send(
|
84
|
+
def send(
|
85
|
+
self, item: Tuple[int, str, Optional[float], Optional[bool]]
|
86
|
+
) -> concurrent.futures.Future:
|
73
87
|
with self._lock:
|
74
88
|
if self._stopped:
|
75
89
|
raise RuntimeError("Cannot put items in a stopped service instance.")
|
@@ -77,7 +91,9 @@ class ConcurrencySlotAcquisitionService(QueueService):
|
|
77
91
|
logger.debug("Service %r enqueuing item %r", self, item)
|
78
92
|
future: concurrent.futures.Future = concurrent.futures.Future()
|
79
93
|
|
80
|
-
occupy, mode, timeout_seconds = item
|
81
|
-
self._queue.put_nowait(
|
94
|
+
occupy, mode, timeout_seconds, create_if_missing = item
|
95
|
+
self._queue.put_nowait(
|
96
|
+
(occupy, mode, timeout_seconds, future, create_if_missing)
|
97
|
+
)
|
82
98
|
|
83
99
|
return future
|
prefect/concurrency/sync.py
CHANGED
@@ -40,6 +40,7 @@ def concurrency(
|
|
40
40
|
names: Union[str, List[str]],
|
41
41
|
occupy: int = 1,
|
42
42
|
timeout_seconds: Optional[float] = None,
|
43
|
+
create_if_missing: Optional[bool] = True,
|
43
44
|
) -> Generator[None, None, None]:
|
44
45
|
"""A context manager that acquires and releases concurrency slots from the
|
45
46
|
given concurrency limits.
|
@@ -49,6 +50,7 @@ def concurrency(
|
|
49
50
|
occupy: The number of slots to acquire and hold from each limit.
|
50
51
|
timeout_seconds: The number of seconds to wait for the slots to be acquired before
|
51
52
|
raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
|
53
|
+
create_if_missing: Whether to create the concurrency limits if they do not exist.
|
52
54
|
|
53
55
|
Raises:
|
54
56
|
TimeoutError: If the slots are not acquired within the given timeout.
|
@@ -66,10 +68,18 @@ def concurrency(
|
|
66
68
|
resource_heavy()
|
67
69
|
```
|
68
70
|
"""
|
71
|
+
if not names:
|
72
|
+
yield
|
73
|
+
return
|
74
|
+
|
69
75
|
names = names if isinstance(names, list) else [names]
|
70
76
|
|
71
77
|
limits: List[MinimalConcurrencyLimitResponse] = _call_async_function_from_sync(
|
72
|
-
_acquire_concurrency_slots,
|
78
|
+
_acquire_concurrency_slots,
|
79
|
+
names,
|
80
|
+
occupy,
|
81
|
+
timeout_seconds=timeout_seconds,
|
82
|
+
create_if_missing=create_if_missing,
|
73
83
|
)
|
74
84
|
acquisition_time = pendulum.now("UTC")
|
75
85
|
emitted_events = _emit_concurrency_acquisition_events(limits, occupy)
|
@@ -87,7 +97,12 @@ def concurrency(
|
|
87
97
|
_emit_concurrency_release_events(limits, occupy, emitted_events)
|
88
98
|
|
89
99
|
|
90
|
-
def rate_limit(
|
100
|
+
def rate_limit(
|
101
|
+
names: Union[str, List[str]],
|
102
|
+
occupy: int = 1,
|
103
|
+
timeout_seconds: Optional[float] = None,
|
104
|
+
create_if_missing: Optional[bool] = True,
|
105
|
+
) -> None:
|
91
106
|
"""Block execution until an `occupy` number of slots of the concurrency
|
92
107
|
limits given in `names` are acquired. Requires that all given concurrency
|
93
108
|
limits have a slot decay.
|
@@ -95,10 +110,22 @@ def rate_limit(names: Union[str, List[str]], occupy: int = 1) -> None:
|
|
95
110
|
Args:
|
96
111
|
names: The names of the concurrency limits to acquire slots from.
|
97
112
|
occupy: The number of slots to acquire and hold from each limit.
|
113
|
+
timeout_seconds: The number of seconds to wait for the slots to be acquired before
|
114
|
+
raising a `TimeoutError`. A timeout of `None` will wait indefinitely.
|
115
|
+
create_if_missing: Whether to create the concurrency limits if they do not exist.
|
98
116
|
"""
|
117
|
+
if not names:
|
118
|
+
return
|
119
|
+
|
99
120
|
names = names if isinstance(names, list) else [names]
|
121
|
+
|
100
122
|
limits = _call_async_function_from_sync(
|
101
|
-
_acquire_concurrency_slots,
|
123
|
+
_acquire_concurrency_slots,
|
124
|
+
names,
|
125
|
+
occupy,
|
126
|
+
mode="rate_limit",
|
127
|
+
timeout_seconds=timeout_seconds,
|
128
|
+
create_if_missing=create_if_missing,
|
102
129
|
)
|
103
130
|
_emit_concurrency_acquisition_events(limits, occupy)
|
104
131
|
|
prefect/context.py
CHANGED
@@ -10,12 +10,13 @@ import os
|
|
10
10
|
import sys
|
11
11
|
import warnings
|
12
12
|
import weakref
|
13
|
-
from contextlib import ExitStack, contextmanager
|
13
|
+
from contextlib import ExitStack, asynccontextmanager, contextmanager
|
14
14
|
from contextvars import ContextVar, Token
|
15
15
|
from pathlib import Path
|
16
16
|
from typing import (
|
17
17
|
TYPE_CHECKING,
|
18
18
|
Any,
|
19
|
+
AsyncGenerator,
|
19
20
|
Dict,
|
20
21
|
Generator,
|
21
22
|
Mapping,
|
@@ -44,6 +45,7 @@ from prefect.settings import PREFECT_HOME, Profile, Settings
|
|
44
45
|
from prefect.states import State
|
45
46
|
from prefect.task_runners import TaskRunner
|
46
47
|
from prefect.utilities.asyncutils import run_coro_as_sync
|
48
|
+
from prefect.utilities.services import start_client_metrics_server
|
47
49
|
|
48
50
|
T = TypeVar("T")
|
49
51
|
|
@@ -177,36 +179,34 @@ class ContextModel(BaseModel):
|
|
177
179
|
return self.model_dump(exclude_unset=True)
|
178
180
|
|
179
181
|
|
180
|
-
class
|
182
|
+
class SyncClientContext(ContextModel):
|
181
183
|
"""
|
182
|
-
A context for managing the Prefect client instances.
|
184
|
+
A context for managing the sync Prefect client instances.
|
183
185
|
|
184
186
|
Clients were formerly tracked on the TaskRunContext and FlowRunContext, but
|
185
187
|
having two separate places and the addition of both sync and async clients
|
186
188
|
made it difficult to manage. This context is intended to be the single
|
187
|
-
source for clients.
|
189
|
+
source for sync clients.
|
188
190
|
|
189
|
-
The client creates
|
190
|
-
|
191
|
-
|
191
|
+
The client creates a sync client, which can either be read directly from
|
192
|
+
the context object OR loaded with get_client, inject_client, or other
|
193
|
+
Prefect utilities.
|
192
194
|
|
193
|
-
with
|
195
|
+
with SyncClientContext.get_or_create() as ctx:
|
194
196
|
c1 = get_client(sync_client=True)
|
195
197
|
c2 = get_client(sync_client=True)
|
196
198
|
assert c1 is c2
|
197
|
-
assert c1 is ctx.
|
199
|
+
assert c1 is ctx.client
|
198
200
|
"""
|
199
201
|
|
200
|
-
__var__ = ContextVar("
|
201
|
-
|
202
|
-
async_client: PrefectClient
|
202
|
+
__var__ = ContextVar("sync-client-context")
|
203
|
+
client: SyncPrefectClient
|
203
204
|
_httpx_settings: Optional[dict[str, Any]] = PrivateAttr(None)
|
204
205
|
_context_stack: int = PrivateAttr(0)
|
205
206
|
|
206
207
|
def __init__(self, httpx_settings: Optional[dict[str, Any]] = None):
|
207
208
|
super().__init__(
|
208
|
-
|
209
|
-
async_client=get_client(sync_client=False, httpx_settings=httpx_settings),
|
209
|
+
client=get_client(sync_client=True, httpx_settings=httpx_settings),
|
210
210
|
)
|
211
211
|
self._httpx_settings = httpx_settings
|
212
212
|
self._context_stack = 0
|
@@ -214,8 +214,7 @@ class ClientContext(ContextModel):
|
|
214
214
|
def __enter__(self):
|
215
215
|
self._context_stack += 1
|
216
216
|
if self._context_stack == 1:
|
217
|
-
self.
|
218
|
-
run_coro_as_sync(self.async_client.__aenter__())
|
217
|
+
self.client.__enter__()
|
219
218
|
return super().__enter__()
|
220
219
|
else:
|
221
220
|
return self
|
@@ -223,18 +222,74 @@ class ClientContext(ContextModel):
|
|
223
222
|
def __exit__(self, *exc_info):
|
224
223
|
self._context_stack -= 1
|
225
224
|
if self._context_stack == 0:
|
226
|
-
self.
|
227
|
-
run_coro_as_sync(self.async_client.__aexit__(*exc_info))
|
225
|
+
self.client.__exit__(*exc_info)
|
228
226
|
return super().__exit__(*exc_info)
|
229
227
|
|
230
228
|
@classmethod
|
231
229
|
@contextmanager
|
232
|
-
def get_or_create(cls) -> Generator["
|
233
|
-
ctx =
|
230
|
+
def get_or_create(cls) -> Generator["SyncClientContext", None, None]:
|
231
|
+
ctx = SyncClientContext.get()
|
234
232
|
if ctx:
|
235
233
|
yield ctx
|
236
234
|
else:
|
237
|
-
with
|
235
|
+
with SyncClientContext() as ctx:
|
236
|
+
yield ctx
|
237
|
+
|
238
|
+
|
239
|
+
class AsyncClientContext(ContextModel):
|
240
|
+
"""
|
241
|
+
A context for managing the async Prefect client instances.
|
242
|
+
|
243
|
+
Clients were formerly tracked on the TaskRunContext and FlowRunContext, but
|
244
|
+
having two separate places and the addition of both sync and async clients
|
245
|
+
made it difficult to manage. This context is intended to be the single
|
246
|
+
source for async clients.
|
247
|
+
|
248
|
+
The client creates an async client, which can either be read directly from
|
249
|
+
the context object OR loaded with get_client, inject_client, or other
|
250
|
+
Prefect utilities.
|
251
|
+
|
252
|
+
with AsyncClientContext.get_or_create() as ctx:
|
253
|
+
c1 = get_client(sync_client=False)
|
254
|
+
c2 = get_client(sync_client=False)
|
255
|
+
assert c1 is c2
|
256
|
+
assert c1 is ctx.client
|
257
|
+
"""
|
258
|
+
|
259
|
+
__var__ = ContextVar("async-client-context")
|
260
|
+
client: PrefectClient
|
261
|
+
_httpx_settings: Optional[dict[str, Any]] = PrivateAttr(None)
|
262
|
+
_context_stack: int = PrivateAttr(0)
|
263
|
+
|
264
|
+
def __init__(self, httpx_settings: Optional[dict[str, Any]] = None):
|
265
|
+
super().__init__(
|
266
|
+
client=get_client(sync_client=False, httpx_settings=httpx_settings),
|
267
|
+
)
|
268
|
+
self._httpx_settings = httpx_settings
|
269
|
+
self._context_stack = 0
|
270
|
+
|
271
|
+
async def __aenter__(self):
|
272
|
+
self._context_stack += 1
|
273
|
+
if self._context_stack == 1:
|
274
|
+
await self.client.__aenter__()
|
275
|
+
return super().__enter__()
|
276
|
+
else:
|
277
|
+
return self
|
278
|
+
|
279
|
+
async def __aexit__(self, *exc_info):
|
280
|
+
self._context_stack -= 1
|
281
|
+
if self._context_stack == 0:
|
282
|
+
await self.client.__aexit__(*exc_info)
|
283
|
+
return super().__exit__(*exc_info)
|
284
|
+
|
285
|
+
@classmethod
|
286
|
+
@asynccontextmanager
|
287
|
+
async def get_or_create(cls) -> AsyncGenerator[Self, None]:
|
288
|
+
ctx = cls.get()
|
289
|
+
if ctx:
|
290
|
+
yield ctx
|
291
|
+
else:
|
292
|
+
with cls() as ctx:
|
238
293
|
yield ctx
|
239
294
|
|
240
295
|
|
@@ -248,6 +303,11 @@ class RunContext(ContextModel):
|
|
248
303
|
client: The Prefect client instance being used for API communication
|
249
304
|
"""
|
250
305
|
|
306
|
+
def __init__(self, *args, **kwargs):
|
307
|
+
super().__init__(*args, **kwargs)
|
308
|
+
|
309
|
+
start_client_metrics_server()
|
310
|
+
|
251
311
|
start_time: DateTime = Field(default_factory=lambda: pendulum.now("UTC"))
|
252
312
|
input_keyset: Optional[Dict[str, Dict[str, str]]] = None
|
253
313
|
client: Union[PrefectClient, SyncPrefectClient]
|
@@ -300,7 +360,7 @@ class EngineContext(RunContext):
|
|
300
360
|
default_factory=weakref.WeakValueDictionary
|
301
361
|
)
|
302
362
|
|
303
|
-
# Events worker to emit events
|
363
|
+
# Events worker to emit events
|
304
364
|
events: Optional[EventsWorker] = None
|
305
365
|
|
306
366
|
__var__: ContextVar = ContextVar("flow_run")
|
@@ -601,7 +661,7 @@ def root_settings_context():
|
|
601
661
|
),
|
602
662
|
file=sys.stderr,
|
603
663
|
)
|
604
|
-
active_name = "
|
664
|
+
active_name = "ephemeral"
|
605
665
|
|
606
666
|
with use_profile(
|
607
667
|
profiles[active_name],
|
prefect/events/clients.py
CHANGED
@@ -19,6 +19,7 @@ import httpx
|
|
19
19
|
import orjson
|
20
20
|
import pendulum
|
21
21
|
from cachetools import TTLCache
|
22
|
+
from prometheus_client import Counter
|
22
23
|
from typing_extensions import Self
|
23
24
|
from websockets import Subprotocol
|
24
25
|
from websockets.client import WebSocketClientProtocol, connect
|
@@ -36,6 +37,30 @@ from prefect.settings import PREFECT_API_KEY, PREFECT_API_URL, PREFECT_CLOUD_API
|
|
36
37
|
if TYPE_CHECKING:
|
37
38
|
from prefect.events.filters import EventFilter
|
38
39
|
|
40
|
+
EVENTS_EMITTED = Counter(
|
41
|
+
"prefect_events_emitted",
|
42
|
+
"The number of events emitted by Prefect event clients",
|
43
|
+
labelnames=["client"],
|
44
|
+
)
|
45
|
+
EVENTS_OBSERVED = Counter(
|
46
|
+
"prefect_events_observed",
|
47
|
+
"The number of events observed by Prefect event subscribers",
|
48
|
+
labelnames=["client"],
|
49
|
+
)
|
50
|
+
EVENT_WEBSOCKET_CONNECTIONS = Counter(
|
51
|
+
"prefect_event_websocket_connections",
|
52
|
+
(
|
53
|
+
"The number of times Prefect event clients have connected to an event stream, "
|
54
|
+
"broken down by direction (in/out) and connection (initial/reconnect)"
|
55
|
+
),
|
56
|
+
labelnames=["client", "direction", "connection"],
|
57
|
+
)
|
58
|
+
EVENT_WEBSOCKET_CHECKPOINTS = Counter(
|
59
|
+
"prefect_event_websocket_checkpoints",
|
60
|
+
"The number of checkpoints performed by Prefect event clients",
|
61
|
+
labelnames=["client"],
|
62
|
+
)
|
63
|
+
|
39
64
|
logger = get_logger(__name__)
|
40
65
|
|
41
66
|
|
@@ -82,6 +107,10 @@ def get_events_subscriber(
|
|
82
107
|
class EventsClient(abc.ABC):
|
83
108
|
"""The abstract interface for all Prefect Events clients"""
|
84
109
|
|
110
|
+
@property
|
111
|
+
def client_name(self) -> str:
|
112
|
+
return self.__class__.__name__
|
113
|
+
|
85
114
|
async def emit(self, event: Event) -> None:
|
86
115
|
"""Emit a single event"""
|
87
116
|
if not hasattr(self, "_in_context"):
|
@@ -89,7 +118,11 @@ class EventsClient(abc.ABC):
|
|
89
118
|
"Events may only be emitted while this client is being used as a "
|
90
119
|
"context manager"
|
91
120
|
)
|
92
|
-
|
121
|
+
|
122
|
+
try:
|
123
|
+
return await self._emit(event)
|
124
|
+
finally:
|
125
|
+
EVENTS_EMITTED.labels(self.client_name).inc()
|
93
126
|
|
94
127
|
@abc.abstractmethod
|
95
128
|
async def _emit(self, event: Event) -> None: # pragma: no cover
|
@@ -140,6 +173,11 @@ class AssertingEventsClient(EventsClient):
|
|
140
173
|
cls.last = None
|
141
174
|
cls.all = []
|
142
175
|
|
176
|
+
def pop_events(self) -> List[Event]:
|
177
|
+
events = self.events
|
178
|
+
self.events = []
|
179
|
+
return events
|
180
|
+
|
143
181
|
async def _emit(self, event: Event) -> None:
|
144
182
|
self.events.append(event)
|
145
183
|
|
@@ -294,6 +332,8 @@ class PrefectEventsClient(EventsClient):
|
|
294
332
|
# don't clear the list, just the ones that we are sure of.
|
295
333
|
self._unconfirmed_events = self._unconfirmed_events[unconfirmed_count:]
|
296
334
|
|
335
|
+
EVENT_WEBSOCKET_CHECKPOINTS.labels(self.client_name).inc()
|
336
|
+
|
297
337
|
async def _emit(self, event: Event) -> None:
|
298
338
|
for i in range(self._reconnection_attempts + 1):
|
299
339
|
try:
|
@@ -421,10 +461,17 @@ class PrefectEventSubscriber:
|
|
421
461
|
if self._reconnection_attempts < 0:
|
422
462
|
raise ValueError("reconnection_attempts must be a non-negative integer")
|
423
463
|
|
464
|
+
@property
|
465
|
+
def client_name(self) -> str:
|
466
|
+
return self.__class__.__name__
|
467
|
+
|
424
468
|
async def __aenter__(self) -> Self:
|
425
469
|
# Don't handle any errors in the initial connection, because these are most
|
426
470
|
# likely a permission or configuration issue that should propagate
|
427
|
-
|
471
|
+
try:
|
472
|
+
await self._reconnect()
|
473
|
+
finally:
|
474
|
+
EVENT_WEBSOCKET_CONNECTIONS.labels(self.client_name, "out", "initial")
|
428
475
|
return self
|
429
476
|
|
430
477
|
async def _reconnect(self) -> None:
|
@@ -498,7 +545,12 @@ class PrefectEventSubscriber:
|
|
498
545
|
# Otherwise, after the first time through this loop, we're recovering
|
499
546
|
# from a ConnectionClosed, so reconnect now.
|
500
547
|
if not self._websocket or i > 0:
|
501
|
-
|
548
|
+
try:
|
549
|
+
await self._reconnect()
|
550
|
+
finally:
|
551
|
+
EVENT_WEBSOCKET_CONNECTIONS.labels(
|
552
|
+
self.client_name, "out", "reconnect"
|
553
|
+
)
|
502
554
|
assert self._websocket
|
503
555
|
|
504
556
|
while True:
|
@@ -509,7 +561,10 @@ class PrefectEventSubscriber:
|
|
509
561
|
continue
|
510
562
|
self._seen_events[event.id] = True
|
511
563
|
|
512
|
-
|
564
|
+
try:
|
565
|
+
return event
|
566
|
+
finally:
|
567
|
+
EVENTS_OBSERVED.labels(self.client_name).inc()
|
513
568
|
except ConnectionClosedOK:
|
514
569
|
logger.debug('Connection closed with "OK" status')
|
515
570
|
raise StopAsyncIteration
|
prefect/events/worker.py
CHANGED
@@ -17,7 +17,6 @@ from .clients import (
|
|
17
17
|
EventsClient,
|
18
18
|
NullEventsClient,
|
19
19
|
PrefectCloudEventsClient,
|
20
|
-
PrefectEphemeralEventsClient,
|
21
20
|
PrefectEventsClient,
|
22
21
|
)
|
23
22
|
from .related import related_resources_from_run_context
|
@@ -97,7 +96,15 @@ class EventsWorker(QueueService[Event]):
|
|
97
96
|
elif should_emit_events_to_running_server():
|
98
97
|
client_type = PrefectEventsClient
|
99
98
|
elif should_emit_events_to_ephemeral_server():
|
100
|
-
|
99
|
+
# create an ephemeral API if none was provided
|
100
|
+
from prefect.server.api.server import SubprocessASGIServer
|
101
|
+
|
102
|
+
server = SubprocessASGIServer()
|
103
|
+
server.start()
|
104
|
+
assert server.server_process is not None, "Server process did not start"
|
105
|
+
|
106
|
+
client_kwargs = {"api_url": server.api_url}
|
107
|
+
client_type = PrefectEventsClient
|
101
108
|
else:
|
102
109
|
client_type = NullEventsClient
|
103
110
|
|
prefect/flow_engine.py
CHANGED
@@ -29,7 +29,8 @@ from prefect.client.orchestration import SyncPrefectClient, get_client
|
|
29
29
|
from prefect.client.schemas import FlowRun, TaskRun
|
30
30
|
from prefect.client.schemas.filters import FlowRunFilter
|
31
31
|
from prefect.client.schemas.sorting import FlowRunSort
|
32
|
-
from prefect.context import
|
32
|
+
from prefect.concurrency.context import ConcurrencyContext
|
33
|
+
from prefect.context import FlowRunContext, SyncClientContext, TagsContext
|
33
34
|
from prefect.exceptions import (
|
34
35
|
Abort,
|
35
36
|
Pause,
|
@@ -505,6 +506,8 @@ class FlowRunEngine(Generic[P, R]):
|
|
505
506
|
task_runner=task_runner,
|
506
507
|
)
|
507
508
|
)
|
509
|
+
stack.enter_context(ConcurrencyContext())
|
510
|
+
|
508
511
|
# set the logger to the flow run logger
|
509
512
|
self.logger = flow_run_logger(flow_run=self.flow_run, flow=self.flow)
|
510
513
|
|
@@ -529,8 +532,8 @@ class FlowRunEngine(Generic[P, R]):
|
|
529
532
|
"""
|
530
533
|
Enters a client context and creates a flow run if needed.
|
531
534
|
"""
|
532
|
-
with
|
533
|
-
self._client = client_ctx.
|
535
|
+
with SyncClientContext.get_or_create() as client_ctx:
|
536
|
+
self._client = client_ctx.client
|
534
537
|
self._is_started = True
|
535
538
|
|
536
539
|
if not self.flow_run:
|