Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e7002fa
Refused stream readers in Workflows and non-Workflow owners.
moetemp Oct 9, 2026
a02eaa0
Merged the stream reference kind widening into p3c-unsupported-owners.
moetemp Oct 9, 2026
0f20b58
Merged the review fixes into p3c-unsupported-owners.
moetemp Oct 10, 2026
4007ba0
Merged the review fixes into p3c-unsupported-owners.
moetemp Oct 10, 2026
b34af5c
Refused an unsupported ref kind where a handle opens, not at decode.
moetemp Oct 10, 2026
d0568a4
Merged p3b-outside-handles into p3c-unsupported-owners.
moetemp Oct 11, 2026
4a3bf5d
Refused unsupported owners on the Core-backed handles.
moetemp Oct 11, 2026
955c707
Merged p3b-outside-handles into p3c-unsupported-owners.
moetemp Oct 11, 2026
2896a7f
Merged p3b-outside-handles into p3c-unsupported-owners.
moetemp Oct 11, 2026
029581d
Merged p3b-outside-handles into p3c-unsupported-owners.
moetemp Oct 11, 2026
e48d047
Merged p3b-outside-handles into p3c-unsupported-owners.
moetemp Oct 11, 2026
6117b5f
Merged p3b-outside-handles into p3c-unsupported-owners.
moetemp Oct 11, 2026
4197e30
Merged p3b-outside-handles into p3c-unsupported-owners.
moetemp Oct 11, 2026
9b472f7
Restored the Activity test imports the P3b merge dropped.
moetemp Oct 11, 2026
ef7eb32
Merged p3b-outside-handles into p3c-unsupported-owners.
moetemp Oct 11, 2026
4ef1ac6
Merged p3b-outside-handles into p3c-unsupported-owners.
moetemp Oct 11, 2026
1ca067b
Merged p3b into p3c.
moetemp Oct 13, 2026
f269a48
Merged p3b into p3c.
moetemp Oct 13, 2026
092bea3
Merged p3b into p3c.
moetemp Oct 13, 2026
830b39c
Merged p3b into p3c.
moetemp Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion temporalio/contrib/streams/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
:func:`get_stream_handle`, and writes with a producer id and an attempt of
its own.

In this release only a Workflow owns a stream, and only Activities and
clients read one. Reading inside a Workflow (:func:`workflow_reader`) and
the other owner kinds raise :class:`StreamUnsupportedError`.

The stored record is ``temporal.sdk.streams.v1.StreamRecord``, in
:mod:`temporalio.bridge.proto.streams.v1`, with the user's value in ``body``
as an ordinary payload.
Expand Down Expand Up @@ -80,7 +84,11 @@
)
from temporalio.contrib.streams._ref import StreamOwnerKind, StreamRef
from temporalio.contrib.streams._topic import DEFAULT_TOPIC, StreamTopic, topic
from temporalio.contrib.streams._workflow import WorkflowStreamWriter, workflow_writer
from temporalio.contrib.streams._workflow import (
WorkflowStreamWriter,
workflow_reader,
workflow_writer,
)

__all__ = [
"ActivityStreamHandle",
Expand Down Expand Up @@ -112,5 +120,6 @@
"activity_handle",
"get_stream_handle",
"topic",
"workflow_reader",
"workflow_writer",
]
14 changes: 8 additions & 6 deletions temporalio/contrib/streams/_handles.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,9 +490,10 @@ def activity_handle() -> ActivityStreamHandle:
Workflow instead, which commits with the Workflow Task.

Raises:
ValueError: The Activity was not scheduled by a Workflow, or no stream
store is registered on the Worker's client.
StreamUnsupportedError: The Activity is a Local Activity.
StreamUnsupportedError: The Activity is a Local Activity, or it was
not scheduled by a Workflow, since streams owned by an Activity
are not supported in this release.
ValueError: No stream store is registered on the Worker's client.
RuntimeError: Not called from inside an Activity.
"""
info = temporalio.activity.info()
Expand All @@ -502,9 +503,9 @@ def activity_handle() -> ActivityStreamHandle:
"attempt isn't durable; publish from the Workflow instead"
)
if info.workflow_id is None or info.workflow_run_id is None:
raise ValueError(
"this Activity was not scheduled by a Workflow, so it has no Workflow "
"stream"
raise StreamUnsupportedError(
"this Activity was not scheduled by a Workflow, and streams owned by "
"an Activity are not supported in this release"
)
client = temporalio.activity.client()
ref = StreamRef.for_workflow(info.workflow_id, run_id=info.workflow_run_id)
Expand Down Expand Up @@ -566,4 +567,5 @@ def get_stream_handle(
ref = workflow_id
else:
ref = StreamRef.for_workflow(workflow_id, run_id=run_id, topic=topic)
ref._require_supported()
return StreamHandle(store_for_client(client), client, ref)
32 changes: 24 additions & 8 deletions temporalio/contrib/streams/_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from dataclasses import dataclass
from typing import Any, Literal

from temporalio.contrib.streams._errors import StreamUnsupportedError
from temporalio.contrib.streams._topic import (
DEFAULT_TOPIC,
StreamTopic,
Expand All @@ -25,8 +26,7 @@
"""The owner kinds this release opens. Only a Workflow owns a stream.

