Skip to content

Commit 058106a

Browse files
committed
Added Activity Streams, SyncStream and BufferedPublisher.
1 parent aef29b9 commit 058106a

10 files changed

Lines changed: 1761 additions & 8 deletions

File tree

‎temporalio/client/__init__.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@
185185
ScheduleUpdate,
186186
ScheduleUpdateInput,
187187
)
188-
from ._streams import Stream
188+
from ._streams import BufferedPublisher, Stream, SyncStream, SyncSubscription
189189
from ._worker_versioning import (
190190
BuildIdOp,
191191
BuildIdOpAddNewCompatible,
@@ -398,5 +398,8 @@
398398
"ParamType",
399399
"ReturnType",
400400
"SelfType",
401+
"BufferedPublisher",
401402
"Stream",
403+
"SyncStream",
404+
"SyncSubscription",
402405
]

‎temporalio/client/_streams.py‎

Lines changed: 308 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,26 @@
77
from __future__ import annotations
88

99
import asyncio
10-
from collections.abc import Mapping
10+
from collections.abc import Generator, Mapping
1111
from datetime import timedelta
1212
from types import TracebackType
1313
from typing import TYPE_CHECKING, Any, Generic, TypeVar
1414

1515
from typing_extensions import Self, Unpack
1616

17+
import temporalio.workflow
18+
1719
if TYPE_CHECKING:
1820
import temporalio.api.stream.v1
1921
import temporalio.client
20-
from temporalio.streams import End, StreamOptions, StreamRef, Subscription
22+
from temporalio.streams import (
23+
End,
24+
Record,
25+
StreamOptions,
26+
StreamRef,
27+
Subscription,
28+
Superseded,
29+
)
2130
from temporalio.streams._stream_client import StreamCalls
2231

2332
T = TypeVar("T")
@@ -37,6 +46,12 @@ class Stream(Generic[T]):
3746
returns a Stream that writes as a Producer with a name of its own. A write error that no
3847
caller got raises at the next call of the Stream.
3948
49+
In an Activity, including a Local Activity, a Stream from the Worker's client, such as from
50+
``activity.client()``, writes as the Producer of the attempt. So a retry withdraws the
51+
Records of the failed attempt. It can't close the Stream. The end of the attempt waits
52+
for the attempt's writes, and a write error that no caller got fails the attempt. A sync
53+
Activity wraps the Stream in :py:class:`SyncStream`.
54+
4055
.. warning::
4156
This API is experimental.
4257
"""
@@ -89,6 +104,8 @@ def publish(
89104
90105
Raises:
91106
StreamClosedError: This object closed the Stream.
107+
StreamUnsupportedError: The code is a sync Activity, which uses
108+
:py:class:`SyncStream`.
92109
"""
93110
return self._calls.publish(values, rpc_metadata, rpc_timeout)
94111

