Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
2c40524
Move sdk-core submodule to 6e90e6d5 and fix bridge build
mfateev Aug 14, 2026
d8dbfdd
External streams: generated protos, record model, and the Redis fixture
mfateev Aug 15, 2026
bcc67e6
External streams: backend contract, annotation codec, codec, failure …
mfateev Aug 15, 2026
a6b44bc
External streams: parking contract, Redis provider, registry, produce…
mfateev Aug 15, 2026
1d4b22e
External streams: subscription manager and the Workflow-facing API
mfateev Aug 15, 2026
0615ea9
External streams: wire the feature into the Python Worker
mfateev Aug 16, 2026
aadf012
P13: the replay read path
mfateev Aug 16, 2026
ea590b0
P14: the producer wake-signal path
mfateev Aug 16, 2026
301af4d
P6b: publish()'s acknowledged-wake semantics
mfateev Aug 16, 2026
a6507e9
P15: the Continue-As-New cursor
mfateev Aug 16, 2026
4d0aea7
P21: multiple streams, merge, and same-stream subscriptions
mfateev Aug 16, 2026
8c12e9f
P20: the Worker shutdown wake sweep
mfateev Aug 16, 2026
bd8840b
P16b: the Milestone 2 required-test list
mfateev Aug 16, 2026
5fdab62
Fix the failure-taxonomy row for an annotation mismatch, and P20's mi…
mfateev Aug 16, 2026
d0bad85
P16a/P16b: make the milestone gates enforceable
mfateev Aug 16, 2026
7e0bf4a
Core bump for C8/C10, and three more Milestone 1 cases
mfateev Aug 16, 2026
e0b7957
Replay a stream history through the real Replayer
mfateev Aug 16, 2026
892e549
Deliver a record buffered while Workflow code was elsewhere
mfateev Aug 16, 2026
4ad9809
Core bump for C15b, and correct a replay finding made against a stale…
mfateev Aug 16, 2026
9f53650
The livelock does not exist: fix the test helper that caused it
mfateev Aug 17, 2026
74e5fc3
Bound delivery within one activation (ADR-026)
mfateev Aug 17, 2026
72d467d
Fix four defects the required-test list exposed
mfateev Aug 17, 2026
f87e790
Make an unparked wake's sender identity unique per sender
mfateev Aug 17, 2026
3ee3d05
Rollover cases: one test defect fixed, two reasons corrected
mfateev Aug 17, 2026
3c79510
Rollover cases 19 and 23: both were test defects
mfateev Aug 17, 2026
8ab01be
Milestone 1 gate: 53 of 55, and two honest gaps
mfateev Aug 17, 2026
3f68a8a
Case 29: reach the window it names, and state what actually blocks it
mfateev Aug 17, 2026
987c9f7
Fix four more defects the handoff case exposed, and take the gate to …
mfateev Aug 18, 2026
e878e21
Close the deadlock: report the quiescent snapshot even when commands …
mfateev Aug 18, 2026
01fb3fd
Close case 36: an empty stream parks, is evicted, and replays from it…
mfateev Aug 18, 2026
890af9d
Read the required-test lists from their own directory
mfateev Aug 18, 2026
0a157b5
Bind each wait to its own backend, and stop reporting unsent wakes as…
mfateev Aug 18, 2026
7458e1e
Make readiness reporting total, and stop dropping a stale answer
mfateev Aug 18, 2026
08b78f1
Make the Redis key layout injective, and stop a stream name widening …
mfateev Aug 18, 2026
8be6ec4
Make merge fair, and stop consuming a record before it decodes
mfateev Aug 18, 2026
1358c17
Reconcile an inherited park intent, and make parking match Core's wai…
mfateev Aug 18, 2026
f7c65f7
Bind a late subscription, and replay a segment in its recorded order
mfateev Aug 18, 2026
1c7ee60
Require a provider's key derivation to be injective
mfateev Aug 18, 2026
ae8a561
Decode off the Workflow thread, and connect the failure taxonomy
mfateev Aug 18, 2026
0dd5ba9
Ask the converter's public members, not a predicate marked for removal
mfateev Aug 18, 2026
34abed5
Finish close(): stop the watcher and take back the park intent
mfateev Aug 19, 2026
e37a63c
Guard the close() teardown, none of which was tested
mfateev Aug 19, 2026
99729cc
Close the seven follow-up findings, which were all failure paths
mfateev Aug 19, 2026
02eb1ed
Close the third review's six findings, and the seven the fixes introd…
mfateev Aug 19, 2026
90002e2
Settle the Run before asking the status probe to be repeatable
mfateev Aug 19, 2026
668c0c2
Re-announce buffered records on every completion, not only a budget stop
mfateev Aug 19, 2026
2620ee1
Close the fourth review's five findings, and the one its own fix left…
mfateev Aug 19, 2026
952ec2a
Bind the unknown-append recovery to its operation, stream and producer
mfateev Aug 19, 2026
a0ebb24
Keep one canonical operation across repeated unknown-append recoveries
mfateev Aug 20, 2026
a6e7ae2
Close out the agent handoff notes, and the one defect they left open
mfateev Aug 20, 2026
2de43e9
Close the fifth review's four findings
mfateev Aug 20, 2026
7d2f91a
Close the fifth review's remaining findings, including one in its own…
mfateev Aug 21, 2026
81b518d
Let the continuation writer be staged behind its reader
mfateev Aug 21, 2026
b4aaf04
Name the writer stage, and run a chain through it
mfateev Aug 21, 2026
f7fa7c7
Let a merged wait end for every member, not only its winner
mfateev Aug 21, 2026
381e177
State the merged wait's contract where it belongs, and cover its claim
mfateev Aug 21, 2026
bae10ec
Validate recorded stream state against History, not against configura…
mfateev Aug 22, 2026
60f451c
Draw the unparked wake counter from the sender, not from the Run
mfateev Aug 22, 2026
6c71094
Retry an owed park removal without waiting for another event
mfateev Aug 22, 2026
f4a0425
Close six findings in the owed-removal retry, one in its own fix
mfateev Aug 22, 2026
d66f29a
Carry the wake a retired park intent owes past its subscription
mfateev Aug 23, 2026
94a7a23
Stop three streaming tests failing on things they do not test
mfateev Aug 24, 2026
5897602
Coalesce external stream wake cycles
mfateev Aug 24, 2026
e1dba93
Correlate wake retries with task attempts
mfateev Aug 24, 2026
7f063c5
Stabilize sandboxed handoff fixtures
mfateev Aug 24, 2026
9dac2cc
Await streaming worker test shutdown
mfateev Aug 24, 2026
b1cc1a5
Keep pytest rewriting out of workflow sandboxes
mfateev Aug 24, 2026
afd9730
Format streaming issue fixes
mfateev Aug 24, 2026
776bed2
Document and type-check streaming support
mfateev Aug 24, 2026
e68425b
Point streaming docs at current design
mfateev Aug 25, 2026
d00168e
Use a single external stream backend
mfateev Aug 26, 2026
12f35b7
Expose external workflow stream APIs
mfateev Aug 26, 2026
5ed2941
Implement workflow-originated external streams
mfateev Aug 27, 2026
6857a4a
Update Core streaming documentation
mfateev Aug 27, 2026
3e36f0b
Moved the External Workflow Streams changelog entries to Unreleased.
moetemp Oct 5, 2026
f19765f
Merged moe/AI-198-o7-py-1-core-pin into moe/AI-198-o7-py-2-import.
moetemp Oct 5, 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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ to include examples, links to docs, or any other relevant information.
stream's notification channel, reads on each delivery and completes when the stream closes.
`temporalio.streams.providers.nexus_consumer_service` hosts it on its own and needs the
`streams-nexus` extra.
- Added experimental External Workflow Streams in
`temporalio.contrib.external_workflow_streams`. Workflow stream payloads are
stored in a configured external backend instead of Temporal History, with a
Redis Streams provider included. Workflows subscribe with `external_stream`,
external processes publish with `ExternalStreamProducer`, and Workers are
configured with `external_stream_backend`.
- Added the output direction for External Workflow Streams.
Workflows publish with `external_output_stream`, Activities and external
processes use `ExternalOutputStreamProducer`, and external consumers resume
through `ExternalOutputStreamClient`. Workflow output is staged outside
History and becomes readable only after its compact Workflow Task marker is
committed.

