prefect-client 3.0.0rc13__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.
@@ -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, occupy, timeout_seconds=timeout_seconds
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
- await _release_concurrency_slots(
75
- names, occupy, occupancy_period.total_seconds()
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(names: Union[str, List[str]], occupy: int = 1) -> None:
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
- limits = await _acquire_concurrency_slots(names, occupy, mode="rate_limit")
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)
@@ -34,11 +34,16 @@ class ConcurrencySlotAcquisitionService(QueueService):
34
34
  yield
35
35
 
36
36
  async def _handle(
37
- self, item: Tuple[int, str, Optional[float], concurrent.futures.Future]
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(occupy, mode, timeout_seconds)
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, slots: int, mode: str, timeout_seconds: Optional[float] = None
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, slots=slots, mode=mode
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(self, item: Tuple[int, str, Optional[float]]) -> concurrent.futures.Future:
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((occupy, mode, timeout_seconds, future))
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
@@ -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, names, occupy, timeout_seconds=timeout_seconds
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(names: Union[str, List[str]], occupy: int = 1) -> None:
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, names, occupy, mode="rate_limit"
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 ClientContext(ContextModel):
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 both sync and async clients, which can either be read
190
- directly from the context object OR loaded with get_client, inject_client,
191
- or other Prefect utilities.
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 ClientContext.get_or_create() as ctx:
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.sync_client
199
+ assert c1 is ctx.client
198
200
  """
199
201
 
200
- __var__ = ContextVar("clients")
201
- sync_client: SyncPrefectClient
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
- sync_client=get_client(sync_client=True, httpx_settings=httpx_settings),
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.sync_client.__enter__()
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.sync_client.__exit__(*exc_info)
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["ClientContext", None, None]:
233
- ctx = ClientContext.get()
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 ClientContext() as ctx:
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 to Prefect Cloud
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 = "default"
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
- return await self._emit(event)
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
@@ -299,6 +332,8 @@ class PrefectEventsClient(EventsClient):
299
332
  # don't clear the list, just the ones that we are sure of.
300
333
  self._unconfirmed_events = self._unconfirmed_events[unconfirmed_count:]
301
334
 
335
+ EVENT_WEBSOCKET_CHECKPOINTS.labels(self.client_name).inc()
336
+
302
337
  async def _emit(self, event: Event) -> None:
303
338
  for i in range(self._reconnection_attempts + 1):
304
339
  try:
@@ -426,10 +461,17 @@ class PrefectEventSubscriber:
426
461
  if self._reconnection_attempts < 0:
427
462
  raise ValueError("reconnection_attempts must be a non-negative integer")
428
463
 
464
+ @property
465
+ def client_name(self) -> str:
466
+ return self.__class__.__name__
467
+
429
468
  async def __aenter__(self) -> Self:
430
469
  # Don't handle any errors in the initial connection, because these are most
431
470
  # likely a permission or configuration issue that should propagate
432
- await self._reconnect()
471
+ try:
472
+ await self._reconnect()
473
+ finally:
474
+ EVENT_WEBSOCKET_CONNECTIONS.labels(self.client_name, "out", "initial")
433
475
  return self
434
476
 
435
477
  async def _reconnect(self) -> None:
@@ -503,7 +545,12 @@ class PrefectEventSubscriber:
503
545
  # Otherwise, after the first time through this loop, we're recovering
504
546
  # from a ConnectionClosed, so reconnect now.
505
547
  if not self._websocket or i > 0:
506
- await self._reconnect()
548
+ try:
549
+ await self._reconnect()
550
+ finally:
551
+ EVENT_WEBSOCKET_CONNECTIONS.labels(
552
+ self.client_name, "out", "reconnect"
553
+ )
507
554
  assert self._websocket
508
555
 
509
556
  while True:
@@ -514,7 +561,10 @@ class PrefectEventSubscriber:
514
561
  continue
515
562
  self._seen_events[event.id] = True
516
563
 
517
- return event
564
+ try:
565
+ return event
566
+ finally:
567
+ EVENTS_OBSERVED.labels(self.client_name).inc()
518
568
  except ConnectionClosedOK:
519
569
  logger.debug('Connection closed with "OK" status')
520
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
- client_type = PrefectEphemeralEventsClient
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 ClientContext, FlowRunContext, TagsContext
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 ClientContext.get_or_create() as client_ctx:
533
- self._client = client_ctx.sync_client
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: