cadence-python-client 0.1.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 (95) hide show
  1. cadence/__init__.py +18 -0
  2. cadence/_internal/__init__.py +8 -0
  3. cadence/_internal/activity/__init__.py +5 -0
  4. cadence/_internal/activity/_activity_executor.py +113 -0
  5. cadence/_internal/activity/_context.py +58 -0
  6. cadence/_internal/rpc/__init__.py +0 -0
  7. cadence/_internal/rpc/error.py +148 -0
  8. cadence/_internal/rpc/retry.py +104 -0
  9. cadence/_internal/rpc/yarpc.py +42 -0
  10. cadence/_internal/workflow/__init__.py +0 -0
  11. cadence/_internal/workflow/context.py +121 -0
  12. cadence/_internal/workflow/decision_events_iterator.py +161 -0
  13. cadence/_internal/workflow/decisions_helper.py +312 -0
  14. cadence/_internal/workflow/deterministic_event_loop.py +498 -0
  15. cadence/_internal/workflow/history_event_iterator.py +58 -0
  16. cadence/_internal/workflow/statemachine/__init__.py +0 -0
  17. cadence/_internal/workflow/statemachine/activity_state_machine.py +106 -0
  18. cadence/_internal/workflow/statemachine/decision_manager.py +157 -0
  19. cadence/_internal/workflow/statemachine/decision_state_machine.py +87 -0
  20. cadence/_internal/workflow/statemachine/event_dispatcher.py +76 -0
  21. cadence/_internal/workflow/statemachine/timer_state_machine.py +73 -0
  22. cadence/_internal/workflow/workflow_engine.py +245 -0
  23. cadence/_internal/workflow/workflow_intance.py +44 -0
  24. cadence/activity.py +255 -0
  25. cadence/api/v1/__init__.py +92 -0
  26. cadence/api/v1/common_pb2.py +90 -0
  27. cadence/api/v1/common_pb2.pyi +200 -0
  28. cadence/api/v1/common_pb2_grpc.py +24 -0
  29. cadence/api/v1/decision_pb2.py +67 -0
  30. cadence/api/v1/decision_pb2.pyi +225 -0
  31. cadence/api/v1/decision_pb2_grpc.py +24 -0
  32. cadence/api/v1/domain_pb2.py +68 -0
  33. cadence/api/v1/domain_pb2.pyi +145 -0
  34. cadence/api/v1/domain_pb2_grpc.py +24 -0
  35. cadence/api/v1/error_pb2.py +59 -0
  36. cadence/api/v1/error_pb2.pyi +82 -0
  37. cadence/api/v1/error_pb2_grpc.py +24 -0
  38. cadence/api/v1/history_pb2.py +134 -0
  39. cadence/api/v1/history_pb2.pyi +780 -0
  40. cadence/api/v1/history_pb2_grpc.py +24 -0
  41. cadence/api/v1/query_pb2.py +49 -0
  42. cadence/api/v1/query_pb2.pyi +59 -0
  43. cadence/api/v1/query_pb2_grpc.py +24 -0
  44. cadence/api/v1/service_domain_pb2.py +76 -0
  45. cadence/api/v1/service_domain_pb2.pyi +164 -0
  46. cadence/api/v1/service_domain_pb2_grpc.py +327 -0
  47. cadence/api/v1/service_meta_pb2.py +41 -0
  48. cadence/api/v1/service_meta_pb2.pyi +17 -0
  49. cadence/api/v1/service_meta_pb2_grpc.py +97 -0
  50. cadence/api/v1/service_visibility_pb2.py +71 -0
  51. cadence/api/v1/service_visibility_pb2.pyi +149 -0
  52. cadence/api/v1/service_visibility_pb2_grpc.py +362 -0
  53. cadence/api/v1/service_worker_pb2.py +116 -0
  54. cadence/api/v1/service_worker_pb2.pyi +350 -0
  55. cadence/api/v1/service_worker_pb2_grpc.py +743 -0
  56. cadence/api/v1/service_workflow_pb2.py +126 -0
  57. cadence/api/v1/service_workflow_pb2.pyi +395 -0
  58. cadence/api/v1/service_workflow_pb2_grpc.py +861 -0
  59. cadence/api/v1/tasklist_pb2.py +78 -0
  60. cadence/api/v1/tasklist_pb2.pyi +147 -0
  61. cadence/api/v1/tasklist_pb2_grpc.py +24 -0
  62. cadence/api/v1/visibility_pb2.py +47 -0
  63. cadence/api/v1/visibility_pb2.pyi +53 -0
  64. cadence/api/v1/visibility_pb2_grpc.py +24 -0
  65. cadence/api/v1/workflow_pb2.py +89 -0
  66. cadence/api/v1/workflow_pb2.pyi +365 -0
  67. cadence/api/v1/workflow_pb2_grpc.py +24 -0
  68. cadence/client.py +382 -0
  69. cadence/data_converter.py +78 -0
  70. cadence/error.py +111 -0
  71. cadence/metrics/__init__.py +12 -0
  72. cadence/metrics/constants.py +136 -0
  73. cadence/metrics/metrics.py +56 -0
  74. cadence/metrics/prometheus.py +165 -0
  75. cadence/sample/__init__.py +1 -0
  76. cadence/sample/client_example.py +15 -0
  77. cadence/sample/grpc_usage_example.py +230 -0
  78. cadence/sample/simple_usage_example.py +155 -0
  79. cadence/signal.py +174 -0
  80. cadence/worker/__init__.py +13 -0
  81. cadence/worker/_activity.py +60 -0
  82. cadence/worker/_base_task_handler.py +71 -0
  83. cadence/worker/_decision.py +62 -0
  84. cadence/worker/_decision_task_handler.py +285 -0
  85. cadence/worker/_poller.py +64 -0
  86. cadence/worker/_registry.py +245 -0
  87. cadence/worker/_types.py +26 -0
  88. cadence/worker/_worker.py +56 -0
  89. cadence/workflow.py +271 -0
  90. cadence_python_client-0.1.0.dist-info/METADATA +180 -0
  91. cadence_python_client-0.1.0.dist-info/RECORD +95 -0
  92. cadence_python_client-0.1.0.dist-info/WHEEL +5 -0
  93. cadence_python_client-0.1.0.dist-info/licenses/LICENSE +201 -0
  94. cadence_python_client-0.1.0.dist-info/licenses/NOTICE +19 -0
  95. cadence_python_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,157 @@
