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.
- 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/routes.py +14 -6
- 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.0.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.0.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.0.dist-info/RECORD +0 -52
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
- {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
|
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
-
import os
|
|
6
5
|
import time
|
|
6
|
+
import warnings
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
8
|
from typing import (
|
|
9
9
|
TYPE_CHECKING,
|
|
@@ -16,7 +16,6 @@ from typing import (
|
|
|
16
16
|
cast,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
-
import aws_durable_execution_sdk_python
|
|
20
19
|
import boto3 # type: ignore
|
|
21
20
|
from botocore.exceptions import ClientError # type: ignore
|
|
22
21
|
from aws_durable_execution_sdk_python.execution import (
|
|
@@ -34,19 +33,35 @@ from aws_durable_execution_sdk_python.lambda_service import Operation as SvcOper
|
|
|
34
33
|
|
|
35
34
|
from aws_durable_execution_sdk_python_testing.checkpoint.processor import (
|
|
36
35
|
CheckpointProcessor,
|
|
36
|
+
DEFAULT_MAX_INVOCATION_PAGE_BYTES,
|
|
37
37
|
)
|
|
38
38
|
from aws_durable_execution_sdk_python_testing.client import InMemoryServiceClient
|
|
39
|
+
from aws_durable_execution_sdk_python_testing.clock import Clock, RealClock, SkipClock
|
|
40
|
+
from aws_durable_execution_sdk_python_testing.worker.registry import ExecutionRegistry
|
|
39
41
|
from aws_durable_execution_sdk_python_testing.exceptions import (
|
|
40
42
|
DurableFunctionsLocalRunnerError,
|
|
41
43
|
DurableFunctionsTestError,
|
|
42
44
|
InvalidParameterValueException,
|
|
43
45
|
ResourceNotFoundException,
|
|
44
46
|
)
|
|
47
|
+
from aws_durable_execution_sdk_python_testing.child_dispatcher import (
|
|
48
|
+
DEFAULT_CHILD_EXECUTION_TIMEOUT_SECONDS,
|
|
49
|
+
DEFAULT_CHILD_RETENTION_PERIOD_DAYS,
|
|
50
|
+
ChildDispatcher,
|
|
51
|
+
EndpointChildDispatcher,
|
|
52
|
+
FunctionConfigs,
|
|
53
|
+
FunctionRegistry,
|
|
54
|
+
InProcessChildDispatcher,
|
|
55
|
+
UnconfiguredChildDispatcher,
|
|
56
|
+
)
|
|
45
57
|
from aws_durable_execution_sdk_python_testing.executor import Executor
|
|
58
|
+
from aws_durable_execution_sdk_python_testing.execution import ExecutionStatus
|
|
46
59
|
from aws_durable_execution_sdk_python_testing.invoker import (
|
|
47
60
|
InProcessInvoker,
|
|
48
61
|
LambdaInvoker,
|
|
49
62
|
create_lambda_client,
|
|
63
|
+
create_test_lambda_context,
|
|
64
|
+
read_timeout_for,
|
|
50
65
|
)
|
|
51
66
|
from aws_durable_execution_sdk_python_testing.model import (
|
|
52
67
|
GetDurableExecutionHistoryResponse,
|
|
@@ -107,6 +122,28 @@ class WebRunnerConfig:
|
|
|
107
122
|
store_type: StoreType = StoreType.MEMORY
|
|
108
123
|
store_path: str | None = None # Path for filesystem store
|
|
109
124
|
|
|
125
|
+
# Emulated Lambda function timeout, applied to each handler
|
|
126
|
+
# invocation. Also bounds how long the runner waits on any Invoke it
|
|
127
|
+
# sends (see invoker.read_timeout_for).
|
|
128
|
+
invocation_timeout_seconds: int = 900
|
|
129
|
+
|
|
130
|
+
# Skip durable timer wall-clock waits (waits and step retries complete
|
|
131
|
+
# on the next scheduler turn while history keeps the real modeled
|
|
132
|
+
# durations). Defaults False so the server emulates real cloud timing;
|
|
133
|
+
# set True to opt into fast skip.
|
|
134
|
+
skip_time: bool = False
|
|
135
|
+
|
|
136
|
+
# Invocation-input pagination cap (bytes). Handler receives pages
|
|
137
|
+
# up to this size; larger state is served via
|
|
138
|
+
# GetDurableExecutionState. None falls back to Executor default
|
|
139
|
+
# (5 MB).
|
|
140
|
+
max_invocation_page_bytes: int | None = None
|
|
141
|
+
# The functions a durable function may invoke, by name. Required for
|
|
142
|
+
# chained invokes: the runner cannot learn a target's durability from
|
|
143
|
+
# the Lambda endpoint. See FunctionConfigs.from_value for the input.
|
|
144
|
+
# Last, so positional construction from before it exists is unchanged.
|
|
145
|
+
function_configs: FunctionConfigs | None = None
|
|
146
|
+
|
|
110
147
|
|
|
111
148
|
@dataclass(frozen=True)
|
|
112
149
|
class Operation:
|
|
@@ -398,6 +435,16 @@ def create_operation(
|
|
|
398
435
|
return operation_class.from_svc_operation(svc_operation, all_operations)
|
|
399
436
|
|
|
400
437
|
|
|
438
|
+
def _is_callback_ready_for_response(
|
|
439
|
+
events: list[Event], callback_started_event: Event
|
|
440
|
+
) -> bool:
|
|
441
|
+
"""Return True once the invocation that created the callback has settled."""
|
|
442
|
+
for event in events[events.index(callback_started_event) + 1 :]:
|
|
443
|
+
if event.event_type == "InvocationCompleted":
|
|
444
|
+
return True
|
|
445
|
+
return False
|
|
446
|
+
|
|
447
|
+
|
|
401
448
|
def _get_callback_id_from_events(
|
|
402
449
|
events: list[Event], name: str | None = None
|
|
403
450
|
) -> str | None:
|
|
@@ -409,7 +456,8 @@ def _get_callback_id_from_events(
|
|
|
409
456
|
name: Optional callback name to search for. If not provided, returns the latest callback.
|
|
410
457
|
|
|
411
458
|
Returns:
|
|
412
|
-
The callback ID string for a non-completed callback
|
|
459
|
+
The callback ID string for a non-completed callback whose creating
|
|
460
|
+
invocation has completed, or None if not found.
|
|
413
461
|
|
|
414
462
|
Raises:
|
|
415
463
|
DurableFunctionsTestError: If the named callback has already succeeded/failed/timed out.
|
|
@@ -436,6 +484,8 @@ def _get_callback_id_from_events(
|
|
|
436
484
|
raise DurableFunctionsTestError(
|
|
437
485
|
f"Callback {name} has already completed (succeeded/failed/timed out)"
|
|
438
486
|
)
|
|
487
|
+
if not _is_callback_ready_for_response(events, event):
|
|
488
|
+
return None
|
|
439
489
|
return (
|
|
440
490
|
event.callback_started_details.callback_id
|
|
441
491
|
if event.callback_started_details
|
|
@@ -448,6 +498,7 @@ def _get_callback_id_from_events(
|
|
|
448
498
|
event
|
|
449
499
|
for event in callback_started_events
|
|
450
500
|
if event.event_id not in completed_callback_ids
|
|
501
|
+
and _is_callback_ready_for_response(events, event)
|
|
451
502
|
]
|
|
452
503
|
|
|
453
504
|
if not active_callbacks:
|
|
@@ -467,6 +518,7 @@ class DurableFunctionTestResult:
|
|
|
467
518
|
operations: list[Operation]
|
|
468
519
|
result: OperationPayload | None = None
|
|
469
520
|
error: ErrorObject | None = None
|
|
521
|
+
execution_status: ExecutionStatus | None = None
|
|
470
522
|
|
|
471
523
|
@classmethod
|
|
472
524
|
def create(cls, execution: Execution) -> DurableFunctionTestResult:
|
|
@@ -482,12 +534,16 @@ class DurableFunctionTestResult:
|
|
|
482
534
|
if execution.result is None:
|
|
483
535
|
msg: str = "Execution result must exist to create test result."
|
|
484
536
|
raise DurableFunctionsTestError(msg)
|
|
537
|
+
if execution.close_status is None:
|
|
538
|
+
msg_status: str = "Execution close status must exist to create test result."
|
|
539
|
+
raise DurableFunctionsTestError(msg_status)
|
|
485
540
|
|
|
486
541
|
return cls(
|
|
487
542
|
status=execution.result.status,
|
|
488
543
|
operations=operations,
|
|
489
544
|
result=execution.result.result,
|
|
490
545
|
error=execution.result.error,
|
|
546
|
+
execution_status=execution.close_status,
|
|
491
547
|
)
|
|
492
548
|
|
|
493
549
|
@classmethod
|
|
@@ -510,6 +566,16 @@ class DurableFunctionTestResult:
|
|
|
510
566
|
)
|
|
511
567
|
status = InvocationStatus.FAILED
|
|
512
568
|
|
|
569
|
+
# Map overall execution status string separately from invocation status.
|
|
570
|
+
try:
|
|
571
|
+
execution_status = ExecutionStatus[execution_response.status]
|
|
572
|
+
except KeyError:
|
|
573
|
+
logger.warning(
|
|
574
|
+
"Unknown execution status: %s, defaulting to FAILED",
|
|
575
|
+
execution_response.status,
|
|
576
|
+
)
|
|
577
|
+
execution_status = ExecutionStatus.FAILED
|
|
578
|
+
|
|
513
579
|
# Convert Events to Operations - group by operation_id and merge
|
|
514
580
|
try:
|
|
515
581
|
svc_operations = events_to_operations(history_response.events)
|
|
@@ -530,6 +596,7 @@ class DurableFunctionTestResult:
|
|
|
530
596
|
operations=operations,
|
|
531
597
|
result=execution_response.result,
|
|
532
598
|
error=execution_response.error,
|
|
599
|
+
execution_status=execution_status,
|
|
533
600
|
)
|
|
534
601
|
|
|
535
602
|
def get_operation_by_name(self, name: str) -> Operation:
|
|
@@ -571,25 +638,117 @@ class DurableFunctionTestResult:
|
|
|
571
638
|
|
|
572
639
|
|
|
573
640
|
class DurableFunctionTestRunner:
|
|
574
|
-
def __init__(
|
|
641
|
+
def __init__(
|
|
642
|
+
self,
|
|
643
|
+
handler: Callable,
|
|
644
|
+
poll_interval: float = 1.0,
|
|
645
|
+
max_invocation_page_bytes: int | None = None,
|
|
646
|
+
execution_timeout: int = 300,
|
|
647
|
+
invocation_timeout: int = 900,
|
|
648
|
+
store: ExecutionStore | None = None,
|
|
649
|
+
skip_time: bool = True, # noqa: FBT001, FBT002
|
|
650
|
+
region: str = "us-west-2",
|
|
651
|
+
):
|
|
652
|
+
self._execution_timeout = execution_timeout
|
|
653
|
+
self._invocation_timeout = invocation_timeout
|
|
654
|
+
# Region the runner stands in for: reported in Lambda contexts and
|
|
655
|
+
# used to reject chained-invoke targets in another region.
|
|
656
|
+
self._region = region
|
|
657
|
+
self._max_invocation_page_bytes = (
|
|
658
|
+
max_invocation_page_bytes
|
|
659
|
+
if max_invocation_page_bytes is not None
|
|
660
|
+
else DEFAULT_MAX_INVOCATION_PAGE_BYTES
|
|
661
|
+
)
|
|
575
662
|
self._scheduler: Scheduler = Scheduler()
|
|
576
663
|
self._scheduler.start()
|
|
577
|
-
|
|
664
|
+
# Defaults to the in-memory store. Pass a filesystem or sqlite
|
|
665
|
+
# store to run the in-process lifecycle on a disk-backed store.
|
|
666
|
+
self._store: ExecutionStore = (
|
|
667
|
+
store if store is not None else InMemoryExecutionStore()
|
|
668
|
+
)
|
|
578
669
|
self.poll_interval = poll_interval
|
|
670
|
+
self._clock: Clock = SkipClock() if skip_time else RealClock()
|
|
671
|
+
self._registry = ExecutionRegistry(self._store, self._scheduler)
|
|
579
672
|
self._checkpoint_processor = CheckpointProcessor(
|
|
580
|
-
store=self._store,
|
|
673
|
+
store=self._store,
|
|
674
|
+
scheduler=self._scheduler,
|
|
675
|
+
clock=self._clock,
|
|
581
676
|
)
|
|
582
|
-
self._service_client = InMemoryServiceClient(
|
|
583
|
-
|
|
677
|
+
self._service_client = InMemoryServiceClient(
|
|
678
|
+
self._checkpoint_processor, self._registry
|
|
679
|
+
)
|
|
680
|
+
self._invoker = InProcessInvoker(
|
|
681
|
+
handler,
|
|
682
|
+
self._service_client,
|
|
683
|
+
max_page_bytes=self._max_invocation_page_bytes,
|
|
684
|
+
region=region,
|
|
685
|
+
)
|
|
686
|
+
self._function_registry = FunctionRegistry()
|
|
584
687
|
self._executor = Executor(
|
|
585
688
|
store=self._store,
|
|
586
689
|
scheduler=self._scheduler,
|
|
587
690
|
invoker=self._invoker,
|
|
588
691
|
checkpoint_processor=self._checkpoint_processor,
|
|
692
|
+
max_invocation_page_bytes=self._max_invocation_page_bytes,
|
|
693
|
+
invocation_timeout_seconds=invocation_timeout,
|
|
694
|
+
registry=self._registry,
|
|
695
|
+
clock=self._clock,
|
|
696
|
+
child_dispatcher=InProcessChildDispatcher(
|
|
697
|
+
self._function_registry,
|
|
698
|
+
lambda request: create_test_lambda_context(
|
|
699
|
+
region=region,
|
|
700
|
+
account_id=request.account_id,
|
|
701
|
+
function_name=request.invoke_identifier(),
|
|
702
|
+
tenant_id=request.tenant_id,
|
|
703
|
+
),
|
|
704
|
+
invocation_timeout_seconds=invocation_timeout,
|
|
705
|
+
clock=self._clock,
|
|
706
|
+
),
|
|
707
|
+
region=region,
|
|
589
708
|
)
|
|
590
709
|
|
|
591
710
|
# Wire up observer pattern - CheckpointProcessor uses this to notify executor of state changes
|
|
592
711
|
self._checkpoint_processor.add_execution_observer(self._executor)
|
|
712
|
+
# A chained-invoke target the runner cannot resolve fails in the
|
|
713
|
+
# checkpoint response, as it does at the service.
|
|
714
|
+
self._checkpoint_processor.set_chained_invoke_preflight(
|
|
715
|
+
self._executor.preflight_chained_invoke
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
def register_durable_function(
|
|
719
|
+
self,
|
|
720
|
+
function_name: str,
|
|
721
|
+
handler: Callable,
|
|
722
|
+
execution_timeout: int = DEFAULT_CHILD_EXECUTION_TIMEOUT_SECONDS,
|
|
723
|
+
retention_period_days: int = DEFAULT_CHILD_RETENTION_PERIOD_DAYS,
|
|
724
|
+
) -> DurableFunctionTestRunner:
|
|
725
|
+
"""Register a durable function as a chained-invoke target.
|
|
726
|
+
|
|
727
|
+
A ``ctx.invoke`` of ``function_name`` runs ``handler`` as its
|
|
728
|
+
own durable execution with the given timeout and retention.
|
|
729
|
+
Returns the runner for chaining.
|
|
730
|
+
"""
|
|
731
|
+
self._invoker.register(function_name, handler)
|
|
732
|
+
self._function_registry.register(
|
|
733
|
+
function_name,
|
|
734
|
+
handler,
|
|
735
|
+
is_durable=True,
|
|
736
|
+
execution_timeout_seconds=execution_timeout,
|
|
737
|
+
retention_period_days=retention_period_days,
|
|
738
|
+
)
|
|
739
|
+
return self
|
|
740
|
+
|
|
741
|
+
def register_function(
|
|
742
|
+
self, function_name: str, handler: Callable
|
|
743
|
+
) -> DurableFunctionTestRunner:
|
|
744
|
+
"""Register a non-durable function as a chained-invoke target.
|
|
745
|
+
|
|
746
|
+
A ``ctx.invoke`` of ``function_name`` calls ``handler`` once
|
|
747
|
+
with the deserialized payload and a test Lambda context.
|
|
748
|
+
Returns the runner for chaining.
|
|
749
|
+
"""
|
|
750
|
+
self._function_registry.register(function_name, handler, is_durable=False)
|
|
751
|
+
return self
|
|
593
752
|
|
|
594
753
|
def __enter__(self):
|
|
595
754
|
return self
|
|
@@ -598,25 +757,44 @@ class DurableFunctionTestRunner:
|
|
|
598
757
|
self.close()
|
|
599
758
|
|
|
600
759
|
def close(self):
|
|
760
|
+
self._executor.shutdown()
|
|
761
|
+
self._registry.shutdown()
|
|
601
762
|
self._scheduler.stop()
|
|
602
763
|
|
|
603
764
|
def run(
|
|
604
765
|
self,
|
|
605
766
|
input: str | None = None, # noqa: A002
|
|
606
|
-
|
|
767
|
+
execution_timeout: int | None = None,
|
|
768
|
+
timeout: int | None = None,
|
|
607
769
|
function_name: str = "test-function",
|
|
608
770
|
execution_name: str = "execution-name",
|
|
609
771
|
account_id: str = "123456789012",
|
|
772
|
+
tenant_id: str | None = None,
|
|
610
773
|
) -> DurableFunctionTestResult:
|
|
774
|
+
if timeout is not None and execution_timeout is None:
|
|
775
|
+
warnings.warn(
|
|
776
|
+
"timeout on run() is deprecated. Use execution_timeout instead.",
|
|
777
|
+
DeprecationWarning,
|
|
778
|
+
stacklevel=2,
|
|
779
|
+
)
|
|
780
|
+
execution_timeout = timeout
|
|
781
|
+
effective_timeout: int = (
|
|
782
|
+
execution_timeout
|
|
783
|
+
if execution_timeout is not None
|
|
784
|
+
else self._execution_timeout
|
|
785
|
+
)
|
|
611
786
|
execution_arn = self.run_async(
|
|
612
787
|
input=input,
|
|
613
|
-
|
|
788
|
+
execution_timeout=effective_timeout,
|
|
614
789
|
function_name=function_name,
|
|
615
790
|
execution_name=execution_name,
|
|
616
791
|
account_id=account_id,
|
|
792
|
+
tenant_id=tenant_id,
|
|
617
793
|
)
|
|
618
794
|
|
|
619
|
-
return self.wait_for_result(
|
|
795
|
+
return self.wait_for_result(
|
|
796
|
+
execution_arn=execution_arn, timeout=effective_timeout
|
|
797
|
+
)
|
|
620
798
|
|
|
621
799
|
def send_callback_success(
|
|
622
800
|
self, callback_id: str, result: bytes | None = None
|
|
@@ -631,24 +809,48 @@ class DurableFunctionTestRunner:
|
|
|
631
809
|
def send_callback_heartbeat(self, callback_id: str) -> None:
|
|
632
810
|
self._executor.send_callback_heartbeat(callback_id=callback_id)
|
|
633
811
|
|
|
812
|
+
def get_execution_history(
|
|
813
|
+
self,
|
|
814
|
+
execution_arn: str,
|
|
815
|
+
include_execution_data: bool = False, # noqa: FBT001, FBT002
|
|
816
|
+
) -> GetDurableExecutionHistoryResponse:
|
|
817
|
+
"""Return the event history for an execution.
|
|
818
|
+
|
|
819
|
+
Works for the execution under test and for any chained child
|
|
820
|
+
execution (the child's ARN is on the parent's
|
|
821
|
+
ChainedInvokeStarted event).
|
|
822
|
+
"""
|
|
823
|
+
return self._executor.get_execution_history(
|
|
824
|
+
execution_arn, include_execution_data=include_execution_data
|
|
825
|
+
)
|
|
826
|
+
|
|
634
827
|
def run_async(
|
|
635
828
|
self,
|
|
636
829
|
input: str | None = None, # noqa: A002
|
|
637
|
-
|
|
830
|
+
execution_timeout: int | None = None,
|
|
831
|
+
timeout: int | None = None,
|
|
638
832
|
function_name: str = "test-function",
|
|
639
833
|
execution_name: str = "execution-name",
|
|
640
834
|
account_id: str = "123456789012",
|
|
835
|
+
tenant_id: str | None = None,
|
|
641
836
|
) -> str:
|
|
837
|
+
if timeout is not None and execution_timeout is None:
|
|
838
|
+
execution_timeout = timeout
|
|
839
|
+
effective_timeout: int = (
|
|
840
|
+
execution_timeout
|
|
841
|
+
if execution_timeout is not None
|
|
842
|
+
else self._execution_timeout
|
|
843
|
+
)
|
|
642
844
|
start_input = StartDurableExecutionInput(
|
|
643
845
|
account_id=account_id,
|
|
644
846
|
function_name=function_name,
|
|
645
847
|
function_qualifier="$LATEST",
|
|
646
848
|
execution_name=execution_name,
|
|
647
|
-
execution_timeout_seconds=
|
|
849
|
+
execution_timeout_seconds=effective_timeout,
|
|
648
850
|
execution_retention_period_days=7,
|
|
649
851
|
invocation_id="inv-12345678-1234-1234-1234-123456789012",
|
|
650
852
|
trace_fields={"trace_id": "abc123", "span_id": "def456"},
|
|
651
|
-
tenant_id=
|
|
853
|
+
tenant_id=tenant_id,
|
|
652
854
|
input=input,
|
|
653
855
|
)
|
|
654
856
|
|
|
@@ -661,6 +863,53 @@ class DurableFunctionTestRunner:
|
|
|
661
863
|
raise DurableFunctionsTestError(msg_arn)
|
|
662
864
|
return output.execution_arn
|
|
663
865
|
|
|
866
|
+
def pause_execution(self, execution_arn: str, timeout: float = 60) -> None:
|
|
867
|
+
"""Experimental; may change or be removed in a future release.
|
|
868
|
+
|
|
869
|
+
Pause an execution and wait for its current invocation to stop.
|
|
870
|
+
|
|
871
|
+
If a running invocation sends another checkpoint after the pause is
|
|
872
|
+
applied, its updates are accepted and saved. The response omits the
|
|
873
|
+
checkpoint token, causing the SDK to end the invocation with PENDING.
|
|
874
|
+
Further checkpoints from that invocation are rejected.
|
|
875
|
+
|
|
876
|
+
Returns once the pause request is applied and no invocation is running.
|
|
877
|
+
A running step is not interrupted.
|
|
878
|
+
|
|
879
|
+
Repeated calls leave an existing pause unchanged and wait again for
|
|
880
|
+
the running invocation to stop. Completed executions are unchanged.
|
|
881
|
+
|
|
882
|
+
If this call times out, the pause request is not cancelled.
|
|
883
|
+
|
|
884
|
+
Args:
|
|
885
|
+
execution_arn: Execution to pause.
|
|
886
|
+
timeout: Maximum wait in real seconds. Must be finite and positive.
|
|
887
|
+
|
|
888
|
+
Raises:
|
|
889
|
+
InvalidParameterValueException: If the ARN is blank.
|
|
890
|
+
ResourceNotFoundException: If the execution does not exist.
|
|
891
|
+
ValueError: If timeout is not finite and positive.
|
|
892
|
+
TimeoutError: If the pause wait times out.
|
|
893
|
+
"""
|
|
894
|
+
self._executor.pause_execution(execution_arn, timeout=timeout)
|
|
895
|
+
|
|
896
|
+
def resume_execution(self, execution_arn: str) -> None:
|
|
897
|
+
"""Experimental; may change or be removed in a future release.
|
|
898
|
+
|
|
899
|
+
Make the local checkpoint server answer this execution's
|
|
900
|
+
checkpoints with a token again.
|
|
901
|
+
|
|
902
|
+
Starts the invocation pause_execution(arn) held back, if any.
|
|
903
|
+
|
|
904
|
+
Repeated calls leave an execution that is not paused unchanged.
|
|
905
|
+
Completed executions are unchanged.
|
|
906
|
+
|
|
907
|
+
Raises:
|
|
908
|
+
InvalidParameterValueException: If the ARN is blank.
|
|
909
|
+
ResourceNotFoundException: If the execution does not exist.
|
|
910
|
+
"""
|
|
911
|
+
self._executor.resume_execution(execution_arn)
|
|
912
|
+
|
|
664
913
|
def wait_for_result(
|
|
665
914
|
self, execution_arn: str, timeout: int = 60
|
|
666
915
|
) -> DurableFunctionTestResult:
|
|
@@ -688,7 +937,8 @@ class DurableFunctionTestRunner:
|
|
|
688
937
|
)
|
|
689
938
|
if callback_id:
|
|
690
939
|
return callback_id
|
|
691
|
-
except ResourceNotFoundException
|
|
940
|
+
except ResourceNotFoundException:
|
|
941
|
+
# Execution may not be visible yet during async invoke.
|
|
692
942
|
pass
|
|
693
943
|
except Exception as e:
|
|
694
944
|
msg = f"Failed to fetch execution history: {e}"
|
|
@@ -699,7 +949,7 @@ class DurableFunctionTestRunner:
|
|
|
699
949
|
|
|
700
950
|
# Timeout reached
|
|
701
951
|
elapsed = time.time() - start_time
|
|
702
|
-
msg = f"Callback did not available within {timeout}s (elapsed: {elapsed:.1f}s."
|
|
952
|
+
msg = f"Callback did not become available within {timeout}s (elapsed: {elapsed:.1f}s)."
|
|
703
953
|
raise TimeoutError(msg)
|
|
704
954
|
|
|
705
955
|
|
|
@@ -710,6 +960,7 @@ class DurableChildContextTestRunner(DurableFunctionTestRunner):
|
|
|
710
960
|
self,
|
|
711
961
|
context_function: Callable[Concatenate[DurableContext, P], Any],
|
|
712
962
|
*args,
|
|
963
|
+
skip_time: bool = True,
|
|
713
964
|
**kwargs,
|
|
714
965
|
):
|
|
715
966
|
# wrap the durable context around a durable execution handler as a convenience to run directly
|
|
@@ -717,11 +968,26 @@ class DurableChildContextTestRunner(DurableFunctionTestRunner):
|
|
|
717
968
|
def handler(event: Any, context: DurableContext): # noqa: ARG001
|
|
718
969
|
return context_function(*args, **kwargs)(context)
|
|
719
970
|
|
|
720
|
-
super().__init__(handler)
|
|
971
|
+
super().__init__(handler, skip_time=skip_time)
|
|
721
972
|
|
|
722
973
|
|
|
723
974
|
class WebRunner:
|
|
724
|
-
"""Web server runner for durable functions testing with HTTP API endpoints.
|
|
975
|
+
"""Web server runner for durable functions testing with HTTP API endpoints.
|
|
976
|
+
|
|
977
|
+
Handlers run at the Lambda endpoint (``WebRunnerConfig.lambda_endpoint``);
|
|
978
|
+
the runner drives each execution by invoking its handler there.
|
|
979
|
+
|
|
980
|
+
Chained invokes (``context.invoke``) need ``WebRunnerConfig.function_configs``,
|
|
981
|
+
the targets by name (:class:`FunctionConfigs`). Without it every
|
|
982
|
+
chained invoke fails with an error naming the option. A durable
|
|
983
|
+
target runs as a child execution the runner drives; a plain target is
|
|
984
|
+
one RequestResponse Invoke at the endpoint, bounded by the invocation
|
|
985
|
+
timeout, whose function error fails the operation. Every handler
|
|
986
|
+
invocation the runner sends carries ``X-Dex-Handler-Invoke: true``
|
|
987
|
+
(:data:`invoker.INVOCATION_MARKER_HEADER`), so an endpoint that starts
|
|
988
|
+
an execution on Invoke of a durable function runs the handler once
|
|
989
|
+
instead; plain targets are sent without it.
|
|
990
|
+
"""
|
|
725
991
|
|
|
726
992
|
def __init__(self, config: WebRunnerConfig) -> None:
|
|
727
993
|
"""Initialize WebRunner with configuration.
|
|
@@ -735,6 +1001,7 @@ class WebRunner:
|
|
|
735
1001
|
self._store: ExecutionStore | None = None
|
|
736
1002
|
self._invoker: LambdaInvoker | None = None
|
|
737
1003
|
self._executor: Executor | None = None
|
|
1004
|
+
self._registry: ExecutionRegistry | None = None
|
|
738
1005
|
|
|
739
1006
|
def __enter__(self) -> Self:
|
|
740
1007
|
"""Context manager entry point.
|
|
@@ -779,10 +1046,45 @@ class WebRunner:
|
|
|
779
1046
|
else:
|
|
780
1047
|
self._store = InMemoryExecutionStore()
|
|
781
1048
|
self._scheduler = Scheduler()
|
|
782
|
-
|
|
1049
|
+
resolved_max_page_bytes: int = (
|
|
1050
|
+
self._config.max_invocation_page_bytes
|
|
1051
|
+
if self._config.max_invocation_page_bytes is not None
|
|
1052
|
+
else DEFAULT_MAX_INVOCATION_PAGE_BYTES
|
|
1053
|
+
)
|
|
1054
|
+
read_timeout_seconds: int = read_timeout_for(
|
|
1055
|
+
self._config.invocation_timeout_seconds
|
|
1056
|
+
)
|
|
1057
|
+
lambda_client: Any = self._create_boto3_client(read_timeout_seconds)
|
|
1058
|
+
self._invoker = LambdaInvoker(
|
|
1059
|
+
lambda_client,
|
|
1060
|
+
max_page_bytes=resolved_max_page_bytes,
|
|
1061
|
+
read_timeout_seconds=read_timeout_seconds,
|
|
1062
|
+
endpoint_url=self._config.lambda_endpoint,
|
|
1063
|
+
region_name=self._config.local_runner_region,
|
|
1064
|
+
)
|
|
783
1065
|
|
|
784
1066
|
# Create shared CheckpointProcessor
|
|
785
|
-
|
|
1067
|
+
clock: Clock = SkipClock() if self._config.skip_time else RealClock()
|
|
1068
|
+
self._clock = clock
|
|
1069
|
+
self._registry = ExecutionRegistry(self._store, self._scheduler)
|
|
1070
|
+
checkpoint_processor = CheckpointProcessor(
|
|
1071
|
+
self._store,
|
|
1072
|
+
self._scheduler,
|
|
1073
|
+
clock=clock,
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
# Non-durable targets are invoked with the invoker's unmarked client
|
|
1077
|
+
# for the parent's endpoint, so the endpoint runs them as any
|
|
1078
|
+
# caller's Invoke and they go where the parent's invocations go.
|
|
1079
|
+
child_dispatcher: ChildDispatcher
|
|
1080
|
+
if self._config.function_configs is not None:
|
|
1081
|
+
child_dispatcher = EndpointChildDispatcher(
|
|
1082
|
+
self._config.function_configs,
|
|
1083
|
+
self._invoker.unmarked_client_for,
|
|
1084
|
+
invocation_timeout_seconds=self._config.invocation_timeout_seconds,
|
|
1085
|
+
)
|
|
1086
|
+
else:
|
|
1087
|
+
child_dispatcher = UnconfiguredChildDispatcher()
|
|
786
1088
|
|
|
787
1089
|
# Create executor with all dependencies including checkpoint processor
|
|
788
1090
|
self._executor = Executor(
|
|
@@ -790,10 +1092,19 @@ class WebRunner:
|
|
|
790
1092
|
scheduler=self._scheduler,
|
|
791
1093
|
invoker=self._invoker,
|
|
792
1094
|
checkpoint_processor=checkpoint_processor,
|
|
1095
|
+
max_invocation_page_bytes=resolved_max_page_bytes,
|
|
1096
|
+
invocation_timeout_seconds=self._config.invocation_timeout_seconds,
|
|
1097
|
+
registry=self._registry,
|
|
1098
|
+
clock=clock,
|
|
1099
|
+
child_dispatcher=child_dispatcher,
|
|
1100
|
+
region=self._config.local_runner_region,
|
|
793
1101
|
)
|
|
794
1102
|
|
|
795
1103
|
# Add executor as observer to the checkpoint processor
|
|
796
1104
|
checkpoint_processor.add_execution_observer(self._executor)
|
|
1105
|
+
checkpoint_processor.set_chained_invoke_preflight(
|
|
1106
|
+
self._executor.preflight_chained_invoke
|
|
1107
|
+
)
|
|
797
1108
|
|
|
798
1109
|
# Start the scheduler
|
|
799
1110
|
self._scheduler.start()
|
|
@@ -836,6 +1147,19 @@ class WebRunner:
|
|
|
836
1147
|
|
|
837
1148
|
self._server = None
|
|
838
1149
|
|
|
1150
|
+
if self._executor is not None:
|
|
1151
|
+
try:
|
|
1152
|
+
self._executor.shutdown()
|
|
1153
|
+
except Exception:
|
|
1154
|
+
logger.exception("error shutting down executor")
|
|
1155
|
+
|
|
1156
|
+
if self._registry is not None:
|
|
1157
|
+
try:
|
|
1158
|
+
self._registry.shutdown()
|
|
1159
|
+
except Exception:
|
|
1160
|
+
logger.exception("error stopping execution registry")
|
|
1161
|
+
self._registry = None
|
|
1162
|
+
|
|
839
1163
|
if self._scheduler is not None:
|
|
840
1164
|
try:
|
|
841
1165
|
self._scheduler.stop()
|
|
@@ -847,10 +1171,12 @@ class WebRunner:
|
|
|
847
1171
|
self._invoker = None
|
|
848
1172
|
self._executor = None
|
|
849
1173
|
|
|
850
|
-
def _create_boto3_client(self) -> Any:
|
|
1174
|
+
def _create_boto3_client(self, read_timeout_seconds: int) -> Any:
|
|
851
1175
|
"""Create boto3 client for Lambda service.
|
|
852
1176
|
|
|
853
|
-
Creates a boto3 client with the local runner endpoint and region from
|
|
1177
|
+
Creates a boto3 client with the local runner endpoint and region from
|
|
1178
|
+
configuration. ``read_timeout_seconds`` is passed through to
|
|
1179
|
+
:func:`create_lambda_client`.
|
|
854
1180
|
|
|
855
1181
|
Returns:
|
|
856
1182
|
Configured boto3 client for Lambda service
|
|
@@ -862,11 +1188,12 @@ class WebRunner:
|
|
|
862
1188
|
return create_lambda_client(
|
|
863
1189
|
endpoint_url=self._config.lambda_endpoint,
|
|
864
1190
|
region_name=self._config.local_runner_region,
|
|
1191
|
+
read_timeout_seconds=read_timeout_seconds,
|
|
865
1192
|
)
|
|
866
1193
|
|
|
867
1194
|
|
|
868
1195
|
class DurableFunctionCloudTestRunner:
|
|
869
|
-
"""Test runner that executes durable functions against
|
|
1196
|
+
"""Test runner that executes durable functions against the deployed service.
|
|
870
1197
|
|
|
871
1198
|
This runner invokes deployed Lambda functions and polls for execution completion,
|
|
872
1199
|
providing the same interface as DurableFunctionTestRunner for seamless test
|
|
@@ -984,6 +1311,28 @@ class DurableFunctionCloudTestRunner:
|
|
|
984
1311
|
|
|
985
1312
|
return response.get("DurableExecutionArn")
|
|
986
1313
|
|
|
1314
|
+
def pause_execution(self, execution_arn: str, timeout: float = 60) -> None:
|
|
1315
|
+
"""Experimental; may change or be removed in a future release.
|
|
1316
|
+
|
|
1317
|
+
Cloud pause is not implemented; use DurableFunctionTestRunner instead.
|
|
1318
|
+
|
|
1319
|
+
Raises:
|
|
1320
|
+
NotImplementedError: Always. No service request is sent.
|
|
1321
|
+
"""
|
|
1322
|
+
msg = "pause_execution() is not implemented for DurableFunctionCloudTestRunner"
|
|
1323
|
+
raise NotImplementedError(msg)
|
|
1324
|
+
|
|
1325
|
+
def resume_execution(self, execution_arn: str) -> None:
|
|
1326
|
+
"""Experimental; may change or be removed in a future release.
|
|
1327
|
+
|
|
1328
|
+
Cloud resume is not implemented; use DurableFunctionTestRunner instead.
|
|
1329
|
+
|
|
1330
|
+
Raises:
|
|
1331
|
+
NotImplementedError: Always. No service request is sent.
|
|
1332
|
+
"""
|
|
1333
|
+
msg = "resume_execution() is not implemented for DurableFunctionCloudTestRunner"
|
|
1334
|
+
raise NotImplementedError(msg)
|
|
1335
|
+
|
|
987
1336
|
def send_callback_success(
|
|
988
1337
|
self, callback_id: str, result: bytes | None = None
|
|
989
1338
|
) -> None:
|
|
@@ -1040,6 +1389,17 @@ class DurableFunctionCloudTestRunner:
|
|
|
1040
1389
|
DurableExecutionArn=execution_arn
|
|
1041
1390
|
)
|
|
1042
1391
|
execution = GetDurableExecutionResponse.from_dict(execution_dict)
|
|
1392
|
+
except ClientError as e:
|
|
1393
|
+
error_code = e.response.get("Error", {}).get("Code")
|
|
1394
|
+
# Retry while the asynchronous invoke's durable execution record is
|
|
1395
|
+
# still becoming visible to GetDurableExecution.
|
|
1396
|
+
if error_code == "ResourceNotFoundException":
|
|
1397
|
+
logger.info("Execution status not available yet; retrying")
|
|
1398
|
+
time.sleep(self.poll_interval)
|
|
1399
|
+
continue
|
|
1400
|
+
|
|
1401
|
+
msg = f"Failed to get execution status: {e}"
|
|
1402
|
+
raise DurableFunctionsTestError(msg) from e
|
|
1043
1403
|
except Exception as e:
|
|
1044
1404
|
msg = f"Failed to get execution status: {e}"
|
|
1045
1405
|
raise DurableFunctionsTestError(msg) from e
|
|
@@ -1056,7 +1416,7 @@ class DurableFunctionCloudTestRunner:
|
|
|
1056
1416
|
if execution.status == "FAILED":
|
|
1057
1417
|
logger.warning("Execution failed")
|
|
1058
1418
|
return execution
|
|
1059
|
-
if execution.status in ["TIMED_OUT", "
|
|
1419
|
+
if execution.status in ["TIMED_OUT", "STOPPED"]:
|
|
1060
1420
|
logger.warning("Execution terminated: %s", execution.status)
|
|
1061
1421
|
return execution
|
|
1062
1422
|
|
|
@@ -1139,7 +1499,7 @@ class DurableFunctionCloudTestRunner:
|
|
|
1139
1499
|
|
|
1140
1500
|
# Timeout reached
|
|
1141
1501
|
elapsed = time.time() - start_time
|
|
1142
|
-
msg = f"Callback did not available within {timeout}s (elapsed: {elapsed:.1f}s."
|
|
1502
|
+
msg = f"Callback did not become available within {timeout}s (elapsed: {elapsed:.1f}s)."
|
|
1143
1503
|
raise TimeoutError(msg)
|
|
1144
1504
|
|
|
1145
1505
|
def _fetch_execution_history(
|
|
@@ -1156,11 +1516,28 @@ class DurableFunctionCloudTestRunner:
|
|
|
1156
1516
|
Raises:
|
|
1157
1517
|
ClientError: If lambda client encounter error
|
|
1158
1518
|
"""
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1519
|
+
events: list[Event] = []
|
|
1520
|
+
next_marker: str | None = None
|
|
1521
|
+
|
|
1522
|
+
while True:
|
|
1523
|
+
request = {
|
|
1524
|
+
"DurableExecutionArn": execution_arn,
|
|
1525
|
+
"IncludeExecutionData": True,
|
|
1526
|
+
}
|
|
1527
|
+
if next_marker:
|
|
1528
|
+
request["Marker"] = next_marker
|
|
1529
|
+
|
|
1530
|
+
history_dict = self.lambda_client.get_durable_execution_history(**request)
|
|
1531
|
+
history_response = GetDurableExecutionHistoryResponse.from_dict(
|
|
1532
|
+
history_dict
|
|
1533
|
+
)
|
|
1534
|
+
events.extend(history_response.events)
|
|
1535
|
+
|
|
1536
|
+
next_marker = history_response.next_marker
|
|
1537
|
+
if not next_marker:
|
|
1538
|
+
break
|
|
1539
|
+
|
|
1540
|
+
history_response = GetDurableExecutionHistoryResponse(events=events)
|
|
1164
1541
|
|
|
1165
1542
|
logger.info("Retrieved %d events from history", len(history_response.events))
|
|
1166
1543
|
|