dex-python-sdk 0.2.9__tar.gz → 0.2.11__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.9 → dex_python_sdk-0.2.11}/PKG-INFO +37 -5
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/README.md +36 -4
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/__init__.py +8 -1
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_async_value_hydrator.py +16 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_async_worker_dispatcher.py +4 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_invocation_context.py +86 -4
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_utils.py +7 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_value_hydrator.py +16 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_worker_dispatcher.py +4 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/async_client.py +5 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/attribute.py +56 -8
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/channel.py +162 -15
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/client.py +5 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/condition.py +3 -1
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/context.py +6 -0
- dex_python_sdk-0.2.11/dex/dexpb/dex_pb2.py +477 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/dexpb/dex_pb2.pyi +50 -10
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow.py +123 -11
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow_options.py +5 -5
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/runtime_errors.py +12 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/wait.py +18 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/pyproject.toml +1 -1
- dex_python_sdk-0.2.9/dex/dexpb/dex_pb2.py +0 -469
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/LEGACY_NOTICES.md +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/LICENSE +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_async_worker_service.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_grpc_errors.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_native.pyi +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_value_mapper.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_worker_service.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/async_worker.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/blob_cache.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/client_options.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/codec.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/dexpb/__init__.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/dexpb/dex_pb2_grpc.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow_config.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow_info.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow_result.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/py.typed +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/step.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/step_execution.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/stream.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/subflow.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/timer.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/worker.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/worker_options.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/Cargo.lock +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/Cargo.toml +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/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.11
|
|
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
|
|
@@ -30,6 +30,35 @@ RPC handlers can stage `channel.delete(context, message_id)`. Declare the RPC as
|
|
|
30
30
|
Attribute locks already select transactional execution, but Channel deletion
|
|
31
31
|
itself does not.
|
|
32
32
|
|
|
33
|
+
## RPC state loading
|
|
34
|
+
|
|
35
|
+
RPCs receive ordinary Attributes and all Channel size metadata by default.
|
|
36
|
+
AttributeMap entries and pending Channel messages are opt-in:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
@dex.rpc(
|
|
40
|
+
load_attribute_map_instances=(items.load("tenant-a"),),
|
|
41
|
+
load_channels=(queued,),
|
|
42
|
+
load_channel_maps=(by_tenant,),
|
|
43
|
+
)
|
|
44
|
+
def snapshot(self, context: dex.Context) -> dex.RPCResult[Snapshot]:
|
|
45
|
+
messages = queued.pending_messages(context)
|
|
46
|
+
return dex.RPCResult(Snapshot(messages=messages))
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Put an AttributeMap or ChannelMap directly in its plural load option when every
|
|
50
|
+
current instance is needed. Use the singular instance options for the less common
|
|
51
|
+
exact-instance case. A selected empty queue returns an empty tuple; reading an
|
|
52
|
+
unselected map entry raises **AttributeMapNotLoadedError**. An unselected pending-message
|
|
53
|
+
snapshot raises **ChannelMessagesNotLoadedError**.
|
|
54
|
+
Pending messages preserve FIFO order and include the server-assigned message ID.
|
|
55
|
+
The snapshot does not change after the handler stages a publish or deletion.
|
|
56
|
+
|
|
57
|
+
Loading controls which data reaches the Worker. **is_transactional** controls
|
|
58
|
+
atomic commit and Channel deletion validation. Attribute locks add isolation
|
|
59
|
+
only among cooperating Steps and RPCs using the same lock. Write-only publish
|
|
60
|
+
and delete operations do not require loading.
|
|
61
|
+
|
|
33
62
|
Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
|
|
34
63
|
|
|
35
64
|
## New user contracts
|
|
@@ -284,14 +313,17 @@ instead of raw Flow, Step, or RPC strings.
|
|
|
284
313
|
|
|
285
314
|
### Waiting and map inspection
|
|
286
315
|
|
|
287
|
-
`Wait.all_of
|
|
288
|
-
|
|
289
|
-
|
|
316
|
+
`Wait.until`, `Wait.all_of`, and `Wait.any_of` use unnamed Conditions by
|
|
317
|
+
default. Do not add condition IDs merely because a Condition is nested in one
|
|
318
|
+
of these waits. Every Condition in `Wait.any_combination_of` must have a
|
|
319
|
+
non-empty user ID; the same Condition instance may appear in multiple
|
|
320
|
+
combinations.
|
|
290
321
|
|
|
291
322
|
Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
|
|
292
323
|
overloads of `wait_for_attribute_equal`. They target the current run and accept
|
|
293
324
|
only string, bool, int, or float wire values. JSON objects, bytes, and null fail
|
|
294
|
-
before transport.
|
|
325
|
+
before transport. Every AttributeMap and ChannelMap instance must be non-empty
|
|
326
|
+
and must not contain `/`. `AttributeMap.get_map_size/get_all_instance_keys` include
|
|
295
327
|
buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
|
|
296
328
|
include buffered publishes, and omit empty instances. Keys are decoded and
|
|
297
329
|
sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
|
|
@@ -14,6 +14,35 @@ RPC handlers can stage `channel.delete(context, message_id)`. Declare the RPC as
|
|
|
14
14
|
Attribute locks already select transactional execution, but Channel deletion
|
|
15
15
|
itself does not.
|
|
16
16
|
|
|
17
|
+
## RPC state loading
|
|
18
|
+
|
|
19
|
+
RPCs receive ordinary Attributes and all Channel size metadata by default.
|
|
20
|
+
AttributeMap entries and pending Channel messages are opt-in:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
@dex.rpc(
|
|
24
|
+
load_attribute_map_instances=(items.load("tenant-a"),),
|
|
25
|
+
load_channels=(queued,),
|
|
26
|
+
load_channel_maps=(by_tenant,),
|
|
27
|
+
)
|
|
28
|
+
def snapshot(self, context: dex.Context) -> dex.RPCResult[Snapshot]:
|
|
29
|
+
messages = queued.pending_messages(context)
|
|
30
|
+
return dex.RPCResult(Snapshot(messages=messages))
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Put an AttributeMap or ChannelMap directly in its plural load option when every
|
|
34
|
+
current instance is needed. Use the singular instance options for the less common
|
|
35
|
+
exact-instance case. A selected empty queue returns an empty tuple; reading an
|
|
36
|
+
unselected map entry raises **AttributeMapNotLoadedError**. An unselected pending-message
|
|
37
|
+
snapshot raises **ChannelMessagesNotLoadedError**.
|
|
38
|
+
Pending messages preserve FIFO order and include the server-assigned message ID.
|
|
39
|
+
The snapshot does not change after the handler stages a publish or deletion.
|
|
40
|
+
|
|
41
|
+
Loading controls which data reaches the Worker. **is_transactional** controls
|
|
42
|
+
atomic commit and Channel deletion validation. Attribute locks add isolation
|
|
43
|
+
only among cooperating Steps and RPCs using the same lock. Write-only publish
|
|
44
|
+
and delete operations do not require loading.
|
|
45
|
+
|
|
17
46
|
Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
|
|
18
47
|
|
|
19
48
|
## New user contracts
|
|
@@ -268,14 +297,17 @@ instead of raw Flow, Step, or RPC strings.
|
|
|
268
297
|
|
|
269
298
|
### Waiting and map inspection
|
|
270
299
|
|
|
271
|
-
`Wait.all_of
|
|
272
|
-
|
|
273
|
-
|
|
300
|
+
`Wait.until`, `Wait.all_of`, and `Wait.any_of` use unnamed Conditions by
|
|
301
|
+
default. Do not add condition IDs merely because a Condition is nested in one
|
|
302
|
+
of these waits. Every Condition in `Wait.any_combination_of` must have a
|
|
303
|
+
non-empty user ID; the same Condition instance may appear in multiple
|
|
304
|
+
combinations.
|
|
274
305
|
|
|
275
306
|
Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
|
|
276
307
|
overloads of `wait_for_attribute_equal`. They target the current run and accept
|
|
277
308
|
only string, bool, int, or float wire values. JSON objects, bytes, and null fail
|
|
278
|
-
before transport.
|
|
309
|
+
before transport. Every AttributeMap and ChannelMap instance must be non-empty
|
|
310
|
+
and must not contain `/`. `AttributeMap.get_map_size/get_all_instance_keys` include
|
|
279
311
|
buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
|
|
280
312
|
include buffered publishes, and omit empty instances. Keys are decoded and
|
|
281
313
|
sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
|
|
@@ -16,10 +16,11 @@ from dex.attribute import (
|
|
|
16
16
|
AttributeIndex,
|
|
17
17
|
AttributeLock,
|
|
18
18
|
AttributeMap,
|
|
19
|
+
AttributeMapLoad,
|
|
19
20
|
IndexType,
|
|
20
21
|
)
|
|
21
22
|
from dex.blob_cache import BlobCache, BlobCacheConfig, open_blob_cache
|
|
22
|
-
from dex.channel import Channel, ChannelMap, ChannelMessage
|
|
23
|
+
from dex.channel import Channel, ChannelMap, ChannelMapLoad, ChannelMessage
|
|
23
24
|
from dex.client import Client
|
|
24
25
|
from dex.client_options import ClientOptions
|
|
25
26
|
from dex.codec import (
|
|
@@ -61,6 +62,8 @@ from dex.flow_result import FlowResult, StepCompletion
|
|
|
61
62
|
from dex.runtime_errors import (
|
|
62
63
|
DexServiceError,
|
|
63
64
|
ErrorSubStatus,
|
|
65
|
+
AttributeMapNotLoadedError,
|
|
66
|
+
ChannelMessagesNotLoadedError,
|
|
64
67
|
FlowAlreadyStartedError,
|
|
65
68
|
FlowDefinitionError,
|
|
66
69
|
FlowErrorType,
|
|
@@ -116,6 +119,8 @@ __all__ = [
|
|
|
116
119
|
"AttributeIndex",
|
|
117
120
|
"AttributeLock",
|
|
118
121
|
"AttributeMap",
|
|
122
|
+
"AttributeMapLoad",
|
|
123
|
+
"AttributeMapNotLoadedError",
|
|
119
124
|
"AsyncContext",
|
|
120
125
|
"AsyncClient",
|
|
121
126
|
"AsyncBufferedTextStream",
|
|
@@ -125,8 +130,10 @@ __all__ = [
|
|
|
125
130
|
"BufferedTextStream",
|
|
126
131
|
"Channel",
|
|
127
132
|
"ChannelMap",
|
|
133
|
+
"ChannelMapLoad",
|
|
128
134
|
"ChannelMessage",
|
|
129
135
|
"ChannelMessageNotFoundError",
|
|
136
|
+
"ChannelMessagesNotLoadedError",
|
|
130
137
|
"Client",
|
|
131
138
|
"ClientOptions",
|
|
132
139
|
"Codec",
|
|
@@ -124,10 +124,24 @@ class AsyncValueHydrator:
|
|
|
124
124
|
result = pb.InvokeWorkerRPCRequest()
|
|
125
125
|
result.CopyFrom(request)
|
|
126
126
|
values = [request.input, *(entry.value for entry in request.attributes)]
|
|
127
|
+
channel_messages = [
|
|
128
|
+
message
|
|
129
|
+
for channel_values in request.loaded_channel_messages.values()
|
|
130
|
+
for message in channel_values.messages
|
|
131
|
+
]
|
|
132
|
+
values.extend(message.value for message in channel_messages)
|
|
127
133
|
hydrated = await self.hydrate_all(values)
|
|
128
134
|
result.input.CopyFrom(hydrated[0])
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
hydrated_entries = iter(hydrated[1:])
|
|
136
|
+
for entry in result.attributes:
|
|
137
|
+
entry.value.CopyFrom(next(hydrated_entries))
|
|
138
|
+
result_messages = [
|
|
139
|
+
message
|
|
140
|
+
for channel_values in result.loaded_channel_messages.values()
|
|
141
|
+
for message in channel_values.messages
|
|
142
|
+
]
|
|
143
|
+
for message in result_messages:
|
|
144
|
+
message.value.CopyFrom(next(hydrated_entries))
|
|
131
145
|
return result
|
|
132
146
|
|
|
133
147
|
async def step_outputs(
|
|
@@ -294,6 +294,10 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
|
|
|
294
294
|
self._values,
|
|
295
295
|
request.attributes,
|
|
296
296
|
channel_infos=dict(request.channel_infos),
|
|
297
|
+
loaded_channel_messages=dict(request.loaded_channel_messages),
|
|
298
|
+
loaded_attribute_map_instances=request.loaded_attribute_map_instances,
|
|
299
|
+
loaded_channel_names=request.loaded_channel_names,
|
|
300
|
+
loaded_channel_map_instances=request.loaded_channel_map_instances,
|
|
297
301
|
is_active=is_active,
|
|
298
302
|
)
|
|
299
303
|
arguments: list[object] = [context]
|
|
@@ -12,14 +12,18 @@ from enum import Enum
|
|
|
12
12
|
from typing import Any, Callable, Protocol, Sequence, TypeVar, cast
|
|
13
13
|
from urllib.parse import unquote
|
|
14
14
|
|
|
15
|
-
from dex._utils import require_name
|
|
15
|
+
from dex._utils import require_map_instance, require_name
|
|
16
16
|
from dex._value_mapper import ValueMapper
|
|
17
17
|
from dex.attribute import Attribute, AttributeMap, _apply_attribute_store_sync
|
|
18
|
-
from dex.channel import Channel, ChannelMap
|
|
18
|
+
from dex.channel import Channel, ChannelMap, ChannelMessage
|
|
19
19
|
from dex.codec import Codec
|
|
20
20
|
from dex.dexpb import dex_pb2 as pb
|
|
21
21
|
from dex.flow import Registry, _RegisteredFlow
|
|
22
22
|
from dex.flow_result import FlowResult, flow_result_from_proto
|
|
23
|
+
from dex.runtime_errors import (
|
|
24
|
+
AttributeMapNotLoadedError,
|
|
25
|
+
ChannelMessagesNotLoadedError,
|
|
26
|
+
)
|
|
23
27
|
from dex.step import (
|
|
24
28
|
_NO_HEARTBEAT_VALUE,
|
|
25
29
|
StepOutput,
|
|
@@ -66,6 +70,10 @@ class InvocationContext:
|
|
|
66
70
|
locals: Sequence[pb.KV] = (),
|
|
67
71
|
condition_results: pb.ConditionResults | None = None,
|
|
68
72
|
channel_infos: dict[str, pb.ChannelInfo] | None = None,
|
|
73
|
+
loaded_channel_messages: dict[str, pb.ChannelValues] | None = None,
|
|
74
|
+
loaded_attribute_map_instances: Sequence[str] = (),
|
|
75
|
+
loaded_channel_names: Sequence[str] = (),
|
|
76
|
+
loaded_channel_map_instances: Sequence[str] = (),
|
|
69
77
|
is_active: Callable[[], bool] | None = None,
|
|
70
78
|
output_emitter: _StepOutputEmitter | None = None,
|
|
71
79
|
) -> None:
|
|
@@ -77,6 +85,10 @@ class InvocationContext:
|
|
|
77
85
|
self._locals = self._map_values("step-execution local", locals)
|
|
78
86
|
self._condition_results = condition_results
|
|
79
87
|
self._channel_infos = dict(channel_infos or {})
|
|
88
|
+
self._loaded_channel_messages = dict(loaded_channel_messages or {})
|
|
89
|
+
self._loaded_attribute_map_instances = frozenset(loaded_attribute_map_instances)
|
|
90
|
+
self._loaded_channel_names = frozenset(loaded_channel_names)
|
|
91
|
+
self._loaded_channel_map_instances = frozenset(loaded_channel_map_instances)
|
|
80
92
|
self._is_active = is_active or _always_active
|
|
81
93
|
self._output_emitter = output_emitter
|
|
82
94
|
self.attribute_writes: dict[str, pb.AttributeWrite] = {}
|
|
@@ -276,6 +288,7 @@ class InvocationContext:
|
|
|
276
288
|
instance: str | None,
|
|
277
289
|
) -> ValueT:
|
|
278
290
|
self._require_registered(definition)
|
|
291
|
+
self._require_attribute_map_instance_loaded(definition, instance)
|
|
279
292
|
key = self._physical_name(definition, instance)
|
|
280
293
|
write = self.attribute_writes.get(key)
|
|
281
294
|
if write is not None:
|
|
@@ -375,6 +388,7 @@ class InvocationContext:
|
|
|
375
388
|
definition: AttributeMap[object],
|
|
376
389
|
) -> tuple[str, ...]:
|
|
377
390
|
self._require_registered(definition)
|
|
391
|
+
self._require_attribute_map_all_loaded(definition)
|
|
378
392
|
prefix = f"{definition.name}/"
|
|
379
393
|
physical_keys = {key for key in self._attributes if key.startswith(prefix)}
|
|
380
394
|
for key, write in self.attribute_writes.items():
|
|
@@ -384,7 +398,12 @@ class InvocationContext:
|
|
|
384
398
|
physical_keys.discard(key)
|
|
385
399
|
else:
|
|
386
400
|
physical_keys.add(key)
|
|
387
|
-
return tuple(
|
|
401
|
+
return tuple(
|
|
402
|
+
sorted(
|
|
403
|
+
require_map_instance(unquote(key[len(prefix) :]))
|
|
404
|
+
for key in physical_keys
|
|
405
|
+
)
|
|
406
|
+
)
|
|
388
407
|
|
|
389
408
|
def _channel_map_keys(
|
|
390
409
|
self,
|
|
@@ -396,7 +415,7 @@ class InvocationContext:
|
|
|
396
415
|
prefix = f"{definition.name}/"
|
|
397
416
|
return tuple(
|
|
398
417
|
sorted(
|
|
399
|
-
unquote(key[len(prefix) :])
|
|
418
|
+
require_map_instance(unquote(key[len(prefix) :]))
|
|
400
419
|
for key, info in self._channel_infos.items()
|
|
401
420
|
if key.startswith(prefix) and info.size > 0
|
|
402
421
|
)
|
|
@@ -411,6 +430,39 @@ class InvocationContext:
|
|
|
411
430
|
info = self._channel_infos.get(self._physical_name(definition, instance))
|
|
412
431
|
return info.size if info is not None else 0
|
|
413
432
|
|
|
433
|
+
def _pending_channel_messages(
|
|
434
|
+
self,
|
|
435
|
+
definition: Channel[ValueT] | ChannelMap[ValueT],
|
|
436
|
+
instance: str | None,
|
|
437
|
+
) -> tuple[ChannelMessage[ValueT], ...]:
|
|
438
|
+
if self._method is not InvocationMethod.RPC:
|
|
439
|
+
raise ValueError("pending Channel messages require an RPC Context")
|
|
440
|
+
self._require_registered(definition)
|
|
441
|
+
if isinstance(definition, ChannelMap):
|
|
442
|
+
channel_name = self._physical_name(definition, instance)
|
|
443
|
+
is_loaded = (
|
|
444
|
+
f"{definition.name}/" in self._loaded_channel_map_instances
|
|
445
|
+
or channel_name in self._loaded_channel_map_instances
|
|
446
|
+
)
|
|
447
|
+
else:
|
|
448
|
+
channel_name = self._physical_name(definition, instance)
|
|
449
|
+
is_loaded = definition.name in self._loaded_channel_names
|
|
450
|
+
if not is_loaded:
|
|
451
|
+
raise ChannelMessagesNotLoadedError(
|
|
452
|
+
f"Channel messages were not loaded for RPC: {definition.name}"
|
|
453
|
+
)
|
|
454
|
+
values = self._loaded_channel_messages.get(channel_name)
|
|
455
|
+
if values is None:
|
|
456
|
+
return ()
|
|
457
|
+
codec = self._flow_codec(definition.value_type)
|
|
458
|
+
return tuple(
|
|
459
|
+
ChannelMessage(
|
|
460
|
+
message_id=message.message_id,
|
|
461
|
+
value=cast(ValueT, self._values.decode(message.value, codec)),
|
|
462
|
+
)
|
|
463
|
+
for message in values.messages
|
|
464
|
+
)
|
|
465
|
+
|
|
414
466
|
def _channel_results(
|
|
415
467
|
self,
|
|
416
468
|
definition: Channel[ValueT] | ChannelMap[ValueT],
|
|
@@ -432,6 +484,36 @@ class InvocationContext:
|
|
|
432
484
|
def _flow_codec(self, value_type: object) -> Codec[Any]:
|
|
433
485
|
return self._values.codec(value_type)
|
|
434
486
|
|
|
487
|
+
def _require_attribute_map_instance_loaded(
|
|
488
|
+
self,
|
|
489
|
+
definition: Attribute[Any] | AttributeMap[Any],
|
|
490
|
+
instance: str | None,
|
|
491
|
+
) -> None:
|
|
492
|
+
if self._method is not InvocationMethod.RPC or not isinstance(
|
|
493
|
+
definition, AttributeMap
|
|
494
|
+
):
|
|
495
|
+
return
|
|
496
|
+
physical_name = self._physical_name(definition, instance)
|
|
497
|
+
if (
|
|
498
|
+
f"{definition.name}/" not in self._loaded_attribute_map_instances
|
|
499
|
+
and physical_name not in self._loaded_attribute_map_instances
|
|
500
|
+
):
|
|
501
|
+
raise AttributeMapNotLoadedError(
|
|
502
|
+
f"AttributeMap instance was not loaded for RPC: {physical_name}"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def _require_attribute_map_all_loaded(
|
|
506
|
+
self,
|
|
507
|
+
definition: AttributeMap[object],
|
|
508
|
+
) -> None:
|
|
509
|
+
if (
|
|
510
|
+
self._method is InvocationMethod.RPC
|
|
511
|
+
and f"{definition.name}/" not in self._loaded_attribute_map_instances
|
|
512
|
+
):
|
|
513
|
+
raise AttributeMapNotLoadedError(
|
|
514
|
+
f"all AttributeMap instances were not loaded for RPC: {definition.name}"
|
|
515
|
+
)
|
|
516
|
+
|
|
435
517
|
def _require_registered(self, definition: _Definition) -> None:
|
|
436
518
|
registered = self._flow.persistence.get(definition.name)
|
|
437
519
|
if registered is not definition:
|
|
@@ -20,6 +20,13 @@ def require_persistence_definition_name(name: str) -> str:
|
|
|
20
20
|
return name
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def require_map_instance(instance: str) -> str:
|
|
24
|
+
require_name(instance)
|
|
25
|
+
if "/" in instance:
|
|
26
|
+
raise ValueError("map instances must not contain '/'")
|
|
27
|
+
return instance
|
|
28
|
+
|
|
29
|
+
|
|
23
30
|
def validate_condition_id(condition_id: str | None) -> None:
|
|
24
31
|
if condition_id is not None and not condition_id:
|
|
25
32
|
raise ValueError("condition ID must not be empty")
|
|
@@ -131,10 +131,24 @@ class ValueHydrator:
|
|
|
131
131
|
result = pb.InvokeWorkerRPCRequest()
|
|
132
132
|
result.CopyFrom(request)
|
|
133
133
|
values = [request.input, *(entry.value for entry in request.attributes)]
|
|
134
|
+
channel_messages = [
|
|
135
|
+
message
|
|
136
|
+
for channel_values in request.loaded_channel_messages.values()
|
|
137
|
+
for message in channel_values.messages
|
|
138
|
+
]
|
|
139
|
+
values.extend(message.value for message in channel_messages)
|
|
134
140
|
hydrated = self.hydrate_all(values)
|
|
135
141
|
result.input.CopyFrom(hydrated[0])
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
hydrated_entries = iter(hydrated[1:])
|
|
143
|
+
for entry in result.attributes:
|
|
144
|
+
entry.value.CopyFrom(next(hydrated_entries))
|
|
145
|
+
result_messages = [
|
|
146
|
+
message
|
|
147
|
+
for channel_values in result.loaded_channel_messages.values()
|
|
148
|
+
for message in channel_values.messages
|
|
149
|
+
]
|
|
150
|
+
for message in result_messages:
|
|
151
|
+
message.value.CopyFrom(next(hydrated_entries))
|
|
138
152
|
return result
|
|
139
153
|
|
|
140
154
|
def step_outputs(
|
|
@@ -219,6 +219,10 @@ class WorkerDispatcher:
|
|
|
219
219
|
self._values,
|
|
220
220
|
request.attributes,
|
|
221
221
|
channel_infos=dict(request.channel_infos),
|
|
222
|
+
loaded_channel_messages=dict(request.loaded_channel_messages),
|
|
223
|
+
loaded_attribute_map_instances=request.loaded_attribute_map_instances,
|
|
224
|
+
loaded_channel_names=request.loaded_channel_names,
|
|
225
|
+
loaded_channel_map_instances=request.loaded_channel_map_instances,
|
|
222
226
|
is_active=is_active,
|
|
223
227
|
)
|
|
224
228
|
arguments: list[object] = [context]
|
|
@@ -274,6 +274,9 @@ class AsyncClient:
|
|
|
274
274
|
lock_attribute_keys=rpc.locks,
|
|
275
275
|
request_id=str(uuid4()),
|
|
276
276
|
is_transactional=rpc.options.is_transactional,
|
|
277
|
+
load_attribute_map_instances=rpc.load_attribute_map_instances,
|
|
278
|
+
load_channel_names=rpc.load_channel_names,
|
|
279
|
+
load_channel_map_instances=rpc.load_channel_map_instances,
|
|
277
280
|
),
|
|
278
281
|
"invoke_rpc",
|
|
279
282
|
flow_id,
|
|
@@ -319,7 +322,7 @@ class AsyncClient:
|
|
|
319
322
|
Args:
|
|
320
323
|
flow_id: The non-empty target Flow ID.
|
|
321
324
|
attribute: A typed singleton Attribute or AttributeMap definition.
|
|
322
|
-
instance: The
|
|
325
|
+
instance: The map instance. Omit it for a singleton Attribute. Slash is prohibited because it is a reserved character.
|
|
323
326
|
run_id: Optional exact run; ``""`` targets the current run.
|
|
324
327
|
|
|
325
328
|
Returns:
|
|
@@ -1068,7 +1071,7 @@ class AsyncClient:
|
|
|
1068
1071
|
Args:
|
|
1069
1072
|
flow_id: The non-empty active Flow ID.
|
|
1070
1073
|
attribute: The registered AttributeMap to observe.
|
|
1071
|
-
instance: The
|
|
1074
|
+
instance: The map instance to observe. Slash is prohibited because it is a reserved character.
|
|
1072
1075
|
expected: The string, bool, int, or float value to await.
|
|
1073
1076
|
timeout: The non-negative server-side wait duration.
|
|
1074
1077
|
|
|
@@ -13,14 +13,41 @@ from __future__ import annotations
|
|
|
13
13
|
from dataclasses import dataclass
|
|
14
14
|
from enum import Enum
|
|
15
15
|
from typing import Any, Generic, TypeVar, cast
|
|
16
|
+
from urllib.parse import quote
|
|
16
17
|
|
|
17
|
-
from dex._utils import
|
|
18
|
+
from dex._utils import require_map_instance, require_persistence_definition_name
|
|
18
19
|
from dex.context import Context
|
|
19
20
|
from dex.dexpb import dex_pb2 as pb
|
|
20
21
|
|
|
21
22
|
ValueT = TypeVar("ValueT")
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class AttributeMapLoad:
|
|
27
|
+
"""Load one AttributeMap instance for an RPC snapshot.
|
|
28
|
+
|
|
29
|
+
Create a load with :meth:`AttributeMap.load`. Loading provides a point-in-time
|
|
30
|
+
value snapshot; it does not lock the map against concurrent writers.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
attribute_map: The exact AttributeMap definition registered with the Flow.
|
|
34
|
+
instance: One logical instance key.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
attribute_map: AttributeMap[Any]
|
|
38
|
+
instance: str
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def physical_name(self) -> str:
|
|
42
|
+
"""Return the physical instance name for this typed load.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The encoded physical instance name.
|
|
46
|
+
"""
|
|
47
|
+
instance = require_map_instance(self.instance)
|
|
48
|
+
return f"{self.attribute_map.name}/{quote(instance, safe='')}"
|
|
49
|
+
|
|
50
|
+
|
|
24
51
|
class IndexType(Enum):
|
|
25
52
|
"""Select how Dex indexes an Attribute for Flow search.
|
|
26
53
|
|
|
@@ -145,6 +172,7 @@ class AttributeMap(Generic[ValueT]):
|
|
|
145
172
|
|
|
146
173
|
AttributeMap instances share one schema definition while keeping independent
|
|
147
174
|
values and locks. Declare the map in ``PersistenceSchema`` before using it.
|
|
175
|
+
Instance keys must be non-empty and must not contain ``/``.
|
|
148
176
|
Synced instances use their physical Attribute names as target columns. Projection
|
|
149
177
|
is asynchronous and latest-state only, deletion projects SQL NULL, and failures
|
|
150
178
|
do not roll back Flow Attributes.
|
|
@@ -171,19 +199,35 @@ class AttributeMap(Generic[ValueT]):
|
|
|
171
199
|
def __post_init__(self) -> None:
|
|
172
200
|
require_persistence_definition_name(self.name)
|
|
173
201
|
|
|
202
|
+
def load(self, instance: str) -> AttributeMapLoad:
|
|
203
|
+
"""Select one instance for an RPC snapshot.
|
|
204
|
+
|
|
205
|
+
Pass the result through ``@rpc(load_attribute_map_instances=(...))``.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
instance: The non-empty, slash-free logical map key to load.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
A load for this one instance.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValueError: If ``instance`` is empty or contains ``/``.
|
|
215
|
+
"""
|
|
216
|
+
return AttributeMapLoad(self, require_map_instance(instance))
|
|
217
|
+
|
|
174
218
|
def get(self, context: Context, instance: str) -> ValueT:
|
|
175
219
|
"""Return one map instance from a Step or RPC Context.
|
|
176
220
|
|
|
177
221
|
Args:
|
|
178
222
|
context: The current handler Context.
|
|
179
|
-
instance: The
|
|
223
|
+
instance: The map instance. Slash is prohibited because it is a reserved character.
|
|
180
224
|
|
|
181
225
|
Returns:
|
|
182
226
|
The decoded instance value.
|
|
183
227
|
|
|
184
228
|
Raises:
|
|
185
229
|
KeyError: If the instance has no value.
|
|
186
|
-
ValueError: If ``instance`` is empty
|
|
230
|
+
ValueError: If ``instance`` is empty or contains ``/``.
|
|
187
231
|
"""
|
|
188
232
|
return context._get_attribute(self, instance)
|
|
189
233
|
|
|
@@ -192,7 +236,7 @@ class AttributeMap(Generic[ValueT]):
|
|
|
192
236
|
|
|
193
237
|
Args:
|
|
194
238
|
context: The current handler Context.
|
|
195
|
-
instance: The
|
|
239
|
+
instance: The map instance. Slash is prohibited because it is a reserved character.
|
|
196
240
|
value: A value compatible with ``value_type``.
|
|
197
241
|
"""
|
|
198
242
|
context._set_attribute(self, instance, value)
|
|
@@ -202,7 +246,7 @@ class AttributeMap(Generic[ValueT]):
|
|
|
202
246
|
|
|
203
247
|
Args:
|
|
204
248
|
context: The current handler Context.
|
|
205
|
-
instance: The
|
|
249
|
+
instance: The map instance. Slash is prohibited because it is a reserved character.
|
|
206
250
|
"""
|
|
207
251
|
context._delete_attribute(cast(AttributeMap[object], self), instance)
|
|
208
252
|
|
|
@@ -232,15 +276,15 @@ class AttributeMap(Generic[ValueT]):
|
|
|
232
276
|
"""Return a lock request for one map instance.
|
|
233
277
|
|
|
234
278
|
Args:
|
|
235
|
-
instance: The
|
|
279
|
+
instance: The map instance. Slash is prohibited because it is a reserved character.
|
|
236
280
|
|
|
237
281
|
Returns:
|
|
238
282
|
A lock for ``instance``.
|
|
239
283
|
|
|
240
284
|
Raises:
|
|
241
|
-
ValueError: If ``instance`` is empty
|
|
285
|
+
ValueError: If ``instance`` is empty or contains ``/``.
|
|
242
286
|
"""
|
|
243
|
-
|
|
287
|
+
require_map_instance(instance)
|
|
244
288
|
return AttributeLock(self, instance)
|
|
245
289
|
|
|
246
290
|
|
|
@@ -256,6 +300,10 @@ class AttributeLock:
|
|
|
256
300
|
attribute: Attribute[Any] | AttributeMap[Any]
|
|
257
301
|
instance: str | None = None
|
|
258
302
|
|
|
303
|
+
def __post_init__(self) -> None:
|
|
304
|
+
if self.instance is not None:
|
|
305
|
+
require_map_instance(self.instance)
|
|
306
|
+
|
|
259
307
|
|
|
260
308
|
def _apply_attribute_store_sync(
|
|
261
309
|
write: pb.AttributeWrite,
|