Skip to content

Commit 86ae5d2

Browse files
committed
Added client Stream handles and Stream reads.
`Client.stream` and `WorkflowHandle.stream` get a Stream with no call. A read yields Records, the Superseded items that withdraw earlier Records, and the close.
1 parent 9f62387 commit 86ae5d2

13 files changed

Lines changed: 1873 additions & 1 deletion

File tree

‎temporalio/client/_client.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
TYPE_CHECKING,
1414
Any,
1515
Concatenate,
16+
TypeVar,
1617
cast,
1718
overload,
1819
)
@@ -110,11 +111,13 @@
110111
)
111112

112113
if TYPE_CHECKING:
113-
from temporalio.common import StreamBackend
114+
from temporalio.common import ClientStream, StreamBackend, StreamHandle
114115

115116
from ._interceptor import Interceptor
116117
from ._plugin import Plugin
117118

119+
_StreamDefinitionT = TypeVar("_StreamDefinitionT")
120+
118121

119122
class Client:
120123
"""Client for accessing Temporal.
@@ -911,6 +914,42 @@ def get_workflow_handle(
911914
result_type=result_type,
912915
)
913916

917+
def stream(
918+
self,
919+
definition: type[_StreamDefinitionT],
920+
*,
921+
id: str | None = None,
922+
stream: StreamHandle[Any] | None = None,
923+
backend: str | None = None,
924+
) -> ClientStream[_StreamDefinitionT]:
925+
"""Get a Stream by its id, or from a handle that came in data, such as in a Record.
926+
927+
This method makes no call. To create a Stream that the client owns, use a backend's
928+
``create`` function.
929+
930+
.. warning::
931+
This API is experimental.
932+
933+
Args:
934+
definition: The Stream definition class.
935+
id: The Stream id.
936+
stream: A handle of the Stream, such as one in a Record. The result keeps its id,
937+
owner and backend.
938+
backend: The name of the backend. Defaults to the handle's backend, or else to the
939+
registered backend with ``default=True``.
940+
941+
Raises:
942+
StreamConfigError: The client has no backend with that name, or no backend has
943+
``default=True`` and the call gives no backend.
944+
TypeError: Neither or both of ``id`` and ``stream`` are given.
945+
ValueError: The id is empty, or ``backend`` is not the handle's backend.
946+
"""
947+
from temporalio.common._streams import _stream_client
948+
949+
return _stream_client.client_stream(
950+
self, definition, id=id, stream=stream, backend=backend
951+
)
952+
914953
def get_workflow_handle_for(
915954
self,
916955
workflow: (

‎temporalio/client/_workflow.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Any,
2222
Concatenate,
2323
Generic,
24+
TypeVar,
2425
cast,
2526
overload,
2627
)
@@ -78,9 +79,13 @@
7879
)
7980

8081
if TYPE_CHECKING:
82+
from temporalio.common import ClientStream
83+
8184
from ._client import Client
8285
from ._interceptor import ListWorkflowsInput
8386

87+
_StreamDefinitionT = TypeVar("_StreamDefinitionT")
88+
8489

8590
class WorkflowHistoryEventFilterType(IntEnum):
8691
"""Type of history events to get for a workflow.
@@ -187,6 +192,32 @@ def first_execution_run_id(self) -> str | None:
187192
"""
188193
return self._first_execution_run_id
189194

195+
def stream(
196+
self, definition: type[_StreamDefinitionT], *, backend: str | None = None
197+
) -> ClientStream[_StreamDefinitionT]:
198+
"""Get a Stream that this Workflow created, by its definition.
199+
200+
This method builds the Stream id from the Workflow id and the first run id, so it makes
201+
no call. A handle with no first run id makes one Describe call when one of its Streams
202+
is first used, and caches the result.
203+
204+
.. warning::
205+
This API is experimental.
206+
207+
Args:
208+
definition: The Stream definition class, with the same name as in the Workflow's
209+
create.
210+
backend: The name of the backend. Defaults to the registered backend with
211+
``default=True``.
212+
213+
Raises:
214+
StreamConfigError: The client has no backend with that name, or no backend has
215+
``default=True`` and the call gives no backend.
216+
"""
217+
from temporalio.common._streams import _stream_client
218+
219+
return _stream_client.workflow_stream(self, definition, backend=backend)
220+
190221
async def result(
191222
self,
192223
*,

‎temporalio/common/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,15 @@
6666
Superseded,
6767
)
6868
from temporalio.common._streams._plugin import StreamBackend
69+
from temporalio.common._streams._reader import Subscription
70+
from temporalio.common._streams._stream_client import ClientStream, ClientTopic
6971

7072
__all__ = [
7173
"ActivityIDConflictPolicy",
7274
"ActivityIDReusePolicy",
7375
"AutoUpgradeVersioningOverride",
76+
"ClientStream",
77+
"ClientTopic",
7478
"CloseStatus",
7579
"Closed",
7680
"Creating",
@@ -122,6 +126,7 @@
122126
"StreamStorageError",
123127
"StreamTooLargeError",
124128
"StreamUnsupportedError",
129+
"Subscription",
125130
"Superseded",
126131
"TopicDefinition",
127132
"TypedSearchAttributes",
@@ -1591,6 +1596,8 @@ def wait_sync(self, timeout: float | None = None) -> None:
15911596
# so this module imports the Stream names on first use.
15921597
_STREAM_NAMES = frozenset(
15931598
[
1599+
"ClientStream",
1600+
"ClientTopic",
15941601
"CloseStatus",
15951602
"Closed",
15961603
"Creating",
@@ -1613,6 +1620,7 @@ def wait_sync(self, timeout: float | None = None) -> None:
16131620
"StreamStorageError",
16141621
"StreamTooLargeError",
16151622
"StreamUnsupportedError",
1623+
"Subscription",
16161624
"Superseded",
16171625
"TopicDefinition",
16181626
"defn",

‎temporalio/common/_streams/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@
3232
Superseded,
3333
)
3434
from temporalio.common._streams._plugin import StreamBackend
35+
from temporalio.common._streams._reader import Subscription
36+
from temporalio.common._streams._stream_client import ClientStream, ClientTopic
3537

3638
__all__ = [
3739
"END",
40+
"ClientStream",
41+
"ClientTopic",
3842
"CloseStatus",
3943
"Closed",
4044
"Creating",
@@ -56,6 +60,7 @@
5660
"StreamStorageError",
5761
"StreamTooLargeError",
5862
"StreamUnsupportedError",
63+
"Subscription",
5964
"Superseded",
6065
"TopicDefinition",
6166
"defn",
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Stream calls outside Workflow code, which Activity and client handles share."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence
6+
from dataclasses import dataclass
7+
from datetime import timedelta
8+
from typing import Any, TypeVar
9+
10+
import temporalio.converter
11+
from temporalio.common._streams import _convert, _reader
12+
from temporalio.common._streams._backend import BackendCalls, CallOptions
13+
from temporalio.common._streams._defn import TopicDefinition
14+
from temporalio.common._streams._handle import StreamHandle, TopicBase, workflow_owner
15+
from temporalio.common._streams._items import End, close_status
16+
from temporalio.common._streams._reader import Item, Position, Subscription
17+
from temporalio.common._streams._writer import StreamWriter
18+
from temporalio.common._streams.system import models
19+
20+
T = TypeVar("T")
21+
22+
23+
@dataclass
24+
class Access:
25+
"""The calls of one Stream handle, bound to a backend."""
26+
27+
backend: BackendCalls
28+
converter: temporalio.converter.DataConverter
29+
namespace: str
30+
stream_id: Callable[[], Awaitable[str]]
31+
writer: Callable[[], StreamWriter]
32+
# A close sends these, and the Store checks them against the Stream's owner.
33+
workflow_id: str | None = None
34+
first_execution_run_id: str | None = None
35+
36+
async def publish(self, topic: str, values: Sequence[Any]) -> int:
37+
"""Write the values as Records now, and return the offset of the last one."""
38+
if not values:
39+
raise TypeError("publish takes one or more values")
40+
return await self.writer().write([(topic, value) for value in values])
41+
42+
async def publish_records(self, records: Sequence[tuple[str, Any]]) -> int:
43+
"""Write ``(topic, value)`` pairs as Records now, in order, and return the offset of
44+
the last one.
45+
"""
46+
return await self.writer().write(records)
47+
48+
async def items(
49+
self, read: ReadSpec, position: Position
50+
) -> AsyncGenerator[Item, None]:
51+
"""Read the Stream's items in order, and move ``position`` past each page."""
52+
items = _reader.read(
53+
self.backend.service(self.namespace, _convert.raw(self.converter)),
54+
self.converter,
55+
await self.stream_id(),
56+
position=position,
57+
topics=read.topics,
58+
topic_prefixes=read.topic_prefixes,
59+
types=read.types,
60+
default_type=read.default_type,
61+
from_offset=read.offset,
62+
last=read.last,
63+
wait_new_records=read.wait_new_records,
64+
create_wait=read.create_wait,
65+
)
66+
try:
67+
async for item in items:
68+
yield item
69+
finally:
70+
await items.aclose()
71+
72+
async def get_offset(self) -> int:
73+
"""The next offset of the Stream, the newest offset plus one, or 0 if it has no Record.
74+
75+
Offsets are sparse and above zero, so ``offset=0`` reads from the start.
76+
"""
77+
service = self.backend.service(self.namespace)
78+
response = await (
79+
await service.describe_stream(
80+
stream=models.StreamRef(id=await self.stream_id())
81+
)
82+
)
83+
return response.last_offset + 1 if response.last_offset else 0
84+
85+
async def close(self, result: Any | None, failure: BaseException | None) -> None:
86+
"""Write this Producer's Records, then close the Stream."""
87+
writer = self.writer()
88+
await writer.close()
89+
producer = models._ProducerTransferTypeConverter().from_transfer_type(
90+
writer.producer, models.Producer
91+
)
92+
status = close_status(failure)
93+
service = self.backend.service(self.namespace, self.converter)
94+
await (
95+
await service.close_stream(
96+
stream=models.StreamRef(
97+
id=await self.stream_id(),
98+
owner=workflow_owner(self.workflow_id, self.first_execution_run_id),
99+
),
100+
producer=producer,
101+
result=result,
102+
failure=failure,
103+
status=models.StreamCloseStatus(status) if status is not None else None,
104+
)
105+
)
106+
107+
108+
@dataclass
109+
class ReadSpec:
110+
"""What one read asks for."""
111+
112+
topics: list[str] | None
113+
topic_prefixes: Sequence[str]
114+
types: dict[str, Any]
115+
default_type: Any
116+
offset: int | End | None
117+
last: int | None
118+
wait_new_records: bool
119+
create_wait: timedelta | None
120+
121+
122+
def read_spec(
123+
handle: StreamHandle[Any],
124+
topics: Sequence[
125+
str | TopicDefinition[Any] | _AsyncTopic[Any] | SyncTopicBase[Any]
126+
],
127+
*,
128+
topic_prefixes: Sequence[str],
129+
default_type: Any,
130+
offset: int | End | None,
131+
last: int | None,
132+
wait_new_records: bool,
133+
create_wait: timedelta | None,
134+
) -> ReadSpec:
135+
"""The Topic names of a read, the decode type of each known Topic, and its options.
136+
137+
Raises:
138+
ValueError: ``last`` is not above zero, or the call also gives ``offset``.
139+
"""
140+
if last is not None and (last < 1 or offset is not None):
141+
raise ValueError("last takes a count above zero and no offset")
142+
types = handle._known_types()
143+
names = []
144+
for topic in topics:
145+
if isinstance(topic, (_AsyncTopic, SyncTopicBase)):
146+
name, topic_type = topic.name, topic.type
147+
elif isinstance(topic, TopicDefinition):
148+
name, topic_type = handle._topic_spec(topic, None)
149+
else:
150+
# A Topic name that the definition doesn't have gets no type here, so its values
151+
# decode with the read's default type.
152+
name, topic_type = topic, types.get(topic)
153+
names.append(name)
154+
if topic_type is not None:
155+
types[name] = topic_type
156+
return ReadSpec(
157+
topics=names or None,
158+
topic_prefixes=topic_prefixes,
159+
types=types,
160+
default_type=default_type,
161+
offset=offset,
162+
last=last,
163+
wait_new_records=wait_new_records,
164+
create_wait=create_wait,
165+
)
166+
167+
168+
def subscription(
169+
items: Callable[[Position], AsyncGenerator[Item, None]],
170+
read: ReadSpec,
171+
options: CallOptions | None = None,
172+
) -> Subscription[Any]:
173+
"""A read over the items that ``items`` gives."""
174+
# A read from END or of the last Records has no resume offset until its first page.
175+
if read.last is not None or read.offset is End.END:
176+
position = Position(None)
177+
else:
178+
position = Position(read.offset or 0)
179+
return Subscription(items(position), position, options)
180+
181+
182+
class _AsyncTopic(TopicBase[T]):
183+
# A Topic of an Activity or client handle. Each publish writes before it returns.
184+
185+
async def publish(self, *values: T) -> int:
186+
"""Write one Record for each value, in order, and return the last Record's offset.
187+
188+
A list is one value.
189+
"""
190+
return await self._stream._access().publish(self._name, values)
191+
192+
193+
class SyncTopicBase(TopicBase[T]):
194+
"""Base class of a Topic whose calls block, for sync Activities."""

0 commit comments

Comments
 (0)