Coverage for async_durable_execution/_runner/model.py: 99%
706 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-30 23:43 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-30 23:43 +0000
1"""Shared runner models."""
3from __future__ import annotations
5import datetime
6import json
7import logging
8from dataclasses import dataclass, field, replace
9from enum import Enum
10from typing import Any, Protocol, TYPE_CHECKING
12from .._core import (
13 AwsApiModel,
14 CallbackDetails,
15 CallbackOptions,
16 ChainedInvokeDetails,
17 ContextDetails,
18 DurableExecutionInvocationOutput,
19 ErrorObject,
20 ExecutionDetails,
21 ExtendedTypeSerDes,
22 InvocationStatus,
23 Operation,
24 OperationAction,
25 OperationPayload,
26 OperationStatus,
27 OperationSubType,
28 OperationType,
29 OperationUpdate,
30 StepDetails,
31 WaitDetails,
32)
33from .exceptions import (
34 DurableFunctionsTestError,
35 InvalidParameterValueException,
36)
38if TYPE_CHECKING:
39 from .local.model import StartDurableExecutionInput
42logger = logging.getLogger(__name__)
45class EventType(Enum):
46 """Event types for durable execution events."""
48 EXECUTION_STARTED = "ExecutionStarted"
49 EXECUTION_SUCCEEDED = "ExecutionSucceeded"
50 EXECUTION_FAILED = "ExecutionFailed"
51 EXECUTION_TIMED_OUT = "ExecutionTimedOut"
52 EXECUTION_STOPPED = "ExecutionStopped"
53 CONTEXT_STARTED = "ContextStarted"
54 CONTEXT_SUCCEEDED = "ContextSucceeded"
55 CONTEXT_FAILED = "ContextFailed"
56 WAIT_STARTED = "WaitStarted"
57 WAIT_SUCCEEDED = "WaitSucceeded"
58 WAIT_CANCELLED = "WaitCancelled"
59 STEP_STARTED = "StepStarted"
60 STEP_SUCCEEDED = "StepSucceeded"
61 STEP_FAILED = "StepFailed"
62 CHAINED_INVOKE_STARTED = "ChainedInvokeStarted"
63 CHAINED_INVOKE_SUCCEEDED = "ChainedInvokeSucceeded"
64 CHAINED_INVOKE_FAILED = "ChainedInvokeFailed"
65 CHAINED_INVOKE_TIMED_OUT = "ChainedInvokeTimedOut"
66 CHAINED_INVOKE_STOPPED = "ChainedInvokeStopped"
67 CALLBACK_STARTED = "CallbackStarted"
68 CALLBACK_SUCCEEDED = "CallbackSucceeded"
69 CALLBACK_FAILED = "CallbackFailed"
70 CALLBACK_TIMED_OUT = "CallbackTimedOut"
71 INVOCATION_COMPLETED = "InvocationCompleted"
74TERMINAL_STATUSES: set[OperationStatus] = {
75 OperationStatus.SUCCEEDED,
76 OperationStatus.FAILED,
77 OperationStatus.TIMED_OUT,
78 OperationStatus.STOPPED,
79 OperationStatus.CANCELLED,
80}
83@dataclass(frozen=True)
84class GetDurableExecutionResponse(AwsApiModel):
85 """Response containing durable execution details."""
87 durable_execution_arn: str = field(metadata={"alias": "DurableExecutionArn"})
88 durable_execution_name: str = field(metadata={"alias": "DurableExecutionName"})
89 function_arn: str = field(metadata={"alias": "FunctionArn"})
90 status: str = field(metadata={"alias": "Status"})
91 start_timestamp: datetime.datetime = field(
92 metadata={"alias": "StartTimestamp", "is_timestamp": True}
93 )
94 input_payload: str | None = field(default=None, metadata={"alias": "InputPayload"})
95 result: str | None = field(default=None, metadata={"alias": "Result"})
96 error: ErrorObject | None = field(default=None, metadata={"alias": "Error"})
97 end_timestamp: datetime.datetime | None = field(
98 default=None, metadata={"alias": "EndTimestamp", "is_timestamp": True}
99 )
100 version: str | None = field(default=None, metadata={"alias": "Version"})
103# Event-related structures from Smithy model
104@dataclass(frozen=True)
105class EventInput(AwsApiModel):
106 """Event input structure."""
108 payload: str | None = field(default=None, metadata={"alias": "Payload"})
109 truncated: bool = field(default=False, metadata={"alias": "Truncated"})
111 @classmethod
112 def from_details(
113 cls,
114 details: ExecutionDetails,
115 include: bool = False, # noqa: FBT001, FBT002
116 ) -> EventInput:
117 details_input: str | None = details.input_payload if details else None
118 payload: str | None = details_input if include else None
119 truncated: bool = not include
120 return cls(payload=payload, truncated=truncated)
122 @classmethod
123 def from_start_durable_execution_input(
124 cls,
125 start_durable_execution_input: StartDurableExecutionInput,
126 include: bool = False, # noqa: FBT001, FBT002
127 ) -> EventInput:
128 input: str | None = start_durable_execution_input.input
129 truncated: bool = not include
130 return cls(input, truncated)
133@dataclass(frozen=True)
134class EventResult(AwsApiModel):
135 """Event result structure."""
137 payload: str | None = field(default=None, metadata={"alias": "Payload"})
138 truncated: bool = field(default=False, metadata={"alias": "Truncated"})
140 @classmethod
141 def from_details(
142 cls,
143 details: CallbackDetails | StepDetails | ChainedInvokeDetails | ContextDetails,
144 include: bool = False, # noqa: FBT001, FBT002
145 ) -> EventResult:
146 details_result: str | None = details.result if details else None
147 payload: str | None = details_result if include else None
148 truncated: bool = not include
149 return cls(payload=payload, truncated=truncated)
151 @classmethod
152 def from_durable_execution_invocation_output(
153 cls,
154 durable_execution_invocation_output: DurableExecutionInvocationOutput,
155 include: bool = False, # noqa: FBT001, FBT002
156 ) -> EventResult:
157 truncated: bool = not include
158 return cls(durable_execution_invocation_output.result, truncated)
161@dataclass(frozen=True)
162class EventError(AwsApiModel):
163 """Event error structure."""
165 payload: ErrorObject | None = field(default=None, metadata={"alias": "Payload"})
166 truncated: bool = field(default=False, metadata={"alias": "Truncated"})
168 @classmethod
169 def from_details(
170 cls,
171 details: CallbackDetails | StepDetails | ChainedInvokeDetails | ContextDetails,
172 include: bool = False, # noqa: FBT001, FBT002
173 ) -> EventError:
174 error_object: ErrorObject | None = details.error if details else None
175 truncated: bool = not include
176 return cls(error_object, truncated)
178 @classmethod
179 def from_durable_execution_invocation_output(
180 cls,
181 durable_execution_invocation_output: DurableExecutionInvocationOutput,
182 include: bool = False, # noqa: FBT001, FBT002
183 ) -> EventError:
184 truncated: bool = not include
185 return cls(durable_execution_invocation_output.error, truncated)
188@dataclass(frozen=True)
189class RetryDetails(AwsApiModel):
190 """Retry details structure."""
192 current_attempt: int = field(default=0, metadata={"alias": "CurrentAttempt"})
193 next_attempt_delay_seconds: int | None = field(
194 default=None, metadata={"alias": "NextAttemptDelaySeconds"}
195 )
198# Event detail structures
199@dataclass(frozen=True)
200class ExecutionStartedDetails(AwsApiModel):
201 """Execution started event details."""
203 input: EventInput | None = field(default=None, metadata={"alias": "Input"})
204 execution_timeout: int | None = field(
205 default=None, metadata={"alias": "ExecutionTimeout"}
206 )
209@dataclass(frozen=True)
210class ExecutionSucceededDetails(AwsApiModel):
211 """Execution succeeded event details."""
213 result: EventResult | None = field(default=None, metadata={"alias": "Result"})
216@dataclass(frozen=True)
217class ExecutionFailedDetails(AwsApiModel):
218 """Execution failed event details."""
220 error: EventError | None = field(default=None, metadata={"alias": "Error"})
223@dataclass(frozen=True)
224class ExecutionTimedOutDetails(AwsApiModel):
225 """Execution timed out event details."""
227 error: EventError | None = field(default=None, metadata={"alias": "Error"})
230@dataclass(frozen=True)
231class ExecutionStoppedDetails(AwsApiModel):
232 """Execution stopped event details."""
234 error: EventError | None = field(default=None, metadata={"alias": "Error"})
237@dataclass(frozen=True)
238class ContextStartedDetails(AwsApiModel):
239 """Context started event details."""
242@dataclass(frozen=True)
243class ContextSucceededDetails(AwsApiModel):
244 """Context succeeded event details."""
246 result: EventResult | None = field(default=None, metadata={"alias": "Result"})
249@dataclass(frozen=True)
250class ContextFailedDetails(AwsApiModel):
251 """Context failed event details."""
253 error: EventError | None = field(default=None, metadata={"alias": "Error"})
256@dataclass(frozen=True)
257class WaitStartedDetails(AwsApiModel):
258 """Wait started event details."""
260 duration: int | None = field(default=None, metadata={"alias": "Duration"})
261 scheduled_end_timestamp: datetime.datetime | None = field(
262 default=None,
263 metadata={"alias": "ScheduledEndTimestamp", "is_timestamp": True},
264 )
267@dataclass(frozen=True)
268class WaitSucceededDetails(AwsApiModel):
269 """Wait succeeded event details."""
271 duration: int | None = field(default=None, metadata={"alias": "Duration"})
274@dataclass(frozen=True)
275class WaitCancelledDetails(AwsApiModel):
276 """Wait cancelled event details."""
278 error: EventError | None = field(default=None, metadata={"alias": "Error"})
281@dataclass(frozen=True)
282class StepStartedDetails(AwsApiModel):
283 """Step started event details."""
286@dataclass(frozen=True)
287class StepSucceededDetails(AwsApiModel):
288 """Step succeeded event details."""
290 result: EventResult | None = field(default=None, metadata={"alias": "Result"})
291 retry_details: RetryDetails | None = field(
292 default=None, metadata={"alias": "RetryDetails"}
293 )
296@dataclass(frozen=True)
297class StepFailedDetails(AwsApiModel):
298 """Step failed event details."""
300 error: EventError | None = field(default=None, metadata={"alias": "Error"})
301 retry_details: RetryDetails | None = field(
302 default=None, metadata={"alias": "RetryDetails"}
303 )
306@dataclass(frozen=True)
307class ChainedInvokePendingDetails(AwsApiModel):
308 """Chained Invoke Pending event details."""
310 input: EventInput | None = field(default=None, metadata={"alias": "Input"})
311 function_name: str | None = field(default=None, metadata={"alias": "FunctionName"})
314@dataclass(frozen=True)
315class ChainedInvokeStartedDetails(AwsApiModel):
316 """Chained invoke started event details."""
318 durable_execution_arn: str | None = field(
319 default=None, metadata={"alias": "DurableExecutionArn"}
320 )
323@dataclass(frozen=True)
324class ChainedInvokeSucceededDetails(AwsApiModel):
325 """Chained invoke succeeded event details."""
327 result: EventResult | None = field(default=None, metadata={"alias": "Result"})
330@dataclass(frozen=True)
331class ChainedInvokeFailedDetails(AwsApiModel):
332 """Chained invoke failed event details."""
334 error: EventError | None = field(default=None, metadata={"alias": "Error"})
337@dataclass(frozen=True)
338class ChainedInvokeTimedOutDetails(AwsApiModel):
339 """Chained invoke timed out event details."""
341 error: EventError | None = field(default=None, metadata={"alias": "Error"})
344@dataclass(frozen=True)
345class ChainedInvokeStoppedDetails(AwsApiModel):
346 """Chained invoke stopped event details."""
348 error: EventError | None = field(default=None, metadata={"alias": "Error"})
351@dataclass(frozen=True)
352class CallbackStartedDetails(AwsApiModel):
353 """Callback started event details."""
355 callback_id: str | None = field(default=None, metadata={"alias": "CallbackId"})
356 heartbeat_timeout: int | None = field(
357 default=None, metadata={"alias": "HeartbeatTimeout"}
358 )
359 timeout: int | None = field(default=None, metadata={"alias": "Timeout"})
362@dataclass(frozen=True)
363class CallbackSucceededDetails(AwsApiModel):
364 """Callback succeeded event details."""
366 result: EventResult | None = field(default=None, metadata={"alias": "Result"})
369@dataclass(frozen=True)
370class CallbackFailedDetails(AwsApiModel):
371 """Callback failed event details."""
373 error: EventError | None = field(default=None, metadata={"alias": "Error"})
376@dataclass(frozen=True)
377class CallbackTimedOutDetails(AwsApiModel):
378 """Callback timed out event details."""
380 error: EventError | None = field(default=None, metadata={"alias": "Error"})
383@dataclass(frozen=True)
384class InvocationCompletedDetails(AwsApiModel):
385 """Invocation completed event details."""
387 start_timestamp: datetime.datetime = field(
388 metadata={"alias": "StartTimestamp", "is_timestamp": True}
389 )
390 end_timestamp: datetime.datetime = field(
391 metadata={"alias": "EndTimestamp", "is_timestamp": True}
392 )
393 request_id: str = field(metadata={"alias": "RequestId"})
396@dataclass(frozen=True)
397class EventCreationContext:
398 operation: Operation
399 event_id: int
400 durable_execution_arn: str
401 start_durable_execution_input: StartDurableExecutionInput
402 durable_execution_invocation_output: DurableExecutionInvocationOutput | None = None
403 operation_update: OperationUpdate | None = None
404 include_execution_data: bool = False
406 @property
407 def sub_type(self) -> str | None:
408 sub_type = self.operation.sub_type
409 if isinstance(sub_type, OperationSubType):
410 return sub_type.value
411 return sub_type
413 def get_retry_details(self) -> RetryDetails | None:
414 if not self.operation.step_details or not self.operation_update:
415 return None
417 delay = 0
418 if (
419 self.operation_update.operation_type == OperationType.STEP
420 and self.operation_update.step_options
421 ):
422 delay = self.operation_update.step_options.next_attempt_delay_seconds
424 return RetryDetails(
425 current_attempt=self.operation.step_details.attempt,
426 next_attempt_delay_seconds=delay,
427 )
429 @property
430 def start_timestamp(self) -> datetime.datetime:
431 return (
432 self.operation.start_timestamp
433 if self.operation.start_timestamp is not None
434 else datetime.datetime.now(datetime.timezone.utc)
435 )
437 @property
438 def end_timestamp(self) -> datetime.datetime:
439 return (
440 self.operation.end_timestamp
441 if self.operation.end_timestamp is not None
442 else datetime.datetime.now(datetime.timezone.utc)
443 )
446@dataclass(frozen=True)
447class Event(AwsApiModel):
448 """Event structure from Smithy model."""
450 event_type: str = field(metadata={"alias": "EventType"})
451 event_timestamp: datetime.datetime = field(
452 metadata={"alias": "EventTimestamp", "is_timestamp": True}
453 )
454 sub_type: str | None = field(default=None, metadata={"alias": "SubType"})
455 event_id: int = field(default=1, metadata={"alias": "EventId"})
456 operation_id: str | None = field(default=None, metadata={"alias": "Id"})
457 name: str | None = field(default=None, metadata={"alias": "Name"})
458 parent_id: str | None = field(default=None, metadata={"alias": "ParentId"})
459 execution_started_details: ExecutionStartedDetails | None = field(
460 default=None, metadata={"alias": "ExecutionStartedDetails"}
461 )
462 execution_succeeded_details: ExecutionSucceededDetails | None = field(
463 default=None, metadata={"alias": "ExecutionSucceededDetails"}
464 )
465 execution_failed_details: ExecutionFailedDetails | None = field(
466 default=None, metadata={"alias": "ExecutionFailedDetails"}
467 )
468 execution_timed_out_details: ExecutionTimedOutDetails | None = field(
469 default=None, metadata={"alias": "ExecutionTimedOutDetails"}
470 )
471 execution_stopped_details: ExecutionStoppedDetails | None = field(
472 default=None, metadata={"alias": "ExecutionStoppedDetails"}
473 )
474 context_started_details: ContextStartedDetails | None = field(
475 default=None, metadata={"alias": "ContextStartedDetails"}
476 )
477 context_succeeded_details: ContextSucceededDetails | None = field(
478 default=None, metadata={"alias": "ContextSucceededDetails"}
479 )
480 context_failed_details: ContextFailedDetails | None = field(
481 default=None, metadata={"alias": "ContextFailedDetails"}
482 )
483 wait_started_details: WaitStartedDetails | None = field(
484 default=None, metadata={"alias": "WaitStartedDetails"}
485 )
486 wait_succeeded_details: WaitSucceededDetails | None = field(
487 default=None, metadata={"alias": "WaitSucceededDetails"}
488 )
489 wait_cancelled_details: WaitCancelledDetails | None = field(
490 default=None, metadata={"alias": "WaitCancelledDetails"}
491 )
492 step_started_details: StepStartedDetails | None = field(
493 default=None, metadata={"alias": "StepStartedDetails"}
494 )
495 step_succeeded_details: StepSucceededDetails | None = field(
496 default=None, metadata={"alias": "StepSucceededDetails"}
497 )
498 step_failed_details: StepFailedDetails | None = field(
499 default=None, metadata={"alias": "StepFailedDetails"}
500 )
501 chained_invoke_pending_details: ChainedInvokePendingDetails | None = field(
502 default=None, metadata={"alias": "ChainedInvokePendingDetails"}
503 )
504 chained_invoke_started_details: ChainedInvokeStartedDetails | None = field(
505 default=None, metadata={"alias": "ChainedInvokeStartedDetails"}
506 )
507 chained_invoke_succeeded_details: ChainedInvokeSucceededDetails | None = field(
508 default=None, metadata={"alias": "ChainedInvokeSucceededDetails"}
509 )
510 chained_invoke_failed_details: ChainedInvokeFailedDetails | None = field(
511 default=None, metadata={"alias": "ChainedInvokeFailedDetails"}
512 )
513 chained_invoke_timed_out_details: ChainedInvokeTimedOutDetails | None = field(
514 default=None, metadata={"alias": "ChainedInvokeTimedOutDetails"}
515 )
516 chained_invoke_stopped_details: ChainedInvokeStoppedDetails | None = field(
517 default=None, metadata={"alias": "ChainedInvokeStoppedDetails"}
518 )
519 callback_started_details: CallbackStartedDetails | None = field(
520 default=None, metadata={"alias": "CallbackStartedDetails"}
521 )
522 callback_succeeded_details: CallbackSucceededDetails | None = field(
523 default=None, metadata={"alias": "CallbackSucceededDetails"}
524 )
525 callback_failed_details: CallbackFailedDetails | None = field(
526 default=None, metadata={"alias": "CallbackFailedDetails"}
527 )
528 callback_timed_out_details: CallbackTimedOutDetails | None = field(
529 default=None, metadata={"alias": "CallbackTimedOutDetails"}
530 )
531 invocation_completed_details: InvocationCompletedDetails | None = field(
532 default=None, metadata={"alias": "InvocationCompletedDetails"}
533 )
535 @classmethod
536 def create_execution_event_started(cls, context: EventCreationContext) -> Event:
537 execution_details: ExecutionDetails | None = context.operation.execution_details
538 event_input: EventInput | None = (
539 EventInput.from_details(execution_details, context.include_execution_data)
540 if execution_details
541 else None
542 )
543 execution_timeout: int | None = (
544 context.start_durable_execution_input.execution_timeout_seconds
545 )
547 return cls(
548 event_type=EventType.EXECUTION_STARTED.value,
549 event_timestamp=context.start_timestamp,
550 sub_type=context.sub_type,
551 event_id=context.event_id,
552 operation_id=context.operation.operation_id,
553 name=context.operation.name,
554 parent_id=context.operation.parent_id,
555 execution_started_details=ExecutionStartedDetails(
556 input=event_input,
557 execution_timeout=execution_timeout,
558 ),
559 )
561 @classmethod
562 def create_execution_event_succeeded(cls, context: EventCreationContext) -> Event:
563 result: EventResult | None = (
564 EventResult.from_durable_execution_invocation_output(
565 context.durable_execution_invocation_output,
566 context.include_execution_data,
567 )
568 if context.durable_execution_invocation_output
569 else None
570 )
571 return cls(
572 event_type=EventType.EXECUTION_SUCCEEDED.value,
573 event_timestamp=context.end_timestamp,
574 sub_type=context.sub_type,
575 event_id=context.event_id,
576 operation_id=context.operation.operation_id,
577 name=context.operation.name,
578 parent_id=context.operation.parent_id,
579 execution_succeeded_details=ExecutionSucceededDetails(result=result),
580 )
582 @classmethod
583 def create_execution_event_failed(cls, context: EventCreationContext) -> Event:
584 error: EventError | None = (
585 EventError.from_durable_execution_invocation_output(
586 context.durable_execution_invocation_output,
587 include=context.include_execution_data,
588 )
589 if context.durable_execution_invocation_output
590 else None
591 )
592 return cls(
593 event_type=EventType.EXECUTION_FAILED.value,
594 event_timestamp=context.end_timestamp,
595 sub_type=context.sub_type,
596 event_id=context.event_id,
597 operation_id=context.operation.operation_id,
598 name=context.operation.name,
599 parent_id=context.operation.parent_id,
600 execution_failed_details=ExecutionFailedDetails(error=error),
601 )
603 @classmethod
604 def create_execution_event_timed_out(cls, context: EventCreationContext) -> Event:
605 error: EventError | None = (
606 EventError.from_durable_execution_invocation_output(
607 context.durable_execution_invocation_output,
608 include=context.include_execution_data,
609 )
610 if context.durable_execution_invocation_output
611 else None
612 )
613 return cls(
614 event_type=EventType.EXECUTION_TIMED_OUT.value,
615 event_timestamp=context.end_timestamp,
616 sub_type=context.sub_type,
617 event_id=context.event_id,
618 operation_id=context.operation.operation_id,
619 name=context.operation.name,
620 parent_id=context.operation.parent_id,
621 execution_timed_out_details=ExecutionTimedOutDetails(error=error),
622 )
624 @classmethod
625 def create_execution_event_stopped(cls, context: EventCreationContext) -> Event:
626 error: EventError | None = (
627 EventError.from_durable_execution_invocation_output(
628 context.durable_execution_invocation_output,
629 include=context.include_execution_data,
630 )
631 if context.durable_execution_invocation_output
632 else None
633 )
634 return cls(
635 event_type=EventType.EXECUTION_STOPPED.value,
636 event_timestamp=context.end_timestamp,
637 sub_type=context.sub_type,
638 event_id=context.event_id,
639 operation_id=context.operation.operation_id,
640 name=context.operation.name,
641 parent_id=context.operation.parent_id,
642 execution_stopped_details=ExecutionStoppedDetails(error=error),
643 )
645 @classmethod
646 def create_execution_event(cls, context: EventCreationContext) -> Event:
647 """Create execution event based on action."""
648 match context.operation.status:
649 case OperationStatus.STARTED:
650 return cls.create_execution_event_started(context)
651 case OperationStatus.SUCCEEDED:
652 return cls.create_execution_event_succeeded(context)
653 case OperationStatus.FAILED:
654 return cls.create_execution_event_failed(context)
655 case OperationStatus.TIMED_OUT:
656 return cls.create_execution_event_timed_out(context)
657 case OperationStatus.STOPPED:
658 return cls.create_execution_event_stopped(context)
659 case _:
660 msg = f"Operation status {context.operation.status} is not valid for execution operations. Valid statuses are: STARTED, SUCCEEDED, FAILED, TIMED_OUT, STOPPED"
661 raise InvalidParameterValueException(msg)
663 @classmethod
664 def create_context_event_started(cls, context: EventCreationContext) -> Event:
665 return cls(
666 event_type=EventType.CONTEXT_STARTED.value,
667 event_timestamp=context.start_timestamp,
668 sub_type=context.sub_type,
669 event_id=context.event_id,
670 operation_id=context.operation.operation_id,
671 name=context.operation.name,
672 parent_id=context.operation.parent_id,
673 context_started_details=ContextStartedDetails(),
674 )
676 @classmethod
677 def create_context_event_succeeded(cls, context: EventCreationContext) -> Event:
678 context_details: ContextDetails | None = context.operation.context_details
679 event_result: EventResult | None = (
680 EventResult.from_details(context_details, context.include_execution_data)
681 if context_details
682 else None
683 )
684 return cls(
685 event_type=EventType.CONTEXT_SUCCEEDED.value,
686 event_timestamp=context.end_timestamp,
687 sub_type=context.sub_type,
688 event_id=context.event_id,
689 operation_id=context.operation.operation_id,
690 name=context.operation.name,
691 parent_id=context.operation.parent_id,
692 context_succeeded_details=ContextSucceededDetails(result=event_result),
693 )
695 @classmethod
696 def create_context_event_failed(cls, context: EventCreationContext) -> Event:
697 context_details: ContextDetails | None = context.operation.context_details
698 event_error: EventError | None = (
699 EventError.from_details(context_details) if context_details else None
700 )
701 return cls(
702 event_type=EventType.CONTEXT_FAILED.value,
703 event_timestamp=context.end_timestamp,
704 sub_type=context.sub_type,
705 event_id=context.event_id,
706 operation_id=context.operation.operation_id,
707 name=context.operation.name,
708 parent_id=context.operation.parent_id,
709 context_failed_details=ContextFailedDetails(error=event_error),
710 )
712 @classmethod
713 def create_context_event(cls, context: EventCreationContext) -> Event:
714 """Create context event based on action."""
715 match context.operation.status:
716 case OperationStatus.STARTED:
717 return cls.create_context_event_started(context)
718 case OperationStatus.SUCCEEDED:
719 return cls.create_context_event_succeeded(context)
720 case OperationStatus.FAILED:
721 return cls.create_context_event_failed(context)
722 case _:
723 msg = (
724 f"Operation status {context.operation.status} is not valid for context operations. "
725 f"Valid statuses are: STARTED, SUCCEEDED, FAILED"
726 )
727 raise InvalidParameterValueException(msg)
729 @classmethod
730 def create_wait_event_started(cls, context: EventCreationContext) -> Event:
731 wait_details: WaitDetails | None = context.operation.wait_details
732 scheduled_end_timestamp: datetime.datetime | None = (
733 wait_details.scheduled_end_timestamp if wait_details else None
734 )
735 duration: int | None = None
736 if ( 736 ↛ 747line 736 didn't jump to line 747 because the condition on line 736 was always true
737 wait_details
738 and wait_details.scheduled_end_timestamp
739 and context.operation.start_timestamp
740 ):
741 duration = round(
742 (
743 wait_details.scheduled_end_timestamp
744 - context.operation.start_timestamp
745 ).total_seconds()
746 )
747 return cls(
748 event_type=EventType.WAIT_STARTED.value,
749 event_timestamp=context.start_timestamp,
750 sub_type=context.sub_type,
751 event_id=context.event_id,
752 operation_id=context.operation.operation_id,
753 name=context.operation.name,
754 parent_id=context.operation.parent_id,
755 wait_started_details=WaitStartedDetails(
756 duration=duration,
757 scheduled_end_timestamp=scheduled_end_timestamp,
758 ),
759 )
761 @classmethod
762 def create_wait_event_succeeded(cls, context: EventCreationContext) -> Event:
763 wait_details: WaitDetails | None = context.operation.wait_details
764 duration: int | None = None
765 if ( 765 ↛ 775line 765 didn't jump to line 775 because the condition on line 765 was always true
766 wait_details
767 and wait_details.scheduled_end_timestamp
768 and context.operation.start_timestamp
769 ):
770 duration = round(
771 (
772 wait_details.scheduled_end_timestamp - context.start_timestamp
773 ).total_seconds()
774 )
775 return cls(
776 event_type=EventType.WAIT_SUCCEEDED.value,
777 event_timestamp=context.end_timestamp,
778 sub_type=context.sub_type,
779 event_id=context.event_id,
780 operation_id=context.operation.operation_id,
781 name=context.operation.name,
782 parent_id=context.operation.parent_id,
783 wait_succeeded_details=WaitSucceededDetails(duration=duration),
784 )
786 @classmethod
787 def create_wait_event_cancelled(cls, context: EventCreationContext) -> Event:
788 error: EventError | None = None
789 if ( 789 ↛ 794line 789 didn't jump to line 794 because the condition on line 789 was never true
790 context.operation_update
791 and context.operation_update.operation_type == OperationType.WAIT
792 and context.operation_update.action == OperationAction.CANCEL
793 ):
794 error = EventError(
795 context.operation_update.error, not context.include_execution_data
796 )
797 return cls(
798 event_type=EventType.WAIT_CANCELLED.value,
799 event_timestamp=context.end_timestamp,
800 sub_type=context.sub_type,
801 event_id=context.event_id,
802 operation_id=context.operation.operation_id,
803 name=context.operation.name,
804 parent_id=context.operation.parent_id,
805 wait_cancelled_details=WaitCancelledDetails(error=error),
806 )
808 @classmethod
809 def create_wait_event(cls, context: EventCreationContext) -> Event:
810 """Create wait event based on action."""
811 match context.operation.status:
812 case OperationStatus.STARTED:
813 return cls.create_wait_event_started(context)
814 case OperationStatus.SUCCEEDED:
815 return cls.create_wait_event_succeeded(context)
816 case OperationStatus.CANCELLED:
817 return cls.create_wait_event_cancelled(context)
818 case _:
819 msg = (
820 f"Operation status {context.operation.status} is not valid for wait operations. "
821 f"Valid statuses are: STARTED, SUCCEEDED, CANCELLED"
822 )
823 raise InvalidParameterValueException(msg)
825 @classmethod
826 def create_step_event_started(cls, context: EventCreationContext) -> Event:
827 return cls(
828 event_type=EventType.STEP_STARTED.value,
829 event_timestamp=context.start_timestamp,
830 sub_type=context.sub_type,
831 event_id=context.event_id,
832 operation_id=context.operation.operation_id,
833 name=context.operation.name,
834 parent_id=context.operation.parent_id,
835 step_started_details=StepStartedDetails(),
836 )
838 @classmethod
839 def create_step_event_succeeded(cls, context: EventCreationContext) -> Event:
840 step_details: StepDetails | None = context.operation.step_details
841 event_result: EventResult | None = (
842 EventResult.from_details(step_details, context.include_execution_data)
843 if step_details
844 else None
845 )
846 return cls(
847 event_type=EventType.STEP_SUCCEEDED.value,
848 event_timestamp=context.end_timestamp,
849 sub_type=context.sub_type,
850 event_id=context.event_id,
851 operation_id=context.operation.operation_id,
852 name=context.operation.name,
853 parent_id=context.operation.parent_id,
854 step_succeeded_details=StepSucceededDetails(
855 result=event_result,
856 retry_details=context.get_retry_details(),
857 ),
858 )
860 @classmethod
861 def create_step_event_failed(cls, context: EventCreationContext) -> Event:
862 step_details: StepDetails | None = context.operation.step_details
863 event_error: EventError | None = (
864 EventError.from_details(
865 step_details, include=context.include_execution_data
866 )
867 if step_details
868 else None
869 )
870 return cls(
871 event_type=EventType.STEP_FAILED.value,
872 event_timestamp=context.end_timestamp,
873 sub_type=context.sub_type,
874 event_id=context.event_id,
875 operation_id=context.operation.operation_id,
876 name=context.operation.name,
877 parent_id=context.operation.parent_id,
878 step_failed_details=StepFailedDetails(
879 error=event_error,
880 retry_details=context.get_retry_details(),
881 ),
882 )
884 @classmethod
885 def create_step_event(cls, context: EventCreationContext) -> Event:
886 """Create step event based on action."""
887 match context.operation.status:
888 case OperationStatus.STARTED:
889 return cls.create_step_event_started(context)
890 case OperationStatus.SUCCEEDED:
891 return cls.create_step_event_succeeded(context)
892 case OperationStatus.FAILED:
893 return cls.create_step_event_failed(context)
894 case _:
895 msg = (
896 f"Operation status {context.operation.status} is not valid for step operations. "
897 f"Valid statuses are: STARTED, SUCCEEDED, FAILED"
898 )
899 raise InvalidParameterValueException(msg)
901 @classmethod
902 def create_chained_invoke_event_pending(
903 cls, context: EventCreationContext
904 ) -> Event:
905 input: EventInput = EventInput.from_start_durable_execution_input(
906 context.start_durable_execution_input, context.include_execution_data
907 )
908 return cls(
909 event_type=EventType.CHAINED_INVOKE_STARTED.value,
910 event_timestamp=context.start_timestamp,
911 sub_type=context.sub_type,
912 event_id=context.event_id,
913 operation_id=context.operation.operation_id,
914 name=context.operation.name,
915 parent_id=context.operation.parent_id,
916 chained_invoke_pending_details=ChainedInvokePendingDetails(
917 input=input,
918 function_name=context.start_durable_execution_input.function_name,
919 ),
920 )
922 @classmethod
923 def create_chained_invoke_event_started(
924 cls, context: EventCreationContext
925 ) -> Event:
926 return cls(
927 event_type=EventType.CHAINED_INVOKE_STARTED.value,
928 event_timestamp=context.start_timestamp,
929 sub_type=context.sub_type,
930 event_id=context.event_id,
931 operation_id=context.operation.operation_id,
932 name=context.operation.name,
933 parent_id=context.operation.parent_id,
934 chained_invoke_started_details=ChainedInvokeStartedDetails(
935 durable_execution_arn=context.durable_execution_arn
936 ),
937 )
939 @classmethod
940 def create_chained_invoke_event_succeeded(
941 cls, context: EventCreationContext
942 ) -> Event:
943 chained_invoke_details: ChainedInvokeDetails | None = (
944 context.operation.chained_invoke_details
945 )
946 event_result: EventResult | None = (
947 EventResult.from_details(
948 chained_invoke_details, context.include_execution_data
949 )
950 if chained_invoke_details
951 else None
952 )
953 return cls(
954 event_type=EventType.CHAINED_INVOKE_SUCCEEDED.value,
955 event_timestamp=context.end_timestamp,
956 sub_type=context.sub_type,
957 event_id=context.event_id,
958 operation_id=context.operation.operation_id,
959 name=context.operation.name,
960 parent_id=context.operation.parent_id,
961 chained_invoke_succeeded_details=ChainedInvokeSucceededDetails(
962 result=event_result
963 ),
964 )
966 @classmethod
967 def create_chained_invoke_event_failed(cls, context: EventCreationContext) -> Event:
968 chained_invoke_details: ChainedInvokeDetails | None = (
969 context.operation.chained_invoke_details
970 )
971 event_error: EventError | None = (
972 EventError.from_details(
973 chained_invoke_details, include=context.include_execution_data
974 )
975 if chained_invoke_details
976 else None
977 )
978 return cls(
979 event_type=EventType.CHAINED_INVOKE_FAILED.value,
980 event_timestamp=context.end_timestamp,
981 sub_type=context.sub_type,
982 event_id=context.event_id,
983 operation_id=context.operation.operation_id,
984 name=context.operation.name,
985 parent_id=context.operation.parent_id,
986 chained_invoke_failed_details=ChainedInvokeFailedDetails(error=event_error),
987 )
989 @classmethod
990 def create_chained_invoke_event_timed_out(
991 cls, context: EventCreationContext
992 ) -> Event:
993 chained_invoke_details: ChainedInvokeDetails | None = (
994 context.operation.chained_invoke_details
995 )
996 event_error: EventError | None = (
997 EventError.from_details(
998 chained_invoke_details, include=context.include_execution_data
999 )
1000 if chained_invoke_details
1001 else None
1002 )
1003 return cls(
1004 event_type=EventType.CHAINED_INVOKE_TIMED_OUT.value,
1005 event_timestamp=context.end_timestamp,
1006 sub_type=context.sub_type,
1007 event_id=context.event_id,
1008 operation_id=context.operation.operation_id,
1009 name=context.operation.name,
1010 parent_id=context.operation.parent_id,
1011 chained_invoke_timed_out_details=ChainedInvokeTimedOutDetails(
1012 error=event_error
1013 ),
1014 )
1016 @classmethod
1017 def create_chained_invoke_event_stopped(
1018 cls, context: EventCreationContext
1019 ) -> Event:
1020 chained_invoke_details: ChainedInvokeDetails | None = (
1021 context.operation.chained_invoke_details
1022 )
1023 event_error: EventError | None = (
1024 EventError.from_details(
1025 chained_invoke_details, include=context.include_execution_data
1026 )
1027 if chained_invoke_details
1028 else None
1029 )
1030 return cls(
1031 event_type=EventType.CHAINED_INVOKE_STOPPED.value,
1032 event_timestamp=context.end_timestamp,
1033 sub_type=context.sub_type,
1034 event_id=context.event_id,
1035 operation_id=context.operation.operation_id,
1036 name=context.operation.name,
1037 parent_id=context.operation.parent_id,
1038 chained_invoke_stopped_details=ChainedInvokeStoppedDetails(
1039 error=event_error
1040 ),
1041 )
1043 @classmethod
1044 def create_chained_invoke_event(cls, context: EventCreationContext) -> Event:
1045 """Create chained invoke event based on action."""
1046 match context.operation.status:
1047 case OperationStatus.PENDING: 1047 ↛ 1048line 1047 didn't jump to line 1048 because the pattern on line 1047 never matched
1048 return cls.create_chained_invoke_event_pending(context)
1049 case OperationStatus.STARTED:
1050 return cls.create_chained_invoke_event_started(context)
1051 case OperationStatus.SUCCEEDED:
1052 return cls.create_chained_invoke_event_succeeded(context)
1053 case OperationStatus.FAILED:
1054 return cls.create_chained_invoke_event_failed(context)
1055 case OperationStatus.TIMED_OUT:
1056 return cls.create_chained_invoke_event_timed_out(context)
1057 case OperationStatus.STOPPED:
1058 return cls.create_chained_invoke_event_stopped(context)
1059 case _:
1060 msg = (
1061 f"Operation status {context.operation.status} is not valid for chained invoke operations. Valid statuses are: "
1062 f"STARTED, SUCCEEDED, FAILED, TIMED_OUT, STOPPED"
1063 )
1064 raise InvalidParameterValueException(msg)
1066 @classmethod
1067 def create_callback_event_started(cls, context: EventCreationContext) -> Event:
1068 callback_details: CallbackDetails | None = context.operation.callback_details
1069 callback_id: str | None = (
1070 callback_details.callback_id if callback_details else None
1071 )
1072 callback_options: CallbackOptions | None = (
1073 context.operation_update.callback_options
1074 if context.operation_update
1075 else None
1076 )
1077 timeout: int | None = (
1078 callback_options.timeout_seconds if callback_options else None
1079 )
1080 heartbeat_timeout: int | None = (
1081 callback_options.heartbeat_timeout_seconds if callback_options else None
1082 )
1083 return cls(
1084 event_type=EventType.CALLBACK_STARTED.value,
1085 event_timestamp=context.start_timestamp,
1086 sub_type=context.sub_type,
1087 event_id=context.event_id,
1088 operation_id=context.operation.operation_id,
1089 name=context.operation.name,
1090 parent_id=context.operation.parent_id,
1091 callback_started_details=CallbackStartedDetails(
1092 callback_id=callback_id,
1093 timeout=timeout,
1094 heartbeat_timeout=heartbeat_timeout,
1095 ),
1096 )
1098 @classmethod
1099 def create_callback_event_succeeded(cls, context: EventCreationContext) -> Event:
1100 callback_details: CallbackDetails | None = context.operation.callback_details
1101 event_result: EventResult | None = (
1102 EventResult.from_details(callback_details, context.include_execution_data)
1103 if callback_details
1104 else None
1105 )
1106 return cls(
1107 event_type=EventType.CALLBACK_SUCCEEDED.value,
1108 event_timestamp=context.end_timestamp,
1109 sub_type=context.sub_type,
1110 event_id=context.event_id,
1111 operation_id=context.operation.operation_id,
1112 name=context.operation.name,
1113 parent_id=context.operation.parent_id,
1114 callback_succeeded_details=CallbackSucceededDetails(result=event_result),
1115 )
1117 @classmethod
1118 def create_callback_event_failed(cls, context: EventCreationContext) -> Event:
1119 callback_details: CallbackDetails | None = context.operation.callback_details
1120 event_error: EventError | None = (
1121 EventError.from_details(callback_details) if callback_details else None
1122 )
1123 return cls(
1124 event_type=EventType.CALLBACK_FAILED.value,
1125 event_timestamp=context.end_timestamp,
1126 sub_type=context.sub_type,
1127 event_id=context.event_id,
1128 operation_id=context.operation.operation_id,
1129 name=context.operation.name,
1130 parent_id=context.operation.parent_id,
1131 callback_failed_details=CallbackFailedDetails(error=event_error),
1132 )
1134 @classmethod
1135 def create_callback_event_timed_out(cls, context: EventCreationContext) -> Event:
1136 callback_details: CallbackDetails | None = context.operation.callback_details
1137 event_error: EventError | None = (
1138 EventError.from_details(callback_details) if callback_details else None
1139 )
1140 return cls(
1141 event_type=EventType.CALLBACK_TIMED_OUT.value,
1142 event_timestamp=context.end_timestamp,
1143 sub_type=context.sub_type,
1144 event_id=context.event_id,
1145 operation_id=context.operation.operation_id,
1146 name=context.operation.name,
1147 parent_id=context.operation.parent_id,
1148 callback_timed_out_details=CallbackTimedOutDetails(error=event_error),
1149 )
1151 @classmethod
1152 def create_callback_event(cls, context: EventCreationContext) -> Event:
1153 """Create callback event based on action."""
1154 match context.operation.status:
1155 case OperationStatus.STARTED:
1156 return cls.create_callback_event_started(context)
1157 case OperationStatus.SUCCEEDED:
1158 return cls.create_callback_event_succeeded(context)
1159 case OperationStatus.FAILED:
1160 return cls.create_callback_event_failed(context)
1161 case OperationStatus.TIMED_OUT:
1162 return cls.create_callback_event_timed_out(context)
1163 case _:
1164 msg = (
1165 f"Operation status {context.operation.status} is not valid for callback operations. "
1166 f"Valid statuses are: STARTED, SUCCEEDED, FAILED, TIMED_OUT"
1167 )
1168 raise InvalidParameterValueException(msg)
1170 @classmethod
1171 def create_invocation_completed(
1172 cls,
1173 event_id: int,
1174 event_timestamp: datetime.datetime,
1175 start_timestamp: datetime.datetime,
1176 end_timestamp: datetime.datetime,
1177 request_id: str,
1178 ) -> Event:
1179 """Create invocation completed event."""
1180 return cls(
1181 event_type=EventType.INVOCATION_COMPLETED.value,
1182 event_timestamp=event_timestamp,
1183 event_id=event_id,
1184 invocation_completed_details=InvocationCompletedDetails(
1185 start_timestamp=start_timestamp,
1186 end_timestamp=end_timestamp,
1187 request_id=request_id,
1188 ),
1189 )
1191 @classmethod
1192 def create_event_started(cls, context: EventCreationContext) -> Event:
1193 """Convert operation to started event."""
1194 if context.operation.start_timestamp is None:
1195 msg: str = "Operation start timestamp cannot be None when converting to started event"
1196 raise InvalidParameterValueException(msg)
1198 match context.operation.operation_type:
1199 case OperationType.EXECUTION:
1200 return cls.create_execution_event_started(context)
1201 case OperationType.CONTEXT:
1202 return cls.create_context_event_started(context)
1203 case OperationType.WAIT:
1204 return cls.create_wait_event_started(context)
1205 case OperationType.STEP:
1206 return cls.create_step_event_started(context)
1207 case OperationType.CHAINED_INVOKE:
1208 return cls.create_chained_invoke_event_started(context)
1209 case OperationType.CALLBACK:
1210 return cls.create_callback_event_started(context)
1211 case _:
1212 msg = f"Unknown operation type: {context.operation.operation_type}"
1213 raise InvalidParameterValueException(msg)
1215 @classmethod
1216 def from_event_with_id(cls, event: Event, event_id: int) -> Event:
1217 """Create a new Event from an existing event with updated event_id."""
1218 return cls(
1219 event_type=event.event_type,
1220 event_timestamp=event.event_timestamp,
1221 sub_type=event.sub_type,
1222 event_id=event_id,
1223 operation_id=event.operation_id,
1224 name=event.name,
1225 parent_id=event.parent_id,
1226 execution_started_details=event.execution_started_details,
1227 execution_succeeded_details=event.execution_succeeded_details,
1228 execution_failed_details=event.execution_failed_details,
1229 execution_timed_out_details=event.execution_timed_out_details,
1230 execution_stopped_details=event.execution_stopped_details,
1231 context_started_details=event.context_started_details,
1232 context_succeeded_details=event.context_succeeded_details,
1233 context_failed_details=event.context_failed_details,
1234 wait_started_details=event.wait_started_details,
1235 wait_succeeded_details=event.wait_succeeded_details,
1236 wait_cancelled_details=event.wait_cancelled_details,
1237 step_started_details=event.step_started_details,
1238 step_succeeded_details=event.step_succeeded_details,
1239 step_failed_details=event.step_failed_details,
1240 chained_invoke_pending_details=event.chained_invoke_pending_details,
1241 chained_invoke_started_details=event.chained_invoke_started_details,
1242 chained_invoke_succeeded_details=event.chained_invoke_succeeded_details,
1243 chained_invoke_failed_details=event.chained_invoke_failed_details,
1244 chained_invoke_timed_out_details=event.chained_invoke_timed_out_details,
1245 chained_invoke_stopped_details=event.chained_invoke_stopped_details,
1246 callback_started_details=event.callback_started_details,
1247 callback_succeeded_details=event.callback_succeeded_details,
1248 callback_failed_details=event.callback_failed_details,
1249 callback_timed_out_details=event.callback_timed_out_details,
1250 )
1252 @classmethod
1253 def create_event_terminated(cls, context: EventCreationContext) -> Event:
1254 """Convert operation to finished event."""
1255 operation: Operation = context.operation
1256 if operation.end_timestamp is None:
1257 msg: str = "Operation end timestamp cannot be None when converting to finished event"
1258 raise InvalidParameterValueException(msg)
1260 if operation.status not in TERMINAL_STATUSES:
1261 msg = f"Operation status must be one of SUCCEEDED, FAILED, TIMED_OUT, STOPPED, or CANCELLED. Got: {operation.status}"
1262 raise InvalidParameterValueException(msg)
1264 match operation.operation_type:
1265 case OperationType.EXECUTION:
1266 return cls.create_execution_event(context)
1267 case OperationType.CONTEXT:
1268 return cls.create_context_event(context)
1269 case OperationType.WAIT:
1270 return cls.create_wait_event(context)
1271 case OperationType.STEP:
1272 return cls.create_step_event(context)
1273 case OperationType.CHAINED_INVOKE:
1274 return cls.create_chained_invoke_event(context)
1275 case OperationType.CALLBACK:
1276 return cls.create_callback_event(context)
1277 case _:
1278 msg = f"Unknown operation type: {operation.operation_type}"
1279 raise InvalidParameterValueException(msg)
1282@dataclass(frozen=True)
1283class HistoryEventTypeConfig:
1284 """Configuration for how to process a specific event type."""
1286 operation_type: OperationType | None
1287 operation_status: OperationStatus | None
1288 is_start_event: bool
1289 is_end_event: bool
1290 has_result: bool # Whether this event type contains result/error data
1293# Mapping of event types to their processing configuration
1294# This matches the TypeScript historyEventTypes constant
1295HISTORY_EVENT_TYPES: dict[str, HistoryEventTypeConfig] = {
1296 "ExecutionStarted": HistoryEventTypeConfig(
1297 operation_type=OperationType.EXECUTION,
1298 operation_status=OperationStatus.STARTED,
1299 is_start_event=True,
1300 is_end_event=False,
1301 has_result=False,
1302 ),
1303 "ExecutionFailed": HistoryEventTypeConfig(
1304 operation_type=OperationType.EXECUTION,
1305 operation_status=OperationStatus.FAILED,
1306 is_start_event=False,
1307 is_end_event=True,
1308 has_result=False,
1309 ),
1310 "ExecutionStopped": HistoryEventTypeConfig(
1311 operation_type=OperationType.EXECUTION,
1312 operation_status=OperationStatus.STOPPED,
1313 is_start_event=False,
1314 is_end_event=True,
1315 has_result=False,
1316 ),
1317 "ExecutionSucceeded": HistoryEventTypeConfig(
1318 operation_type=OperationType.EXECUTION,
1319 operation_status=OperationStatus.SUCCEEDED,
1320 is_start_event=False,
1321 is_end_event=True,
1322 has_result=False,
1323 ),
1324 "ExecutionTimedOut": HistoryEventTypeConfig(
1325 operation_type=OperationType.EXECUTION,
1326 operation_status=OperationStatus.TIMED_OUT,
1327 is_start_event=False,
1328 is_end_event=True,
1329 has_result=False,
1330 ),
1331 "CallbackStarted": HistoryEventTypeConfig(
1332 operation_type=OperationType.CALLBACK,
1333 operation_status=OperationStatus.STARTED,
1334 is_start_event=True,
1335 is_end_event=False,
1336 has_result=False,
1337 ),
1338 "CallbackFailed": HistoryEventTypeConfig(
1339 operation_type=OperationType.CALLBACK,
1340 operation_status=OperationStatus.FAILED,
1341 is_start_event=False,
1342 is_end_event=True,
1343 has_result=True,
1344 ),
1345 "CallbackSucceeded": HistoryEventTypeConfig(
1346 operation_type=OperationType.CALLBACK,
1347 operation_status=OperationStatus.SUCCEEDED,
1348 is_start_event=False,
1349 is_end_event=True,
1350 has_result=True,
1351 ),
1352 "CallbackTimedOut": HistoryEventTypeConfig(
1353 operation_type=OperationType.CALLBACK,
1354 operation_status=OperationStatus.TIMED_OUT,
1355 is_start_event=False,
1356 is_end_event=True,
1357 has_result=True,
1358 ),
1359 "ContextStarted": HistoryEventTypeConfig(
1360 operation_type=OperationType.CONTEXT,
1361 operation_status=OperationStatus.STARTED,
1362 is_start_event=True,
1363 is_end_event=False,
1364 has_result=False,
1365 ),
1366 "ContextFailed": HistoryEventTypeConfig(
1367 operation_type=OperationType.CONTEXT,
1368 operation_status=OperationStatus.FAILED,
1369 is_start_event=False,
1370 is_end_event=True,
1371 has_result=True,
1372 ),
1373 "ContextSucceeded": HistoryEventTypeConfig(
1374 operation_type=OperationType.CONTEXT,
1375 operation_status=OperationStatus.SUCCEEDED,
1376 is_start_event=False,
1377 is_end_event=True,
1378 has_result=True,
1379 ),
1380 "ChainedInvokeStarted": HistoryEventTypeConfig(
1381 operation_type=OperationType.CHAINED_INVOKE,
1382 operation_status=OperationStatus.STARTED,
1383 is_start_event=True,
1384 is_end_event=False,
1385 has_result=False,
1386 ),
1387 "ChainedInvokeFailed": HistoryEventTypeConfig(
1388 operation_type=OperationType.CHAINED_INVOKE,
1389 operation_status=OperationStatus.FAILED,
1390 is_start_event=False,
1391 is_end_event=True,
1392 has_result=True,
1393 ),
1394 "ChainedInvokeSucceeded": HistoryEventTypeConfig(
1395 operation_type=OperationType.CHAINED_INVOKE,
1396 operation_status=OperationStatus.SUCCEEDED,
1397 is_start_event=False,
1398 is_end_event=True,
1399 has_result=True,
1400 ),
1401 "ChainedInvokeTimedOut": HistoryEventTypeConfig(
1402 operation_type=OperationType.CHAINED_INVOKE,
1403 operation_status=OperationStatus.TIMED_OUT,
1404 is_start_event=False,
1405 is_end_event=True,
1406 has_result=True,
1407 ),
1408 "ChainedInvokeCancelled": HistoryEventTypeConfig(
1409 operation_type=OperationType.CHAINED_INVOKE,
1410 operation_status=OperationStatus.CANCELLED,
1411 is_start_event=False,
1412 is_end_event=True,
1413 has_result=True,
1414 ),
1415 "StepStarted": HistoryEventTypeConfig(
1416 operation_type=OperationType.STEP,
1417 operation_status=OperationStatus.STARTED,
1418 is_start_event=True,
1419 is_end_event=False,
1420 has_result=False,
1421 ),
1422 "StepFailed": HistoryEventTypeConfig(
1423 operation_type=OperationType.STEP,
1424 operation_status=OperationStatus.FAILED,
1425 is_start_event=False,
1426 is_end_event=True,
1427 has_result=True,
1428 ),
1429 "StepSucceeded": HistoryEventTypeConfig(
1430 operation_type=OperationType.STEP,
1431 operation_status=OperationStatus.SUCCEEDED,
1432 is_start_event=False,
1433 is_end_event=True,
1434 has_result=True,
1435 ),
1436 "WaitStarted": HistoryEventTypeConfig(
1437 operation_type=OperationType.WAIT,
1438 operation_status=OperationStatus.STARTED,
1439 is_start_event=True,
1440 is_end_event=False,
1441 has_result=True,
1442 ),
1443 "WaitSucceeded": HistoryEventTypeConfig(
1444 operation_type=OperationType.WAIT,
1445 operation_status=OperationStatus.SUCCEEDED,
1446 is_start_event=False,
1447 is_end_event=True,
1448 has_result=True,
1449 ),
1450 "WaitCancelled": HistoryEventTypeConfig(
1451 operation_type=OperationType.WAIT,
1452 operation_status=OperationStatus.CANCELLED,
1453 is_start_event=False,
1454 is_end_event=True,
1455 has_result=True,
1456 ),
1457 # TODO: add support for populating invocation information from InvocationCompleted event
1458 "InvocationCompleted": HistoryEventTypeConfig(
1459 operation_type=None,
1460 operation_status=None,
1461 is_start_event=False,
1462 is_end_event=False,
1463 has_result=True,
1464 ),
1465}
1468def events_to_operations(events: list[Event]) -> list[Operation]:
1469 """Convert a list of history events into operations.
1471 This function processes raw history events and groups them by operation ID,
1472 creating comprehensive operation objects following the TypeScript pattern from
1473 aws-durable-execution-sdk-js-testing.
1475 Multiple events for the same operation_id are merged together, with each event
1476 contributing its specific fields (e.g., CallbackStarted provides callback_id,
1477 CallbackSucceeded provides result).
1479 Args:
1480 events: List of history events to process
1482 Returns:
1483 List of operations, one per unique operation ID
1485 Raises:
1486 InvalidParameterValueException: When required fields are missing from an event
1488 Note:
1489 InvocationCompleted events are currently skipped as they don't represent
1490 operations. Future enhancement: populate invocation information from these
1491 events (TODO).
1492 """
1493 operations_map: dict[str, Operation] = {}
1495 for event in events:
1496 if not event.event_type:
1497 msg = "Missing required 'event_type' field in event"
1498 raise InvalidParameterValueException(msg)
1500 # Get event type configuration
1501 event_config: HistoryEventTypeConfig | None = HISTORY_EVENT_TYPES.get(
1502 event.event_type
1503 )
1504 if not event_config:
1505 msg = f"Unknown event type: {event.event_type}"
1506 raise InvalidParameterValueException(msg)
1508 # TODO: add support for populating invocation information from InvocationCompleted event
1509 if event.event_type == "InvocationCompleted":
1510 continue
1512 if not event.operation_id:
1513 msg = f"Missing required 'operation_id' field in event {event.event_id}"
1514 raise InvalidParameterValueException(msg)
1516 # Get previous operation if it exists
1517 previous_operation: Operation | None = operations_map.get(event.operation_id)
1519 # Get operation type and status from configuration
1520 operation_type: OperationType = (
1521 event_config.operation_type or OperationType.EXECUTION
1522 )
1523 status: OperationStatus = (
1524 event_config.operation_status or OperationStatus.PENDING
1525 )
1527 # Parse sub_type
1528 sub_type: OperationSubType | str | None = None
1529 if event.sub_type:
1530 try:
1531 sub_type = OperationSubType(event.sub_type)
1532 except ValueError:
1533 sub_type = event.sub_type
1535 # Create base operation
1536 operation = Operation(
1537 operation_id=event.operation_id,
1538 operation_type=operation_type,
1539 status=status,
1540 name=event.name,
1541 parent_id=event.parent_id,
1542 sub_type=sub_type,
1543 start_timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
1544 )
1546 # Merge with previous operation if it exists
1547 # Most fields are immutable, so they get preserved from previous events
1548 if previous_operation:
1549 operation = replace(
1550 operation,
1551 name=operation.name or previous_operation.name,
1552 parent_id=operation.parent_id or previous_operation.parent_id,
1553 sub_type=operation.sub_type or previous_operation.sub_type,
1554 start_timestamp=previous_operation.start_timestamp,
1555 end_timestamp=previous_operation.end_timestamp,
1556 execution_details=previous_operation.execution_details,
1557 context_details=previous_operation.context_details,
1558 step_details=previous_operation.step_details,
1559 wait_details=previous_operation.wait_details,
1560 callback_details=previous_operation.callback_details,
1561 chained_invoke_details=previous_operation.chained_invoke_details,
1562 )
1564 # Set timestamps based on event configuration
1565 if event_config.is_start_event:
1566 operation = replace(operation, start_timestamp=event.event_timestamp)
1567 if event_config.is_end_event:
1568 operation = replace(operation, end_timestamp=event.event_timestamp)
1570 # Add operation-specific details incrementally
1571 # Each event type contributes only the fields it has
1573 # EXECUTION details
1574 if (
1575 operation_type == OperationType.EXECUTION
1576 and event.execution_started_details
1577 and event.execution_started_details.input
1578 ):
1579 operation = replace(
1580 operation,
1581 execution_details=ExecutionDetails(
1582 input_payload=event.execution_started_details.input.payload
1583 ),
1584 )
1586 # CALLBACK details - merge callback_id, result, and error from different events
1587 if operation_type == OperationType.CALLBACK:
1588 existing_cb: CallbackDetails | None = operation.callback_details
1589 callback_id: str = existing_cb.callback_id if existing_cb else ""
1590 result: str | None = existing_cb.result if existing_cb else None
1591 error: ErrorObject | None = existing_cb.error if existing_cb else None
1593 # CallbackStarted provides callback_id
1594 if event.callback_started_details:
1595 callback_id = event.callback_started_details.callback_id or callback_id
1597 # CallbackSucceeded provides result
1598 if (
1599 event.callback_succeeded_details
1600 and event.callback_succeeded_details.result
1601 ):
1602 result = event.callback_succeeded_details.result.payload
1604 # CallbackFailed provides error
1605 if event.callback_failed_details and event.callback_failed_details.error:
1606 error = event.callback_failed_details.error.payload
1608 # CallbackTimedOut provides error
1609 if (
1610 event.callback_timed_out_details
1611 and event.callback_timed_out_details.error
1612 ):
1613 error = event.callback_timed_out_details.error.payload
1615 operation = replace(
1616 operation,
1617 callback_details=CallbackDetails(
1618 callback_id=callback_id,
1619 result=result,
1620 error=error,
1621 ),
1622 )
1624 # STEP details - only update if this event type has result data
1625 if operation_type == OperationType.STEP and event_config.has_result:
1626 existing_step: StepDetails | None = operation.step_details
1627 result_val: str | None = existing_step.result if existing_step else None
1628 error_val: ErrorObject | None = (
1629 existing_step.error if existing_step else None
1630 )
1631 attempt: int = existing_step.attempt if existing_step else 0
1632 next_attempt_ts: datetime.datetime | None = (
1633 existing_step.next_attempt_timestamp if existing_step else None
1634 )
1636 # StepSucceeded provides result
1637 if event.step_succeeded_details:
1638 if event.step_succeeded_details.result: 1638 ↛ 1640line 1638 didn't jump to line 1640 because the condition on line 1638 was always true
1639 result_val = event.step_succeeded_details.result.payload
1640 if event.step_succeeded_details.retry_details:
1641 attempt = event.step_succeeded_details.retry_details.current_attempt
1643 # StepFailed provides error and retry details
1644 if event.step_failed_details:
1645 if event.step_failed_details.error: 1645 ↛ 1647line 1645 didn't jump to line 1647 because the condition on line 1645 was always true
1646 error_val = event.step_failed_details.error.payload
1647 if event.step_failed_details.retry_details: 1647 ↛ 1657line 1647 didn't jump to line 1657 because the condition on line 1647 was always true
1648 attempt = event.step_failed_details.retry_details.current_attempt
1649 if ( 1649 ↛ 1657line 1649 didn't jump to line 1657 because the condition on line 1649 was always true
1650 event.step_failed_details.retry_details.next_attempt_delay_seconds
1651 is not None
1652 ):
1653 next_attempt_ts = event.event_timestamp + datetime.timedelta(
1654 seconds=event.step_failed_details.retry_details.next_attempt_delay_seconds
1655 )
1657 operation = replace(
1658 operation,
1659 step_details=StepDetails(
1660 result=result_val,
1661 error=error_val,
1662 attempt=attempt,
1663 next_attempt_timestamp=next_attempt_ts,
1664 ),
1665 )
1667 # WAIT details
1668 if operation_type == OperationType.WAIT and event.wait_started_details:
1669 operation = replace(
1670 operation,
1671 wait_details=WaitDetails(
1672 scheduled_end_timestamp=event.wait_started_details.scheduled_end_timestamp
1673 ),
1674 )
1676 # CONTEXT details - only update if this event type has result data (matching TypeScript hasResult)
1677 if operation_type == OperationType.CONTEXT and event_config.has_result:
1678 if (
1679 event.context_succeeded_details
1680 and event.context_succeeded_details.result
1681 ):
1682 operation = replace(
1683 operation,
1684 context_details=ContextDetails(
1685 result=event.context_succeeded_details.result.payload,
1686 error=None,
1687 ),
1688 )
1689 elif event.context_failed_details and event.context_failed_details.error: 1689 ↛ 1699line 1689 didn't jump to line 1699 because the condition on line 1689 was always true
1690 operation = replace(
1691 operation,
1692 context_details=ContextDetails(
1693 result=None,
1694 error=event.context_failed_details.error.payload,
1695 ),
1696 )
1698 # CHAINED_INVOKE details - only update if this event type has result data (matching TypeScript hasResult)
1699 if operation_type == OperationType.CHAINED_INVOKE and event_config.has_result:
1700 if (
1701 event.chained_invoke_succeeded_details
1702 and event.chained_invoke_succeeded_details.result
1703 ):
1704 operation = replace(
1705 operation,
1706 chained_invoke_details=ChainedInvokeDetails(
1707 result=event.chained_invoke_succeeded_details.result.payload,
1708 error=None,
1709 ),
1710 )
1711 elif ( 1711 ↛ 1724line 1711 didn't jump to line 1724 because the condition on line 1711 was always true
1712 event.chained_invoke_failed_details
1713 and event.chained_invoke_failed_details.error
1714 ):
1715 operation = replace(
1716 operation,
1717 chained_invoke_details=ChainedInvokeDetails(
1718 result=None,
1719 error=event.chained_invoke_failed_details.error.payload,
1720 ),
1721 )
1723 # Store in map
1724 operations_map[event.operation_id] = operation
1726 return list(operations_map.values())
1729@dataclass(frozen=True)
1730class GetDurableExecutionHistoryResponse(AwsApiModel):
1731 """Response containing durable execution history events."""
1733 events: list[Event] = field(default_factory=list, metadata={"alias": "Events"})
1734 next_marker: str | None = field(default=None, metadata={"alias": "NextMarker"})
1737class _ExecutionResultSource(Protocol):
1738 operations: list[Operation]
1739 result: DurableExecutionInvocationOutput | None
1742@dataclass(frozen=True)
1743class DurableFunctionTestResult:
1744 status: InvocationStatus
1745 operations: list[Operation]
1746 result: OperationPayload | None = None
1747 error: ErrorObject | None = None
1748 _all_operations: list[Operation] = field(
1749 default_factory=list,
1750 repr=False,
1751 compare=False,
1752 )
1754 @classmethod
1755 def create(cls, execution: _ExecutionResultSource) -> DurableFunctionTestResult:
1756 operations = []
1757 for operation in execution.operations:
1758 if operation.operation_type is OperationType.EXECUTION:
1759 # don't want the EXECUTION operations in the list test code asserts against
1760 continue
1762 if operation.parent_id is None:
1763 operations.append(operation)
1765 if execution.result is None:
1766 msg: str = "Execution result must exist to create test result."
1767 raise DurableFunctionsTestError(msg)
1769 return cls(
1770 status=execution.result.status,
1771 operations=operations,
1772 result=execution.result.result,
1773 error=execution.result.error,
1774 _all_operations=execution.operations,
1775 )
1777 @classmethod
1778 def from_execution_history(
1779 cls,
1780 execution_response: GetDurableExecutionResponse,
1781 history_response: GetDurableExecutionHistoryResponse,
1782 ) -> DurableFunctionTestResult:
1783 """Create test result from execution history responses.
1785 Factory method for cloud runner that builds DurableFunctionTestResult
1786 from GetDurableExecution and GetDurableExecutionHistory API responses.
1787 """
1788 # Map status string to InvocationStatus enum
1789 try:
1790 status = InvocationStatus[execution_response.status]
1791 except KeyError:
1792 logger.warning(
1793 "Unknown status: %s, defaulting to FAILED", execution_response.status
1794 )
1795 status = InvocationStatus.FAILED
1797 # Convert Events to Operations - group by operation_id and merge
1798 try:
1799 svc_operations = events_to_operations(history_response.events)
1800 except Exception as e:
1801 logger.warning("Failed to convert events to operations: %s", e)
1802 svc_operations = []
1804 # Build top-level operation list (exclude EXECUTION type)
1805 operations = []
1806 for svc_op in svc_operations:
1807 if svc_op.operation_type == OperationType.EXECUTION:
1808 continue
1809 if svc_op.parent_id is None:
1810 operations.append(svc_op)
1812 return cls(
1813 status=status,
1814 operations=operations,
1815 result=execution_response.result,
1816 error=execution_response.error,
1817 _all_operations=svc_operations,
1818 )
1820 def get_operation_by_name(self, name: str) -> Operation:
1821 for operation in self.operations:
1822 if operation.name == name:
1823 return operation
1824 msg: str = f"Operation with name '{name}' not found"
1825 raise DurableFunctionsTestError(msg)
1827 def get_step(self, name: str) -> Operation:
1828 return self._get_operation_by_name_and_type(name, OperationType.STEP)
1830 def get_wait(self, name: str) -> Operation:
1831 return self._get_operation_by_name_and_type(name, OperationType.WAIT)
1833 def get_context(self, name: str) -> Operation:
1834 return self._get_operation_by_name_and_type(name, OperationType.CONTEXT)
1836 def get_callback(self, name: str) -> Operation:
1837 return self._get_operation_by_name_and_type(name, OperationType.CALLBACK)
1839 def get_invoke(self, name: str) -> Operation:
1840 return self._get_operation_by_name_and_type(name, OperationType.CHAINED_INVOKE)
1842 def get_execution(self, name: str) -> Operation:
1843 return self._get_operation_by_name_and_type(name, OperationType.EXECUTION)
1845 def get_deserialized_result(self, serdes: ExtendedTypeSerDes | None = None) -> Any:
1846 """Return the deserialized execution result."""
1847 return _deserialize_operation_payload(self.result, serdes)
1849 def get_operation_deserialized_result(
1850 self,
1851 operation: Operation,
1852 serdes: ExtendedTypeSerDes | None = None,
1853 ) -> Any:
1854 """Return the deserialized result payload for a service operation."""
1855 match operation.operation_type:
1856 case OperationType.CONTEXT:
1857 result = (
1858 operation.context_details.result
1859 if operation.context_details
1860 else None
1861 )
1862 case OperationType.STEP:
1863 result = (
1864 operation.step_details.result if operation.step_details else None
1865 )
1866 case OperationType.CALLBACK:
1867 result = (
1868 operation.callback_details.result
1869 if operation.callback_details
1870 else None
1871 )
1872 case OperationType.CHAINED_INVOKE:
1873 result = (
1874 operation.chained_invoke_details.result
1875 if operation.chained_invoke_details
1876 else None
1877 )
1878 case _:
1879 result = None
1880 return _deserialize_operation_payload(result, serdes)
1882 def get_child_operations(self, operation: Operation) -> list[Operation]:
1883 """Return direct child operations for a service operation."""
1884 return [
1885 candidate
1886 for candidate in self._operation_source()
1887 if candidate.parent_id == operation.operation_id
1888 ]
1890 def get_all_operations(self) -> list[Operation]:
1891 """Return all non-execution operations, including nested operations."""
1892 return [
1893 operation
1894 for operation in self._operation_source()
1895 if operation.operation_type != OperationType.EXECUTION
1896 ]
1898 def _operation_source(self) -> list[Operation]:
1899 return self._all_operations or self.operations
1901 def _get_operation_by_name_and_type(
1902 self, name: str, operation_type: OperationType
1903 ) -> Operation:
1904 operation = self.get_operation_by_name(name)
1905 if operation.operation_type != operation_type:
1906 msg = (
1907 f"Operation with name '{name}' has type "
1908 f"{operation.operation_type}, expected {operation_type}"
1909 )
1910 raise DurableFunctionsTestError(msg)
1911 return operation
1914def _deserialize_operation_payload(
1915 payload: OperationPayload | None,
1916 serdes: ExtendedTypeSerDes | None = None,
1917) -> Any:
1918 """Deserialize an operation payload using the provided or default serializer."""
1919 if not payload:
1920 return None
1922 if serdes is None: 1922 ↛ 1925line 1922 didn't jump to line 1925 because the condition on line 1922 was always true
1923 serdes = ExtendedTypeSerDes()
1925 try:
1926 return serdes.deserialize_sync(payload)
1927 except Exception:
1928 return json.loads(payload)
1931def _get_callback_id_from_events(
1932 events: list[Event], name: str | None = None
1933) -> str | None:
1934 """
1935 Get callback ID from execution history for callbacks that haven't completed.
1937 Args:
1938 execution_arn: The ARN of the execution to query.
1939 name: Optional callback name to search for. If not provided, returns the latest callback.
1941 Returns:
1942 The callback ID string for a non-completed callback whose creating
1943 invocation has completed, or None if not found.
1945 Raises:
1946 DurableFunctionsTestError: If the named callback has already succeeded/failed/timed out.
1947 """
1948 callback_started_events = [
1949 event for event in events if event.event_type == "CallbackStarted"
1950 ]
1952 if not callback_started_events:
1953 return None
1955 completed_callback_ids = {
1956 event.event_id
1957 for event in events
1958 if event.event_type
1959 in ["CallbackSucceeded", "CallbackFailed", "CallbackTimedOut"]
1960 }
1962 def is_callback_ready(callback_started_event: Event) -> bool:
1963 for event in events[events.index(callback_started_event) + 1 :]:
1964 if event.event_type == "InvocationCompleted":
1965 return True
1966 return False
1968 if name is not None:
1969 for event in callback_started_events:
1970 if event.name == name:
1971 callback_id = event.event_id
1972 if callback_id in completed_callback_ids:
1973 raise DurableFunctionsTestError(
1974 f"Callback {name} has already completed (succeeded/failed/timed out)"
1975 )
1976 if not is_callback_ready(event):
1977 return None
1978 return (
1979 event.callback_started_details.callback_id
1980 if event.callback_started_details
1981 else None
1982 )
1983 return None
1985 # If name is not provided, find the latest non-completed callback event
1986 active_callbacks = [
1987 event
1988 for event in callback_started_events
1989 if event.event_id not in completed_callback_ids and is_callback_ready(event)
1990 ]
1992 if not active_callbacks:
1993 return None
1995 latest_event = active_callbacks[-1]
1996 return (
1997 latest_event.callback_started_details.callback_id
1998 if latest_event.callback_started_details
1999 else None
2000 )
2003@dataclass(frozen=True)
2004class InvokeResponse:
2005 """Response from invoking a durable function."""
2007 invocation_output: DurableExecutionInvocationOutput
2008 request_id: str