dex-python-sdk 0.1.4__tar.gz → 0.1.10__tar.gz

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 (62) hide show
  1. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/PKG-INFO +81 -5
  2. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/README.md +80 -4
  3. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/__init__.py +16 -8
  4. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_async_value_hydrator.py +4 -2
  5. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_async_worker_dispatcher.py +62 -3
  6. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_async_worker_service.py +44 -11
  7. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_invocation_context.py +31 -1
  8. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_value_hydrator.py +11 -2
  9. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_worker_dispatcher.py +254 -5
  10. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_worker_service.py +17 -3
  11. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/async_client.py +58 -53
  12. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/client.py +58 -53
  13. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/condition.py +21 -1
  14. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/context.py +13 -1
  15. dex_python_sdk-0.1.10/dex/dexpb/dex_pb2.py +441 -0
  16. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/dexpb/dex_pb2.pyi +212 -49
  17. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/dexpb/dex_pb2_grpc.py +3 -3
  18. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/flow.py +65 -2
  19. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/flow_info.py +2 -2
  20. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/flow_options.py +152 -12
  21. dex_python_sdk-0.1.10/dex/flow_result.py +215 -0
  22. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/runtime_errors.py +3 -44
  23. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/step.py +63 -0
  24. dex_python_sdk-0.1.10/dex/subflow.py +85 -0
  25. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/pyproject.toml +1 -1
  26. dex_python_sdk-0.1.4/dex/dexpb/dex_pb2.py +0 -409
  27. dex_python_sdk-0.1.4/dex/flow_result.py +0 -127
  28. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/LEGACY_NOTICES.md +0 -0
  29. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/LICENSE +0 -0
  30. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_grpc_errors.py +0 -0
  31. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_native.pyi +0 -0
  32. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_utils.py +0 -0
  33. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/_value_mapper.py +0 -0
  34. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/async_worker.py +0 -0
  35. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/attribute.py +0 -0
  36. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/blob_cache.py +0 -0
  37. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/channel.py +0 -0
  38. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/client_options.py +0 -0
  39. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/codec.py +0 -0
  40. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/dexpb/__init__.py +0 -0
  41. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/flow_config.py +0 -0
  42. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/py.typed +0 -0
  43. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/step_execution.py +0 -0
  44. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/timer.py +0 -0
  45. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/wait.py +0 -0
  46. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/worker.py +0 -0
  47. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/dex/worker_options.py +0 -0
  48. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/Cargo.lock +0 -0
  49. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/Cargo.toml +0 -0
  50. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  51. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  52. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  53. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  54. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  55. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  56. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  57. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  58. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  59. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  60. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  61. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  62. {dex_python_sdk-0.1.4 → dex_python_sdk-0.1.10}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dex-python-sdk
3
- Version: 0.1.4
3
+ Version: 0.1.10
4
4
  Requires-Dist: grpcio>=1.83.0
5
5
  Requires-Dist: grpcio-status>=1.83.0
6
6
  Requires-Dist: protobuf>=7.35.1
@@ -137,6 +137,65 @@ attempts, total duration, and 1-based attempt numbers. Fallback starts
137
137
  immediately; later regular retries continue the backoff sequence at the
138
138
  cumulative attempt.
139
139
 
