dex-python-sdk 0.2.8__tar.gz → 0.2.9__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.9}/PKG-INFO +14 -1
  2. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/README.md +13 -0
  3. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/__init__.py +4 -1
  4. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_async_worker_dispatcher.py +1 -0
  5. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_grpc_errors.py +6 -0
  6. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_invocation_context.py +21 -0
  7. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_utils.py +7 -0
  8. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_worker_dispatcher.py +1 -0
  9. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/async_client.py +144 -1
  10. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/attribute.py +5 -5
  11. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/channel.py +41 -5
  12. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/client.py +142 -1
  13. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/context.py +8 -1
  14. dex_python_sdk-0.2.9/dex/dexpb/dex_pb2.py +469 -0
  15. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/dexpb/dex_pb2.pyi +78 -16
  16. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/dexpb/dex_pb2_grpc.py +95 -9
  17. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/flow.py +5 -1
  18. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/runtime_errors.py +8 -0
  19. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/pyproject.toml +1 -1
  20. dex_python_sdk-0.2.8/dex/dexpb/dex_pb2.py +0 -459
  21. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/LEGACY_NOTICES.md +0 -0
  22. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/LICENSE +0 -0
  23. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_async_value_hydrator.py +0 -0
  24. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_async_worker_service.py +0 -0
  25. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_native.pyi +0 -0
  26. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_value_hydrator.py +0 -0
  27. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_value_mapper.py +0 -0
  28. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/_worker_service.py +0 -0
  29. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/async_worker.py +0 -0
  30. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/blob_cache.py +0 -0
  31. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/client_options.py +0 -0
  32. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/codec.py +0 -0
  33. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/condition.py +0 -0
  34. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/dexpb/__init__.py +0 -0
  35. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/flow_config.py +0 -0
  36. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/flow_info.py +0 -0
  37. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/flow_options.py +0 -0
  38. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/flow_result.py +0 -0
  39. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/py.typed +0 -0
  40. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/step.py +0 -0
  41. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/step_execution.py +0 -0
  42. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/stream.py +0 -0
  43. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/subflow.py +0 -0
  44. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/timer.py +0 -0
  45. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/wait.py +0 -0
  46. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/worker.py +0 -0
  47. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/dex/worker_options.py +0 -0
  48. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/Cargo.lock +0 -0
  49. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/Cargo.toml +0 -0
  50. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  51. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  52. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  53. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  54. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  55. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  56. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  57. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  58. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  59. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/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.9}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  61. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  62. {dex_python_sdk-0.2.8 → dex_python_sdk-0.2.9}/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.9
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,19 @@ 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
+
20
33
  Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
21
34
 
22
35
  ## New user contracts
@@ -1,6 +1,19 @@
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
+
4
17
  Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
5
18
 
6
19
  ## New user contracts
@@ -19,7 +19,7 @@ from dex.attribute import (
19
19
  IndexType,
20
20
  )
21
21
  from dex.blob_cache import BlobCache, BlobCacheConfig, open_blob_cache
22
- from dex.channel import Channel, ChannelMap
22
+ from dex.channel import Channel, ChannelMap, ChannelMessage
23
23
  from dex.client import Client
24
24
  from dex.client_options import ClientOptions
