aws-durable-execution-sdk-python-testing 1.0.0__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.
- aws_durable_execution_sdk_python_testing/__about__.py +4 -0
- aws_durable_execution_sdk_python_testing/__init__.py +20 -0
- aws_durable_execution_sdk_python_testing/checkpoint/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processor.py +101 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +199 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +89 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +59 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +52 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +124 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +95 -0
- aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +104 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +242 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/callback.py +45 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/context.py +73 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/execution.py +47 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +56 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/step.py +106 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/wait.py +54 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +66 -0
- aws_durable_execution_sdk_python_testing/cli.py +501 -0
- aws_durable_execution_sdk_python_testing/client.py +50 -0
- aws_durable_execution_sdk_python_testing/exceptions.py +288 -0
- aws_durable_execution_sdk_python_testing/execution.py +440 -0
- aws_durable_execution_sdk_python_testing/executor.py +1214 -0
- aws_durable_execution_sdk_python_testing/invoker.py +327 -0
- aws_durable_execution_sdk_python_testing/model.py +3267 -0
- aws_durable_execution_sdk_python_testing/observer.py +144 -0
- aws_durable_execution_sdk_python_testing/py.typed +1 -0
- aws_durable_execution_sdk_python_testing/runner.py +1185 -0
- aws_durable_execution_sdk_python_testing/scheduler.py +246 -0
- aws_durable_execution_sdk_python_testing/stores/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/stores/base.py +147 -0
- aws_durable_execution_sdk_python_testing/stores/filesystem.py +104 -0
- aws_durable_execution_sdk_python_testing/stores/memory.py +38 -0
- aws_durable_execution_sdk_python_testing/stores/sqlite.py +283 -0
- aws_durable_execution_sdk_python_testing/token.py +49 -0
- aws_durable_execution_sdk_python_testing/web/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/web/errors.py +8 -0
- aws_durable_execution_sdk_python_testing/web/handlers.py +807 -0
- aws_durable_execution_sdk_python_testing/web/models.py +266 -0
- aws_durable_execution_sdk_python_testing/web/routes.py +684 -0
- aws_durable_execution_sdk_python_testing/web/serialization.py +245 -0
- aws_durable_execution_sdk_python_testing/web/server.py +243 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/METADATA +221 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/RECORD +52 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/WHEEL +4 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/entry_points.txt +2 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/LICENSE +175 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/NOTICE +1 -0
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import (
|
|
9
|
+
TYPE_CHECKING,
|
|
10
|
+
Any,
|
|
11
|
+
Concatenate,
|
|
12
|
+
ParamSpec,
|
|
13
|
+
Protocol,
|
|
14
|
+
Self,
|
|
15
|
+
TypeVar,
|
|
16
|
+
cast,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
import aws_durable_execution_sdk_python
|
|
20
|
+
import boto3 # type: ignore
|
|
21
|
+
from botocore.exceptions import ClientError # type: ignore
|
|
22
|
+
from aws_durable_execution_sdk_python.execution import (
|
|
23
|
+
InvocationStatus,
|
|
24
|
+
durable_execution,
|
|
25
|
+
)
|
|
26
|
+
from aws_durable_execution_sdk_python.lambda_service import (
|
|
27
|
+
ErrorObject,
|
|
28
|
+
OperationPayload,
|
|
29
|
+
OperationStatus,
|
|
30
|
+
OperationSubType,
|
|
31
|
+
OperationType,
|
|
32
|
+
)
|
|
33
|
+
from aws_durable_execution_sdk_python.lambda_service import Operation as SvcOperation
|
|
34
|
+
|
|
35
|
+
from aws_durable_execution_sdk_python_testing.checkpoint.processor import (
|
|
36
|
+
CheckpointProcessor,
|
|
37
|
+
)
|
|
38
|
+
from aws_durable_execution_sdk_python_testing.client import InMemoryServiceClient
|
|
39
|
+
from aws_durable_execution_sdk_python_testing.exceptions import (
|
|
40
|
+
DurableFunctionsLocalRunnerError,
|
|
41
|
+
DurableFunctionsTestError,
|
|
42
|
+
InvalidParameterValueException,
|
|
43
|
+
ResourceNotFoundException,
|
|
44
|
+
)
|
|
45
|
+
from aws_durable_execution_sdk_python_testing.executor import Executor
|
|
46
|
+
from aws_durable_execution_sdk_python_testing.invoker import (
|
|
47
|
+
InProcessInvoker,
|
|
48
|
+
LambdaInvoker,
|
|
49
|
+
)
|
|
50
|
+
from aws_durable_execution_sdk_python_testing.model import (
|
|
51
|
+
GetDurableExecutionHistoryResponse,
|
|
52
|
+
GetDurableExecutionResponse,
|
|
53
|
+
StartDurableExecutionInput,
|
|
54
|
+
StartDurableExecutionOutput,
|
|
55
|
+
events_to_operations,
|
|
56
|
+
)
|
|
57
|
+
from aws_durable_execution_sdk_python_testing.scheduler import Scheduler
|
|
58
|
+
from aws_durable_execution_sdk_python_testing.stores.base import (
|
|
59
|
+
ExecutionStore,
|
|
60
|
+
StoreType,
|
|
61
|
+
)
|
|
62
|
+
from aws_durable_execution_sdk_python_testing.stores.filesystem import (
|
|
63
|
+
FileSystemExecutionStore,
|
|
64
|
+
)
|
|
65
|
+
from aws_durable_execution_sdk_python_testing.stores.memory import (
|
|
66
|
+
InMemoryExecutionStore,
|
|
67
|
+
)
|
|
68
|
+
from aws_durable_execution_sdk_python_testing.stores.sqlite import SQLiteExecutionStore
|
|
69
|
+
from aws_durable_execution_sdk_python_testing.web.server import WebServer
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
import datetime
|
|
74
|
+
from collections.abc import Callable, MutableMapping
|
|
75
|
+
|
|
76
|
+
from aws_durable_execution_sdk_python.context import DurableContext
|
|
77
|
+
from aws_durable_execution_sdk_python.execution import InvocationStatus
|
|
78
|
+
|
|
79
|
+
from aws_durable_execution_sdk_python_testing.execution import Execution
|
|
80
|
+
from aws_durable_execution_sdk_python_testing.web.server import WebServiceConfig
|
|
81
|
+
from aws_durable_execution_sdk_python_testing.model import Event
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
logger = logging.getLogger(__name__)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class WebRunnerConfig:
|
|
89
|
+
"""Configuration for the WebRunner using composition pattern.
|
|
90
|
+
|
|
91
|
+
This configuration class encapsulates all settings needed to run the web server
|
|
92
|
+
for durable functions testing, including HTTP server configuration and Lambda
|
|
93
|
+
service configuration.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# HTTP server configuration (existing WebServiceConfig)
|
|
97
|
+
web_service: WebServiceConfig
|
|
98
|
+
|
|
99
|
+
# Lambda service configuration (web runner specific)
|
|
100
|
+
lambda_endpoint: str = "http://127.0.0.1:3001"
|
|
101
|
+
local_runner_endpoint: str = "http://0.0.0.0:5000"
|
|
102
|
+
local_runner_region: str = "us-west-2"
|
|
103
|
+
local_runner_mode: str = "local"
|
|
104
|
+
|
|
105
|
+
# Store configuration
|
|
106
|
+
store_type: StoreType = StoreType.MEMORY
|
|
107
|
+
store_path: str | None = None # Path for filesystem store
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Operation:
|
|
112
|
+
operation_id: str
|
|
113
|
+
operation_type: OperationType
|
|
114
|
+
status: OperationStatus
|
|
115
|
+
parent_id: str | None = field(default=None, kw_only=True)
|
|
116
|
+
name: str | None = field(default=None, kw_only=True)
|
|
117
|
+
sub_type: OperationSubType | None = field(default=None, kw_only=True)
|
|
118
|
+
start_timestamp: datetime.datetime | None = field(default=None, kw_only=True)
|
|
119
|
+
end_timestamp: datetime.datetime | None = field(default=None, kw_only=True)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
T = TypeVar("T", bound=Operation)
|
|
123
|
+
P = ParamSpec("P")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class OperationFactory(Protocol):
|
|
127
|
+
@staticmethod
|
|
128
|
+
def from_svc_operation(
|
|
129
|
+
operation: SvcOperation, all_operations: list[SvcOperation] | None = None
|
|
130
|
+
) -> Operation: ...
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class ExecutionOperation(Operation):
|
|
135
|
+
input_payload: str | None = None
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def from_svc_operation(
|
|
139
|
+
operation: SvcOperation,
|
|
140
|
+
all_operations: list[SvcOperation] | None = None, # noqa: ARG004
|
|
141
|
+
) -> ExecutionOperation:
|
|
142
|
+
if operation.operation_type != OperationType.EXECUTION:
|
|
143
|
+
msg: str = f"Expected EXECUTION operation, got {operation.operation_type}"
|
|
144
|
+
raise InvalidParameterValueException(msg)
|
|
145
|
+
return ExecutionOperation(
|
|
146
|
+
operation_id=operation.operation_id,
|
|
147
|
+
operation_type=operation.operation_type,
|
|
148
|
+
status=operation.status,
|
|
149
|
+
parent_id=operation.parent_id,
|
|
150
|
+
name=operation.name,
|
|
151
|
+
sub_type=operation.sub_type,
|
|
152
|
+
start_timestamp=operation.start_timestamp,
|
|
153
|
+
end_timestamp=operation.end_timestamp,
|
|
154
|
+
input_payload=(
|
|
155
|
+
operation.execution_details.input_payload
|
|
156
|
+
if operation.execution_details
|
|
157
|
+
else None
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class ContextOperation(Operation):
|
|
164
|
+
child_operations: list[Operation]
|
|
165
|
+
result: OperationPayload | None = None
|
|
166
|
+
error: ErrorObject | None = None
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def from_svc_operation(
|
|
170
|
+
operation: SvcOperation, all_operations: list[SvcOperation] | None = None
|
|
171
|
+
) -> ContextOperation:
|
|
172
|
+
if operation.operation_type != OperationType.CONTEXT:
|
|
173
|
+
msg: str = f"Expected CONTEXT operation, got {operation.operation_type}"
|
|
174
|
+
raise InvalidParameterValueException(msg)
|
|
175
|
+
|
|
176
|
+
child_operations = []
|
|
177
|
+
if all_operations:
|
|
178
|
+
child_operations = [
|
|
179
|
+
create_operation(op, all_operations)
|
|
180
|
+
for op in all_operations
|
|
181
|
+
if op.parent_id == operation.operation_id
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
return ContextOperation(
|
|
185
|
+
operation_id=operation.operation_id,
|
|
186
|
+
operation_type=operation.operation_type,
|
|
187
|
+
status=operation.status,
|
|
188
|
+
parent_id=operation.parent_id,
|
|
189
|
+
name=operation.name,
|
|
190
|
+
sub_type=operation.sub_type,
|
|
191
|
+
start_timestamp=operation.start_timestamp,
|
|
192
|
+
end_timestamp=operation.end_timestamp,
|
|
193
|
+
child_operations=child_operations,
|
|
194
|
+
result=operation.context_details.result
|
|
195
|
+
if operation.context_details
|
|
196
|
+
else None,
|
|
197
|
+
error=operation.context_details.error
|
|
198
|
+
if operation.context_details
|
|
199
|
+
else None,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def get_operation_by_name(self, name: str) -> Operation:
|
|
203
|
+
for operation in self.child_operations:
|
|
204
|
+
if operation.name == name:
|
|
205
|
+
return operation
|
|
206
|
+
msg: str = f"Child Operation with name '{name}' not found"
|
|
207
|
+
raise DurableFunctionsTestError(msg)
|
|
208
|
+
|
|
209
|
+
def get_step(self, name: str) -> StepOperation:
|
|
210
|
+
return cast(StepOperation, self.get_operation_by_name(name))
|
|
211
|
+
|
|
212
|
+
def get_wait(self, name: str) -> WaitOperation:
|
|
213
|
+
return cast(WaitOperation, self.get_operation_by_name(name))
|
|
214
|
+
|
|
215
|
+
def get_context(self, name: str) -> ContextOperation:
|
|
216
|
+
return cast(ContextOperation, self.get_operation_by_name(name))
|
|
217
|
+
|
|
218
|
+
def get_callback(self, name: str) -> CallbackOperation:
|
|
219
|
+
return cast(CallbackOperation, self.get_operation_by_name(name))
|
|
220
|
+
|
|
221
|
+
def get_invoke(self, name: str) -> InvokeOperation:
|
|
222
|
+
return cast(InvokeOperation, self.get_operation_by_name(name))
|
|
223
|
+
|
|
224
|
+
def get_execution(self, name: str) -> ExecutionOperation:
|
|
225
|
+
return cast(ExecutionOperation, self.get_operation_by_name(name))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True)
|
|
229
|
+
class StepOperation(ContextOperation):
|
|
230
|
+
attempt: int = 0
|
|
231
|
+
next_attempt_timestamp: datetime.datetime | None = None
|
|
232
|
+
result: OperationPayload | None = None
|
|
233
|
+
error: ErrorObject | None = None
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def from_svc_operation(
|
|
237
|
+
operation: SvcOperation, all_operations: list[SvcOperation] | None = None
|
|
238
|
+
) -> StepOperation:
|
|
239
|
+
if operation.operation_type != OperationType.STEP:
|
|
240
|
+
msg: str = f"Expected STEP operation, got {operation.operation_type}"
|
|
241
|
+
raise InvalidParameterValueException(msg)
|
|
242
|
+
|
|
243
|
+
child_operations = []
|
|
244
|
+
if all_operations:
|
|
245
|
+
child_operations = [
|
|
246
|
+
create_operation(op, all_operations)
|
|
247
|
+
for op in all_operations
|
|
248
|
+
if op.parent_id == operation.operation_id
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
return StepOperation(
|
|
252
|
+
operation_id=operation.operation_id,
|
|
253
|
+
operation_type=operation.operation_type,
|
|
254
|
+
status=operation.status,
|
|
255
|
+
parent_id=operation.parent_id,
|
|
256
|
+
name=operation.name,
|
|
257
|
+
sub_type=operation.sub_type,
|
|
258
|
+
start_timestamp=operation.start_timestamp,
|
|
259
|
+
end_timestamp=operation.end_timestamp,
|
|
260
|
+
child_operations=child_operations,
|
|
261
|
+
attempt=operation.step_details.attempt if operation.step_details else 0,
|
|
262
|
+
next_attempt_timestamp=(
|
|
263
|
+
operation.step_details.next_attempt_timestamp
|
|
264
|
+
if operation.step_details
|
|
265
|
+
else None
|
|
266
|
+
),
|
|
267
|
+
result=operation.step_details.result if operation.step_details else None,
|
|
268
|
+
error=operation.step_details.error if operation.step_details else None,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True)
|
|
273
|
+
class WaitOperation(Operation):
|
|
274
|
+
scheduled_end_timestamp: datetime.datetime | None = None
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def from_svc_operation(
|
|
278
|
+
operation: SvcOperation,
|
|
279
|
+
all_operations: list[SvcOperation] | None = None, # noqa: ARG004
|
|
280
|
+
) -> WaitOperation:
|
|
281
|
+
if operation.operation_type != OperationType.WAIT:
|
|
282
|
+
msg: str = f"Expected WAIT operation, got {operation.operation_type}"
|
|
283
|
+
raise InvalidParameterValueException(msg)
|
|
284
|
+
return WaitOperation(
|
|
285
|
+
operation_id=operation.operation_id,
|
|
286
|
+
operation_type=operation.operation_type,
|
|
287
|
+
status=operation.status,
|
|
288
|
+
parent_id=operation.parent_id,
|
|
289
|
+
name=operation.name,
|
|
290
|
+
sub_type=operation.sub_type,
|
|
291
|
+
start_timestamp=operation.start_timestamp,
|
|
292
|
+
end_timestamp=operation.end_timestamp,
|
|
293
|
+
scheduled_end_timestamp=(
|
|
294
|
+
operation.wait_details.scheduled_end_timestamp
|
|
295
|
+
if operation.wait_details
|
|
296
|
+
else None
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True)
|
|
302
|
+
class CallbackOperation(ContextOperation):
|
|
303
|
+
callback_id: str | None = None
|
|
304
|
+
result: OperationPayload | None = None
|
|
305
|
+
error: ErrorObject | None = None
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def from_svc_operation(
|
|
309
|
+
operation: SvcOperation, all_operations: list[SvcOperation] | None = None
|
|
310
|
+
) -> CallbackOperation:
|
|
311
|
+
if operation.operation_type != OperationType.CALLBACK:
|
|
312
|
+
msg: str = f"Expected CALLBACK operation, got {operation.operation_type}"
|
|
313
|
+
raise InvalidParameterValueException(msg)
|
|
314
|
+
|
|
315
|
+
child_operations = []
|
|
316
|
+
if all_operations:
|
|
317
|
+
child_operations = [
|
|
318
|
+
create_operation(op, all_operations)
|
|
319
|
+
for op in all_operations
|
|
320
|
+
if op.parent_id == operation.operation_id
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
return CallbackOperation(
|
|
324
|
+
operation_id=operation.operation_id,
|
|
325
|
+
operation_type=operation.operation_type,
|
|
326
|
+
status=operation.status,
|
|
327
|
+
parent_id=operation.parent_id,
|
|
328
|
+
name=operation.name,
|
|
329
|
+
sub_type=operation.sub_type,
|
|
330
|
+
start_timestamp=operation.start_timestamp,
|
|
331
|
+
end_timestamp=operation.end_timestamp,
|
|
332
|
+
child_operations=child_operations,
|
|
333
|
+
callback_id=(
|
|
334
|
+
operation.callback_details.callback_id
|
|
335
|
+
if operation.callback_details
|
|
336
|
+
else None
|
|
337
|
+
),
|
|
338
|
+
result=operation.callback_details.result
|
|
339
|
+
if operation.callback_details
|
|
340
|
+
else None,
|
|
341
|
+
error=operation.callback_details.error
|
|
342
|
+
if operation.callback_details
|
|
343
|
+
else None,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass(frozen=True)
|
|
348
|
+
class InvokeOperation(Operation):
|
|
349
|
+
result: OperationPayload | None = None
|
|
350
|
+
error: ErrorObject | None = None
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def from_svc_operation(
|
|
354
|
+
operation: SvcOperation,
|
|
355
|
+
all_operations: list[SvcOperation] | None = None, # noqa: ARG004
|
|
356
|
+
) -> InvokeOperation:
|
|
357
|
+
if operation.operation_type != OperationType.CHAINED_INVOKE:
|
|
358
|
+
msg: str = f"Expected INVOKE operation, got {operation.operation_type}"
|
|
359
|
+
raise InvalidParameterValueException(msg)
|
|
360
|
+
return InvokeOperation(
|
|
361
|
+
operation_id=operation.operation_id,
|
|
362
|
+
operation_type=operation.operation_type,
|
|
363
|
+
status=operation.status,
|
|
364
|
+
parent_id=operation.parent_id,
|
|
365
|
+
name=operation.name,
|
|
366
|
+
sub_type=operation.sub_type,
|
|
367
|
+
start_timestamp=operation.start_timestamp,
|
|
368
|
+
end_timestamp=operation.end_timestamp,
|
|
369
|
+
result=operation.chained_invoke_details.result
|
|
370
|
+
if operation.chained_invoke_details
|
|
371
|
+
else None,
|
|
372
|
+
error=operation.chained_invoke_details.error
|
|
373
|
+
if operation.chained_invoke_details
|
|
374
|
+
else None,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
OPERATION_FACTORIES: MutableMapping[OperationType, type[OperationFactory]] = {
|
|
379
|
+
OperationType.EXECUTION: ExecutionOperation,
|
|
380
|
+
OperationType.CONTEXT: ContextOperation,
|
|
381
|
+
OperationType.STEP: StepOperation,
|
|
382
|
+
OperationType.WAIT: WaitOperation,
|
|
383
|
+
OperationType.CHAINED_INVOKE: InvokeOperation,
|
|
384
|
+
OperationType.CALLBACK: CallbackOperation,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def create_operation(
|
|
389
|
+
svc_operation: SvcOperation, all_operations: list[SvcOperation] | None = None
|
|
390
|
+
) -> Operation:
|
|
391
|
+
operation_class: type[OperationFactory] | None = OPERATION_FACTORIES.get(
|
|
392
|
+
svc_operation.operation_type
|
|
393
|
+
)
|
|
394
|
+
if not operation_class:
|
|
395
|
+
msg: str = f"Unknown operation type: {svc_operation.operation_type}"
|
|
396
|
+
raise DurableFunctionsTestError(msg)
|
|
397
|
+
return operation_class.from_svc_operation(svc_operation, all_operations)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _get_callback_id_from_events(
|
|
401
|
+
events: list[Event], name: str | None = None
|
|
402
|
+
) -> str | None:
|
|
403
|
+
"""
|
|
404
|
+
Get callback ID from execution history for callbacks that haven't completed.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
execution_arn: The ARN of the execution to query.
|
|
408
|
+
name: Optional callback name to search for. If not provided, returns the latest callback.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
The callback ID string for a non-completed callback, or None if not found.
|
|
412
|
+
|
|
413
|
+
Raises:
|
|
414
|
+
DurableFunctionsTestError: If the named callback has already succeeded/failed/timed out.
|
|
415
|
+
"""
|
|
416
|
+
callback_started_events = [
|
|
417
|
+
event for event in events if event.event_type == "CallbackStarted"
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
if not callback_started_events:
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
completed_callback_ids = {
|
|
424
|
+
event.event_id
|
|
425
|
+
for event in events
|
|
426
|
+
if event.event_type
|
|
427
|
+
in ["CallbackSucceeded", "CallbackFailed", "CallbackTimedOut"]
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if name is not None:
|
|
431
|
+
for event in callback_started_events:
|
|
432
|
+
if event.name == name:
|
|
433
|
+
callback_id = event.event_id
|
|
434
|
+
if callback_id in completed_callback_ids:
|
|
435
|
+
raise DurableFunctionsTestError(
|
|
436
|
+
f"Callback {name} has already completed (succeeded/failed/timed out)"
|
|
437
|
+
)
|
|
438
|
+
return (
|
|
439
|
+
event.callback_started_details.callback_id
|
|
440
|
+
if event.callback_started_details
|
|
441
|
+
else None
|
|
442
|
+
)
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
# If name is not provided, find the latest non-completed callback event
|
|
446
|
+
active_callbacks = [
|
|
447
|
+
event
|
|
448
|
+
for event in callback_started_events
|
|
449
|
+
if event.event_id not in completed_callback_ids
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
if not active_callbacks:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
latest_event = active_callbacks[-1]
|
|
456
|
+
return (
|
|
457
|
+
latest_event.callback_started_details.callback_id
|
|
458
|
+
if latest_event.callback_started_details
|
|
459
|
+
else None
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@dataclass(frozen=True)
|
|
464
|
+
class DurableFunctionTestResult:
|
|
465
|
+
status: InvocationStatus
|
|
466
|
+
operations: list[Operation]
|
|
467
|
+
result: OperationPayload | None = None
|
|
468
|
+
error: ErrorObject | None = None
|
|
469
|
+
|
|
470
|
+
@classmethod
|
|
471
|
+
def create(cls, execution: Execution) -> DurableFunctionTestResult:
|
|
472
|
+
operations = []
|
|
473
|
+
for operation in execution.operations:
|
|
474
|
+
if operation.operation_type is OperationType.EXECUTION:
|
|
475
|
+
# don't want the EXECUTION operations in the list test code asserts against
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
if operation.parent_id is None:
|
|
479
|
+
operations.append(create_operation(operation, execution.operations))
|
|
480
|
+
|
|
481
|
+
if execution.result is None:
|
|
482
|
+
msg: str = "Execution result must exist to create test result."
|
|
483
|
+
raise DurableFunctionsTestError(msg)
|
|
484
|
+
|
|
485
|
+
return cls(
|
|
486
|
+
status=execution.result.status,
|
|
487
|
+
operations=operations,
|
|
488
|
+
result=execution.result.result,
|
|
489
|
+
error=execution.result.error,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
@classmethod
|
|
493
|
+
def from_execution_history(
|
|
494
|
+
cls,
|
|
495
|
+
execution_response: GetDurableExecutionResponse,
|
|
496
|
+
history_response: GetDurableExecutionHistoryResponse,
|
|
497
|
+
) -> DurableFunctionTestResult:
|
|
498
|
+
"""Create test result from execution history responses.
|
|
499
|
+
|
|
500
|
+
Factory method for cloud runner that builds DurableFunctionTestResult
|
|
501
|
+
from GetDurableExecution and GetDurableExecutionHistory API responses.
|
|
502
|
+
"""
|
|
503
|
+
# Map status string to InvocationStatus enum
|
|
504
|
+
try:
|
|
505
|
+
status = InvocationStatus[execution_response.status]
|
|
506
|
+
except KeyError:
|
|
507
|
+
logger.warning(
|
|
508
|
+
"Unknown status: %s, defaulting to FAILED", execution_response.status
|
|
509
|
+
)
|
|
510
|
+
status = InvocationStatus.FAILED
|
|
511
|
+
|
|
512
|
+
# Convert Events to Operations - group by operation_id and merge
|
|
513
|
+
try:
|
|
514
|
+
svc_operations = events_to_operations(history_response.events)
|
|
515
|
+
except Exception as e:
|
|
516
|
+
logger.warning("Failed to convert events to operations: %s", e)
|
|
517
|
+
svc_operations = []
|
|
518
|
+
|
|
519
|
+
# Build operation tree (exclude EXECUTION type from top level)
|
|
520
|
+
operations = []
|
|
521
|
+
for svc_op in svc_operations:
|
|
522
|
+
if svc_op.operation_type == OperationType.EXECUTION:
|
|
523
|
+
continue
|
|
524
|
+
if svc_op.parent_id is None:
|
|
525
|
+
operations.append(create_operation(svc_op, svc_operations))
|
|
526
|
+
|
|
527
|
+
return cls(
|
|
528
|
+
status=status,
|
|
529
|
+
operations=operations,
|
|
530
|
+
result=execution_response.result,
|
|
531
|
+
error=execution_response.error,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def get_operation_by_name(self, name: str) -> Operation:
|
|
535
|
+
for operation in self.operations:
|
|
536
|
+
if operation.name == name:
|
|
537
|
+
return operation
|
|
538
|
+
msg: str = f"Operation with name '{name}' not found"
|
|
539
|
+
raise DurableFunctionsTestError(msg)
|
|
540
|
+
|
|
541
|
+
def get_step(self, name: str) -> StepOperation:
|
|
542
|
+
return cast(StepOperation, self.get_operation_by_name(name))
|
|
543
|
+
|
|
544
|
+
def get_wait(self, name: str) -> WaitOperation:
|
|
545
|
+
return cast(WaitOperation, self.get_operation_by_name(name))
|
|
546
|
+
|
|
547
|
+
def get_context(self, name: str) -> ContextOperation:
|
|
548
|
+
return cast(ContextOperation, self.get_operation_by_name(name))
|
|
549
|
+
|
|
550
|
+
def get_callback(self, name: str) -> CallbackOperation:
|
|
551
|
+
return cast(CallbackOperation, self.get_operation_by_name(name))
|
|
552
|
+
|
|
553
|
+
def get_invoke(self, name: str) -> InvokeOperation:
|
|
554
|
+
return cast(InvokeOperation, self.get_operation_by_name(name))
|
|
555
|
+
|
|
556
|
+
def get_execution(self, name: str) -> ExecutionOperation:
|
|
557
|
+
return cast(ExecutionOperation, self.get_operation_by_name(name))
|
|
558
|
+
|
|
559
|
+
def get_all_operations(self) -> list[Operation]:
|
|
560
|
+
"""Recursively get all operations including nested ones."""
|
|
561
|
+
all_ops = []
|
|
562
|
+
stack = list(self.operations)
|
|
563
|
+
while stack:
|
|
564
|
+
op = stack.pop()
|
|
565
|
+
all_ops.append(op)
|
|
566
|
+
# Add child operations to stack (if they exist)
|
|
567
|
+
if hasattr(op, "child_operations") and op.child_operations:
|
|
568
|
+
stack.extend(op.child_operations)
|
|
569
|
+
return all_ops
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class DurableFunctionTestRunner:
|
|
573
|
+
def __init__(self, handler: Callable, poll_interval: float = 1.0):
|
|
574
|
+
self._scheduler: Scheduler = Scheduler()
|
|
575
|
+
self._scheduler.start()
|
|
576
|
+
self._store = InMemoryExecutionStore()
|
|
577
|
+
self.poll_interval = poll_interval
|
|
578
|
+
self._checkpoint_processor = CheckpointProcessor(
|
|
579
|
+
store=self._store, scheduler=self._scheduler
|
|
580
|
+
)
|
|
581
|
+
self._service_client = InMemoryServiceClient(self._checkpoint_processor)
|
|
582
|
+
self._invoker = InProcessInvoker(handler, self._service_client)
|
|
583
|
+
self._executor = Executor(
|
|
584
|
+
store=self._store,
|
|
585
|
+
scheduler=self._scheduler,
|
|
586
|
+
invoker=self._invoker,
|
|
587
|
+
checkpoint_processor=self._checkpoint_processor,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# Wire up observer pattern - CheckpointProcessor uses this to notify executor of state changes
|
|
591
|
+
self._checkpoint_processor.add_execution_observer(self._executor)
|
|
592
|
+
|
|
593
|
+
def __enter__(self):
|
|
594
|
+
return self
|
|
595
|
+
|
|
596
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
597
|
+
self.close()
|
|
598
|
+
|
|
599
|
+
def close(self):
|
|
600
|
+
self._scheduler.stop()
|
|
601
|
+
|
|
602
|
+
def run(
|
|
603
|
+
self,
|
|
604
|
+
input: str | None = None, # noqa: A002
|
|
605
|
+
timeout: int = 900,
|
|
606
|
+
function_name: str = "test-function",
|
|
607
|
+
execution_name: str = "execution-name",
|
|
608
|
+
account_id: str = "123456789012",
|
|
609
|
+
) -> DurableFunctionTestResult:
|
|
610
|
+
execution_arn = self.run_async(
|
|
611
|
+
input=input,
|
|
612
|
+
timeout=timeout,
|
|
613
|
+
function_name=function_name,
|
|
614
|
+
execution_name=execution_name,
|
|
615
|
+
account_id=account_id,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
return self.wait_for_result(execution_arn=execution_arn, timeout=timeout)
|
|
619
|
+
|
|
620
|
+
def send_callback_success(
|
|
621
|
+
self, callback_id: str, result: bytes | None = None
|
|
622
|
+
) -> None:
|
|
623
|
+
self._executor.send_callback_success(callback_id=callback_id, result=result)
|
|
624
|
+
|
|
625
|
+
def send_callback_failure(
|
|
626
|
+
self, callback_id: str, error: ErrorObject | None = None
|
|
627
|
+
) -> None:
|
|
628
|
+
self._executor.send_callback_failure(callback_id=callback_id, error=error)
|
|
629
|
+
|
|
630
|
+
def send_callback_heartbeat(self, callback_id: str) -> None:
|
|
631
|
+
self._executor.send_callback_heartbeat(callback_id=callback_id)
|
|
632
|
+
|
|
633
|
+
def run_async(
|
|
634
|
+
self,
|
|
635
|
+
input: str | None = None, # noqa: A002
|
|
636
|
+
timeout: int = 900,
|
|
637
|
+
function_name: str = "test-function",
|
|
638
|
+
execution_name: str = "execution-name",
|
|
639
|
+
account_id: str = "123456789012",
|
|
640
|
+
) -> str:
|
|
641
|
+
start_input = StartDurableExecutionInput(
|
|
642
|
+
account_id=account_id,
|
|
643
|
+
function_name=function_name,
|
|
644
|
+
function_qualifier="$LATEST",
|
|
645
|
+
execution_name=execution_name,
|
|
646
|
+
execution_timeout_seconds=timeout,
|
|
647
|
+
execution_retention_period_days=7,
|
|
648
|
+
invocation_id="inv-12345678-1234-1234-1234-123456789012",
|
|
649
|
+
trace_fields={"trace_id": "abc123", "span_id": "def456"},
|
|
650
|
+
tenant_id="tenant-001",
|
|
651
|
+
input=input,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
output: StartDurableExecutionOutput = self._executor.start_execution(
|
|
655
|
+
start_input
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
if output.execution_arn is None:
|
|
659
|
+
msg_arn: str = "Execution ARN must exist to run test."
|
|
660
|
+
raise DurableFunctionsTestError(msg_arn)
|
|
661
|
+
return output.execution_arn
|
|
662
|
+
|
|
663
|
+
def wait_for_result(
|
|
664
|
+
self, execution_arn: str, timeout: int = 60
|
|
665
|
+
) -> DurableFunctionTestResult:
|
|
666
|
+
# Block until completion
|
|
667
|
+
completed = self._executor.wait_until_complete(execution_arn, timeout)
|
|
668
|
+
|
|
669
|
+
if not completed:
|
|
670
|
+
msg_timeout: str = "Execution did not complete within timeout"
|
|
671
|
+
|
|
672
|
+
raise TimeoutError(msg_timeout)
|
|
673
|
+
|
|
674
|
+
execution: Execution = self._store.load(execution_arn)
|
|
675
|
+
return DurableFunctionTestResult.create(execution=execution)
|
|
676
|
+
|
|
677
|
+
def wait_for_callback(
|
|
678
|
+
self, execution_arn: str, name: str | None = None, timeout: int = 60
|
|
679
|
+
) -> str:
|
|
680
|
+
start_time = time.time()
|
|
681
|
+
|
|
682
|
+
while time.time() - start_time < timeout:
|
|
683
|
+
try:
|
|
684
|
+
history_response = self._executor.get_execution_history(execution_arn)
|
|
685
|
+
callback_id = _get_callback_id_from_events(
|
|
686
|
+
events=history_response.events, name=name
|
|
687
|
+
)
|
|
688
|
+
if callback_id:
|
|
689
|
+
return callback_id
|
|
690
|
+
except ResourceNotFoundException as e:
|
|
691
|
+
pass
|
|
692
|
+
except Exception as e:
|
|
693
|
+
msg = f"Failed to fetch execution history: {e}"
|
|
694
|
+
raise DurableFunctionsTestError(msg) from e
|
|
695
|
+
|
|
696
|
+
# Wait before next poll
|
|
697
|
+
time.sleep(self.poll_interval)
|
|
698
|
+
|
|
699
|
+
# Timeout reached
|
|
700
|
+
elapsed = time.time() - start_time
|
|
701
|
+
msg = (
|
|
702
|
+
f"Callback did not available within {timeout}s "
|
|
703
|
+
f"(elapsed: {elapsed:.1f}s."
|
|
704
|
+
)
|
|
705
|
+
raise TimeoutError(msg)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class DurableChildContextTestRunner(DurableFunctionTestRunner):
|
|
709
|
+
"""Test a durable block, annotated with @durable_with_child_context, in isolation."""
|
|
710
|
+
|
|
711
|
+
def __init__(
|
|
712
|
+
self,
|
|
713
|
+
context_function: Callable[Concatenate[DurableContext, P], Any],
|
|
714
|
+
*args,
|
|
715
|
+
**kwargs,
|
|
716
|
+
):
|
|
717
|
+
# wrap the durable context around a durable execution handler as a convenience to run directly
|
|
718
|
+
@durable_execution
|
|
719
|
+
def handler(event: Any, context: DurableContext): # noqa: ARG001
|
|
720
|
+
return context_function(*args, **kwargs)(context)
|
|
721
|
+
|
|
722
|
+
super().__init__(handler)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
class WebRunner:
|
|
726
|
+
"""Web server runner for durable functions testing with HTTP API endpoints."""
|
|
727
|
+
|
|
728
|
+
def __init__(self, config: WebRunnerConfig) -> None:
|
|
729
|
+
"""Initialize WebRunner with configuration.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
config: WebRunnerConfig containing server and Lambda service settings
|
|
733
|
+
"""
|
|
734
|
+
self._config = config
|
|
735
|
+
self._server: WebServer | None = None
|
|
736
|
+
self._scheduler: Scheduler | None = None
|
|
737
|
+
self._store: ExecutionStore | None = None
|
|
738
|
+
self._invoker: LambdaInvoker | None = None
|
|
739
|
+
self._executor: Executor | None = None
|
|
740
|
+
|
|
741
|
+
def __enter__(self) -> Self:
|
|
742
|
+
"""Context manager entry point.
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
WebRunner: Self for use in with statement
|
|
746
|
+
"""
|
|
747
|
+
self.start()
|
|
748
|
+
return self
|
|
749
|
+
|
|
750
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
751
|
+
"""Context manager exit point with cleanup.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
exc_type: Exception type if an exception occurred
|
|
755
|
+
exc_val: Exception value if an exception occurred
|
|
756
|
+
exc_tb: Exception traceback if an exception occurred
|
|
757
|
+
"""
|
|
758
|
+
self.stop()
|
|
759
|
+
|
|
760
|
+
def start(self) -> None:
|
|
761
|
+
"""Start the server and initialize all dependencies.
|
|
762
|
+
|
|
763
|
+
Creates and configures all required components including scheduler,
|
|
764
|
+
store, invoker, executor, and web server. It does not however start
|
|
765
|
+
serving web requests, for that you need serve_forever.
|
|
766
|
+
|
|
767
|
+
Raises:
|
|
768
|
+
DurableFunctionsLocalRunnerError: If server is already started
|
|
769
|
+
"""
|
|
770
|
+
if self._server is not None:
|
|
771
|
+
msg = "Server is already running"
|
|
772
|
+
raise DurableFunctionsLocalRunnerError(msg)
|
|
773
|
+
|
|
774
|
+
# Create dependencies and server
|
|
775
|
+
if self._config.store_type == StoreType.SQLITE:
|
|
776
|
+
store_path = self._config.store_path
|
|
777
|
+
self._store = SQLiteExecutionStore.create_and_initialize(store_path)
|
|
778
|
+
elif self._config.store_type == StoreType.FILESYSTEM:
|
|
779
|
+
store_path = self._config.store_path or ".durable_executions"
|
|
780
|
+
self._store = FileSystemExecutionStore.create(store_path)
|
|
781
|
+
else:
|
|
782
|
+
self._store = InMemoryExecutionStore()
|
|
783
|
+
self._scheduler = Scheduler()
|
|
784
|
+
self._invoker = LambdaInvoker(self._create_boto3_client())
|
|
785
|
+
|
|
786
|
+
# Create shared CheckpointProcessor
|
|
787
|
+
checkpoint_processor = CheckpointProcessor(self._store, self._scheduler)
|
|
788
|
+
|
|
789
|
+
# Create executor with all dependencies including checkpoint processor
|
|
790
|
+
self._executor = Executor(
|
|
791
|
+
store=self._store,
|
|
792
|
+
scheduler=self._scheduler,
|
|
793
|
+
invoker=self._invoker,
|
|
794
|
+
checkpoint_processor=checkpoint_processor,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# Add executor as observer to the checkpoint processor
|
|
798
|
+
checkpoint_processor.add_execution_observer(self._executor)
|
|
799
|
+
|
|
800
|
+
# Start the scheduler
|
|
801
|
+
self._scheduler.start()
|
|
802
|
+
|
|
803
|
+
# Create web server with configuration and executor
|
|
804
|
+
self._server = WebServer(
|
|
805
|
+
config=self._config.web_service, executor=self._executor
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
def serve_forever(self) -> None:
|
|
809
|
+
"""Start serving HTTP requests indefinitely.
|
|
810
|
+
|
|
811
|
+
Delegates to the underlying WebServer.serve_forever() method.
|
|
812
|
+
This method blocks until the server is stopped.
|
|
813
|
+
|
|
814
|
+
Raises:
|
|
815
|
+
DurableFunctionsLocalRunnerError: If server has not been started
|
|
816
|
+
"""
|
|
817
|
+
if self._server is None:
|
|
818
|
+
msg = "Server not started"
|
|
819
|
+
raise DurableFunctionsLocalRunnerError(msg)
|
|
820
|
+
|
|
821
|
+
# This blocks until KeyboardInterrupt - let caller handle the exception
|
|
822
|
+
self._server.serve_forever()
|
|
823
|
+
|
|
824
|
+
def stop(self) -> None:
|
|
825
|
+
"""Stop the web server and cleanup resources.
|
|
826
|
+
|
|
827
|
+
Gracefully shuts down the server, scheduler, and cleans up
|
|
828
|
+
all allocated resources. Safe to call multiple times.
|
|
829
|
+
Handles cleanup exceptions gracefully to ensure all resources
|
|
830
|
+
are cleaned up even if some fail.
|
|
831
|
+
"""
|
|
832
|
+
if self._server is not None:
|
|
833
|
+
try:
|
|
834
|
+
self._server.server_close()
|
|
835
|
+
except Exception:
|
|
836
|
+
# Log the exception but continue cleanup
|
|
837
|
+
logger.exception("error closing web server")
|
|
838
|
+
|
|
839
|
+
self._server = None
|
|
840
|
+
|
|
841
|
+
if self._scheduler is not None:
|
|
842
|
+
try:
|
|
843
|
+
self._scheduler.stop()
|
|
844
|
+
except Exception:
|
|
845
|
+
logger.exception("error stopping scheduler")
|
|
846
|
+
self._scheduler = None
|
|
847
|
+
|
|
848
|
+
self._store = None
|
|
849
|
+
self._invoker = None
|
|
850
|
+
self._executor = None
|
|
851
|
+
|
|
852
|
+
def _create_boto3_client(self) -> Any:
|
|
853
|
+
"""Create boto3 client for lambdainternal service.
|
|
854
|
+
|
|
855
|
+
Configures AWS data path and creates a boto3 client with the
|
|
856
|
+
local runner endpoint and region from configuration.
|
|
857
|
+
|
|
858
|
+
Returns:
|
|
859
|
+
Configured boto3 client for lambdainternal service
|
|
860
|
+
|
|
861
|
+
Raises:
|
|
862
|
+
Exception: If client creation fails - exceptions propagate naturally
|
|
863
|
+
for CLI to handle as general Exception
|
|
864
|
+
"""
|
|
865
|
+
# Set up AWS data path for boto models
|
|
866
|
+
package_path = os.path.dirname(aws_durable_execution_sdk_python.__file__)
|
|
867
|
+
data_path = f"{package_path}/botocore/data"
|
|
868
|
+
os.environ["AWS_DATA_PATH"] = data_path
|
|
869
|
+
|
|
870
|
+
# Create client with Lambda endpoint configuration
|
|
871
|
+
return boto3.client(
|
|
872
|
+
"lambdainternal",
|
|
873
|
+
endpoint_url=self._config.lambda_endpoint,
|
|
874
|
+
region_name=self._config.local_runner_region,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
class DurableFunctionCloudTestRunner:
|
|
879
|
+
"""Test runner that executes durable functions against actual AWS Lambda backend.
|
|
880
|
+
|
|
881
|
+
This runner invokes deployed Lambda functions and polls for execution completion,
|
|
882
|
+
providing the same interface as DurableFunctionTestRunner for seamless test
|
|
883
|
+
compatibility between local and cloud modes.
|
|
884
|
+
|
|
885
|
+
Example:
|
|
886
|
+
>>> runner = DurableFunctionCloudTestRunner(
|
|
887
|
+
... function_name="HelloWorld-Python-PR-123", region="us-west-2"
|
|
888
|
+
... )
|
|
889
|
+
>>> with runner:
|
|
890
|
+
... result = runner.run(input={"name": "World"}, timeout=60)
|
|
891
|
+
>>> assert result.current_status == InvocationStatus.SUCCEEDED
|
|
892
|
+
"""
|
|
893
|
+
|
|
894
|
+
def __init__(
|
|
895
|
+
self,
|
|
896
|
+
function_name: str,
|
|
897
|
+
region: str = "us-west-2",
|
|
898
|
+
lambda_endpoint: str | None = None,
|
|
899
|
+
poll_interval: float = 1.0,
|
|
900
|
+
):
|
|
901
|
+
"""Initialize cloud test runner."""
|
|
902
|
+
self.function_name = function_name
|
|
903
|
+
self.region = region
|
|
904
|
+
self.lambda_endpoint = lambda_endpoint
|
|
905
|
+
self.poll_interval = poll_interval
|
|
906
|
+
|
|
907
|
+
# Set up AWS data path for custom boto models (durable execution fields)
|
|
908
|
+
package_path = os.path.dirname(aws_durable_execution_sdk_python.__file__)
|
|
909
|
+
data_path = f"{package_path}/botocore/data"
|
|
910
|
+
os.environ["AWS_DATA_PATH"] = data_path
|
|
911
|
+
|
|
912
|
+
client_config = boto3.session.Config(parameter_validation=False)
|
|
913
|
+
self.lambda_client = boto3.client(
|
|
914
|
+
"lambdainternal",
|
|
915
|
+
endpoint_url=lambda_endpoint,
|
|
916
|
+
region_name=region,
|
|
917
|
+
config=client_config,
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
def run(
|
|
921
|
+
self,
|
|
922
|
+
input: str | None = None, # noqa: A002
|
|
923
|
+
timeout: int = 60,
|
|
924
|
+
) -> DurableFunctionTestResult:
|
|
925
|
+
"""Execute function on AWS Lambda and wait for completion."""
|
|
926
|
+
logger.info(
|
|
927
|
+
"Invoking Lambda function: %s (timeout: %ds)", self.function_name, timeout
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
# JSON encode input
|
|
931
|
+
payload = json.dumps(input)
|
|
932
|
+
|
|
933
|
+
# Invoke Lambda function
|
|
934
|
+
try:
|
|
935
|
+
response = self.lambda_client.invoke(
|
|
936
|
+
FunctionName=self.function_name,
|
|
937
|
+
InvocationType="RequestResponse",
|
|
938
|
+
Payload=payload,
|
|
939
|
+
)
|
|
940
|
+
except Exception as e:
|
|
941
|
+
msg = f"Failed to invoke Lambda function {self.function_name}: {e}"
|
|
942
|
+
raise DurableFunctionsTestError(msg) from e
|
|
943
|
+
|
|
944
|
+
# Check HTTP status code, 200 for RequestResponse
|
|
945
|
+
status_code = response.get("StatusCode")
|
|
946
|
+
if status_code != 200:
|
|
947
|
+
error_payload = response["Payload"].read().decode("utf-8")
|
|
948
|
+
msg = f"Lambda invocation failed with status {status_code}: {error_payload}"
|
|
949
|
+
raise DurableFunctionsTestError(msg)
|
|
950
|
+
|
|
951
|
+
# Check for function errors, we want to return function error for testing purpose
|
|
952
|
+
if "FunctionError" in response:
|
|
953
|
+
error_payload = response["Payload"].read().decode("utf-8")
|
|
954
|
+
logger.warning("Lambda function failed: %s", error_payload)
|
|
955
|
+
|
|
956
|
+
result_payload = response["Payload"].read().decode("utf-8")
|
|
957
|
+
logger.info(
|
|
958
|
+
"Lambda invocation completed, response: %s",
|
|
959
|
+
result_payload,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
# Extract durable execution ARN from response headers
|
|
963
|
+
# The InvocationResponse includes X-Amz-Durable-Execution-Arn header
|
|
964
|
+
execution_arn = response.get("DurableExecutionArn")
|
|
965
|
+
if not execution_arn:
|
|
966
|
+
msg = (
|
|
967
|
+
f"No DurableExecutionArn in response for function {self.function_name}"
|
|
968
|
+
)
|
|
969
|
+
raise DurableFunctionsTestError(msg)
|
|
970
|
+
|
|
971
|
+
return self.wait_for_result(execution_arn=execution_arn, timeout=timeout)
|
|
972
|
+
|
|
973
|
+
def run_async(
|
|
974
|
+
self,
|
|
975
|
+
input: str | None = None, # noqa: A002
|
|
976
|
+
timeout: int = 60,
|
|
977
|
+
) -> str:
|
|
978
|
+
"""Execute function on AWS Lambda asynchronously"""
|
|
979
|
+
logger.info(
|
|
980
|
+
"Invoking Lambda function: %s (timeout: %ds)", self.function_name, timeout
|
|
981
|
+
)
|
|
982
|
+
payload = json.dumps(input)
|
|
983
|
+
try:
|
|
984
|
+
response = self.lambda_client.invoke(
|
|
985
|
+
FunctionName=self.function_name,
|
|
986
|
+
InvocationType="Event",
|
|
987
|
+
Payload=payload,
|
|
988
|
+
)
|
|
989
|
+
except Exception as e:
|
|
990
|
+
msg = f"Failed to invoke Lambda function {self.function_name}: {e}"
|
|
991
|
+
raise DurableFunctionsTestError(msg) from e
|
|
992
|
+
|
|
993
|
+
# Check HTTP status code, 202 for Event
|
|
994
|
+
status_code = response.get("StatusCode")
|
|
995
|
+
if status_code != 202:
|
|
996
|
+
error_payload = response["Payload"].read().decode("utf-8")
|
|
997
|
+
msg = f"Lambda invocation failed with status {status_code}: {error_payload}"
|
|
998
|
+
raise DurableFunctionsTestError(msg)
|
|
999
|
+
|
|
1000
|
+
return response.get("DurableExecutionArn")
|
|
1001
|
+
|
|
1002
|
+
def send_callback_success(
|
|
1003
|
+
self, callback_id: str, result: bytes | None = None
|
|
1004
|
+
) -> None:
|
|
1005
|
+
try:
|
|
1006
|
+
self.lambda_client.send_durable_execution_callback_success(
|
|
1007
|
+
CallbackId=callback_id, Result=result
|
|
1008
|
+
)
|
|
1009
|
+
except Exception as e:
|
|
1010
|
+
msg = f"Failed to send callback success for {self.function_name}, callback_id {callback_id}: {e}"
|
|
1011
|
+
raise DurableFunctionsTestError(msg) from e
|
|
1012
|
+
|
|
1013
|
+
def send_callback_failure(
|
|
1014
|
+
self, callback_id: str, error: ErrorObject | None = None
|
|
1015
|
+
) -> None:
|
|
1016
|
+
try:
|
|
1017
|
+
self.lambda_client.send_durable_execution_callback_failure(
|
|
1018
|
+
CallbackId=callback_id, Error=error.to_dict() if error else None
|
|
1019
|
+
)
|
|
1020
|
+
except Exception as e:
|
|
1021
|
+
msg = f"Failed to send callback failure for {self.function_name}, callback_id {callback_id}: {e}"
|
|
1022
|
+
raise DurableFunctionsTestError(msg) from e
|
|
1023
|
+
|
|
1024
|
+
def send_callback_heartbeat(self, callback_id: str) -> None:
|
|
1025
|
+
try:
|
|
1026
|
+
self.lambda_client.send_durable_execution_callback_heartbeat(
|
|
1027
|
+
CallbackId=callback_id
|
|
1028
|
+
)
|
|
1029
|
+
except Exception as e:
|
|
1030
|
+
msg = f"Failed to send callback heartbeat for {self.function_name}, callback_id {callback_id}: {e}"
|
|
1031
|
+
raise DurableFunctionsTestError(msg) from e
|
|
1032
|
+
|
|
1033
|
+
def _wait_for_completion(
|
|
1034
|
+
self, execution_arn: str, timeout: int
|
|
1035
|
+
) -> GetDurableExecutionResponse:
|
|
1036
|
+
"""Poll execution status until completion or timeout.
|
|
1037
|
+
|
|
1038
|
+
Args:
|
|
1039
|
+
execution_arn: ARN of the durable execution
|
|
1040
|
+
timeout: Maximum seconds to wait
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
GetDurableExecutionResponse with typed execution details
|
|
1044
|
+
|
|
1045
|
+
Raises:
|
|
1046
|
+
TimeoutError: If execution doesn't complete within timeout
|
|
1047
|
+
DurableFunctionsTestError: If status check fails
|
|
1048
|
+
"""
|
|
1049
|
+
start_time = time.time()
|
|
1050
|
+
last_status = None
|
|
1051
|
+
|
|
1052
|
+
while time.time() - start_time < timeout:
|
|
1053
|
+
try:
|
|
1054
|
+
execution_dict = self.lambda_client.get_durable_execution(
|
|
1055
|
+
DurableExecutionArn=execution_arn
|
|
1056
|
+
)
|
|
1057
|
+
execution = GetDurableExecutionResponse.from_dict(execution_dict)
|
|
1058
|
+
except Exception as e:
|
|
1059
|
+
msg = f"Failed to get execution status: {e}"
|
|
1060
|
+
raise DurableFunctionsTestError(msg) from e
|
|
1061
|
+
|
|
1062
|
+
# Log status changes
|
|
1063
|
+
if execution.status != last_status:
|
|
1064
|
+
logger.info("Execution status: %s", execution.status)
|
|
1065
|
+
last_status = execution.status
|
|
1066
|
+
|
|
1067
|
+
# Check if execution completed
|
|
1068
|
+
if execution.status == "SUCCEEDED":
|
|
1069
|
+
logger.info("Execution succeeded")
|
|
1070
|
+
return execution
|
|
1071
|
+
if execution.status == "FAILED":
|
|
1072
|
+
logger.warning("Execution failed")
|
|
1073
|
+
return execution
|
|
1074
|
+
if execution.status in ["TIMED_OUT", "ABORTED"]:
|
|
1075
|
+
logger.warning("Execution terminated: %s", execution.status)
|
|
1076
|
+
return execution
|
|
1077
|
+
|
|
1078
|
+
# Wait before next poll
|
|
1079
|
+
time.sleep(self.poll_interval)
|
|
1080
|
+
|
|
1081
|
+
# Timeout reached
|
|
1082
|
+
elapsed = time.time() - start_time
|
|
1083
|
+
msg = (
|
|
1084
|
+
f"Execution did not complete within {timeout}s "
|
|
1085
|
+
f"(elapsed: {elapsed:.1f}s, last status: {last_status})"
|
|
1086
|
+
)
|
|
1087
|
+
raise TimeoutError(msg)
|
|
1088
|
+
|
|
1089
|
+
def wait_for_result(
|
|
1090
|
+
self, execution_arn: str, timeout: int = 60
|
|
1091
|
+
) -> DurableFunctionTestResult:
|
|
1092
|
+
# Poll for completion
|
|
1093
|
+
execution_response = self._wait_for_completion(execution_arn, timeout)
|
|
1094
|
+
|
|
1095
|
+
try:
|
|
1096
|
+
history_response = self._fetch_execution_history(execution_arn)
|
|
1097
|
+
except Exception as e:
|
|
1098
|
+
msg = f"Failed to fetch execution history: {e}"
|
|
1099
|
+
raise DurableFunctionsTestError(msg) from e
|
|
1100
|
+
|
|
1101
|
+
# Build test result from execution history
|
|
1102
|
+
return DurableFunctionTestResult.from_execution_history(
|
|
1103
|
+
execution_response, history_response
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
def wait_for_callback(
|
|
1107
|
+
self, execution_arn: str, name: str | None = None, timeout: int = 60
|
|
1108
|
+
) -> str:
|
|
1109
|
+
"""
|
|
1110
|
+
Wait for and retrieve a callback ID from a Step Functions execution.
|
|
1111
|
+
|
|
1112
|
+
Polls the execution history at regular intervals until a callback ID is found
|
|
1113
|
+
or the timeout is reached.
|
|
1114
|
+
|
|
1115
|
+
Args:
|
|
1116
|
+
execution_arn: Execution Arn
|
|
1117
|
+
name: Specific callback name, default to None
|
|
1118
|
+
timeout: Maximum time in seconds to wait for callback. Defaults to 60.
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
str: The callback ID/token retrieved from the execution history
|
|
1122
|
+
|
|
1123
|
+
Raises:
|
|
1124
|
+
TimeoutError: If callback is not found within the specified timeout period
|
|
1125
|
+
DurableFunctionsTestError: If there's an error fetching execution history
|
|
1126
|
+
(excluding retryable errors)
|
|
1127
|
+
"""
|
|
1128
|
+
start_time = time.time()
|
|
1129
|
+
|
|
1130
|
+
while time.time() - start_time < timeout:
|
|
1131
|
+
try:
|
|
1132
|
+
history_response = self._fetch_execution_history(execution_arn)
|
|
1133
|
+
callback_id = _get_callback_id_from_events(
|
|
1134
|
+
events=history_response.events, name=name
|
|
1135
|
+
)
|
|
1136
|
+
if callback_id:
|
|
1137
|
+
return callback_id
|
|
1138
|
+
except ClientError as e:
|
|
1139
|
+
error_code = e.response["Error"]["Code"]
|
|
1140
|
+
# retryable error, the execution may not start yet in async invoke situation
|
|
1141
|
+
if error_code in ["ResourceNotFoundException"]:
|
|
1142
|
+
pass
|
|
1143
|
+
else:
|
|
1144
|
+
msg = f"Failed to fetch execution history: {e}"
|
|
1145
|
+
raise DurableFunctionsTestError(msg) from e
|
|
1146
|
+
except DurableFunctionsTestError as e:
|
|
1147
|
+
raise e
|
|
1148
|
+
except Exception as e:
|
|
1149
|
+
msg = f"Failed to fetch execution history: {e}"
|
|
1150
|
+
raise DurableFunctionsTestError(msg) from e
|
|
1151
|
+
|
|
1152
|
+
# Wait before next poll
|
|
1153
|
+
time.sleep(self.poll_interval)
|
|
1154
|
+
|
|
1155
|
+
# Timeout reached
|
|
1156
|
+
elapsed = time.time() - start_time
|
|
1157
|
+
msg = (
|
|
1158
|
+
f"Callback did not available within {timeout}s "
|
|
1159
|
+
f"(elapsed: {elapsed:.1f}s."
|
|
1160
|
+
)
|
|
1161
|
+
raise TimeoutError(msg)
|
|
1162
|
+
|
|
1163
|
+
def _fetch_execution_history(
|
|
1164
|
+
self, execution_arn: str
|
|
1165
|
+
) -> GetDurableExecutionHistoryResponse:
|
|
1166
|
+
"""Retrieve execution history from Lambda service.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
execution_arn: ARN of the durable execution
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
GetDurableExecutionHistoryResponse with typed Event objects
|
|
1173
|
+
|
|
1174
|
+
Raises:
|
|
1175
|
+
ClientError: If lambda client encounter error
|
|
1176
|
+
"""
|
|
1177
|
+
history_dict = self.lambda_client.get_durable_execution_history(
|
|
1178
|
+
DurableExecutionArn=execution_arn,
|
|
1179
|
+
IncludeExecutionData=True,
|
|
1180
|
+
)
|
|
1181
|
+
history_response = GetDurableExecutionHistoryResponse.from_dict(history_dict)
|
|
1182
|
+
|
|
1183
|
+
logger.info("Retrieved %d events from history", len(history_response.events))
|
|
1184
|
+
|
|
1185
|
+
return history_response
|