140
+ ### Canceling Step executions
141
+
142
+ A successful Step can cancel queued or active executions while continuing with
143
+ its normal decision:
144
+
145
+ ```python
146
+ return (
147
+ dex.go_to(self.record_quote, quote)
148
+ .with_canceling_sibling_steps(self.carrier_a, self.carrier_b)
149
+ .with_canceling_steps(self.global_quote_timeout)
150
+ )
151
+ ```
152
+
153
+ `with_canceling_steps` selects every current execution of each registered Step
154
+ type. `with_canceling_sibling_steps` selects only executions with the same
155
+ `Context.from_step_execution_id` as the current execution. Decisions are
156
+ immutable; repeated calls form a union, and Flow-wide selection wins for the
157
+ same Step type. Unregistered selectors produce an invalid Step result.
158
+
159
+ Dex resolves one snapshot after the current execution succeeds. Completed,
160
+ already-canceled, and absent targets are no-ops. Next Steps created by the same
161
+ decision are outside the snapshot. Dex immediately applies the next or close
162
+ action; late decisions, writes, retries, and recovery Steps are discarded.
163
+
164
+ Set `StepOptions.heartbeat_timeout` on long-running regular Steps so
165
+ cancellation reaches the Worker promptly. It applies to `wait_for` and
166
+ `execute`; local activities ignore it, while an ASYNC fallback uses it. `None`
167
+ and zero disable heartbeats, and positive values must be whole seconds in the
168
+ signed int32 range. `AsyncWorker` cancels the handler's asyncio task. A handler
169
+ may catch `asyncio.CancelledError` for cleanup; synchronous CPU-bound handlers
170
+ may check `Context.is_cancellation_requested()` at natural boundaries.
171
+
172
+ `RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
173
+ RPCs do not support sibling selection because they have no Step execution
174
+ lineage.
175
+
176
+ ### Soft Flow timeout
177
+
178
+ Override `Flow.handle_timeout` to make a positive timeout use handler policy
179
+ by default. Both synchronous and async Workers support the hook:
180
+
181
+ ```python
182
+ class Orders(dex.Flow[str]):
183
+ async def handle_timeout(self, context: dex.Context) -> dex.StepDecision:
184
+ await notify_expiration(context)
185
+ return dex.force_complete("expired")
186
+
187
+ options = dex.StartFlowOptions(
188
+ timeout=timedelta(minutes=30),
189
+ timeout_policy=dex.FlowTimeoutPolicy.HANDLER,
190
+ )
191
+ ```
192
+
193
+ Register async hooks with `allow_async_handlers=True` and run them with
194
+ `AsyncWorker`. `FAIL` produces `FlowErrorType.FLOW_TIMEOUT` and permits Flow
195
+ retry; `CANCEL` cancels without retry. Continue-as-new preserves the deadline,
196
+ while retry and cron runs receive a fresh budget. A zero or absent timeout
197
+ disables the feature.
198
+
140
199
  `Registry` validates every Flow, Step, RPC signature, durable name, lock, and
141
200
  codec before Client or Worker startup. `Client` methods use these typed objects
142
201
  instead of raw Flow, Step, or RPC strings.
@@ -156,7 +215,7 @@ include buffered publishes, and omit empty instances. Keys are decoded and
156
215
  sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
157
216
 
158
217
  `Client.wait_for_flow` and `AsyncClient.wait_for_flow` return a
159
- `WaitForFlowResult` after hydrating every output-bearing completion. Use
218
+ `FlowResult` after hydrating every output-bearing completion. Use
160
219
  `single_output` only when the Flow contract produces exactly one output:
161
220
 
162
221
  ```python
@@ -171,13 +230,30 @@ for completion in result.completions:
171
230
  `completions` is an immutable tuple in server collection order. Parallel branch
172
231
  order is not deterministic, so select by `step_type` or `step_execution_id`.
173
232
  No-output Flows return an empty tuple; `single_output` raises `ValueError` for
174
- zero or multiple completions. `FlowUncompletedError.completions` uses the same
175
- hydrated model for partial outputs.
233
+ zero or multiple completions. Every terminal status returns a `FlowResult`; inspect
234
+ `status`, `error_type`, and `error_message` for unsuccessful completion.
235
+
236
+ SubFlows are normal, independently addressable Flows used as durable Conditions:
237
+
238
+ ```python
239
+ def wait_for(self, context: Context, input: ChargeInput) -> Wait:
240
+ return Wait.until(SubFlow.run(self.charge_flow, input))
241
+
242
+ def execute(self, context: Context, input: ChargeInput) -> StepDecision:
243
+ del input
244
+ receipt = SubFlow.get_condition_results(context).single_output(Receipt)
245
+ return graceful_complete(receipt)
246
+ ```
247
+
248
+ `SubFlow.get_flow_id(context, index=0)` remains available for a running `any_of`
249
+ loser. `SubFlowOptions` configures timing, timeout policy, retry, initial target
250
+ Attributes, Flow config, Condition ID, and reuse. Parent completion does not cancel an unfinished
251
+ SubFlow.
176
252
 
177
253
  ### Errors
178
254
 