### Changed

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ dev = [
"pytest-rerunfailures>=16.1",
"pytest-xdist>=3.6,<4",
"moto[s3,server]>=5",
"redis>=5,<9",
"langgraph>=1.1.0",
"langsmith>=0.7.34,<0.9",
"deepagents>=0.6.12,<0.7; python_version >= '3.11'",
Expand Down
63 changes: 60 additions & 3 deletions temporalio/bridge/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ use temporalio_common::protos::temporal::api::history::v1::History;
use temporalio_common::protos::temporal::api::worker::v1::{PluginInfo, StorageDriverInfo};
use temporalio_sdk_core::replay::{HistoryForReplay, ReplayWorkerInput};
use temporalio_sdk_core::{
PollError, SlotInfo, SlotInfoTrait, SlotKind, SlotKindType, SlotMarkUsedContext,
SlotReleaseContext, SlotReservationContext, SlotSupplier as SlotSupplierTrait,
SlotSupplierPermit, WorkflowErrorType,
ExternalStreamReadyResult, ExternalStreamRunStatus, PollError, SlotInfo, SlotInfoTrait,
SlotKind, SlotKindType, SlotMarkUsedContext, SlotReleaseContext, SlotReservationContext,
SlotSupplier as SlotSupplierTrait, SlotSupplierPermit, WorkflowErrorType,
};
use tokio::sync::mpsc::{channel, Sender};
use tokio_stream::wrappers::ReceiverStream;
Expand Down Expand Up @@ -671,6 +671,63 @@ impl WorkerRef {
Ok(())
}

/// Tell Core a record is buffered for one external stream wait.
///
/// **Synchronous and blocking**, like `record_activity_heartbeat`, but unlike it
/// **acknowledged**: the caller is a watcher whose next action -- keep waiting, re-probe, send
/// a wake Signal, or tear itself down -- depends entirely on which of the five results comes
/// back, so a fire-and-forget send would leave it unable to tell "Core will activate" from
/// "nothing here will".
///
/// Synchronous rather than a Python awaitable on purpose. There is one watcher per
/// subscription and they do not share an event loop, and a future produced by
/// `future_into_py` is bound to the loop that created it -- so an awaitable here would work
/// from the worker's own loop and hang from anywhere else. Blocking with the GIL released is
/// safe: the answer comes off Core's serialized local-input lane, which does no I/O.
///
/// Returns one of `Accepted`, `Stale`, `Parked`, `NoOpenWorkflowTask`, `RunNotFound`.
fn notify_external_stream_ready(
&self,
py: Python<'_>,
run_id: String,
wait_id: u32,
wait_generation: u64,
) -> PyResult<&'static str> {
let worker = self.worker.as_ref().unwrap().clone();
// Deliberately *not* `enter_sync!`: entering the tokio handle would put this thread in an
// async context, and `Handle::block_on` panics there.
let handle = self.runtime.core.tokio_handle();
let result = py.detach(move || {
handle.block_on(worker.notify_external_stream_ready(&run_id, wait_id, wait_generation))
});
Ok(match result {
ExternalStreamReadyResult::Accepted => "Accepted",
ExternalStreamReadyResult::Stale => "Stale",
ExternalStreamReadyResult::Parked => "Parked",
ExternalStreamReadyResult::NoOpenWorkflowTask => "NoOpenWorkflowTask",
ExternalStreamReadyResult::RunNotFound => "RunNotFound",
})
}

