prefect-client 2.14.9__py3-none-any.whl → 2.14.11__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.
Files changed (38) hide show
  1. prefect/__init__.py +4 -1
  2. prefect/_internal/pydantic/v2_schema.py +9 -2
  3. prefect/client/orchestration.py +51 -4
  4. prefect/client/schemas/objects.py +16 -1
  5. prefect/deployments/runner.py +34 -3
  6. prefect/engine.py +302 -25
  7. prefect/events/clients.py +216 -5
  8. prefect/events/filters.py +214 -0
  9. prefect/exceptions.py +4 -0
  10. prefect/flows.py +16 -0
  11. prefect/infrastructure/base.py +106 -1
  12. prefect/infrastructure/container.py +52 -0
  13. prefect/infrastructure/kubernetes.py +64 -0
  14. prefect/infrastructure/process.py +38 -0
  15. prefect/infrastructure/provisioners/__init__.py +2 -0
  16. prefect/infrastructure/provisioners/cloud_run.py +206 -34
  17. prefect/infrastructure/provisioners/container_instance.py +1080 -0
  18. prefect/infrastructure/provisioners/ecs.py +483 -48
  19. prefect/input/__init__.py +11 -0
  20. prefect/input/actions.py +88 -0
  21. prefect/input/run_input.py +107 -0
  22. prefect/runner/runner.py +5 -0
  23. prefect/runner/server.py +92 -8
  24. prefect/runner/utils.py +92 -0
  25. prefect/settings.py +34 -9
  26. prefect/states.py +26 -3
  27. prefect/utilities/dockerutils.py +31 -0
  28. prefect/utilities/processutils.py +5 -2
  29. prefect/utilities/services.py +10 -0
  30. prefect/utilities/validation.py +63 -0
  31. prefect/workers/__init__.py +1 -0
  32. prefect/workers/block.py +226 -0
  33. prefect/workers/utilities.py +2 -2
  34. {prefect_client-2.14.9.dist-info → prefect_client-2.14.11.dist-info}/METADATA +2 -1
  35. {prefect_client-2.14.9.dist-info → prefect_client-2.14.11.dist-info}/RECORD +38 -30
  36. {prefect_client-2.14.9.dist-info → prefect_client-2.14.11.dist-info}/LICENSE +0 -0
  37. {prefect_client-2.14.9.dist-info → prefect_client-2.14.11.dist-info}/WHEEL +0 -0
  38. {prefect_client-2.14.9.dist-info → prefect_client-2.14.11.dist-info}/top_level.txt +0 -0
prefect/engine.py CHANGED
@@ -84,6 +84,7 @@ import asyncio
84
84
  import contextlib
85
85
  import logging
86
86
  import os
87
+ import random
87
88
  import signal
88
89
  import sys
89
90
  import threading
@@ -99,8 +100,10 @@ from typing import (
99
100
  List,
100
101
  Optional,
101
102
  Set,
103
+ Type,
102
104
  TypeVar,
103
105
  Union,
106
+ overload,
104
107
  )
105
108
  from uuid import UUID, uuid4
106
109
 
@@ -112,6 +115,8 @@ from typing_extensions import Literal
112
115
  import prefect
113
116
  import prefect.context
114
117
  import prefect.plugins
118
+ from prefect._internal.compatibility.deprecated import deprecated_parameter
119
+ from prefect._internal.compatibility.experimental import experimental_parameter
115
120
  from prefect._internal.concurrency.api import create_call, from_async, from_sync
116
121
  from prefect._internal.concurrency.calls import get_current_call
117
122
  from prefect._internal.concurrency.cancellation import CancelledError, get_deadline
@@ -150,6 +155,7 @@ from prefect.exceptions import (
150
155
  )
151
156
  from prefect.flows import Flow
152
157
  from prefect.futures import PrefectFuture, call_repr, resolve_futures_to_states
158
+ from prefect.input import RunInput, keyset_from_paused_state
153
159
  from prefect.logging.configuration import setup_logging
