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.
- cadence/__init__.py +18 -0
- cadence/_internal/__init__.py +8 -0
- cadence/_internal/activity/__init__.py +5 -0
- cadence/_internal/activity/_activity_executor.py +113 -0
- cadence/_internal/activity/_context.py +58 -0
- cadence/_internal/rpc/__init__.py +0 -0
- cadence/_internal/rpc/error.py +148 -0
- cadence/_internal/rpc/retry.py +104 -0
- cadence/_internal/rpc/yarpc.py +42 -0
- cadence/_internal/workflow/__init__.py +0 -0
- cadence/_internal/workflow/context.py +121 -0
- cadence/_internal/workflow/decision_events_iterator.py +161 -0
- cadence/_internal/workflow/decisions_helper.py +312 -0
- cadence/_internal/workflow/deterministic_event_loop.py +498 -0
- cadence/_internal/workflow/history_event_iterator.py +58 -0
- cadence/_internal/workflow/statemachine/__init__.py +0 -0
- cadence/_internal/workflow/statemachine/activity_state_machine.py +106 -0
- cadence/_internal/workflow/statemachine/decision_manager.py +157 -0
- cadence/_internal/workflow/statemachine/decision_state_machine.py +87 -0
- cadence/_internal/workflow/statemachine/event_dispatcher.py +76 -0
- cadence/_internal/workflow/statemachine/timer_state_machine.py +73 -0
- cadence/_internal/workflow/workflow_engine.py +245 -0
- cadence/_internal/workflow/workflow_intance.py +44 -0
- cadence/activity.py +255 -0
- cadence/api/v1/__init__.py +92 -0
- cadence/api/v1/common_pb2.py +90 -0
- cadence/api/v1/common_pb2.pyi +200 -0
- cadence/api/v1/common_pb2_grpc.py +24 -0
- cadence/api/v1/decision_pb2.py +67 -0
- cadence/api/v1/decision_pb2.pyi +225 -0
- cadence/api/v1/decision_pb2_grpc.py +24 -0
- cadence/api/v1/domain_pb2.py +68 -0
- cadence/api/v1/domain_pb2.pyi +145 -0
- cadence/api/v1/domain_pb2_grpc.py +24 -0
- cadence/api/v1/error_pb2.py +59 -0
- cadence/api/v1/error_pb2.pyi +82 -0
- cadence/api/v1/error_pb2_grpc.py +24 -0
- cadence/api/v1/history_pb2.py +134 -0
- cadence/api/v1/history_pb2.pyi +780 -0
- cadence/api/v1/history_pb2_grpc.py +24 -0
- cadence/api/v1/query_pb2.py +49 -0
- cadence/api/v1/query_pb2.pyi +59 -0
- cadence/api/v1/query_pb2_grpc.py +24 -0
- cadence/api/v1/service_domain_pb2.py +76 -0
- cadence/api/v1/service_domain_pb2.pyi +164 -0
- cadence/api/v1/service_domain_pb2_grpc.py +327 -0
- cadence/api/v1/service_meta_pb2.py +41 -0
- cadence/api/v1/service_meta_pb2.pyi +17 -0
- cadence/api/v1/service_meta_pb2_grpc.py +97 -0
- cadence/api/v1/service_visibility_pb2.py +71 -0
- cadence/api/v1/service_visibility_pb2.pyi +149 -0
- cadence/api/v1/service_visibility_pb2_grpc.py +362 -0
- cadence/api/v1/service_worker_pb2.py +116 -0
- cadence/api/v1/service_worker_pb2.pyi +350 -0
- cadence/api/v1/service_worker_pb2_grpc.py +743 -0
- cadence/api/v1/service_workflow_pb2.py +126 -0
- cadence/api/v1/service_workflow_pb2.pyi +395 -0
- cadence/api/v1/service_workflow_pb2_grpc.py +861 -0
- cadence/api/v1/tasklist_pb2.py +78 -0
- cadence/api/v1/tasklist_pb2.pyi +147 -0
- cadence/api/v1/tasklist_pb2_grpc.py +24 -0
- cadence/api/v1/visibility_pb2.py +47 -0
- cadence/api/v1/visibility_pb2.pyi +53 -0
- cadence/api/v1/visibility_pb2_grpc.py +24 -0
- cadence/api/v1/workflow_pb2.py +89 -0
- cadence/api/v1/workflow_pb2.pyi +365 -0
- cadence/api/v1/workflow_pb2_grpc.py +24 -0
- cadence/client.py +382 -0
- cadence/data_converter.py +78 -0
- cadence/error.py +111 -0
- cadence/metrics/__init__.py +12 -0
- cadence/metrics/constants.py +136 -0
- cadence/metrics/metrics.py +56 -0
- cadence/metrics/prometheus.py +165 -0
- cadence/sample/__init__.py +1 -0
- cadence/sample/client_example.py +15 -0
- cadence/sample/grpc_usage_example.py +230 -0
- cadence/sample/simple_usage_example.py +155 -0
- cadence/signal.py +174 -0
- cadence/worker/__init__.py +13 -0
- cadence/worker/_activity.py +60 -0
- cadence/worker/_base_task_handler.py +71 -0
- cadence/worker/_decision.py +62 -0
- cadence/worker/_decision_task_handler.py +285 -0
- cadence/worker/_poller.py +64 -0
- cadence/worker/_registry.py +245 -0
- cadence/worker/_types.py +26 -0
- cadence/worker/_worker.py +56 -0
- cadence/workflow.py +271 -0
- cadence_python_client-0.1.0.dist-info/METADATA +180 -0
- cadence_python_client-0.1.0.dist-info/RECORD +95 -0
- cadence_python_client-0.1.0.dist-info/WHEEL +5 -0
- cadence_python_client-0.1.0.dist-info/licenses/LICENSE +201 -0
- cadence_python_client-0.1.0.dist-info/licenses/NOTICE +19 -0
- 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])
|