:attr:`StreamRef.kind` is a plain string, so a ref that names an owner kind
a later release adds still reaches the ref's own check when it is decoded,
instead of failing the converter's type check.
a later release adds still decodes, and is refused where a handle opens.
"""


Expand All @@ -53,19 +53,35 @@ class StreamRef:
topic: str = DEFAULT_TOPIC

def __post_init__(self) -> None:
"""Refuse a ref this release cannot open.
"""Refuse a ref that names no stream.

Any ``kind`` decodes, since a ref of a later release can arrive as
Workflow input, where raising would fail the Workflow Task on every
retry. :meth:`_require_supported` refuses it where a handle opens.

Raises:
ValueError: ``kind`` is not ``"workflow"``, or ``workflow_id`` or
``topic`` is empty.
ValueError: ``kind`` or ``topic`` is empty, or a Workflow ref has
no ``workflow_id``.
"""
if self.kind != "workflow":
raise ValueError(f"unknown StreamRef kind {self.kind!r}")
if not self.workflow_id:
if not self.kind:
raise ValueError("a StreamRef needs a kind")
if self.kind == "workflow" and not self.workflow_id:
raise ValueError("a Workflow StreamRef needs a workflow_id")
if not self.topic:
raise ValueError("a StreamRef needs a topic name")

def _require_supported(self) -> None:
"""Refuse to open a stream whose owner kind this release lacks.

Raises:
StreamUnsupportedError: ``kind`` is not ``"workflow"``.
"""
if self.kind != "workflow":
raise StreamUnsupportedError(
f"streams owned by kind {self.kind!r} are not supported in this "
"release; only Workflow-owned streams are"
)

