cadence-python-client 0.2.2__py3-none-any.whl → 0.2.3__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/_internal/rpc/error.py +24 -24
- cadence/_internal/rpc/retry.py +17 -13
- cadence/_internal/rpc/yarpc.py +7 -8
- cadence/_internal/workflow/workflow_engine.py +26 -2
- cadence/_internal/workflow/workflow_instance.py +33 -0
- cadence/client.py +311 -3
- cadence/contrib/openai/cadence_tool.py +6 -0
- cadence/query.py +103 -0
- cadence/worker/_decision_task_handler.py +93 -15
- cadence/workflow.py +91 -8
- {cadence_python_client-0.2.2.dist-info → cadence_python_client-0.2.3.dist-info}/METADATA +49 -48
- {cadence_python_client-0.2.2.dist-info → cadence_python_client-0.2.3.dist-info}/RECORD +16 -15
- {cadence_python_client-0.2.2.dist-info → cadence_python_client-0.2.3.dist-info}/WHEEL +0 -0
- {cadence_python_client-0.2.2.dist-info → cadence_python_client-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {cadence_python_client-0.2.2.dist-info → cadence_python_client-0.2.3.dist-info}/licenses/NOTICE +0 -0
- {cadence_python_client-0.2.2.dist-info → cadence_python_client-0.2.3.dist-info}/top_level.txt +0 -0
cadence/_internal/rpc/error.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
from typing import Callable, Any, Optional, Generator, TypeVar
|
|
1
|
+
from typing import Callable, Any, Optional, Generator, TypeVar, cast
|
|
2
2
|
|
|
3
3
|
import grpc
|
|
4
|
-
from google.rpc.status_pb2 import Status
|
|
4
|
+
from google.rpc.status_pb2 import Status
|
|
5
5
|
from grpc.aio import (
|
|
6
6
|
UnaryUnaryClientInterceptor,
|
|
7
7
|
ClientCallDetails,
|
|
@@ -9,7 +9,7 @@ from grpc.aio import (
|
|
|
9
9
|
UnaryUnaryCall,
|
|
10
10
|
Metadata,
|
|
11
11
|
)
|
|
12
|
-
from grpc_status.rpc_status import from_call
|
|
12
|
+
from grpc_status.rpc_status import from_call
|
|
13
13
|
|
|
14
14
|
from cadence.api.v1 import error_pb2
|
|
15
15
|
from cadence import error
|
|
@@ -46,22 +46,22 @@ class CadenceErrorUnaryUnaryCall(UnaryUnaryCall[RequestType, ResponseType]):
|
|
|
46
46
|
return await self._wrapped.code()
|
|
47
47
|
|
|
48
48
|
async def details(self) -> str:
|
|
49
|
-
return await self._wrapped.details()
|
|
49
|
+
return await self._wrapped.details()
|
|
50
50
|
|
|
51
51
|
async def wait_for_connection(self) -> None:
|
|
52
52
|
await self._wrapped.wait_for_connection()
|
|
53
53
|
|
|
54
54
|
def cancelled(self) -> bool:
|
|
55
|
-
return self._wrapped.cancelled()
|
|
55
|
+
return self._wrapped.cancelled()
|
|
56
56
|
|
|
57
57
|
def done(self) -> bool:
|
|
58
|
-
return self._wrapped.done()
|
|
58
|
+
return self._wrapped.done()
|
|
59
59
|
|
|
60
60
|
def time_remaining(self) -> Optional[float]:
|
|
61
|
-
return self._wrapped.time_remaining()
|
|
61
|
+
return self._wrapped.time_remaining()
|
|
62
62
|
|
|
63
63
|
def cancel(self) -> bool:
|
|
64
|
-
return self._wrapped.cancel()
|
|
64
|
+
return self._wrapped.cancel()
|
|
65
65
|
|
|
66
66
|
def add_done_callback(self, callback: DoneCallbackType) -> None:
|
|
67
67
|
self._wrapped.add_done_callback(callback)
|
|
@@ -79,16 +79,18 @@ class CadenceErrorInterceptor(UnaryUnaryClientInterceptor):
|
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
def map_error(e: AioRpcError) -> error.CadenceRpcError:
|
|
82
|
-
|
|
82
|
+
# AioRpcError implements the grpc.Call interface but doesn't inherit from it in the type stubs
|
|
83
|
+
status: Status | None = from_call(cast(grpc.Call, e))
|
|
84
|
+
message = e.details() or ""
|
|
83
85
|
if not status or not status.details:
|
|
84
|
-
return error.CadenceRpcError(
|
|
86
|
+
return error.CadenceRpcError(message, e.code())
|
|
85
87
|
|
|
86
88
|
details = status.details[0]
|
|
87
89
|
if details.Is(error_pb2.WorkflowExecutionAlreadyStartedError.DESCRIPTOR):
|
|
88
90
|
already_started = error_pb2.WorkflowExecutionAlreadyStartedError()
|
|
89
91
|
details.Unpack(already_started)
|
|
90
92
|
return error.WorkflowExecutionAlreadyStartedError(
|
|
91
|
-
|
|
93
|
+
message,
|
|
92
94
|
e.code(),
|
|
93
95
|
already_started.start_request_id,
|
|
94
96
|
already_started.run_id,
|
|
@@ -97,19 +99,19 @@ def map_error(e: AioRpcError) -> error.CadenceRpcError:
|
|
|
97
99
|
not_exists = error_pb2.EntityNotExistsError()
|
|
98
100
|
details.Unpack(not_exists)
|
|
99
101
|
return error.EntityNotExistsError(
|
|
100
|
-
|
|
102
|
+
message,
|
|
101
103
|
e.code(),
|
|
102
104
|
not_exists.current_cluster,
|
|
103
105
|
not_exists.active_cluster,
|
|
104
106
|
list(not_exists.active_clusters),
|
|
105
107
|
)
|
|
106
108
|
elif details.Is(error_pb2.WorkflowExecutionAlreadyCompletedError.DESCRIPTOR):
|
|
107
|
-
return error.WorkflowExecutionAlreadyCompletedError(
|
|
109
|
+
return error.WorkflowExecutionAlreadyCompletedError(message, e.code())
|
|
108
110
|
elif details.Is(error_pb2.DomainNotActiveError.DESCRIPTOR):
|
|
109
111
|
not_active = error_pb2.DomainNotActiveError()
|
|
110
112
|
details.Unpack(not_active)
|
|
111
113
|
return error.DomainNotActiveError(
|
|
112
|
-
|
|
114
|
+
message,
|
|
113
115
|
e.code(),
|
|
114
116
|
not_active.domain,
|
|
115
117
|
not_active.current_cluster,
|
|
@@ -120,7 +122,7 @@ def map_error(e: AioRpcError) -> error.CadenceRpcError:
|
|
|
120
122
|
not_supported = error_pb2.ClientVersionNotSupportedError()
|
|
121
123
|
details.Unpack(not_supported)
|
|
122
124
|
return error.ClientVersionNotSupportedError(
|
|
123
|
-
|
|
125
|
+
message,
|
|
124
126
|
e.code(),
|
|
125
127
|
not_supported.feature_version,
|
|
126
128
|
not_supported.client_impl,
|
|
@@ -129,20 +131,18 @@ def map_error(e: AioRpcError) -> error.CadenceRpcError:
|
|
|
129
131
|
elif details.Is(error_pb2.FeatureNotEnabledError.DESCRIPTOR):
|
|
130
132
|
not_enabled = error_pb2.FeatureNotEnabledError()
|
|
131
133
|
details.Unpack(not_enabled)
|
|
132
|
-
return error.FeatureNotEnabledError(
|
|
133
|
-
e.details(), e.code(), not_enabled.feature_flag
|
|
134
|
-
)
|
|
134
|
+
return error.FeatureNotEnabledError(message, e.code(), not_enabled.feature_flag)
|
|
135
135
|
elif details.Is(error_pb2.CancellationAlreadyRequestedError.DESCRIPTOR):
|
|
136
|
-
return error.CancellationAlreadyRequestedError(
|
|
136
|
+
return error.CancellationAlreadyRequestedError(message, e.code())
|
|
137
137
|
elif details.Is(error_pb2.DomainAlreadyExistsError.DESCRIPTOR):
|
|
138
|
-
return error.DomainAlreadyExistsError(
|
|
138
|
+
return error.DomainAlreadyExistsError(message, e.code())
|
|
139
139
|
elif details.Is(error_pb2.LimitExceededError.DESCRIPTOR):
|
|
140
|
-
return error.LimitExceededError(
|
|
140
|
+
return error.LimitExceededError(message, e.code())
|
|
141
141
|
elif details.Is(error_pb2.QueryFailedError.DESCRIPTOR):
|
|
142
|
-
return error.QueryFailedError(
|
|
142
|
+
return error.QueryFailedError(message, e.code())
|
|
143
143
|
elif details.Is(error_pb2.ServiceBusyError.DESCRIPTOR):
|
|
144
144
|
service_busy = error_pb2.ServiceBusyError()
|
|
145
145
|
details.Unpack(service_busy)
|
|
146
|
-
return error.ServiceBusyError(
|
|
146
|
+
return error.ServiceBusyError(message, e.code(), service_busy.reason)
|
|
147
147
|
else:
|
|
148
|
-
return error.CadenceRpcError(
|
|
148
|
+
return error.CadenceRpcError(message, e.code())
|
cadence/_internal/rpc/retry.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from dataclasses import dataclass
|
|
3
|
-
from typing import Callable, Any
|
|
3
|
+
from typing import Callable, Any, cast
|
|
4
4
|
|
|
5
5
|
from grpc import StatusCode
|
|
6
6
|
from grpc.aio import UnaryUnaryClientInterceptor, ClientCallDetails
|
|
@@ -59,19 +59,23 @@ class RetryInterceptor(UnaryUnaryClientInterceptor):
|
|
|
59
59
|
request: Any,
|
|
60
60
|
) -> Any:
|
|
61
61
|
loop = asyncio.get_running_loop()
|
|
62
|
-
expiration_interval =
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
expiration_interval = (
|
|
63
|
+
client_call_details.timeout
|
|
64
|
+
if client_call_details.timeout is not None
|
|
65
|
+
else float("inf")
|
|
66
|
+
)
|
|
65
67
|
start_time = loop.time()
|
|
66
68
|
deadline = start_time + expiration_interval
|
|
67
69
|
|
|
68
70
|
attempts = 0
|
|
69
71
|
while True:
|
|
70
72
|
remaining = deadline - loop.time()
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
call_details = ClientCallDetails(
|
|
74
|
+
method=client_call_details.method,
|
|
75
|
+
timeout=remaining,
|
|
76
|
+
metadata=client_call_details.metadata,
|
|
77
|
+
credentials=client_call_details.credentials,
|
|
78
|
+
wait_for_ready=client_call_details.wait_for_ready,
|
|
75
79
|
)
|
|
76
80
|
rpc_call = await continuation(call_details, request)
|
|
77
81
|
try:
|
|
@@ -98,11 +102,11 @@ class RetryInterceptor(UnaryUnaryClientInterceptor):
|
|
|
98
102
|
|
|
99
103
|
|
|
100
104
|
def is_retryable(err: CadenceRpcError, call_details: ClientCallDetails) -> bool:
|
|
101
|
-
# Handle requests to the passive side, matching the Go and Java Clients
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
):
|
|
105
|
+
# Handle requests to the passive side, matching the Go and Java Clients.
|
|
106
|
+
# grpc-stubs types method as str, but grpcio corrected it to bytes in v1.75.0
|
|
107
|
+
# (grpc/grpc#39405). Cast to bytes to match the actual runtime type.
|
|
108
|
+
method = cast(bytes, call_details.method)
|
|
109
|
+
if method == GET_WORKFLOW_HISTORY and isinstance(err, EntityNotExistsError):
|
|
106
110
|
return (
|
|
107
111
|
err.active_cluster is not None
|
|
108
112
|
and err.current_cluster is not None
|
cadence/_internal/rpc/yarpc.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any, Callable
|
|
1
|
+
from typing import Any, Callable
|
|
2
2
|
|
|
3
3
|
from grpc.aio import Metadata
|
|
4
4
|
from grpc.aio import UnaryUnaryClientInterceptor, ClientCallDetails
|
|
@@ -38,11 +38,10 @@ class YarpcMetadataInterceptor(UnaryUnaryClientInterceptor):
|
|
|
38
38
|
else:
|
|
39
39
|
metadata += self._metadata
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
client_call_details.
|
|
46
|
-
|
|
47
|
-
),
|
|
41
|
+
return ClientCallDetails(
|
|
42
|
+
method=client_call_details.method,
|
|
43
|
+
timeout=client_call_details.timeout or 60.0,
|
|
44
|
+
metadata=metadata,
|
|
45
|
+
credentials=client_call_details.credentials,
|
|
46
|
+
wait_for_ready=client_call_details.wait_for_ready,
|
|
48
47
|
)
|
|
@@ -25,6 +25,11 @@ from cadence.api.v1.history_pb2 import (
|
|
|
25
25
|
WorkflowExecutionSignaledEventAttributes,
|
|
26
26
|
WorkflowExecutionStartedEventAttributes,
|
|
27
27
|
)
|
|
28
|
+
from cadence.api.v1.query_pb2 import (
|
|
29
|
+
WorkflowQuery,
|
|
30
|
+
WorkflowQueryResult,
|
|
31
|
+
QUERY_RESULT_TYPE_ANSWERED,
|
|
32
|
+
)
|
|
28
33
|
from cadence.api.v1.tasklist_pb2 import TaskList
|
|
29
34
|
from cadence.error import ContinueAsNewError
|
|
30
35
|
from cadence.workflow import WorkflowDefinition, WorkflowInfo
|
|
@@ -35,6 +40,7 @@ logger = logging.getLogger(__name__)
|
|
|
35
40
|
@dataclass
|
|
36
41
|
class DecisionResult:
|
|
37
42
|
decisions: list[Decision]
|
|
43
|
+
query_result: Optional[WorkflowQueryResult] = None
|
|
38
44
|
|
|
39
45
|
|
|
40
46
|
class WorkflowEngine:
|
|
@@ -51,6 +57,7 @@ class WorkflowEngine:
|
|
|
51
57
|
def process_decision(
|
|
52
58
|
self,
|
|
53
59
|
events: List[HistoryEvent],
|
|
60
|
+
query: Optional[WorkflowQuery] = None,
|
|
54
61
|
) -> DecisionResult:
|
|
55
62
|
"""
|
|
56
63
|
Process a decision task and generate decisions using DecisionEventsIterator.
|
|
@@ -59,7 +66,7 @@ class WorkflowEngine:
|
|
|
59
66
|
to drive the decision processing pipeline with proper replay handling.
|
|
60
67
|
|
|
61
68
|
Args:
|
|
62
|
-
|
|
69
|
+
events: The workflow history events.
|
|
63
70
|
|
|
64
71
|
Returns:
|
|
65
72
|
DecisionResult containing the list of decisions
|
|
@@ -74,6 +81,7 @@ class WorkflowEngine:
|
|
|
74
81
|
"workflow_type": ctx.info().workflow_type,
|
|
75
82
|
"workflow_id": ctx.info().workflow_id,
|
|
76
83
|
"run_id": ctx.info().workflow_run_id,
|
|
84
|
+
"query": query.query_type if query else None,
|
|
77
85
|
},
|
|
78
86
|
)
|
|
79
87
|
|
|
@@ -83,11 +91,15 @@ class WorkflowEngine:
|
|
|
83
91
|
# Process decision events using iterator-driven approach
|
|
84
92
|
self._process_decision_events(ctx, events_iterator)
|
|
85
93
|
|
|
94
|
+
if query:
|
|
95
|
+
return self._execute_query(query)
|
|
96
|
+
|
|
86
97
|
# Collect all pending decisions from state machines
|
|
87
98
|
decisions = self._decision_manager.collect_pending_decisions()
|
|
88
99
|
|
|
89
|
-
return DecisionResult(decisions=decisions)
|
|
100
|
+
return DecisionResult(decisions=decisions, query_result=None)
|
|
90
101
|
|
|
102
|
+
# TODO: reevaluate if this is needed to log error here or in the caller
|
|
91
103
|
except Exception as e:
|
|
92
104
|
# Log decision task failure with full context (matches Java ReplayDecisionTaskHandler)
|
|
93
105
|
logger.error(
|
|
@@ -103,6 +115,18 @@ class WorkflowEngine:
|
|
|
103
115
|
# Re-raise the exception so the handler can properly handle the failure
|
|
104
116
|
raise
|
|
105
117
|
|
|
118
|
+
def _execute_query(self, query: WorkflowQuery) -> DecisionResult:
|
|
119
|
+
result_payload = self._workflow_instance.handle_query(
|
|
120
|
+
query.query_type, query.query_args
|
|
121
|
+
)
|
|
122
|
+
return DecisionResult(
|
|
123
|
+
decisions=[],
|
|
124
|
+
query_result=WorkflowQueryResult(
|
|
125
|
+
result_type=QUERY_RESULT_TYPE_ANSWERED,
|
|
126
|
+
answer=result_payload,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
106
130
|
def is_done(self) -> bool:
|
|
107
131
|
return self._workflow_instance.is_done()
|
|
108
132
|
|
|
@@ -96,6 +96,39 @@ class WorkflowInstance:
|
|
|
96
96
|
if inspect.iscoroutine(result):
|
|
97
97
|
await result
|
|
98
98
|
|
|
99
|
+
def handle_query(self, query_type: str, query_args: Payload) -> Payload:
|
|
100
|
+
"""Execute a query handler and return the serialized result.
|
|
101
|
+
|
|
102
|
+
The query runs synchronously against the current workflow state
|
|
103
|
+
(after replay has caught up). It must not mutate state.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
query_type: The registered query type name.
|
|
107
|
+
query_args: Serialized query arguments.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Serialized query result as a Payload.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If the query type is not registered.
|
|
114
|
+
Exception: If the query handler raises.
|
|
115
|
+
"""
|
|
116
|
+
query_def = self._definition.queries.get(query_type)
|
|
117
|
+
if query_def is None:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Unknown query type '{query_type}'. "
|
|
120
|
+
f"Known types: {list(self._definition.queries.keys())}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
args = query_def.params_from_payload(self._data_converter, query_args)
|
|
124
|
+
result = query_def(self._instance, *args)
|
|
125
|
+
if inspect.iscoroutine(result):
|
|
126
|
+
result.close()
|
|
127
|
+
raise TypeError(
|
|
128
|
+
f"Query handler '{query_type}' must be synchronous, got async function"
|
|
129
|
+
)
|
|
130
|
+
return self._data_converter.to_data([result])
|
|
131
|
+
|
|
99
132
|
def _on_signal_task_done(self, task: Task[Any], signal_name: str) -> None:
|
|
100
133
|
self._signal_tasks.discard(task)
|
|
101
134
|
if task.cancelled():
|