dex-python-sdk 0.2.5__tar.gz → 0.2.6__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.
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/PKG-INFO +69 -38
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/README.md +68 -37
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/__init__.py +6 -1
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_async_value_hydrator.py +15 -4
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_async_worker_dispatcher.py +122 -13
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_async_worker_service.py +33 -9
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_invocation_context.py +58 -37
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_value_hydrator.py +15 -4
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_worker_dispatcher.py +110 -27
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_worker_service.py +24 -4
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/async_client.py +8 -11
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/async_worker.py +0 -1
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/client.py +8 -11
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/context.py +57 -1
- dex_python_sdk-0.2.6/dex/dexpb/dex_pb2.py +459 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/dexpb/dex_pb2.pyi +48 -10
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/dexpb/dex_pb2_grpc.py +12 -12
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow.py +35 -9
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/step.py +84 -14
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/stream.py +36 -10
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/worker.py +0 -1
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/pyproject.toml +1 -1
- dex_python_sdk-0.2.5/dex/dexpb/dex_pb2.py +0 -451
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/LEGACY_NOTICES.md +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/LICENSE +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_grpc_errors.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_native.pyi +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_utils.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_value_mapper.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/attribute.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/blob_cache.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/channel.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/client_options.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/codec.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/condition.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/dexpb/__init__.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow_config.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow_info.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow_options.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow_result.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/py.typed +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/runtime_errors.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/step_execution.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/subflow.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/timer.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/wait.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/worker_options.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/Cargo.lock +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/Cargo.toml +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/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.2.
|
|
3
|
+
Version: 0.2.6
|
|
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
|
|
@@ -29,6 +29,7 @@ the shared Rust Core is used only for BlobCache.
|
|
|
29
29
|
|
|
30
30
|
```python
|
|
31
31
|
from datetime import timedelta
|
|
32
|
+
from typing import Generator
|
|
32
33
|
|
|
33
34
|
import dex
|
|
34
35
|
|
|
@@ -46,8 +47,9 @@ class Run(dex.Step[str]):
|
|
|
46
47
|
|
|
47
48
|
def execute(
|
|
48
49
|
self, context: dex.Context, input: str
|
|
49
|
-
) -> dex.StepDecision:
|
|
50
|
-
progress.write(context, "running")
|
|
50
|
+
) -> Generator[dex.StepOutput, None, dex.StepDecision]:
|
|
51
|
+
yield progress.write(context, "running")
|
|
52
|
+
yield dex.heartbeat({"phase": "running"})
|
|
51
53
|
return dex.graceful_complete(input)
|
|
52
54
|
|
|
53
55
|
class CounterFlow(dex.Flow[str]):
|
|
@@ -78,23 +80,6 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
|
|
|
78
80
|
`PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
|
|
79
81
|
partitions them by definition type.
|
|
80
82
|
|
|
81
|
-
Streams provide best-effort resumable progress messages. Their approximate byte
|
|
82
|
-
budget is shared by all instances of the owning Flow type. Client keys cannot
|
|
83
|
-
contain `#`; Step writes generate `runID#stepExecutionID` and allow one write per
|
|
84
|
-
Stream per invocation.
|
|
85
|
-
|
|
86
|
-
```python
|
|
87
|
-
client.write_stream(flow_id, progress, "frontend/1", "starting")
|
|
88
|
-
message = client.read_stream(
|
|
89
|
-
flow_id, progress, resume_token, timeout=timedelta(seconds=30)
|
|
90
|
-
)
|
|
91
|
-
resume_token = message.resume_token
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Async Step handlers await **Stream.write**, and **AsyncClient** exposes matching
|
|
95
|
-
async methods. Reads return the decoded value, resume token, creation time, and
|
|
96
|
-
idempotency key.
|
|
97
|
-
|
|
98
83
|
`Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
|
|
99
84
|
Dex Server before opening their listener. Existing indexes return immediately;
|
|
100
85
|
failure or the default two-minute deadline aborts startup. An indexed
|
|
@@ -144,9 +129,10 @@ Applications implement two generic interfaces from [`dex`](dex/):
|
|
|
144
129
|
binds the Flow input to the starting Step input. Use `StepList.empty()` when
|
|
145
130
|
a Flow has no Steps.
|
|
146
131
|
- `Step[INPUT]` implements `execute` and optionally `wait_for`. The default
|
|
147
|
-
Worker
|
|
148
|
-
`
|
|
149
|
-
`
|
|
132
|
+
Worker accepts ordinary synchronous handlers and generator handlers. A generator
|
|
133
|
+
yields `StepOutput` progress frames and returns its final `Wait` or `StepDecision`.
|
|
134
|
+
With `AsyncWorker` and `Registry(..., allow_async_handlers=True)`, Step
|
|
135
|
+
coroutines use `AsyncContext`; async RPCs keep `Context`.
|
|
150
136
|
|
|
151
137
|
`StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
|
|
152
138
|
two handler calls. Timer and channel conditions determine how long a Step waits.
|
|
@@ -157,6 +143,58 @@ attempts, total duration, and 1-based attempt numbers. Fallback starts
|
|
|
157
143
|
immediately; later regular retries continue the backoff sequence at the
|
|
158
144
|
cumulative attempt.
|
|
159
145
|
|
|
146
|
+
The default Step durability is synchronous. A Flow configuration can select
|
|
147
|
+
asynchronous durability, and a Step method override has highest precedence. The
|
|
148
|
+
default retry total duration is four hours. Regular attempts default to a two-hour
|
|
149
|
+
method timeout and one-minute heartbeat timeout; an explicit heartbeat timeout must
|
|
150
|
+
meet the server minimum, which defaults to ten seconds. Asynchronous durability
|
|
151
|
+
first allows at most three local attempts in seven seconds. The local phase ignores
|
|
152
|
+
method timeouts and heartbeat frames before falling back to a regular activity.
|
|
153
|
+
|
|
154
|
+
### Step progress and heartbeat recovery
|
|
155
|
+
|
|
156
|
+
A synchronous handler yields every heartbeat and Stream write. The generator return
|
|
157
|
+
value is the only final result:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
def execute(
|
|
161
|
+
self, context: dex.Context, input: str
|
|
162
|
+
) -> Generator[dex.StepOutput, None, dex.StepDecision]:
|
|
163
|
+
yield dex.heartbeat({"offset": 10})
|
|
164
|
+
yield progress.write(context, "processed 10 items")
|
|
165
|
+
return dex.graceful_complete(input)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
An asynchronous handler keeps its normal coroutine return. Stream writes enqueue
|
|
169
|
+
without waiting for Stream Store acknowledgement; heartbeat waits only for the
|
|
170
|
+
Worker output queue:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
async def execute(
|
|
174
|
+
self, context: dex.AsyncContext, input: str
|
|
175
|
+
) -> dex.StepDecision:
|
|
176
|
+
progress.write(context, "started")
|
|
177
|
+
await context.heartbeat({"offset": 10})
|
|
178
|
+
return dex.graceful_complete(input)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
|
|
182
|
+
details. Passing Python `None` persists a present null Value. On a later regular
|
|
183
|
+
attempt, use `context.has_last_heartbeat_value()` before decoding with
|
|
184
|
+
`context.get_last_heartbeat_value(ExpectedType)`. A Stream frame is also an implicit
|
|
185
|
+
heartbeat, but it preserves the latest explicit heartbeat state.
|
|
186
|
+
|
|
187
|
+
A Step may write the same Stream any number of times. Dex assigns
|
|
188
|
+
`#<stepExecutionID>` as each Step message's `StreamMessage.source`. Client writes
|
|
189
|
+
provide their own non-empty source; duplicate sources and `#` are allowed and every
|
|
190
|
+
write appends:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
client.write_stream(flow_id, progress, "frontend#preview", "rendering")
|
|
194
|
+
message = client.read_stream(flow_id, progress)
|
|
195
|
+
print(message.source)
|
|
196
|
+
```
|
|
197
|
+
|
|
160
198
|
### Canceling Step executions
|
|
161
199
|
|
|
162
200
|
A successful Step can cancel queued or active executions while continuing with
|
|
@@ -181,14 +219,6 @@ already-canceled, and absent targets are no-ops. Next Steps created by the same
|
|
|
181
219
|
decision are outside the snapshot. Dex immediately applies the next or close
|
|
182
220
|
action; late decisions, writes, retries, and recovery Steps are discarded.
|
|
183
221
|
|
|
184
|
-
Set `StepOptions.heartbeat_timeout` on long-running regular Steps so
|
|
185
|
-
cancellation reaches the Worker promptly. It applies to `wait_for` and
|
|
186
|
-
`execute`; local activities ignore it, while an ASYNC fallback uses it. `None`
|
|
187
|
-
and zero disable heartbeats, and positive values must be whole seconds in the
|
|
188
|
-
signed int32 range. `AsyncWorker` cancels the handler's asyncio task. A handler
|
|
189
|
-
may catch `asyncio.CancelledError` for cleanup; synchronous CPU-bound handlers
|
|
190
|
-
may check `Context.is_cancellation_requested()` at natural boundaries.
|
|
191
|
-
|
|
192
222
|
`RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
|
|
193
223
|
RPCs do not support sibling selection because they have no Step execution
|
|
194
224
|
lineage.
|
|
@@ -298,14 +328,14 @@ serialization, and invalid handler returns use `FlowDefinitionError`,
|
|
|
298
328
|
### Sync vs asyncio
|
|
299
329
|
|
|
300
330
|
- **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
|
|
301
|
-
Worker.
|
|
302
|
-
|
|
331
|
+
Worker. A progress generator cooperatively hands each yielded frame to gRPC;
|
|
332
|
+
`Stream.write` must therefore be yielded. Blocking Client calls inside
|
|
333
|
+
`Step.execute` are safe while other pool threads remain available.
|
|
303
334
|
- **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
|
|
304
335
|
`Registry(..., allow_async_handlers=True)` when Steps/RPCs are coroutines.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
sync Worker dispatcher rejects awaitable return values).
|
|
336
|
+
Step coroutines annotate `AsyncContext`, call `Stream.write` without `await`,
|
|
337
|
+
and await `context.heartbeat`. Inside async `execute`, inject `AsyncClient` —
|
|
338
|
+
do not call sync `Client` on the Worker event loop. Async generators are rejected.
|
|
309
339
|
|
|
310
340
|
Integration scenarios live under
|
|
311
341
|
[`tests/integ`](tests/integ/README.md). They exercise the same workflows,
|
|
@@ -318,7 +348,8 @@ The strongly typed contracts, registry, synchronous Client/Worker, optional
|
|
|
318
348
|
`AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
|
|
319
349
|
implemented. Python owns its gRPC transport; the native bridge is limited to
|
|
320
350
|
the shared BlobCache. Design notes:
|
|
321
|
-
[`
|
|
351
|
+
[`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
|
|
352
|
+
[`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
|
|
322
353
|
|
|
323
354
|
## Running Dex locally
|
|
324
355
|
|
|
@@ -13,6 +13,7 @@ the shared Rust Core is used only for BlobCache.
|
|
|
13
13
|
|
|
14
14
|
```python
|
|
15
15
|
from datetime import timedelta
|
|
16
|
+
from typing import Generator
|
|
16
17
|
|
|
17
18
|
import dex
|
|
18
19
|
|
|
@@ -30,8 +31,9 @@ class Run(dex.Step[str]):
|
|
|
30
31
|
|
|
31
32
|
def execute(
|
|
32
33
|
self, context: dex.Context, input: str
|
|
33
|
-
) -> dex.StepDecision:
|
|
34
|
-
progress.write(context, "running")
|
|
34
|
+
) -> Generator[dex.StepOutput, None, dex.StepDecision]:
|
|
35
|
+
yield progress.write(context, "running")
|
|
36
|
+
yield dex.heartbeat({"phase": "running"})
|
|
35
37
|
return dex.graceful_complete(input)
|
|
36
38
|
|
|
37
39
|
class CounterFlow(dex.Flow[str]):
|
|
@@ -62,23 +64,6 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
|
|
|
62
64
|
`PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
|
|
63
65
|
partitions them by definition type.
|
|
64
66
|
|
|
65
|
-
Streams provide best-effort resumable progress messages. Their approximate byte
|
|
66
|
-
budget is shared by all instances of the owning Flow type. Client keys cannot
|
|
67
|
-
contain `#`; Step writes generate `runID#stepExecutionID` and allow one write per
|
|
68
|
-
Stream per invocation.
|
|
69
|
-
|
|
70
|
-
```python
|
|
71
|
-
client.write_stream(flow_id, progress, "frontend/1", "starting")
|
|
72
|
-
message = client.read_stream(
|
|
73
|
-
flow_id, progress, resume_token, timeout=timedelta(seconds=30)
|
|
74
|
-
)
|
|
75
|
-
resume_token = message.resume_token
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Async Step handlers await **Stream.write**, and **AsyncClient** exposes matching
|
|
79
|
-
async methods. Reads return the decoded value, resume token, creation time, and
|
|
80
|
-
idempotency key.
|
|
81
|
-
|
|
82
67
|
`Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
|
|
83
68
|
Dex Server before opening their listener. Existing indexes return immediately;
|
|
84
69
|
failure or the default two-minute deadline aborts startup. An indexed
|
|
@@ -128,9 +113,10 @@ Applications implement two generic interfaces from [`dex`](dex/):
|
|
|
128
113
|
binds the Flow input to the starting Step input. Use `StepList.empty()` when
|
|
129
114
|
a Flow has no Steps.
|
|
130
115
|
- `Step[INPUT]` implements `execute` and optionally `wait_for`. The default
|
|
131
|
-
Worker
|
|
132
|
-
`
|
|
133
|
-
`
|
|
116
|
+
Worker accepts ordinary synchronous handlers and generator handlers. A generator
|
|
117
|
+
yields `StepOutput` progress frames and returns its final `Wait` or `StepDecision`.
|
|
118
|
+
With `AsyncWorker` and `Registry(..., allow_async_handlers=True)`, Step
|
|
119
|
+
coroutines use `AsyncContext`; async RPCs keep `Context`.
|
|
134
120
|
|
|
135
121
|
`StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
|
|
136
122
|
two handler calls. Timer and channel conditions determine how long a Step waits.
|
|
@@ -141,6 +127,58 @@ attempts, total duration, and 1-based attempt numbers. Fallback starts
|
|
|
141
127
|
immediately; later regular retries continue the backoff sequence at the
|
|
142
128
|
cumulative attempt.
|
|
143
129
|
|
|
130
|
+
The default Step durability is synchronous. A Flow configuration can select
|
|
131
|
+
asynchronous durability, and a Step method override has highest precedence. The
|
|
132
|
+
default retry total duration is four hours. Regular attempts default to a two-hour
|
|
133
|
+
method timeout and one-minute heartbeat timeout; an explicit heartbeat timeout must
|
|
134
|
+
meet the server minimum, which defaults to ten seconds. Asynchronous durability
|
|
135
|
+
first allows at most three local attempts in seven seconds. The local phase ignores
|
|
136
|
+
method timeouts and heartbeat frames before falling back to a regular activity.
|
|
137
|
+
|
|
138
|
+
### Step progress and heartbeat recovery
|
|
139
|
+
|
|
140
|
+
A synchronous handler yields every heartbeat and Stream write. The generator return
|
|
141
|
+
value is the only final result:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
def execute(
|
|
145
|
+
self, context: dex.Context, input: str
|
|
146
|
+
) -> Generator[dex.StepOutput, None, dex.StepDecision]:
|
|
147
|
+
yield dex.heartbeat({"offset": 10})
|
|
148
|
+
yield progress.write(context, "processed 10 items")
|
|
149
|
+
return dex.graceful_complete(input)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
An asynchronous handler keeps its normal coroutine return. Stream writes enqueue
|
|
153
|
+
without waiting for Stream Store acknowledgement; heartbeat waits only for the
|
|
154
|
+
Worker output queue:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
async def execute(
|
|
158
|
+
self, context: dex.AsyncContext, input: str
|
|
159
|
+
) -> dex.StepDecision:
|
|
160
|
+
progress.write(context, "started")
|
|
161
|
+
await context.heartbeat({"offset": 10})
|
|
162
|
+
return dex.graceful_complete(input)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
|
|
166
|
+
details. Passing Python `None` persists a present null Value. On a later regular
|
|
167
|
+
attempt, use `context.has_last_heartbeat_value()` before decoding with
|
|
168
|
+
`context.get_last_heartbeat_value(ExpectedType)`. A Stream frame is also an implicit
|
|
169
|
+
heartbeat, but it preserves the latest explicit heartbeat state.
|
|
170
|
+
|
|
171
|
+
A Step may write the same Stream any number of times. Dex assigns
|
|
172
|
+
`#<stepExecutionID>` as each Step message's `StreamMessage.source`. Client writes
|
|
173
|
+
provide their own non-empty source; duplicate sources and `#` are allowed and every
|
|
174
|
+
write appends:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
client.write_stream(flow_id, progress, "frontend#preview", "rendering")
|
|
178
|
+
message = client.read_stream(flow_id, progress)
|
|
179
|
+
print(message.source)
|
|
180
|
+
```
|
|
181
|
+
|
|
144
182
|
### Canceling Step executions
|
|
145
183
|
|
|
146
184
|
A successful Step can cancel queued or active executions while continuing with
|
|
@@ -165,14 +203,6 @@ already-canceled, and absent targets are no-ops. Next Steps created by the same
|
|
|
165
203
|
decision are outside the snapshot. Dex immediately applies the next or close
|
|
166
204
|
action; late decisions, writes, retries, and recovery Steps are discarded.
|
|
167
205
|
|
|
168
|
-
Set `StepOptions.heartbeat_timeout` on long-running regular Steps so
|
|
169
|
-
cancellation reaches the Worker promptly. It applies to `wait_for` and
|
|
170
|
-
`execute`; local activities ignore it, while an ASYNC fallback uses it. `None`
|
|
171
|
-
and zero disable heartbeats, and positive values must be whole seconds in the
|
|
172
|
-
signed int32 range. `AsyncWorker` cancels the handler's asyncio task. A handler
|
|
173
|
-
may catch `asyncio.CancelledError` for cleanup; synchronous CPU-bound handlers
|
|
174
|
-
may check `Context.is_cancellation_requested()` at natural boundaries.
|
|
175
|
-
|
|
176
206
|
`RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
|
|
177
207
|
RPCs do not support sibling selection because they have no Step execution
|
|
178
208
|
lineage.
|
|
@@ -282,14 +312,14 @@ serialization, and invalid handler returns use `FlowDefinitionError`,
|
|
|
282
312
|
### Sync vs asyncio
|
|
283
313
|
|
|
284
314
|
- **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
|
|
285
|
-
Worker.
|
|
286
|
-
|
|
315
|
+
Worker. A progress generator cooperatively hands each yielded frame to gRPC;
|
|
316
|
+
`Stream.write` must therefore be yielded. Blocking Client calls inside
|
|
317
|
+
`Step.execute` are safe while other pool threads remain available.
|
|
287
318
|
- **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
|
|
288
319
|
`Registry(..., allow_async_handlers=True)` when Steps/RPCs are coroutines.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
sync Worker dispatcher rejects awaitable return values).
|
|
320
|
+
Step coroutines annotate `AsyncContext`, call `Stream.write` without `await`,
|
|
321
|
+
and await `context.heartbeat`. Inside async `execute`, inject `AsyncClient` —
|
|
322
|
+
do not call sync `Client` on the Worker event loop. Async generators are rejected.
|
|
293
323
|
|
|
294
324
|
Integration scenarios live under
|
|
295
325
|
[`tests/integ`](tests/integ/README.md). They exercise the same workflows,
|
|
@@ -302,7 +332,8 @@ The strongly typed contracts, registry, synchronous Client/Worker, optional
|
|
|
302
332
|
`AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
|
|
303
333
|
implemented. Python owns its gRPC transport; the native bridge is limited to
|
|
304
334
|
the shared BlobCache. Design notes:
|
|
305
|
-
[`
|
|
335
|
+
[`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
|
|
336
|
+
[`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
|
|
306
337
|
|
|
307
338
|
## Running Dex locally
|
|
308
339
|
|
|
@@ -35,7 +35,7 @@ from dex.codec import (
|
|
|
35
35
|
WireKind,
|
|
36
36
|
)
|
|
37
37
|
from dex.condition import ConditionCombination
|
|
38
|
-
from dex.context import Context
|
|
38
|
+
from dex.context import AsyncContext, Context
|
|
39
39
|
from dex.flow import Flow, PersistenceSchema, Registry, RPCResult, rpc
|
|
40
40
|
from dex.flow_config import ActiveStepSearchMode, FlowConfig
|
|
41
41
|
from dex.flow_info import (
|
|
@@ -80,6 +80,7 @@ from dex.step import (
|
|
|
80
80
|
StepList,
|
|
81
81
|
StepMovement,
|
|
82
82
|
StepOptions,
|
|
83
|
+
StepOutput,
|
|
83
84
|
WaitForFailurePolicy,
|
|
84
85
|
dead_end,
|
|
85
86
|
force_complete,
|
|
@@ -88,6 +89,7 @@ from dex.step import (
|
|
|
88
89
|
go_to,
|
|
89
90
|
go_to_many,
|
|
90
91
|
graceful_complete,
|
|
92
|
+
heartbeat,
|
|
91
93
|
)
|
|
92
94
|
from dex.step_execution import StepExecutionId, TimerId
|
|
93
95
|
from dex.stream import Stream, StreamMessage
|
|
@@ -108,6 +110,7 @@ __all__ = [
|
|
|
108
110
|
"AttributeIndex",
|
|
109
111
|
"AttributeLock",
|
|
110
112
|
"AttributeMap",
|
|
113
|
+
"AsyncContext",
|
|
111
114
|
"AsyncClient",
|
|
112
115
|
"AsyncWorker",
|
|
113
116
|
"BlobCache",
|
|
@@ -161,6 +164,7 @@ __all__ = [
|
|
|
161
164
|
"StepList",
|
|
162
165
|
"StepDurability",
|
|
163
166
|
"StepMovement",
|
|
167
|
+
"StepOutput",
|
|
164
168
|
"StepOptions",
|
|
165
169
|
"Stream",
|
|
166
170
|
"StreamMessage",
|
|
@@ -182,6 +186,7 @@ __all__ = [
|
|
|
182
186
|
"force_complete_if_channels_empty",
|
|
183
187
|
"force_fail",
|
|
184
188
|
"graceful_complete",
|
|
189
|
+
"heartbeat",
|
|
185
190
|
"go_to",
|
|
186
191
|
"go_to_many",
|
|
187
192
|
"open_blob_cache",
|
|
@@ -75,10 +75,16 @@ class AsyncValueHydrator:
|
|
|
75
75
|
) -> pb.InvokeWaitForMethodRequest:
|
|
76
76
|
result = pb.InvokeWaitForMethodRequest()
|
|
77
77
|
result.CopyFrom(request)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
has_heartbeat = request.context.HasField("last_heartbeat_value")
|
|
79
|
+
values = [request.step_input]
|
|
80
|
+
if has_heartbeat:
|
|
81
|
+
values.append(request.context.last_heartbeat_value)
|
|
82
|
+
values.extend(entry.value for entry in request.attributes)
|
|
83
|
+
hydrated = iter(await self.hydrate_all(values))
|
|
84
|
+
result.step_input.CopyFrom(next(hydrated))
|
|
85
|
+
if has_heartbeat:
|
|
86
|
+
result.context.last_heartbeat_value.CopyFrom(next(hydrated))
|
|
87
|
+
for entry, value in zip(result.attributes, hydrated):
|
|
82
88
|
entry.value.CopyFrom(value)
|
|
83
89
|
return result
|
|
84
90
|
|
|
@@ -89,7 +95,10 @@ class AsyncValueHydrator:
|
|
|
89
95
|
result = pb.InvokeExecuteMethodRequest()
|
|
90
96
|
result.CopyFrom(request)
|
|
91
97
|
has_step_input = request.HasField("step_input")
|
|
98
|
+
has_heartbeat = request.context.HasField("last_heartbeat_value")
|
|
92
99
|
values = [request.step_input] if has_step_input else []
|
|
100
|
+
if has_heartbeat:
|
|
101
|
+
values.append(request.context.last_heartbeat_value)
|
|
93
102
|
values.extend(entry.value for entry in request.attributes)
|
|
94
103
|
values.extend(entry.value for entry in request.step_exe_locals)
|
|
95
104
|
for channel_result in request.condition_results.channel_results:
|
|
@@ -97,6 +106,8 @@ class AsyncValueHydrator:
|
|
|
97
106
|
hydrated = iter(await self.hydrate_all(values))
|
|
98
107
|
if has_step_input:
|
|
99
108
|
result.step_input.CopyFrom(next(hydrated))
|
|
109
|
+
if has_heartbeat:
|
|
110
|
+
result.context.last_heartbeat_value.CopyFrom(next(hydrated))
|
|
100
111
|
for entry in result.attributes:
|
|
101
112
|
entry.value.CopyFrom(next(hydrated))
|
|
102
113
|
for entry in result.step_exe_locals:
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import AsyncGenerator, AsyncIterator
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from inspect import isawaitable, isgenerator
|
|
12
15
|
from typing import Any, Callable
|
|
13
16
|
|
|
14
17
|
from dex._async_value_hydrator import AsyncValueHydrator
|
|
@@ -18,27 +21,64 @@ from dex._worker_dispatcher import _TIMEOUT_HANDLER_STEP_TYPE, WorkerDispatcher
|
|
|
18
21
|
from dex.dexpb import dex_pb2 as pb
|
|
19
22
|
from dex.flow import Registry, RPCResult
|
|
20
23
|
from dex.runtime_errors import InvalidStepResultError, ValueMappingError
|
|
21
|
-
from dex.step import StepDecision
|
|
24
|
+
from dex.step import StepDecision, StepOutput
|
|
22
25
|
from dex.wait import Wait
|
|
23
26
|
|
|
24
27
|
|
|
28
|
+
class _AsyncStepOutputEmitter:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.queue: asyncio.Queue[StepOutput] = asyncio.Queue()
|
|
31
|
+
self._is_closed = False
|
|
32
|
+
|
|
33
|
+
def emit_nowait(self, output: StepOutput) -> None:
|
|
34
|
+
if self._is_closed:
|
|
35
|
+
return
|
|
36
|
+
self.queue.put_nowait(output)
|
|
37
|
+
|
|
38
|
+
async def emit(self, output: StepOutput) -> None:
|
|
39
|
+
if self._is_closed:
|
|
40
|
+
raise asyncio.CancelledError
|
|
41
|
+
self.queue.put_nowait(output)
|
|
42
|
+
await asyncio.sleep(0)
|
|
43
|
+
if self._is_closed:
|
|
44
|
+
raise asyncio.CancelledError
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self._is_closed = True
|
|
48
|
+
|
|
49
|
+
|
|
25
50
|
class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
26
51
|
def __init__(
|
|
27
52
|
self,
|
|
28
53
|
registry: Registry,
|
|
29
54
|
values: ValueMapper,
|
|
30
55
|
hydrator: AsyncValueHydrator,
|
|
31
|
-
stream_writer: Callable[[pb.WriteStreamRequest], Any],
|
|
32
56
|
) -> None:
|
|
33
57
|
self._registry = registry
|
|
34
58
|
self._values = values
|
|
35
59
|
self._async_hydrator = hydrator
|
|
36
|
-
self._stream_writer = stream_writer
|
|
37
60
|
|
|
38
61
|
async def invoke_wait_for( # type: ignore[override]
|
|
39
62
|
self,
|
|
40
63
|
original: pb.InvokeWaitForMethodRequest,
|
|
41
64
|
is_active: Callable[[], bool] | None = None,
|
|
65
|
+
) -> AsyncGenerator[pb.InvokeWaitForMethodOutput, None]:
|
|
66
|
+
emitter = _AsyncStepOutputEmitter()
|
|
67
|
+
handler = asyncio.create_task(
|
|
68
|
+
self._invoke_wait_for_result(original, emitter, is_active)
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
async for output in self._drain_outputs(emitter, handler):
|
|
72
|
+
yield self._map_wait_for_output(output)
|
|
73
|
+
yield pb.InvokeWaitForMethodOutput(result=await handler)
|
|
74
|
+
finally:
|
|
75
|
+
await self._close_invocation(emitter, handler)
|
|
76
|
+
|
|
77
|
+
async def _invoke_wait_for_result(
|
|
78
|
+
self,
|
|
79
|
+
original: pb.InvokeWaitForMethodRequest,
|
|
80
|
+
emitter: _AsyncStepOutputEmitter,
|
|
81
|
+
is_active: Callable[[], bool] | None,
|
|
42
82
|
) -> pb.InvokeWaitForMethodResponse:
|
|
43
83
|
request = await self._async_hydrator.wait_for_request(original)
|
|
44
84
|
flow = self._registry._flow_by_type(request.flow_type)
|
|
@@ -48,12 +88,20 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
48
88
|
flow,
|
|
49
89
|
request.context,
|
|
50
90
|
self._values,
|
|
51
|
-
self._stream_writer,
|
|
52
91
|
request.attributes,
|
|
53
92
|
is_active=is_active,
|
|
93
|
+
output_emitter=emitter,
|
|
54
94
|
)
|
|
55
95
|
input = self._values.decode(request.step_input, step.input_codec)
|
|
56
96
|
wait = step.step.wait_for(context, input)
|
|
97
|
+
if isgenerator(wait):
|
|
98
|
+
wait.close()
|
|
99
|
+
raise InvalidStepResultError(
|
|
100
|
+
flow.name,
|
|
101
|
+
step.name,
|
|
102
|
+
"wait_for",
|
|
103
|
+
"synchronous generators require Worker",
|
|
104
|
+
)
|
|
57
105
|
if isawaitable(wait):
|
|
58
106
|
wait = await wait
|
|
59
107
|
try:
|
|
@@ -78,11 +126,30 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
78
126
|
self,
|
|
79
127
|
original: pb.InvokeExecuteMethodRequest,
|
|
80
128
|
is_active: Callable[[], bool] | None = None,
|
|
129
|
+
) -> AsyncGenerator[pb.InvokeExecuteMethodOutput, None]:
|
|
130
|
+
if original.step_type == _TIMEOUT_HANDLER_STEP_TYPE:
|
|
131
|
+
response = await self._invoke_timeout_handler_async(original)
|
|
132
|
+
yield pb.InvokeExecuteMethodOutput(result=response)
|
|
133
|
+
return
|
|
134
|
+
emitter = _AsyncStepOutputEmitter()
|
|
135
|
+
handler = asyncio.create_task(
|
|
136
|
+
self._invoke_execute_result(original, emitter, is_active)
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
async for output in self._drain_outputs(emitter, handler):
|
|
140
|
+
yield self._map_execute_output(output)
|
|
141
|
+
yield pb.InvokeExecuteMethodOutput(result=await handler)
|
|
142
|
+
finally:
|
|
143
|
+
await self._close_invocation(emitter, handler)
|
|
144
|
+
|
|
145
|
+
async def _invoke_execute_result(
|
|
146
|
+
self,
|
|
147
|
+
original: pb.InvokeExecuteMethodRequest,
|
|
148
|
+
emitter: _AsyncStepOutputEmitter,
|
|
149
|
+
is_active: Callable[[], bool] | None,
|
|
81
150
|
) -> pb.InvokeExecuteMethodResponse:
|
|
82
151
|
request = await self._async_hydrator.execute_request(original)
|
|
83
152
|
flow = self._registry._flow_by_type(request.flow_type)
|
|
84
|
-
if request.step_type == _TIMEOUT_HANDLER_STEP_TYPE:
|
|
85
|
-
return await self._invoke_timeout_handler_async(request, flow)
|
|
86
153
|
step = flow.step(request.step_type)
|
|
87
154
|
condition_results = (
|
|
88
155
|
request.condition_results if request.HasField("condition_results") else None
|
|
@@ -92,14 +159,22 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
92
159
|
flow,
|
|
93
160
|
request.context,
|
|
94
161
|
self._values,
|
|
95
|
-
self._stream_writer,
|
|
96
162
|
request.attributes,
|
|
97
163
|
request.step_exe_locals,
|
|
98
164
|
condition_results,
|
|
99
165
|
is_active=is_active,
|
|
166
|
+
output_emitter=emitter,
|
|
100
167
|
)
|
|
101
168
|
input = self._values.decode(request.step_input, step.input_codec)
|
|
102
169
|
decision: Any = step.step.execute(context, input)
|
|
170
|
+
if isgenerator(decision):
|
|
171
|
+
decision.close()
|
|
172
|
+
raise InvalidStepResultError(
|
|
173
|
+
flow.name,
|
|
174
|
+
step.name,
|
|
175
|
+
"execute",
|
|
176
|
+
"synchronous generators require Worker",
|
|
177
|
+
)
|
|
103
178
|
if isawaitable(decision):
|
|
104
179
|
decision = await decision
|
|
105
180
|
try:
|
|
@@ -119,9 +194,10 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
119
194
|
|
|
120
195
|
async def _invoke_timeout_handler_async(
|
|
121
196
|
self,
|
|
122
|
-
|
|
123
|
-
flow: Any,
|
|
197
|
+
original: pb.InvokeExecuteMethodRequest,
|
|
124
198
|
) -> pb.InvokeExecuteMethodResponse:
|
|
199
|
+
request = await self._async_hydrator.execute_request(original)
|
|
200
|
+
flow = self._registry._flow_by_type(request.flow_type)
|
|
125
201
|
if request.HasField("step_input"):
|
|
126
202
|
raise InvalidStepResultError(
|
|
127
203
|
flow.name, _TIMEOUT_HANDLER_STEP_TYPE, "execute", "input must be absent"
|
|
@@ -137,11 +213,10 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
137
213
|
request.condition_results if request.HasField("condition_results") else None
|
|
138
214
|
)
|
|
139
215
|
context = InvocationContext(
|
|
140
|
-
InvocationMethod.
|
|
216
|
+
InvocationMethod.TIMEOUT,
|
|
141
217
|
flow,
|
|
142
218
|
request.context,
|
|
143
219
|
self._values,
|
|
144
|
-
self._stream_writer,
|
|
145
220
|
request.attributes,
|
|
146
221
|
request.step_exe_locals,
|
|
147
222
|
condition_results,
|
|
@@ -177,7 +252,6 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
177
252
|
flow,
|
|
178
253
|
request.context,
|
|
179
254
|
self._values,
|
|
180
|
-
self._stream_writer,
|
|
181
255
|
request.attributes,
|
|
182
256
|
channel_infos=dict(request.channel_infos),
|
|
183
257
|
is_active=is_active,
|
|
@@ -218,3 +292,38 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
218
292
|
raise
|
|
219
293
|
except (TypeError, ValueError) as error:
|
|
220
294
|
raise InvalidStepResultError(flow.name, None, "rpc", str(error)) from error
|
|
295
|
+
|
|
296
|
+
async def _drain_outputs(
|
|
297
|
+
self,
|
|
298
|
+
emitter: _AsyncStepOutputEmitter,
|
|
299
|
+
handler: asyncio.Task[Any],
|
|
300
|
+
) -> AsyncIterator[StepOutput]:
|
|
301
|
+
while not handler.done() or not emitter.queue.empty():
|
|
302
|
+
if not emitter.queue.empty():
|
|
303
|
+
yield emitter.queue.get_nowait()
|
|
304
|
+
continue
|
|
305
|
+
output = asyncio.create_task(emitter.queue.get())
|
|
306
|
+
try:
|
|
307
|
+
done, _ = await asyncio.wait(
|
|
308
|
+
(handler, output),
|
|
309
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
310
|
+
)
|
|
311
|
+
if output in done:
|
|
312
|
+
yield output.result()
|
|
313
|
+
finally:
|
|
314
|
+
if not output.done():
|
|
315
|
+
output.cancel()
|
|
316
|
+
with suppress(asyncio.CancelledError):
|
|
317
|
+
await output
|
|
318
|
+
|
|
319
|
+
async def _close_invocation(
|
|
320
|
+
self,
|
|
321
|
+
emitter: _AsyncStepOutputEmitter,
|
|
322
|
+
handler: asyncio.Task[Any],
|
|
323
|
+
) -> None:
|
|
324
|
+
emitter.close()
|
|
325
|
+
if handler.done():
|
|
326
|
+
return
|
|
327
|
+
handler.cancel()
|
|
328
|
+
with suppress(asyncio.CancelledError):
|
|
329
|
+
await handler
|