dex-python-sdk 0.2.8__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.8 → dex_python_sdk-0.2.10}/PKG-INFO +49 -5
  2. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/README.md +48 -4
  3. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/__init__.py +9 -1
  4. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_async_value_hydrator.py +16 -2
  5. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_async_worker_dispatcher.py +5 -0
  6. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_grpc_errors.py +6 -0
  7. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_invocation_context.py +104 -4
  8. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_utils.py +14 -0
  9. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_value_hydrator.py +16 -2
  10. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_worker_dispatcher.py +5 -0
  11. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/async_client.py +149 -3
  12. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/attribute.py +60 -12
  13. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/channel.py +201 -18
  14. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/client.py +147 -3
  15. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/condition.py +3 -1
  16. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/context.py +14 -1
  17. dex_python_sdk-0.2.10/dex/dexpb/dex_pb2.py +477 -0
  18. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/dexpb/dex_pb2.pyi +126 -24
  19. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/dexpb/dex_pb2_grpc.py +95 -9
  20. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/flow.py +127 -11
  21. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/flow_options.py +5 -5
  22. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/runtime_errors.py +14 -0
  23. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/wait.py +18 -2
  24. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/pyproject.toml +1 -1
  25. dex_python_sdk-0.2.8/dex/dexpb/dex_pb2.py +0 -459
  26. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/LEGACY_NOTICES.md +0 -0
  27. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/LICENSE +0 -0
  28. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_async_worker_service.py +0 -0
  29. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_native.pyi +0 -0
  30. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_value_mapper.py +0 -0
  31. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/_worker_service.py +0 -0
  32. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/async_worker.py +0 -0
  33. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/blob_cache.py +0 -0
  34. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/client_options.py +0 -0
  35. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/codec.py +0 -0
  36. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/dexpb/__init__.py +0 -0
  37. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/flow_config.py +0 -0
  38. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/flow_info.py +0 -0
  39. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/flow_result.py +0 -0
  40. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/py.typed +0 -0
  41. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/step.py +0 -0
  42. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/step_execution.py +0 -0
  43. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/stream.py +0 -0
  44. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/subflow.py +0 -0
  45. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/timer.py +0 -0
  46. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/worker.py +0 -0
  47. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/dex/worker_options.py +0 -0
  48. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/Cargo.lock +0 -0
  49. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/Cargo.toml +0 -0
  50. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  51. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  52. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  53. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  54. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  55. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  56. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  57. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  58. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  59. {dex_python_sdk-0.2.8 → 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.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  61. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.10}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  62. {dex_python_sdk-0.2.8 → 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.8
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
@@ -17,6 +17,47 @@ Project-URL: Repository, https://github.com/superdurable/dex
17
17
 
18
18
  # Dex SDK for Python
19
19
 
20
+ ## Pending Channel messages
21
+
22
+ `Client.get_channel_messages(flow_id, channel)` returns typed `ChannelMessage`
23
+ envelopes in FIFO order. Each envelope contains the decoded value and the UUIDv7
24
+ assigned by Dex. `Client.delete_channel_message` deletes only a still-pending
25
+ message and raises `ChannelMessageNotFoundError` after consumption or another
26
+ deletion.
27
+
28
+ RPC handlers can stage `channel.delete(context, message_id)`. Declare the RPC as
29
+ `@rpc(is_transactional=True)` when a missing message must abort its other writes.
30
+ Attribute locks already select transactional execution, but Channel deletion
31
+ itself does not.
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
+
20
61
  Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
21
62
 
22
63
  ## New user contracts
@@ -271,14 +312,17 @@ instead of raw Flow, Step, or RPC strings.
271
312
 
272
313
  ### Waiting and map inspection
273
314
 
274
- `Wait.all_of` and `Wait.any_of` may use unnamed Conditions. Every Condition in
275
- `Wait.any_combination_of` must have a non-empty user ID; the same Condition
276
- 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.
277
320
 
278
321
  Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
279
322
  overloads of `wait_for_attribute_equal`. They target the current run and accept
280
323
  only string, bool, int, or float wire values. JSON objects, bytes, and null fail
281
- 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
282
326
  buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
283
327
  include buffered publishes, and omit empty instances. Keys are decoded and
284
328
  sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
@@ -1,6 +1,47 @@
1
1
 
2
2
  # Dex SDK for Python
3
3
 
4
+ ## Pending Channel messages
5
+
6
+ `Client.get_channel_messages(flow_id, channel)` returns typed `ChannelMessage`
7
+ envelopes in FIFO order. Each envelope contains the decoded value and the UUIDv7
8
+ assigned by Dex. `Client.delete_channel_message` deletes only a still-pending
9
+ message and raises `ChannelMessageNotFoundError` after consumption or another
10
+ deletion.
11
+
12
+ RPC handlers can stage `channel.delete(context, message_id)`. Declare the RPC as
13
+ `@rpc(is_transactional=True)` when a missing message must abort its other writes.
14
+ Attribute locks already select transactional execution, but Channel deletion
15
+ itself does not.
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
+
4
45
  Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
5
46
 
6
47
  ## New user contracts
@@ -255,14 +296,17 @@ instead of raw Flow, Step, or RPC strings.
255
296
 
256
297
  ### Waiting and map inspection
257
298
 
258
- `Wait.all_of` and `Wait.any_of` may use unnamed Conditions. Every Condition in
259
- `Wait.any_combination_of` must have a non-empty user ID; the same Condition
260
- 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.
261
304
 
262
305
  Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
263
306
  overloads of `wait_for_attribute_equal`. They target the current run and accept
264
307
  only string, bool, int, or float wire values. JSON objects, bytes, and null fail
265
- 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
266
310
  buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
267
311
  include buffered publishes, and omit empty instances. Keys are decoded and
268
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
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 (
@@ -68,7 +69,9 @@ from dex.runtime_errors import (
68
69
  FlowNotFoundError,
69
70
  InvalidStepResultError,
70
71
  LongPollTimeoutError,
72
+ ChannelMessageNotFoundError,
71
73
  RpcLockConflictError,
74
+ StateNotLoadedError,
72
75
  ValueMappingError,
73
76
  WorkerInvocationError,
74
77
  )
@@ -115,6 +118,7 @@ __all__ = [
115
118
  "AttributeIndex",
116
119
  "AttributeLock",
117
120
  "AttributeMap",
121
+ "AttributeMapLoad",
118
122
  "AsyncContext",
119
123
  "AsyncClient",
120
124
  "AsyncBufferedTextStream",
@@ -124,6 +128,9 @@ __all__ = [
124
128
  "BufferedTextStream",
125
129
  "Channel",
126
130
  "ChannelMap",
131
+ "ChannelMapLoad",
132
+ "ChannelMessage",
133
+ "ChannelMessageNotFoundError",
127
134
  "Client",
128
135
  "ClientOptions",
129
136
  "Codec",
@@ -153,6 +160,7 @@ __all__ = [
153
160
  "RPCResult",
154
161
  "RetryAfterError",
155
162
  "RpcLockConflictError",
163
+ "StateNotLoadedError",
156
164
  "Registry",
157
165
  "TimeTravelOptions",
158
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]
@@ -306,6 +310,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
306
310
  response = pb.InvokeWorkerRPCResponse(
307
311
  upsert_attributes=list(context.attribute_writes.values()),
308
312
  record_events=context.events,
313
+ delete_from_channel=context.channel_deletions,
309
314
  publish_to_channel=context.publications,
310
315
  )
311
316
  if isinstance(returned, RPCResult):
@@ -19,6 +19,7 @@ from grpc_status import rpc_status
19
19
 
20
20
  from dex.dexpb import dex_pb2 as pb
21
21
  from dex.runtime_errors import (
22
+ ChannelMessageNotFoundError,
22
23
  DexServiceError,
23
24
  ErrorSubStatus,
24
25
  FlowAlreadyStartedError,
@@ -136,6 +137,8 @@ def translate_rpc_error(
136
137
  )
137
138
  if sub_status is ErrorSubStatus.LONG_POLL_TIMEOUT:
138
139
  return LongPollTimeoutError(*parameters)
140
+ if sub_status is ErrorSubStatus.CHANNEL_MESSAGE_NOT_FOUND:
141
+ return ChannelMessageNotFoundError(*parameters)
139
142
  return DexServiceError(*parameters)
140
143
 
141
144
 
@@ -219,5 +222,8 @@ def _map_sub_status(value: int) -> ErrorSubStatus:
219
222
  int(pb.ERROR_SUB_STATUS_FLOW_NOT_EXISTS): ErrorSubStatus.FLOW_NOT_EXISTS,
220
223
  int(pb.ERROR_SUB_STATUS_WORKER_API_ERROR): ErrorSubStatus.WORKER_API_ERROR,
221
224
  int(pb.ERROR_SUB_STATUS_LONG_POLL_TIME_OUT): ErrorSubStatus.LONG_POLL_TIMEOUT,
225
+ int(
226
+ pb.ERROR_SUB_STATUS_CHANNEL_MESSAGE_NOT_FOUND
227
+ ): ErrorSubStatus.CHANNEL_MESSAGE_NOT_FOUND,
222
228
  }
223
229
  return statuses.get(value, ErrorSubStatus.UNCATEGORIZED)
@@ -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,11 +82,16 @@ 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] = {}
83
92
  self.local_writes: dict[str, pb.KV] = {}
84
93
  self.events: list[pb.KV] = []
94
+ self.channel_deletions: list[pb.ChannelMessageDeletion] = []
85
95
  self.publications: list[pb.ChannelMessage] = []
86
96
  self._event_names: set[str] = set()
87
97
  self._step_output_finalizers: list[_StepOutputFinalizer] = []
@@ -275,6 +285,7 @@ class InvocationContext:
275
285
  instance: str | None,
276
286
  ) -> ValueT:
277
287
  self._require_registered(definition)
288
+ self._require_attribute_map_instance_loaded(definition, instance)
278
289
  key = self._physical_name(definition, instance)
279
290
  write = self.attribute_writes.get(key)
280
291
  if write is not None:
@@ -349,11 +360,32 @@ class InvocationContext:
349
360
  size=(current.size if current is not None else 0) + 1
350
361
  )
351
362
 
363
+ def _delete_channel_message(
364
+ self,
365
+ definition: Channel[object] | ChannelMap[object],
366
+ instance: str | None,
367
+ message_id: str,
368
+ ) -> None:
369
+ if self._method is not InvocationMethod.RPC:
370
+ raise ValueError("Channel message deletion requires an RPC Context")
371
+ self._require_registered(definition)
372
+ name = self._physical_name(definition, instance)
373
+ self.channel_deletions.append(
374
+ pb.ChannelMessageDeletion(
375
+ channel_name=name,
376
+ message_id=require_name(message_id),
377
+ )
378
+ )
379
+ current = self._channel_infos.get(name)
380
+ if current is not None and current.size > 0:
381
+ self._channel_infos[name] = pb.ChannelInfo(size=current.size - 1)
382
+
352
383
  def _attribute_map_keys(
353
384
  self,
354
385
  definition: AttributeMap[object],
355
386
  ) -> tuple[str, ...]:
356
387
  self._require_registered(definition)
388
+ self._require_attribute_map_all_loaded(definition)
357
389
  prefix = f"{definition.name}/"
358
390
  physical_keys = {key for key in self._attributes if key.startswith(prefix)}
359
391
  for key, write in self.attribute_writes.items():
@@ -363,7 +395,12 @@ class InvocationContext:
363
395
  physical_keys.discard(key)
364
396
  else:
365
397
  physical_keys.add(key)
366
- 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
+ )
367
404
 