25
25
  from dex.codec import (
@@ -68,6 +68,7 @@ from dex.runtime_errors import (
68
68
  FlowNotFoundError,
69
69
  InvalidStepResultError,
70
70
  LongPollTimeoutError,
71
+ ChannelMessageNotFoundError,
71
72
  RpcLockConflictError,
72
73
  ValueMappingError,
73
74
  WorkerInvocationError,
@@ -124,6 +125,8 @@ __all__ = [
124
125
  "BufferedTextStream",
125
126
  "Channel",
126
127
  "ChannelMap",
128
+ "ChannelMessage",
129
+ "ChannelMessageNotFoundError",
127
130
  "Client",
128
131
  "ClientOptions",
129
132
  "Codec",
@@ -306,6 +306,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
306
306
  response = pb.InvokeWorkerRPCResponse(
307
307
  upsert_attributes=list(context.attribute_writes.values()),
308
308
  record_events=context.events,
309
+ delete_from_channel=context.channel_deletions,
309
310
  publish_to_channel=context.publications,
310
311
  )
311
312
  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)
@@ -82,6 +82,7 @@ class InvocationContext:
82
82
  self.attribute_writes: dict[str, pb.AttributeWrite] = {}
83
83
  self.local_writes: dict[str, pb.KV] = {}
84
84
  self.events: list[pb.KV] = []
85
+ self.channel_deletions: list[pb.ChannelMessageDeletion] = []
85
86
  self.publications: list[pb.ChannelMessage] = []
86
87
  self._event_names: set[str] = set()
87
88
  self._step_output_finalizers: list[_StepOutputFinalizer] = []
@@ -349,6 +350,26 @@ class InvocationContext:
349
350
  size=(current.size if current is not None else 0) + 1
350
351
  )
351
352
 
353
+ def _delete_channel_message(
354
+ self,
355
+ definition: Channel[object] | ChannelMap[object],
356
+ instance: str | None,
357
+ message_id: str,
358
+ ) -> None:
359
+ if self._method is not InvocationMethod.RPC:
360
+ raise ValueError("Channel message deletion requires an RPC Context")
361
+ self._require_registered(definition)
362
+ name = self._physical_name(definition, instance)
363
+ self.channel_deletions.append(
364
+ pb.ChannelMessageDeletion(
365
+ channel_name=name,
366
+ message_id=require_name(message_id),
367
+ )
368
+ )
369
+ current = self._channel_infos.get(name)
370
+ if current is not None and current.size > 0:
371
+ self._channel_infos[name] = pb.ChannelInfo(size=current.size - 1)
372
+
352
373
  def _attribute_map_keys(
353
374
  self,
354
375
  definition: AttributeMap[object],
@@ -13,6 +13,13 @@ 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
+
16
23
  def validate_condition_id(condition_id: str | None) -> None:
17
24
  if condition_id is not None and not condition_id:
18
25
  raise ValueError("condition ID must not be empty")
@@ -233,6 +233,7 @@ class WorkerDispatcher:
233
233
  response = pb.InvokeWorkerRPCResponse(
234
234
  upsert_attributes=list(context.attribute_writes.values()),
235
235
  record_events=context.events,
236
+ delete_from_channel=context.channel_deletions,
236
237
  publish_to_channel=context.publications,
237
238
  )
238
239
  if isinstance(returned, RPCResult):
@@ -24,7 +24,7 @@ from dex._value_mapper import ValueMapper
24
24
  from dex._worker_dispatcher import WorkerDispatcher
25
25
  from dex.attribute import Attribute, AttributeMap, _apply_attribute_store_sync
26
26
  from dex.blob_cache import BlobCache
27
- from dex.channel import Channel, ChannelMap
27
+ from dex.channel import Channel, ChannelMap, ChannelMessage
28
28
  from dex.client_options import ClientOptions
29
29
  from dex.context import Context
30
30
  from dex.dexpb import dex_pb2 as pb
@@ -273,6 +273,7 @@ class AsyncClient:
273
273
  timeout_seconds=timeout,
274
274
  lock_attribute_keys=rpc.locks,
275
275
  request_id=str(uuid4()),
276
+ is_transactional=rpc.options.is_transactional,
276
277
  ),
277
278
  "invoke_rpc",
278
279
  flow_id,
@@ -544,6 +545,148 @@ class AsyncClient:
544
545
  "none",
545
546
  )
546
547
 