@@ -122,6 +139,10 @@ def subscribe(
122139
ignore it.
123140
rpc_timeout: The deadline of each call of the read, on every backend, and not of the
124141
whole read. A call that waits for new Records returns before the deadline.
142+
143+
Raises:
144+
StreamUnsupportedError: The code is a sync Activity, which uses
145+
:py:class:`SyncStream`.
125146
"""
126147
from temporalio.streams._access import read_spec
127148

@@ -168,7 +189,8 @@ async def close(
168189
169190
Raises:
170191
StreamNotOwnerError: The code can't close the Stream. Only the run chain closes a
171-
Stream bound to a Workflow, and it raises with no call.
192+
Stream bound to a Workflow, and an Activity closes no Stream. Both raise with no
193+
call.
172194
"""
173195
await self._calls.close(rpc_metadata, rpc_timeout)
174196

@@ -208,6 +230,7 @@ def producer(self, name: str, epoch: int) -> Stream[T]:
208230
209231
Raises:
210232
ValueError: The epoch is below 1.
233+
StreamUnsupportedError: The Stream writes as an Activity attempt.
211234
"""
212235
return Stream(self._ref, self._payload_type, self._calls.producer(name, epoch))
213236

@@ -216,6 +239,288 @@ def __repr__(self) -> str:
216239
return f"{type(self).__name__}({self._ref!r})"
217240

218241

242+
class SyncSubscription(Generic[T]):
243+
"""The items of one read of a Stream in a sync Activity, in order. Iterate it with ``for``.
244+
245+
Each iteration blocks the Activity's thread. See :py:class:`temporalio.streams.Subscription`.
246+
247+
.. warning::
248+
This API is experimental.
249+
"""
250+
251+
def __init__(self, subscription: Subscription[T]) -> None:
252+
"""Create a read. Use a ``SyncStream``'s ``subscribe`` instead."""
253+
from temporalio.streams import _activity_stream
254+
255+
self._subscription = subscription
256+
self._steps: Generator[Record[T] | Superseded, None, None] = (
257+
_activity_stream.sync_items(subscription)
258+
)
259+
260+
@property
261+
def next_offset(self) -> int | None:
262+
"""The offset to resume from. See :py:attr:`temporalio.streams.Subscription.next_offset`."""
263+
return self._subscription.next_offset
264+
265+
def __iter__(self) -> SyncSubscription[T]:
266+
"""The read is its own iterator."""
267+
return self
268+
269+
def __next__(self) -> Record[T] | Superseded:
270+
"""The next item."""
271+
return next(self._steps)
272+
273+
def close(self) -> None:
274+
"""End the read early. It makes no more calls."""
275+
self._steps.close()
276+
277+
278+
class SyncStream(Generic[T]):
279+
"""A Stream in a sync Activity. Its calls block the Activity's thread.
280+
281+
It wraps a :py:class:`Stream` of the Worker's client, such as for a ref of the Activity's
282+
input. A sync Activity has no ``activity.client()``, so it keeps the Worker's client, such as
283+
in an Activity class. Each call runs the Stream's call of the same name on the Worker's event
284+
loop, and blocks until it ends. A publish returns the offset, not a future.
285+
286+
.. warning::
287+
This API is experimental.
288+
"""
289+
290+
def __init__(self, stream: Stream[T]) -> None:
291+
"""Wrap ``stream`` for a sync Activity."""
292+
self._stream = stream
293+
294+
def publish(self, *values: T) -> int:
295+
"""Write one Record for each value, in order, and return the last Record's offset.
296+
297+
A list is one value.
298+
299+
Raises:
300+
StreamNotFoundError: Nobody created the Stream.
301+
"""
302+
from temporalio.streams import _activity_stream
303+
304+
async def publish() -> int:
305+
return await self._stream.publish(*values)
306+
307+
return _activity_stream.run_blocking(publish())
308+
309+
def subscribe(
310+
self,
311+
*,
312+
offset: int | End | None = None,
313+
last: int | None = None,
314+
wait_new_records: bool = True,
315+
create_wait: timedelta | None = None,
316+
) -> SyncSubscription[T]:
317+
"""Read the Stream's items in order. See :py:meth:`Stream.subscribe`."""
318+
from temporalio.streams import _activity_stream
319+
320+
async def subscribe() -> Subscription[T]:
321+
return self._stream.subscribe(
322+
offset=offset,
323+
last=last,
324+
wait_new_records=wait_new_records,
325+
create_wait=create_wait,
326+
)
327+
328+
return SyncSubscription(_activity_stream.run_blocking(subscribe()))
329+
330+
def get_offset(self) -> int:
331+
"""The next offset of the Stream, the newest offset plus one, or 0 if it has no Record.
332+
333+
Offsets are sparse and above zero, so ``offset=0`` reads from the start.
334+
"""
335+
from temporalio.streams import _activity_stream
336+
337+
return _activity_stream.run_blocking(self._stream.get_offset())
338+
339+
def close(self) -> None:
340+
"""Close the Stream. See :py:meth:`Stream.close`.
341+
342+
Raises:
343+
StreamNotOwnerError: The Stream writes as the Activity attempt, which can't close it.
344+
"""
345+
from temporalio.streams import _activity_stream
346+
347+
_activity_stream.run_blocking(self._stream.close())
348+
349+
350+
# Records in one batch of the crate's writer. A full batch is written at once.
351+
_BATCH = 1000
352+
353+
354+
class BufferedPublisher(Generic[T]):
355+
"""Batches the publishes of one Stream.
356+
357+
Use it in an Activity or a client that writes often. :py:meth:`publish` returns at once.
358+
The publisher writes the buffered values as one batch at most ``max_delay`` after the first
359+
of them, or at once when they fill a batch. One write is in flight at a time, and values
360+
published during it wait for the next one. Use it in ``async with``, which writes the
361+
remaining values at the end of the block. For a Stream that writes as an Activity attempt,
362+
the end of the attempt also writes the remaining values.
363+
364+
``await flush()`` is the backpressure point. A caller that publishes faster than the Store
365+
accepts Records must await it from time to time, so the buffer doesn't grow without bound.
366+
367+
After a batch fails, the publisher writes nothing more, so a reader sees no gap. Each later
368+
call raises the error.
369+
370+
.. warning::
371+
This API is experimental.
372+
"""
373+
374+
def __init__(
375+
self,
376+
stream: Stream[T],
377+
*,
378+
max_delay: timedelta = timedelta(milliseconds=100),
379+
) -> None:
380+
"""Create a publisher.
381+
382+
Args:
383+
stream: The Stream to publish to.
384+
max_delay: The longest time that a value waits in the buffer.
385+
386+
Raises:
387+
StreamUnsupportedError: The code is Workflow code. There, the publishes that wait
388+
behind a write to the same Stream go as one Local Activity.
389+
"""
390+
from temporalio.streams import _activity_stream, _errors
391+
392+
if temporalio.workflow.in_workflow():
393+
raise _errors.StreamUnsupportedError(
394+
"Workflow code can't use a BufferedPublisher. Its publishes that wait behind "
395+
"a write already go as one Local Activity."
396+
)
397+
self._stream = stream
398+
self._max_delay = max_delay
399+
self._buffer: list[T] = []
400+
# When the oldest buffered value arrived, so the next batch is at most max_delay later.
401+
self._first_at = 0.0
402+
self._timer: asyncio.TimerHandle | None = None
403+
self._writing: asyncio.Task[None] | None = None
404+
self._published = 0
405+
# Count of the values that a write took, written or not.
406+
self._settled = 0
407+
self._error: BaseException | None = None
408+
self._last_offset = 0
409+
self._unwritten = 0
410+
self._reported = False
411+
attempt = _activity_stream.attempt_of(stream)
412+
if attempt is not None:
413+
attempt.publishers.append(self)
414+
415+
def publish(self, *values: T) -> None:
416+
"""Add one Record for each value to the buffer, and return at once.
417+
418+
A list is one value.
419+
420+
Raises:
421+
StreamError: A batch failed to write, so the publisher takes no more values.
422+
"""
423+
self._raise_error()
424+
if not values:
425+
raise TypeError("publish takes one or more values")
426+
loop = asyncio.get_running_loop()
427+
if not self._buffer:
428+
self._first_at = loop.time()
429+
self._buffer.extend(values)
430+
self._published += len(values)
431+
self._schedule()
432+
433+
async def flush(self) -> int:
434+
"""Write every value published before the call, and return the last written offset.
435+
436+
Returns 0 if the publisher hasn't written anything.
437+
"""
438+
target = self._published
439+
while self._settled < target:
440+
if self._writing is None:
441+
self._start()
442+
assert self._writing is not None
443+
await asyncio.shield(self._writing)
444+
self._raise_error()
445+
return self._last_offset
446+
447+
def _raise_error(self) -> None:
448+
if self._error is not None:
449+
self._reported = True
450+
raise self._error
451+
452+
async def _flush_at_attempt_end(self) -> None:
453+
# Fail the attempt for unwritten values, unless the Activity already got the error and
454+
# continued.
455+
reported = self._reported
456+
try:
457+
await self.flush()
458+
except Exception:
459+
if not reported:
460+
raise
461+
462+
def _schedule(self) -> None:
463+
if self._writing is not None or not self._buffer:
464+
return
465+
if len(self._buffer) >= _BATCH:
466+
self._start()
467+
elif self._timer is None:
468+
self._timer = asyncio.get_running_loop().call_at(
469+
self._first_at + self._max_delay.total_seconds(), self._start
470+
)
471+
472+
def _start(self) -> None:
473+
if self._timer is not None:
474+
self._timer.cancel()
475+
self._timer = None
476+
if self._writing is not None or not self._buffer:
477+
return
478+
batch, self._buffer = self._buffer, []
479+
self._writing = asyncio.get_running_loop().create_task(self._write(batch))
480+
481+
async def _write(self, batch: list[T]) -> None:
482+
try:
483+
if self._error is None:
484+
self._last_offset = await self._send(batch)
485+
else:
486+
# A failed batch fails the later ones too, so a reader sees no gap.
487+
self._unwritten += len(batch)
488+
except Exception as err:
489+
self._unwritten += len(batch)
490+
self._error = err
491+
finally:
492+
self._settled += len(batch)
493+
self._writing = None
494+
# Schedule the values published during the write, for when their delay ends or they
495+
# fill a batch.
496+
self._schedule()
497+
498+
async def _send(self, batch: list[T]) -> int:
499+
return await self._stream.publish(*batch)
500+
501+
@property
502+
def unwritten(self) -> int:
503+
"""How many published values a failed batch left unwritten."""
504+
return self._unwritten
505+
506+
async def __aenter__(self) -> BufferedPublisher[T]:
507+
"""Use the publisher in ``async with``."""
508+
return self
509+
510+
async def __aexit__(
511+
self,
512+
exc_type: type[BaseException] | None,
513+
exc: BaseException | None,
514+
tb: TracebackType | None,
515+
) -> None:
516+
"""Write the buffered values. A write error does not hide an error of the block."""
517+
try:
518+
await self.flush()
519+
except Exception:
520+
if exc is None:
521+
raise
522+
523+
219524
# Python has no type arguments on a call, so a call that takes the payload type is an object
220525
# whose subscript binds the type, as in client.get_stream[Turn]("chat-42"). The subscript passes
221526
# the class on, so a read decodes each Record with it, and type checkers see Stream[Turn]. With

0 commit comments

Comments
 (0)