async-durable-execution-runner 2.0.0a1__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.
- LICENSE +175 -0
- NOTICE +8 -0
- VERSION.py +5 -0
- async_durable_execution_runner/__about__.py +33 -0
- async_durable_execution_runner/__init__.py +23 -0
- async_durable_execution_runner/checkpoint/__init__.py +1 -0
- async_durable_execution_runner/checkpoint/processor.py +101 -0
- async_durable_execution_runner/checkpoint/processors/__init__.py +1 -0
- async_durable_execution_runner/checkpoint/processors/base.py +199 -0
- async_durable_execution_runner/checkpoint/processors/callback.py +89 -0
- async_durable_execution_runner/checkpoint/processors/context.py +59 -0
- async_durable_execution_runner/checkpoint/processors/execution.py +52 -0
- async_durable_execution_runner/checkpoint/processors/step.py +124 -0
- async_durable_execution_runner/checkpoint/processors/wait.py +95 -0
- async_durable_execution_runner/checkpoint/transformer.py +104 -0
- async_durable_execution_runner/checkpoint/validators/__init__.py +1 -0
- async_durable_execution_runner/checkpoint/validators/checkpoint.py +242 -0
- async_durable_execution_runner/checkpoint/validators/operations/__init__.py +1 -0
- async_durable_execution_runner/checkpoint/validators/operations/callback.py +45 -0
- async_durable_execution_runner/checkpoint/validators/operations/context.py +73 -0
- async_durable_execution_runner/checkpoint/validators/operations/execution.py +47 -0
- async_durable_execution_runner/checkpoint/validators/operations/invoke.py +56 -0
- async_durable_execution_runner/checkpoint/validators/operations/step.py +106 -0
- async_durable_execution_runner/checkpoint/validators/operations/wait.py +54 -0
- async_durable_execution_runner/checkpoint/validators/transitions.py +66 -0
- async_durable_execution_runner/cli.py +498 -0
- async_durable_execution_runner/client.py +50 -0
- async_durable_execution_runner/exceptions.py +288 -0
- async_durable_execution_runner/execution.py +444 -0
- async_durable_execution_runner/executor.py +1234 -0
- async_durable_execution_runner/invoker.py +340 -0
- async_durable_execution_runner/model.py +3296 -0
- async_durable_execution_runner/observer.py +144 -0
- async_durable_execution_runner/py.typed +1 -0
- async_durable_execution_runner/runner.py +1167 -0
- async_durable_execution_runner/scheduler.py +246 -0
- async_durable_execution_runner/stores/__init__.py +1 -0
- async_durable_execution_runner/stores/base.py +147 -0
- async_durable_execution_runner/stores/filesystem.py +79 -0
- async_durable_execution_runner/stores/memory.py +38 -0
- async_durable_execution_runner/stores/sqlite.py +273 -0
- async_durable_execution_runner/token.py +49 -0
- async_durable_execution_runner/web/__init__.py +1 -0
- async_durable_execution_runner/web/errors.py +8 -0
- async_durable_execution_runner/web/handlers.py +813 -0
- async_durable_execution_runner/web/models.py +266 -0
- async_durable_execution_runner/web/routes.py +692 -0
- async_durable_execution_runner/web/serialization.py +235 -0
- async_durable_execution_runner/web/server.py +243 -0
- async_durable_execution_runner-2.0.0a1.dist-info/METADATA +238 -0
- async_durable_execution_runner-2.0.0a1.dist-info/RECORD +55 -0
- async_durable_execution_runner-2.0.0a1.dist-info/WHEEL +4 -0
- async_durable_execution_runner-2.0.0a1.dist-info/entry_points.txt +2 -0
- async_durable_execution_runner-2.0.0a1.dist-info/licenses/LICENSE +175 -0
- async_durable_execution_runner-2.0.0a1.dist-info/licenses/NOTICE +1 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from threading import Lock
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
import boto3 # type: ignore
|
|
10
|
+
from botocore.config import Config # type: ignore
|
|
11
|
+
|
|
12
|
+
from async_durable_execution.execution import (
|
|
13
|
+
DurableExecutionInvocationInput,
|
|
14
|
+
DurableExecutionInvocationInputWithClient,
|
|
15
|
+
DurableExecutionInvocationOutput,
|
|
16
|
+
InitialExecutionState,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from async_durable_execution_runner.exceptions import (
|
|
20
|
+
DurableFunctionsTestError,
|
|
21
|
+
InvalidParameterValueException,
|
|
22
|
+
ResourceNotFoundException,
|
|
23
|
+
)
|
|
24
|
+
from async_durable_execution_runner.model import LambdaContext
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
|
|
30
|
+
from async_durable_execution_runner.client import InMemoryServiceClient
|
|
31
|
+
from async_durable_execution_runner.execution import Execution
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Max Lambda function timeout is 15 minutes (900s); we give headroom for
|
|
35
|
+
# network round-trip and RIE startup.
|
|
36
|
+
_LAMBDA_READ_TIMEOUT_SECONDS = 960
|
|
37
|
+
_LAMBDA_CLIENT_CONFIG = Config(
|
|
38
|
+
read_timeout=_LAMBDA_READ_TIMEOUT_SECONDS,
|
|
39
|
+
retries={"max_attempts": 0},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_lambda_client(endpoint_url: str | None, region_name: str) -> Any:
|
|
44
|
+
"""Create a boto3 Lambda client configured for durable function invocations."""
|
|
45
|
+
|
|
46
|
+
return boto3.client(
|
|
47
|
+
"lambda",
|
|
48
|
+
endpoint_url=endpoint_url,
|
|
49
|
+
region_name=region_name,
|
|
50
|
+
config=_LAMBDA_CLIENT_CONFIG,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class InvokeResponse:
|
|
56
|
+
"""Response from invoking a durable function."""
|
|
57
|
+
|
|
58
|
+
invocation_output: DurableExecutionInvocationOutput
|
|
59
|
+
request_id: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_test_lambda_context() -> LambdaContext:
|
|
63
|
+
# Create client context as a dictionary, not as objects
|
|
64
|
+
# LambdaContext.__init__ expects dictionaries and will create the objects internally
|
|
65
|
+
client_context_dict = {
|
|
66
|
+
"custom": {"test_key": "test_value"},
|
|
67
|
+
"env": {"platform": "test", "make": "test", "model": "test"},
|
|
68
|
+
"client": {
|
|
69
|
+
"installation_id": "test-installation-123",
|
|
70
|
+
"app_title": "TestApp",
|
|
71
|
+
"app_version_name": "1.0.0",
|
|
72
|
+
"app_version_code": "100",
|
|
73
|
+
"app_package_name": "com.test.app",
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
cognito_identity_dict = {
|
|
78
|
+
"cognitoIdentityId": "test-cognito-identity-123",
|
|
79
|
+
"cognitoIdentityPoolId": "us-west-2:test-pool-456",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return LambdaContext(
|
|
83
|
+
aws_request_id="test-invoke-12345",
|
|
84
|
+
client_context=client_context_dict,
|
|
85
|
+
identity=cognito_identity_dict,
|
|
86
|
+
invoked_function_arn="arn:aws:lambda:us-west-2:123456789012:function:test-function",
|
|
87
|
+
tenant_id="test-tenant-789",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Invoker(Protocol):
|
|
92
|
+
def create_invocation_input(
|
|
93
|
+
self, execution: Execution
|
|
94
|
+
) -> DurableExecutionInvocationInput: ... # pragma: no cover
|
|
95
|
+
|
|
96
|
+
def invoke(
|
|
97
|
+
self,
|
|
98
|
+
function_name: str,
|
|
99
|
+
input: DurableExecutionInvocationInput,
|
|
100
|
+
endpoint_url: str | None = None,
|
|
101
|
+
) -> InvokeResponse: ... # pragma: no cover
|
|
102
|
+
|
|
103
|
+
def update_endpoint(
|
|
104
|
+
self, endpoint_url: str, region_name: str
|
|
105
|
+
) -> None: ... # pragma: no cover
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class InProcessInvoker(Invoker):
|
|
109
|
+
def __init__(self, handler: Callable, service_client: InMemoryServiceClient):
|
|
110
|
+
self.handler = handler
|
|
111
|
+
self.service_client = service_client
|
|
112
|
+
|
|
113
|
+
def create_invocation_input(
|
|
114
|
+
self, execution: Execution
|
|
115
|
+
) -> DurableExecutionInvocationInput:
|
|
116
|
+
return DurableExecutionInvocationInputWithClient(
|
|
117
|
+
durable_execution_arn=execution.durable_execution_arn,
|
|
118
|
+
# TODO: this needs better logic - use existing if not used yet, vs create new
|
|
119
|
+
checkpoint_token=execution.get_new_checkpoint_token(),
|
|
120
|
+
initial_execution_state=InitialExecutionState(
|
|
121
|
+
operations=execution.operations,
|
|
122
|
+
next_marker="",
|
|
123
|
+
),
|
|
124
|
+
service_client=self.service_client,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def invoke(
|
|
128
|
+
self,
|
|
129
|
+
function_name: str, # noqa: ARG002
|
|
130
|
+
input: DurableExecutionInvocationInput,
|
|
131
|
+
endpoint_url: str | None = None, # noqa: ARG002
|
|
132
|
+
) -> InvokeResponse:
|
|
133
|
+
# TODO: reasses if function_name will be used in future
|
|
134
|
+
input_with_client = DurableExecutionInvocationInputWithClient.from_durable_execution_invocation_input(
|
|
135
|
+
input, self.service_client
|
|
136
|
+
)
|
|
137
|
+
context = create_test_lambda_context()
|
|
138
|
+
response_dict = self.handler(input_with_client, context)
|
|
139
|
+
output = DurableExecutionInvocationOutput.from_dict(response_dict)
|
|
140
|
+
return InvokeResponse(
|
|
141
|
+
invocation_output=output, request_id=context.aws_request_id
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def update_endpoint(self, endpoint_url: str, region_name: str) -> None:
|
|
145
|
+
"""No-op for in-process invoker."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class LambdaInvoker(Invoker):
|
|
149
|
+
def __init__(self, lambda_client: Any) -> None:
|
|
150
|
+
self.lambda_client = lambda_client
|
|
151
|
+
# Maps execution_arn -> endpoint for that execution
|
|
152
|
+
# Maps endpoint -> client to reuse clients across executions
|
|
153
|
+
self._execution_endpoints: dict[str, str] = {}
|
|
154
|
+
self._endpoint_clients: dict[str, Any] = {}
|
|
155
|
+
self._current_endpoint: str = "" # Track current endpoint for new executions
|
|
156
|
+
self._lock = Lock()
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def create(endpoint_url: str, region_name: str) -> LambdaInvoker:
|
|
160
|
+
"""Create with the boto lambda client."""
|
|
161
|
+
invoker = LambdaInvoker(create_lambda_client(endpoint_url, region_name))
|
|
162
|
+
invoker._current_endpoint = endpoint_url
|
|
163
|
+
invoker._endpoint_clients[endpoint_url] = invoker.lambda_client
|
|
164
|
+
return invoker
|
|
165
|
+
|
|
166
|
+
def update_endpoint(self, endpoint_url: str, region_name: str) -> None:
|
|
167
|
+
"""Update the Lambda client endpoint."""
|
|
168
|
+
# Cache client by endpoint to reuse across executions
|
|
169
|
+
with self._lock:
|
|
170
|
+
if endpoint_url not in self._endpoint_clients:
|
|
171
|
+
self._endpoint_clients[endpoint_url] = create_lambda_client(
|
|
172
|
+
endpoint_url, region_name
|
|
173
|
+
)
|
|
174
|
+
self.lambda_client = self._endpoint_clients[endpoint_url]
|
|
175
|
+
self._current_endpoint = endpoint_url
|
|
176
|
+
|
|
177
|
+
def _get_client_for_execution(
|
|
178
|
+
self,
|
|
179
|
+
durable_execution_arn: str,
|
|
180
|
+
lambda_endpoint: str | None = None,
|
|
181
|
+
region_name: str | None = None,
|
|
182
|
+
) -> Any:
|
|
183
|
+
"""Get the appropriate client for this execution."""
|
|
184
|
+
# Use provided endpoint or fall back to cached endpoint for this execution
|
|
185
|
+
if lambda_endpoint:
|
|
186
|
+
if lambda_endpoint not in self._endpoint_clients:
|
|
187
|
+
self._endpoint_clients[lambda_endpoint] = create_lambda_client(
|
|
188
|
+
lambda_endpoint, region_name or "us-east-1"
|
|
189
|
+
)
|
|
190
|
+
return self._endpoint_clients[lambda_endpoint]
|
|
191
|
+
|
|
192
|
+
# Fallback to cached endpoint
|
|
193
|
+
if durable_execution_arn not in self._execution_endpoints:
|
|
194
|
+
with self._lock:
|
|
195
|
+
if durable_execution_arn not in self._execution_endpoints:
|
|
196
|
+
self._execution_endpoints[durable_execution_arn] = (
|
|
197
|
+
self._current_endpoint
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
endpoint = self._execution_endpoints[durable_execution_arn]
|
|
201
|
+
|
|
202
|
+
# If no endpoint configured, fall back to default client
|
|
203
|
+
if not endpoint:
|
|
204
|
+
return self.lambda_client
|
|
205
|
+
|
|
206
|
+
return self._endpoint_clients[endpoint]
|
|
207
|
+
|
|
208
|
+
def create_invocation_input(
|
|
209
|
+
self, execution: Execution
|
|
210
|
+
) -> DurableExecutionInvocationInput:
|
|
211
|
+
return DurableExecutionInvocationInput(
|
|
212
|
+
durable_execution_arn=execution.durable_execution_arn,
|
|
213
|
+
checkpoint_token=execution.get_new_checkpoint_token(),
|
|
214
|
+
initial_execution_state=InitialExecutionState(
|
|
215
|
+
operations=execution.operations,
|
|
216
|
+
next_marker="",
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def invoke(
|
|
221
|
+
self,
|
|
222
|
+
function_name: str,
|
|
223
|
+
input: DurableExecutionInvocationInput,
|
|
224
|
+
endpoint_url: str | None = None,
|
|
225
|
+
) -> InvokeResponse:
|
|
226
|
+
"""Invoke AWS Lambda function and return durable execution result.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
function_name: Name of the Lambda function to invoke
|
|
230
|
+
input: Durable execution invocation input
|
|
231
|
+
endpoint_url: Lambda endpoint url
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
InvokeResponse: Response containing invocation output and request ID
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
ResourceNotFoundException: If function does not exist
|
|
238
|
+
InvalidParameterValueException: If parameters are invalid
|
|
239
|
+
DurableFunctionsTestError: For other invocation failures
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
# Parameter validation
|
|
243
|
+
if not function_name or not function_name.strip():
|
|
244
|
+
msg = "Function name is required"
|
|
245
|
+
raise InvalidParameterValueException(msg)
|
|
246
|
+
|
|
247
|
+
# Get the client for this execution
|
|
248
|
+
client = self._get_client_for_execution(
|
|
249
|
+
input.durable_execution_arn, endpoint_url
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
# Invoke AWS Lambda function using standard invoke method
|
|
254
|
+
response = client.invoke(
|
|
255
|
+
FunctionName=function_name,
|
|
256
|
+
InvocationType="RequestResponse", # Synchronous invocation
|
|
257
|
+
Payload=json.dumps(input.to_json_dict()),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Check HTTP status code
|
|
261
|
+
status_code = response.get("StatusCode")
|
|
262
|
+
if status_code not in (200, 202, 204):
|
|
263
|
+
msg = f"Lambda invocation failed with status code: {status_code}"
|
|
264
|
+
raise DurableFunctionsTestError(msg)
|
|
265
|
+
|
|
266
|
+
# Check for function errors
|
|
267
|
+
if "FunctionError" in response:
|
|
268
|
+
error_payload = response["Payload"].read().decode("utf-8")
|
|
269
|
+
msg = f"Lambda invocation failed with status {status_code}: {error_payload}"
|
|
270
|
+
raise DurableFunctionsTestError(msg)
|
|
271
|
+
|
|
272
|
+
# Parse response payload
|
|
273
|
+
response_payload = response["Payload"].read().decode("utf-8")
|
|
274
|
+
response_dict = json.loads(response_payload)
|
|
275
|
+
|
|
276
|
+
# Extract request ID from response headers (x-amzn-RequestId or x-amzn-request-id)
|
|
277
|
+
headers = response.get("ResponseMetadata", {}).get("HTTPHeaders", {})
|
|
278
|
+
request_id = (
|
|
279
|
+
headers.get("x-amzn-RequestId")
|
|
280
|
+
or headers.get("x-amzn-request-id")
|
|
281
|
+
or f"local-{uuid4()}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Convert to DurableExecutionInvocationOutput
|
|
285
|
+
output = DurableExecutionInvocationOutput.from_dict(response_dict)
|
|
286
|
+
return InvokeResponse(invocation_output=output, request_id=request_id)
|
|
287
|
+
|
|
288
|
+
except client.exceptions.ResourceNotFoundException as e:
|
|
289
|
+
msg = f"Function not found: {function_name}"
|
|
290
|
+
raise ResourceNotFoundException(msg) from e
|
|
291
|
+
except client.exceptions.InvalidParameterValueException as e:
|
|
292
|
+
msg = f"Invalid parameter: {e}"
|
|
293
|
+
raise InvalidParameterValueException(msg) from e
|
|
294
|
+
except (
|
|
295
|
+
client.exceptions.TooManyRequestsException,
|
|
296
|
+
client.exceptions.ServiceException,
|
|
297
|
+
client.exceptions.ResourceConflictException,
|
|
298
|
+
client.exceptions.InvalidRequestContentException,
|
|
299
|
+
client.exceptions.RequestTooLargeException,
|
|
300
|
+
client.exceptions.UnsupportedMediaTypeException,
|
|
301
|
+
client.exceptions.InvalidRuntimeException,
|
|
302
|
+
client.exceptions.InvalidZipFileException,
|
|
303
|
+
client.exceptions.ResourceNotReadyException,
|
|
304
|
+
client.exceptions.SnapStartTimeoutException,
|
|
305
|
+
client.exceptions.SnapStartNotReadyException,
|
|
306
|
+
client.exceptions.SnapStartException,
|
|
307
|
+
client.exceptions.RecursiveInvocationException,
|
|
308
|
+
) as e:
|
|
309
|
+
msg = f"Lambda invocation failed: {e}"
|
|
310
|
+
raise DurableFunctionsTestError(msg) from e
|
|
311
|
+
except (
|
|
312
|
+
client.exceptions.InvalidSecurityGroupIDException,
|
|
313
|
+
client.exceptions.EC2ThrottledException,
|
|
314
|
+
client.exceptions.EFSMountConnectivityException,
|
|
315
|
+
client.exceptions.SubnetIPAddressLimitReachedException,
|
|
316
|
+
client.exceptions.EC2UnexpectedException,
|
|
317
|
+
client.exceptions.InvalidSubnetIDException,
|
|
318
|
+
client.exceptions.EC2AccessDeniedException,
|
|
319
|
+
client.exceptions.EFSIOException,
|
|
320
|
+
client.exceptions.ENILimitReachedException,
|
|
321
|
+
client.exceptions.EFSMountTimeoutException,
|
|
322
|
+
client.exceptions.EFSMountFailureException,
|
|
323
|
+
) as e:
|
|
324
|
+
msg = f"Lambda infrastructure error: {e}"
|
|
325
|
+
raise DurableFunctionsTestError(msg) from e
|
|
326
|
+
except (
|
|
327
|
+
client.exceptions.KMSAccessDeniedException,
|
|
328
|
+
client.exceptions.KMSDisabledException,
|
|
329
|
+
client.exceptions.KMSNotFoundException,
|
|
330
|
+
client.exceptions.KMSInvalidStateException,
|
|
331
|
+
) as e:
|
|
332
|
+
msg = f"Lambda KMS error: {e}"
|
|
333
|
+
raise DurableFunctionsTestError(msg) from e
|
|
334
|
+
except Exception as e:
|
|
335
|
+
# Handle any remaining exceptions, including custom ones like DurableExecutionAlreadyStartedException
|
|
336
|
+
if "DurableExecutionAlreadyStartedException" in str(type(e)):
|
|
337
|
+
msg = f"Durable execution already started: {e}"
|
|
338
|
+
raise DurableFunctionsTestError(msg) from e
|
|
339
|
+
msg = f"Unexpected error during Lambda invocation: {e}"
|
|
340
|
+
raise DurableFunctionsTestError(msg) from e
|