1
+ import asyncio
2
+ from collections import OrderedDict
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, Type, Tuple, ClassVar, List
5
+
6
+ from cadence._internal.workflow.statemachine.activity_state_machine import (
7
+ activity_events,
8
+ ActivityStateMachine,
9
+ )
10
+ from cadence._internal.workflow.statemachine.decision_state_machine import (
11
+ DecisionId,
12
+ DecisionStateMachine,
13
+ DecisionType,
14
+ DecisionFuture,
15
+ )
16
+ from cadence._internal.workflow.statemachine.event_dispatcher import (
17
+ EventDispatcher,
18
+ Action,
19
+ )
20
+ from cadence._internal.workflow.statemachine.timer_state_machine import (
21
+ TimerStateMachine,
22
+ timer_events,
23
+ )
24
+ from cadence.api.v1 import decision, history
25
+ from cadence.api.v1.common_pb2 import Payload
26
+
27
+ DecisionAlias = Tuple[DecisionType, str | int]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class EventDispatch:
32
+ decision_type: DecisionType
33
+ action: Action
34
+
35
+
36
+ def _create_dispatch_map(
37
+ dispatchers: dict[DecisionType, EventDispatcher],
38
+ ) -> dict[Type, EventDispatch]:
39
+ result: dict[Type, EventDispatch] = {}
40
+ for decision_type, dispatcher in dispatchers.items():
41
+ for event_type, action in dispatcher.handlers.items():
42
+ if event_type in result:
43
+ raise ValueError(
44
+ f"Received duplicate registration for {event_type}: {decision_type} and {result[event_type].decision_type}"
45
+ )
46
+ result[event_type] = EventDispatch(decision_type, action)
47
+
48
+ return result
49
+
50
+
51
+ @dataclass
52
+ class DecisionManager:
53
+ """Aggregates multiple decision state machines and coordinates decisions.
54
+
55
+ Typical flow per decision task:
56
+ - Instantiate/update state machines based on application intent and incoming history
57
+ - Call collect_pending_decisions() to build the decisions list
58
+ - Submit via RespondDecisionTaskCompleted
59
+ """
60
+
61
+ type_to_action: ClassVar[Dict[Type, EventDispatch]] = _create_dispatch_map(
62
+ {
63
+ DecisionType.ACTIVITY: activity_events,
64
+ DecisionType.TIMER: timer_events,
65
+ }
66
+ )
67
+ state_machines: OrderedDict[DecisionId, DecisionStateMachine] = field(
68
+ default_factory=OrderedDict
69
+ )
70
+ aliases: Dict[DecisionAlias, DecisionStateMachine] = field(default_factory=dict)
71
+
72
+ # ----- Activity API -----
73
+
74
+ def schedule_activity(
75
+ self, attrs: decision.ScheduleActivityTaskDecisionAttributes
76
+ ) -> asyncio.Future[Payload]:
77
+ decision_id = DecisionId(DecisionType.ACTIVITY, attrs.activity_id)
78
+ future = DecisionFuture[Payload](lambda: self._request_cancel(decision_id))
79
+ machine = ActivityStateMachine(attrs, future)
80
+ self._add_state_machine(machine)
81
+
82
+ return future
83
+
84
+ # ----- Timer API -----
85
+
86
+ def start_timer(
87
+ self, attrs: decision.StartTimerDecisionAttributes
88
+ ) -> asyncio.Future[None]:
89
+ decision_id = DecisionId(DecisionType.TIMER, attrs.timer_id)
90
+ future = DecisionFuture[None](lambda: self._request_cancel(decision_id))
91
+ machine = TimerStateMachine(attrs, future)
92
+ self._add_state_machine(machine)
93
+
94
+ return future
95
+
96
+ def _get_machine(self, decision_id: DecisionId) -> DecisionStateMachine:
97
+ machine = self.state_machines.get(decision_id, None)
98
+ if machine is None:
99
+ raise ValueError(f"Unknown state machine: {decision_id}")
100
+ return machine
101
+
102
+ def _add_state_machine(self, state: DecisionStateMachine) -> None:
103
+ decision_id = state.get_id()
104
+ if decision_id in self.state_machines:
105
+ raise ValueError(f"Received duplicate decision: {decision_id}")
106
+ self.state_machines[decision_id] = state
107
+ self.aliases[(decision_id.decision_type, decision_id.id)] = state
108
+
109
+ # ----- History routing -----
110
+
111
+ def handle_history_event(self, event: history.HistoryEvent) -> None:
112
+ """Dispatch history event to typed handlers using the global transition map."""
113
+ attr = event.WhichOneof("attributes")
114
+ # Based on the type of the event, determine what DecisionType it's referencing and
115
+ # the correct action to take
116
+ event_attributes = getattr(event, attr)
117
+ event_action = DecisionManager.type_to_action.get(
118
+ event_attributes.__class__, None
119
+ )
120
+ if event_action is not None:
121
+ decision_type = event_action.decision_type
122
+ action = event_action.action
123
+ # Find what state machine the event references.
124
+ # This may be a reference via the user id or a reference to a previous event
125
+ id_for_event = getattr(event_attributes, action.id_attr)
126
+ alias = (decision_type, id_for_event)
127
+ machine = self.aliases.get(alias, None)
128
+ if machine is None:
129
+ raise KeyError(
130
+ f"Event {event.event_id} references unknown state machine {alias}"
131
+ )
132
+
133
+ action.fn(machine, event_attributes)
134
+
135
+ # Certain events (scheduled) are often referenced by subsequent events
136
+ # rather than using the client provided id
137
+ if action.event_id_is_alias:
138
+ self.aliases[(decision_type, event.event_id)] = machine
139
+
140
+ # ----- Decision aggregation -----
141
+
142
+ def collect_pending_decisions(self) -> List[decision.Decision]:
143
+ decisions: List[decision.Decision] = []
144
+
145
+ for machine in self.state_machines.values():
146
+ to_send = machine.get_decision()
147
+ if to_send is not None:
148
+ decisions.append(to_send)
149
+
150
+ return decisions
151
+
152
+ def _request_cancel(self, decision_id: DecisionId) -> bool:
153
+ machine = self._get_machine(decision_id)
154
+ # Interactions with the state machines should move them to the end so that the decisions are ordered as they
155
+ # happened in the Workflow
156
+ self.state_machines.move_to_end(decision_id)
157
+ return machine.request_cancel()
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Callable, Protocol, TypeVar, Optional
7
+
8
+ from cadence.api.v1 import (
9
+ decision_pb2 as decision,
10
+ )
11
+
12
+
13
+ # TODO: Remove unused states
14
+ class DecisionState(Enum):
15
+ """Lifecycle states for a decision-producing state machine instance."""
16
+
17
+ CREATED = 0
18
+ DECISION_SENT = 1
19
+ CANCELED_BEFORE_INITIATED = 2
20
+ INITIATED = 3
21
+ STARTED = 4
22
+ CANCELED_AFTER_INITIATED = 5
23
+ CANCELED_AFTER_STARTED = 6
24
+ CANCELLATION_DECISION_SENT = 7
25
+ COMPLETED_AFTER_CANCELLATION_DECISION_SENT = 8
26
+ COMPLETED = 9
27
+
28
+
29
+ class DecisionType(Enum):
30
+ """Types of decisions that can be made by state machines."""
31
+
32
+ ACTIVITY = 0
33
+ CHILD_WORKFLOW = 1
34
+ CANCELLATION = 2
35
+ MARKER = 3
36
+ TIMER = 4
37
+ SIGNAL = 5
38
+ UPSERT_SEARCH_ATTRIBUTES = 6
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class DecisionId:
43
+ decision_type: DecisionType
44
+ id: str
45
+
46
+
47
+ class DecisionStateMachine(Protocol):
48
+ def get_id(self) -> DecisionId: ...
49
+
50
+ def get_decision(self) -> decision.Decision | None: ...
51
+
52
+ def request_cancel(self) -> bool: ...
53
+
54
+
55
+ class BaseDecisionStateMachine(DecisionStateMachine):
56
+ def __init__(self):
57
+ self._state = DecisionState.CREATED
58
+
59
+ def _transition(
60
+ self, to: DecisionState, allowed_from: list[DecisionState] | None = None
61
+ ) -> None:
62
+ # TODO: Maybe track previous states like the other clients
63
+ if allowed_from and self.state not in allowed_from:
64
+ raise RuntimeError(f"unable to transition to {to} from {self.state}")
65
+ self._state = to
66
+
67
+ @property
68
+ def state(self) -> DecisionState:
69
+ return self._state
70
+
71
+
72
+ T = TypeVar("T")
73
+ CancelFn = Callable[[], bool]
74
+
75
+
76
+ class DecisionFuture(asyncio.Future[T]):
77
+ def __init__(self, request_cancel: CancelFn | None = None) -> None:
78
+ super().__init__()
79
+ if request_cancel is None:
80
+ request_cancel = self.force_cancel
81
+ self._request_cancel = request_cancel
82
+
83
+ def force_cancel(self, message: Optional[str] = None) -> bool:
84
+ return super().cancel(message)
85
+
86
+ def cancel(self, msg=None) -> bool:
87
+ return self._request_cancel()
@@ -0,0 +1,76 @@
1
+ from dataclasses import dataclass
2
+ from inspect import signature
3
+ from typing import Type, Callable, get_type_hints, TypeVar, Any, cast
4
+
5
+ from google.protobuf.message import Message
6
+
7
+ T = TypeVar("T")
8
+
9
+ EventHandler = Callable[[Any, T], None]
10
+
11
+
12
+ @dataclass(
13
+ frozen=True,
14
+ )
15
+ class Action:
16
+ fn: EventHandler
17
+ id_attr: str
18
+ event_id_is_alias: bool
19
+
20
+
21
+ class EventDispatcher:
22
+ handlers: dict[Type, Action]
23
+
24
+ def __init__(self, default_id_attr: str) -> None:
25
+ self._default_id_attr = default_id_attr
26
+ self.handlers = {}
27
+
28
+ def event(
29
+ self, id_attr: str = "", event_id_is_alias: bool = False
30
+ ) -> Callable[[EventHandler], EventHandler]:
31
+ def decorator(func: EventHandler) -> EventHandler:
32
+ event_type = _find_event_type(func)
33
+ event_id_attr = id_attr if id_attr else self._default_id_attr
34
+
35
+ _validate_field(func, event_type, event_id_attr)
36
+ if event_type in self.handlers:
37
+ raise ValueError(
38
+ f"Duplicate handler for {event_type}: {func.__qualname__} and {self.handlers[event_type].fn.__qualname__}"
39
+ )
40
+ self.handlers[event_type] = Action(func, event_id_attr, event_id_is_alias)
41
+ return func
42
+
43
+ return decorator
44
+
45
+
46
+ def _find_event_type(func: EventHandler) -> Type[Message]:
47
+ sig = signature(func)
48
+ type_hints = get_type_hints(func)
49
+ if len(sig.parameters) != 2:
50
+ raise ValueError(
51
+ f"Expected 2 arguments (self, event), {func.__qualname__} has: {sig.parameters}"
52
+ )
53
+ (non_self_param, _) = list(sig.parameters.items())[1]
54
+ if non_self_param not in type_hints:
55
+ raise ValueError(f"Missing type hint on {func.__qualname__}: {non_self_param}")
56
+ if "return" in type_hints and type_hints["return"] != None.__class__:
57
+ raise ValueError(
58
+ f"Event methods must return None, {func.__qualname__} returns: {type_hints['return']}"
59
+ )
60
+
61
+ event_type = type_hints[non_self_param]
62
+ if not issubclass(event_type, Message):
63
+ raise ValueError(
64
+ f"Event methods must accept a Message, {func.__qualname__} accepts: {event_type}"
65
+ )
66
+
67
+ # Mypy struggles without this for some reason, despite type narrowing being supported
68
+ return cast(Type[Message], event_type)
69
+
70
+
71
+ def _validate_field(func: EventHandler, event_type: Type[Message], field: str) -> None:
72
+ fields = event_type.DESCRIPTOR.fields_by_name
73
+ if field not in fields:
74
+ raise ValueError(
75
+ f"{func.__qualname__} handles {event_type.__qualname__}, which has no field {field}"
76
+ )
@@ -0,0 +1,73 @@
1
+ from cadence._internal.workflow.statemachine.decision_state_machine import (
2
+ DecisionState,
3
+ DecisionFuture,
4
+ DecisionType,
5
+ DecisionId,
6
+ BaseDecisionStateMachine,
7
+ )
8
+ from cadence._internal.workflow.statemachine.event_dispatcher import EventDispatcher
9
+ from cadence.api.v1 import decision, history
10
+
11
+ timer_events = EventDispatcher("timer_id")
12
+
13
+
14
+ class TimerStateMachine(BaseDecisionStateMachine):
15
+ request: decision.StartTimerDecisionAttributes
16
+ completed: DecisionFuture[None]
17
+
18
+ def __init__(
19
+ self,
20
+ request: decision.StartTimerDecisionAttributes,
21
+ completed: DecisionFuture[None],
22
+ ) -> None:
23
+ super().__init__()
24
+ self.request = request
25
+ self.completed = completed
26
+
27
+ def get_id(self) -> DecisionId:
28
+ return DecisionId(DecisionType.TIMER, self.request.timer_id)
29
+
30
+ def get_decision(self) -> decision.Decision | None:
31
+ if self.state is DecisionState.CREATED:
32
+ return decision.Decision(start_timer_decision_attributes=self.request)
33
+ if self.state is DecisionState.CANCELED_AFTER_INITIATED:
34
+ return decision.Decision(
35
+ cancel_timer_decision_attributes=decision.CancelTimerDecisionAttributes(
36
+ timer_id=self.request.timer_id,
37
+ )
38
+ )
39
+ return None
40
+
41
+ def request_cancel(self) -> bool:
42
+ if self.state is DecisionState.CREATED:
43
+ self._transition(DecisionState.COMPLETED)
44
+ self.completed.force_cancel()
45
+ return True
46
+
47
+ if self.state is DecisionState.INITIATED:
48
+ self._transition(DecisionState.CANCELED_AFTER_INITIATED)
49
+ self.completed.force_cancel()
50
+ return True
51
+
52
+ return False
53
+
54
+ @timer_events.event()
55
+ def handle_started(self, _: history.TimerStartedEventAttributes) -> None:
56
+ self._transition(DecisionState.INITIATED)
57
+
58
+ @timer_events.event()
59
+ def handle_fired(self, _: history.TimerFiredEventAttributes) -> None:
60
+ self._transition(DecisionState.COMPLETED)
61
+ self.completed.set_result(None)
62
+
63
+ @timer_events.event()
64
+ def handle_canceled(self, _: history.TimerCanceledEventAttributes) -> None:
65
+ # Timers resolve immediately regardless of the outcome of the cancellation
66
+ self._transition(DecisionState.COMPLETED)
67
+
68
+ @timer_events.event()
69
+ def handle_cancel_failed(self, _: history.CancelTimerFailedEventAttributes) -> None:
70
+ # This leaves the timer in a likely invalid state, but matches the other clients.
71
+ # The only way for timer cancellation to fail is if the timer ID isn't known, so this
72
+ # can't really happen in the first place.
73
+ self._transition(DecisionState.INITIATED)
@@ -0,0 +1,245 @@
1
+ import logging
2
+ from dataclasses import dataclass
3
+ import traceback
4
+ from typing import List
5
+
6
+ from cadence._internal.workflow.context import Context
7
+ from cadence._internal.workflow.decision_events_iterator import DecisionEventsIterator
8
+ from cadence._internal.workflow.statemachine.decision_manager import DecisionManager
9
+ from cadence._internal.workflow.workflow_intance import WorkflowInstance
10
+ from cadence.api.v1.common_pb2 import Failure, Payload
11
+ from cadence.api.v1.decision_pb2 import (
12
+ CompleteWorkflowExecutionDecisionAttributes,
13
+ Decision,
14
+ FailWorkflowExecutionDecisionAttributes,
15
+ )
16
+ from cadence.api.v1.history_pb2 import (
17
+ HistoryEvent,
18
+ WorkflowExecutionStartedEventAttributes,
19
+ )
20
+ from cadence.api.v1.service_worker_pb2 import PollForDecisionTaskResponse
21
+ from cadence.error import WorkflowFailure
22
+ from cadence.workflow import WorkflowDefinition, WorkflowInfo
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass
28
+ class DecisionResult:
29
+ decisions: list[Decision]
30
+
31
+
32
+ class WorkflowEngine:
33
+ def __init__(self, info: WorkflowInfo, workflow_definition: WorkflowDefinition):
34
+ self._workflow_instance = WorkflowInstance(
35
+ workflow_definition, info.data_converter
36
+ )
37
+ self._decision_manager = (
38
+ DecisionManager()
39
+ ) # TODO: remove this stateful object and use the context instead
40
+ self._context = Context(info, self._decision_manager)
41
+
42
+ def process_decision(
43
+ self,
44
+ events: List[HistoryEvent],
45
+ ) -> DecisionResult:
46
+ """
47
+ Process a decision task and generate decisions using DecisionEventsIterator.
48
+
49
+ This method follows the Java client pattern of using DecisionEventsIterator
50
+ to drive the decision processing pipeline with proper replay handling.
51
+
52
+ Args:
53
+ decision_task: The PollForDecisionTaskResponse from the service
54
+
55
+ Returns:
56
+ DecisionResult containing the list of decisions
57
+ """
58
+ try:
59
+ # Activate workflow context for the entire decision processing
60
+ with self._context._activate() as ctx:
61
+ # Log decision task processing start with full context (matches Java ReplayDecisionTaskHandler)
62
+ logger.info(
63
+ "Processing decision task for workflow",
64
+ extra={
65
+ "workflow_type": ctx.info().workflow_type,
66
+ "workflow_id": ctx.info().workflow_id,
67
+ "run_id": ctx.info().workflow_run_id,
68
+ },
69
+ )
70
+
71
+ # Create DecisionEventsIterator for structured event processing
72
+ events_iterator = DecisionEventsIterator(events)
73
+
74
+ # Process decision events using iterator-driven approach
75
+ self._process_decision_events(ctx, events_iterator)
76
+
77
+ # Collect all pending decisions from state machines
78
+ decisions = self._decision_manager.collect_pending_decisions()
79
+
80
+ # complete workflow if it is done
81
+ if self._workflow_instance.is_done():
82
+ try:
83
+ result = self._workflow_instance.get_result()
84
+ except WorkflowFailure as e:
85
+ decisions.append(
86
+ Decision(
87
+ fail_workflow_execution_decision_attributes=FailWorkflowExecutionDecisionAttributes(
88
+ failure=_failure_from_workflow_failure(e)
89
+ )
90
+ )
91
+ )
92
+ # TODO: handle cancellation error
93
+ except Exception:
94
+ raise
95
+ else:
96
+ decisions.append(
97
+ Decision(
98
+ complete_workflow_execution_decision_attributes=CompleteWorkflowExecutionDecisionAttributes(
99
+ result=result
100
+ )
101
+ )
102
+ )
103
+ return DecisionResult(decisions=decisions)
104
+
105
+ except Exception as e:
106
+ # Log decision task failure with full context (matches Java ReplayDecisionTaskHandler)
107
+ logger.error(
108
+ "Decision task processing failed",
109
+ extra={
110
+ "workflow_type": ctx.info().workflow_type,
111
+ "workflow_id": ctx.info().workflow_id,
112
+ "run_id": ctx.info().workflow_run_id,
113
+ "error_type": type(e).__name__,
114
+ },
115
+ exc_info=True,
116
+ )
117
+ # Re-raise the exception so the handler can properly handle the failure
118
+ raise
119
+
120
+ def is_done(self) -> bool:
121
+ return self._workflow_instance.is_done()
122
+
123
+ def _process_decision_events(
124
+ self,
125
+ ctx: Context,
126
+ events_iterator: DecisionEventsIterator,
127
+ ) -> None:
128
+ """
129
+ Process decision events using the iterator-driven approach similar to Java client.
130
+
131
+ This method implements the three-phase event processing pattern:
132
+ 1. Process markers first (for deterministic replay)
133
+ 2. Process regular events (trigger workflow state changes)
134
+ 3. Execute workflow logic
135
+ 4. Process decision events from previous decisions
136
+
137
+ Args:
138
+ events_iterator: The DecisionEventsIterator for structured event processing
139
+ decision_task: The original decision task
140
+ """
141
+
142
+ # Check if there are any decision events to process
143
+ for decision_events in events_iterator:
144
+ # Log decision events batch processing (matches Go client patterns)
145
+ logger.debug(
146
+ "Processing decision events batch",
147
+ extra={
148
+ "workflow_id": ctx.info().workflow_id,
149
+ "markers_count": len(decision_events.markers),
150
+ "replay_mode": decision_events.replay,
151
+ "replay_time": decision_events.replay_current_time_milliseconds,
152
+ },
153
+ )
154
+
155
+ # Update context with replay information
156
+ ctx.set_replay_mode(decision_events.replay)
157
+ if decision_events.replay_current_time_milliseconds:
158
+ ctx.set_replay_current_time_milliseconds(
159
+ decision_events.replay_current_time_milliseconds
160
+ )
161
+
162
+ # Phase 1: Process markers first
163
+ for marker_event in decision_events.markers:
164
+ logger.debug(
165
+ "Processing marker event",
166
+ extra={
167
+ "workflow_id": ctx.info().workflow_id,
168
+ "marker_name": getattr(marker_event, "marker_name", "unknown"),
169
+ "event_id": getattr(marker_event, "event_id", None),
170
+ "replay_mode": decision_events.replay,
171
+ },
172
+ )
173
+ # Process through state machines (DecisionsHelper now delegates to DecisionManager)
174
+ self._decision_manager.handle_history_event(marker_event)
175
+
176
+ # Phase 2: Process regular input events
177
+ for event in decision_events.input:
178
+ logger.debug(
179
+ "Processing history event",
180
+ extra={
181
+ "workflow_id": ctx.info().workflow_id,
182
+ "event_type": getattr(event, "event_type", "unknown"),
183
+ "event_id": getattr(event, "event_id", None),
184
+ "replay_mode": decision_events.replay,
185
+ },
186
+ )
187
+ # start workflow on workflow started event
188
+ if (
189
+ event.WhichOneof("attributes")
190
+ == "workflow_execution_started_event_attributes"
191
+ ):
192
+ started_attrs: WorkflowExecutionStartedEventAttributes = (
193
+ event.workflow_execution_started_event_attributes
194
+ )
195
+ if started_attrs and hasattr(started_attrs, "input"):
196
+ self._workflow_instance.start(started_attrs.input)
197
+
198
+ # Process through state machines (DecisionsHelper now delegates to DecisionManager)
199
+ self._decision_manager.handle_history_event(event)
200
+
201
+ # Phase 3: Execute workflow logic
202
+ self._workflow_instance.run_once()
203
+
204
+ # Phase 4: update state machine with output events
205
+ for event in decision_events.output:
206
+ self._decision_manager.handle_history_event(event)
207
+
208
+ def _extract_workflow_input(
209
+ self, decision_task: PollForDecisionTaskResponse
210
+ ) -> Payload:
211
+ """
212
+ Extract workflow input from the decision task history.
213
+
214
+ Args:
215
+ decision_task: The decision task containing workflow history
216
+
217
+ Returns:
218
+ The workflow input data, or None if not found
219
+ """
220
+ if not decision_task.history or not hasattr(decision_task.history, "events"):
221
+ raise ValueError("No history events found in decision task")
222
+
223
+ # Look for WorkflowExecutionStarted event
224
+ for event in decision_task.history.events:
225
+ if hasattr(event, "workflow_execution_started_event_attributes"):
226
+ started_attrs: WorkflowExecutionStartedEventAttributes = (
227
+ event.workflow_execution_started_event_attributes
228
+ )
229
+ if started_attrs and hasattr(started_attrs, "input"):
230
+ return started_attrs.input
231
+
232
+ raise ValueError("No WorkflowExecutionStarted event found in history")
233
+
234
+
235
+ def _failure_from_workflow_failure(e: WorkflowFailure) -> Failure:
236
+ cause = e.__cause__
237
+
238
+ stacktrace = "".join(traceback.format_exception(cause))
239
+
240
+ details = f"message: {str(cause)}\nstacktrace: {stacktrace}"
241
+
242
+ return Failure(
243
+ reason=type(cause).__name__,
244
+ details=details.encode("utf-8"),
245
+ )
@@ -0,0 +1,44 @@
1
+ from asyncio import CancelledError, InvalidStateError, Task
2
+ from typing import Any, Optional
3
+ from cadence._internal.workflow.deterministic_event_loop import DeterministicEventLoop
4
+ from cadence.api.v1.common_pb2 import Payload
5
+ from cadence.data_converter import DataConverter
6
+ from cadence.error import WorkflowFailure
7
+ from cadence.workflow import WorkflowDefinition
8
+
9
+
10
+ class WorkflowInstance:
11
+ def __init__(
12
+ self, workflow_definition: WorkflowDefinition, data_converter: DataConverter
13
+ ):
14
+ self._definition = workflow_definition
15
+ self._data_converter = data_converter
16
+ self._instance = workflow_definition.cls() # construct a new workflow object
17
+ self._loop = DeterministicEventLoop()
18
+ self._task: Optional[Task] = None
19
+
20
+ def start(self, input: Payload):
21
+ if self._task is None:
22
+ run_method = self._definition.get_run_method(self._instance)
23
+ # TODO handle multiple inputs
24
+ workflow_input = self._data_converter.from_data(input, [Any])
25
+ self._task = self._loop.create_task(run_method(*workflow_input))
26
+
27
+ def run_once(self):
28
+ self._loop.run_until_yield()
29
+
30
+ def is_done(self) -> bool:
31
+ return self._task is not None and self._task.done()
32
+
33
+ # TODO: consider cache result to avoid multiple data conversions
34
+ def get_result(self) -> Payload:
35
+ if self._task is None:
36
+ raise RuntimeError("Workflow is not started yet")
37
+ try:
38
+ result = self._task.result()
39
+ except (CancelledError, InvalidStateError) as e:
40
+ raise e
41
+ except Exception as e:
42
+ raise WorkflowFailure(f"Workflow failed: {e}") from e
43
+ # TODO: handle result with multiple outputs
44
+ return self._data_converter.to_data([result])