aws-durable-execution-sdk-python-testing 1.2.0__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.
Files changed (49) hide show
  1. aws_durable_execution_sdk_python_testing/__about__.py +1 -1
  2. aws_durable_execution_sdk_python_testing/checkpoint/core.py +171 -0
  3. aws_durable_execution_sdk_python_testing/checkpoint/effects.py +61 -0
  4. aws_durable_execution_sdk_python_testing/checkpoint/processor.py +124 -45
  5. aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +40 -14
  6. aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +5 -2
  7. aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +6 -0
  8. aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +4 -9
  9. aws_durable_execution_sdk_python_testing/checkpoint/processors/invoke.py +139 -0
  10. aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +15 -21
  11. aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +10 -16
  12. aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +145 -48
  13. aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +65 -8
  14. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +79 -18
  15. aws_durable_execution_sdk_python_testing/child_dispatcher.py +828 -0
  16. aws_durable_execution_sdk_python_testing/cli.py +48 -1
  17. aws_durable_execution_sdk_python_testing/client.py +21 -10
  18. aws_durable_execution_sdk_python_testing/clock.py +69 -0
  19. aws_durable_execution_sdk_python_testing/exceptions.py +14 -0
  20. aws_durable_execution_sdk_python_testing/execution.py +583 -49
  21. aws_durable_execution_sdk_python_testing/executor.py +1813 -349
  22. aws_durable_execution_sdk_python_testing/invoker.py +356 -85
  23. aws_durable_execution_sdk_python_testing/model.py +81 -73
  24. aws_durable_execution_sdk_python_testing/observer.py +100 -88
  25. aws_durable_execution_sdk_python_testing/runner.py +407 -30
  26. aws_durable_execution_sdk_python_testing/scheduler.py +38 -13
  27. aws_durable_execution_sdk_python_testing/stores/filesystem.py +10 -2
  28. aws_durable_execution_sdk_python_testing/stores/sqlite.py +18 -6
  29. aws_durable_execution_sdk_python_testing/threads.py +157 -0
  30. aws_durable_execution_sdk_python_testing/token.py +11 -2
  31. aws_durable_execution_sdk_python_testing/web/handlers.py +33 -7
  32. aws_durable_execution_sdk_python_testing/web/routes.py +14 -6
  33. aws_durable_execution_sdk_python_testing/web/serialization.py +1 -2
  34. aws_durable_execution_sdk_python_testing/web/server.py +1 -1
  35. aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
  36. aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
  37. aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
  38. aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
  39. aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
  40. aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
  41. aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
  42. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/METADATA +50 -58
  43. aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
  44. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/WHEEL +1 -1
  45. aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
  46. aws_durable_execution_sdk_python_testing-1.2.0.dist-info/RECORD +0 -52
  47. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
  48. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
  49. {aws_durable_execution_sdk_python_testing-1.2.0.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
+ )