Skip to content
Open
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
ee080d7
Move sdk-core submodule to 6e90e6d5 and fix bridge build
mfateev Aug 14, 2026
9f81277
External streams: generated protos, record model, and the Redis fixture
mfateev Aug 15, 2026
62e331b
External streams: backend contract, annotation codec, codec, failure …
mfateev Aug 15, 2026
57f2476
External streams: parking contract, Redis provider, registry, produce…
mfateev Aug 15, 2026
4e61345
External streams: subscription manager and the Workflow-facing API
mfateev Aug 15, 2026
6e5d4ce
External streams: wire the feature into the Python Worker
mfateev Aug 16, 2026
2c432ba
P13: the replay read path
mfateev Aug 16, 2026
d4c4f73
P14: the producer wake-signal path
mfateev Aug 16, 2026
1814d37
P6b: publish()'s acknowledged-wake semantics
mfateev Aug 16, 2026
7415480
P15: the Continue-As-New cursor
mfateev Aug 16, 2026
23a352d
P21: multiple streams, merge, and same-stream subscriptions
mfateev Aug 16, 2026
f99ccc7
P20: the Worker shutdown wake sweep
mfateev Aug 16, 2026
e71ba38
P16b: the Milestone 2 required-test list
mfateev Aug 16, 2026
c263f2a
Fix the failure-taxonomy row for an annotation mismatch, and P20's mi…
mfateev Aug 16, 2026
c5072df
P16a/P16b: make the milestone gates enforceable
mfateev Aug 16, 2026
003926c
Core bump for C8/C10, and three more Milestone 1 cases
mfateev Aug 16, 2026
84d599c
Replay a stream history through the real Replayer
mfateev Aug 16, 2026
5dc5815
Deliver a record buffered while Workflow code was elsewhere
mfateev Aug 16, 2026
fe7148b
Core bump for C15b, and correct a replay finding made against a stale…
mfateev Aug 16, 2026
482d458
The livelock does not exist: fix the test helper that caused it
mfateev Aug 17, 2026
fe7f65a
Bound delivery within one activation (ADR-026)
mfateev Aug 17, 2026
ff5f0f3
Fix four defects the required-test list exposed
mfateev Aug 17, 2026
9211fa1
Make an unparked wake's sender identity unique per sender
mfateev Aug 17, 2026
21dc039
Rollover cases: one test defect fixed, two reasons corrected
mfateev Aug 17, 2026
d19a896
Rollover cases 19 and 23: both were test defects
mfateev Aug 17, 2026
7c94f41
Milestone 1 gate: 53 of 55, and two honest gaps
mfateev Aug 17, 2026
c72577a
Case 29: reach the window it names, and state what actually blocks it
mfateev Aug 17, 2026
b77003a
Fix four more defects the handoff case exposed, and take the gate to …
mfateev Aug 18, 2026
9ae39db
Close the deadlock: report the quiescent snapshot even when commands …
mfateev Aug 18, 2026
764088d
Close case 36: an empty stream parks, is evicted, and replays from it…
mfateev Aug 18, 2026
ec4b010
Read the required-test lists from their own directory
mfateev Aug 18, 2026
13362db
Bind each wait to its own backend, and stop reporting unsent wakes as…
mfateev Aug 18, 2026
c01d3b4
Make readiness reporting total, and stop dropping a stale answer
mfateev Aug 18, 2026
2dfd3f8
Make the Redis key layout injective, and stop a stream name widening …
mfateev Aug 18, 2026
fd361c1
Make merge fair, and stop consuming a record before it decodes
mfateev Aug 18, 2026
3e4a6f0
Reconcile an inherited park intent, and make parking match Core's wai…
mfateev Aug 18, 2026
b6fa6d5
Bind a late subscription, and replay a segment in its recorded order
mfateev Aug 18, 2026
65474f8
Require a provider's key derivation to be injective
mfateev Aug 18, 2026
b2164b6
Decode off the Workflow thread, and connect the failure taxonomy
mfateev Aug 18, 2026
b13f79f
Ask the converter's public members, not a predicate marked for removal
mfateev Aug 18, 2026
626427e
Finish close(): stop the watcher and take back the park intent
mfateev Aug 19, 2026
8f320d3
Guard the close() teardown, none of which was tested
mfateev Aug 19, 2026
a6fb6e1
Close the seven follow-up findings, which were all failure paths
mfateev Aug 19, 2026
f896f2e
Close the third review's six findings, and the seven the fixes introd…
mfateev Aug 19, 2026
ce3bed0
Settle the Run before asking the status probe to be repeatable
mfateev Aug 19, 2026
77fa4f6
Re-announce buffered records on every completion, not only a budget stop
mfateev Aug 19, 2026
b67a517
Close the fourth review's five findings, and the one its own fix left…
mfateev Aug 19, 2026
212f3bd
Bind the unknown-append recovery to its operation, stream and producer
mfateev Aug 19, 2026
b14769f
Keep one canonical operation across repeated unknown-append recoveries
mfateev Aug 20, 2026
e94df71
Close out the agent handoff notes, and the one defect they left open
mfateev Aug 20, 2026
0863891
Close the fifth review's four findings
mfateev Aug 20, 2026
24ff329
Close the fifth review's remaining findings, including one in its own…
mfateev Aug 21, 2026
9abca0a
Let the continuation writer be staged behind its reader
mfateev Aug 21, 2026
1697446
Name the writer stage, and run a chain through it
mfateev Aug 21, 2026
0aa2fec
Let a merged wait end for every member, not only its winner
mfateev Aug 21, 2026
0ffe0a9
State the merged wait's contract where it belongs, and cover its claim
mfateev Aug 21, 2026
c953bfb
Validate recorded stream state against History, not against configura…
mfateev Aug 22, 2026
3cdb55f
Draw the unparked wake counter from the sender, not from the Run
mfateev Aug 22, 2026
f46898b
Retry an owed park removal without waiting for another event
mfateev Aug 22, 2026
55e1a1c
Close six findings in the owed-removal retry, one in its own fix
mfateev Aug 22, 2026
af4e5ec
Carry the wake a retired park intent owes past its subscription
mfateev Aug 23, 2026
65c46cb
Stop three streaming tests failing on things they do not test
mfateev Aug 24, 2026
ba2e8f5
Coalesce external stream wake cycles
mfateev Aug 24, 2026
d8fb06d
Correlate wake retries with task attempts
mfateev Aug 24, 2026
babf11b
Stabilize sandboxed handoff fixtures
mfateev Aug 24, 2026
5a64c47
Await streaming worker test shutdown
mfateev Aug 24, 2026
5ca2838
Keep pytest rewriting out of workflow sandboxes
mfateev Aug 24, 2026
1094911
Format streaming issue fixes
mfateev Aug 24, 2026
f94da02
Document and type-check streaming support
mfateev Aug 24, 2026
a2c76dc
Point streaming docs at current design
mfateev Aug 25, 2026
7ad1adc
Use a single external stream backend
mfateev Aug 26, 2026
0ebec35
Expose external workflow stream APIs
mfateev Aug 26, 2026
608f3d3
Implement workflow-originated external streams
mfateev Aug 27, 2026
8b46cef
Update Core streaming documentation
mfateev Aug 27, 2026
55734a6
Moved the External Workflow Streams changelog entries to Unreleased.
moetemp Oct 6, 2026
be51a6b
Merge branch 'moe/AI-198-s1-py-11-core-pin' into moe/AI-198-s1-py-12-…
moetemp Oct 7, 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 @@ -78,6 +78,18 @@ to include examples, links to docs, or any other relevant information.
which the handler maps onto the store's accessor for that owner. Configure
the front with `data_converter=` to run a payload codec on the caller side,
so records are encoded before they leave the process.
- 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 @@ -95,6 +95,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