dex-python-sdk 0.2.9__tar.gz → 0.2.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/PKG-INFO +36 -5
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/README.md +35 -4
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/__init__.py +6 -1
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_async_value_hydrator.py +16 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_async_worker_dispatcher.py +4 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_invocation_context.py +83 -4
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_utils.py +7 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_value_hydrator.py +16 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_worker_dispatcher.py +4 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/async_client.py +5 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/attribute.py +56 -8
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/channel.py +162 -15
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/client.py +5 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/condition.py +3 -1
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/context.py +6 -0
- dex_python_sdk-0.2.10/dex/dexpb/dex_pb2.py +477 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/dexpb/dex_pb2.pyi +50 -10
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow.py +123 -11
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow_options.py +5 -5
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/runtime_errors.py +6 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/wait.py +18 -2
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/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.10}/LEGACY_NOTICES.md +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/LICENSE +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_async_worker_service.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_grpc_errors.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_native.pyi +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_value_mapper.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_worker_service.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/async_worker.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/blob_cache.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/client_options.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/codec.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/dexpb/__init__.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/dexpb/dex_pb2_grpc.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow_config.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow_info.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow_result.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/py.typed +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/step.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/step_execution.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/stream.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/subflow.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/timer.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/worker.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/worker_options.py +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/Cargo.lock +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/Cargo.toml +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
- {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dex-python-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
4
4
|
Requires-Dist: grpcio>=1.83.0
|
|
5
5
|
Requires-Dist: grpcio-status>=1.83.0
|
|
6
6
|
Requires-Dist: protobuf>=7.35.1
|
|
@@ -30,6 +30,34 @@ 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 or pending-message snapshot raises **StateNotLoadedError**.
|
|
53
|
+
Pending messages preserve FIFO order and include the server-assigned message ID.
|
|
54
|
+
The snapshot does not change after the handler stages a publish or deletion.
|
|
55
|
+
|
|
56
|
+
Loading controls which data reaches the Worker. **is_transactional** controls
|
|
57
|
+
atomic commit and Channel deletion validation. Attribute locks add isolation
|
|
58
|
+
only among cooperating Steps and RPCs using the same lock. Write-only publish
|
|
59
|
+
and delete operations do not require loading.
|
|
60
|
+
|
|
33
61
|
Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
|
|
34
62
|
|
|
35
63
|
## New user contracts
|
|
@@ -284,14 +312,17 @@ instead of raw Flow, Step, or RPC strings.
|
|
|
284
312
|
|
|
285
313
|
### Waiting and map inspection
|
|
286
314
|
|
|
287
|
-
`Wait.all_of
|
|
288
|
-
|
|
289
|
-
|
|
315
|
+
`Wait.until`, `Wait.all_of`, and `Wait.any_of` use unnamed Conditions by
|
|
316
|
+
default. Do not add condition IDs merely because a Condition is nested in one
|
|
317
|
+
of these waits. Every Condition in `Wait.any_combination_of` must have a
|
|
318
|
+
non-empty user ID; the same Condition instance may appear in multiple
|
|
319
|
+
combinations.
|
|
290
320
|
|
|
291
321
|
Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
|
|
292
322
|
overloads of `wait_for_attribute_equal`. They target the current run and accept
|
|
293
323
|
only string, bool, int, or float wire values. JSON objects, bytes, and null fail
|
|
294
|
-
before transport.
|
|
324
|
+
before transport. Every AttributeMap and ChannelMap instance must be non-empty
|
|
325
|
+
and must not contain `/`. `AttributeMap.get_map_size/get_all_instance_keys` include
|
|
295
326
|
buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
|
|
296
327
|
include buffered publishes, and omit empty instances. Keys are decoded and
|
|
297
328
|
sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
|
|
@@ -14,6 +14,34 @@ 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 or pending-message snapshot raises **StateNotLoadedError**.
|
|
37
|
+
Pending messages preserve FIFO order and include the server-assigned message ID.
|
|
38
|
+
The snapshot does not change after the handler stages a publish or deletion.
|
|
39
|
+
|
|
40
|
+
Loading controls which data reaches the Worker. **is_transactional** controls
|
|
41
|
+
atomic commit and Channel deletion validation. Attribute locks add isolation
|
|
42
|
+
only among cooperating Steps and RPCs using the same lock. Write-only publish
|
|
43
|
+
and delete operations do not require loading.
|
|
44
|
+
|
|
17
45
|
Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
|
|
18
46
|
|
|
19
47
|
## New user contracts
|
|
@@ -268,14 +296,17 @@ instead of raw Flow, Step, or RPC strings.
|
|
|
268
296
|
|
|
269
297
|
### Waiting and map inspection
|
|
270
298
|
|
|
271
|
-
`Wait.all_of
|
|
272
|
-
|
|
273
|
-
|
|
299
|
+
`Wait.until`, `Wait.all_of`, and `Wait.any_of` use unnamed Conditions by
|
|
300
|
+
default. Do not add condition IDs merely because a Condition is nested in one
|
|
301
|
+
of these waits. Every Condition in `Wait.any_combination_of` must have a
|
|
302
|
+
non-empty user ID; the same Condition instance may appear in multiple
|
|
303
|
+
combinations.
|
|
274
304
|
|
|
275
305
|
Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
|
|
276
306
|
overloads of `wait_for_attribute_equal`. They target the current run and accept
|
|
277
307
|
only string, bool, int, or float wire values. JSON objects, bytes, and null fail
|
|
278
|
-
before transport.
|
|
308
|
+
before transport. Every AttributeMap and ChannelMap instance must be non-empty
|
|
309
|
+
and must not contain `/`. `AttributeMap.get_map_size/get_all_instance_keys` include
|
|
279
310
|
buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
|
|
280
311
|
include buffered publishes, and omit empty instances. Keys are decoded and
|
|
281
312
|
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 (
|
|
@@ -70,6 +71,7 @@ from dex.runtime_errors import (
|
|
|
70
71
|
LongPollTimeoutError,
|
|
71
72
|
ChannelMessageNotFoundError,
|
|
72
73
|
RpcLockConflictError,
|
|
74
|
+
StateNotLoadedError,
|
|
73
75
|
ValueMappingError,
|
|
74
76
|
WorkerInvocationError,
|
|
75
77
|
)
|
|
@@ -116,6 +118,7 @@ __all__ = [
|
|
|
116
118
|
"AttributeIndex",
|
|
117
119
|
"AttributeLock",
|
|
118
120
|
"AttributeMap",
|
|
121
|
+
"AttributeMapLoad",
|
|
119
122
|
"AsyncContext",
|
|
120
123
|
"AsyncClient",
|
|
121
124
|
"AsyncBufferedTextStream",
|
|
@@ -125,6 +128,7 @@ __all__ = [
|
|
|
125
128
|
"BufferedTextStream",
|
|
126
129
|
"Channel",
|
|
127
130
|
"ChannelMap",
|
|
131
|
+
"ChannelMapLoad",
|
|
128
132
|
"ChannelMessage",
|
|
129
133
|
"ChannelMessageNotFoundError",
|
|
130
134
|
"Client",
|
|
@@ -156,6 +160,7 @@ __all__ = [
|
|
|
156
160
|
"RPCResult",
|
|
157
161
|
"RetryAfterError",
|
|
158
162
|
"RpcLockConflictError",
|
|
163
|
+
"StateNotLoadedError",
|
|
159
164
|
"Registry",
|
|
160
165
|
"TimeTravelOptions",
|
|
161
166
|
"TimeTravelStepMethod",
|
|
@@ -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,15 @@ 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 StateNotLoadedError
|
|
23
24
|
from dex.step import (
|
|
24
25
|
_NO_HEARTBEAT_VALUE,
|
|
25
26
|
StepOutput,
|
|
@@ -66,6 +67,10 @@ class InvocationContext:
|
|
|
66
67
|
locals: Sequence[pb.KV] = (),
|
|
67
68
|
condition_results: pb.ConditionResults | None = None,
|
|
68
69
|
channel_infos: dict[str, pb.ChannelInfo] | None = None,
|
|
70
|
+
loaded_channel_messages: dict[str, pb.ChannelValues] | None = None,
|
|
71
|
+
loaded_attribute_map_instances: Sequence[str] = (),
|
|
72
|
+
loaded_channel_names: Sequence[str] = (),
|
|
73
|
+
loaded_channel_map_instances: Sequence[str] = (),
|
|
69
74
|
is_active: Callable[[], bool] | None = None,
|
|
70
75
|
output_emitter: _StepOutputEmitter | None = None,
|
|
71
76
|
) -> None:
|
|
@@ -77,6 +82,10 @@ class InvocationContext:
|
|
|
77
82
|
self._locals = self._map_values("step-execution local", locals)
|
|
78
83
|
self._condition_results = condition_results
|
|
79
84
|
self._channel_infos = dict(channel_infos or {})
|
|
85
|
+
self._loaded_channel_messages = dict(loaded_channel_messages or {})
|
|
86
|
+
self._loaded_attribute_map_instances = frozenset(loaded_attribute_map_instances)
|
|
87
|
+
self._loaded_channel_names = frozenset(loaded_channel_names)
|
|
88
|
+
self._loaded_channel_map_instances = frozenset(loaded_channel_map_instances)
|
|
80
89
|
self._is_active = is_active or _always_active
|
|
81
90
|
self._output_emitter = output_emitter
|
|
82
91
|
self.attribute_writes: dict[str, pb.AttributeWrite] = {}
|
|
@@ -276,6 +285,7 @@ class InvocationContext:
|
|
|
276
285
|
instance: str | None,
|
|
277
286
|
) -> ValueT:
|
|
278
287
|
self._require_registered(definition)
|
|
288
|
+
self._require_attribute_map_instance_loaded(definition, instance)
|
|
279
289
|
key = self._physical_name(definition, instance)
|
|
280
290
|
write = self.attribute_writes.get(key)
|
|
281
291
|
if write is not None:
|
|
@@ -375,6 +385,7 @@ class InvocationContext:
|
|
|
375
385
|
definition: AttributeMap[object],
|
|
376
386
|
) -> tuple[str, ...]:
|
|
377
387
|
self._require_registered(definition)
|
|
388
|
+
self._require_attribute_map_all_loaded(definition)
|
|
378
389
|
prefix = f"{definition.name}/"
|
|
379
390
|
physical_keys = {key for key in self._attributes if key.startswith(prefix)}
|
|
380
391
|
for key, write in self.attribute_writes.items():
|
|
@@ -384,7 +395,12 @@ class InvocationContext:
|
|
|
384
395
|
physical_keys.discard(key)
|
|
385
396
|
else:
|
|
386
397
|
physical_keys.add(key)
|
|
387
|
-
return tuple(
|
|
398
|
+
return tuple(
|
|
399
|
+
sorted(
|
|
400
|
+
require_map_instance(unquote(key[len(prefix) :]))
|
|
401
|
+
for key in physical_keys
|
|
402
|
+
)
|
|
403
|
+
)
|
|
388
404
|
|
|
389
405
|
def _channel_map_keys(
|
|
390
406
|
self,
|
|
@@ -396,7 +412,7 @@ class InvocationContext:
|
|
|
396
412
|
prefix = f"{definition.name}/"
|
|
397
413
|
return tuple(
|
|
398
414
|
sorted(
|
|
399
|
-
unquote(key[len(prefix) :])
|
|
415
|
+
require_map_instance(unquote(key[len(prefix) :]))
|
|
400
416
|
for key, info in self._channel_infos.items()
|
|
401
417
|
if key.startswith(prefix) and info.size > 0
|
|
402
418
|
)
|
|
@@ -411,6 +427,39 @@ class InvocationContext:
|
|
|
411
427
|
info = self._channel_infos.get(self._physical_name(definition, instance))
|
|
412
428
|
return info.size if info is not None else 0
|
|
413
429
|
|
|
430
|
+
def _pending_channel_messages(
|
|
431
|
+
self,
|
|
432
|
+
definition: Channel[ValueT] | ChannelMap[ValueT],
|
|
433
|
+
instance: str | None,
|
|
434
|
+
) -> tuple[ChannelMessage[ValueT], ...]:
|
|
435
|
+
if self._method is not InvocationMethod.RPC:
|
|
436
|
+
raise ValueError("pending Channel messages require an RPC Context")
|
|
437
|
+
self._require_registered(definition)
|
|
438
|
+
if isinstance(definition, ChannelMap):
|
|
439
|
+
channel_name = self._physical_name(definition, instance)
|
|
440
|
+
is_loaded = (
|
|
441
|
+
f"{definition.name}/" in self._loaded_channel_map_instances
|
|
442
|
+
or channel_name in self._loaded_channel_map_instances
|
|
443
|
+
)
|
|
444
|
+
else:
|
|
445
|
+
channel_name = self._physical_name(definition, instance)
|
|
446
|
+
is_loaded = definition.name in self._loaded_channel_names
|
|
447
|
+
if not is_loaded:
|
|
448
|
+
raise StateNotLoadedError(
|
|
449
|
+
f"Channel messages were not loaded for RPC: {definition.name}"
|
|
450
|
+
)
|
|
451
|
+
values = self._loaded_channel_messages.get(channel_name)
|
|
452
|
+
if values is None:
|
|
453
|
+
return ()
|
|
454
|
+
codec = self._flow_codec(definition.value_type)
|
|
455
|
+
return tuple(
|
|
456
|
+
ChannelMessage(
|
|
457
|
+
message_id=message.message_id,
|
|
458
|
+
value=cast(ValueT, self._values.decode(message.value, codec)),
|
|
459
|
+
)
|
|
460
|
+
for message in values.messages
|
|
461
|
+
)
|
|
462
|
+
|
|
414
463
|
def _channel_results(
|
|
415
464
|
self,
|
|
416
465
|
definition: Channel[ValueT] | ChannelMap[ValueT],
|
|
@@ -432,6 +481,36 @@ class InvocationContext:
|
|
|
432
481
|
def _flow_codec(self, value_type: object) -> Codec[Any]:
|
|
433
482
|
return self._values.codec(value_type)
|
|
434
483
|
|
|
484
|
+
def _require_attribute_map_instance_loaded(
|
|
485
|
+
self,
|
|
486
|
+
definition: Attribute[Any] | AttributeMap[Any],
|
|
487
|
+
instance: str | None,
|
|
488
|
+
) -> None:
|
|
489
|
+
if self._method is not InvocationMethod.RPC or not isinstance(
|
|
490
|
+
definition, AttributeMap
|
|
491
|
+
):
|
|
492
|
+
return
|
|
493
|
+
physical_name = self._physical_name(definition, instance)
|
|
494
|
+
if (
|
|
495
|
+
f"{definition.name}/" not in self._loaded_attribute_map_instances
|
|
496
|
+
and physical_name not in self._loaded_attribute_map_instances
|
|
497
|
+
):
|
|
498
|
+
raise StateNotLoadedError(
|
|
499
|
+
f"AttributeMap instance was not loaded for RPC: {physical_name}"
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
def _require_attribute_map_all_loaded(
|
|
503
|
+
self,
|
|
504
|
+
definition: AttributeMap[object],
|
|
505
|
+
) -> None:
|
|
506
|
+
if (
|
|
507
|
+
self._method is InvocationMethod.RPC
|
|
508
|
+
and f"{definition.name}/" not in self._loaded_attribute_map_instances
|
|
509
|
+
):
|
|
510
|
+
raise StateNotLoadedError(
|
|
511
|
+
f"all AttributeMap instances were not loaded for RPC: {definition.name}"
|
|
512
|
+
)
|
|
513
|
+
|
|
435
514
|
def _require_registered(self, definition: _Definition) -> None:
|
|
436
515
|
registered = self._flow.persistence.get(definition.name)
|
|
437
516
|
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,
|