368
405
  def _channel_map_keys(
369
406
  self,
@@ -375,7 +412,7 @@ class InvocationContext:
375
412
  prefix = f"{definition.name}/"
376
413
  return tuple(
377
414
  sorted(
378
- unquote(key[len(prefix) :])
415
+ require_map_instance(unquote(key[len(prefix) :]))
379
416
  for key, info in self._channel_infos.items()
380
417
  if key.startswith(prefix) and info.size > 0
381
418
  )
@@ -390,6 +427,39 @@ class InvocationContext:
390
427
  info = self._channel_infos.get(self._physical_name(definition, instance))
391
428
  return info.size if info is not None else 0
392
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
+
393
463
  def _channel_results(
394
464
  self,
395
465
  definition: Channel[ValueT] | ChannelMap[ValueT],
@@ -411,6 +481,36 @@ class InvocationContext:
411
481
  def _flow_codec(self, value_type: object) -> Codec[Any]:
412
482
  return self._values.codec(value_type)
413
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
+
414
514
  def _require_registered(self, definition: _Definition) -> None:
415
515
  registered = self._flow.persistence.get(definition.name)
416
516
  if registered is not definition:
@@ -13,6 +13,20 @@ def require_name(name: str) -> str:
13
13
  return name
14
14
 
15
15
 
16
+ def require_persistence_definition_name(name: str) -> str:
17
+ require_name(name)
18
+ if "/" in name:
19
+ raise ValueError("persistence definition names must not contain '/'")
20
+ return name
21
+
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
+
16
30
  def validate_condition_id(condition_id: str | None) -> None:
17
31
  if condition_id is not None and not condition_id:
18
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]
@@ -233,6 +237,7 @@ class WorkerDispatcher:
233
237
  response = pb.InvokeWorkerRPCResponse(
234
238
  upsert_attributes=list(context.attribute_writes.values()),
235
239
  record_events=context.events,
240
+ delete_from_channel=context.channel_deletions,
236
241
  publish_to_channel=context.publications,
237
242
  )
238
243
  if isinstance(returned, RPCResult):