179
255
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
180
- (`get_attribute`, `describe_flow`, `wait_for_flow`, and `reset_flow`) raise
256
+ (`get_attribute`, `describe_flow`, `wait_for_flow`, and `time_travel`) raise
181
257
  `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
182
258
  waits, config updates, and continue-as-new triggers raise
183
259
  `FlowNotActiveError` when no running Flow can accept the operation.
@@ -121,6 +121,65 @@ attempts, total duration, and 1-based attempt numbers. Fallback starts
121
121
  immediately; later regular retries continue the backoff sequence at the
122
122
  cumulative attempt.
123
123
 
124
+ ### Canceling Step executions
125
+
126
+ A successful Step can cancel queued or active executions while continuing with
127
+ its normal decision:
128
+
129
+ ```python
130
+ return (
131
+ dex.go_to(self.record_quote, quote)
132
+ .with_canceling_sibling_steps(self.carrier_a, self.carrier_b)
133
+ .with_canceling_steps(self.global_quote_timeout)
134
+ )
135
+ ```
136
+
137
+ `with_canceling_steps` selects every current execution of each registered Step
138
+ type. `with_canceling_sibling_steps` selects only executions with the same
139
+ `Context.from_step_execution_id` as the current execution. Decisions are
140
+ immutable; repeated calls form a union, and Flow-wide selection wins for the
141
+ same Step type. Unregistered selectors produce an invalid Step result.
142
+
143
+ Dex resolves one snapshot after the current execution succeeds. Completed,
144
+ already-canceled, and absent targets are no-ops. Next Steps created by the same
145
+ decision are outside the snapshot. Dex immediately applies the next or close
146
+ action; late decisions, writes, retries, and recovery Steps are discarded.
147
+
148
+ Set `StepOptions.heartbeat_timeout` on long-running regular Steps so
149
+ cancellation reaches the Worker promptly. It applies to `wait_for` and
150
+ `execute`; local activities ignore it, while an ASYNC fallback uses it. `None`
151
+ and zero disable heartbeats, and positive values must be whole seconds in the
152
+ signed int32 range. `AsyncWorker` cancels the handler's asyncio task. A handler
153
+ may catch `asyncio.CancelledError` for cleanup; synchronous CPU-bound handlers
154
+ may check `Context.is_cancellation_requested()` at natural boundaries.
155
+
156
+ `RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
157
+ RPCs do not support sibling selection because they have no Step execution
158
+ lineage.
159
+
160
+ ### Soft Flow timeout
161
+
162
+ Override `Flow.handle_timeout` to make a positive timeout use handler policy
163
+ by default. Both synchronous and async Workers support the hook:
164
+
165
+ ```python
166
+ class Orders(dex.Flow[str]):
167
+ async def handle_timeout(self, context: dex.Context) -> dex.StepDecision:
168
+ await notify_expiration(context)
169
+ return dex.force_complete("expired")
170
+
171
+ options = dex.StartFlowOptions(
172
+ timeout=timedelta(minutes=30),
173
+ timeout_policy=dex.FlowTimeoutPolicy.HANDLER,
174
+ )
175
+ ```
176
+
177
+ Register async hooks with `allow_async_handlers=True` and run them with
178
+ `AsyncWorker`. `FAIL` produces `FlowErrorType.FLOW_TIMEOUT` and permits Flow
179
+ retry; `CANCEL` cancels without retry. Continue-as-new preserves the deadline,
180
+ while retry and cron runs receive a fresh budget. A zero or absent timeout
181
+ disables the feature.
182
+
124
183
  `Registry` validates every Flow, Step, RPC signature, durable name, lock, and
125
184
  codec before Client or Worker startup. `Client` methods use these typed objects
126
185
  instead of raw Flow, Step, or RPC strings.
@@ -140,7 +199,7 @@ include buffered publishes, and omit empty instances. Keys are decoded and
140
199
  sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
141
200
 
142
201
  `Client.wait_for_flow` and `AsyncClient.wait_for_flow` return a
143
- `WaitForFlowResult` after hydrating every output-bearing completion. Use
202
+ `FlowResult` after hydrating every output-bearing completion. Use
144
203
  `single_output` only when the Flow contract produces exactly one output:
145
204
 
