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.
Files changed (55) hide show
  1. LICENSE +175 -0
  2. NOTICE +8 -0
  3. VERSION.py +5 -0
  4. async_durable_execution_runner/__about__.py +33 -0
  5. async_durable_execution_runner/__init__.py +23 -0
  6. async_durable_execution_runner/checkpoint/__init__.py +1 -0
  7. async_durable_execution_runner/checkpoint/processor.py +101 -0
  8. async_durable_execution_runner/checkpoint/processors/__init__.py +1 -0
  9. async_durable_execution_runner/checkpoint/processors/base.py +199 -0
  10. async_durable_execution_runner/checkpoint/processors/callback.py +89 -0
  11. async_durable_execution_runner/checkpoint/processors/context.py +59 -0
  12. async_durable_execution_runner/checkpoint/processors/execution.py +52 -0
  13. async_durable_execution_runner/checkpoint/processors/step.py +124 -0
  14. async_durable_execution_runner/checkpoint/processors/wait.py +95 -0
  15. async_durable_execution_runner/checkpoint/transformer.py +104 -0
  16. async_durable_execution_runner/checkpoint/validators/__init__.py +1 -0
  17. async_durable_execution_runner/checkpoint/validators/checkpoint.py +242 -0
  18. async_durable_execution_runner/checkpoint/validators/operations/__init__.py +1 -0
  19. async_durable_execution_runner/checkpoint/validators/operations/callback.py +45 -0
  20. async_durable_execution_runner/checkpoint/validators/operations/context.py +73 -0
  21. async_durable_execution_runner/checkpoint/validators/operations/execution.py +47 -0
  22. async_durable_execution_runner/checkpoint/validators/operations/invoke.py +56 -0
  23. async_durable_execution_runner/checkpoint/validators/operations/step.py +106 -0
  24. async_durable_execution_runner/checkpoint/validators/operations/wait.py +54 -0
  25. async_durable_execution_runner/checkpoint/validators/transitions.py +66 -0
  26. async_durable_execution_runner/cli.py +498 -0
  27. async_durable_execution_runner/client.py +50 -0
  28. async_durable_execution_runner/exceptions.py +288 -0
  29. async_durable_execution_runner/execution.py +444 -0
  30. async_durable_execution_runner/executor.py +1234 -0
  31. async_durable_execution_runner/invoker.py +340 -0
  32. async_durable_execution_runner/model.py +3296 -0
  33. async_durable_execution_runner/observer.py +144 -0
  34. async_durable_execution_runner/py.typed +1 -0
  35. async_durable_execution_runner/runner.py +1167 -0
  36. async_durable_execution_runner/scheduler.py +246 -0
  37. async_durable_execution_runner/stores/__init__.py +1 -0
  38. async_durable_execution_runner/stores/base.py +147 -0
  39. async_durable_execution_runner/stores/filesystem.py +79 -0
  40. async_durable_execution_runner/stores/memory.py +38 -0
  41. async_durable_execution_runner/stores/sqlite.py +273 -0
  42. async_durable_execution_runner/token.py +49 -0
  43. async_durable_execution_runner/web/__init__.py +1 -0
  44. async_durable_execution_runner/web/errors.py +8 -0
  45. async_durable_execution_runner/web/handlers.py +813 -0
  46. async_durable_execution_runner/web/models.py +266 -0
  47. async_durable_execution_runner/web/routes.py +692 -0
  48. async_durable_execution_runner/web/serialization.py +235 -0
  49. async_durable_execution_runner/web/server.py +243 -0
  50. async_durable_execution_runner-2.0.0a1.dist-info/METADATA +238 -0
  51. async_durable_execution_runner-2.0.0a1.dist-info/RECORD +55 -0
  52. async_durable_execution_runner-2.0.0a1.dist-info/WHEEL +4 -0
  53. async_durable_execution_runner-2.0.0a1.dist-info/entry_points.txt +2 -0
  54. async_durable_execution_runner-2.0.0a1.dist-info/licenses/LICENSE +175 -0
  55. 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