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.
Files changed (62) hide show
  1. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/PKG-INFO +36 -5
  2. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/README.md +35 -4
  3. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/__init__.py +6 -1
  4. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_async_value_hydrator.py +16 -2
  5. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_async_worker_dispatcher.py +4 -0
  6. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_invocation_context.py +83 -4
  7. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_utils.py +7 -0
  8. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_value_hydrator.py +16 -2
  9. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_worker_dispatcher.py +4 -0
  10. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/async_client.py +5 -2
  11. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/attribute.py +56 -8
  12. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/channel.py +162 -15
  13. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/client.py +5 -2
  14. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/condition.py +3 -1
  15. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/context.py +6 -0
  16. dex_python_sdk-0.2.10/dex/dexpb/dex_pb2.py +477 -0
  17. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/dexpb/dex_pb2.pyi +50 -10
  18. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow.py +123 -11
  19. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow_options.py +5 -5
  20. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/runtime_errors.py +6 -0
  21. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/wait.py +18 -2
  22. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/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.10}/LEGACY_NOTICES.md +0 -0
  25. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/LICENSE +0 -0
  26. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_async_worker_service.py +0 -0
  27. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_grpc_errors.py +0 -0
  28. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_native.pyi +0 -0
  29. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_value_mapper.py +0 -0
  30. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/_worker_service.py +0 -0
  31. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/async_worker.py +0 -0
  32. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/blob_cache.py +0 -0
  33. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/client_options.py +0 -0
  34. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/codec.py +0 -0
  35. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/dexpb/__init__.py +0 -0
  36. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/dexpb/dex_pb2_grpc.py +0 -0
  37. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow_config.py +0 -0
  38. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow_info.py +0 -0
  39. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/flow_result.py +0 -0
  40. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/py.typed +0 -0
  41. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/step.py +0 -0
  42. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/step_execution.py +0 -0
  43. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/stream.py +0 -0
  44. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/subflow.py +0 -0
  45. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/timer.py +0 -0
  46. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/worker.py +0 -0
  47. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/dex/worker_options.py +0 -0
  48. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/Cargo.lock +0 -0
  49. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/Cargo.toml +0 -0
  50. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  51. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  52. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  53. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  54. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  55. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  56. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  57. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  58. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  59. {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
  60. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  61. {dex_python_sdk-0.2.9 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  62. {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.9
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` 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.
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. `AttributeMap.get_map_size/get_all_instance_keys` include
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` 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.
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. `AttributeMap.get_map_size/get_all_instance_keys` include
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
- 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,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(sorted(unquote(key[len(prefix) :]) for key in physical_keys))
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
- 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,