146
205
  ```python
@@ -155,13 +214,30 @@ for completion in result.completions:
155
214
  `completions` is an immutable tuple in server collection order. Parallel branch
156
215
  order is not deterministic, so select by `step_type` or `step_execution_id`.
157
216
  No-output Flows return an empty tuple; `single_output` raises `ValueError` for
158
- zero or multiple completions. `FlowUncompletedError.completions` uses the same
159
- hydrated model for partial outputs.
217
+ zero or multiple completions. Every terminal status returns a `FlowResult`; inspect
218
+ `status`, `error_type`, and `error_message` for unsuccessful completion.
219
+
220
+ SubFlows are normal, independently addressable Flows used as durable Conditions:
221
+
222
+ ```python
223
+ def wait_for(self, context: Context, input: ChargeInput) -> Wait:
224
+ return Wait.until(SubFlow.run(self.charge_flow, input))
225
+
226
+ def execute(self, context: Context, input: ChargeInput) -> StepDecision:
227
+ del input
228
+ receipt = SubFlow.get_condition_results(context).single_output(Receipt)
229
+ return graceful_complete(receipt)
230
+ ```
231
+
232
+ `SubFlow.get_flow_id(context, index=0)` remains available for a running `any_of`
233
+ loser. `SubFlowOptions` configures timing, timeout policy, retry, initial target
234
+ Attributes, Flow config, Condition ID, and reuse. Parent completion does not cancel an unfinished
235
+ SubFlow.
160
236
 
161
237
  ### Errors
162
238
 
163
239
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
164
- (`get_attribute`, `describe_flow`, `wait_for_flow`, and `reset_flow`) raise
240
+ (`get_attribute`, `describe_flow`, `wait_for_flow`, and `time_travel`) raise
165
241
  `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
166
242
  waits, config updates, and continue-as-new triggers raise
167
243
  `FlowNotActiveError` when no running Flow can accept the operation.
@@ -45,14 +45,18 @@ from dex.flow_info import (
45
45
  SearchFlowsPage,
46
46
  )
47
47
  from dex.flow_options import (
48
+ FlowTimeoutPolicy,
48
49
  IdReusePolicy,
49
- ResetFlowOptions,
50
- ResetType,
50
+ TimeTravelOptions,
51
+ TimeTravelStepMethod,
52
+ TimeTravelType,
51
53
  StartFlowOptions,
54
+ SubFlowOptions,
55
+ SubFlowReusePolicy,
52
56
  StopFlowOptions,
53
57
  StopType,
54
58
  )
