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.
Files changed (62) hide show
  1. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/PKG-INFO +37 -5
  2. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/README.md +36 -4
  3. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/__init__.py +8 -1
  4. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_async_value_hydrator.py +16 -2
  5. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_async_worker_dispatcher.py +4 -0
  6. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_invocation_context.py +86 -4
  7. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_utils.py +7 -0
  8. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_value_hydrator.py +16 -2
  9. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_worker_dispatcher.py +4 -0
  10. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/async_client.py +5 -2
  11. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/attribute.py +56 -8
  12. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/channel.py +162 -15
  13. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/client.py +5 -2
  14. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/condition.py +3 -1
  15. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/context.py +6 -0
  16. dex_python_sdk-0.2.11/dex/dexpb/dex_pb2.py +477 -0
  17. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/dexpb/dex_pb2.pyi +50 -10
  18. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow.py +123 -11
  19. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow_options.py +5 -5
  20. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/runtime_errors.py +12 -0
  21. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/wait.py +18 -2
  22. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/pyproject.toml +1 -1
  23. dex_python_sdk-0.2.9/dex/dexpb/dex_pb2.py +0 -469
  24. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/LEGACY_NOTICES.md +0 -0
  25. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/LICENSE +0 -0
  26. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_async_worker_service.py +0 -0
  27. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_grpc_errors.py +0 -0
  28. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_native.pyi +0 -0
  29. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_value_mapper.py +0 -0
  30. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/_worker_service.py +0 -0
  31. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/async_worker.py +0 -0
  32. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/blob_cache.py +0 -0
  33. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/client_options.py +0 -0
  34. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/codec.py +0 -0
  35. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/dexpb/__init__.py +0 -0
  36. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/dexpb/dex_pb2_grpc.py +0 -0
  37. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow_config.py +0 -0
  38. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow_info.py +0 -0
  39. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/flow_result.py +0 -0
  40. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/py.typed +0 -0
  41. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/step.py +0 -0
  42. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/step_execution.py +0 -0
  43. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/stream.py +0 -0
  44. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/subflow.py +0 -0
  45. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/timer.py +0 -0
  46. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/worker.py +0 -0
  47. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/dex/worker_options.py +0 -0
  48. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/Cargo.lock +0 -0
  49. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/Cargo.toml +0 -0
  50. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  51. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  52. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  53. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  54. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  55. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  56. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  57. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  58. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  59. {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
  60. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  61. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.11}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  62. {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.9
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` and `Wait.any_of` may use unnamed Conditions. Every Condition in
288
- `Wait.any_combination_of` must have a non-empty user ID; the same Condition
289
- instance may appear in multiple combinations.
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. `AttributeMap.get_map_size/get_all_instance_keys` include
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` and `Wait.any_of` may use unnamed Conditions. Every Condition in
272
- `Wait.any_combination_of` must have a non-empty user ID; the same Condition
273
- instance may appear in multiple combinations.
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. `AttributeMap.get_map_size/get_all_instance_keys` include
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
- for entry, value in zip(result.attributes, hydrated[1:]):
130
- entry.value.CopyFrom(value)
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(sorted(unquote(key[len(prefix) :]) for key in physical_keys))
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
- for entry, value in zip(result.attributes, hydrated[1:]):
137
- entry.value.CopyFrom(value)
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 non-empty map key; omit for a singleton Attribute.
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 non-empty logical map key to observe.
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 require_name, require_persistence_definition_name
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 non-empty logical map key.
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 non-empty logical map key.
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 non-empty logical map key.
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 non-empty logical map key.
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
- require_name(instance)
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,