dex-python-sdk 0.2.5__tar.gz → 0.2.7__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.7}/PKG-INFO +88 -38
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/README.md +87 -37
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/__init__.py +14 -2
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_async_value_hydrator.py +15 -4
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_async_worker_dispatcher.py +174 -25
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_async_worker_service.py +33 -9
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_invocation_context.py +103 -34
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_value_hydrator.py +15 -4
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_worker_dispatcher.py +110 -27
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_worker_service.py +24 -4
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/async_client.py +8 -11
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/async_worker.py +0 -1
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/client.py +8 -11
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/context.py +70 -1
- dex_python_sdk-0.2.7/dex/dexpb/dex_pb2.py +459 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/dexpb/dex_pb2.pyi +48 -10
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/dexpb/dex_pb2_grpc.py +12 -12
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow.py +35 -9
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/step.py +84 -14
- dex_python_sdk-0.2.7/dex/stream.py +420 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/worker.py +0 -1
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/pyproject.toml +1 -1
- dex_python_sdk-0.2.5/dex/dexpb/dex_pb2.py +0 -451
- dex_python_sdk-0.2.5/dex/stream.py +0 -80
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/LEGACY_NOTICES.md +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/LICENSE +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_grpc_errors.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_native.pyi +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_utils.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_value_mapper.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/attribute.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/blob_cache.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/channel.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/client_options.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/codec.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/condition.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/dexpb/__init__.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow_config.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow_info.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow_options.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow_result.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/py.typed +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/runtime_errors.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/step_execution.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/subflow.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/timer.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/wait.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/worker_options.py +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/Cargo.lock +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/Cargo.toml +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
- {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/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.7
|
|
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,77 @@ 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
|
+
writer = progress.buffered_text(context)
|
|
177
|
+
writer.write("started")
|
|
178
|
+
await context.heartbeat({"offset": 10})
|
|
179
|
+
return dex.graceful_complete(input)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The async buffered writer has synchronous `write` and `flush` methods, so
|
|
183
|
+
`writer.write` can be passed directly as an LLM delta callback. It flushes after
|
|
184
|
+
one second, at a soft 16 KiB UTF-8 threshold, or before the final result or
|
|
185
|
+
error. It concatenates chunks exactly and ignores empty chunks.
|
|
186
|
+
|
|
187
|
+
A synchronous generator uses the cooperative form because only yielded
|
|
188
|
+
`StepOutput` values can reach gRPC:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
writer = progress.buffered_text(context)
|
|
192
|
+
yield from writer.write(delta)
|
|
193
|
+
yield from writer.flush()
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Its interval is checked by the next write, and the handler must explicitly
|
|
197
|
+
flush the tail. Retry does not restore either writer's unsent buffer or
|
|
198
|
+
deduplicate sent batches.
|
|
199
|
+
|
|
200
|
+
Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
|
|
201
|
+
details. Passing Python `None` persists a present null Value. On a later regular
|
|
202
|
+
attempt, use `context.has_last_heartbeat_value()` before decoding with
|
|
203
|
+
`context.get_last_heartbeat_value(ExpectedType)`. A Stream frame is also an implicit
|
|
204
|
+
heartbeat, but it preserves the latest explicit heartbeat state.
|
|
205
|
+
|
|
206
|
+
A Step may write the same Stream any number of times. Dex assigns
|
|
207
|
+
`#<stepExecutionID>` as each Step message's `StreamMessage.source`. Client writes
|
|
208
|
+
provide their own non-empty source; duplicate sources and `#` are allowed and every
|
|
209
|
+
write appends:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
client.write_stream(flow_id, progress, "frontend#preview", "rendering")
|
|
213
|
+
message = client.read_stream(flow_id, progress)
|
|
214
|
+
print(message.source)
|
|
215
|
+
```
|
|
216
|
+
|
|
160
217
|
### Canceling Step executions
|
|
161
218
|
|
|
162
219
|
A successful Step can cancel queued or active executions while continuing with
|
|
@@ -181,14 +238,6 @@ already-canceled, and absent targets are no-ops. Next Steps created by the same
|
|
|
181
238
|
decision are outside the snapshot. Dex immediately applies the next or close
|
|
182
239
|
action; late decisions, writes, retries, and recovery Steps are discarded.
|
|
183
240
|
|
|
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
241
|
`RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
|
|
193
242
|
RPCs do not support sibling selection because they have no Step execution
|
|
194
243
|
lineage.
|
|
@@ -298,14 +347,14 @@ serialization, and invalid handler returns use `FlowDefinitionError`,
|
|
|
298
347
|
### Sync vs asyncio
|
|
299
348
|
|
|
300
349
|
- **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
|
|
301
|
-
Worker.
|
|
302
|
-
|
|
350
|
+
Worker. A progress generator cooperatively hands each yielded frame to gRPC;
|
|
351
|
+
`Stream.write` must therefore be yielded. Blocking Client calls inside
|
|
352
|
+
`Step.execute` are safe while other pool threads remain available.
|
|
303
353
|
- **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
|
|
304
354
|
`Registry(..., allow_async_handlers=True)` when Steps/RPCs are coroutines.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
sync Worker dispatcher rejects awaitable return values).
|
|
355
|
+
Step coroutines annotate `AsyncContext`, call `Stream.write` without `await`,
|
|
356
|
+
and await `context.heartbeat`. Inside async `execute`, inject `AsyncClient` —
|
|
357
|
+
do not call sync `Client` on the Worker event loop. Async generators are rejected.
|
|
309
358
|
|
|
310
359
|
Integration scenarios live under
|
|
311
360
|
[`tests/integ`](tests/integ/README.md). They exercise the same workflows,
|
|
@@ -318,7 +367,8 @@ The strongly typed contracts, registry, synchronous Client/Worker, optional
|
|
|
318
367
|
`AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
|
|
319
368
|
implemented. Python owns its gRPC transport; the native bridge is limited to
|
|
320
369
|
the shared BlobCache. Design notes:
|
|
321
|
-
[`
|
|
370
|
+
[`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
|
|
371
|
+
[`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
|
|
322
372
|
|
|
323
373
|
## Running Dex locally
|
|
324
374
|
|
|
@@ -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,77 @@ 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
|
+
writer = progress.buffered_text(context)
|
|
161
|
+
writer.write("started")
|
|
162
|
+
await context.heartbeat({"offset": 10})
|
|
163
|
+
return dex.graceful_complete(input)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The async buffered writer has synchronous `write` and `flush` methods, so
|
|
167
|
+
`writer.write` can be passed directly as an LLM delta callback. It flushes after
|
|
168
|
+
one second, at a soft 16 KiB UTF-8 threshold, or before the final result or
|
|
169
|
+
error. It concatenates chunks exactly and ignores empty chunks.
|
|
170
|
+
|
|
171
|
+
A synchronous generator uses the cooperative form because only yielded
|
|
172
|
+
`StepOutput` values can reach gRPC:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
writer = progress.buffered_text(context)
|
|
176
|
+
yield from writer.write(delta)
|
|
177
|
+
yield from writer.flush()
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Its interval is checked by the next write, and the handler must explicitly
|
|
181
|
+
flush the tail. Retry does not restore either writer's unsent buffer or
|
|
182
|
+
deduplicate sent batches.
|
|
183
|
+
|
|
184
|
+
Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
|
|
185
|
+
details. Passing Python `None` persists a present null Value. On a later regular
|
|
186
|
+
attempt, use `context.has_last_heartbeat_value()` before decoding with
|
|
187
|
+
`context.get_last_heartbeat_value(ExpectedType)`. A Stream frame is also an implicit
|
|
188
|
+
heartbeat, but it preserves the latest explicit heartbeat state.
|
|
189
|
+
|
|
190
|
+
A Step may write the same Stream any number of times. Dex assigns
|
|
191
|
+
`#<stepExecutionID>` as each Step message's `StreamMessage.source`. Client writes
|
|
192
|
+
provide their own non-empty source; duplicate sources and `#` are allowed and every
|
|
193
|
+
write appends:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
client.write_stream(flow_id, progress, "frontend#preview", "rendering")
|
|
197
|
+
message = client.read_stream(flow_id, progress)
|
|
198
|
+
print(message.source)
|
|
199
|
+
```
|
|
200
|
+
|
|
144
201
|
### Canceling Step executions
|
|
145
202
|
|
|
146
203
|
A successful Step can cancel queued or active executions while continuing with
|
|
@@ -165,14 +222,6 @@ already-canceled, and absent targets are no-ops. Next Steps created by the same
|
|
|
165
222
|
decision are outside the snapshot. Dex immediately applies the next or close
|
|
166
223
|
action; late decisions, writes, retries, and recovery Steps are discarded.
|
|
167
224
|
|
|
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
225
|
`RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
|
|
177
226
|
RPCs do not support sibling selection because they have no Step execution
|
|
178
227
|
lineage.
|
|
@@ -282,14 +331,14 @@ serialization, and invalid handler returns use `FlowDefinitionError`,
|
|
|
282
331
|
### Sync vs asyncio
|
|
283
332
|
|
|
284
333
|
- **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
|
|
285
|
-
Worker.
|
|
286
|
-
|
|
334
|
+
Worker. A progress generator cooperatively hands each yielded frame to gRPC;
|
|
335
|
+
`Stream.write` must therefore be yielded. Blocking Client calls inside
|
|
336
|
+
`Step.execute` are safe while other pool threads remain available.
|
|
287
337
|
- **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
|
|
288
338
|
`Registry(..., allow_async_handlers=True)` when Steps/RPCs are coroutines.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
sync Worker dispatcher rejects awaitable return values).
|
|
339
|
+
Step coroutines annotate `AsyncContext`, call `Stream.write` without `await`,
|
|
340
|
+
and await `context.heartbeat`. Inside async `execute`, inject `AsyncClient` —
|
|
341
|
+
do not call sync `Client` on the Worker event loop. Async generators are rejected.
|
|
293
342
|
|
|
294
343
|
Integration scenarios live under
|
|
295
344
|
[`tests/integ`](tests/integ/README.md). They exercise the same workflows,
|
|
@@ -302,7 +351,8 @@ The strongly typed contracts, registry, synchronous Client/Worker, optional
|
|
|
302
351
|
`AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
|
|
303
352
|
implemented. Python owns its gRPC transport; the native bridge is limited to
|
|
304
353
|
the shared BlobCache. Design notes:
|
|
305
|
-
[`
|
|
354
|
+
[`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
|
|
355
|
+
[`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
|
|
306
356
|
|
|
307
357
|
## Running Dex locally
|
|
308
358
|
|
|
@@ -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,9 +89,15 @@ 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
|
-
from dex.stream import
|
|
95
|
+
from dex.stream import (
|
|
96
|
+
AsyncBufferedTextStream,
|
|
97
|
+
BufferedTextStream,
|
|
98
|
+
Stream,
|
|
99
|
+
StreamMessage,
|
|
100
|
+
)
|
|
94
101
|
from dex.subflow import SubFlow
|
|
95
102
|
from dex.timer import Timer
|
|
96
103
|
from dex.wait import Wait
|
|
@@ -108,10 +115,13 @@ __all__ = [
|
|
|
108
115
|
"AttributeIndex",
|
|
109
116
|
"AttributeLock",
|
|
110
117
|
"AttributeMap",
|
|
118
|
+
"AsyncContext",
|
|
111
119
|
"AsyncClient",
|
|
120
|
+
"AsyncBufferedTextStream",
|
|
112
121
|
"AsyncWorker",
|
|
113
122
|
"BlobCache",
|
|
114
123
|
"BlobCacheConfig",
|
|
124
|
+
"BufferedTextStream",
|
|
115
125
|
"Channel",
|
|
116
126
|
"ChannelMap",
|
|
117
127
|
"Client",
|
|
@@ -161,6 +171,7 @@ __all__ = [
|
|
|
161
171
|
"StepList",
|
|
162
172
|
"StepDurability",
|
|
163
173
|
"StepMovement",
|
|
174
|
+
"StepOutput",
|
|
164
175
|
"StepOptions",
|
|
165
176
|
"Stream",
|
|
166
177
|
"StreamMessage",
|
|
@@ -182,6 +193,7 @@ __all__ = [
|
|
|
182
193
|
"force_complete_if_channels_empty",
|
|
183
194
|
"force_fail",
|
|
184
195
|
"graceful_complete",
|
|
196
|
+
"heartbeat",
|
|
185
197
|
"go_to",
|
|
186
198
|
"go_to_many",
|
|
187
199
|
"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:
|