Skip to content

Commit 707dbbb

Browse files
committed
Served a closed workflow's stream tail by Query on the shipped transport.
The poll Update stops answering once the workflow is closing, so a reader between polls at that moment lost whatever the final task published, and nothing could read the stream after completion at all. The log is workflow state, so one Query serves it for as long as the History is retained; the consumer asks for the tail when its subscription ends.
1 parent 1780eb3 commit 707dbbb

1 file changed

Lines changed: 83 additions & 18 deletions

File tree

‎temporalio/streams/providers/workflow_streams.py‎

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
and existing Workflow Streams code interoperate on one stream, and old
66
histories replay. Records live in the owning workflow's History, which is
77
also 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
1113
The mapping, in one place:
1214
@@ -24,6 +26,7 @@
2426

2527
from __future__ import annotations
2628

29+
import base64
2730
from collections.abc import AsyncIterator
2831
from datetime import timedelta
2932
from typing import Any
@@ -46,6 +49,7 @@
4649
from 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

Comments
 (0)