aws-durable-execution-sdk-python-testing 1.2.1__py3-none-any.whl → 1.3.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 +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/core.py +171 -0
- aws_durable_execution_sdk_python_testing/checkpoint/effects.py +61 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processor.py +124 -45
- aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +40 -14
- aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +5 -2
- aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +6 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +4 -9
- aws_durable_execution_sdk_python_testing/checkpoint/processors/invoke.py +139 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +15 -21
- aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +10 -16
- aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +145 -48
- aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +65 -8
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +79 -18
- aws_durable_execution_sdk_python_testing/child_dispatcher.py +828 -0
- aws_durable_execution_sdk_python_testing/cli.py +48 -1
- aws_durable_execution_sdk_python_testing/client.py +21 -10
- aws_durable_execution_sdk_python_testing/clock.py +69 -0
- aws_durable_execution_sdk_python_testing/exceptions.py +14 -0
- aws_durable_execution_sdk_python_testing/execution.py +583 -49
- aws_durable_execution_sdk_python_testing/executor.py +1813 -349
- aws_durable_execution_sdk_python_testing/invoker.py +356 -85
- aws_durable_execution_sdk_python_testing/model.py +81 -73
- aws_durable_execution_sdk_python_testing/observer.py +100 -88
- aws_durable_execution_sdk_python_testing/runner.py +407 -30
- aws_durable_execution_sdk_python_testing/scheduler.py +38 -13
- aws_durable_execution_sdk_python_testing/stores/filesystem.py +10 -2
- aws_durable_execution_sdk_python_testing/stores/sqlite.py +18 -6
- aws_durable_execution_sdk_python_testing/threads.py +157 -0
- aws_durable_execution_sdk_python_testing/token.py +11 -2
- aws_durable_execution_sdk_python_testing/web/handlers.py +33 -7
- aws_durable_execution_sdk_python_testing/web/serialization.py +1 -2
- aws_durable_execution_sdk_python_testing/web/server.py +1 -1
- aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
- aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
- aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
- aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
- aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
- aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
- aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/METADATA +50 -58
- aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/WHEEL +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
- aws_durable_execution_sdk_python_testing-1.2.1.dist-info/RECORD +0 -52
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
"""Dispatch of chained-invoke targets.
|
|
2
|
+
|
|
3
|
+
A CHAINED_INVOKE operation names a function to invoke. The in-process
|
|
4
|
+
runner resolves the name from registered handlers; the web runner
|
|
5
|
+
resolves it from a function configuration file and invokes at a
|
|
6
|
+
Lambda-compatible endpoint. A :class:`ChildDispatcher` owns that
|
|
7
|
+
difference and reports one of three results:
|
|
8
|
+
|
|
9
|
+
* ``StartChild``: a durable target; the executor creates, links, and
|
|
10
|
+
launches a child durable execution;
|
|
11
|
+
* ``RunInvocation``: a non-durable target; one invocation whose return
|
|
12
|
+
value is the outcome;
|
|
13
|
+
* ``KnownOutcome``: the target could not be dispatched.
|
|
14
|
+
|
|
15
|
+
A child execution completes the parent's operation through the
|
|
16
|
+
executor's terminal-transition hook; no dispatcher waits for it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import pathlib
|
|
24
|
+
import re
|
|
25
|
+
import threading
|
|
26
|
+
import uuid
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Final, Protocol
|
|
29
|
+
|
|
30
|
+
from botocore.exceptions import ClientError, ReadTimeoutError # type: ignore
|
|
31
|
+
|
|
32
|
+
from aws_durable_execution_sdk_python.lambda_service import ErrorObject
|
|
33
|
+
|
|
34
|
+
from aws_durable_execution_sdk_python_testing.model import (
|
|
35
|
+
StartDurableExecutionInput,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from collections.abc import Callable, Mapping
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
|
|
42
|
+
from aws_durable_execution_sdk_python_testing.clock import Clock
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# Default execution settings for a chained child execution when the
|
|
47
|
+
# registration does not specify them.
|
|
48
|
+
DEFAULT_CHILD_EXECUTION_TIMEOUT_SECONDS = 120
|
|
49
|
+
DEFAULT_CHILD_RETENTION_PERIOD_DAYS = 1
|
|
50
|
+
|
|
51
|
+
# Error surfaced on a CHAINED_INVOKE operation that timed out: a durable
|
|
52
|
+
# child whose execution timed out, or a plain target whose single
|
|
53
|
+
# invocation ran past the invocation timeout. The service reports this
|
|
54
|
+
# fixed type; a stopped or failed child instead surfaces its own error
|
|
55
|
+
# object, and a target that could not be invoked surfaces the Lambda
|
|
56
|
+
# API error code.
|
|
57
|
+
CHAINED_INVOKE_TIMEOUT_ERROR_TYPE: Final[str] = "ChainedInvoke.Timeout"
|
|
58
|
+
# Error type Lambda reports for a function that ran past its timeout.
|
|
59
|
+
FUNCTION_TIMEOUT_ERROR_TYPE: Final[str] = "Sandbox.Timedout"
|
|
60
|
+
|
|
61
|
+
# Lambda API error code for an Invoke of a function that does not exist.
|
|
62
|
+
FUNCTION_NOT_FOUND_ERROR_TYPE: Final[str] = "ResourceNotFoundException"
|
|
63
|
+
|
|
64
|
+
# Chained-invoke payload limits, as the service enforces them: the
|
|
65
|
+
# input a parent sends, the result a durable child returns, and the
|
|
66
|
+
# result a plain target returns are each capped at 1 MiB.
|
|
67
|
+
MAX_CHAINED_INVOKE_PAYLOAD_BYTES: Final[int] = 1_048_576
|
|
68
|
+
CHAINED_INVOKE_INPUT_TOO_LARGE_MESSAGE: Final[str] = (
|
|
69
|
+
"CHAINED_INVOKE input payload size must be less than or equal to "
|
|
70
|
+
f"{MAX_CHAINED_INVOKE_PAYLOAD_BYTES} bytes."
|
|
71
|
+
)
|
|
72
|
+
CHAINED_INVOKE_OUTPUT_TOO_LARGE_MESSAGE: Final[str] = (
|
|
73
|
+
"CHAINED_INVOKE output payload size must be less than or equal to "
|
|
74
|
+
f"{MAX_CHAINED_INVOKE_PAYLOAD_BYTES} bytes."
|
|
75
|
+
)
|
|
76
|
+
CHILD_EXECUTION_OUTPUT_TOO_LARGE_MESSAGE: Final[str] = (
|
|
77
|
+
"Execution output payload size must be less than or equal to "
|
|
78
|
+
f"{MAX_CHAINED_INVOKE_PAYLOAD_BYTES} bytes."
|
|
79
|
+
)
|
|
80
|
+
CHAINED_INVOKE_UTF8_DECODING_MESSAGE: Final[str] = (
|
|
81
|
+
"CHAINED_INVOKE response could not be decoded as UTF-8."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# A chained-invoke target is a Lambda function name in any form Invoke
|
|
85
|
+
# accepts: bare name, name with qualifier, partial ARN, or full ARN.
|
|
86
|
+
# Grammar and length are those of ChainedInvokeOptions.FunctionName.
|
|
87
|
+
MAX_FUNCTION_NAME_LENGTH: Final[int] = 256
|
|
88
|
+
_FUNCTION_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(
|
|
89
|
+
r"(?P<arn_prefix>arn:(aws[a-zA-Z-]*)?:lambda:)?"
|
|
90
|
+
r"((?P<region>(eusc-)?[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\d{1}):)?"
|
|
91
|
+
r"((?P<account_id>\d{12}):)?"
|
|
92
|
+
r"(function:)?"
|
|
93
|
+
r"(?P<name>[a-zA-Z0-9-_\.]+)"
|
|
94
|
+
r"(:(?P<qualifier>\$LATEST(\.PUBLISHED)?|[a-zA-Z0-9-_]+))?"
|
|
95
|
+
)
|
|
96
|
+
INVALID_FUNCTION_ARN_MESSAGE_FORMAT: Final[str] = "Invalid function ARN '{}'"
|
|
97
|
+
|
|
98
|
+
# ChainedInvokeOptions.TenantId: 1 to 256 characters from this set.
|
|
99
|
+
MAX_TENANT_ID_LENGTH: Final[int] = 256
|
|
100
|
+
_TENANT_ID_PATTERN: Final[re.Pattern[str]] = re.compile(r"[a-zA-Z0-9\._:\/=+\-@ ]+")
|
|
101
|
+
INVALID_TENANT_ID_MESSAGE: Final[str] = (
|
|
102
|
+
"TenantId must be 1 to 256 characters matching [a-zA-Z0-9._:/=+-@ ]."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_valid_tenant_id(tenant_id: str) -> bool:
|
|
107
|
+
"""Whether ``tenant_id`` satisfies the ChainedInvokeOptions.TenantId constraint."""
|
|
108
|
+
return (
|
|
109
|
+
0 < len(tenant_id) <= MAX_TENANT_ID_LENGTH
|
|
110
|
+
and _TENANT_ID_PATTERN.fullmatch(tenant_id) is not None
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
CHAINED_INVOKE_DIFFERENT_ACCOUNT_MESSAGE: Final[str] = (
|
|
115
|
+
"Cannot start a CHAINED_INVOKE on a function in another account."
|
|
116
|
+
)
|
|
117
|
+
CHAINED_INVOKE_DIFFERENT_REGION_MESSAGE: Final[str] = (
|
|
118
|
+
"Cannot start a CHAINED_INVOKE on a function in another region."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def chained_invoke_timeout_message(timeout_seconds: int) -> str:
|
|
123
|
+
"""The service's message for a chained invoke that timed out."""
|
|
124
|
+
return f"CHAINED_INVOKE timed out after {timeout_seconds} seconds."
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def chained_invoke_timeout_error(timeout_seconds: int) -> ErrorObject:
|
|
128
|
+
"""The error a timed-out CHAINED_INVOKE operation carries."""
|
|
129
|
+
return ErrorObject(
|
|
130
|
+
message=chained_invoke_timeout_message(timeout_seconds),
|
|
131
|
+
type=CHAINED_INVOKE_TIMEOUT_ERROR_TYPE,
|
|
132
|
+
data=None,
|
|
133
|
+
stack_trace=None,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def payload_size_bytes(payload: str) -> int:
|
|
138
|
+
"""Size of ``payload`` as the service measures it: UTF-8 bytes."""
|
|
139
|
+
return len(payload.encode("utf-8"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class FunctionTarget:
|
|
144
|
+
"""A parsed chained-invoke target."""
|
|
145
|
+
|
|
146
|
+
name: str
|
|
147
|
+
qualifier: str | None
|
|
148
|
+
account_id: str | None
|
|
149
|
+
region: str | None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_function_target(function_name: str) -> FunctionTarget:
|
|
153
|
+
"""Parse a Lambda function name into its parts.
|
|
154
|
+
|
|
155
|
+
Accepts the four forms Invoke accepts (bare name, name with
|
|
156
|
+
qualifier, partial ARN, full ARN). Raises ``ValueError`` for any
|
|
157
|
+
other string, including a full ARN missing its region or account.
|
|
158
|
+
"""
|
|
159
|
+
match = (
|
|
160
|
+
_FUNCTION_NAME_PATTERN.fullmatch(function_name)
|
|
161
|
+
if 0 < len(function_name) <= MAX_FUNCTION_NAME_LENGTH
|
|
162
|
+
else None
|
|
163
|
+
)
|
|
164
|
+
if match is None:
|
|
165
|
+
raise ValueError(function_name)
|
|
166
|
+
region: str | None = match.group("region")
|
|
167
|
+
account_id: str | None = match.group("account_id")
|
|
168
|
+
if match.group("arn_prefix") and (region is None or account_id is None):
|
|
169
|
+
raise ValueError(function_name)
|
|
170
|
+
return FunctionTarget(
|
|
171
|
+
name=match.group("name"),
|
|
172
|
+
qualifier=match.group("qualifier"),
|
|
173
|
+
account_id=account_id,
|
|
174
|
+
region=region,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class ChainedInvokeRequest:
|
|
180
|
+
"""A chained-invoke dispatch request in wire form.
|
|
181
|
+
|
|
182
|
+
``function_name`` is the target's bare name and ``qualifier`` its
|
|
183
|
+
alias or version, if the parent gave one.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
parent_execution_arn: str
|
|
187
|
+
operation_id: str
|
|
188
|
+
function_name: str
|
|
189
|
+
tenant_id: str | None
|
|
190
|
+
payload: str | None
|
|
191
|
+
account_id: str
|
|
192
|
+
trace_fields: dict | None = None
|
|
193
|
+
qualifier: str | None = None
|
|
194
|
+
|
|
195
|
+
def lookup_keys(self) -> tuple[str, ...]:
|
|
196
|
+
"""Keys the target's registration or configuration may sit under,
|
|
197
|
+
most specific first. See :func:`target_lookup_keys`."""
|
|
198
|
+
return target_lookup_keys(self.function_name, self.qualifier)
|
|
199
|
+
|
|
200
|
+
def child_qualifier(self) -> str:
|
|
201
|
+
"""Qualifier the child execution records."""
|
|
202
|
+
return self.qualifier if self.qualifier is not None else "$LATEST"
|
|
203
|
+
|
|
204
|
+
def invoke_identifier(self) -> str:
|
|
205
|
+
"""The function identifier to invoke, exactly as the parent wrote
|
|
206
|
+
it: ``name``, or ``name:qualifier`` including an explicit $LATEST."""
|
|
207
|
+
if self.qualifier is None:
|
|
208
|
+
return self.function_name
|
|
209
|
+
return f"{self.function_name}:{self.qualifier}"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def target_lookup_keys(function_name: str, qualifier: str | None) -> tuple[str, ...]:
|
|
213
|
+
"""Keys a target's registration or configuration may sit under, most
|
|
214
|
+
specific first.
|
|
215
|
+
|
|
216
|
+
The runner has one registration per key, not versions: a qualified
|
|
217
|
+
key wins when present, otherwise the bare name serves every
|
|
218
|
+
qualifier. The key selects the registration only; what is invoked
|
|
219
|
+
is the identifier as the parent wrote it.
|
|
220
|
+
"""
|
|
221
|
+
if qualifier is None:
|
|
222
|
+
return (function_name,)
|
|
223
|
+
return (f"{function_name}:{qualifier}", function_name)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def invoke_identifier(function_name: str, qualifier: str) -> str:
|
|
227
|
+
"""The identifier a durable execution's handler is invoked by:
|
|
228
|
+
``name`` for $LATEST, else ``name:qualifier``."""
|
|
229
|
+
if qualifier == "$LATEST":
|
|
230
|
+
return function_name
|
|
231
|
+
return f"{function_name}:{qualifier}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class ChildOutcome:
|
|
236
|
+
"""Terminal outcome of a single-invocation chained target.
|
|
237
|
+
|
|
238
|
+
``timed_out`` marks an Invoke the endpoint did not answer within the
|
|
239
|
+
invocation timeout; ``error`` then carries the service's timeout
|
|
240
|
+
error and the operation ends TIMED_OUT rather than FAILED.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
result: str | None = None
|
|
244
|
+
error: ErrorObject | None = None
|
|
245
|
+
timed_out: bool = False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def timed_out_outcome(timeout_seconds: int) -> ChildOutcome:
|
|
249
|
+
"""Outcome of a plain target the endpoint did not answer for within ``timeout_seconds``."""
|
|
250
|
+
return ChildOutcome(
|
|
251
|
+
error=chained_invoke_timeout_error(timeout_seconds), timed_out=True
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def function_timeout_outcome(
|
|
256
|
+
timeout_seconds: int, request_id: str, now: datetime
|
|
257
|
+
) -> ChildOutcome:
|
|
258
|
+
"""Outcome of a plain target that ran past its function timeout.
|
|
259
|
+
|
|
260
|
+
Lambda ends the function and answers the Invoke with a function
|
|
261
|
+
error, so the operation ends FAILED with that error, as it does at
|
|
262
|
+
the service. The message follows Lambda's own.
|
|
263
|
+
"""
|
|
264
|
+
stamp: str = now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
265
|
+
return ChildOutcome(
|
|
266
|
+
error=ErrorObject(
|
|
267
|
+
message=f"{stamp} {request_id} Task timed out after {timeout_seconds:.2f} seconds",
|
|
268
|
+
type=FUNCTION_TIMEOUT_ERROR_TYPE,
|
|
269
|
+
data=None,
|
|
270
|
+
stack_trace=None,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def result_outcome(result: str | None) -> ChildOutcome:
|
|
276
|
+
"""Outcome of a plain target that returned ``result``, size-checked."""
|
|
277
|
+
if (
|
|
278
|
+
result is not None
|
|
279
|
+
and payload_size_bytes(result) > MAX_CHAINED_INVOKE_PAYLOAD_BYTES
|
|
280
|
+
):
|
|
281
|
+
return ChildOutcome(
|
|
282
|
+
error=ErrorObject.from_message(CHAINED_INVOKE_OUTPUT_TOO_LARGE_MESSAGE)
|
|
283
|
+
)
|
|
284
|
+
return ChildOutcome(result=result)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(frozen=True)
|
|
288
|
+
class StartChild:
|
|
289
|
+
"""Start a new child durable execution.
|
|
290
|
+
|
|
291
|
+
``child_start.lambda_endpoint`` is ``None``. A per-execution endpoint
|
|
292
|
+
serves one function alone, and the child is another function, so
|
|
293
|
+
the parent's own endpoint is not the child's. The executor pins the
|
|
294
|
+
child to the endpoint the parent's chained targets go to
|
|
295
|
+
(``Invoker.inherit_endpoint``).
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
child_start: StartDurableExecutionInput
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True)
|
|
302
|
+
class RunInvocation:
|
|
303
|
+
"""Run the target as a single invocation off the worker lanes.
|
|
304
|
+
|
|
305
|
+
The callable blocks for at most one invocation of a non-durable
|
|
306
|
+
target and returns that invocation's outcome.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
invocation: Callable[[], ChildOutcome]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class KnownOutcome:
|
|
314
|
+
"""The terminal outcome is already known."""
|
|
315
|
+
|
|
316
|
+
outcome: ChildOutcome
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
DispatchResult = StartChild | RunInvocation | KnownOutcome
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def failed_to_start(message: str, error_type: str | None = None) -> KnownOutcome:
|
|
323
|
+
"""Build the outcome for a target that could not be dispatched.
|
|
324
|
+
|
|
325
|
+
``error_type`` is the Lambda API error code when one applies (for
|
|
326
|
+
example ``ResourceNotFoundException``); a dispatch failure with no
|
|
327
|
+
API counterpart carries a message only, as the service does.
|
|
328
|
+
"""
|
|
329
|
+
return KnownOutcome(
|
|
330
|
+
outcome=ChildOutcome(
|
|
331
|
+
error=ErrorObject(
|
|
332
|
+
message=message,
|
|
333
|
+
type=error_type,
|
|
334
|
+
data=None,
|
|
335
|
+
stack_trace=None,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def function_not_found(function_name: str, hint: str = "") -> KnownOutcome:
|
|
342
|
+
"""Build the outcome for a target that is not a known function."""
|
|
343
|
+
return failed_to_start(
|
|
344
|
+
f"Function not found: {function_name}.{hint}",
|
|
345
|
+
error_type=FUNCTION_NOT_FOUND_ERROR_TYPE,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def invoke_function(
|
|
350
|
+
lambda_client: Any,
|
|
351
|
+
function_name: str,
|
|
352
|
+
payload: str | None,
|
|
353
|
+
tenant_id: str | None,
|
|
354
|
+
invocation_timeout_seconds: int,
|
|
355
|
+
) -> ChildOutcome:
|
|
356
|
+
"""Invoke non-durable ``function_name`` once at a Lambda-compatible endpoint.
|
|
357
|
+
|
|
358
|
+
The response payload (or function error) is the outcome. An Invoke
|
|
359
|
+
API error fails the operation with that error's code and message,
|
|
360
|
+
as the service reports it. A read timeout means the target ran past
|
|
361
|
+
``invocation_timeout_seconds``, which the service reports as a
|
|
362
|
+
chained-invoke timeout.
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
kwargs: dict[str, Any] = {
|
|
366
|
+
"FunctionName": function_name,
|
|
367
|
+
"InvocationType": "RequestResponse",
|
|
368
|
+
"Payload": payload if payload is not None else "{}",
|
|
369
|
+
}
|
|
370
|
+
if tenant_id is not None:
|
|
371
|
+
kwargs["TenantId"] = tenant_id
|
|
372
|
+
response: dict[str, Any] = lambda_client.invoke(**kwargs)
|
|
373
|
+
# The response headers arrive when the target returns; the body
|
|
374
|
+
# streams after them and can time out on its own.
|
|
375
|
+
raw_body: bytes = response["Payload"].read()
|
|
376
|
+
except ReadTimeoutError:
|
|
377
|
+
logger.info("Chained invoke of %s timed out", function_name)
|
|
378
|
+
return timed_out_outcome(invocation_timeout_seconds)
|
|
379
|
+
except ClientError as err:
|
|
380
|
+
logger.info("Chained invoke of %s failed to start: %s", function_name, err)
|
|
381
|
+
api_error: dict[str, Any] = err.response.get("Error", {})
|
|
382
|
+
return ChildOutcome(
|
|
383
|
+
error=ErrorObject(
|
|
384
|
+
message=api_error.get("Message") or str(err),
|
|
385
|
+
type=api_error.get("Code"),
|
|
386
|
+
data=None,
|
|
387
|
+
stack_trace=None,
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
except Exception as err: # noqa: BLE001 — dispatch failure fails the operation
|
|
391
|
+
logger.info("Chained invoke of %s failed to start: %s", function_name, err)
|
|
392
|
+
return ChildOutcome(
|
|
393
|
+
error=ErrorObject(
|
|
394
|
+
message=f"Failed to invoke {function_name}: {err}",
|
|
395
|
+
type=None,
|
|
396
|
+
data=None,
|
|
397
|
+
stack_trace=None,
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
body: str = raw_body.decode("utf-8")
|
|
403
|
+
except UnicodeDecodeError:
|
|
404
|
+
return ChildOutcome(
|
|
405
|
+
error=ErrorObject.from_message(CHAINED_INVOKE_UTF8_DECODING_MESSAGE)
|
|
406
|
+
)
|
|
407
|
+
if "FunctionError" in response:
|
|
408
|
+
return ChildOutcome(error=_error_from_invoke_body(body))
|
|
409
|
+
return result_outcome(body if body else None)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class ChildDispatcher(Protocol):
|
|
413
|
+
"""Dispatches a chained-invoke target for one environment."""
|
|
414
|
+
|
|
415
|
+
def preflight(self, target: FunctionTarget) -> ChildOutcome | None:
|
|
416
|
+
"""The outcome of ``target`` known before anything is dispatched,
|
|
417
|
+
or ``None`` when dispatch may proceed.
|
|
418
|
+
|
|
419
|
+
The service resolves a target before it schedules anything. A
|
|
420
|
+
target it cannot resolve comes back FAILED in the checkpoint
|
|
421
|
+
response itself, so the handler learns of it without
|
|
422
|
+
suspending. This is the runner's counterpart. It consults only
|
|
423
|
+
what the runner already holds (registrations or configurations)
|
|
424
|
+
and never the endpoint, so it returns at once.
|
|
425
|
+
"""
|
|
426
|
+
...
|
|
427
|
+
|
|
428
|
+
def dispatch(self, request: ChainedInvokeRequest) -> DispatchResult:
|
|
429
|
+
"""Dispatch ``request`` and report how it will complete.
|
|
430
|
+
|
|
431
|
+
Runs off the parent execution's worker lane. May block for the
|
|
432
|
+
duration of a single invocation of a non-durable target, but
|
|
433
|
+
must not block on a durable child reaching a terminal state.
|
|
434
|
+
"""
|
|
435
|
+
...
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@dataclass(frozen=True)
|
|
439
|
+
class RegisteredFunction:
|
|
440
|
+
"""A function registered with the in-process runner."""
|
|
441
|
+
|
|
442
|
+
handler: Callable[..., Any]
|
|
443
|
+
is_durable: bool
|
|
444
|
+
execution_timeout_seconds: int = DEFAULT_CHILD_EXECUTION_TIMEOUT_SECONDS
|
|
445
|
+
retention_period_days: int = DEFAULT_CHILD_RETENTION_PERIOD_DAYS
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class FunctionRegistry:
|
|
449
|
+
"""Name-to-handler registry for the in-process runner."""
|
|
450
|
+
|
|
451
|
+
def __init__(self) -> None:
|
|
452
|
+
self._functions: dict[str, RegisteredFunction] = {}
|
|
453
|
+
|
|
454
|
+
def register(
|
|
455
|
+
self,
|
|
456
|
+
function_name: str,
|
|
457
|
+
handler: Callable[..., Any],
|
|
458
|
+
*,
|
|
459
|
+
is_durable: bool,
|
|
460
|
+
execution_timeout_seconds: int = DEFAULT_CHILD_EXECUTION_TIMEOUT_SECONDS,
|
|
461
|
+
retention_period_days: int = DEFAULT_CHILD_RETENTION_PERIOD_DAYS,
|
|
462
|
+
) -> None:
|
|
463
|
+
"""Register ``handler`` under ``function_name``."""
|
|
464
|
+
self._functions[function_name] = RegisteredFunction(
|
|
465
|
+
handler=handler,
|
|
466
|
+
is_durable=is_durable,
|
|
467
|
+
execution_timeout_seconds=execution_timeout_seconds,
|
|
468
|
+
retention_period_days=retention_period_days,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def get(self, function_name: str) -> RegisteredFunction | None:
|
|
472
|
+
"""Return the registration for ``function_name`` if present."""
|
|
473
|
+
return self._functions.get(function_name)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class InProcessChildDispatcher:
|
|
477
|
+
"""Resolves chained-invoke targets from registered handlers.
|
|
478
|
+
|
|
479
|
+
A durable registration becomes a child durable execution start; a
|
|
480
|
+
non-durable registration runs as a single handler call whose
|
|
481
|
+
payload and result cross the seam in serialized form; an unknown
|
|
482
|
+
name fails to start.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
def __init__(
|
|
486
|
+
self,
|
|
487
|
+
registry: FunctionRegistry,
|
|
488
|
+
context_factory: Callable[[ChainedInvokeRequest], Any],
|
|
489
|
+
invocation_timeout_seconds: int,
|
|
490
|
+
clock: Clock,
|
|
491
|
+
) -> None:
|
|
492
|
+
"""``context_factory`` builds the Lambda context a plain target
|
|
493
|
+
receives; ``clock`` stamps a function-timeout error."""
|
|
494
|
+
self._registry = registry
|
|
495
|
+
self._context_factory = context_factory
|
|
496
|
+
self._invocation_timeout_seconds = invocation_timeout_seconds
|
|
497
|
+
self._clock = clock
|
|
498
|
+
|
|
499
|
+
def _find_registration(
|
|
500
|
+
self, function_name: str, qualifier: str | None
|
|
501
|
+
) -> RegisteredFunction | None:
|
|
502
|
+
for key in target_lookup_keys(function_name, qualifier):
|
|
503
|
+
registration: RegisteredFunction | None = self._registry.get(key)
|
|
504
|
+
if registration is not None:
|
|
505
|
+
return registration
|
|
506
|
+
return None
|
|
507
|
+
|
|
508
|
+
@staticmethod
|
|
509
|
+
def _not_found(function_name: str) -> KnownOutcome:
|
|
510
|
+
return function_not_found(
|
|
511
|
+
function_name,
|
|
512
|
+
hint=" Register it with register_durable_function or register_function.",
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def preflight(self, target: FunctionTarget) -> ChildOutcome | None:
|
|
516
|
+
"""Fail a target no handler is registered for."""
|
|
517
|
+
if self._find_registration(target.name, target.qualifier) is None:
|
|
518
|
+
return self._not_found(target.name).outcome
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
def dispatch(self, request: ChainedInvokeRequest) -> DispatchResult:
|
|
522
|
+
"""Dispatch ``request`` against the registered functions."""
|
|
523
|
+
registration: RegisteredFunction | None = self._find_registration(
|
|
524
|
+
request.function_name, request.qualifier
|
|
525
|
+
)
|
|
526
|
+
if registration is None:
|
|
527
|
+
return self._not_found(request.function_name)
|
|
528
|
+
|
|
529
|
+
if registration.is_durable:
|
|
530
|
+
return StartChild(
|
|
531
|
+
child_start=StartDurableExecutionInput(
|
|
532
|
+
account_id=request.account_id,
|
|
533
|
+
function_name=request.function_name,
|
|
534
|
+
function_qualifier=request.child_qualifier(),
|
|
535
|
+
execution_name=str(uuid.uuid4()),
|
|
536
|
+
execution_timeout_seconds=registration.execution_timeout_seconds,
|
|
537
|
+
execution_retention_period_days=registration.retention_period_days,
|
|
538
|
+
invocation_id=None,
|
|
539
|
+
trace_fields=request.trace_fields,
|
|
540
|
+
tenant_id=request.tenant_id,
|
|
541
|
+
input=request.payload,
|
|
542
|
+
lambda_endpoint=None,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
handler: Callable[..., Any] = registration.handler
|
|
547
|
+
return RunInvocation(
|
|
548
|
+
invocation=lambda: self._run_single_invocation(handler, request)
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def _run_single_invocation(
|
|
552
|
+
self, handler: Callable[..., Any], request: ChainedInvokeRequest
|
|
553
|
+
) -> ChildOutcome:
|
|
554
|
+
"""Run ``handler`` once, bounded by the invocation timeout.
|
|
555
|
+
|
|
556
|
+
The handler runs on its own thread so the bound can be enforced.
|
|
557
|
+
A handler still running at the deadline is abandoned and the
|
|
558
|
+
outcome is Lambda's function-timeout error, as the runner does
|
|
559
|
+
for a handler invocation that exceeds the timeout. Python cannot
|
|
560
|
+
terminate a thread, so the abandoned handler keeps running until
|
|
561
|
+
it returns and its result is discarded. Lambda stops the sandbox
|
|
562
|
+
at the timeout; the in-process runner cannot, for this handler
|
|
563
|
+
as for its durable handlers.
|
|
564
|
+
"""
|
|
565
|
+
outcome: list[ChildOutcome] = []
|
|
566
|
+
context: Any = self._context_factory(request)
|
|
567
|
+
|
|
568
|
+
def run() -> None:
|
|
569
|
+
try:
|
|
570
|
+
payload: str | None = request.payload
|
|
571
|
+
event: Any = json.loads(payload) if payload else None
|
|
572
|
+
result: Any = handler(event, context)
|
|
573
|
+
outcome.append(result_outcome(json.dumps(result)))
|
|
574
|
+
except Exception as err: # noqa: BLE001 — the outcome carries the error
|
|
575
|
+
logger.info("Chained invoke target raised: %s", err)
|
|
576
|
+
outcome.append(ChildOutcome(error=ErrorObject.from_exception(err)))
|
|
577
|
+
|
|
578
|
+
thread = threading.Thread(
|
|
579
|
+
target=run, name="durable-chained-invoke-target", daemon=True
|
|
580
|
+
)
|
|
581
|
+
thread.start()
|
|
582
|
+
thread.join(self._invocation_timeout_seconds)
|
|
583
|
+
if thread.is_alive():
|
|
584
|
+
logger.info("Chained invoke target exceeded the invocation timeout")
|
|
585
|
+
return function_timeout_outcome(
|
|
586
|
+
self._invocation_timeout_seconds,
|
|
587
|
+
str(getattr(context, "aws_request_id", "")),
|
|
588
|
+
self._clock.now(),
|
|
589
|
+
)
|
|
590
|
+
return outcome[0]
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _error_from_invoke_body(body: str) -> ErrorObject:
|
|
594
|
+
"""Build an ErrorObject from a function-error invoke response body."""
|
|
595
|
+
try:
|
|
596
|
+
data: dict[str, Any] = json.loads(body)
|
|
597
|
+
except (json.JSONDecodeError, TypeError):
|
|
598
|
+
return ErrorObject.from_message(body or "Chained invoke failed.")
|
|
599
|
+
return ErrorObject(
|
|
600
|
+
message=data.get("errorMessage"),
|
|
601
|
+
type=data.get("errorType"),
|
|
602
|
+
data=data.get("errorData"),
|
|
603
|
+
stack_trace=data.get("stackTrace"),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@dataclass(frozen=True)
|
|
608
|
+
class FunctionConfig:
|
|
609
|
+
"""Durability configuration for a chained-invoke target function.
|
|
610
|
+
|
|
611
|
+
Mirrors the Lambda function configuration: a function is durable
|
|
612
|
+
when it carries a ``DurableConfig``, whose ``ExecutionTimeout`` and
|
|
613
|
+
``RetentionPeriodInDays`` bound its executions. A function without
|
|
614
|
+
one is a plain function.
|
|
615
|
+
"""
|
|
616
|
+
|
|
617
|
+
is_durable: bool
|
|
618
|
+
execution_timeout_seconds: int = DEFAULT_CHILD_EXECUTION_TIMEOUT_SECONDS
|
|
619
|
+
retention_period_days: int = DEFAULT_CHILD_RETENTION_PERIOD_DAYS
|
|
620
|
+
|
|
621
|
+
@classmethod
|
|
622
|
+
def from_dict(cls, data: Any, name: str | None = None) -> FunctionConfig:
|
|
623
|
+
"""Build from one entry of the function configurations.
|
|
624
|
+
|
|
625
|
+
``{}`` or ``null`` is a plain function. ``{"DurableConfig": {...}}``
|
|
626
|
+
is a durable function; fields the ``DurableConfig`` omits take
|
|
627
|
+
the runner defaults. Any other shape raises ``ValueError`` naming
|
|
628
|
+
``name``: a malformed entry must not turn a durable target plain
|
|
629
|
+
or fail with a bare traceback.
|
|
630
|
+
"""
|
|
631
|
+
if data is None:
|
|
632
|
+
return cls(is_durable=False)
|
|
633
|
+
if not isinstance(data, dict):
|
|
634
|
+
raise ValueError(
|
|
635
|
+
_malformed(name, "the entry must be a JSON object or null")
|
|
636
|
+
)
|
|
637
|
+
durable_config: Any = data.get("DurableConfig")
|
|
638
|
+
if durable_config is None:
|
|
639
|
+
return cls(is_durable=False)
|
|
640
|
+
if not isinstance(durable_config, dict):
|
|
641
|
+
raise ValueError(_malformed(name, "DurableConfig must be a JSON object"))
|
|
642
|
+
return cls(
|
|
643
|
+
is_durable=True,
|
|
644
|
+
execution_timeout_seconds=_positive_int(
|
|
645
|
+
durable_config,
|
|
646
|
+
"ExecutionTimeout",
|
|
647
|
+
DEFAULT_CHILD_EXECUTION_TIMEOUT_SECONDS,
|
|
648
|
+
name,
|
|
649
|
+
),
|
|
650
|
+
retention_period_days=_positive_int(
|
|
651
|
+
durable_config,
|
|
652
|
+
"RetentionPeriodInDays",
|
|
653
|
+
DEFAULT_CHILD_RETENTION_PERIOD_DAYS,
|
|
654
|
+
name,
|
|
655
|
+
),
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _malformed(name: str | None, detail: str) -> str:
|
|
660
|
+
"""Message for a malformed function configuration entry."""
|
|
661
|
+
subject = (
|
|
662
|
+
f"Function configuration for {name!r}" if name else "Function configuration"
|
|
663
|
+
)
|
|
664
|
+
return f"{subject}: {detail}."
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _positive_int(
|
|
668
|
+
config: dict[str, Any], field: str, default: int, name: str | None
|
|
669
|
+
) -> int:
|
|
670
|
+
"""``config[field]`` as a positive integer, or ``default`` when absent."""
|
|
671
|
+
value: Any = config.get(field, default)
|
|
672
|
+
# bool is an int subclass; true/false are not durations.
|
|
673
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
674
|
+
msg = _malformed(name, f"DurableConfig.{field} must be a positive integer")
|
|
675
|
+
raise ValueError(msg)
|
|
676
|
+
return value
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
FILE_URL_PREFIX: Final[str] = "file://"
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@dataclass(frozen=True)
|
|
683
|
+
class FunctionConfigs:
|
|
684
|
+
"""The functions a durable function may invoke, by name.
|
|
685
|
+
|
|
686
|
+
Each key is a function name a parent may pass to ``context.invoke``;
|
|
687
|
+
each value is that function's :class:`FunctionConfig`. Built from
|
|
688
|
+
the ``--function-configs`` value with :meth:`from_value`, or from
|
|
689
|
+
an already parsed mapping with :meth:`from_dict`.
|
|
690
|
+
"""
|
|
691
|
+
|
|
692
|
+
by_name: Mapping[str, FunctionConfig]
|
|
693
|
+
|
|
694
|
+
@classmethod
|
|
695
|
+
def from_value(cls, value: str) -> FunctionConfigs:
|
|
696
|
+
"""Parse the ``--function-configs`` value.
|
|
697
|
+
|
|
698
|
+
``value`` is a JSON object mapping function names to entries in
|
|
699
|
+
the shape :meth:`FunctionConfig.from_dict` reads, or
|
|
700
|
+
``file://<path>`` naming a file that holds one. Raises
|
|
701
|
+
``ValueError`` for malformed JSON or a non-object, and ``OSError``
|
|
702
|
+
for an unreadable file.
|
|
703
|
+
"""
|
|
704
|
+
text: str = value
|
|
705
|
+
if value.startswith(FILE_URL_PREFIX):
|
|
706
|
+
text = pathlib.Path(value[len(FILE_URL_PREFIX) :]).read_text(
|
|
707
|
+
encoding="utf-8"
|
|
708
|
+
)
|
|
709
|
+
raw: Any = json.loads(text)
|
|
710
|
+
if not isinstance(raw, dict):
|
|
711
|
+
msg = "--function-configs must be a JSON object mapping function names to configurations."
|
|
712
|
+
raise ValueError(msg)
|
|
713
|
+
return cls.from_dict(raw)
|
|
714
|
+
|
|
715
|
+
@classmethod
|
|
716
|
+
def from_dict(cls, raw: Mapping[str, Any]) -> FunctionConfigs:
|
|
717
|
+
"""Build from a parsed name-to-entry mapping; a malformed entry raises ``ValueError``."""
|
|
718
|
+
return cls(
|
|
719
|
+
{name: FunctionConfig.from_dict(entry, name) for name, entry in raw.items()}
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
def resolve(self, request: ChainedInvokeRequest) -> FunctionConfig | None:
|
|
723
|
+
"""The configuration for ``request``'s target, if any."""
|
|
724
|
+
return self.lookup(request.function_name, request.qualifier)
|
|
725
|
+
|
|
726
|
+
def lookup(
|
|
727
|
+
self, function_name: str, qualifier: str | None
|
|
728
|
+
) -> FunctionConfig | None:
|
|
729
|
+
"""The configuration for a target, if any.
|
|
730
|
+
|
|
731
|
+
The entry under the qualified identifier wins; otherwise the
|
|
732
|
+
bare name's entry serves every qualifier.
|
|
733
|
+
"""
|
|
734
|
+
for key in target_lookup_keys(function_name, qualifier):
|
|
735
|
+
config: FunctionConfig | None = self.by_name.get(key)
|
|
736
|
+
if config is not None:
|
|
737
|
+
return config
|
|
738
|
+
return None
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
class UnconfiguredChildDispatcher:
|
|
742
|
+
"""Fails every chained invoke because no function configuration was given.
|
|
743
|
+
|
|
744
|
+
The web runner cannot learn a target's durability from the endpoint,
|
|
745
|
+
so without the configuration it cannot dispatch; the error names the
|
|
746
|
+
option.
|
|
747
|
+
"""
|
|
748
|
+
|
|
749
|
+
def preflight(self, target: FunctionTarget) -> ChildOutcome | None:
|
|
750
|
+
"""Every target fails: the runner cannot resolve any."""
|
|
751
|
+
return self._unconfigured(target.name).outcome
|
|
752
|
+
|
|
753
|
+
def dispatch(self, request: ChainedInvokeRequest) -> DispatchResult:
|
|
754
|
+
"""Fail ``request`` with a message naming the missing option."""
|
|
755
|
+
return self._unconfigured(request.function_name)
|
|
756
|
+
|
|
757
|
+
@staticmethod
|
|
758
|
+
def _unconfigured(function_name: str) -> KnownOutcome:
|
|
759
|
+
return failed_to_start(
|
|
760
|
+
f"Cannot invoke {function_name}: the local runner has no "
|
|
761
|
+
"function configurations. Start it with --function-configs "
|
|
762
|
+
"mapping each function name to its configuration."
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
class EndpointChildDispatcher:
|
|
767
|
+
"""Resolves chained-invoke targets against configured functions served
|
|
768
|
+
by a Lambda-compatible endpoint.
|
|
769
|
+
|
|
770
|
+
A durable target becomes a child durable execution start: this
|
|
771
|
+
runner creates the child and invokes its handler at the endpoint by
|
|
772
|
+
function name through the normal invocation path. A non-durable
|
|
773
|
+
target runs as one RequestResponse invoke at the endpoint. An
|
|
774
|
+
unknown name fails to start with ``ResourceNotFoundException``.
|
|
775
|
+
"""
|
|
776
|
+
|
|
777
|
+
def __init__(
|
|
778
|
+
self,
|
|
779
|
+
function_configs: FunctionConfigs,
|
|
780
|
+
client_provider: Callable[[str], Any],
|
|
781
|
+
invocation_timeout_seconds: int,
|
|
782
|
+
) -> None:
|
|
783
|
+
"""``client_provider`` maps a parent execution ARN to the Lambda
|
|
784
|
+
client its non-durable targets are invoked with, so a target goes
|
|
785
|
+
to the endpoint the parent is invoked at."""
|
|
786
|
+
self._function_configs = function_configs
|
|
787
|
+
self._client_provider = client_provider
|
|
788
|
+
self._invocation_timeout_seconds = invocation_timeout_seconds
|
|
789
|
+
|
|
790
|
+
def preflight(self, target: FunctionTarget) -> ChildOutcome | None:
|
|
791
|
+
"""Fail a target no configuration names."""
|
|
792
|
+
if self._function_configs.lookup(target.name, target.qualifier) is None:
|
|
793
|
+
return function_not_found(target.name).outcome
|
|
794
|
+
return None
|
|
795
|
+
|
|
796
|
+
def dispatch(self, request: ChainedInvokeRequest) -> DispatchResult:
|
|
797
|
+
"""Dispatch ``request`` against the configured functions."""
|
|
798
|
+
config: FunctionConfig | None = self._function_configs.resolve(request)
|
|
799
|
+
if config is None:
|
|
800
|
+
return function_not_found(request.function_name)
|
|
801
|
+
|
|
802
|
+
if config.is_durable:
|
|
803
|
+
return StartChild(
|
|
804
|
+
child_start=StartDurableExecutionInput(
|
|
805
|
+
account_id=request.account_id,
|
|
806
|
+
function_name=request.function_name,
|
|
807
|
+
function_qualifier=request.child_qualifier(),
|
|
808
|
+
execution_name=str(uuid.uuid4()),
|
|
809
|
+
execution_timeout_seconds=config.execution_timeout_seconds,
|
|
810
|
+
execution_retention_period_days=config.retention_period_days,
|
|
811
|
+
invocation_id=None,
|
|
812
|
+
trace_fields=request.trace_fields,
|
|
813
|
+
tenant_id=request.tenant_id,
|
|
814
|
+
input=request.payload,
|
|
815
|
+
lambda_endpoint=None,
|
|
816
|
+
)
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
client: Any = self._client_provider(request.parent_execution_arn)
|
|
820
|
+
function_name: str = request.invoke_identifier()
|
|
821
|
+
payload: str | None = request.payload
|
|
822
|
+
tenant_id: str | None = request.tenant_id
|
|
823
|
+
timeout_seconds: int = self._invocation_timeout_seconds
|
|
824
|
+
return RunInvocation(
|
|
825
|
+
invocation=lambda: invoke_function(
|
|
826
|
+
client, function_name, payload, tenant_id, timeout_seconds
|
|
827
|
+
)
|
|
828
|
+
)
|