/// Ask what state a run's external stream wait set is in, changing nothing.
///
/// Deliberately not the readiness call: readiness means "a record is buffered", so probing
/// with it would assert something false and manufacture a spurious activation on the way out
/// of a shutting-down worker.
///
/// Returns one of `WftOpen`, `Parked`, `NoOpenWorkflowTask`, `RunNotFound`.
fn external_stream_run_status(&self, py: Python<'_>, run_id: String) -> PyResult<&'static str> {
let worker = self.worker.as_ref().unwrap().clone();
let handle = self.runtime.core.tokio_handle();
let status = py.detach(move || handle.block_on(worker.external_stream_run_status(&run_id)));
Ok(match status {
ExternalStreamRunStatus::WftOpen => "WftOpen",
ExternalStreamRunStatus::Parked => "Parked",
ExternalStreamRunStatus::NoOpenWorkflowTask => "NoOpenWorkflowTask",
ExternalStreamRunStatus::RunNotFound => "RunNotFound",
})
}

fn request_workflow_eviction(&self, run_id: &str) -> PyResult<()> {
enter_sync!(self.runtime);
self.worker
Expand Down
116 changes: 116 additions & 0 deletions temporalio/bridge/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from __future__ import annotations

import asyncio
import enum
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from typing import (
Expand Down Expand Up @@ -32,6 +34,62 @@
from temporalio.worker._command_aware_visitor import CommandAwarePayloadVisitor


class ExternalStreamReadyResult(enum.Enum):
"""What Core did with an external stream readiness notification.

Five values, not two, because a watcher does something different with each
and an operator should conclude something different from each.

``NO_OPEN_WORKFLOW_TASK`` and ``RUN_NOT_FOUND`` are separate on purpose. A
Run cached between Workflow Tasks is the healthy post-completion state;
reporting it as a missing Run would both corrupt the metric and tell the
watcher to tear itself down while it is still needed.
"""

ACCEPTED = "Accepted"
"""Serialized into an open Workflow Task. Core will activate; do nothing."""

STALE = "Stale"
"""The wait moved on. Re-probe; do **not** send a Signal."""

PARKED = "Parked"
"""A confirmed park generation exists. Send the reserved wake Signal."""

NO_OPEN_WORKFLOW_TASK = "NoOpenWorkflowTask"
"""Cached with waits registered, no task open. Signal, and **keep** the watcher."""

RUN_NOT_FOUND = "RunNotFound"
"""Absent from this Worker's cache. Signal, then tear the watcher down."""

@property
def needs_wake_signal(self) -> bool:
"""Whether local readiness could not be delivered and a Signal is owed."""
return self in (
ExternalStreamReadyResult.PARKED,
ExternalStreamReadyResult.NO_OPEN_WORKFLOW_TASK,
ExternalStreamReadyResult.RUN_NOT_FOUND,
)


class ExternalStreamRunStatus(enum.Enum):
"""What state a Run's external stream wait set is in.

The answer to a **read-only** probe. Asking changes nothing.
"""

WFT_OPEN = "WftOpen"
"""A Workflow Task is open, retained by the wait set."""

PARKED = "Parked"
"""The complete wait set is parked."""

NO_OPEN_WORKFLOW_TASK = "NoOpenWorkflowTask"
"""Cached with active waits, but no Workflow Task open."""

RUN_NOT_FOUND = "RunNotFound"
"""Absent from this Worker's cache."""


@dataclass
class WorkerConfig:
"""Python representation of the Rust struct for configuring a worker."""
Expand Down Expand Up @@ -259,6 +317,64 @@ def record_activity_heartbeat(
"""Record an activity heartbeat."""
self._ref.record_activity_heartbeat(comp.SerializeToString()) # type: ignore[reportOptionalMemberAccess]

def notify_external_stream_ready_sync(
self, run_id: str, wait_id: int, wait_generation: int
) -> ExternalStreamReadyResult:
"""Tell Core a record is **buffered** for one external stream wait.

Buffered, not merely available: a notification for a record the caller
has not yet read into its buffer would produce an activation whose
drain must block, which is the deadlock hazard the out-of-thread buffer
exists to remove.

Acknowledged rather than fire-and-forget, because what the caller does
next depends entirely on the answer -- see
:py:class:`ExternalStreamReadyResult`.

Blocks while Core answers on its serialized local-input lane. Safe from
any thread, and safe to block in -- the lane does no I/O.

.. warning::
Do **not** call this from the event loop running the Worker. That
loop is what drives the poll this answer comes from, so blocking it
here waits for something only it can produce. Use
:py:meth:`notify_external_stream_ready` from a loop.
"""
return ExternalStreamReadyResult(
self._ref.notify_external_stream_ready( # type: ignore[reportOptionalMemberAccess]
run_id, wait_id, wait_generation
)
)

async def notify_external_stream_ready(
self, run_id: str, wait_id: int, wait_generation: int
) -> ExternalStreamReadyResult:
""":py:meth:`notify_external_stream_ready_sync`, off the calling loop.

There is one watcher per subscription and they do not share a loop, so
this runs the blocking call in a thread rather than binding a future to
whichever loop happened to create it.
"""
return await asyncio.to_thread(
self.notify_external_stream_ready_sync, run_id, wait_id, wait_generation
)

def external_stream_run_status_sync(self, run_id: str) -> ExternalStreamRunStatus:
"""Ask what state a Run's external stream wait set is in, changing nothing.

A separate call from :py:meth:`notify_external_stream_ready` on purpose:
readiness means "a record is buffered", so probing with it would assert
something false and manufacture a spurious Workflow Task on the way out
of a shutting-down Worker.
"""
return ExternalStreamRunStatus(
self._ref.external_stream_run_status(run_id) # type: ignore[reportOptionalMemberAccess]
)

async def external_stream_run_status(self, run_id: str) -> ExternalStreamRunStatus:
""":py:meth:`external_stream_run_status_sync`, off the calling loop."""
return await asyncio.to_thread(self.external_stream_run_status_sync, run_id)

def request_workflow_eviction(self, run_id: str) -> None:
"""Request a workflow be evicted."""
self._ref.request_workflow_eviction(run_id) # type: ignore[reportOptionalMemberAccess]
Expand Down
Loading
Loading