77from __future__ import annotations
88
99import asyncio
10- from collections .abc import Mapping
10+ from collections .abc import Generator , Mapping
1111from datetime import timedelta
1212from types import TracebackType
1313from typing import TYPE_CHECKING , Any , Generic , TypeVar
1414
1515from typing_extensions import Self , Unpack
1616
17+ import temporalio .workflow
18+
1719if 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
2332T = 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