154
160
  from prefect.logging.handlers import APILogHandler
155
161
  from prefect.logging.loggers import (
@@ -172,6 +178,7 @@ from prefect.states import (
172
178
  Pending,
173
179
  Running,
174
180
  State,
181
+ Suspended,
175
182
  exception_to_crashed_state,
176
183
  exception_to_failed_state,
177
184
  get_state_exception,
@@ -201,6 +208,7 @@ from prefect.utilities.pydantic import PartialModel
201
208
  from prefect.utilities.text import truncated_to
202
209
 
203
210
  R = TypeVar("R")
211
+ T = TypeVar("T", bound=RunInput)
204
212
  EngineReturnType = Literal["future", "state", "result"]
205
213
 
206
214
 
@@ -940,8 +948,45 @@ async def orchestrate_flow_run(
940
948
  return state
941
949
 
942
950
 
951
+ @overload
952
+ async def pause_flow_run(
953
+ wait_for_input: None = None,
954
+ flow_run_id: UUID = None,
955
+ timeout: int = 300,
956
+ poll_interval: int = 10,
957
+ reschedule: bool = False,
958
+ key: str = None,
959
+ ) -> None:
960
+ ...
961
+
962
+
963
+ @overload
964
+ async def pause_flow_run(
965
+ wait_for_input: Type[T],
966
+ flow_run_id: UUID = None,
967
+ timeout: int = 300,
968
+ poll_interval: int = 10,
969
+ reschedule: bool = False,
970
+ key: str = None,
971
+ ) -> T:
972
+ ...
973
+
974
+
943
975
  @sync_compatible
976
+ @deprecated_parameter(
977
+ "flow_run_id", start_date="Dec 2023", help="Use `suspend_flow_run` instead."
978
+ )
979
+ @deprecated_parameter(
980
+ "reschedule",
981
+ start_date="Dec 2023",
982
+ when=lambda p: p is True,
983
+ help="Use `suspend_flow_run` instead.",
984
+ )
985
+ @experimental_parameter(
986
+ "wait_for_input", group="flow_run_input", when=lambda y: y is not None
987
+ )
944
988
  async def pause_flow_run(
989
+ wait_for_input: Optional[Type[T]] = None,
945
990
  flow_run_id: UUID = None,
946
991
  timeout: int = 300,
947
992
  poll_interval: int = 10,
@@ -949,7 +994,7 @@ async def pause_flow_run(
949
994
  key: str = None,
950
995
  ):
951
996
  """
952
- Pauses the current flow run by stopping execution until resumed.
997
+ Pauses the current flow run by blocking execution until resumed.
953
998
 
954
999
  When called within a flow run, execution will block and no downstream tasks will
955
1000
  run until the flow is resumed. Task runs that have already started will continue
@@ -978,8 +1023,16 @@ async def pause_flow_run(
978
1023
  the number of pauses observed by the flow so far, and prevents pauses that
979
1024
  use the "reschedule" option from running the same pause twice. A custom key
980
1025
  can be supplied for custom pausing behavior.
1026
+ wait_for_input: a subclass of `RunInput`. If provided when the flow pauses, the
1027
+ flow will wait for the input to be provided before resuming. If the flow is
1028
+ resumed without providing the input, the flow will fail. If the flow is
1029
+ resumed with the input, the flow will resume and the input will be loaded
1030
+ and returned from this function.
981
1031
  """
982
1032
  if flow_run_id:
1033
+ if wait_for_input is not None:
1034
+ raise RuntimeError("Cannot wait for input when pausing out of process.")
1035
+
983
1036
  return await _out_of_process_pause(
984
1037
  flow_run_id=flow_run_id,
985
1038
  timeout=timeout,
@@ -988,18 +1041,26 @@ async def pause_flow_run(
988
1041
  )
989
1042
  else:
990
1043
  return await _in_process_pause(
991
- timeout=timeout, poll_interval=poll_interval, reschedule=reschedule, key=key
1044
+ timeout=timeout,
1045
+ poll_interval=poll_interval,
1046
+ reschedule=reschedule,
1047
+ key=key,
1048
+ wait_for_input=wait_for_input,
992
1049
  )
993
1050
 
994
1051
 
995
1052
  @inject_client
1053
+ @experimental_parameter(
1054
+ "wait_for_input", group="flow_run_input", when=lambda y: y is not None
1055
+ )
996
1056
  async def _in_process_pause(
997
1057
  timeout: int = 300,
998
1058
  poll_interval: int = 10,
999
1059
  reschedule=False,
1000
1060
  key: str = None,
1001
1061
  client=None,
1002
- ):
1062
+ wait_for_input: Optional[Type[RunInput]] = None,
1063
+ ) -> Optional[RunInput]:
1003
1064
  if TaskRunContext.get():
1004
1065
  raise RuntimeError("Cannot pause task runs.")
1005
1066
 
@@ -1014,12 +1075,18 @@ async def _in_process_pause(
1014
1075
 
1015
1076
  logger.info("Pausing flow, execution will continue when this flow run is resumed.")
1016
1077
 
1078
+ proposed_state = Paused(
1079
+ timeout_seconds=timeout, reschedule=reschedule, pause_key=pause_key
1080
+ )
1081
+
1082
+ if wait_for_input:
1083
+ run_input_keyset = keyset_from_paused_state(proposed_state)
1084
+ proposed_state.state_details.run_input_keyset = run_input_keyset
1085
+
1017
1086
  try:
1018
1087
  state = await propose_state(
1019
1088
  client=client,
1020
- state=Paused(
1021
- timeout_seconds=timeout, reschedule=reschedule, pause_key=pause_key
1022
- ),
1089
+ state=proposed_state,
1023
1090
  flow_run_id=context.flow_run.id,
1024
1091
  )
1025
1092
  except Abort as exc:
@@ -1027,7 +1094,14 @@ async def _in_process_pause(
1027
1094
  raise RuntimeError(f"Flow run cannot be paused: {exc}")
1028
1095
 
1029
1096
  if state.is_running():
1030
- # The orchestrator requests that this pause be ignored
1097
+ # The orchestrator rejected the paused state which means that this
1098
+ # pause has happened before (via reschedule) and the flow run has
1099
+ # been resumed.
1100
+ if wait_for_input:
1101
+ # The flow run wanted input, so we need to load it and return it
1102
+ # to the user.
1103
+ await wait_for_input.load(run_input_keyset)
1104
+
1031
1105
  return
1032
1106
 
1033
1107
  if not state.is_paused():
@@ -1036,31 +1110,37 @@ async def _in_process_pause(
1036
1110
  f"Flow run cannot be paused. Received non-paused state from API: {state}"
1037
1111
  )
1038
1112
 
1113
+ if wait_for_input:
1114
+ # We're now in a paused state and the flow run is waiting for input.
1115
+ # Save the schema of the users `RunInput` subclass, stored in
1116
+ # `wait_for_input`, so the UI can display the form and we can validate
1117
+ # the input when the flow is resumed.
1118
+ await wait_for_input.save(run_input_keyset)
1119
+
1039
1120
  if reschedule:
1040
1121
  # If a rescheduled pause, exit this process so the run can be resubmitted later
1041
- raise Pause()
1122
+ raise Pause(state=state)
1042
1123
 
1043
1124
  # Otherwise, block and check for completion on an interval
1044
1125
  with anyio.move_on_after(timeout):
1045
1126
  # attempt to check if a flow has resumed at least once
1046
1127
  initial_sleep = min(timeout / 2, poll_interval)
1047
1128
  await anyio.sleep(initial_sleep)
1048
- flow_run = await client.read_flow_run(context.flow_run.id)
1049
- if flow_run.state.is_running():
1050
- logger.info("Resuming flow run execution!")
1051
- return
1052
-
1053
1129
  while True:
1054
- await anyio.sleep(poll_interval)
1055
1130
  flow_run = await client.read_flow_run(context.flow_run.id)
1056
1131
  if flow_run.state.is_running():
1057
1132
  logger.info("Resuming flow run execution!")
1133
+ if wait_for_input:
1134
+ return await wait_for_input.load(run_input_keyset)
1058
1135
  return
1136
+ await anyio.sleep(poll_interval)
1059
1137
 
1060
1138
  # check one last time before failing the flow
1061
1139
  flow_run = await client.read_flow_run(context.flow_run.id)
1062
1140
  if flow_run.state.is_running():
1063
1141
  logger.info("Resuming flow run execution!")
1142
+ if wait_for_input:
1143
+ return await wait_for_input.load(run_input_keyset)
1064
1144
  return
1065
1145
 
1066
1146
  raise FlowPauseTimeout("Flow run was paused and never resumed.")
@@ -1088,13 +1168,141 @@ async def _out_of_process_pause(
1088
1168
  raise RuntimeError(response.details.reason)
1089
1169
 
1090
1170
 
1171
+ @overload
1172
+ async def suspend_flow_run(
1173
+ wait_for_input: None = None,
1174
+ flow_run_id: Optional[UUID] = None,
1175
+ timeout: Optional[int] = 300,
1176
+ key: Optional[str] = None,
1177
+ client: PrefectClient = None,
1178
+ ) -> None:
1179
+ ...
1180
+
1181
+
1182
+ @overload
1183
+ async def suspend_flow_run(
1184
+ wait_for_input: Type[T],
1185
+ flow_run_id: Optional[UUID] = None,
1186
+ timeout: Optional[int] = 300,
1187
+ key: Optional[str] = None,
1188
+ client: PrefectClient = None,
1189
+ ) -> T:
1190
+ ...
1191
+
1192
+
1091
1193
  @sync_compatible
1092
- async def resume_flow_run(flow_run_id):
1194
+ @inject_client
1195
+ async def suspend_flow_run(
1196
+ wait_for_input: Optional[Type[T]] = None,
1197
+ flow_run_id: Optional[UUID] = None,
1198
+ timeout: Optional[int] = 300,
1199
+ key: Optional[str] = None,
1200
+ client: PrefectClient = None,
1201
+ ):
1202
+ """
1203
+ Suspends a flow run by stopping code execution until resumed.
1204
+
1205
+ When suspended, the flow run will continue execution until the NEXT task is
1206
+ orchestrated, at which point the flow will exit. Any tasks that have
1207
+ already started will run until completion. When resumed, the flow run will
1208
+ be rescheduled to finish execution. In order suspend a flow run in this
1209
+ way, the flow needs to have an associated deployment and results need to be
1210
+ configured with the `persist_results` option.
1211
+
1212
+ Args:
1213
+ flow_run_id: a flow run id. If supplied, this function will attempt to
1214
+ suspend the specified flow run. If not supplied will attempt to
1215
+ suspend the current flow run.
1216
+ timeout: the number of seconds to wait for the flow to be resumed before
1217
+ failing. Defaults to 5 minutes (300 seconds). If the pause timeout
1218
+ exceeds any configured flow-level timeout, the flow might fail even
1219
+ after resuming.
1220
+ key: An optional key to prevent calling suspend more than once. This
1221
+ defaults to a random string and prevents suspends from running the
1222
+ same suspend twice. A custom key can be supplied for custom
1223
+ suspending behavior.
1224
+ wait_for_input: a subclass of `RunInput`. If provided when the flow
1225
+ suspends, the flow will wait for the input to be provided before
1226
+ resuming. If the flow is resumed without providing the input, the
1227
+ flow will fail. If the flow is resumed with the input, the flow
1228
+ will resume and the input will be loaded and returned from this
1229
+ function.
1230
+ """
1231
+ context = FlowRunContext.get()
1232
+
1233
+ if flow_run_id is None:
1234
+ if TaskRunContext.get():
1235
+ raise RuntimeError("Cannot suspend task runs.")
1236
+
1237
+ if context is None or context.flow_run is None:
1238
+ raise RuntimeError(
1239
+ "Flow runs can only be suspended from within a flow run."
1240
+ )
1241
+
1242
+ logger = get_run_logger(context=context)
1243
+ logger.info(
1244
+ "Suspending flow run, execution will be rescheduled when this flow run is"
1245
+ " resumed."
1246
+ )
1247
+ flow_run_id = context.flow_run.id
1248
+ suspending_current_flow_run = True
1249
+ pause_counter = _observed_flow_pauses(context)
1250
+ pause_key = key or str(pause_counter)
1251
+ else:
1252
+ # Since we're suspending another flow run we need to generate a pause
1253
+ # key that won't conflict with whatever suspends/pauses that flow may
1254
+ # have. Since this method won't be called during that flow run it's
1255
+ # okay that this is non-deterministic.
1256
+ suspending_current_flow_run = False
1257
+ pause_key = key or str(uuid4())
1258
+
1259
+ proposed_state = Suspended(timeout_seconds=timeout, pause_key=pause_key)
1260
+
1261
+ if wait_for_input:
1262
+ run_input_keyset = keyset_from_paused_state(proposed_state)
1263
+ proposed_state.state_details.run_input_keyset = run_input_keyset
1264
+
1265
+ try:
1266
+ state = await propose_state(
1267
+ client=client,
1268
+ state=proposed_state,
1269
+ flow_run_id=flow_run_id,
1270
+ )
1271
+ except Abort as exc:
1272
+ # Aborted requests mean the suspension is not allowed
1273
+ raise RuntimeError(f"Flow run cannot be suspended: {exc}")
1274
+
1275
+ if state.is_running():
1276
+ # The orchestrator rejected the suspended state which means that this
1277
+ # suspend has happened before and the flow run has been resumed.
1278
+ if wait_for_input:
1279
+ # The flow run wanted input, so we need to load it and return it
1280
+ # to the user.
1281
+ return await wait_for_input.load(run_input_keyset)
1282
+ return
1283
+
1284
+ if not state.is_paused():
1285
+ # If we receive anything but a PAUSED state, we are unable to continue
1286
+ raise RuntimeError(
1287
+ f"Flow run cannot be suspended. Received unexpected state from API: {state}"
1288
+ )
1289
+
1290
+ if wait_for_input:
1291
+ await wait_for_input.save(run_input_keyset)
1292
+
1293
+ if suspending_current_flow_run:
1294
+ # Exit this process so the run can be resubmitted later
1295
+ raise Pause()
1296
+
1297
+
1298
+ @sync_compatible
1299
+ async def resume_flow_run(flow_run_id, run_input: Optional[Dict] = None):
1093
1300
  """
1094
1301
  Resumes a paused flow.
1095
1302
 
1096
1303
  Args:
1097
1304
  flow_run_id: the flow_run_id to resume
1305
+ run_input: a dictionary of inputs to provide to the flow run.
1098
1306
  """
1099
1307
  client = get_client()
1100
1308
  flow_run = await client.read_flow_run(flow_run_id)
@@ -1102,7 +1310,7 @@ async def resume_flow_run(flow_run_id):
1102
1310
  if not flow_run.state.is_paused():
1103
1311
  raise NotPausedError("Cannot resume a run that isn't paused!")
1104
1312
 
1105
- response = await client.resume_flow_run(flow_run_id)
1313
+ response = await client.resume_flow_run(flow_run_id, run_input=run_input)
1106
1314
 
1107
1315
  if response.status == SetStateStatus.REJECT:
1108
1316
  if response.state.type == StateType.FAILED:
@@ -1585,10 +1793,18 @@ async def begin_task_run(
1585
1793
  state = task_run.state
1586
1794
 
1587
1795
  except Pause:
1796
+ # A pause signal here should mean the flow run suspended, so we
1797
+ # should do the same. We'll look up the flow run's pause state to
1798
+ # try and reuse it, so we capture any data like timeouts.
1799
+ flow_run = await client.read_flow_run(task_run.flow_run_id)
1800
+ if flow_run.state and flow_run.state.is_paused():
1801
+ state = flow_run.state
1802
+ else:
1803
+ state = Suspended()
1804
+
1588
1805
  task_run_logger(task_run).info(
1589
1806
  "Task run encountered a pause signal during orchestration."
1590
1807
  )
1591
- state = Paused()
1592
1808
 
1593
1809
  return state
1594
1810
 
@@ -1702,13 +1918,74 @@ async def orchestrate_task_run(
1702
1918
  last_state = task_run.state
1703
1919
 
1704
1920
  # Transition from `PENDING` -> `RUNNING`
1705
- state = await propose_state(
1706
- client,
1707
- Running(
1708
- state_details=StateDetails(cache_key=cache_key, refresh_cache=refresh_cache)
1709
- ),
1710
- task_run_id=task_run.id,
1711
- )
1921
+ try:
1922
+ state = await propose_state(
1923
+ client,
1924
+ Running(
1925
+ state_details=StateDetails(
1926
+ cache_key=cache_key, refresh_cache=refresh_cache
1927
+ )
1928
+ ),
1929
+ task_run_id=task_run.id,
1930
+ )
1931
+ except Pause as exc:
1932
+ # We shouldn't get a pause signal without a state, but if this happens,
1933
+ # just use a Paused state to assume an in-process pause.
1934
+ state = exc.state if exc.state else Paused()
1935
+
1936
+ # If a flow submits tasks and then pauses, we may reach this point due
1937
+ # to concurrency timing because the tasks will try to transition after
1938
+ # the flow run has paused. Orchestration will send back a Paused state
1939
+ # for the task runs.
1940
+ if state.state_details.pause_reschedule:
1941
+ # If we're being asked to pause and reschedule, we should exit the
1942
+ # task and expect to be resumed later.
1943
+ raise
1944
+
1945
+ if state.is_paused():
1946
+ BACKOFF_MAX = 10 # Seconds
1947
+ backoff_count = 0
1948
+
1949
+ async def tick():
1950
+ nonlocal backoff_count
1951
+ if backoff_count < BACKOFF_MAX:
1952
+ backoff_count += 1
1953
+ interval = 1 + backoff_count + random.random() * backoff_count
1954
+ await anyio.sleep(interval)
1955
+
1956
+ # Enter a loop to wait for the task run to be resumed, i.e.
1957
+ # become Pending, and then propose a Running state again.
1958
+ while True:
1959
+ await tick()
1960
+
1961
+ # Propose a Running state again. We do this instead of reading the
1962
+ # task run because if the flow run times out, this lets
1963
+ # orchestration fail the task run.
1964
+ try:
1965
+ state = await propose_state(
1966
+ client,
1967
+ Running(
1968
+ state_details=StateDetails(
1969
+ cache_key=cache_key, refresh_cache=refresh_cache
1970
+ )
1971
+ ),
1972
+ task_run_id=task_run.id,
1973
+ )
1974
+ except Pause as exc:
1975
+ if not exc.state:
1976
+ continue
1977
+
1978
+ if exc.state.state_details.pause_reschedule:
1979
+ # If the pause state includes pause_reschedule, we should exit the
1980
+ # task and expect to be resumed later. We've already checked for this
1981
+ # above, but we check again here in case the state changed; e.g. the
1982
+ # flow run suspended.
1983
+ raise
1984
+ else:
1985
+ # Propose a Running state again.
1986
+ continue
1987
+ else:
1988
+ break
1712
1989
 
1713
1990
  # Emit an event to capture the result of proposing a `RUNNING` state.
1714
1991
  last_event = _emit_task_run_state_change_event(
@@ -2207,7 +2484,7 @@ async def propose_state(
2207
2484
 
2208
2485
  elif response.status == SetStateStatus.REJECT:
2209
2486
  if response.state.is_paused():
2210
- raise Pause(response.details.reason)
2487
+ raise Pause(response.details.reason, state=response.state)
2211
2488
  return response.state
2212
2489
 
2213
2490
  else: