dex-python-sdk 0.2.1__tar.gz → 0.2.2__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.1 → dex_python_sdk-0.2.2}/PKG-INFO +23 -4
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/README.md +22 -3
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/__init__.py +9 -6
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_async_worker_dispatcher.py +6 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_invocation_context.py +50 -1
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_worker_dispatcher.py +6 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/async_client.py +108 -3
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/async_worker.py +1 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/client.py +108 -3
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/context.py +7 -0
- dex_python_sdk-0.2.2/dex/dexpb/dex_pb2.py +451 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/dexpb/dex_pb2.pyi +48 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/dexpb/dex_pb2_grpc.py +101 -6
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/flow.py +44 -6
- dex_python_sdk-0.2.2/dex/stream.py +80 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/worker.py +1 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/pyproject.toml +1 -1
- dex_python_sdk-0.2.1/dex/dexpb/dex_pb2.py +0 -443
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/LEGACY_NOTICES.md +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/LICENSE +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_async_value_hydrator.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_async_worker_service.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_grpc_errors.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_native.pyi +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_utils.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_value_hydrator.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_value_mapper.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/_worker_service.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/attribute.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/blob_cache.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/channel.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/client_options.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/codec.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/condition.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/dexpb/__init__.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/flow_config.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/flow_info.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/flow_options.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/flow_result.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/py.typed +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/runtime_errors.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/step.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/step_execution.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/subflow.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/timer.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/wait.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/dex/worker_options.py +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/Cargo.lock +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/Cargo.toml +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
- {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.2}/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.2
|
|
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
|
|
@@ -22,7 +22,7 @@ Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
|
|
|
22
22
|
## New user contracts
|
|
23
23
|
|
|
24
24
|
The rewrite targets Python 3.11+ and exposes strongly typed workflow contracts
|
|
25
|
-
from `dex`. This phase includes definitions, attributes, channels, waits,
|
|
25
|
+
from `dex`. This phase includes definitions, attributes, channels, streams, waits,
|
|
26
26
|
decisions, codecs, registry validation, synchronous client calls, and synchronous
|
|
27
27
|
worker handlers. Python owns its gRPC Client and Worker transport;
|
|
28
28
|
the shared Rust Core is used only for BlobCache.
|
|
@@ -34,6 +34,7 @@ import dex
|
|
|
34
34
|
|
|
35
35
|
counter = dex.Attribute("counter", int)
|
|
36
36
|
counters_by_region = dex.AttributeMap("counters-by-region", int)
|
|
37
|
+
progress = dex.Stream("progress", str, 10 * 1024 * 1024)
|
|
37
38
|
|
|
38
39
|
class Run(dex.Step[str]):
|
|
39
40
|
def wait_for(
|
|
@@ -46,6 +47,7 @@ class Run(dex.Step[str]):
|
|
|
46
47
|
def execute(
|
|
47
48
|
self, context: dex.Context, input: str
|
|
48
49
|
) -> dex.StepDecision:
|
|
50
|
+
progress.write(context, "running")
|
|
49
51
|
return dex.graceful_complete(input)
|
|
50
52
|
|
|
51
53
|
class CounterFlow(dex.Flow[str]):
|
|
@@ -58,7 +60,7 @@ class CounterFlow(dex.Flow[str]):
|
|
|
58
60
|
return dex.StepList.start_step(self.run)
|
|
59
61
|
|
|
60
62
|
def get_persistence_schema(self) -> dex.PersistenceSchema:
|
|
61
|
-
return dex.PersistenceSchema.of(counter, counters_by_region)
|
|
63
|
+
return dex.PersistenceSchema.of(counter, counters_by_region, progress)
|
|
62
64
|
|
|
63
65
|
@dex.rpc(name="Increment")
|
|
64
66
|
def increment(
|
|
@@ -73,9 +75,26 @@ registry = dex.Registry((flow,))
|
|
|
73
75
|
Registry derives codecs from declared Python types and handler annotations.
|
|
74
76
|
Built-in primitive types and dataclasses need no codec arguments. Register an
|
|
75
77
|
explicit codec only for a custom encoding or a type Registry cannot derive.
|
|
76
|
-
`PersistenceSchema.of(...)` accepts attributes and
|
|
78
|
+
`PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
|
|
77
79
|
partitions them by definition type.
|
|
78
80
|
|
|
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
|
+
|
|
79
98
|
`Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
|
|
80
99
|
Dex Server before opening their listener. Existing indexes return immediately;
|
|
81
100
|
failure or the default two-minute deadline aborts startup. An indexed
|
|
@@ -6,7 +6,7 @@ Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
|
|
|
6
6
|
## New user contracts
|
|
7
7
|
|
|
8
8
|
The rewrite targets Python 3.11+ and exposes strongly typed workflow contracts
|
|
9
|
-
from `dex`. This phase includes definitions, attributes, channels, waits,
|
|
9
|
+
from `dex`. This phase includes definitions, attributes, channels, streams, waits,
|
|
10
10
|
decisions, codecs, registry validation, synchronous client calls, and synchronous
|
|
11
11
|
worker handlers. Python owns its gRPC Client and Worker transport;
|
|
12
12
|
the shared Rust Core is used only for BlobCache.
|
|
@@ -18,6 +18,7 @@ import dex
|
|
|
18
18
|
|
|
19
19
|
counter = dex.Attribute("counter", int)
|
|
20
20
|
counters_by_region = dex.AttributeMap("counters-by-region", int)
|
|
21
|
+
progress = dex.Stream("progress", str, 10 * 1024 * 1024)
|
|
21
22
|
|
|
22
23
|
class Run(dex.Step[str]):
|
|
23
24
|
def wait_for(
|
|
@@ -30,6 +31,7 @@ class Run(dex.Step[str]):
|
|
|
30
31
|
def execute(
|
|
31
32
|
self, context: dex.Context, input: str
|
|
32
33
|
) -> dex.StepDecision:
|
|
34
|
+
progress.write(context, "running")
|
|
33
35
|
return dex.graceful_complete(input)
|
|
34
36
|
|
|
35
37
|
class CounterFlow(dex.Flow[str]):
|
|
@@ -42,7 +44,7 @@ class CounterFlow(dex.Flow[str]):
|
|
|
42
44
|
return dex.StepList.start_step(self.run)
|
|
43
45
|
|
|
44
46
|
def get_persistence_schema(self) -> dex.PersistenceSchema:
|
|
45
|
-
return dex.PersistenceSchema.of(counter, counters_by_region)
|
|
47
|
+
return dex.PersistenceSchema.of(counter, counters_by_region, progress)
|
|
46
48
|
|
|
47
49
|
@dex.rpc(name="Increment")
|
|
48
50
|
def increment(
|
|
@@ -57,9 +59,26 @@ registry = dex.Registry((flow,))
|
|
|
57
59
|
Registry derives codecs from declared Python types and handler annotations.
|
|
58
60
|
Built-in primitive types and dataclasses need no codec arguments. Register an
|
|
59
61
|
explicit codec only for a custom encoding or a type Registry cannot derive.
|
|
60
|
-
`PersistenceSchema.of(...)` accepts attributes and
|
|
62
|
+
`PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
|
|
61
63
|
partitions them by definition type.
|
|
62
64
|
|
|
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
|
+
|
|
63
82
|
`Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
|
|
64
83
|
Dex Server before opening their listener. Existing indexes return immediately;
|
|
65
84
|
failure or the default two-minute deadline aborts startup. An indexed
|
|
@@ -47,14 +47,14 @@ from dex.flow_info import (
|
|
|
47
47
|
from dex.flow_options import (
|
|
48
48
|
FlowTimeoutPolicy,
|
|
49
49
|
IdReusePolicy,
|
|
50
|
-
TimeTravelOptions,
|
|
51
|
-
TimeTravelStepMethod,
|
|
52
|
-
TimeTravelType,
|
|
53
50
|
StartFlowOptions,
|
|
54
|
-
SubFlowOptions,
|
|
55
|
-
SubFlowReusePolicy,
|
|
56
51
|
StopFlowOptions,
|
|
57
52
|
StopType,
|
|
53
|
+
SubFlowOptions,
|
|
54
|
+
SubFlowReusePolicy,
|
|
55
|
+
TimeTravelOptions,
|
|
56
|
+
TimeTravelStepMethod,
|
|
57
|
+
TimeTravelType,
|
|
58
58
|
)
|
|
59
59
|
from dex.flow_result import FlowResult, StepCompletion
|
|
60
60
|
from dex.runtime_errors import (
|
|
@@ -89,8 +89,9 @@ from dex.step import (
|
|
|
89
89
|
graceful_complete,
|
|
90
90
|
)
|
|
91
91
|
from dex.step_execution import StepExecutionId, TimerId
|
|
92
|
-
from dex.
|
|
92
|
+
from dex.stream import Stream, StreamMessage
|
|
93
93
|
from dex.subflow import SubFlow
|
|
94
|
+
from dex.timer import Timer
|
|
94
95
|
from dex.wait import Wait
|
|
95
96
|
from dex.worker import Worker
|
|
96
97
|
from dex.worker_options import WorkerOptions, WorkerTarget
|
|
@@ -159,6 +160,8 @@ __all__ = [
|
|
|
159
160
|
"StepDurability",
|
|
160
161
|
"StepMovement",
|
|
161
162
|
"StepOptions",
|
|
163
|
+
"Stream",
|
|
164
|
+
"StreamMessage",
|
|
162
165
|
"StopFlowOptions",
|
|
163
166
|
"StopType",
|
|
164
167
|
"Timer",
|
|
@@ -28,10 +28,12 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
28
28
|
registry: Registry,
|
|
29
29
|
values: ValueMapper,
|
|
30
30
|
hydrator: AsyncValueHydrator,
|
|
31
|
+
stream_writer: Callable[[pb.WriteStreamRequest], Any],
|
|
31
32
|
) -> None:
|
|
32
33
|
self._registry = registry
|
|
33
34
|
self._values = values
|
|
34
35
|
self._async_hydrator = hydrator
|
|
36
|
+
self._stream_writer = stream_writer
|
|
35
37
|
|
|
36
38
|
async def invoke_wait_for( # type: ignore[override]
|
|
37
39
|
self,
|
|
@@ -46,6 +48,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
46
48
|
flow,
|
|
47
49
|
request.context,
|
|
48
50
|
self._values,
|
|
51
|
+
self._stream_writer,
|
|
49
52
|
request.attributes,
|
|
50
53
|
is_active=is_active,
|
|
51
54
|
)
|
|
@@ -89,6 +92,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
89
92
|
flow,
|
|
90
93
|
request.context,
|
|
91
94
|
self._values,
|
|
95
|
+
self._stream_writer,
|
|
92
96
|
request.attributes,
|
|
93
97
|
request.step_exe_locals,
|
|
94
98
|
condition_results,
|
|
@@ -137,6 +141,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
137
141
|
flow,
|
|
138
142
|
request.context,
|
|
139
143
|
self._values,
|
|
144
|
+
self._stream_writer,
|
|
140
145
|
request.attributes,
|
|
141
146
|
request.step_exe_locals,
|
|
142
147
|
condition_results,
|
|
@@ -172,6 +177,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
172
177
|
flow,
|
|
173
178
|
request.context,
|
|
174
179
|
self._values,
|
|
180
|
+
self._stream_writer,
|
|
175
181
|
request.attributes,
|
|
176
182
|
channel_infos=dict(request.channel_infos),
|
|
177
183
|
is_active=is_active,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from enum import Enum
|
|
12
|
+
from inspect import isawaitable
|
|
12
13
|
from typing import Any, Callable, Sequence, TypeVar, cast
|
|
13
14
|
from urllib.parse import unquote
|
|
14
15
|
|
|
@@ -20,9 +21,12 @@ from dex.codec import Codec
|
|
|
20
21
|
from dex.dexpb import dex_pb2 as pb
|
|
21
22
|
from dex.flow import Registry, _RegisteredFlow
|
|
22
23
|
from dex.flow_result import FlowResult, flow_result_from_proto
|
|
24
|
+
from dex.stream import Stream
|
|
23
25
|
|
|
24
26
|
ValueT = TypeVar("ValueT")
|
|
25
|
-
_Definition =
|
|
27
|
+
_Definition = (
|
|
28
|
+
Attribute[Any] | AttributeMap[Any] | Channel[Any] | ChannelMap[Any] | Stream[Any]
|
|
29
|
+
)
|
|
26
30
|
|
|
27
31
|
|
|
28
32
|
class InvocationMethod(Enum):
|
|
@@ -38,6 +42,7 @@ class InvocationContext:
|
|
|
38
42
|
flow: _RegisteredFlow,
|
|
39
43
|
metadata: pb.Context,
|
|
40
44
|
values: ValueMapper,
|
|
45
|
+
stream_writer: Callable[[pb.WriteStreamRequest], Any],
|
|
41
46
|
attributes: Sequence[pb.KV],
|
|
42
47
|
locals: Sequence[pb.KV] = (),
|
|
43
48
|
condition_results: pb.ConditionResults | None = None,
|
|
@@ -48,6 +53,7 @@ class InvocationContext:
|
|
|
48
53
|
self._flow = flow
|
|
49
54
|
self._metadata = metadata
|
|
50
55
|
self._values = values
|
|
56
|
+
self._stream_writer = stream_writer
|
|
51
57
|
self._attributes = self._map_values("Attribute", attributes)
|
|
52
58
|
self._locals = self._map_values("step-execution local", locals)
|
|
53
59
|
self._condition_results = condition_results
|
|
@@ -58,6 +64,7 @@ class InvocationContext:
|
|
|
58
64
|
self.events: list[pb.KV] = []
|
|
59
65
|
self.publications: list[pb.ChannelMessage] = []
|
|
60
66
|
self._event_names: set[str] = set()
|
|
67
|
+
self._stream_writes: set[int] = set()
|
|
61
68
|
|
|
62
69
|
@property
|
|
63
70
|
def flow_id(self) -> str:
|
|
@@ -151,6 +158,48 @@ class InvocationContext:
|
|
|
151
158
|
self._event_names.add(name)
|
|
152
159
|
self.events.append(pb.KV(key=name, value=self._values.encode_dynamic(value)))
|
|
153
160
|
|
|
161
|
+
def _write_stream(
|
|
162
|
+
self,
|
|
163
|
+
definition: Stream[ValueT],
|
|
164
|
+
value: ValueT,
|
|
165
|
+
) -> Any:
|
|
166
|
+
if self._method is InvocationMethod.RPC:
|
|
167
|
+
raise ValueError("Stream writes require a Step Context")
|
|
168
|
+
self._require_registered(definition)
|
|
169
|
+
identity = id(definition)
|
|
170
|
+
if identity in self._stream_writes:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"Stream {definition.name} was already written by this Step execution"
|
|
173
|
+
)
|
|
174
|
+
request = pb.WriteStreamRequest(
|
|
175
|
+
flow_id=self.flow_id,
|
|
176
|
+
flow_type=self._flow.name,
|
|
177
|
+
stream_name=definition.name,
|
|
178
|
+
max_estimated_bytes=definition.max_estimated_bytes,
|
|
179
|
+
value=self._values.encode(
|
|
180
|
+
value,
|
|
181
|
+
self._values.codec(definition.value_type),
|
|
182
|
+
),
|
|
183
|
+
idempotency_key=f"{self.run_id}#{self.step_execution_id}",
|
|
184
|
+
)
|
|
185
|
+
result = self._stream_writer(request)
|
|
186
|
+
if isawaitable(result):
|
|
187
|
+
self._stream_writes.add(identity)
|
|
188
|
+
return self._finish_async_stream_write(result, identity)
|
|
189
|
+
self._stream_writes.add(identity)
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
async def _finish_async_stream_write(
|
|
193
|
+
self,
|
|
194
|
+
result: Any,
|
|
195
|
+
identity: int,
|
|
196
|
+
) -> None:
|
|
197
|
+
try:
|
|
198
|
+
await result
|
|
199
|
+
except BaseException:
|
|
200
|
+
self._stream_writes.remove(identity)
|
|
201
|
+
raise
|
|
202
|
+
|
|
154
203
|
def _get_attribute(
|
|
155
204
|
self,
|
|
156
205
|
definition: Attribute[ValueT] | AttributeMap[ValueT],
|
|
@@ -49,10 +49,12 @@ class WorkerDispatcher:
|
|
|
49
49
|
registry: Registry,
|
|
50
50
|
values: ValueMapper,
|
|
51
51
|
hydrator: ValueHydrator,
|
|
52
|
+
stream_writer: Callable[[pb.WriteStreamRequest], Any],
|
|
52
53
|
) -> None:
|
|
53
54
|
self._registry = registry
|
|
54
55
|
self._values = values
|
|
55
56
|
self._hydrator = hydrator
|
|
57
|
+
self._stream_writer = stream_writer
|
|
56
58
|
|
|
57
59
|
def invoke_wait_for(
|
|
58
60
|
self,
|
|
@@ -67,6 +69,7 @@ class WorkerDispatcher:
|
|
|
67
69
|
flow,
|
|
68
70
|
request.context,
|
|
69
71
|
self._values,
|
|
72
|
+
self._stream_writer,
|
|
70
73
|
request.attributes,
|
|
71
74
|
is_active=is_active,
|
|
72
75
|
)
|
|
@@ -112,6 +115,7 @@ class WorkerDispatcher:
|
|
|
112
115
|
flow,
|
|
113
116
|
request.context,
|
|
114
117
|
self._values,
|
|
118
|
+
self._stream_writer,
|
|
115
119
|
request.attributes,
|
|
116
120
|
request.step_exe_locals,
|
|
117
121
|
condition_results,
|
|
@@ -162,6 +166,7 @@ class WorkerDispatcher:
|
|
|
162
166
|
flow,
|
|
163
167
|
request.context,
|
|
164
168
|
self._values,
|
|
169
|
+
self._stream_writer,
|
|
165
170
|
request.attributes,
|
|
166
171
|
request.step_exe_locals,
|
|
167
172
|
condition_results,
|
|
@@ -199,6 +204,7 @@ class WorkerDispatcher:
|
|
|
199
204
|
flow,
|
|
200
205
|
request.context,
|
|
201
206
|
self._values,
|
|
207
|
+
self._stream_writer,
|
|
202
208
|
request.attributes,
|
|
203
209
|
channel_infos=dict(request.channel_infos),
|
|
204
210
|
is_active=is_active,
|
|
@@ -35,18 +35,19 @@ from dex.flow_info import FlowInfo, FlowStatus, SearchFlowEntry, SearchFlowsPage
|
|
|
35
35
|
from dex.flow_options import (
|
|
36
36
|
FlowTimeoutPolicy,
|
|
37
37
|
IdReusePolicy,
|
|
38
|
-
TimeTravelOptions,
|
|
39
|
-
TimeTravelStepMethod,
|
|
40
|
-
TimeTravelType,
|
|
41
38
|
StartFlowOptions,
|
|
42
39
|
StopFlowOptions,
|
|
43
40
|
StopType,
|
|
41
|
+
TimeTravelOptions,
|
|
42
|
+
TimeTravelStepMethod,
|
|
43
|
+
TimeTravelType,
|
|
44
44
|
_resolve_flow_timeout_policy,
|
|
45
45
|
)
|
|
46
46
|
from dex.flow_result import FlowResult, flow_result_from_proto
|
|
47
47
|
from dex.runtime_errors import FlowErrorType
|
|
48
48
|
from dex.step import RetryPolicy, StepDurability
|
|
49
49
|
from dex.step_execution import StepExecutionId, TimerId
|
|
50
|
+
from dex.stream import Stream, StreamMessage
|
|
50
51
|
|
|
51
52
|
InputT = TypeVar("InputT")
|
|
52
53
|
OutputT = TypeVar("OutputT")
|
|
@@ -97,6 +98,7 @@ class AsyncClient:
|
|
|
97
98
|
registry,
|
|
98
99
|
self._values,
|
|
99
100
|
cast(Any, self._hydrator),
|
|
101
|
+
self._service.WriteStream,
|
|
100
102
|
)
|
|
101
103
|
self._closed = False
|
|
102
104
|
|
|
@@ -502,6 +504,109 @@ class AsyncClient:
|
|
|
502
504
|
"active",
|
|
503
505
|
)
|
|
504
506
|
|
|
507
|
+
async def write_stream(
|
|
508
|
+
self,
|
|
509
|
+
flow_id: str,
|
|
510
|
+
stream: Stream[ValueT],
|
|
511
|
+
idempotency_key: str,
|
|
512
|
+
value: ValueT,
|
|
513
|
+
) -> None:
|
|
514
|
+
"""Await one typed best-effort Stream append with client idempotency.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
flow_id: Logical Flow instance ID; the Flow need not exist or be active.
|
|
518
|
+
stream: Exact Stream object registered in one Flow schema.
|
|
519
|
+
idempotency_key: Non-empty key without ``#``; retained duplicates are no-ops.
|
|
520
|
+
value: Typed message to append.
|
|
521
|
+
|
|
522
|
+
Raises:
|
|
523
|
+
ValueError: If an ID is empty or the key contains the reserved separator.
|
|
524
|
+
FlowDefinitionError: If the Stream is not registered.
|
|
525
|
+
ValueMappingError: If the message cannot be encoded.
|
|
526
|
+
DexServiceError: If FlowService cannot append the message.
|
|
527
|
+
"""
|
|
528
|
+
require_name(idempotency_key)
|
|
529
|
+
if "#" in idempotency_key:
|
|
530
|
+
raise ValueError("Stream client idempotency key must not contain #")
|
|
531
|
+
flow = self.registry._flow_for_stream(stream)
|
|
532
|
+
await self._call(
|
|
533
|
+
self._service.WriteStream,
|
|
534
|
+
pb.WriteStreamRequest(
|
|
535
|
+
flow_id=require_name(flow_id),
|
|
536
|
+
flow_type=flow.name,
|
|
537
|
+
stream_name=stream.name,
|
|
538
|
+
max_estimated_bytes=stream.max_estimated_bytes,
|
|
539
|
+
value=self._values.encode(
|
|
540
|
+
value,
|
|
541
|
+
self._values.codec(stream.value_type),
|
|
542
|
+
),
|
|
543
|
+
idempotency_key=idempotency_key,
|
|
544
|
+
),
|
|
545
|
+
"write_stream",
|
|
546
|
+
flow_id,
|
|
547
|
+
"none",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
async def read_stream(
|
|
551
|
+
self,
|
|
552
|
+
flow_id: str,
|
|
553
|
+
stream: Stream[ValueT],
|
|
554
|
+
resume_token: str = "",
|
|
555
|
+
timeout: timedelta | None = None,
|
|
556
|
+
) -> StreamMessage[ValueT]:
|
|
557
|
+
"""Await the next retained Stream message after a resume token.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
flow_id: Logical Flow instance ID used as the Stream instance key.
|
|
561
|
+
stream: Exact Stream object registered in one Flow schema.
|
|
562
|
+
resume_token: Previous message token, or empty for the retained head.
|
|
563
|
+
timeout: Optional non-negative server-side long-poll duration.
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
The decoded message, next resume token, creation time, and idempotency key.
|
|
567
|
+
|
|
568
|
+
Raises:
|
|
569
|
+
LongPollTimeoutError: If no message arrives before the server wait expires.
|
|
570
|
+
FlowDefinitionError: If the Stream is not registered.
|
|
571
|
+
ValueMappingError: If the retained message cannot be decoded.
|
|
572
|
+
DexServiceError: If FlowService cannot perform the read.
|
|
573
|
+
"""
|
|
574
|
+
flow = self.registry._flow_for_stream(stream)
|
|
575
|
+
response = cast(
|
|
576
|
+
pb.ReadStreamResponse,
|
|
577
|
+
await self._call(
|
|
578
|
+
self._service.ReadStream,
|
|
579
|
+
pb.ReadStreamRequest(
|
|
580
|
+
flow_id=require_name(flow_id),
|
|
581
|
+
flow_type=flow.name,
|
|
582
|
+
stream_name=stream.name,
|
|
583
|
+
resume_token=resume_token,
|
|
584
|
+
wait_time_seconds=(
|
|
585
|
+
0 if timeout is None else self._seconds32(timeout)
|
|
586
|
+
),
|
|
587
|
+
),
|
|
588
|
+
"read_stream",
|
|
589
|
+
flow_id,
|
|
590
|
+
"none",
|
|
591
|
+
),
|
|
592
|
+
)
|
|
593
|
+
if (
|
|
594
|
+
not response.HasField("message")
|
|
595
|
+
or not response.message.HasField("value")
|
|
596
|
+
or not response.message.HasField("created_time")
|
|
597
|
+
or not response.message.resume_token
|
|
598
|
+
):
|
|
599
|
+
raise ValueError("Dex returned an incomplete Stream message")
|
|
600
|
+
return StreamMessage(
|
|
601
|
+
self._values.decode(
|
|
602
|
+
response.message.value,
|
|
603
|
+
self._values.codec(stream.value_type),
|
|
604
|
+
),
|
|
605
|
+
response.message.resume_token,
|
|
606
|
+
response.message.created_time.ToDatetime(tzinfo=timezone.utc),
|
|
607
|
+
response.message.idempotency_key,
|
|
608
|
+
)
|
|
609
|
+
|
|
505
610
|
async def wait_for_flow(
|
|
506
611
|
self,
|
|
507
612
|
flow_id: str,
|
|
@@ -35,18 +35,19 @@ from dex.flow_info import FlowInfo, FlowStatus, SearchFlowEntry, SearchFlowsPage
|
|
|
35
35
|
from dex.flow_options import (
|
|
36
36
|
FlowTimeoutPolicy,
|
|
37
37
|
IdReusePolicy,
|
|
38
|
-
TimeTravelOptions,
|
|
39
|
-
TimeTravelStepMethod,
|
|
40
|
-
TimeTravelType,
|
|
41
38
|
StartFlowOptions,
|
|
42
39
|
StopFlowOptions,
|
|
43
40
|
StopType,
|
|
41
|
+
TimeTravelOptions,
|
|
42
|
+
TimeTravelStepMethod,
|
|
43
|
+
TimeTravelType,
|
|
44
44
|
_resolve_flow_timeout_policy,
|
|
45
45
|
)
|
|
46
46
|
from dex.flow_result import FlowResult, flow_result_from_proto
|
|
47
47
|
from dex.runtime_errors import FlowErrorType
|
|
48
48
|
from dex.step import RetryPolicy, StepDurability
|
|
49
49
|
from dex.step_execution import StepExecutionId, TimerId
|
|
50
|
+
from dex.stream import Stream, StreamMessage
|
|
50
51
|
|
|
51
52
|
InputT = TypeVar("InputT")
|
|
52
53
|
OutputT = TypeVar("OutputT")
|
|
@@ -97,6 +98,7 @@ class Client:
|
|
|
97
98
|
registry,
|
|
98
99
|
self._values,
|
|
99
100
|
self._hydrator,
|
|
101
|
+
self._service.WriteStream,
|
|
100
102
|
)
|
|
101
103
|
self._closed = False
|
|
102
104
|
|
|
@@ -503,6 +505,109 @@ class Client:
|
|
|
503
505
|
"active",
|
|
504
506
|
)
|
|
505
507
|
|
|
508
|
+
def write_stream(
|
|
509
|
+
self,
|
|
510
|
+
flow_id: str,
|
|
511
|
+
stream: Stream[ValueT],
|
|
512
|
+
idempotency_key: str,
|
|
513
|
+
value: ValueT,
|
|
514
|
+
) -> None:
|
|
515
|
+
"""Append one typed best-effort Stream message with client idempotency.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
flow_id: Logical Flow instance ID; the Flow need not exist or be active.
|
|
519
|
+
stream: Exact Stream object registered in one Flow schema.
|
|
520
|
+
idempotency_key: Non-empty key without ``#``; retained duplicates are no-ops.
|
|
521
|
+
value: Typed message to append.
|
|
522
|
+
|
|
523
|
+
Raises:
|
|
524
|
+
ValueError: If an ID is empty or the key contains the reserved separator.
|
|
525
|
+
FlowDefinitionError: If the Stream is not registered.
|
|
526
|
+
ValueMappingError: If the message cannot be encoded.
|
|
527
|
+
DexServiceError: If FlowService cannot append the message.
|
|
528
|
+
"""
|
|
529
|
+
require_name(idempotency_key)
|
|
530
|
+
if "#" in idempotency_key:
|
|
531
|
+
raise ValueError("Stream client idempotency key must not contain #")
|
|
532
|
+
flow = self.registry._flow_for_stream(stream)
|
|
533
|
+
self._call(
|
|
534
|
+
self._service.WriteStream,
|
|
535
|
+
pb.WriteStreamRequest(
|
|
536
|
+
flow_id=require_name(flow_id),
|
|
537
|
+
flow_type=flow.name,
|
|
538
|
+
stream_name=stream.name,
|
|
539
|
+
max_estimated_bytes=stream.max_estimated_bytes,
|
|
540
|
+
value=self._values.encode(
|
|
541
|
+
value,
|
|
542
|
+
self._values.codec(stream.value_type),
|
|
543
|
+
),
|
|
544
|
+
idempotency_key=idempotency_key,
|
|
545
|
+
),
|
|
546
|
+
"write_stream",
|
|
547
|
+
flow_id,
|
|
548
|
+
"none",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def read_stream(
|
|
552
|
+
self,
|
|
553
|
+
flow_id: str,
|
|
554
|
+
stream: Stream[ValueT],
|
|
555
|
+
resume_token: str = "",
|
|
556
|
+
timeout: timedelta | None = None,
|
|
557
|
+
) -> StreamMessage[ValueT]:
|
|
558
|
+
"""Block for the next retained Stream message after a resume token.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
flow_id: Logical Flow instance ID used as the Stream instance key.
|
|
562
|
+
stream: Exact Stream object registered in one Flow schema.
|
|
563
|
+
resume_token: Previous message token, or empty for the retained head.
|
|
564
|
+
timeout: Optional non-negative server-side long-poll duration.
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
The decoded message, next resume token, creation time, and idempotency key.
|
|
568
|
+
|
|
569
|
+
Raises:
|
|
570
|
+
LongPollTimeoutError: If no message arrives before the server wait expires.
|
|
571
|
+
FlowDefinitionError: If the Stream is not registered.
|
|
572
|
+
ValueMappingError: If the retained message cannot be decoded.
|
|
573
|
+
DexServiceError: If FlowService cannot perform the read.
|
|
574
|
+
"""
|
|
575
|
+
flow = self.registry._flow_for_stream(stream)
|
|
576
|
+
response = cast(
|
|
577
|
+
pb.ReadStreamResponse,
|
|
578
|
+
self._call(
|
|
579
|
+
self._service.ReadStream,
|
|
580
|
+
pb.ReadStreamRequest(
|
|
581
|
+
flow_id=require_name(flow_id),
|
|
582
|
+
flow_type=flow.name,
|
|
583
|
+
stream_name=stream.name,
|
|
584
|
+
resume_token=resume_token,
|
|
585
|
+
wait_time_seconds=(
|
|
586
|
+
0 if timeout is None else self._seconds32(timeout)
|
|
587
|
+
),
|
|
588
|
+
),
|
|
589
|
+
"read_stream",
|
|
590
|
+
flow_id,
|
|
591
|
+
"none",
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
if (
|
|
595
|
+
not response.HasField("message")
|
|
596
|
+
or not response.message.HasField("value")
|
|
597
|
+
or not response.message.HasField("created_time")
|
|
598
|
+
or not response.message.resume_token
|
|
599
|
+
):
|
|
600
|
+
raise ValueError("Dex returned an incomplete Stream message")
|
|
601
|
+
return StreamMessage(
|
|
602
|
+
self._values.decode(
|
|
603
|
+
response.message.value,
|
|
604
|
+
self._values.codec(stream.value_type),
|
|
605
|
+
),
|
|
606
|
+
response.message.resume_token,
|
|
607
|
+
response.message.created_time.ToDatetime(tzinfo=timezone.utc),
|
|
608
|
+
response.message.idempotency_key,
|
|
609
|
+
)
|
|
610
|
+
|
|
506
611
|
def wait_for_flow(
|
|
507
612
|
self,
|
|
508
613
|
flow_id: str,
|
|
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Protocol, Sequence, TypeVar
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from dex.attribute import Attribute, AttributeMap
|
|
17
17
|
from dex.channel import Channel, ChannelMap
|
|
18
|
+
from dex.stream import Stream
|
|
18
19
|
|
|
19
20
|
ValueT = TypeVar("ValueT")
|
|
20
21
|
|
|
@@ -189,3 +190,9 @@ class Context(Protocol):
|
|
|
189
190
|
self,
|
|
190
191
|
definition: ChannelMap[object],
|
|
191
192
|
) -> tuple[str, ...]: ...
|
|
193
|
+
|
|
194
|
+
def _write_stream(
|
|
195
|
+
self,
|
|
196
|
+
definition: Stream[ValueT],
|
|
197
|
+
value: ValueT,
|
|
198
|
+
) -> object: ...
|