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
@@ -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, or None if not found.
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__(self, handler: Callable, poll_interval: float = 1.0):
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
- self._store = InMemoryExecutionStore()
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, scheduler=self._scheduler
673
+ store=self._store,
674
+ scheduler=self._scheduler,
675
+ clock=self._clock,
581
676
  )
582
- self._service_client = InMemoryServiceClient(self._checkpoint_processor)
583
- self._invoker = InProcessInvoker(handler, self._service_client)
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
- timeout: int = 900,
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
- timeout=timeout,
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(execution_arn=execution_arn, timeout=timeout)
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
- timeout: int = 900,
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=timeout,
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="tenant-001",
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 as e:
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
- self._invoker = LambdaInvoker(self._create_boto3_client())
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
- checkpoint_processor = CheckpointProcessor(self._store, self._scheduler)
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 configuration.
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 actual AWS Lambda backend.
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", "ABORTED"]:
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
- history_dict = self.lambda_client.get_durable_execution_history(
1160
- DurableExecutionArn=execution_arn,
1161
- IncludeExecutionData=True,
1162
- )
1163
- history_response = GetDurableExecutionHistoryResponse.from_dict(history_dict)
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