55and existing Workflow Streams code interoperate on one stream, and old
66histories replay. Records live in the owning workflow's History, which is
77also this provider's limit: Option 0's caps (payloads in History, the Signal
8- cap, bounded subscribers, no reads after the workflow closes) are transport
9- properties and remain.
8+ cap, bounded subscribers) are transport properties and remain. Reads after
9+ the workflow closes go through one Query this provider adds, which serves
10+ the log from workflow state for as long as the History is retained, so a
11+ reader between polls when the workflow completed still gets the tail.
1012
1113The mapping, in one place:
1214
2426
2527from __future__ import annotations
2628
29+ import base64
2730from collections .abc import AsyncIterator
2831from datetime import timedelta
2932from typing import Any
4649from temporalio .streams ._record import BEGINNING , Cursor , RecordKind , StreamRecord
4750
4851_RUN_ATTR = "__temporal_streams_ws_runtime"
52+ _TAIL_QUERY = "__temporal_streams_tail"
4953_IN = "in:"
5054_OUT = "out:"
5155
@@ -60,6 +64,23 @@ class _Runtime:
6064
6165 def __init__ (self ) -> None :
6266 self .stream = WorkflowStream ()
67+ # The poll Update stops answering once the workflow is closing, and a
68+ # reader between polls at that moment would lose what the final task
69+ # published. The log is workflow state, so a Query still serves it
70+ # after completion.
71+ workflow .set_query_handler (_TAIL_QUERY , self ._tail )
72+
73+ def _tail (self , from_offset : int ) -> list [dict [str , Any ]]:
74+ base = self .stream ._base_offset
75+ return [
76+ {
77+ "offset" : base + index ,
78+ "topic" : item .topic ,
79+ "data" : base64 .b64encode (item .data .data ).decode ("ascii" ),
80+ }
81+ for index , item in enumerate (self .stream ._log )
82+ if base + index >= from_offset
83+ ]
6384
6485
6586# Held per run rather than on the workflow instance, because the provider is
@@ -265,28 +286,70 @@ async def read(
265286 type : type | None = None ,
266287 ) -> AsyncIterator [StreamRecord [Any ]]:
267288 attempts = AttemptTracker ()
289+ next_offset = int (after .token ) + 1 if after .token else 0
268290 subscription = self ._client .subscribe (
269291 self ._shipped_topic ,
270- from_offset = int ( after . token ) + 1 if after . token else 0 ,
292+ from_offset = next_offset ,
271293 result_type = RawValue ,
272294 poll_cooldown = self ._poll_cooldown ,
273295 )
274296 async for item in subscription :
275- if self ._shipped_topic is None and not item .topic .startswith (_OUT ):
276- continue
277- cursor = Cursor (str (item .offset ))
278- try :
279- kind , frame_topic , source , attempt , sequence , body = _frame .decode (
280- item .data .payload .data
281- )
282- except ValueError :
283- continue
284- if topic is not None and frame_topic != topic :
285- continue
286- superseded = attempts .note (source , attempt , cursor )
287- if superseded is not None :
288- yield superseded
289- yield StreamRecord (
297+ next_offset = item .offset + 1
298+ record = self ._record (
299+ attempts , item .offset , item .topic , item .data .payload .data , topic , type
300+ )
301+ for out in record :
302+ yield out
303+ # The subscription ends when the workflow is closing or closed. What
304+ # landed after the last poll is still in workflow state, so the tail
305+ # comes back by Query rather than being lost with the run.
306+ for wire in await self ._tail (next_offset ):
307+ for out in self ._record (
308+ attempts ,
309+ wire ["offset" ],
310+ wire ["topic" ],
311+ base64 .b64decode (wire ["data" ]),
312+ topic ,
313+ type ,
314+ ):
315+ yield out
316+
317+ async def _tail (self , from_offset : int ) -> list [dict [str , Any ]]:
318+ try :
319+ return await self ._client ._handle .query (
320+ _TAIL_QUERY , from_offset , result_type = list
321+ )
322+ except Exception :
323+ # A workflow that never opened a stream has no handler to ask, and
324+ # one whose History is gone has nothing left to serve.
325+ return []
326+
327+ def _record (
328+ self ,
329+ attempts : AttemptTracker ,
330+ offset : int ,
331+ shipped_topic : str ,
332+ frame : bytes ,
333+ topic : str | None ,
334+ type : type | None ,
335+ ) -> list [StreamRecord [Any ]]:
336+ if self ._shipped_topic is None and not shipped_topic .startswith (_OUT ):
337+ return []
338+ if self ._shipped_topic is not None and shipped_topic != self ._shipped_topic :
339+ return []
340+ cursor = Cursor (str (offset ))
341+ try :
342+ kind , frame_topic , source , attempt , sequence , body = _frame .decode (frame )
343+ except ValueError :
344+ return []
345+ if topic is not None and frame_topic != topic :
346+ return []
347+ out : list [StreamRecord [Any ]] = []
348+ superseded = attempts .note (source , attempt , cursor )
349+ if superseded is not None :
350+ out .append (superseded )
351+ out .append (
352+ StreamRecord (
290353 value = self ._decode (body , type ) if kind is RecordKind .DATA else None ,
291354 cursor = cursor ,
292355 kind = kind ,
@@ -295,6 +358,8 @@ async def read(
295358 attempt = attempt ,
296359 sequence = sequence ,
297360 )
361+ )
362+ return out
298363
299364 async def latest (self , * , topic : str | None = None ) -> Cursor :
300365 del topic # one log per workflow, whatever the topic
0 commit comments