548
+ @overload
549
+ async def get_channel_messages(
550
+ self,
551
+ flow_id: str,
552
+ channel: Channel[ValueT],
553
+ /,
554
+ *,
555
+ run_id: str = "",
556
+ ) -> tuple[ChannelMessage[ValueT], ...]: ...
557
+
558
+ @overload
559
+ async def get_channel_messages(
560
+ self,
561
+ flow_id: str,
562
+ channel: ChannelMap[ValueT],
563
+ instance: str,
564
+ /,
565
+ *,
566
+ run_id: str = "",
567
+ ) -> tuple[ChannelMessage[ValueT], ...]: ...
568
+
569
+ async def get_channel_messages(
570
+ self,
571
+ flow_id: str,
572
+ channel: Channel[Any] | ChannelMap[Any],
573
+ instance: str | None = None,
574
+ /,
575
+ *,
576
+ run_id: str = "",
577
+ ) -> tuple[ChannelMessage[Any], ...]:
578
+ """Return every pending message for a Channel in FIFO order.
579
+
580
+ Args:
581
+ flow_id: The non-empty target Flow ID.
582
+ channel: A typed singleton Channel or ChannelMap definition.
583
+ instance: Required ChannelMap instance; omit for a singleton Channel.
584
+ run_id: Optional exact run; ``""`` targets the current run.
585
+
586
+ Returns:
587
+ Immutable typed message envelopes with server-assigned IDs.
588
+
589
+ Raises:
590
+ ValueMappingError: If a pending value cannot be decoded.
591
+ FlowNotFoundError: If the selected Flow run does not exist.
592
+ DexServiceError: If FlowService cannot read the queue.
593
+ """
594
+ name = self._definition_name(channel, instance)
595
+ response = cast(
596
+ pb.GetChannelMessagesResponse,
597
+ await self._call(
598
+ self._service.GetChannelMessages,
599
+ pb.GetChannelMessagesRequest(
600
+ flow_id=require_name(flow_id),
601
+ run_id=run_id,
602
+ channel_name=name,
603
+ ),
604
+ "get_channel_messages",
605
+ flow_id,
606
+ "existing",
607
+ ),
608
+ )
609
+ codec = self._values.codec(channel.value_type)
610
+ messages: list[ChannelMessage[Any]] = []
611
+ for message in response.messages:
612
+ messages.append(
613
+ ChannelMessage(
614
+ message_id=message.message_id,
615
+ value=self._values.decode(
616
+ await self._hydrator.hydrate(message.value),
617
+ codec,
618
+ ),
619
+ )
620
+ )
621
+ return tuple(messages)
622
+
623
+ @overload
624
+ async def delete_channel_message(
625
+ self,
626
+ flow_id: str,
627
+ channel: Channel[Any],
628
+ message_id: str,
629
+ /,
630
+ *,
631
+ run_id: str = "",
632
+ ) -> None: ...
633
+
634
+ @overload
635
+ async def delete_channel_message(
636
+ self,
637
+ flow_id: str,
638
+ channel: ChannelMap[Any],
639
+ instance: str,
640
+ message_id: str,
641
+ /,
642
+ *,
643
+ run_id: str = "",
644
+ ) -> None: ...
645
+
646
+ async def delete_channel_message(
647
+ self,
648
+ flow_id: str,
649
+ channel: Channel[Any] | ChannelMap[Any],
650
+ instance_or_message_id: str,
651
+ message_id: str | None = None,
652
+ /,
653
+ *,
654
+ run_id: str = "",
655
+ ) -> None:
656
+ """Delete one pending Channel message by its server-assigned ID.
657
+
658
+ Args:
659
+ flow_id: The non-empty target Flow ID.
660
+ channel: A typed singleton Channel or ChannelMap definition.
661
+ instance_or_message_id: Singleton message ID or ChannelMap instance.
662
+ message_id: ChannelMap message ID; omit for a singleton Channel.
663
+ run_id: Optional exact run; ``""`` targets the active run.
664
+
665
+ Raises:
666
+ ChannelMessageNotFoundError: If the message is no longer pending.
667
+ FlowNotActiveError: If the selected Flow run is closed.
668
+ DexServiceError: If FlowService cannot delete the message.
669
+ """
670
+ instance = instance_or_message_id if isinstance(channel, ChannelMap) else None
671
+ resolved_message_id = (
672
+ message_id if instance is not None else instance_or_message_id
673
+ )
674
+ if resolved_message_id is None:
675
+ raise ValueError("ChannelMap message ID is required")
676
+ await self._call(
677
+ self._service.DeleteChannelMessage,
678
+ pb.DeleteChannelMessageRequest(
679
+ flow_id=require_name(flow_id),
680
+ run_id=run_id,
681
+ channel_name=self._definition_name(channel, instance),
682
+ message_id=require_name(resolved_message_id),
683
+ request_id=str(uuid4()),
684
+ ),
685
+ "delete_channel_message",
686
+ flow_id,
687
+ "active",
688
+ )
689
+
547
690
  async def read_stream(
548
691
  self,
549
692
  flow_id: str,
@@ -14,7 +14,7 @@ from dataclasses import dataclass
14
14
  from enum import Enum
15
15
  from typing import Any, Generic, TypeVar, cast
16
16
 
17
- from dex._utils import require_name
17
+ from dex._utils import require_name, require_persistence_definition_name
18
18
  from dex.context import Context
19
19
  from dex.dexpb import dex_pb2 as pb
20
20
 
@@ -71,7 +71,7 @@ class Attribute(Generic[ValueT]):
71
71
  projects SQL NULL, and failures do not roll back the Flow Attribute.
72
72
 
73
73
  Attributes:
74
- name: The non-empty logical Attribute name, unique within its Flow.
74
+ name: The non-empty logical Attribute name without ``/``, unique within its Flow.
75
75
  value_type: The Python type used to encode and decode values.
76
76
  index: Optional search-index configuration; ``None`` disables indexing.
77
77
  sync_to_attribute_store: Whether writes are projected to the selected
@@ -90,7 +90,7 @@ class Attribute(Generic[ValueT]):
90
90
  sync_to_attribute_store: bool = False
91
91
 
92
92
  def __post_init__(self) -> None:
93
- require_name(self.name)
93
+ require_persistence_definition_name(self.name)
94
94
 
95
95
  def get(self, context: Context) -> ValueT:
96
96
  """Return the current value from a Step or RPC Context.
@@ -150,7 +150,7 @@ class AttributeMap(Generic[ValueT]):
150
150
  do not roll back Flow Attributes.
151
151
 
152
152
  Attributes:
153
- name: The non-empty logical Attribute name, unique within its Flow.
153
+ name: The non-empty logical Attribute name without ``/``, unique within its Flow.
154
154
  value_type: The Python type used for every map instance.
155
155
  index: Optional shared search-index configuration.
156
156
  sync_to_attribute_store: Whether every instance is projected to the selected
@@ -169,7 +169,7 @@ class AttributeMap(Generic[ValueT]):
169
169
  sync_to_attribute_store: bool = False
170
170
 
171
171
  def __post_init__(self) -> None:
172
- require_name(self.name)
172
+ require_persistence_definition_name(self.name)
173
173
 
174
174
  def get(self, context: Context, instance: str) -> ValueT:
175
175
  """Return one map instance from a Step or RPC Context.
@@ -13,13 +13,26 @@ from __future__ import annotations
13
13
  from dataclasses import dataclass
14
14
  from typing import Generic, Sequence, TypeVar, cast
15
15
 
16
- from dex._utils import require_name
16
+ from dex._utils import require_persistence_definition_name
17
17
  from dex.condition import ChannelCondition, Condition
18
18
  from dex.context import Context
19
19
 
20
20
  ValueT = TypeVar("ValueT")
21
21
 
22
22
 
23
+ @dataclass(frozen=True)
24
+ class ChannelMessage(Generic[ValueT]):
25
+ """Represent one pending Channel message and its server-assigned identity.
26
+
27
+ Attributes:
28
+ message_id: UUIDv7 assigned by Dex when the message was published.
29
+ value: The decoded Channel value.
30
+ """
31
+
32
+ message_id: str
33
+ value: ValueT
34
+
35
+
23
36
  @dataclass(frozen=True)
24
37
  class Channel(Generic[ValueT]):
25
38
  """Define a typed, durable singleton message stream owned by a Flow.
@@ -29,7 +42,7 @@ class Channel(Generic[ValueT]):
29
42
  selected values through ``results``.
30
43
 
31
44
  Attributes:
32
- name: The non-empty Channel name, unique within its Flow.
45
+ name: The non-empty Channel name without ``/``, unique within its Flow.
33
46
  value_type: The Python type of every published value.
34
47
 
35
48
  Examples:
@@ -42,7 +55,7 @@ class Channel(Generic[ValueT]):
42
55
  value_type: type[ValueT]
43
56
 
44
57
  def __post_init__(self) -> None:
45
- require_name(self.name)
58
+ require_persistence_definition_name(self.name)
46
59
 
47
60
  def publish(self, context: Context, value: ValueT) -> None:
48
61
  """Stage one value to append with the current handler decision.
@@ -53,6 +66,19 @@ class Channel(Generic[ValueT]):
53
66
  """
54
67
  context._publish_channel(self, None, value)
55
68
 
69
+ def delete(self, context: Context, message_id: str) -> None:
70
+ """Stage deletion of one pending message from an RPC handler.
71
+
72
+ Use ``@rpc(is_transactional=True)`` when a missing message must fail the
73
+ entire RPC without committing its other writes. Step Contexts reject this
74
+ operation.
75
+
76
+ Args:
77
+ context: The current RPC Context.
78
+ message_id: Non-empty ID returned by a Client pending-message read.
79
+ """
80
+ context._delete_channel_message(self, None, message_id)
81
+
56
82
  def size(self, context: Context) -> int:
57
83
  """Return the current number of queued values.
58
84
 
@@ -173,7 +199,7 @@ class ChannelMap(Generic[ValueT]):
173
199
  Add the ChannelMap definition to the Flow's ``PersistenceSchema``.
174
200
 
175
201
  Attributes:
176
- name: The non-empty Channel name, unique within its Flow.
202
+ name: The non-empty Channel name without ``/``, unique within its Flow.
177
203
  value_type: The Python type of every published value.
178
204
 
179
205
  Examples:
@@ -186,7 +212,7 @@ class ChannelMap(Generic[ValueT]):
186
212
  value_type: type[ValueT]
187
213
 
188
214
  def __post_init__(self) -> None:
189
- require_name(self.name)
215
+ require_persistence_definition_name(self.name)
190
216
 
191
217
  def publish(self, context: Context, instance: str, value: ValueT) -> None:
192
218
  """Stage a value to append to one ChannelMap instance.
@@ -198,6 +224,16 @@ class ChannelMap(Generic[ValueT]):
198
224
  """
199
225
  context._publish_channel(self, instance, value)
200
226
 
227
+ def delete(self, context: Context, instance: str, message_id: str) -> None:
228
+ """Stage deletion of one pending message from a ChannelMap instance.
229
+
230
+ Args:
231
+ context: The current RPC Context.
232
+ instance: The non-empty logical map key.
233
+ message_id: Non-empty ID returned by a Client pending-message read.
234
+ """
235
+ context._delete_channel_message(self, instance, message_id)
236
+
201
237
  def size(self, context: Context, instance: str) -> int:
202
238
  """Return the queued value count for one instance.
203
239
 
@@ -24,7 +24,7 @@ from dex._value_mapper import ValueMapper
24
24
  from dex._worker_dispatcher import WorkerDispatcher
25
25
  from dex.attribute import Attribute, AttributeMap, _apply_attribute_store_sync
26
26
  from dex.blob_cache import BlobCache
27
- from dex.channel import Channel, ChannelMap
27
+ from dex.channel import Channel, ChannelMap, ChannelMessage
28
28
  from dex.client_options import ClientOptions
29
29
  from dex.context import Context
30
30
  from dex.dexpb import dex_pb2 as pb
@@ -273,6 +273,7 @@ class Client:
273
273
  timeout_seconds=timeout,
274
274
  lock_attribute_keys=rpc.locks,
275
275
  request_id=str(uuid4()),
276
+ is_transactional=rpc.options.is_transactional,
276
277
  ),
277
278
  "invoke_rpc",
278
279
  flow_id,
@@ -545,6 +546,146 @@ class Client:
545
546
  "none",
546
547
  )
547
548
 
549
+ @overload
550
+ def get_channel_messages(
551
+ self,
552
+ flow_id: str,
553
+ channel: Channel[ValueT],
554
+ /,
555
+ *,
556
+ run_id: str = "",
557
+ ) -> tuple[ChannelMessage[ValueT], ...]: ...
558
+
559
+ @overload
560
+ def get_channel_messages(
561
+ self,
562
+ flow_id: str,
563
+ channel: ChannelMap[ValueT],
564
+ instance: str,
565
+ /,
566
+ *,
567
+ run_id: str = "",
568
+ ) -> tuple[ChannelMessage[ValueT], ...]: ...
569
+
570
+ def get_channel_messages(
571
+ self,
572
+ flow_id: str,
573
+ channel: Channel[Any] | ChannelMap[Any],
574
+ instance: str | None = None,
575
+ /,
576
+ *,
577
+ run_id: str = "",
578
+ ) -> tuple[ChannelMessage[Any], ...]:
579
+ """Return every pending message for a Channel in FIFO order.
580
+
581
+ Args:
582
+ flow_id: The non-empty target Flow ID.
583
+ channel: A typed singleton Channel or ChannelMap definition.
584
+ instance: Required ChannelMap instance; omit for a singleton Channel.
585
+ run_id: Optional exact run; ``""`` targets the current run.
586
+
587
+ Returns:
588
+ Immutable typed message envelopes with server-assigned IDs.
589
+
590
+ Raises:
591
+ ValueMappingError: If a pending value cannot be decoded.
592
+ FlowNotFoundError: If the selected Flow run does not exist.
593
+ DexServiceError: If FlowService cannot read the queue.
594
+ """
595
+ name = self._definition_name(channel, instance)
596
+ response = cast(
597
+ pb.GetChannelMessagesResponse,
598
+ self._call(
599
+ self._service.GetChannelMessages,
600
+ pb.GetChannelMessagesRequest(
601
+ flow_id=require_name(flow_id),
602
+ run_id=run_id,
603
+ channel_name=name,
604
+ ),
605
+ "get_channel_messages",
606
+ flow_id,
607
+ "existing",
608
+ ),
609
+ )
610
+ codec = self._values.codec(channel.value_type)
611
+ return tuple(
612
+ ChannelMessage(
613
+ message_id=message.message_id,
614
+ value=self._values.decode(
615
+ self._hydrator.hydrate(message.value),
616
+ codec,
617
+ ),
618
+ )
619
+ for message in response.messages
620
+ )
621
+
622
+ @overload
623
+ def delete_channel_message(
624
+ self,
625
+ flow_id: str,
626
+ channel: Channel[Any],
627
+ message_id: str,
628
+ /,
629
+ *,
630
+ run_id: str = "",
631
+ ) -> None: ...
632
+
633
+ @overload
634
+ def delete_channel_message(
635
+ self,
636
+ flow_id: str,
637
+ channel: ChannelMap[Any],
638
+ instance: str,
639
+ message_id: str,
640
+ /,
641
+ *,
642
+ run_id: str = "",
643
+ ) -> None: ...
644
+
645
+ def delete_channel_message(
646
+ self,
647
+ flow_id: str,
648
+ channel: Channel[Any] | ChannelMap[Any],
649
+ instance_or_message_id: str,
650
+ message_id: str | None = None,
651
+ /,
652
+ *,
653
+ run_id: str = "",
654
+ ) -> None:
655
+ """Delete one pending Channel message by its server-assigned ID.
656
+
657
+ Args:
658
+ flow_id: The non-empty target Flow ID.
659
+ channel: A typed singleton Channel or ChannelMap definition.
660
+ instance_or_message_id: Singleton message ID or ChannelMap instance.
661
+ message_id: ChannelMap message ID; omit for a singleton Channel.
662
+ run_id: Optional exact run; ``""`` targets the active run.
663
+
664
+ Raises:
665
+ ChannelMessageNotFoundError: If the message is no longer pending.
666
+ FlowNotActiveError: If the selected Flow run is closed.
667
+ DexServiceError: If FlowService cannot delete the message.
668
+ """
669
+ instance = instance_or_message_id if isinstance(channel, ChannelMap) else None
670
+ resolved_message_id = (
671
+ message_id if instance is not None else instance_or_message_id
672
+ )
673
+ if resolved_message_id is None:
674
+ raise ValueError("ChannelMap message ID is required")
675
+ self._call(
676
+ self._service.DeleteChannelMessage,
677
+ pb.DeleteChannelMessageRequest(
678
+ flow_id=require_name(flow_id),
679
+ run_id=run_id,
680
+ channel_name=self._definition_name(channel, instance),
681
+ message_id=require_name(resolved_message_id),
682
+ request_id=str(uuid4()),
683
+ ),
684
+ "delete_channel_message",
685
+ flow_id,
686
+ "active",
687
+ )
688
+
548
689
  def read_stream(
549
690
  self,
550
691
  flow_id: str,
@@ -10,7 +10,7 @@
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- from typing import TYPE_CHECKING, Protocol, Sequence, TypeVar
13
+ from typing import TYPE_CHECKING, Any, Protocol, Sequence, TypeVar
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  from dex.attribute import Attribute, AttributeMap
@@ -210,6 +210,13 @@ class Context(Protocol):
210
210
  value: ValueT,
211
211
  ) -> None: ...
212
212
 
213
+ def _delete_channel_message(
214
+ self,
215
+ definition: Channel[Any] | ChannelMap[Any],
216
+ instance: str | None,
217
+ message_id: str,
218
+ ) -> None: ...
219
+
213
220
  def _channel_size(
214
221
  self,
215
222
  definition: Channel[object] | ChannelMap[object],