@classmethod
def for_workflow(
cls,
Expand Down
25 changes: 22 additions & 3 deletions temporalio/contrib/streams/_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
records to the activation's ``WorkflowOutputStreamCommit``. Nothing here does
I/O. The Worker's completion encoder runs each body through the payload
codec, and Core stages the records, records a marker of them, and makes them
visible once the Workflow Task is accepted.
visible once the Workflow Task is accepted. Reading a stream inside a
Workflow is not part of this release.
"""

from __future__ import annotations
Expand All @@ -19,11 +20,11 @@
WorkflowOutputStreamCommit,
)
from temporalio.contrib.streams._body import content_hash
from temporalio.contrib.streams._errors import StreamError
from temporalio.contrib.streams._errors import StreamError, StreamUnsupportedError
from temporalio.contrib.streams._plugin import worker_has_store
from temporalio.contrib.streams._topic import StreamTopic, resolve_topic

__all__ = ["WorkflowStreamWriter", "workflow_writer"]
__all__ = ["WorkflowStreamWriter", "workflow_reader", "workflow_writer"]

T = TypeVar("T")

Expand Down Expand Up @@ -200,3 +201,21 @@ def workflow_writer(
"""
name, _ = resolve_topic(topic)
return WorkflowStreamWriter(name)


def workflow_reader(*_args: Any, **_kwargs: Any) -> Any:
"""Reading a stream inside a Workflow is not supported in this release.

Read from an Activity or a client with
:func:`temporalio.contrib.streams.activity_handle` or
:func:`temporalio.contrib.streams.get_stream_handle`, and send the
Workflow what it needs, for example as a Signal.

Raises:
StreamUnsupportedError: Always.
"""
raise StreamUnsupportedError(
"reading a stream inside a Workflow is not supported in this release; "
"read it from an Activity or a client and send the Workflow what it "
"needs, for example as a Signal"
)
13 changes: 0 additions & 13 deletions tests/contrib/streams/test_outside_handles.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,19 +304,6 @@ async def test_a_retry_through_a_nonce_codec_still_dedupes(client: Client):
assert [r.value for r in await read_all(stream.read())] == ["a"]


async def test_an_activity_with_no_workflow_has_no_workflow_stream():
env = ActivityEnvironment()
env.info = dataclasses.replace(
ActivityEnvironment.default_info(), workflow_id=None, workflow_run_id=None
)

async def standalone() -> None:
activity_handle()

with pytest.raises(ValueError, match="not scheduled by a Workflow"):
await env.run(standalone)


@activity.defn
async def count_twice() -> str:
producer = activity_handle().producer(topic=TOKENS)
Expand Down
19 changes: 11 additions & 8 deletions tests/contrib/streams/test_topics_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
DEFAULT_TOPIC,
StreamRef,
StreamTopic,
StreamUnsupportedError,
topic,
)
from temporalio.contrib.streams._topic import (
Expand Down Expand Up @@ -51,24 +52,26 @@ def test_a_ref_round_trips_through_the_default_converter():


def test_a_ref_refuses_what_it_cannot_name():
with pytest.raises(ValueError, match="unknown"):
StreamRef("nexus", "x")
with pytest.raises(ValueError):
StreamRef("workflow", "")
with pytest.raises(ValueError):
StreamRef("workflow", "wf", topic="")


def test_a_ref_from_a_later_release_decodes_to_the_kind_check():
# The kind is a plain string, so the converter builds the ref and the ref
# itself refuses the kind, rather than the converter failing a type check.
@pytest.mark.parametrize("kind", ["activity", "nexus"])
def test_a_ref_from_a_later_release_decodes_and_fails_where_it_opens(kind: str):
# Decoding happens while a Workflow Task applies its input, where a raise
# fails the task on every retry. Opening the handle is where the caller
# can catch it.
converter = DataConverter.default.payload_converter
payload = Payload(
metadata={"encoding": b"json/plain"},
data=b'{"kind": "activity", "workflow_id": "wf", "topic": "out"}',
data=f'{{"kind": "{kind}", "workflow_id": "wf", "topic": "out"}}'.encode(),
)
with pytest.raises(ValueError, match="'activity'"):
converter.from_payloads([payload], [StreamRef])
ref = converter.from_payloads([payload], [StreamRef])[0]
assert ref.kind == kind
with pytest.raises(StreamUnsupportedError, match="only Workflow-owned"):
ref._require_supported()


@pytest.mark.parametrize(
Expand Down
73 changes: 73 additions & 0 deletions tests/contrib/streams/test_unsupported_owners.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Owners and readers this release does not support say so.

Only a Workflow owns a stream in this release, and only Activities and
clients read one. Everything else raises ``StreamUnsupportedError``.
"""

from __future__ import annotations

import dataclasses

import pytest

from temporalio import workflow
from temporalio.client import Client
from temporalio.contrib.streams import (
StreamRef,
StreamUnsupportedError,
activity_handle,
get_stream_handle,
topic,
workflow_reader,
)
from temporalio.contrib.streams.memory import MemoryStreams
from temporalio.testing import ActivityEnvironment
from tests.contrib.streams._support import connect_with, new_workflow_id
from tests.helpers import new_worker

EVENTS = topic("events", dict)


async def test_a_ref_refuses_owner_kinds_this_release_lacks_where_it_opens(
client: Client,
):
streams_client = await connect_with(client, MemoryStreams())
for kind in ("activity", "standalone"):
ref = StreamRef(kind, "x") # type: ignore[arg-type]
with pytest.raises(StreamUnsupportedError, match="only Workflow-owned"):
get_stream_handle(streams_client, ref)


@workflow.defn
class TriesToRead:
@workflow.run
async def run(self) -> str:
try:
workflow_reader(EVENTS)
except StreamUnsupportedError as error:
return str(error)
return "read"


async def test_a_workflow_cannot_read_a_stream(client: Client):
streams_client = await connect_with(client, MemoryStreams())
async with new_worker(streams_client, TriesToRead) as worker:
said = await streams_client.execute_workflow(
TriesToRead.run,
id=new_workflow_id(),
task_queue=worker.task_queue,
)
assert "not supported in this release" in said


async def test_an_activity_with_no_workflow_has_no_stream():
env = ActivityEnvironment()
env.info = dataclasses.replace(
ActivityEnvironment.default_info(), workflow_id=None, workflow_run_id=None
)

async def standalone() -> None:
activity_handle()

with pytest.raises(StreamUnsupportedError, match="owned by an Activity"):
await env.run(standalone)
Loading