Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 12 additions & 3 deletions temporalio/contrib/stream/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""The Redis Stream backend.

Register it on the client. A client creates a Stream with
:py:attr:`temporalio.client.Client.create_stream`. ``backend=`` picks a backend that isn't the
default::
Register it on the client. Workflow code and clients create Streams with
:py:data:`temporalio.workflow.create_stream` and :py:attr:`temporalio.client.Client.create_stream`.
``backend=`` picks a backend that isn't the default::

from temporalio.contrib.stream import RedisStreamBackend

Expand All @@ -11,6 +11,10 @@
stream_backends=[RedisStreamBackend("redis://localhost", default=True)],
)

A Workflow's close is a regular Activity on the Workflow's task queue. So a Worker on that queue
that polls Activities must run the close, and must register the Redis backend. A Worker with
``no_remote_activities=True`` logs a warning, since the close waits for another Worker.

.. warning::
This package is experimental.
"""
Expand All @@ -34,6 +38,11 @@ class RedisStreamBackend(ExtensionStreamBackend):
each Stream ref that it creates keeps that name. A Worker serves only one backend. A client
can have more than one, and ``backend=`` picks one.

A Workflow's close is a regular Activity on the Workflow's task queue, which runs after the
Workflow Task commits. So a Worker on that queue that polls Activities must have this
backend. A Worker with ``no_remote_activities=True`` logs a warning, since the close waits
for another Worker.

.. warning::
This API is experimental.
"""
Expand Down
4 changes: 3 additions & 1 deletion temporalio/contrib/stream/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ class MemoryStreamBackend(ExtensionStreamBackend):
Register it on the client, as with ``RedisStreamBackend``. Every Worker and client that
shares one backend shares its Streams. The Streams are lost when the process ends. It
registers its ``name``, ``memory`` by default. It has Redis's rules, so tests can swap the
Store without changing Workflow code.
Store without changing Workflow code. As on Redis, a Workflow's close is a regular Activity
on the Workflow's task queue, so a Worker on that queue that polls Activities must have the
backend.

.. warning::
This API is experimental.
Expand Down
69 changes: 46 additions & 23 deletions temporalio/nexus/system/_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,51 +490,74 @@ def wake(_: object, done: asyncio.Future[None] = done) -> None:
def request_id() -> str:
"""The id of one create call, which its retries send again.

In a Workflow it comes from ``workflow.uuid4()``, so a replay sends the same id.
Outside a Workflow it's a random UUID. In a Workflow it's empty, since the Workflow create
sets an id that is the same in each run of the chain.
"""
from temporalio.workflow import in_workflow, uuid4
from temporalio.workflow import in_workflow

return str(uuid4() if in_workflow() else uuid.uuid4())
# A workflow.uuid4() here would move the user's workflow.random() sequence, and the
# Workflow create replaces the id anyway.
return "" if in_workflow() else str(uuid.uuid4())


# The Stream sources. Each one checks where it runs and gives the value of that context, or
# raises where it has none to give.


def _no_workflow_streams(source: str) -> RuntimeError:
# This SDK doesn't run Workflow Stream calls yet, so a generated Workflow call fails here.
return RuntimeError(f"Workflow code can't call the Stream service yet ({source})")
# raises where it has none to give. The Streams API imports this module, so the sources import
# the API only when they run.


def workflow_producer() -> typing.Any:
"""The Producer of the Workflow run's writes."""
raise _no_workflow_streams("workflow_producer")
"""The Workflow Producer of the run."""
from temporalio.streams import _ids
from temporalio.workflow import info

return _ids.workflow_producer(info().run_id)


def withdraw_run() -> str | None:
"""The previous run whose Records a retry run's first write withdraws."""
from temporalio.workflow import in_workflow
from temporalio.workflow import in_workflow, info

if in_workflow():
raise _no_workflow_streams("withdraw_run")
# Code outside a Workflow isn't a retry run.
return None
if not in_workflow():
# Code outside a Workflow isn't a retry run.
return None
run = info()
return run.continued_run_id if run.attempt > 1 and run.continued_run_id else None


def client_producer() -> typing.Any:
"""The Producer of a write outside a Workflow."""
# Each client has its own Producer, and a generated call doesn't say which client makes it.
# So the SDK's Streams send their Producer, and a call without one fails.
raise RuntimeError("A Stream write outside a Workflow needs its Producer")
from temporalio.streams import _activity
from temporalio.streams.system import models

streams = _activity.current.get()
if streams is None:
# Each client has its own Producer, and a generated call doesn't say which client makes
# it. So the SDK's Streams send their Producer, and a call without one fails.
raise RuntimeError("A Stream write outside a Workflow needs its Producer")
return models._ProducerTransferTypeConverter().from_transfer_type(
streams.producer, models.Producer
)


def local_activity_failed_tasks() -> list[int]:
"""The run's failed Workflow Tasks, whose Records a Local Activity's write withdraws."""
# Only a Local Activity has them, and this SDK's Local Activities don't write here yet.
return []
from temporalio.streams import _activity

streams = _activity.current.get()
# Only a Local Activity has them.
return list(streams.failed_tasks) if streams is not None else []


def local_activity_previous_completed_task() -> typing.Any:
"""The run's last completed Workflow Task, for a Local Activity's write."""
# Only a Local Activity has it, and this SDK's Local Activities don't write here yet.
return None
from temporalio.streams import _activity
from temporalio.streams.system import models

streams = _activity.current.get()
previous = streams.previous_completed_task if streams is not None else None
# Only a Local Activity has it.
if previous is None:
return None
return models._WorkflowTaskEpochTransferTypeConverter().from_transfer_type(
previous, models.WorkflowTaskEpoch
)
60 changes: 60 additions & 0 deletions temporalio/streams/_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""The Stream state of one Activity attempt."""

from __future__ import annotations

import asyncio
import contextvars
from dataclasses import dataclass, field

import temporalio.api.stream.v1 as stream_v1
import temporalio.converter
from temporalio.streams._backend import BackendCalls
from temporalio.streams._writer import StreamWriter


@dataclass
class ActivityStreams:
"""What a Stream backend gives the Streams of one Activity attempt."""

backend: BackendCalls
converter: temporalio.converter.DataConverter
namespace: str
producer: stream_v1.Producer
loop: asyncio.AbstractEventLoop
# The attempt's Workflow ID, which the close Activity checks against a bound Stream's owner.
workflow_id: str | None = None
# A Local Activity's writes use these to withdraw the run's orphaned Records.
previous_completed_task: stream_v1.WorkflowTaskEpoch | None = None
failed_tasks: tuple[int, ...] = ()
writers: dict[str, StreamWriter] = field(default_factory=dict)

def writer(self, stream_id: str) -> StreamWriter:
"""The attempt's writer for a Stream, one per Stream."""
writer = self.writers.get(stream_id)
if writer is None:
writer = StreamWriter(
writers=self.backend.writers(self.namespace, self.converter),
stream_id=stream_id,
producer=self.producer,
free=self.backend.free_writer,
previous_completed_task=self.previous_completed_task,
failed_tasks=self.failed_tasks,
)
self.writers[stream_id] = writer
return writer

async def close(self) -> None:
"""Close every writer of the attempt."""
errors: list[BaseException] = []
for writer in self.writers.values():
try:
await writer.close()
except Exception as err:
errors.append(err)
if errors:
raise errors[0]


current: contextvars.ContextVar[ActivityStreams | None] = contextvars.ContextVar(
"__temporal_activity_streams", default=None
)
Loading
Loading