55
- from dex.flow_result import StepCompletion, WaitForFlowResult
59
+ from dex.flow_result import FlowResult, StepCompletion
56
60
  from dex.runtime_errors import (
57
61
  DexServiceError,
58
62
  ErrorSubStatus,
@@ -61,7 +65,6 @@ from dex.runtime_errors import (
61
65
  FlowErrorType,
62
66
  FlowNotActiveError,
63
67
  FlowNotFoundError,
64
- FlowUncompletedError,
65
68
  InvalidStepResultError,
66
69
  LongPollTimeoutError,
67
70
  RpcLockConflictError,
@@ -87,6 +90,7 @@ from dex.step import (
87
90
  )
88
91
  from dex.step_execution import StepExecutionId, TimerId
89
92
  from dex.timer import Timer
93
+ from dex.subflow import SubFlow
90
94
  from dex.wait import Wait
91
95
  from dex.worker import Worker
92
96
  from dex.worker_options import WorkerOptions, WorkerTarget
@@ -122,10 +126,11 @@ __all__ = [
122
126
  "FlowDefinitionError",
123
127
  "FlowErrorType",
124
128
  "FlowInfo",
129
+ "FlowResult",
125
130
  "FlowNotActiveError",
126
131
  "FlowNotFoundError",
127
132
  "FlowStatus",
128
- "FlowUncompletedError",
133
+ "FlowTimeoutPolicy",
129
134
  "HealthInfo",
130
135
  "IdReusePolicy",
131
136
  "InvalidStepResultError",
@@ -136,13 +141,17 @@ __all__ = [
136
141
  "RPCResult",
137
142
  "RpcLockConflictError",
138
143
  "Registry",
139
- "ResetFlowOptions",
140
- "ResetType",
144
+ "TimeTravelOptions",
145
+ "TimeTravelStepMethod",
146
+ "TimeTravelType",
141
147
  "RetryPolicy",
142
148
  "SearchFlowEntry",
143
149
  "SearchFlowsPage",
144
150
  "StepCompletion",
145
151
  "StartFlowOptions",
152
+ "SubFlow",
153
+ "SubFlowOptions",
154
+ "SubFlowReusePolicy",
146
155
  "StepExecutionId",
147
156
  "StepDecision",
148
157
  "Step",
@@ -158,7 +167,6 @@ __all__ = [
158
167
  "ValueMappingError",
159
168
  "Wait",
160
169
  "WaitForFailurePolicy",
161
- "WaitForFlowResult",
162
170
  "WireKind",
163
171
  "Worker",
164
172
  "WorkerInvocationError",
@@ -88,13 +88,15 @@ class AsyncValueHydrator:
88
88
  ) -> pb.InvokeExecuteMethodRequest:
89
89
  result = pb.InvokeExecuteMethodRequest()
90
90
  result.CopyFrom(request)
91
- values = [request.step_input]
91
+ has_step_input = request.HasField("step_input")
92
+ values = [request.step_input] if has_step_input else []
92
93
  values.extend(entry.value for entry in request.attributes)
93
94
  values.extend(entry.value for entry in request.step_exe_locals)
94
95
  for channel_result in request.condition_results.channel_results:
95
96
  values.extend(channel_result.values)
96
97
  hydrated = iter(await self.hydrate_all(values))
97
- result.step_input.CopyFrom(next(hydrated))
98
+ if has_step_input:
99
+ result.step_input.CopyFrom(next(hydrated))
98
100
  for entry in result.attributes:
99
101
  entry.value.CopyFrom(next(hydrated))
100
102
  for entry in result.step_exe_locals:
@@ -9,14 +9,14 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  from inspect import isawaitable
12
- from typing import Any
12
+ from typing import Any, Callable
13
13
 
14
14
  from dex._async_value_hydrator import AsyncValueHydrator
15
15
  from dex._invocation_context import InvocationContext, InvocationMethod
16
16
  from dex._value_mapper import ValueMapper
17
- from dex._worker_dispatcher import WorkerDispatcher
17
+ from dex._worker_dispatcher import _TIMEOUT_HANDLER_STEP_TYPE, WorkerDispatcher
18
18
  from dex.dexpb import dex_pb2 as pb
19
- from dex.flow import RPCResult, Registry
19
+ from dex.flow import Registry, RPCResult
20
20
  from dex.runtime_errors import InvalidStepResultError, ValueMappingError
21
21
  from dex.step import StepDecision
22
22
  from dex.wait import Wait
@@ -36,6 +36,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
36
36
  async def invoke_wait_for( # type: ignore[override]
37
37
  self,
38
38
  original: pb.InvokeWaitForMethodRequest,
39
+ is_active: Callable[[], bool] | None = None,
39
40
  ) -> pb.InvokeWaitForMethodResponse:
40
41
  request = await self._async_hydrator.wait_for_request(original)
41
42
  flow = self._registry._flow_by_type(request.flow_type)
@@ -46,6 +47,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
46
47
  request.context,
47
48
  self._values,
48
49
  request.attributes,
50
+ is_active=is_active,
49
51
  )
50
52
  input = self._values.decode(request.step_input, step.input_codec)
51
53
  wait = step.step.wait_for(context, input)
@@ -72,9 +74,12 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
72
74
  async def invoke_execute( # type: ignore[override]
73
75
  self,
74
76
  original: pb.InvokeExecuteMethodRequest,
77
+ is_active: Callable[[], bool] | None = None,
75
78
  ) -> pb.InvokeExecuteMethodResponse:
76
79
  request = await self._async_hydrator.execute_request(original)
77
80
  flow = self._registry._flow_by_type(request.flow_type)
81
+ if request.step_type == _TIMEOUT_HANDLER_STEP_TYPE:
82
+ return await self._invoke_timeout_handler_async(request, flow)
78
83
  step = flow.step(request.step_type)
79
84
  condition_results = (
80
85
  request.condition_results if request.HasField("condition_results") else None
@@ -87,6 +92,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
87
92
  request.attributes,
88
93
  request.step_exe_locals,
89
94
  condition_results,
95
+ is_active=is_active,
90
96
  )
91
97
  input = self._values.decode(request.step_input, step.input_codec)
92
98
  decision: Any = step.step.execute(context, input)
@@ -107,9 +113,56 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
107
113
  flow.name, step.name, "execute", str(error)
108
114
  ) from error
109
115
 
116
+ async def _invoke_timeout_handler_async(
117
+ self,
118
+ request: pb.InvokeExecuteMethodRequest,
119
+ flow: Any,
120
+ ) -> pb.InvokeExecuteMethodResponse:
121
+ if request.HasField("step_input"):
122
+ raise InvalidStepResultError(
123
+ flow.name, _TIMEOUT_HANDLER_STEP_TYPE, "execute", "input must be absent"
124
+ )
125
+ if not flow.has_timeout_handler:
126
+ raise InvalidStepResultError(
127
+ flow.name,
128
+ _TIMEOUT_HANDLER_STEP_TYPE,
129
+ "execute",
130
+ "handler is not registered",
131
+ )
132
+ condition_results = (
133
+ request.condition_results if request.HasField("condition_results") else None
134
+ )
135
+ context = InvocationContext(
136
+ InvocationMethod.EXECUTE,
137
+ flow,
138
+ request.context,
139
+ self._values,
140
+ request.attributes,
141
+ request.step_exe_locals,
142
+ condition_results,
143
+ )
144
+ decision: Any = flow.flow.handle_timeout(context)
145
+ if isawaitable(decision):
146
+ decision = await decision
147
+ try:
148
+ if not isinstance(decision, StepDecision):
149
+ raise TypeError("handle_timeout must return StepDecision")
150
+ return pb.InvokeExecuteMethodResponse(
151
+ step_decision=self._map_decision(flow, decision),
152
+ upsert_attributes=list(context.attribute_writes.values()),
153
+ record_events=context.events,
154
+ upsert_step_exe_locals=list(context.local_writes.values()),
155
+ publish_to_channel=context.publications,
156
+ )
157
+ except (TypeError, ValueError) as error:
158
+ raise InvalidStepResultError(
159
+ flow.name, _TIMEOUT_HANDLER_STEP_TYPE, "execute", str(error)
160
+ ) from error
161
+
110
162
  async def invoke_rpc( # type: ignore[override]
111
163
  self,
112
164
  original: pb.InvokeWorkerRPCRequest,
165
+ is_active: Callable[[], bool] | None = None,
113
166
  ) -> pb.InvokeWorkerRPCResponse:
114
167
  request = await self._async_hydrator.rpc_request(original)
115
168
  flow = self._registry._flow_by_type(request.flow_type)
@@ -121,6 +174,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
121
174
  self._values,
122
175
  request.attributes,
123
176
  channel_infos=dict(request.channel_infos),
177
+ is_active=is_active,
124
178
  )
125
179
  arguments: list[object] = [context]
126
180
  if rpc.input_codec is not None:
@@ -144,6 +198,11 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
144
198
  response.step_decision.next_steps.extend(
145
199
  self._map_movements(flow, returned.next_steps)
146
200
  )
201
+ response.step_decision.cancel_step_types.extend(
202
+ self._map_cancellation_steps(flow, returned.canceling_steps)
203
+ )
204
+ if not returned.next_steps and not returned.canceling_steps:
205
+ response.ClearField("step_decision")
147
206
  elif returned is None and rpc.output_codec is None:
148
207
  response.output.CopyFrom(self._values.encode_dynamic(None))
149
208
  else:
@@ -9,8 +9,9 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  import logging
12
+ from asyncio import CancelledError, current_task
12
13
  from collections.abc import Awaitable, Callable
13
- from typing import TypeVar
14
+ from typing import Any, TypeVar
14
15
 
15
16
  import grpc
16
17
 
@@ -27,39 +28,71 @@ class AsyncWorkerService(dex_pb2_grpc.WorkerServiceServicer):
27
28
  def __init__(self, dispatcher: AsyncWorkerDispatcher) -> None:
28
29
  self._dispatcher = dispatcher
29
30
 
30
- async def InvokeWaitForMethod( # type: ignore[override]
31
+ async def InvokeWaitForMethod(
31
32
  self,
32
33
  request: pb.InvokeWaitForMethodRequest,
33
- context: grpc.aio.ServicerContext,
34
+ context: grpc.aio.ServicerContext[
35
+ pb.InvokeWaitForMethodRequest,
36
+ pb.InvokeWaitForMethodResponse,
37
+ ],
34
38
  ) -> pb.InvokeWaitForMethodResponse:
35
39
  return await self._invoke(
36
- context, lambda: self._dispatcher.invoke_wait_for(request)
40
+ context,
41
+ lambda: self._dispatcher.invoke_wait_for(
42
+ request, lambda: self._is_active(context)
43
+ ),
37
44
  )
38
45
 
39
- async def InvokeExecuteMethod( # type: ignore[override]
46
+ async def InvokeExecuteMethod(
40
47
  self,
41
48
  request: pb.InvokeExecuteMethodRequest,
42
- context: grpc.aio.ServicerContext,
49
+ context: grpc.aio.ServicerContext[
50
+ pb.InvokeExecuteMethodRequest,
51
+ pb.InvokeExecuteMethodResponse,
52
+ ],
43
53
  ) -> pb.InvokeExecuteMethodResponse:
44
54
  return await self._invoke(
45
- context, lambda: self._dispatcher.invoke_execute(request)
55
+ context,
56
+ lambda: self._dispatcher.invoke_execute(
57
+ request, lambda: self._is_active(context)
58
+ ),
46
59
  )
47
60
 
48
- async def InvokeWorkerRPC( # type: ignore[override]
61
+ async def InvokeWorkerRPC(
49
62
  self,
50
63
  request: pb.InvokeWorkerRPCRequest,
51
- context: grpc.aio.ServicerContext,
64
+ context: grpc.aio.ServicerContext[
65
+ pb.InvokeWorkerRPCRequest,
66
+ pb.InvokeWorkerRPCResponse,
67
+ ],
52
68
  ) -> pb.InvokeWorkerRPCResponse:
53
- return await self._invoke(context, lambda: self._dispatcher.invoke_rpc(request))
69
+ return await self._invoke(
70
+ context,
71
+ lambda: self._dispatcher.invoke_rpc(
72
+ request, lambda: self._is_active(context)
73
+ ),
74
+ )
75
+
76
+ @staticmethod
77
+ def _is_active(context: grpc.aio.ServicerContext[Any, Any]) -> bool:
78
+ task = current_task()
79
+ return not context.cancelled() and (task is None or task.cancelling() == 0)
54
80
 
55
81
  @staticmethod
56
82
  async def _invoke(
57
- context: grpc.aio.ServicerContext,
83
+ context: grpc.aio.ServicerContext[Any, Any],
58
84
  invocation: Callable[[], Awaitable[ResponseT]],
59
85
  ) -> ResponseT:
60
86
  try:
61
87
  return await invocation()
88
+ except CancelledError:
89
+ raise
62
90
  except BaseException as error:
91
+ if not AsyncWorkerService._is_active(context):
92
+ await context.abort(
93
+ grpc.StatusCode.CANCELLED,
94
+ "Python AsyncWorker invocation canceled",
95
+ )
63
96
  _LOGGER.exception("Python AsyncWorker invocation failed")
64
97
  await async_abort_worker_error(context, error)
65
98
  raise RuntimeError("gRPC abort returned unexpectedly") from error
@@ -9,7 +9,7 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  from enum import Enum
12
- from typing import Any, Sequence, TypeVar, cast
12
+ from typing import Any, Callable, Sequence, TypeVar, cast
13
13
  from urllib.parse import unquote
14
14
 
15
15
  from dex._utils import require_name
@@ -19,6 +19,7 @@ from dex.channel import Channel, ChannelMap
19
19
  from dex.codec import Codec
20
20
  from dex.dexpb import dex_pb2 as pb
21
21
  from dex.flow import Registry, _RegisteredFlow
22
+ from dex.flow_result import FlowResult, flow_result_from_proto
22
23
 
23
24
  ValueT = TypeVar("ValueT")
24
25
  _Definition = Attribute[Any] | AttributeMap[Any] | Channel[Any] | ChannelMap[Any]
@@ -41,6 +42,7 @@ class InvocationContext:
41
42
  locals: Sequence[pb.KV] = (),
42
43
  condition_results: pb.ConditionResults | None = None,
43
44
  channel_infos: dict[str, pb.ChannelInfo] | None = None,
45
+ is_active: Callable[[], bool] | None = None,
44
46
  ) -> None:
45
47
  self._method = method
46
48
  self._flow = flow
@@ -50,6 +52,7 @@ class InvocationContext:
50
52
  self._locals = self._map_values("step-execution local", locals)
51
53
  self._condition_results = condition_results
52
54
  self._channel_infos = dict(channel_infos or {})
55
+ self._is_active = is_active or _always_active
53
56
  self.attribute_writes: dict[str, pb.AttributeWrite] = {}
54
57
  self.local_writes: dict[str, pb.KV] = {}
55
58
  self.events: list[pb.KV] = []
@@ -96,6 +99,29 @@ class InvocationContext:
96
99
  and self._condition_results.wait_for_failed
97
100
  )
98
101
 
102
+ def sub_flow_result(self, index: int = 0) -> FlowResult:
103
+ if self._method is not InvocationMethod.EXECUTE:
104
+ raise ValueError("SubFlow results are available only during execute")
105
+ if (
106
+ self._condition_results is None
107
+ or index < 0
108
+ or index >= len(self._condition_results.sub_flow_results)
109
+ ):
110
+ raise ValueError(f"SubFlow result index is unavailable: {index}")
111
+ return flow_result_from_proto(
112
+ self._condition_results.sub_flow_results[index],
113
+ lambda value, output_type: self._values.decode(
114
+ value, self._values.codec(output_type)
115
+ ),
116
+ )
117
+
118
+ def sub_flow_id(self, index: int = 0) -> str:
119
+ self.sub_flow_result(index)
120
+ return f"SubFlow:{self.flow_id}-{self.step_execution_id}-{index}"
121
+
122
+ def is_cancellation_requested(self) -> bool:
123
+ return not self._is_active()
124
+
99
125
  def set_step_execution_local(self, key: str, value: object) -> None:
100
126
  require_name(key)
101
127
  self.local_writes[key] = pb.KV(
@@ -302,3 +328,7 @@ class InvocationContext:
302
328
  if value_type is float:
303
329
  return 0.0
304
330
  return None
331
+
332
+
333
+ def _always_active() -> bool:
334
+ return True
@@ -88,13 +88,19 @@ class ValueHydrator:
88
88
  ) -> pb.InvokeExecuteMethodRequest:
89
89
  result = pb.InvokeExecuteMethodRequest()
90
90
  result.CopyFrom(request)
91
- values = [request.step_input]
91
+ has_step_input = request.HasField("step_input")
92
+ values = [request.step_input] if has_step_input else []
92
93
  values.extend(entry.value for entry in request.attributes)
93
94
  values.extend(entry.value for entry in request.step_exe_locals)
94
95
  for channel_result in request.condition_results.channel_results:
95
96
  values.extend(channel_result.values)
97
+ for flow_result in request.condition_results.sub_flow_results:
98
+ values.extend(
99
+ completion.completed_step_output for completion in flow_result.results
100
+ )
96
101
  hydrated = iter(self.hydrate_all(values))
97
- result.step_input.CopyFrom(next(hydrated))
102
+ if has_step_input:
103
+ result.step_input.CopyFrom(next(hydrated))
98
104
  for entry in result.attributes:
99
105
  entry.value.CopyFrom(next(hydrated))
100
106
  for entry in result.step_exe_locals:
@@ -102,6 +108,9 @@ class ValueHydrator:
102
108
  for channel_result in result.condition_results.channel_results:
103
109
  for value in channel_result.values:
104
110
  value.CopyFrom(next(hydrated))
111
+ for flow_result in result.condition_results.sub_flow_results:
112
+ for completion in flow_result.results:
113
+ completion.completed_step_output.CopyFrom(next(hydrated))
105
114
  return result
106
115
 
107
116
  def rpc_request(