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
cadence/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cadence Python Client
|
|
3
|
+
|
|
4
|
+
A Python framework for authoring workflows and activities for Cadence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Import main client functionality
|
|
8
|
+
from .client import Client
|
|
9
|
+
from .worker import Registry
|
|
10
|
+
from . import workflow
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Client",
|
|
16
|
+
"Registry",
|
|
17
|
+
"workflow",
|
|
18
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Private implementation details for the Cadence Python client.
|
|
2
|
+
|
|
3
|
+
Modules in this package are not part of the public API surface and may change
|
|
4
|
+
without notice. Public callers should import from packages like `cadence.worker`,
|
|
5
|
+
`cadence.workflow`, and `cadence.activity` instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
from traceback import format_exception
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
from google.protobuf.duration import to_timedelta
|
|
6
|
+
from google.protobuf.timestamp import to_datetime
|
|
7
|
+
|
|
8
|
+
from cadence._internal.activity._context import _Context, _SyncContext
|
|
9
|
+
from cadence.activity import ActivityInfo, ActivityDefinition, ExecutionStrategy
|
|
10
|
+
from cadence.api.v1.common_pb2 import Failure
|
|
11
|
+
from cadence.api.v1.service_worker_pb2 import (
|
|
12
|
+
PollForActivityTaskResponse,
|
|
13
|
+
RespondActivityTaskFailedRequest,
|
|
14
|
+
RespondActivityTaskCompletedRequest,
|
|
15
|
+
)
|
|
16
|
+
from cadence.client import Client
|
|
17
|
+
|
|
18
|
+
_logger = getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ActivityExecutor:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
client: Client,
|
|
25
|
+
task_list: str,
|
|
26
|
+
identity: str,
|
|
27
|
+
max_workers: int,
|
|
28
|
+
registry: Callable[[str], ActivityDefinition],
|
|
29
|
+
):
|
|
30
|
+
self._client = client
|
|
31
|
+
self._data_converter = client.data_converter
|
|
32
|
+
self._registry = registry
|
|
33
|
+
self._identity = identity
|
|
34
|
+
self._task_list = task_list
|
|
35
|
+
self._thread_pool = ThreadPoolExecutor(
|
|
36
|
+
max_workers=max_workers, thread_name_prefix=f"{task_list}-activity-"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async def execute(self, task: PollForActivityTaskResponse):
|
|
40
|
+
try:
|
|
41
|
+
context = self._create_context(task)
|
|
42
|
+
result = await context.execute(task.input)
|
|
43
|
+
await self._report_success(task, result)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
await self._report_failure(task, e)
|
|
46
|
+
|
|
47
|
+
def _create_context(self, task: PollForActivityTaskResponse) -> _Context:
|
|
48
|
+
activity_type = task.activity_type.name
|
|
49
|
+
try:
|
|
50
|
+
activity_def = self._registry(activity_type)
|
|
51
|
+
except KeyError:
|
|
52
|
+
raise KeyError(f"Activity type not found: {activity_type}") from None
|
|
53
|
+
|
|
54
|
+
info = self._create_info(task)
|
|
55
|
+
|
|
56
|
+
if activity_def.strategy == ExecutionStrategy.ASYNC:
|
|
57
|
+
return _Context(self._client, info, activity_def)
|
|
58
|
+
else:
|
|
59
|
+
return _SyncContext(self._client, info, activity_def, self._thread_pool)
|
|
60
|
+
|
|
61
|
+
async def _report_failure(
|
|
62
|
+
self, task: PollForActivityTaskResponse, error: Exception
|
|
63
|
+
):
|
|
64
|
+
try:
|
|
65
|
+
await self._client.worker_stub.RespondActivityTaskFailed(
|
|
66
|
+
RespondActivityTaskFailedRequest(
|
|
67
|
+
task_token=task.task_token,
|
|
68
|
+
failure=_to_failure(error),
|
|
69
|
+
identity=self._identity,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
except Exception:
|
|
73
|
+
_logger.exception("Exception reporting activity failure")
|
|
74
|
+
|
|
75
|
+
async def _report_success(self, task: PollForActivityTaskResponse, result: Any):
|
|
76
|
+
as_payload = self._data_converter.to_data([result])
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
await self._client.worker_stub.RespondActivityTaskCompleted(
|
|
80
|
+
RespondActivityTaskCompletedRequest(
|
|
81
|
+
task_token=task.task_token,
|
|
82
|
+
result=as_payload,
|
|
83
|
+
identity=self._identity,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
except Exception:
|
|
87
|
+
_logger.exception("Exception reporting activity complete")
|
|
88
|
+
|
|
89
|
+
def _create_info(self, task: PollForActivityTaskResponse) -> ActivityInfo:
|
|
90
|
+
return ActivityInfo(
|
|
91
|
+
task_token=task.task_token,
|
|
92
|
+
workflow_type=task.workflow_type.name,
|
|
93
|
+
workflow_domain=task.workflow_domain,
|
|
94
|
+
workflow_id=task.workflow_execution.workflow_id,
|
|
95
|
+
workflow_run_id=task.workflow_execution.run_id,
|
|
96
|
+
activity_id=task.activity_id,
|
|
97
|
+
activity_type=task.activity_type.name,
|
|
98
|
+
task_list=self._task_list,
|
|
99
|
+
heartbeat_timeout=to_timedelta(task.heartbeat_timeout),
|
|
100
|
+
scheduled_timestamp=to_datetime(task.scheduled_time),
|
|
101
|
+
started_timestamp=to_datetime(task.started_time),
|
|
102
|
+
start_to_close_timeout=to_timedelta(task.start_to_close_timeout),
|
|
103
|
+
attempt=task.attempt,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _to_failure(exception: Exception) -> Failure:
|
|
108
|
+
stacktrace = "".join(format_exception(exception))
|
|
109
|
+
|
|
110
|
+
return Failure(
|
|
111
|
+
reason=type(exception).__name__,
|
|
112
|
+
details=stacktrace.encode(),
|
|
113
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from concurrent.futures.thread import ThreadPoolExecutor
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from cadence import Client
|
|
6
|
+
from cadence.activity import ActivityInfo, ActivityContext, ActivityDefinition
|
|
7
|
+
from cadence.api.v1.common_pb2 import Payload
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Context(ActivityContext):
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
client: Client,
|
|
14
|
+
info: ActivityInfo,
|
|
15
|
+
activity_fn: ActivityDefinition[[Any], Any],
|
|
16
|
+
):
|
|
17
|
+
self._client = client
|
|
18
|
+
self._info = info
|
|
19
|
+
self._activity_fn = activity_fn
|
|
20
|
+
|
|
21
|
+
async def execute(self, payload: Payload) -> Any:
|
|
22
|
+
params = self._to_params(payload)
|
|
23
|
+
with self._activate():
|
|
24
|
+
return await self._activity_fn(*params)
|
|
25
|
+
|
|
26
|
+
def _to_params(self, payload: Payload) -> list[Any]:
|
|
27
|
+
type_hints = [param.type_hint for param in self._activity_fn.params]
|
|
28
|
+
return self._client.data_converter.from_data(payload, type_hints)
|
|
29
|
+
|
|
30
|
+
def client(self) -> Client:
|
|
31
|
+
return self._client
|
|
32
|
+
|
|
33
|
+
def info(self) -> ActivityInfo:
|
|
34
|
+
return self._info
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _SyncContext(_Context):
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
client: Client,
|
|
41
|
+
info: ActivityInfo,
|
|
42
|
+
activity_fn: ActivityDefinition[[Any], Any],
|
|
43
|
+
executor: ThreadPoolExecutor,
|
|
44
|
+
):
|
|
45
|
+
super().__init__(client, info, activity_fn)
|
|
46
|
+
self._executor = executor
|
|
47
|
+
|
|
48
|
+
async def execute(self, payload: Payload) -> Any:
|
|
49
|
+
params = self._to_params(payload)
|
|
50
|
+
loop = asyncio.get_running_loop()
|
|
51
|
+
return await loop.run_in_executor(self._executor, self._run, params)
|
|
52
|
+
|
|
53
|
+
def _run(self, args: list[Any]) -> Any:
|
|
54
|
+
with self._activate():
|
|
55
|
+
return self._activity_fn(*args)
|
|
56
|
+
|
|
57
|
+
def client(self) -> Client:
|
|
58
|
+
raise RuntimeError("client is only supported in async activities")
|
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from typing import Callable, Any, Optional, Generator, TypeVar
|
|
2
|
+
|
|
3
|
+
import grpc
|
|
4
|
+
from google.rpc.status_pb2 import Status # type: ignore
|
|
5
|
+
from grpc.aio import (
|
|
6
|
+
UnaryUnaryClientInterceptor,
|
|
7
|
+
ClientCallDetails,
|
|
8
|
+
AioRpcError,
|
|
9
|
+
UnaryUnaryCall,
|
|
10
|
+
Metadata,
|
|
11
|
+
)
|
|
12
|
+
from grpc_status.rpc_status import from_call # type: ignore
|
|
13
|
+
|
|
14
|
+
from cadence.api.v1 import error_pb2
|
|
15
|
+
from cadence import error
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
RequestType = TypeVar("RequestType")
|
|
19
|
+
ResponseType = TypeVar("ResponseType")
|
|
20
|
+
DoneCallbackType = Callable[[Any], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# A UnaryUnaryCall is an awaitable type returned by GRPC's aio support.
|
|
24
|
+
# We need to take the UnaryUnaryCall we receive and return one that remaps the exception.
|
|
25
|
+
# It doesn't have any functions to compose operations together, so our only option is to wrap it.
|
|
26
|
+
# If the interceptor directly throws an exception other than AioRpcError it breaks GRPC
|
|
27
|
+
class CadenceErrorUnaryUnaryCall(UnaryUnaryCall[RequestType, ResponseType]):
|
|
28
|
+
def __init__(self, wrapped: UnaryUnaryCall[RequestType, ResponseType]):
|
|
29
|
+
super().__init__()
|
|
30
|
+
self._wrapped = wrapped
|
|
31
|
+
|
|
32
|
+
def __await__(self) -> Generator[Any, None, ResponseType]:
|
|
33
|
+
try:
|
|
34
|
+
response = yield from self._wrapped.__await__() # type: ResponseType
|
|
35
|
+
return response
|
|
36
|
+
except AioRpcError as e:
|
|
37
|
+
raise map_error(e)
|
|
38
|
+
|
|
39
|
+
async def initial_metadata(self) -> Metadata:
|
|
40
|
+
return await self._wrapped.initial_metadata()
|
|
41
|
+
|
|
42
|
+
async def trailing_metadata(self) -> Metadata:
|
|
43
|
+
return await self._wrapped.trailing_metadata()
|
|
44
|
+
|
|
45
|
+
async def code(self) -> grpc.StatusCode:
|
|
46
|
+
return await self._wrapped.code()
|
|
47
|
+
|
|
48
|
+
async def details(self) -> str:
|
|
49
|
+
return await self._wrapped.details() # type: ignore
|
|
50
|
+
|
|
51
|
+
async def wait_for_connection(self) -> None:
|
|
52
|
+
await self._wrapped.wait_for_connection()
|
|
53
|
+
|
|
54
|
+
def cancelled(self) -> bool:
|
|
55
|
+
return self._wrapped.cancelled() # type: ignore
|
|
56
|
+
|
|
57
|
+
def done(self) -> bool:
|
|
58
|
+
return self._wrapped.done() # type: ignore
|
|
59
|
+
|
|
60
|
+
def time_remaining(self) -> Optional[float]:
|
|
61
|
+
return self._wrapped.time_remaining() # type: ignore
|
|
62
|
+
|
|
63
|
+
def cancel(self) -> bool:
|
|
64
|
+
return self._wrapped.cancel() # type: ignore
|
|
65
|
+
|
|
66
|
+
def add_done_callback(self, callback: DoneCallbackType) -> None:
|
|
67
|
+
self._wrapped.add_done_callback(callback)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CadenceErrorInterceptor(UnaryUnaryClientInterceptor):
|
|
71
|
+
async def intercept_unary_unary(
|
|
72
|
+
self,
|
|
73
|
+
continuation: Callable[[ClientCallDetails, Any], Any],
|
|
74
|
+
client_call_details: ClientCallDetails,
|
|
75
|
+
request: Any,
|
|
76
|
+
) -> Any:
|
|
77
|
+
rpc_call = await continuation(client_call_details, request)
|
|
78
|
+
return CadenceErrorUnaryUnaryCall(rpc_call)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def map_error(e: AioRpcError) -> error.CadenceRpcError:
|
|
82
|
+
status: Status | None = from_call(e)
|
|
83
|
+
if not status or not status.details:
|
|
84
|
+
return error.CadenceRpcError(e.details(), e.code())
|
|
85
|
+
|
|
86
|
+
details = status.details[0]
|
|
87
|
+
if details.Is(error_pb2.WorkflowExecutionAlreadyStartedError.DESCRIPTOR):
|
|
88
|
+
already_started = error_pb2.WorkflowExecutionAlreadyStartedError()
|
|
89
|
+
details.Unpack(already_started)
|
|
90
|
+
return error.WorkflowExecutionAlreadyStartedError(
|
|
91
|
+
e.details(),
|
|
92
|
+
e.code(),
|
|
93
|
+
already_started.start_request_id,
|
|
94
|
+
already_started.run_id,
|
|
95
|
+
)
|
|
96
|
+
elif details.Is(error_pb2.EntityNotExistsError.DESCRIPTOR):
|
|
97
|
+
not_exists = error_pb2.EntityNotExistsError()
|
|
98
|
+
details.Unpack(not_exists)
|
|
99
|
+
return error.EntityNotExistsError(
|
|
100
|
+
e.details(),
|
|
101
|
+
e.code(),
|
|
102
|
+
not_exists.current_cluster,
|
|
103
|
+
not_exists.active_cluster,
|
|
104
|
+
list(not_exists.active_clusters),
|
|
105
|
+
)
|
|
106
|
+
elif details.Is(error_pb2.WorkflowExecutionAlreadyCompletedError.DESCRIPTOR):
|
|
107
|
+
return error.WorkflowExecutionAlreadyCompletedError(e.details(), e.code())
|
|
108
|
+
elif details.Is(error_pb2.DomainNotActiveError.DESCRIPTOR):
|
|
109
|
+
not_active = error_pb2.DomainNotActiveError()
|
|
110
|
+
details.Unpack(not_active)
|
|
111
|
+
return error.DomainNotActiveError(
|
|
112
|
+
e.details(),
|
|
113
|
+
e.code(),
|
|
114
|
+
not_active.domain,
|
|
115
|
+
not_active.current_cluster,
|
|
116
|
+
not_active.active_cluster,
|
|
117
|
+
list(not_active.active_clusters),
|
|
118
|
+
)
|
|
119
|
+
elif details.Is(error_pb2.ClientVersionNotSupportedError.DESCRIPTOR):
|
|
120
|
+
not_supported = error_pb2.ClientVersionNotSupportedError()
|
|
121
|
+
details.Unpack(not_supported)
|
|
122
|
+
return error.ClientVersionNotSupportedError(
|
|
123
|
+
e.details(),
|
|
124
|
+
e.code(),
|
|
125
|
+
not_supported.feature_version,
|
|
126
|
+
not_supported.client_impl,
|
|
127
|
+
not_supported.supported_versions,
|
|
128
|
+
)
|
|
129
|
+
elif details.Is(error_pb2.FeatureNotEnabledError.DESCRIPTOR):
|
|
130
|
+
not_enabled = error_pb2.FeatureNotEnabledError()
|
|
131
|
+
details.Unpack(not_enabled)
|
|
132
|
+
return error.FeatureNotEnabledError(
|
|
133
|
+
e.details(), e.code(), not_enabled.feature_flag
|
|
134
|
+
)
|
|
135
|
+
elif details.Is(error_pb2.CancellationAlreadyRequestedError.DESCRIPTOR):
|
|
136
|
+
return error.CancellationAlreadyRequestedError(e.details(), e.code())
|
|
137
|
+
elif details.Is(error_pb2.DomainAlreadyExistsError.DESCRIPTOR):
|
|
138
|
+
return error.DomainAlreadyExistsError(e.details(), e.code())
|
|
139
|
+
elif details.Is(error_pb2.LimitExceededError.DESCRIPTOR):
|
|
140
|
+
return error.LimitExceededError(e.details(), e.code())
|
|
141
|
+
elif details.Is(error_pb2.QueryFailedError.DESCRIPTOR):
|
|
142
|
+
return error.QueryFailedError(e.details(), e.code())
|
|
143
|
+
elif details.Is(error_pb2.ServiceBusyError.DESCRIPTOR):
|
|
144
|
+
service_busy = error_pb2.ServiceBusyError()
|
|
145
|
+
details.Unpack(service_busy)
|
|
146
|
+
return error.ServiceBusyError(e.details(), e.code(), service_busy.reason)
|
|
147
|
+
else:
|
|
148
|
+
return error.CadenceRpcError(e.details(), e.code())
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Callable, Any
|
|
4
|
+
|
|
5
|
+
from grpc import StatusCode
|
|
6
|
+
from grpc.aio import UnaryUnaryClientInterceptor, ClientCallDetails
|
|
7
|
+
|
|
8
|
+
from cadence.error import CadenceRpcError, EntityNotExistsError
|
|
9
|
+
|
|
10
|
+
RETRYABLE_CODES = {
|
|
11
|
+
StatusCode.INTERNAL,
|
|
12
|
+
StatusCode.RESOURCE_EXHAUSTED,
|
|
13
|
+
StatusCode.ABORTED,
|
|
14
|
+
StatusCode.UNAVAILABLE,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# No expiration interval, use the GRPC timeout value instead
|
|
19
|
+
@dataclass
|
|
20
|
+
class ExponentialRetryPolicy:
|
|
21
|
+
initial_interval: float
|
|
22
|
+
backoff_coefficient: float
|
|
23
|
+
max_interval: float
|
|
24
|
+
max_attempts: float
|
|
25
|
+
|
|
26
|
+
def next_delay(
|
|
27
|
+
self, attempts: int, elapsed: float, expiration: float
|
|
28
|
+
) -> float | None:
|
|
29
|
+
if elapsed >= expiration:
|
|
30
|
+
return None
|
|
31
|
+
if self.max_attempts != 0 and attempts >= self.max_attempts:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
backoff = min(
|
|
35
|
+
self.initial_interval * pow(self.backoff_coefficient, attempts - 1),
|
|
36
|
+
self.max_interval,
|
|
37
|
+
)
|
|
38
|
+
if (elapsed + backoff) >= expiration:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
return backoff
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DEFAULT_RETRY_POLICY = ExponentialRetryPolicy(
|
|
45
|
+
initial_interval=0.02, backoff_coefficient=1.2, max_interval=6, max_attempts=0
|
|
46
|
+
)
|
|
47
|
+
GET_WORKFLOW_HISTORY = b"/uber.cadence.api.v1.WorkflowAPI/GetWorkflowExecutionHistory"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RetryInterceptor(UnaryUnaryClientInterceptor):
|
|
51
|
+
def __init__(self, retry_policy: ExponentialRetryPolicy = DEFAULT_RETRY_POLICY):
|
|
52
|
+
super().__init__()
|
|
53
|
+
self._retry_policy = retry_policy
|
|
54
|
+
|
|
55
|
+
async def intercept_unary_unary(
|
|
56
|
+
self,
|
|
57
|
+
continuation: Callable[[ClientCallDetails, Any], Any],
|
|
58
|
+
client_call_details: ClientCallDetails,
|
|
59
|
+
request: Any,
|
|
60
|
+
) -> Any:
|
|
61
|
+
loop = asyncio.get_running_loop()
|
|
62
|
+
expiration_interval = client_call_details.timeout
|
|
63
|
+
start_time = loop.time()
|
|
64
|
+
deadline = start_time + expiration_interval
|
|
65
|
+
|
|
66
|
+
attempts = 0
|
|
67
|
+
while True:
|
|
68
|
+
remaining = deadline - loop.time()
|
|
69
|
+
# Namedtuple methods start with an underscore to avoid conflicts and aren't actually private
|
|
70
|
+
# noinspection PyProtectedMember
|
|
71
|
+
call_details = client_call_details._replace(timeout=remaining)
|
|
72
|
+
rpc_call = await continuation(call_details, request)
|
|
73
|
+
try:
|
|
74
|
+
# Return the result directly if success. GRPC will wrap it back into a UnaryUnaryCall
|
|
75
|
+
return await rpc_call
|
|
76
|
+
except CadenceRpcError as e:
|
|
77
|
+
err = e
|
|
78
|
+
|
|
79
|
+
attempts += 1
|
|
80
|
+
elapsed = loop.time() - start_time
|
|
81
|
+
backoff = self._retry_policy.next_delay(
|
|
82
|
+
attempts, elapsed, expiration_interval
|
|
83
|
+
)
|
|
84
|
+
if not is_retryable(err, client_call_details) or backoff is None:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
await asyncio.sleep(backoff)
|
|
88
|
+
|
|
89
|
+
# On policy expiration, return the most recent UnaryUnaryCall. It has the error we want
|
|
90
|
+
return rpc_call
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_retryable(err: CadenceRpcError, call_details: ClientCallDetails) -> bool:
|
|
94
|
+
# Handle requests to the passive side, matching the Go and Java Clients
|
|
95
|
+
if call_details.method == GET_WORKFLOW_HISTORY and isinstance(
|
|
96
|
+
err, EntityNotExistsError
|
|
97
|
+
):
|
|
98
|
+
return (
|
|
99
|
+
err.active_cluster is not None
|
|
100
|
+
and err.current_cluster is not None
|
|
101
|
+
and err.active_cluster != err.current_cluster
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return err.code in RETRYABLE_CODES
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Any, Callable
|
|
2
|
+
|
|
3
|
+
from grpc.aio import Metadata
|
|
4
|
+
from grpc.aio import UnaryUnaryClientInterceptor, ClientCallDetails
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SERVICE_KEY = "rpc-service"
|
|
8
|
+
CALLER_KEY = "rpc-caller"
|
|
9
|
+
ENCODING_KEY = "rpc-encoding"
|
|
10
|
+
ENCODING_PROTO = "proto"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class YarpcMetadataInterceptor(UnaryUnaryClientInterceptor):
|
|
14
|
+
def __init__(self, service: str, caller: str):
|
|
15
|
+
self._metadata = Metadata(
|
|
16
|
+
(SERVICE_KEY, service),
|
|
17
|
+
(CALLER_KEY, caller),
|
|
18
|
+
(ENCODING_KEY, ENCODING_PROTO),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def intercept_unary_unary(
|
|
22
|
+
self,
|
|
23
|
+
continuation: Callable[[ClientCallDetails, Any], Any],
|
|
24
|
+
client_call_details: ClientCallDetails,
|
|
25
|
+
request: Any,
|
|
26
|
+
) -> Any:
|
|
27
|
+
return await continuation(self._replace_details(client_call_details), request)
|
|
28
|
+
|
|
29
|
+
def _replace_details(
|
|
30
|
+
self, client_call_details: ClientCallDetails
|
|
31
|
+
) -> ClientCallDetails:
|
|
32
|
+
metadata = client_call_details.metadata
|
|
33
|
+
if metadata is None:
|
|
34
|
+
metadata = self._metadata
|
|
35
|
+
else:
|
|
36
|
+
metadata += self._metadata
|
|
37
|
+
|
|
38
|
+
# Namedtuple methods start with an underscore to avoid conflicts and aren't actually private
|
|
39
|
+
# noinspection PyProtectedMember
|
|
40
|
+
return client_call_details._replace(
|
|
41
|
+
metadata=metadata, timeout=client_call_details.timeout or 60.0
|
|
42
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from math import ceil
|
|
4
|
+
from typing import Iterator, Optional, Any, Unpack, Type, cast
|
|
5
|
+
|
|
6
|
+
from cadence._internal.workflow.statemachine.decision_manager import DecisionManager
|
|
7
|
+
from cadence._internal.workflow.decisions_helper import DecisionsHelper
|
|
8
|
+
from cadence.api.v1.common_pb2 import ActivityType
|
|
9
|
+
from cadence.api.v1.decision_pb2 import ScheduleActivityTaskDecisionAttributes
|
|
10
|
+
from cadence.api.v1.tasklist_pb2 import TaskList, TaskListKind
|
|
11
|
+
from cadence.data_converter import DataConverter
|
|
12
|
+
from cadence.workflow import WorkflowContext, WorkflowInfo, ResultType, ActivityOptions
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Context(WorkflowContext):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
info: WorkflowInfo,
|
|
19
|
+
decision_manager: DecisionManager,
|
|
20
|
+
):
|
|
21
|
+
self._info = info
|
|
22
|
+
self._replay_mode = True
|
|
23
|
+
self._replay_current_time_milliseconds: Optional[int] = None
|
|
24
|
+
self._decision_helper = DecisionsHelper()
|
|
25
|
+
self._decision_manager = decision_manager
|
|
26
|
+
|
|
27
|
+
def info(self) -> WorkflowInfo:
|
|
28
|
+
return self._info
|
|
29
|
+
|
|
30
|
+
def data_converter(self) -> DataConverter:
|
|
31
|
+
return self.info().data_converter
|
|
32
|
+
|
|
33
|
+
async def execute_activity(
|
|
34
|
+
self,
|
|
35
|
+
activity: str,
|
|
36
|
+
result_type: Type[ResultType],
|
|
37
|
+
*args: Any,
|
|
38
|
+
**kwargs: Unpack[ActivityOptions],
|
|
39
|
+
) -> ResultType:
|
|
40
|
+
opts = ActivityOptions(**kwargs)
|
|
41
|
+
if "schedule_to_close_timeout" not in opts and (
|
|
42
|
+
"schedule_to_start_timeout" not in opts
|
|
43
|
+
or "start_to_close_timeout" not in opts
|
|
44
|
+
):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"Either schedule_to_close_timeout or both schedule_to_start_timeout and start_to_close_timeout must be specified"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
schedule_to_close = opts.get("schedule_to_close_timeout", None)
|
|
50
|
+
schedule_to_start = opts.get("schedule_to_start_timeout", None)
|
|
51
|
+
start_to_close = opts.get("start_to_close_timeout", None)
|
|
52
|
+
heartbeat = opts.get("heartbeat_timeout", None)
|
|
53
|
+
|
|
54
|
+
if schedule_to_close is None:
|
|
55
|
+
schedule_to_close = schedule_to_start + start_to_close # type: ignore
|
|
56
|
+
|
|
57
|
+
if start_to_close is None:
|
|
58
|
+
start_to_close = schedule_to_close
|
|
59
|
+
|
|
60
|
+
if schedule_to_start is None:
|
|
61
|
+
schedule_to_start = schedule_to_close
|
|
62
|
+
|
|
63
|
+
if heartbeat is None:
|
|
64
|
+
heartbeat = schedule_to_close
|
|
65
|
+
|
|
66
|
+
task_list = (
|
|
67
|
+
opts["task_list"]
|
|
68
|
+
if opts.get("task_list", None)
|
|
69
|
+
else self._info.workflow_task_list
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
activity_input = self.data_converter().to_data(list(args))
|
|
73
|
+
activity_id = self._decision_helper.generate_activity_id(activity)
|
|
74
|
+
schedule_attributes = ScheduleActivityTaskDecisionAttributes(
|
|
75
|
+
activity_id=activity_id,
|
|
76
|
+
activity_type=ActivityType(name=activity),
|
|
77
|
+
domain=self.info().workflow_domain,
|
|
78
|
+
task_list=TaskList(kind=TaskListKind.TASK_LIST_KIND_NORMAL, name=task_list),
|
|
79
|
+
input=activity_input,
|
|
80
|
+
schedule_to_close_timeout=_round_to_nearest_second(schedule_to_close),
|
|
81
|
+
schedule_to_start_timeout=_round_to_nearest_second(schedule_to_start),
|
|
82
|
+
start_to_close_timeout=_round_to_nearest_second(start_to_close),
|
|
83
|
+
heartbeat_timeout=_round_to_nearest_second(heartbeat),
|
|
84
|
+
retry_policy=None,
|
|
85
|
+
header=None,
|
|
86
|
+
request_local_dispatch=False,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
result_payload = await self._decision_manager.schedule_activity(
|
|
90
|
+
schedule_attributes
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
result = self.data_converter().from_data(result_payload, [result_type])[0]
|
|
94
|
+
|
|
95
|
+
return cast(ResultType, result)
|
|
96
|
+
|
|
97
|
+
def set_replay_mode(self, replay: bool) -> None:
|
|
98
|
+
"""Set whether the workflow is currently in replay mode."""
|
|
99
|
+
self._replay_mode = replay
|
|
100
|
+
|
|
101
|
+
def is_replay_mode(self) -> bool:
|
|
102
|
+
"""Check if the workflow is currently in replay mode."""
|
|
103
|
+
return self._replay_mode
|
|
104
|
+
|
|
105
|
+
def set_replay_current_time_milliseconds(self, time_millis: int) -> None:
|
|
106
|
+
"""Set the current replay time in milliseconds."""
|
|
107
|
+
self._replay_current_time_milliseconds = time_millis
|
|
108
|
+
|
|
109
|
+
def get_replay_current_time_milliseconds(self) -> Optional[int]:
|
|
110
|
+
"""Get the current replay time in milliseconds."""
|
|
111
|
+
return self._replay_current_time_milliseconds
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def _activate(self) -> Iterator["Context"]:
|
|
115
|
+
token = WorkflowContext._var.set(self)
|
|
116
|
+
yield self
|
|
117
|
+
WorkflowContext._var.reset(token)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _round_to_nearest_second(delta: timedelta) -> timedelta:
|
|
121
|
+
return timedelta(seconds=ceil(delta.total_seconds()))
|