dex-python-sdk 0.0.2__cp311-abi3-win_amd64.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.
- dex/__init__.py +146 -0
- dex/_grpc_errors.py +73 -0
- dex/_invocation_context.py +269 -0
- dex/_native.pyd +0 -0
- dex/_native.pyi +20 -0
- dex/_utils.py +18 -0
- dex/_value_hydrator.py +223 -0
- dex/_value_mapper.py +174 -0
- dex/_worker_dispatcher.py +451 -0
- dex/_worker_service.py +60 -0
- dex/attribute.py +87 -0
- dex/blob_cache.py +73 -0
- dex/channel.py +165 -0
- dex/client.py +715 -0
- dex/client_options.py +19 -0
- dex/codec.py +286 -0
- dex/command_request.py +120 -0
- dex/command_results.py +107 -0
- dex/communication.py +136 -0
- dex/communication_schema.py +54 -0
- dex/condition.py +74 -0
- dex/context.py +86 -0
- dex/data_attributes.py +70 -0
- dex/dexpb/__init__.py +1 -0
- dex/dexpb/dex_pb2.py +381 -0
- dex/dexpb/dex_pb2.pyi +1734 -0
- dex/dexpb/dex_pb2_grpc.py +1298 -0
- dex/errors.py +109 -0
- dex/flow.py +456 -0
- dex/flow_config.py +29 -0
- dex/flow_info.py +51 -0
- dex/flow_options.py +122 -0
- dex/object_encoder.py +799 -0
- dex/persistence.py +89 -0
- dex/persistence_options.py +12 -0
- dex/persistence_schema.py +51 -0
- dex/py.typed +1 -0
- dex/registry.py +204 -0
- dex/reset_workflow_type_and_options.py +67 -0
- dex/rpc.py +93 -0
- dex/runtime_errors.py +81 -0
- dex/search_attributes.py +184 -0
- dex/state_decision.py +153 -0
- dex/state_execution_locals.py +66 -0
- dex/state_movement.py +115 -0
- dex/state_schema.py +48 -0
- dex/step.py +194 -0
- dex/step_execution.py +42 -0
- dex/stop_workflow_options.py +18 -0
- dex/tests/__init__.py +80 -0
- dex/tests/dex-service-env/.env +7 -0
- dex/tests/dex-service-env/docker-compose-init.sh +44 -0
- dex/tests/dex-service-env/docker-compose.yml +97 -0
- dex/tests/dex-service-env/dynamicconfig/README.md +39 -0
- dex/tests/dex-service-env/dynamicconfig/development-sql.yaml +9 -0
- dex/tests/dex-service-env/dynamicconfig/docker.yaml +2 -0
- dex/tests/test_abnormal_exit_workflow.py +43 -0
- dex/tests/test_basic_workflow.py +70 -0
- dex/tests/test_conditional_complete.py +50 -0
- dex/tests/test_describe_workflow.py +40 -0
- dex/tests/test_empty_data_decodes_properly.py +74 -0
- dex/tests/test_internal_channel.py +28 -0
- dex/tests/test_internal_channel_with_no_prefix_channel.py +41 -0
- dex/tests/test_persistence_data_attributes.py +62 -0
- dex/tests/test_persistence_search_attributes.py +127 -0
- dex/tests/test_persistence_state_execution_locals.py +38 -0
- dex/tests/test_rpc.py +64 -0
- dex/tests/test_rpc_with_memo.py +195 -0
- dex/tests/test_rpc_with_memo_duplicate_java_tests.py +117 -0
- dex/tests/test_signal.py +51 -0
- dex/tests/test_skip_wait_until.py +76 -0
- dex/tests/test_state_failure_recovery.py +28 -0
- dex/tests/test_timer.py +35 -0
- dex/tests/test_wait_for_state_execution_completion.py +53 -0
- dex/tests/test_workflow_errors.py +87 -0
- dex/tests/test_workflow_state_options.py +118 -0
- dex/tests/test_workflow_state_options_override.py +44 -0
- dex/tests/worker_server.py +64 -0
- dex/tests/workflows/abnormal_exit_workflow.py +42 -0
- dex/tests/workflows/basic_workflow.py +62 -0
- dex/tests/workflows/conditional_complete_workflow.py +95 -0
- dex/tests/workflows/describe_workflow.py +46 -0
- dex/tests/workflows/empty_data_workflow.py +45 -0
- dex/tests/workflows/internal_channel_workflow.py +129 -0
- dex/tests/workflows/internal_channel_workflow_with_no_prefix_channel.py +100 -0
- dex/tests/workflows/java_duplicate_rpc_memo_workflow.py +276 -0
- dex/tests/workflows/persistence_data_attributes_workflow.py +98 -0
- dex/tests/workflows/persistence_search_attributes_workflow.py +159 -0
- dex/tests/workflows/persistence_state_execution_local_workflow.py +63 -0
- dex/tests/workflows/recovery_workflow.py +82 -0
- dex/tests/workflows/rpc_memo_workflow.py +231 -0
- dex/tests/workflows/rpc_workflow.py +117 -0
- dex/tests/workflows/state_options_override_workflow.py +93 -0
- dex/tests/workflows/state_options_workflow.py +84 -0
- dex/tests/workflows/timer_workflow.py +46 -0
- dex/tests/workflows/wait_for_state_with_state_execution_id_workflow.py +70 -0
- dex/tests/workflows/wait_for_state_with_wait_for_key_workflow.py +71 -0
- dex/tests/workflows/wait_internal_channel_workflow.py +47 -0
- dex/tests/workflows/wait_signal_workflow.py +147 -0
- dex/timer.py +21 -0
- dex/type_store.py +99 -0
- dex/unregistered_client.py +585 -0
- dex/utils/__init__.py +3 -0
- dex/utils/dex_typing.py +25 -0
- dex/utils/persistence_utils.py +32 -0
- dex/wait.py +49 -0
- dex/worker.py +121 -0
- dex/worker_options.py +22 -0
- dex/worker_service.py +432 -0
- dex/workflow.py +79 -0
- dex/workflow_context.py +44 -0
- dex/workflow_info.py +16 -0
- dex/workflow_options.py +74 -0
- dex/workflow_state.py +123 -0
- dex/workflow_state_options.py +154 -0
- dex_python_sdk-0.0.2.dist-info/METADATA +202 -0
- dex_python_sdk-0.0.2.dist-info/RECORD +121 -0
- dex_python_sdk-0.0.2.dist-info/WHEEL +4 -0
- dex_python_sdk-0.0.2.dist-info/licenses/LEGACY_NOTICES.md +61 -0
- dex_python_sdk-0.0.2.dist-info/licenses/LICENSE +192 -0
- dex_python_sdk-0.0.2.dist-info/sboms/dex-blob-cache-python.cyclonedx.json +2406 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Super Durable Source License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from dex._invocation_context import InvocationContext, InvocationMethod
|
|
15
|
+
from dex._value_hydrator import ValueHydrator
|
|
16
|
+
from dex._value_mapper import ValueMapper
|
|
17
|
+
from dex.attribute import AttributeLock
|
|
18
|
+
from dex.channel import Channel, ChannelMap
|
|
19
|
+
from dex.condition import ChannelCondition, Condition, TimerCondition
|
|
20
|
+
from dex.dexpb import dex_pb2 as pb
|
|
21
|
+
from dex.flow import Registry, RPCResult, _RegisteredFlow, _RegisteredStep
|
|
22
|
+
from dex.step import (
|
|
23
|
+
DecisionKind,
|
|
24
|
+
RetryPolicy,
|
|
25
|
+
StepDecision,
|
|
26
|
+
StepDurability,
|
|
27
|
+
StepMovement,
|
|
28
|
+
StepOptions,
|
|
29
|
+
WaitForFailurePolicy,
|
|
30
|
+
)
|
|
31
|
+
from dex.wait import Wait, WaitKind
|
|
32
|
+
|
|
33
|
+
_INTERNAL_CONDITION_PREFIX = "__dex_internal_condition_"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WorkerDispatcher:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
registry: Registry,
|
|
40
|
+
values: ValueMapper,
|
|
41
|
+
hydrator: ValueHydrator,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._registry = registry
|
|
44
|
+
self._values = values
|
|
45
|
+
self._hydrator = hydrator
|
|
46
|
+
|
|
47
|
+
def invoke_wait_for(
|
|
48
|
+
self,
|
|
49
|
+
original: pb.InvokeWaitForMethodRequest,
|
|
50
|
+
) -> pb.InvokeWaitForMethodResponse:
|
|
51
|
+
request = self._hydrator.wait_for_request(original)
|
|
52
|
+
flow = self._registry._flow_by_type(request.flow_type)
|
|
53
|
+
step = flow.step(request.step_type)
|
|
54
|
+
context = InvocationContext(
|
|
55
|
+
InvocationMethod.WAIT_FOR,
|
|
56
|
+
flow,
|
|
57
|
+
request.context,
|
|
58
|
+
self._values,
|
|
59
|
+
request.attributes,
|
|
60
|
+
)
|
|
61
|
+
input = self._values.decode(request.step_input, step.input_codec)
|
|
62
|
+
wait = step.step.wait_for(context, input)
|
|
63
|
+
if not isinstance(wait, Wait):
|
|
64
|
+
raise TypeError("wait_for must return Wait")
|
|
65
|
+
response = pb.InvokeWaitForMethodResponse(
|
|
66
|
+
upsert_attributes=list(context.attribute_writes.values()),
|
|
67
|
+
upsert_step_exe_locals=list(context.local_writes.values()),
|
|
68
|
+
record_events=context.events,
|
|
69
|
+
publish_to_channel=context.publications,
|
|
70
|
+
)
|
|
71
|
+
waiting = self._map_wait(flow, wait)
|
|
72
|
+
if waiting is not None:
|
|
73
|
+
response.waiting_condition.CopyFrom(waiting)
|
|
74
|
+
return response
|
|
75
|
+
|
|
76
|
+
def invoke_execute(
|
|
77
|
+
self,
|
|
78
|
+
original: pb.InvokeExecuteMethodRequest,
|
|
79
|
+
) -> pb.InvokeExecuteMethodResponse:
|
|
80
|
+
request = self._hydrator.execute_request(original)
|
|
81
|
+
flow = self._registry._flow_by_type(request.flow_type)
|
|
82
|
+
step = flow.step(request.step_type)
|
|
83
|
+
condition_results = (
|
|
84
|
+
request.condition_results if request.HasField("condition_results") else None
|
|
85
|
+
)
|
|
86
|
+
context = InvocationContext(
|
|
87
|
+
InvocationMethod.EXECUTE,
|
|
88
|
+
flow,
|
|
89
|
+
request.context,
|
|
90
|
+
self._values,
|
|
91
|
+
request.attributes,
|
|
92
|
+
request.step_exe_locals,
|
|
93
|
+
condition_results,
|
|
94
|
+
)
|
|
95
|
+
input = self._values.decode(request.step_input, step.input_codec)
|
|
96
|
+
decision = step.step.execute(context, input)
|
|
97
|
+
if not isinstance(decision, StepDecision):
|
|
98
|
+
raise TypeError("execute must return StepDecision")
|
|
99
|
+
return pb.InvokeExecuteMethodResponse(
|
|
100
|
+
step_decision=self._map_decision(flow, decision),
|
|
101
|
+
upsert_attributes=list(context.attribute_writes.values()),
|
|
102
|
+
record_events=context.events,
|
|
103
|
+
upsert_step_exe_locals=list(context.local_writes.values()),
|
|
104
|
+
publish_to_channel=context.publications,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def invoke_rpc(
|
|
108
|
+
self,
|
|
109
|
+
original: pb.InvokeWorkerRPCRequest,
|
|
110
|
+
) -> pb.InvokeWorkerRPCResponse:
|
|
111
|
+
request = self._hydrator.rpc_request(original)
|
|
112
|
+
flow = self._registry._flow_by_type(request.flow_type)
|
|
113
|
+
rpc = flow.rpc(request.rpc_name)
|
|
114
|
+
context = InvocationContext(
|
|
115
|
+
InvocationMethod.RPC,
|
|
116
|
+
flow,
|
|
117
|
+
request.context,
|
|
118
|
+
self._values,
|
|
119
|
+
request.attributes,
|
|
120
|
+
channel_infos=dict(request.channel_infos),
|
|
121
|
+
)
|
|
122
|
+
arguments: list[object] = [context]
|
|
123
|
+
if rpc.input_codec is not None:
|
|
124
|
+
arguments.append(self._values.decode(request.input, rpc.input_codec))
|
|
125
|
+
returned = rpc.method(*arguments)
|
|
126
|
+
response = pb.InvokeWorkerRPCResponse(
|
|
127
|
+
upsert_attributes=list(context.attribute_writes.values()),
|
|
128
|
+
record_events=context.events,
|
|
129
|
+
publish_to_channel=context.publications,
|
|
130
|
+
)
|
|
131
|
+
if isinstance(returned, RPCResult):
|
|
132
|
+
if rpc.output_codec is None:
|
|
133
|
+
raise TypeError("RPCResult requires an output type")
|
|
134
|
+
response.output.CopyFrom(
|
|
135
|
+
self._values.encode(returned.output, rpc.output_codec)
|
|
136
|
+
)
|
|
137
|
+
if returned.next_steps:
|
|
138
|
+
response.step_decision.next_steps.extend(
|
|
139
|
+
self._map_movements(flow, returned.next_steps)
|
|
140
|
+
)
|
|
141
|
+
elif returned is None and rpc.output_codec is None:
|
|
142
|
+
response.output.CopyFrom(self._values.encode_dynamic(None))
|
|
143
|
+
else:
|
|
144
|
+
raise TypeError("RPC must return RPCResult or None")
|
|
145
|
+
return response
|
|
146
|
+
|
|
147
|
+
def map_step_options(
|
|
148
|
+
self,
|
|
149
|
+
flow: _RegisteredFlow,
|
|
150
|
+
options: StepOptions | None,
|
|
151
|
+
) -> pb.StepOptions | None:
|
|
152
|
+
if options is None:
|
|
153
|
+
return None
|
|
154
|
+
mapped = pb.StepOptions(
|
|
155
|
+
wait_for_failure_policy=(
|
|
156
|
+
pb.WAIT_FOR_METHOD_FAILURE_POLICY_PROCEED_ON_FAILURE
|
|
157
|
+
if options.wait_for_failure is WaitForFailurePolicy.PROCEED
|
|
158
|
+
else pb.WAIT_FOR_METHOD_FAILURE_POLICY_FAIL_FLOW_ON_FAILURE
|
|
159
|
+
),
|
|
160
|
+
wait_for_durability_override=cast(
|
|
161
|
+
Any, self._map_durability(options.wait_for_durability)
|
|
162
|
+
),
|
|
163
|
+
execute_durability_override=cast(
|
|
164
|
+
Any, self._map_durability(options.execute_durability)
|
|
165
|
+
),
|
|
166
|
+
wait_for_lock_attribute_keys=[
|
|
167
|
+
self._map_lock(lock) for lock in options.wait_for_lock_attributes
|
|
168
|
+
],
|
|
169
|
+
execute_lock_attribute_keys=[
|
|
170
|
+
self._map_lock(lock) for lock in options.execute_lock_attributes
|
|
171
|
+
],
|
|
172
|
+
)
|
|
173
|
+
if options.wait_for_method_timeout is not None:
|
|
174
|
+
mapped.wait_for_timeout_seconds = self._seconds32(
|
|
175
|
+
options.wait_for_method_timeout
|
|
176
|
+
)
|
|
177
|
+
if options.execute_method_timeout is not None:
|
|
178
|
+
mapped.execute_timeout_seconds = self._seconds32(
|
|
179
|
+
options.execute_method_timeout
|
|
180
|
+
)
|
|
181
|
+
if options.wait_for_retry is not None:
|
|
182
|
+
mapped.wait_for_retry_policy.CopyFrom(
|
|
183
|
+
self._map_retry(options.wait_for_retry)
|
|
184
|
+
)
|
|
185
|
+
if options.execute_retry is not None:
|
|
186
|
+
mapped.execute_retry_policy.CopyFrom(self._map_retry(options.execute_retry))
|
|
187
|
+
if options._execute_failure_target is not None:
|
|
188
|
+
target = self._registered_movement_target(
|
|
189
|
+
flow,
|
|
190
|
+
options._execute_failure_target,
|
|
191
|
+
)
|
|
192
|
+
mapped.execute_failure_policy = (
|
|
193
|
+
pb.EXECUTE_METHOD_FAILURE_POLICY_PROCEED_TO_CONFIGURED_STEP
|
|
194
|
+
)
|
|
195
|
+
mapped.execute_failure_proceed_step_type = target.name
|
|
196
|
+
target_options = self.map_step_options(
|
|
197
|
+
flow,
|
|
198
|
+
(
|
|
199
|
+
options._execute_failure_options
|
|
200
|
+
if options._execute_failure_options is not None
|
|
201
|
+
else target.step.get_step_options()
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
if target_options is not None:
|
|
205
|
+
mapped.execute_failure_proceed_step_options.CopyFrom(target_options)
|
|
206
|
+
mapped.execute_failure_proceed_step_options.skip_wait_for = (
|
|
207
|
+
target.skips_wait_for
|
|
208
|
+
)
|
|
209
|
+
return mapped
|
|
210
|
+
|
|
211
|
+
def _map_wait(
|
|
212
|
+
self,
|
|
213
|
+
flow: _RegisteredFlow,
|
|
214
|
+
wait: Wait,
|
|
215
|
+
) -> pb.WaitingCondition | None:
|
|
216
|
+
if wait.kind is WaitKind.SKIP_IMMEDIATELY:
|
|
217
|
+
return None
|
|
218
|
+
mapper = _ConditionMapper(flow)
|
|
219
|
+
waiting = pb.WaitingCondition()
|
|
220
|
+
if wait.kind is WaitKind.ALL_OF:
|
|
221
|
+
waiting.waiting_condition_type = pb.WAITING_CONDITION_TYPE_ALL_COMPLETED
|
|
222
|
+
mapper.add_all(wait.conditions)
|
|
223
|
+
elif wait.kind is WaitKind.ANY_OF:
|
|
224
|
+
waiting.waiting_condition_type = pb.WAITING_CONDITION_TYPE_ANY_COMPLETED
|
|
225
|
+
mapper.add_all(wait.conditions)
|
|
226
|
+
elif wait.kind is WaitKind.ANY_COMBINATION_OF:
|
|
227
|
+
waiting.waiting_condition_type = (
|
|
228
|
+
pb.WAITING_CONDITION_TYPE_ANY_COMBINATION_COMPLETED
|
|
229
|
+
)
|
|
230
|
+
for combination in wait.combinations:
|
|
231
|
+
waiting.condition_combinations.add(
|
|
232
|
+
condition_ids=[
|
|
233
|
+
mapper.add(condition) for condition in combination.conditions
|
|
234
|
+
]
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
raise ValueError("unsupported Wait kind")
|
|
238
|
+
waiting.timer_conditions.extend(mapper.timers)
|
|
239
|
+
waiting.channel_conditions.extend(mapper.channels)
|
|
240
|
+
return waiting
|
|
241
|
+
|
|
242
|
+
def _map_decision(
|
|
243
|
+
self,
|
|
244
|
+
flow: _RegisteredFlow,
|
|
245
|
+
decision: StepDecision,
|
|
246
|
+
) -> pb.StepDecision:
|
|
247
|
+
mapped = pb.StepDecision()
|
|
248
|
+
if decision.kind is DecisionKind.NEXT:
|
|
249
|
+
if not decision.movements:
|
|
250
|
+
raise ValueError("go_to_multi requires a movement")
|
|
251
|
+
mapped.next_steps.extend(self._map_movements(flow, decision.movements))
|
|
252
|
+
elif decision.kind is DecisionKind.GRACEFUL_COMPLETE:
|
|
253
|
+
mapped.close_decision.CopyFrom(
|
|
254
|
+
self._close(pb.CLOSE_DECISION_TYPE_GRACEFUL_COMPLETE, decision.output)
|
|
255
|
+
)
|
|
256
|
+
elif decision.kind is DecisionKind.FORCE_COMPLETE:
|
|
257
|
+
mapped.close_decision.CopyFrom(
|
|
258
|
+
self._close(pb.CLOSE_DECISION_TYPE_FORCE_COMPLETE, decision.output)
|
|
259
|
+
)
|
|
260
|
+
elif decision.kind is DecisionKind.FORCE_FAIL:
|
|
261
|
+
mapped.close_decision.CopyFrom(
|
|
262
|
+
self._close(pb.CLOSE_DECISION_TYPE_FORCE_FAIL, decision.reason)
|
|
263
|
+
)
|
|
264
|
+
elif decision.kind is DecisionKind.DEAD_END:
|
|
265
|
+
mapped.close_decision.close_decision_type = pb.CLOSE_DECISION_TYPE_DEAD_END
|
|
266
|
+
elif decision.kind is DecisionKind.FORCE_COMPLETE_IF_CHANNELS_EMPTY:
|
|
267
|
+
if decision.fallback is None:
|
|
268
|
+
raise ValueError("conditional close requires a fallback movement")
|
|
269
|
+
names: list[str] = []
|
|
270
|
+
for channel in decision.empty_channels:
|
|
271
|
+
if not isinstance(channel, Channel):
|
|
272
|
+
raise ValueError("conditional close requires static Channels")
|
|
273
|
+
self._require_persistence_identity(flow, channel)
|
|
274
|
+
names.append(channel.name)
|
|
275
|
+
mapped.close_decision.CopyFrom(
|
|
276
|
+
pb.CloseDecision(
|
|
277
|
+
close_decision_type=(
|
|
278
|
+
pb.CLOSE_DECISION_TYPE_FORCE_COMPLETE_ON_CHANNELS_EMPTY
|
|
279
|
+
),
|
|
280
|
+
close_input=self._values.encode_dynamic(decision.output),
|
|
281
|
+
conditional_channel_names=names,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
mapped.next_steps.append(self._map_movement(flow, decision.fallback))
|
|
285
|
+
else:
|
|
286
|
+
raise ValueError("unsupported StepDecision kind")
|
|
287
|
+
return mapped
|
|
288
|
+
|
|
289
|
+
def _close(self, close_type: int, output: object) -> pb.CloseDecision:
|
|
290
|
+
return pb.CloseDecision(
|
|
291
|
+
close_decision_type=cast(Any, close_type),
|
|
292
|
+
close_input=self._values.encode_dynamic(output),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _map_movements(
|
|
296
|
+
self,
|
|
297
|
+
flow: _RegisteredFlow,
|
|
298
|
+
movements: tuple[StepMovement[Any], ...],
|
|
299
|
+
) -> list[pb.StepMovement]:
|
|
300
|
+
return [self._map_movement(flow, movement) for movement in movements]
|
|
301
|
+
|
|
302
|
+
def _map_movement(
|
|
303
|
+
self,
|
|
304
|
+
flow: _RegisteredFlow,
|
|
305
|
+
movement: StepMovement[Any],
|
|
306
|
+
) -> pb.StepMovement:
|
|
307
|
+
target = self._registered_movement_target(flow, movement.step)
|
|
308
|
+
mapped = pb.StepMovement(
|
|
309
|
+
step_type=target.name,
|
|
310
|
+
step_input=self._values.encode(movement.input, target.input_codec),
|
|
311
|
+
)
|
|
312
|
+
options = self.map_step_options(
|
|
313
|
+
flow,
|
|
314
|
+
(
|
|
315
|
+
movement.options
|
|
316
|
+
if movement.options is not None
|
|
317
|
+
else target.step.get_step_options()
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
if options is not None:
|
|
321
|
+
mapped.step_options.CopyFrom(options)
|
|
322
|
+
mapped.step_options.skip_wait_for = target.skips_wait_for
|
|
323
|
+
return mapped
|
|
324
|
+
|
|
325
|
+
@staticmethod
|
|
326
|
+
def _registered_movement_target(
|
|
327
|
+
flow: _RegisteredFlow,
|
|
328
|
+
step: object,
|
|
329
|
+
) -> _RegisteredStep:
|
|
330
|
+
step_type = getattr(step, "get_step_type", lambda: "")()
|
|
331
|
+
target = flow.step(step_type)
|
|
332
|
+
if target.step is not step:
|
|
333
|
+
raise ValueError("Step movement target does not belong to Flow")
|
|
334
|
+
return target
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _require_persistence_identity(
|
|
338
|
+
flow: _RegisteredFlow,
|
|
339
|
+
definition: Channel[Any] | ChannelMap[Any],
|
|
340
|
+
) -> None:
|
|
341
|
+
if flow.persistence.get(definition.name) is not definition:
|
|
342
|
+
raise ValueError("Channel does not belong to Flow")
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _map_retry(retry: RetryPolicy) -> pb.RetryPolicy:
|
|
346
|
+
mapped = pb.RetryPolicy(
|
|
347
|
+
backoff_coefficient=retry.backoff_coefficient,
|
|
348
|
+
maximum_attempts=retry.maximum_attempts,
|
|
349
|
+
)
|
|
350
|
+
if retry.initial_interval is not None:
|
|
351
|
+
mapped.initial_interval_seconds = WorkerDispatcher._seconds32(
|
|
352
|
+
retry.initial_interval
|
|
353
|
+
)
|
|
354
|
+
if retry.maximum_interval is not None:
|
|
355
|
+
mapped.maximum_interval_seconds = WorkerDispatcher._seconds32(
|
|
356
|
+
retry.maximum_interval
|
|
357
|
+
)
|
|
358
|
+
if retry.total_duration is not None:
|
|
359
|
+
mapped.total_duration_seconds = WorkerDispatcher._seconds32(
|
|
360
|
+
retry.total_duration
|
|
361
|
+
)
|
|
362
|
+
return mapped
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _map_durability(durability: StepDurability) -> int:
|
|
366
|
+
return {
|
|
367
|
+
StepDurability.DEFAULT: pb.STEP_DURABILITY_UNSPECIFIED,
|
|
368
|
+
StepDurability.SYNC: pb.STEP_DURABILITY_SYNC,
|
|
369
|
+
StepDurability.ASYNC: pb.STEP_DURABILITY_ASYNC,
|
|
370
|
+
}[durability]
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _map_lock(lock: AttributeLock) -> str:
|
|
374
|
+
if lock.instance is None:
|
|
375
|
+
return lock.attribute.name
|
|
376
|
+
return Registry.physical_name(lock.attribute.name, lock.instance)
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _seconds32(duration: timedelta) -> int:
|
|
380
|
+
seconds = duration.total_seconds()
|
|
381
|
+
if seconds < 0 or not seconds.is_integer() or seconds > 2**31 - 1:
|
|
382
|
+
raise ValueError("duration must be whole seconds within int32")
|
|
383
|
+
return int(seconds)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class _ConditionMapper:
|
|
387
|
+
def __init__(self, flow: _RegisteredFlow) -> None:
|
|
388
|
+
self._flow = flow
|
|
389
|
+
self._ids: dict[int, str] = {}
|
|
390
|
+
self._used: set[str] = set()
|
|
391
|
+
self._next_id = 0
|
|
392
|
+
self.timers: list[pb.TimerCondition] = []
|
|
393
|
+
self.channels: list[pb.ChannelCondition] = []
|
|
394
|
+
|
|
395
|
+
def add_all(self, conditions: tuple[Condition, ...]) -> None:
|
|
396
|
+
if not conditions:
|
|
397
|
+
raise ValueError("Wait requires at least one Condition")
|
|
398
|
+
for condition in conditions:
|
|
399
|
+
self.add(condition)
|
|
400
|
+
|
|
401
|
+
def add(self, condition: Condition) -> str:
|
|
402
|
+
identity = id(condition)
|
|
403
|
+
existing = self._ids.get(identity)
|
|
404
|
+
if existing is not None:
|
|
405
|
+
return existing
|
|
406
|
+
condition_id = condition.condition_id
|
|
407
|
+
if condition_id is None:
|
|
408
|
+
condition_id = f"{_INTERNAL_CONDITION_PREFIX}{self._next_id}"
|
|
409
|
+
self._next_id += 1
|
|
410
|
+
if not condition_id or condition_id in self._used:
|
|
411
|
+
raise ValueError("duplicate or empty Condition ID")
|
|
412
|
+
self._used.add(condition_id)
|
|
413
|
+
if isinstance(condition, TimerCondition):
|
|
414
|
+
seconds = condition.duration.total_seconds()
|
|
415
|
+
if not seconds.is_integer():
|
|
416
|
+
raise ValueError("timer duration must use whole seconds")
|
|
417
|
+
self.timers.append(
|
|
418
|
+
pb.TimerCondition(
|
|
419
|
+
condition_id=condition_id,
|
|
420
|
+
duration_seconds=int(seconds),
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
elif isinstance(condition, ChannelCondition):
|
|
424
|
+
channel = condition.channel
|
|
425
|
+
if (
|
|
426
|
+
channel is None
|
|
427
|
+
or self._flow.persistence.get(channel.name) is not channel
|
|
428
|
+
):
|
|
429
|
+
raise ValueError(
|
|
430
|
+
f"Channel is not registered: {getattr(channel, 'name', '')}"
|
|
431
|
+
)
|
|
432
|
+
channel_name = channel.name
|
|
433
|
+
if isinstance(channel, ChannelMap):
|
|
434
|
+
if condition.instance is None:
|
|
435
|
+
raise ValueError("ChannelMap condition requires an instance")
|
|
436
|
+
channel_name = Registry.physical_name(channel.name, condition.instance)
|
|
437
|
+
elif condition.instance is not None:
|
|
438
|
+
raise ValueError("static Channel cannot use an instance")
|
|
439
|
+
mapped = pb.ChannelCondition(
|
|
440
|
+
condition_id=condition_id,
|
|
441
|
+
channel_name=channel_name,
|
|
442
|
+
)
|
|
443
|
+
if condition.at_least is not None:
|
|
444
|
+
mapped.at_least = condition.at_least
|
|
445
|
+
if condition.at_most is not None:
|
|
446
|
+
mapped.at_most = condition.at_most
|
|
447
|
+
self.channels.append(mapped)
|
|
448
|
+
else:
|
|
449
|
+
raise TypeError("unsupported Condition")
|
|
450
|
+
self._ids[identity] = condition_id
|
|
451
|
+
return condition_id
|
dex/_worker_service.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Super Durable Source License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Callable, TypeVar
|
|
13
|
+
|
|
14
|
+
import grpc
|
|
15
|
+
|
|
16
|
+
from dex._grpc_errors import abort_worker_error
|
|
17
|
+
from dex._worker_dispatcher import WorkerDispatcher
|
|
18
|
+
from dex.dexpb import dex_pb2 as pb
|
|
19
|
+
from dex.dexpb import dex_pb2_grpc
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
ResponseT = TypeVar("ResponseT")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WorkerService(dex_pb2_grpc.WorkerServiceServicer):
|
|
26
|
+
def __init__(self, dispatcher: WorkerDispatcher) -> None:
|
|
27
|
+
self._dispatcher = dispatcher
|
|
28
|
+
|
|
29
|
+
def InvokeWaitForMethod(
|
|
30
|
+
self,
|
|
31
|
+
request: pb.InvokeWaitForMethodRequest,
|
|
32
|
+
context: grpc.ServicerContext,
|
|
33
|
+
) -> pb.InvokeWaitForMethodResponse:
|
|
34
|
+
return self._invoke(context, lambda: self._dispatcher.invoke_wait_for(request))
|
|
35
|
+
|
|
36
|
+
def InvokeExecuteMethod(
|
|
37
|
+
self,
|
|
38
|
+
request: pb.InvokeExecuteMethodRequest,
|
|
39
|
+
context: grpc.ServicerContext,
|
|
40
|
+
) -> pb.InvokeExecuteMethodResponse:
|
|
41
|
+
return self._invoke(context, lambda: self._dispatcher.invoke_execute(request))
|
|
42
|
+
|
|
43
|
+
def InvokeWorkerRPC(
|
|
44
|
+
self,
|
|
45
|
+
request: pb.InvokeWorkerRPCRequest,
|
|
46
|
+
context: grpc.ServicerContext,
|
|
47
|
+
) -> pb.InvokeWorkerRPCResponse:
|
|
48
|
+
return self._invoke(context, lambda: self._dispatcher.invoke_rpc(request))
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _invoke(
|
|
52
|
+
context: grpc.ServicerContext,
|
|
53
|
+
invocation: Callable[[], ResponseT],
|
|
54
|
+
) -> ResponseT:
|
|
55
|
+
try:
|
|
56
|
+
return invocation()
|
|
57
|
+
except BaseException as error:
|
|
58
|
+
_LOGGER.exception("Python Worker invocation failed")
|
|
59
|
+
abort_worker_error(context, error)
|
|
60
|
+
raise RuntimeError("gRPC abort returned unexpectedly") from error
|
dex/attribute.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Legacy Materials in this file remain under their original licenses.
|
|
2
|
+
# See LEGACY_NOTICES.md.
|
|
3
|
+
|
|
4
|
+
# Modifications Copyright (c) 2026 Super Durable, Inc.
|
|
5
|
+
#
|
|
6
|
+
# Modifications after the Legacy Cutoff are licensed under the
|
|
7
|
+
# Super Durable Source License 1.0.
|
|
8
|
+
# Legacy Materials remain under their original licenses.
|
|
9
|
+
# See LICENSE and LEGACY_NOTICES.md.
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Generic, TypeVar, cast
|
|
16
|
+
|
|
17
|
+
from dex._utils import require_name
|
|
18
|
+
from dex.context import Context
|
|
19
|
+
|
|
20
|
+
ValueT = TypeVar("ValueT")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IndexType(Enum):
|
|
24
|
+
KEYWORD = "keyword"
|
|
25
|
+
FULL_TEXT = "full_text"
|
|
26
|
+
KEYWORD_ARRAY = "keyword_array"
|
|
27
|
+
INT = "int"
|
|
28
|
+
DOUBLE = "double"
|
|
29
|
+
BOOL = "bool"
|
|
30
|
+
DATETIME = "datetime"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class AttributeIndex:
|
|
35
|
+
type: IndexType
|
|
36
|
+
index_key: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Attribute(Generic[ValueT]):
|
|
41
|
+
name: str
|
|
42
|
+
value_type: type[ValueT]
|
|
43
|
+
index: AttributeIndex | None = None
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
require_name(self.name)
|
|
47
|
+
|
|
48
|
+
def get(self, context: Context) -> ValueT:
|
|
49
|
+
return context._get_attribute(self, None)
|
|
50
|
+
|
|
51
|
+
def set(self, context: Context, value: ValueT) -> None:
|
|
52
|
+
context._set_attribute(self, None, value)
|
|
53
|
+
|
|
54
|
+
def delete(self, context: Context) -> None:
|
|
55
|
+
context._delete_attribute(cast(Attribute[object], self), None)
|
|
56
|
+
|
|
57
|
+
def lock(self) -> AttributeLock:
|
|
58
|
+
return AttributeLock(self)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class AttributeMap(Generic[ValueT]):
|
|
63
|
+
name: str
|
|
64
|
+
value_type: type[ValueT]
|
|
65
|
+
index: AttributeIndex | None = None
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
require_name(self.name)
|
|
69
|
+
|
|
70
|
+
def get(self, context: Context, instance: str) -> ValueT:
|
|
71
|
+
return context._get_attribute(self, instance)
|
|
72
|
+
|
|
73
|
+
def set(self, context: Context, instance: str, value: ValueT) -> None:
|
|
74
|
+
context._set_attribute(self, instance, value)
|
|
75
|
+
|
|
76
|
+
def delete(self, context: Context, instance: str) -> None:
|
|
77
|
+
context._delete_attribute(cast(AttributeMap[object], self), instance)
|
|
78
|
+
|
|
79
|
+
def lock(self, instance: str) -> AttributeLock:
|
|
80
|
+
require_name(instance)
|
|
81
|
+
return AttributeLock(self, instance)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class AttributeLock:
|
|
86
|
+
attribute: Attribute[Any] | AttributeMap[Any]
|
|
87
|
+
instance: str | None = None
|
dex/blob_cache.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Super Durable Source License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from dex._native import NativeBlobCache
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class BlobCacheConfig:
|
|
17
|
+
directory: str
|
|
18
|
+
max_bytes: int
|
|
19
|
+
frequency_counters: int = 10_000
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if not self.directory:
|
|
23
|
+
raise ValueError("blob cache directory is required")
|
|
24
|
+
if self.max_bytes <= 0:
|
|
25
|
+
raise ValueError("blob cache max_bytes must be positive")
|
|
26
|
+
if self.frequency_counters < 0:
|
|
27
|
+
raise ValueError("blob cache frequency_counters must not be negative")
|
|
28
|
+
if self.frequency_counters == 0:
|
|
29
|
+
object.__setattr__(self, "frequency_counters", 10_000)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BlobCache(Protocol):
|
|
33
|
+
@property
|
|
34
|
+
def config(self) -> BlobCacheConfig: ...
|
|
35
|
+
|
|
36
|
+
def get(self, blob_id: str) -> bytes | None: ...
|
|
37
|
+
|
|
38
|
+
def put(self, blob_id: str, payload: bytes) -> bool: ...
|
|
39
|
+
|
|
40
|
+
def delete(self, blob_id: str) -> None: ...
|
|
41
|
+
|
|
42
|
+
def delete_all(self) -> None: ...
|
|
43
|
+
|
|
44
|
+
def close(self) -> None: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _RustBlobCache:
|
|
48
|
+
def __init__(self, config: BlobCacheConfig) -> None:
|
|
49
|
+
self.config = config
|
|
50
|
+
self._native = NativeBlobCache(
|
|
51
|
+
config.directory,
|
|
52
|
+
config.max_bytes,
|
|
53
|
+
config.frequency_counters,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def get(self, blob_id: str) -> bytes | None:
|
|
57
|
+
return self._native.get(blob_id)
|
|
58
|
+
|
|
59
|
+
def put(self, blob_id: str, payload: bytes) -> bool:
|
|
60
|
+
return self._native.put(blob_id, payload)
|
|
61
|
+
|
|
62
|
+
def delete(self, blob_id: str) -> None:
|
|
63
|
+
self._native.delete(blob_id)
|
|
64
|
+
|
|
65
|
+
def delete_all(self) -> None:
|
|
66
|
+
self._native.delete_all()
|
|
67
|
+
|
|
68
|
+
def close(self) -> None:
|
|
69
|
+
self._native.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def open_blob_cache(config: BlobCacheConfig) -> BlobCache:
|
|
73
|
+
return _RustBlobCache(config)
|