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
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,5 @@
1
+ from ._activity_executor import ActivityExecutor
2
+
3
+ __all__ = [
4
+ "ActivityExecutor",
5
+ ]
@@ -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()))