From 7a990e2fd24d5739231e9431d566149fc78ba55d Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Fri, 14 Aug 2026 11:13:51 -0400 Subject: [PATCH 01/87] =?UTF-8?q?=F0=9F=92=A5=20feat(sdk):=20Support=20Sta?= =?UTF-8?q?ndalone=20Activities=20(#1402)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 + crates/client/src/activity.rs | 142 +++++ .../src/activity/activity_execution_info.rs | 571 ++++++++++++++++++ crates/client/src/activity/activity_handle.rs | 207 +++++++ crates/client/src/async_activity_handle.rs | 316 +++++----- crates/client/src/errors.rs | 137 ++++- crates/client/src/lib.rs | 326 +++++++++- crates/client/src/options_structs.rs | 182 +++++- crates/common-wasm/src/data_converters.rs | 3 +- .../src/data_converters/failure_converter.rs | 12 + crates/common-wasm/src/lib.rs | 47 ++ crates/common/src/lib.rs | 8 +- crates/sdk-core/tests/heavy_tests.rs | 16 +- .../async_activity_client_tests.rs | 19 +- .../integ_tests/standalone_activity_tests.rs | 255 ++++++++ .../integ_tests/workflow_tests/activities.rs | 22 +- crates/sdk-core/tests/main.rs | 1 + crates/sdk/src/activities.rs | 44 +- crates/sdk/src/lib.rs | 10 +- crates/workflow/src/lib.rs | 20 +- crates/workflow/src/workflow_context.rs | 10 +- .../workflow/src/workflow_context/options.rs | 53 +- 22 files changed, 2136 insertions(+), 273 deletions(-) create mode 100644 crates/client/src/activity.rs create mode 100644 crates/client/src/activity/activity_execution_info.rs create mode 100644 crates/client/src/activity/activity_handle.rs create mode 100644 crates/sdk-core/tests/integ_tests/standalone_activity_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d47af8aa..efcf8de70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ relevant information. ## Unreleased ### Added +* Support for running Standalone Activities in Rust SDK Worker. +* Client methods for starting and managing execution of Standalone Activities. * `LoggerFormat` for selecting compact, pretty, or JSON Core console log output. Configured log filters continue to apply to JSON output. * `RpcOptions::builder()` for constructing per-call RPC options. @@ -66,6 +68,12 @@ relevant information. `WorkerInterceptor::with_workflow_replay_worker`. ### Breaking Changes :boom: +* Changes to `ActivityInfo`: instead of `workflow_namespace`, `workflow_execution` and `run_id`, + there is now `namespace`, `workflow_id`, `workflow_run_id` and `activity_run_id`. + Also, `workflow_type` is now `Option`. +* `ActivityIdentifier::ById` was split into 2 variants, `ByIdWorkflow` and `ByIdStandalone`. + `ActivityIdentifier::by_id` method was renamed to `by_id_workflow`, and `by_id_standalone` + was added. * `anyhow::Error` no longer converts directly into `WorkflowTermination`. Wrap an error in `ApplicationFailure` to explicitly fail the Workflow Execution. * `OutgoingWorkflowError` now has a dedicated `PayloadConversion` variant. Converting activity, diff --git a/crates/client/src/activity.rs b/crates/client/src/activity.rs new file mode 100644 index 000000000..eb250c8f0 --- /dev/null +++ b/crates/client/src/activity.rs @@ -0,0 +1,142 @@ +mod activity_execution_info; +mod activity_handle; + +use crate::errors::ClientError; +pub use activity_execution_info::{ + ActivityExecutionDescription, ActivityExecutionInfo, ActivityExecutionInfoLike, + ActivityExecutionStatus, PendingActivityState, +}; +pub use activity_handle::ActivityHandle; +use futures_util::{Stream, StreamExt}; +use std::{ + collections::VecDeque, + pin::Pin, + task::{Context, Poll}, +}; +use temporalio_common::{ + protos::temporal::api::{ + activity::v1::ActivityExecutionListInfo, + workflowservice::v1::{ + CountActivityExecutionsResponse, count_activity_executions_response, + }, + }, + search_attributes::{SearchAttributeError, SearchAttributeValue}, +}; + +/// A stream of activity executions from a list query. +/// Internally paginates through results from the server. +pub struct ListActivitiesStream { + inner: Pin, ClientError>> + Send>>, + buffer: VecDeque, +} + +impl ListActivitiesStream { + pub(crate) fn new( + stream: impl Stream, ClientError>> + Send + 'static, + ) -> Self { + Self { + inner: Box::pin(stream), + buffer: VecDeque::new(), + } + } +} + +impl Stream for ListActivitiesStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + if let Some(info) = self.buffer.pop_front() { + return Poll::Ready(Some(Ok(info.into()))); + } + match self.inner.poll_next_unpin(cx) { + Poll::Ready(Some(Ok(items))) => { + self.buffer = items.into(); + } + Poll::Ready(Some(Err(e))) => { + return Poll::Ready(Some(Err(e))); + } + Poll::Ready(None) => { + return Poll::Ready(None); + } + Poll::Pending => { + return Poll::Pending; + } + } + } + } +} + +/// Result of an activity count operation. +/// +/// If the query includes a group-by clause, `groups` will contain the aggregated +/// counts and `count` will be the sum of all group counts. +#[derive(Debug, Clone)] +pub struct ActivityExecutionCount { + count: usize, + groups: Vec, +} + +impl ActivityExecutionCount { + pub(crate) fn from_response(resp: CountActivityExecutionsResponse) -> Self { + Self { + count: resp.count as usize, + groups: resp + .groups + .into_iter() + .map(ActivityExecutionCountAggregationGroup::from_proto) + .collect(), + } + } + + /// The approximate number of activities matching the query. + /// If grouping was applied, this is the sum of all group counts. + pub fn count(&self) -> usize { + self.count + } + + /// The groups if the query had a group-by clause, or empty if not. + pub fn groups(&self) -> &[ActivityExecutionCountAggregationGroup] { + &self.groups + } +} + +/// Aggregation group from an activity count query with a group-by clause. +#[derive(Debug, Clone)] +pub struct ActivityExecutionCountAggregationGroup { + raw: count_activity_executions_response::AggregationGroup, +} + +impl ActivityExecutionCountAggregationGroup { + fn from_proto(proto: count_activity_executions_response::AggregationGroup) -> Self { + Self { raw: proto } + } + + /// Retrieve a typed group value at `index`. + /// + /// Returns `None` if the index is out of bounds or deserialization fails. + /// Use [`Self::try_get`] for explicit error handling. + pub fn get(&self, index: usize) -> Option { + self.try_get(index).ok().flatten() + } + + /// Retrieve a typed group value at `index`, preserving deserialization + /// errors. + /// + /// Returns `Ok(None)` if the index is out of bounds and `Err` if the + /// payload cannot be deserialized. + pub fn try_get( + &self, + index: usize, + ) -> Result, SearchAttributeError> { + match self.raw.group_values.get(index) { + Some(payload) => T::from_search_attribute_payload(payload).map(Some), + None => Ok(None), + } + } + + /// The approximate number of workflows matching for this group. + pub fn count(&self) -> usize { + self.raw.count as usize + } +} diff --git a/crates/client/src/activity/activity_execution_info.rs b/crates/client/src/activity/activity_execution_info.rs new file mode 100644 index 000000000..7997ef69d --- /dev/null +++ b/crates/client/src/activity/activity_execution_info.rs @@ -0,0 +1,571 @@ +use crate::Priority; +use std::{ + error::Error, + marker::PhantomData, + time::{Duration, SystemTime}, +}; +use temporalio_common::{ + ActivityDefinition, RetryPolicy, UntypedActivity, WorkerDeploymentVersion, + data_converters::{ + DataConverter, NoopDecodeHint, PayloadConversionError, SerializationContextData, + TemporalDeserializable, + }, + error::IncomingError, + protos::{ + proto_ts_to_system_time, + temporal::api::{ + activity::v1::{ + ActivityExecutionInfo as RawInfo, ActivityExecutionListInfo as RawListInfo, + activity_execution_outcome::Value as ActivityExecutionOutcomeValue, + }, + common::v1::{Payload, Payloads}, + enums::v1::{ + ActivityExecutionStatus as ProtoActivityExecutionStatus, + PendingActivityState as ProtoPendingActivityState, + }, + failure::v1::Failure, + workflowservice::v1::DescribeActivityExecutionResponse, + }, + utilities::TryIntoOrNone, + }, + search_attributes::SearchAttributes, +}; + +/// Common methods of [`ActivityExecutionInfo`] and [`ActivityExecutionDescription`]. +pub trait ActivityExecutionInfoLike { + /// ID of the activity. + fn activity_id(&self) -> &str; + /// Run ID of a particular execution of the activity. + fn activity_run_id(&self) -> &str; + /// Type of the activity. + fn activity_type(&self) -> &str; + /// Time the activity was originally scheduled. + fn schedule_time(&self) -> Option; + /// Time when the activity transitioned to a closed state. + fn close_time(&self) -> Option; + /// A general status for this activity, indicates whether it is currently running or in one of + /// the terminal statuses. + fn status(&self) -> ActivityExecutionStatus; + /// The task queue this activity was scheduled on. + fn task_queue(&self) -> &str; + /// The difference between close time and scheduled time. This field is only populated if + /// the activity is closed. + fn execution_duration(&self) -> Option; +} + +/// Contains basic information about an activity. +/// Obtained from [`Client::list_activities`](crate::Client::list_activities). +pub struct ActivityExecutionInfo { + raw: RawListInfo, +} + +impl From for ActivityExecutionInfo { + fn from(raw: RawListInfo) -> Self { + Self { raw } + } +} + +impl ActivityExecutionInfoLike for ActivityExecutionInfo { + fn activity_id(&self) -> &str { + &self.raw.activity_id + } + + fn activity_run_id(&self) -> &str { + &self.raw.run_id + } + + fn activity_type(&self) -> &str { + self.raw + .activity_type + .as_ref() + .map(|t| t.name.as_str()) + .unwrap_or("") + } + + fn schedule_time(&self) -> Option { + self.raw + .schedule_time + .as_ref() + .and_then(proto_ts_to_system_time) + } + + fn close_time(&self) -> Option { + self.raw + .close_time + .as_ref() + .and_then(proto_ts_to_system_time) + } + + fn status(&self) -> ActivityExecutionStatus { + ProtoActivityExecutionStatus::try_from(self.raw.status) + .map(Into::into) + .unwrap_or(ActivityExecutionStatus::Unknown) + } + + fn task_queue(&self) -> &str { + &self.raw.task_queue + } + + fn execution_duration(&self) -> Option { + self.raw.execution_duration.try_into_or_none() + } +} + +impl ActivityExecutionInfo { + /// Raw Protobuf object from server response. + pub fn raw_info(&self) -> &RawListInfo { + &self.raw + } +} + +/// Contains the current state of the activity execution. +/// Obtained from [`ActivityHandle::describe`](crate::ActivityHandle::describe). +/// Methods that deserialize payloads (e.g. [`heartbeat_details`](Self::heartbeat_details)) use +/// [`DataConverter`] of the client associated with the activity handle. +pub struct ActivityExecutionDescription +where + ActivityT: ActivityDefinition, +{ + raw_info: RawInfo, + raw_input: Option, + raw_outcome: Option, + data_converter: DataConverter, + serialization_context: SerializationContextData, + _phantom: PhantomData, +} + +impl ActivityExecutionInfoLike for ActivityExecutionDescription +where + ActivityT: ActivityDefinition, +{ + fn activity_id(&self) -> &str { + &self.raw_info.activity_id + } + + fn activity_run_id(&self) -> &str { + &self.raw_info.run_id + } + + fn activity_type(&self) -> &str { + self.raw_info + .activity_type + .as_ref() + .map(|t| t.name.as_str()) + .unwrap_or("") + } + + fn schedule_time(&self) -> Option { + self.raw_info + .schedule_time + .as_ref() + .and_then(proto_ts_to_system_time) + } + + fn close_time(&self) -> Option { + self.raw_info + .close_time + .as_ref() + .and_then(proto_ts_to_system_time) + } + + fn status(&self) -> ActivityExecutionStatus { + ProtoActivityExecutionStatus::try_from(self.raw_info.status) + .map(Into::into) + .unwrap_or(ActivityExecutionStatus::Unknown) + } + + fn task_queue(&self) -> &str { + &self.raw_info.task_queue + } + + fn execution_duration(&self) -> Option { + self.raw_info.execution_duration.try_into_or_none() + } +} + +impl ActivityExecutionDescription +where + ActivityT: ActivityDefinition, +{ + pub(crate) fn new( + data_converter: DataConverter, + serialization_context: SerializationContextData, + response: DescribeActivityExecutionResponse, + ) -> Result> { + let Some(raw_info) = response.info else { + return Err("info missing in describe response".into()); + }; + Ok(Self { + raw_info, + raw_input: response.input, + raw_outcome: response.outcome.and_then(|o| o.value), + data_converter, + serialization_context, + _phantom: PhantomData, + }) + } + + /// Convert to an untyped description object. + pub fn untyped(self) -> ActivityExecutionDescription { + ActivityExecutionDescription { + raw_info: self.raw_info, + raw_input: self.raw_input, + raw_outcome: self.raw_outcome, + data_converter: self.data_converter, + serialization_context: self.serialization_context, + _phantom: PhantomData, + } + } + + /// Raw Protobuf object from server response. + pub fn raw_info(&self) -> &RawInfo { + &self.raw_info + } + + /// True if activity input is present. + /// See [`ActivityDescribeOptions::include_input`](crate::ActivityDescribeOptions::include_input). + /// Use [`input`](Self::input) or [`raw_input`](Self::raw_input) to retrieve it. + pub fn has_input(&self) -> bool { + self.raw_input.is_some() + } + + /// Raw payload of activity input, if it was requested. + pub fn raw_input(&self) -> Option<&Payloads> { + self.raw_input.as_ref() + } + + /// Deserialize activity input. Returns `Ok(None)` if not present. + /// See [`ActivityDescribeOptions::include_input`](crate::ActivityDescribeOptions::include_input). + pub async fn input(&self) -> Result, PayloadConversionError> { + let Some(input) = &self.raw_input else { + return Ok(None); + }; + Ok(Some(self.convert_payloads(input).await?)) + } + + /// True if activity outcome is present. + /// See [`ActivityDescribeOptions::include_outcome`](crate::ActivityDescribeOptions::include_outcome). + /// Use [`outcome`](Self::outcome) or [`raw_outcome`](Self::outcome) to retrieve it. + pub fn has_outcome(&self) -> bool { + self.raw_outcome.is_some() + } + + /// Raw payload of activity output, if it was requested and available. + pub fn raw_outcome(&self) -> Option<&ActivityExecutionOutcomeValue> { + self.raw_outcome.as_ref() + } + + /// Deserialize activity outcome. Returns `Ok(None)` if not present. + /// See [`ActivityDescribeOptions::include_outcome`](crate::ActivityDescribeOptions::include_outcome). + pub async fn outcome( + &self, + ) -> Result>, PayloadConversionError> { + match &self.raw_outcome { + None => Ok(None), + Some(ActivityExecutionOutcomeValue::Result(payloads)) => { + Ok(Some(Ok(self.convert_payloads(payloads).await?))) + } + Some(ActivityExecutionOutcomeValue::Failure(failure)) => { + Ok(Some(Err(self.convert_failure(failure)?))) + } + } + } + + /// More detailed breakdown of [`ActivityExecutionStatus::Running`]. + pub fn run_state(&self) -> PendingActivityState { + ProtoPendingActivityState::try_from(self.raw_info.run_state) + .map(Into::into) + .unwrap_or(PendingActivityState::Unknown) + } + + /// Indicates how long the caller is willing to wait for an activity completion. Limits how long + /// retries will be attempted. + pub fn schedule_to_close_timeout(&self) -> Option { + self.raw_info.schedule_to_close_timeout.try_into_or_none() + } + + /// Limits time an activity task can stay in a task queue before a worker picks it up. This + /// timeout is always non-retryable. + pub fn schedule_to_start_timeout(&self) -> Option { + self.raw_info.schedule_to_start_timeout.try_into_or_none() + } + + /// Maximum time a single activity attempt is allowed to execute after being picked up by + /// a worker. This timeout is always retryable. + pub fn start_to_close_timeout(&self) -> Option { + self.raw_info.start_to_close_timeout.try_into_or_none() + } + + /// Maximum permitted time between successful worker heartbeats. + pub fn heartbeat_timeout(&self) -> Option { + self.raw_info.heartbeat_timeout.try_into_or_none() + } + + /// The retry policy for the activity. + pub fn retry_policy(&self) -> Option { + self.raw_info.retry_policy.clone().map(Into::into) + } + + /// True if heartbeat details are present. + /// See [`ActivityDescribeOptions::include_heartbeat_details`](crate::ActivityDescribeOptions::include_heartbeat_details). + /// Use [`heartbeat_details`](Self::heartbeat_details) or + /// [`raw_info()`](Self::raw_info)`.`[`heartbeat_details`](RawInfo::heartbeat_details) + /// to retrieve them. + pub fn has_heartbeat_details(&self) -> bool { + self.raw_info.heartbeat_details.is_some() + } + + /// Deserialize heartbeat details. Returns `Ok(None)` if not present. + /// See [`ActivityDescribeOptions::include_heartbeat_details`](crate::ActivityDescribeOptions::include_heartbeat_details). + pub async fn heartbeat_details( + &self, + ) -> Result, PayloadConversionError> { + let Some(details) = &self.raw_info.heartbeat_details else { + return Ok(None); + }; + Ok(Some(self.convert_payloads(details).await?)) + } + + /// Time the last heartbeat was recorded. + pub fn last_heartbeat_time(&self) -> Option { + self.raw_info + .last_heartbeat_time + .as_ref() + .and_then(proto_ts_to_system_time) + } + + /// Time the last attempt was started. + pub fn last_started_time(&self) -> Option { + self.raw_info + .last_started_time + .as_ref() + .and_then(proto_ts_to_system_time) + } + + /// The attempt this activity is currently on. Incremented each time a new attempt is scheduled. + pub fn attempt(&self) -> u32 { + self.raw_info.attempt.try_into().unwrap_or_default() + } + + /// How long this activity has been running for, including all attempts and backoff between + /// attempts. + pub fn execution_duration(&self) -> Option { + self.raw_info.execution_duration.try_into_or_none() + } + + /// Scheduled time + schedule to close timeout. + pub fn expiration_time(&self) -> Option { + self.raw_info + .expiration_time + .as_ref() + .and_then(proto_ts_to_system_time) + } + + /// True if last failure is present. + /// See [`ActivityDescribeOptions::include_last_failure`](crate::ActivityDescribeOptions::include_last_failure). + /// Use [`last_failure()`](Self::last_failure) or + /// [`raw_info()`](Self::raw_info)`.`[`last_failure`](RawInfo::last_failure) + /// to retrieve it. + pub fn has_last_failure(&self) -> bool { + self.raw_info.last_failure.is_some() + } + + /// Deserialize last failure. Returns `Ok(None)` if not present. + /// See [`ActivityDescribeOptions::include_last_failure`](crate::ActivityDescribeOptions::include_last_failure). + pub fn last_failure(&self) -> Result, PayloadConversionError> { + let Some(failure) = &self.raw_info.last_failure else { + return Ok(None); + }; + Ok(Some(self.convert_failure(failure)?)) + } + + /// Identity of the last worker that attempted this activity. + pub fn last_worker_identity(&self) -> Option<&str> { + self.raw_info + .last_worker_identity + .is_empty() + .then_some(self.raw_info.last_worker_identity.as_str()) + } + + /// Time from the last attempt failure to the next activity retry. + pub fn current_retry_interval(&self) -> Option { + self.raw_info.current_retry_interval.try_into_or_none() + } + + /// The time when the last activity attempt completed. + pub fn last_attempt_complete_time(&self) -> Option { + self.raw_info + .last_attempt_complete_time + .as_ref() + .and_then(proto_ts_to_system_time) + } + + /// The time when the next activity attempt will be scheduled. + pub fn next_attempt_schedule_time(&self) -> Option { + self.raw_info + .next_attempt_schedule_time + .as_ref() + .and_then(proto_ts_to_system_time) + } + + /// The Worker Deployment Version this activity was dispatched to most recently. + pub fn last_deployment_version(&self) -> Option { + self.raw_info + .last_deployment_version + .clone() + .map(Into::into) + } + + /// Priority metadata. + pub fn priority(&self) -> Priority { + self.raw_info.priority.clone().unwrap_or_default().into() + } + + /// Search attributes of the activity. + pub fn search_attributes(&self) -> Option { + self.raw_info + .search_attributes + .as_ref() + .map(SearchAttributes::from_proto) + } + + /// Deserialize static summary that was set when activity was scheduled. + /// Returns `Ok(None)` if not present. + pub async fn static_summary(&self) -> Result, PayloadConversionError> { + let Some(summary) = self + .raw_info + .user_metadata + .as_ref() + .and_then(|m| m.summary.clone()) + else { + return Ok(None); + }; + Ok(Some(self.convert_payload(summary).await?)) + } + + /// Deserialize static details that were set when activity was scheduled. + /// Returns `Ok(None)` if not present. + pub async fn static_details(&self) -> Result, PayloadConversionError> { + let Some(details) = self + .raw_info + .user_metadata + .as_ref() + .and_then(|m| m.details.clone()) + else { + return Ok(None); + }; + Ok(Some(self.convert_payload(details).await?)) + } + + /// Reason for activity cancellation if activity was canceled and reason was provided. + pub fn canceled_reason(&self) -> Option<&str> { + let reason = self.raw_info.canceled_reason.as_str(); + (!reason.is_empty()).then_some(reason) + } + + /// Time to wait before dispatching the first activity task. + /// This delay is not applied to retry attempts. + pub fn start_delay(&self) -> Option { + self.raw_info.start_delay.try_into_or_none() + } + + async fn convert_payload( + &self, + payload: Payload, + ) -> Result { + self.data_converter + .from_payload(&self.serialization_context, payload) + .await + } + + async fn convert_payloads( + &self, + payloads: &Payloads, + ) -> Result { + self.data_converter + .from_payloads(&self.serialization_context, payloads.payloads.clone()) + .await + } + + fn convert_failure(&self, failure: &Failure) -> Result { + self.data_converter + .to_error(&self.serialization_context, failure.clone(), NoopDecodeHint) + } +} + +/// Execution status of an activity. See [`ActivityExecutionInfoLike::status`]. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ActivityExecutionStatus { + #[default] + /// This variant indicates the server did not specify a value. + Unspecified, + /// The activity has not reached a terminal status. + /// See [`ActivityExecutionDescription::run_state`] for the run state. + Running, + /// The activity completed successfully. + Completed, + /// The activity failed with an error. + Failed, + /// The activity was canceled. Note that cancellation is cooperative and a cancel request does + /// not always result in canceled status. + Canceled, + /// The activity was terminated. + Terminated, + /// The activity timed out. + TimedOut, + /// The activity is paused. + Paused, + /// This variant indicates the server used a value not known by this version of the SDK. + Unknown, +} + +impl From for ActivityExecutionStatus { + fn from(value: ProtoActivityExecutionStatus) -> Self { + match value { + ProtoActivityExecutionStatus::Unspecified => Self::Unspecified, + ProtoActivityExecutionStatus::Running => Self::Running, + ProtoActivityExecutionStatus::Completed => Self::Completed, + ProtoActivityExecutionStatus::Failed => Self::Failed, + ProtoActivityExecutionStatus::Canceled => Self::Canceled, + ProtoActivityExecutionStatus::Terminated => Self::Terminated, + ProtoActivityExecutionStatus::TimedOut => Self::TimedOut, + ProtoActivityExecutionStatus::Paused => Self::Paused, + } + } +} + +/// Detailed state of an activity with [`ActivityExecutionStatus::Running`]. +/// See [`ActivityExecutionDescription::run_state`]. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum PendingActivityState { + #[default] + /// This variant indicates the server did not specify a state. + Unspecified, + /// Activity is scheduled for execution but not yet running on a worker. + Scheduled, + /// Activity is running on a worker. + Started, + /// Activity has been requested to cancel. + CancelRequested, + /// Activity is paused on the server, and is not running on a worker. + Paused, + /// Activity is currently running on a worker, but paused on the server. + PauseRequested, + /// This variant indicates the server used a value not known by this version of the SDK. + Unknown, +} + +impl From for PendingActivityState { + fn from(value: ProtoPendingActivityState) -> Self { + match value { + ProtoPendingActivityState::Unspecified => Self::Unspecified, + ProtoPendingActivityState::Scheduled => Self::Scheduled, + ProtoPendingActivityState::Started => Self::Started, + ProtoPendingActivityState::CancelRequested => Self::CancelRequested, + ProtoPendingActivityState::Paused => Self::Paused, + ProtoPendingActivityState::PauseRequested => Self::PauseRequested, + } + } +} diff --git a/crates/client/src/activity/activity_handle.rs b/crates/client/src/activity/activity_handle.rs new file mode 100644 index 000000000..bb90b2aea --- /dev/null +++ b/crates/client/src/activity/activity_handle.rs @@ -0,0 +1,207 @@ +use crate::{ + ActivityCancelOptions, ActivityDescribeOptions, ActivityExecutionDescription, + ActivityTerminateOptions, NamespacedClient, + errors::{ActivityInteractionError, ActivityResultError}, + grpc::WorkflowService, +}; +use std::marker::PhantomData; +use temporalio_common::{ + ActivityDefinition, + data_converters::{DecodablePayloads, NoopDecodeHint, SerializationContextData}, + protos::temporal::api::{ + activity::v1::{ActivityExecutionOutcome, activity_execution_outcome}, + failure::v1::failure::FailureInfo, + workflowservice::v1::{ + DescribeActivityExecutionRequest, PollActivityExecutionRequest, + RequestCancelActivityExecutionRequest, TerminateActivityExecutionRequest, + }, + }, +}; +use tonic::IntoRequest; +use uuid::Uuid; + +/// Handle associated with a standalone activity execution that can be used to wait for the result +/// or to manage execution of the activity. Obtained from +/// [`Client::start_activity`](crate::Client::start_activity) or +/// [`Client::get_activity_handle`](crate::Client::get_activity_handle). +/// +/// If [`run_id`](Self::run_id) is set, the handle always targets that specific execution. +/// If [`run_id`](Self::run_id) is `None`, each method call targets the latest run of the specified +/// [`activity_id`](Self::activity_id) at the time the method is called - this means consecutive +/// method calls may target different executions if an activity was started again with the same ID. +pub struct ActivityHandle +where + ActivityT: ActivityDefinition, +{ + client: ClientT, + activity_id: String, + run_id: Option, + _phantom: PhantomData, +} + +impl ActivityHandle +where + ActivityT: ActivityDefinition, +{ + pub(crate) fn new(client: ClientT, activity_id: String, run_id: Option) -> Self { + Self { + client, + activity_id, + run_id, + _phantom: PhantomData, + } + } + + /// Activity ID this handle is associated with. + pub fn activity_id(&self) -> &str { + &self.activity_id + } + + /// Run ID of the activity execution this handle is associated with. If `None`, each method call + /// targets the latest run of the specified [`activity_id`](Self::activity_id) at the time the + /// method is called - this means consecutive method calls may target different executions if + /// an activity was started again with the same ID. + pub fn run_id(&self) -> Option<&str> { + self.run_id.as_deref() + } +} + +impl ActivityHandle +where + ClientT: WorkflowService + NamespacedClient + Clone, + ActivityT: ActivityDefinition, +{ + /// Wait for the activity to complete and fetch its result. If the activity was not successful + /// (e.g. failed, canceled, timed out), this method returns [`ActivityResultError::ActivityFailed`]. + pub async fn result(&self) -> Result { + let mut client = self.client.clone(); + loop { + let resp = client + .poll_activity_execution( + PollActivityExecutionRequest { + namespace: client.namespace(), + activity_id: self.activity_id.clone(), + run_id: self.run_id.clone().unwrap_or_default(), + } + .into_request(), + ) + .await? + .into_inner(); + + // If resp.outcome.value is None, poll again + let Some(ActivityExecutionOutcome { + value: Some(outcome), + .. + }) = resp.outcome + else { + continue; + }; + + let dc = client.data_converter(); + let ctx = SerializationContextData::Activity; + + return match outcome { + activity_execution_outcome::Value::Result(payloads) => { + Ok(dc.from_payloads(&ctx, payloads.payloads).await?) + } + activity_execution_outcome::Value::Failure(failure) => { + Err(match failure.failure_info { + Some(FailureInfo::CanceledFailureInfo(info)) => { + let payloads = info.details.unwrap_or_default().payloads; + let details = DecodablePayloads::new( + payloads, + dc.payload_converter().clone(), + ctx, + ); + ActivityResultError::Cancelled { details } + } + Some(FailureInfo::TerminatedFailureInfo(_)) => { + ActivityResultError::Terminated + } + _ => ActivityResultError::ActivityFailed(dc.to_error( + &ctx, + failure, + NoopDecodeHint, + )?), + }) + } + }; + } + } + + /// Describes the current state of the activity execution. + pub async fn describe( + &self, + options: ActivityDescribeOptions, + ) -> Result, ActivityInteractionError> { + let mut client = self.client.clone(); + let resp = client + .describe_activity_execution( + DescribeActivityExecutionRequest { + namespace: client.namespace(), + activity_id: self.activity_id.clone(), + run_id: self.run_id.clone().unwrap_or_default(), + include_input: options.include_input, + include_outcome: options.include_outcome, + include_heartbeat_details: options.include_heartbeat_details, + include_last_failure: options.include_last_failure, + ..Default::default() + } + .into_request(), + ) + .await? + .into_inner(); + + Ok(ActivityExecutionDescription::new( + client.data_converter().clone(), + SerializationContextData::Activity, + resp, + )?) + } + + /// Requests cancellation of the activity. Does not wait for the cancellation to complete. + pub async fn cancel( + &self, + options: ActivityCancelOptions, + ) -> Result<(), ActivityInteractionError> { + let mut client = self.client.clone(); + client + .request_cancel_activity_execution( + RequestCancelActivityExecutionRequest { + namespace: client.namespace(), + activity_id: self.activity_id.clone(), + run_id: self.run_id.clone().unwrap_or_default(), + identity: client.identity(), + request_id: Uuid::new_v4().to_string(), + reason: options.reason, + } + .into_request(), + ) + .await?; + + Ok(()) + } + + /// Terminates activity execution. + pub async fn terminate( + &self, + options: ActivityTerminateOptions, + ) -> Result<(), ActivityInteractionError> { + let mut client = self.client.clone(); + client + .terminate_activity_execution( + TerminateActivityExecutionRequest { + namespace: client.namespace(), + activity_id: self.activity_id.clone(), + run_id: self.run_id.clone().unwrap_or_default(), + identity: client.identity(), + request_id: Uuid::new_v4().to_string(), + reason: options.reason, + } + .into_request(), + ) + .await?; + + Ok(()) + } +} diff --git a/crates/client/src/async_activity_handle.rs b/crates/client/src/async_activity_handle.rs index d0b5e2c5b..4f7ac257b 100644 --- a/crates/client/src/async_activity_handle.rs +++ b/crates/client/src/async_activity_handle.rs @@ -54,8 +54,8 @@ async fn encode_optional_value( pub enum ActivityIdentifier { /// Identify activity by its task token TaskToken(TaskToken), - /// Identify activity by workflow and activity IDs. - ById { + /// Identify workflow activity by workflow and activity IDs. + ByIdWorkflow { /// ID of the workflow that scheduled this activity. workflow_id: String, /// Run ID of the workflow (optional - if not provided, targets the latest run). @@ -63,6 +63,13 @@ pub enum ActivityIdentifier { /// ID of the activity to complete. activity_id: String, }, + /// Identify standalone activity by activity ID. + ByIdStandalone { + /// ID of the activity to complete. + activity_id: String, + /// Run ID of the activity (optional - if not provided, targets the latest run). + run_id: String, + }, } impl ActivityIdentifier { @@ -73,17 +80,42 @@ impl ActivityIdentifier { /// Create an identifier from workflow and activity IDs. Use an empty run id to target the /// latest workflow execution. - pub fn by_id( + pub fn by_id_workflow( workflow_id: impl Into, run_id: impl Into, activity_id: impl Into, ) -> Self { - Self::ById { + Self::ByIdWorkflow { workflow_id: workflow_id.into(), run_id: run_id.into(), activity_id: activity_id.into(), } } + + /// Create an identifier from standalone activity ID. Use an empty run id to target the + /// latest activity execution. + pub fn by_id_standalone(activity_id: impl Into, run_id: impl Into) -> Self { + Self::ByIdStandalone { + activity_id: activity_id.into(), + run_id: run_id.into(), + } + } + + /// Returns tuple of (workflow_id, run_id, activity_id). + fn into_parts(self) -> Option<(String, String, String)> { + match self { + Self::TaskToken(_) => None, + Self::ByIdWorkflow { + workflow_id, + run_id, + activity_id, + } => Some((workflow_id, run_id, activity_id)), + Self::ByIdStandalone { + activity_id, + run_id, + } => Some((String::new(), run_id, activity_id)), + } + } } /// Handle for completing activities asynchronously (outside the worker). @@ -131,47 +163,41 @@ impl AsyncActivityHandle { Box::pin(async move { let (identifier, result, rpc_options) = input.into_parts(); let result = encode_optional_value(result, client.data_converter()).await?; - match identifier { - ActivityIdentifier::TaskToken(token) => { - let mut request = RespondActivityTaskCompletedRequest { - task_token: token.0, - result, - identity: client.identity(), - namespace: client.namespace(), - ..Default::default() - } - .into_request(); - rpc_options.apply_to(&mut request); - WorkflowService::respond_activity_task_completed( - &mut client, - request, - ) - .await - .map_err(AsyncActivityError::from_status)?; + if let ActivityIdentifier::TaskToken(token) = identifier { + let mut request = RespondActivityTaskCompletedRequest { + task_token: token.0, + result, + identity: client.identity(), + namespace: client.namespace(), + ..Default::default() } - ActivityIdentifier::ById { + .into_request(); + rpc_options.apply_to(&mut request); + WorkflowService::respond_activity_task_completed( + &mut client, + request, + ) + .await + .map_err(AsyncActivityError::from_status)?; + } else { + let (workflow_id, run_id, activity_id) = identifier.into_parts().unwrap(); + let mut request = RespondActivityTaskCompletedByIdRequest { + namespace: client.namespace(), workflow_id, run_id, activity_id, - } => { - let mut request = RespondActivityTaskCompletedByIdRequest { - namespace: client.namespace(), - workflow_id, - run_id, - activity_id, - result, - identity: client.identity(), - resource_id: Default::default(), - } - .into_request(); - rpc_options.apply_to(&mut request); - WorkflowService::respond_activity_task_completed_by_id( - &mut client, - request, - ) - .await - .map_err(AsyncActivityError::from_status)?; + result, + identity: client.identity(), + resource_id: Default::default(), } + .into_request(); + rpc_options.apply_to(&mut request); + WorkflowService::respond_activity_task_completed_by_id( + &mut client, + request, + ) + .await + .map_err(AsyncActivityError::from_status)?; } Ok(()) }) @@ -224,49 +250,43 @@ impl AsyncActivityHandle { .await?; let last_heartbeat_details = encode_optional_value(details, &data_converter).await?; - match identifier { - ActivityIdentifier::TaskToken(token) => { - let mut request = RespondActivityTaskFailedRequest { - task_token: token.0, - failure: Some(failure), - identity: client.identity(), - namespace: client.namespace(), - last_heartbeat_details, - ..Default::default() - } - .into_request(); - rpc_options.apply_to(&mut request); - WorkflowService::respond_activity_task_failed( - &mut client, - request, - ) - .await - .map_err(AsyncActivityError::from_status)?; + if let ActivityIdentifier::TaskToken(token) = identifier { + let mut request = RespondActivityTaskFailedRequest { + task_token: token.0, + failure: Some(failure), + identity: client.identity(), + namespace: client.namespace(), + last_heartbeat_details, + ..Default::default() } - ActivityIdentifier::ById { + .into_request(); + rpc_options.apply_to(&mut request); + WorkflowService::respond_activity_task_failed( + &mut client, + request, + ) + .await + .map_err(AsyncActivityError::from_status)?; + } else { + let (workflow_id, run_id, activity_id) = identifier.into_parts().unwrap(); + let mut request = RespondActivityTaskFailedByIdRequest { + namespace: client.namespace(), workflow_id, run_id, activity_id, - } => { - let mut request = RespondActivityTaskFailedByIdRequest { - namespace: client.namespace(), - workflow_id, - run_id, - activity_id, - failure: Some(failure), - identity: client.identity(), - last_heartbeat_details, - resource_id: Default::default(), - } - .into_request(); - rpc_options.apply_to(&mut request); - WorkflowService::respond_activity_task_failed_by_id( - &mut client, - request, - ) - .await - .map_err(AsyncActivityError::from_status)?; + failure: Some(failure), + identity: client.identity(), + last_heartbeat_details, + resource_id: Default::default(), } + .into_request(); + rpc_options.apply_to(&mut request); + WorkflowService::respond_activity_task_failed_by_id( + &mut client, + request, + ) + .await + .map_err(AsyncActivityError::from_status)?; } Ok(()) }) @@ -301,47 +321,41 @@ impl AsyncActivityHandle { Box::pin(async move { let (identifier, details, rpc_options) = input.into_parts(); let details = encode_optional_value(details, client.data_converter()).await?; - match identifier { - ActivityIdentifier::TaskToken(token) => { - let mut request = RespondActivityTaskCanceledRequest { - task_token: token.0, - details, - identity: client.identity(), - namespace: client.namespace(), - ..Default::default() - } - .into_request(); - rpc_options.apply_to(&mut request); - WorkflowService::respond_activity_task_canceled( - &mut client, - request, - ) - .await - .map_err(AsyncActivityError::from_status)?; + if let ActivityIdentifier::TaskToken(token) = identifier { + let mut request = RespondActivityTaskCanceledRequest { + task_token: token.0, + details, + identity: client.identity(), + namespace: client.namespace(), + ..Default::default() } - ActivityIdentifier::ById { + .into_request(); + rpc_options.apply_to(&mut request); + WorkflowService::respond_activity_task_canceled( + &mut client, + request, + ) + .await + .map_err(AsyncActivityError::from_status)?; + } else { + let (workflow_id, run_id, activity_id) = identifier.into_parts().unwrap(); + let mut request = RespondActivityTaskCanceledByIdRequest { + namespace: client.namespace(), workflow_id, run_id, activity_id, - } => { - let mut request = RespondActivityTaskCanceledByIdRequest { - namespace: client.namespace(), - workflow_id, - run_id, - activity_id, - details, - identity: client.identity(), - ..Default::default() - } - .into_request(); - rpc_options.apply_to(&mut request); - WorkflowService::respond_activity_task_canceled_by_id( - &mut client, - request, - ) - .await - .map_err(AsyncActivityError::from_status)?; + details, + identity: client.identity(), + ..Default::default() } + .into_request(); + rpc_options.apply_to(&mut request); + WorkflowService::respond_activity_task_canceled_by_id( + &mut client, + request, + ) + .await + .map_err(AsyncActivityError::from_status)?; } Ok(()) }) @@ -375,52 +389,46 @@ impl AsyncActivityHandle { Box::pin(async move { let (identifier, details, rpc_options) = input.into_parts(); let details = encode_optional_value(details, client.data_converter()).await?; - match identifier { - ActivityIdentifier::TaskToken(token) => { - let mut request = RecordActivityTaskHeartbeatRequest { - task_token: token.0, - details, - identity: client.identity(), - namespace: client.namespace(), - resource_id: Default::default(), - } - .into_request(); - rpc_options.apply_to(&mut request); - let response = WorkflowService::record_activity_task_heartbeat( + if let ActivityIdentifier::TaskToken(token) = identifier { + let mut request = RecordActivityTaskHeartbeatRequest { + task_token: token.0, + details, + identity: client.identity(), + namespace: client.namespace(), + resource_id: Default::default(), + } + .into_request(); + rpc_options.apply_to(&mut request); + let response = WorkflowService::record_activity_task_heartbeat( + &mut client, + request, + ) + .await + .map_err(AsyncActivityError::from_status)? + .into_inner(); + Ok(ActivityHeartbeatResponse::from(response)) + } else { + let (workflow_id, run_id, activity_id) = identifier.into_parts().unwrap(); + let mut request = RecordActivityTaskHeartbeatByIdRequest { + namespace: client.namespace(), + workflow_id, + run_id, + activity_id, + details, + identity: client.identity(), + resource_id: Default::default(), + } + .into_request(); + rpc_options.apply_to(&mut request); + let response = + WorkflowService::record_activity_task_heartbeat_by_id( &mut client, request, ) .await .map_err(AsyncActivityError::from_status)? .into_inner(); - Ok(ActivityHeartbeatResponse::from(response)) - } - ActivityIdentifier::ById { - workflow_id, - run_id, - activity_id, - } => { - let mut request = RecordActivityTaskHeartbeatByIdRequest { - namespace: client.namespace(), - workflow_id, - run_id, - activity_id, - details, - identity: client.identity(), - resource_id: Default::default(), - } - .into_request(); - rpc_options.apply_to(&mut request); - let response = - WorkflowService::record_activity_task_heartbeat_by_id( - &mut client, - request, - ) - .await - .map_err(AsyncActivityError::from_status)? - .into_inner(); - Ok(ActivityHeartbeatResponse::from(response)) - } + Ok(ActivityHeartbeatResponse::from(response)) } }) } diff --git a/crates/client/src/errors.rs b/crates/client/src/errors.rs index 1bc16a725..9dcc630c2 100644 --- a/crates/client/src/errors.rs +++ b/crates/client/src/errors.rs @@ -3,8 +3,14 @@ use crate::{PluginApplyError, WorkflowExecutionStatus, workflow_handle::WorkflowResultDetails}; use http::uri::InvalidUri; use temporalio_common::{ - data_converters::PayloadConversionError, error::IncomingError, - protos::temporal::api::failure::v1::Failure, + data_converters::{DecodablePayloads, PayloadConversionError}, + error::{IncomingError, TimeoutType}, + protos::{ + temporal::api::{ + errordetails::v1::ActivityExecutionAlreadyStartedFailure, failure::v1::Failure, + }, + utilities::decode_status_detail, + }, }; use tonic::Code; @@ -327,3 +333,130 @@ pub enum ClientNewError { #[error(transparent)] Plugin(#[from] PluginApplyError), } + +/// Errors returned by methods on [crate::ActivityHandle] that don't need more specific error types. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ActivityInteractionError { + /// The activity was not found. + #[error("Activity not found")] + NotFound(#[source] tonic::Status), + + /// Error deserializing output. + #[error("Payload conversion error: {0}")] + PayloadConversion(#[from] PayloadConversionError), + + /// An uncategorized RPC error from the server. + #[error("Server error: {0}")] + Rpc(#[source] tonic::Status), + + /// Other errors. + #[error(transparent)] + Other(#[from] Box), +} + +impl From for ActivityInteractionError { + fn from(status: tonic::Status) -> Self { + if status.code() == Code::NotFound { + Self::NotFound(status) + } else { + Self::Rpc(status) + } + } +} + +/// Errors that can occur when starting a standalone activity. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum StartActivityError { + /// There's a conflicting activity execution with the same ID according to chosen ID reuse + /// policy and ID conflict policy. + #[error("Activity already started with run_id={run_id}")] + AlreadyStarted { + /// Run ID of the existing execution with the same activity ID. + run_id: String, + /// Raw error from the server. + #[source] + source: tonic::Status, + }, + + /// Error serializing input. + #[error("Payload conversion error: {0}")] + PayloadConversion(#[from] PayloadConversionError), + + /// An uncategorized RPC error from the server. + #[error("Server error: {0}")] + Rpc(#[source] tonic::Status), + + /// Other errors. + #[error(transparent)] + Other(#[from] Box), +} + +impl From for StartActivityError { + fn from(status: tonic::Status) -> Self { + if status.code() == tonic::Code::AlreadyExists + && let Some(details) = + decode_status_detail::(status.details()) + { + StartActivityError::AlreadyStarted { + run_id: details.run_id, + source: status, + } + } else { + StartActivityError::Rpc(status) + } + } +} + +/// Errors returned by [`crate::ActivityHandle::result`]. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ActivityResultError { + /// Activity execution did not complete successfully. + #[error("Activity failed: {0}")] + ActivityFailed(#[source] IncomingError), + + /// The activity was canceled. + #[error("Activity canceled")] + Cancelled { + /// Details provided at cancellation time. + details: DecodablePayloads, + }, + + /// The workflow was terminated. + #[error("Activity terminated")] + Terminated, + + /// The activity timed out. + #[error("Activity timed out: {0:?}")] + TimedOut(TimeoutType), + + /// The activity was not found. + #[error("Activity not found")] + NotFound(#[source] tonic::Status), + + /// Error deserializing output. + #[error("Payload conversion error: {0}")] + PayloadConversion(#[from] PayloadConversionError), + + /// An uncategorized RPC error from the server. + #[error("Server error: {0}")] + Rpc(#[source] tonic::Status), + + /// Other errors. + #[error(transparent)] + Other(#[from] Box), +} + +impl From for ActivityResultError { + fn from(status: tonic::Status) -> Self { + if status.code() == Code::NotFound { + Self::NotFound(status) + } else { + Self::Rpc(status) + } + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 19cf2b89b..edb8a3751 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -7,6 +7,7 @@ #[macro_use] extern crate tracing; +mod activity; mod async_activity_handle; pub mod callback_based; mod dns; @@ -41,6 +42,7 @@ pub use crate::{ request_extensions::PayloadErrorLimits, retry::{CallType, RETRYABLE_ERROR_CODES}, }; +pub use activity::*; pub use async_activity_handle::{ ActivityHeartbeatResponse, ActivityIdentifier, AsyncActivityHandle, }; @@ -118,6 +120,7 @@ use http::Uri; use parking_lot::RwLock; use std::{ collections::{HashMap, VecDeque}, + error::Error, fmt::Debug, pin::Pin, str::FromStr, @@ -126,7 +129,7 @@ use std::{ time::{Duration, SystemTime}, }; use temporalio_common::{ - HasWorkflowDefinition, + ActivityDefinition, HasWorkflowDefinition, UntypedActivity, data_converters::{ DataConverter, GenericPayloadConverter, PayloadConverter, SerializationContext, SerializationContextData, @@ -138,8 +141,11 @@ use temporalio_common::{ proto_ts_to_system_time, temporal::api::{ cloud::cloudservice::v1::cloud_service_client::CloudServiceClient, - common::v1::WorkflowType, - enums::v1::TaskQueueKind, + common::v1::{ActivityType, WorkflowType}, + enums::v1::{ + ActivityIdConflictPolicy as ProtoActivityIdConflictPolicy, + ActivityIdReusePolicy as ProtoActivityIdReusePolicy, TaskQueueKind, + }, errordetails::v1::WorkflowExecutionAlreadyStartedFailure, operatorservice::v1::operator_service_client::OperatorServiceClient, sdk::v1::UserMetadata, @@ -1184,12 +1190,93 @@ impl Client { /// Get a handle to complete an activity asynchronously. /// /// An activity returning `ActivityError::WillCompleteAsync` can be completed with this handle. + /// + /// To get a handle to a standalone activity that can be used to wait for result and manage + /// the execution, see [`get_activity_handle`](Self::get_activity_handle). pub fn get_async_activity_handle( &self, identifier: ActivityIdentifier, ) -> AsyncActivityHandle { WorkflowClientTrait::get_async_activity_handle(self, identifier) } + + /// Start a standalone activity. + /// + /// Returns [`ActivityHandle`] that can be used to wait for result or to perform other + /// operations on the activity. + pub async fn start_activity( + &self, + activity: A, + input: A::Input, + options: ActivityStartOptions, + ) -> Result, StartActivityError> + where + A: ActivityDefinition, + { + WorkflowClientTrait::start_activity(self, activity, input, options).await + } + + /// Get a handle to an existing standalone activity execution. If `run_id` is not specified, + /// the handle always targets the latest execution with matching ID. + /// + /// Note that the validity of the handle is not checked until a method is called on it. + /// If invalid ID or run ID is used, the method will return `NotFound` error. + /// + /// To get an untyped handle, use [`get_untyped_activity_handle`](Self::get_untyped_activity_handle). + /// + /// To get a handle that can be used to complete an activity asynchronously, + /// see [`get_async_activity_handle`](Self::get_async_activity_handle). + pub fn get_activity_handle( + &self, + activity: A, + id: impl Into, + run_id: Option, + ) -> ActivityHandle + where + Self: Sized, + A: ActivityDefinition, + { + WorkflowClientTrait::get_activity_handle(self, activity, id, run_id) + } + + /// Get an untyped handle to an existing standalone activity execution. If `run_id` is not + /// specified, the handle always targets the latest execution with matching ID. + /// + /// Note that the validity of the handle is not checked until a method is called on it. + /// If invalid ID or run ID is used, the method will return `NotFound` error. + /// + /// To get a typed handle, use [`get_activity_handle`](Self::get_activity_handle). + /// + /// To get a handle that can be used to complete an activity asynchronously, + /// see [`get_async_activity_handle`](Self::get_async_activity_handle). + pub fn get_untyped_activity_handle( + &self, + id: impl Into, + run_id: Option, + ) -> ActivityHandle + where + Self: Sized, + { + WorkflowClientTrait::get_untyped_activity_handle(self, id, run_id) + } + + /// List activities matching a query. Returns a stream that lazily paginates through results. + pub fn list_activities( + &self, + query: impl Into, + options: ActivityListOptions, + ) -> ListActivitiesStream { + WorkflowClientTrait::list_activities(self, query, options) + } + + /// Count activities matching a query. + pub async fn count_activities( + &self, + query: impl Into, + options: ActivityCountOptions, + ) -> Result { + WorkflowClientTrait::count_activities(self, query, options).await + } } impl NamespacedClient for Client { @@ -1272,6 +1359,51 @@ pub(crate) trait WorkflowClientTrait: NamespacedClient { ) -> AsyncActivityHandle where Self: Sized; + + /// Start a standalone activity. + fn start_activity( + &self, + activity: A, + input: A::Input, + options: ActivityStartOptions, + ) -> impl Future, StartActivityError>> + where + Self: Sized, + A: ActivityDefinition; + + /// Get a handle to a previously started standalone activity. + fn get_activity_handle( + &self, + activity: A, + id: impl Into, + run_id: Option, + ) -> ActivityHandle + where + Self: Sized, + A: ActivityDefinition; + + /// Get an untyped handle to a previously started standalone activity. + fn get_untyped_activity_handle( + &self, + id: impl Into, + run_id: Option, + ) -> ActivityHandle + where + Self: Sized; + + /// List activities matching a query. Returns a stream that lazily paginates through results. + fn list_activities( + &self, + query: impl Into, + _options: ActivityListOptions, + ) -> ListActivitiesStream; + + /// Count activities matching a query. + fn count_activities( + &self, + query: impl Into, + _options: ActivityCountOptions, + ) -> impl Future>; } /// A client that is bound to a namespace @@ -1913,6 +2045,183 @@ where { AsyncActivityHandle::new(self.clone(), identifier) } + + async fn start_activity( + &self, + activity: A, + input: A::Input, + options: ActivityStartOptions, + ) -> Result, StartActivityError> + where + Self: Sized, + A: ActivityDefinition, + { + let mut client = self.clone(); + let dc = client.data_converter(); + let sc = &SerializationContextData::Activity; + + let user_metadata = { + let summary = match &options.summary { + Some(summary) => Some(dc.to_payload(sc, summary).await?), + None => None, + }; + let details = match &options.static_details { + Some(details) => Some(dc.to_payload(sc, details).await?), + None => None, + }; + (summary.is_some() || details.is_some()).then_some(UserMetadata { summary, details }) + }; + + let resp = client + .start_activity_execution( + StartActivityExecutionRequest { + namespace: client.namespace(), + identity: client.identity(), + request_id: Uuid::new_v4().to_string(), + activity_id: options.id.clone(), + activity_type: Some(ActivityType { + name: activity.name().to_string(), + }), + task_queue: Some(TaskQueue { + name: options.task_queue, + kind: TaskQueueKind::Normal.into(), + normal_name: "".to_string(), + }), + schedule_to_close_timeout: try_into_or_box_err( + options.close_timeouts.schedule_to_close(), + StartActivityError::Other, + )?, + schedule_to_start_timeout: try_into_or_box_err( + options.schedule_to_start_timeout, + StartActivityError::Other, + )?, + start_to_close_timeout: try_into_or_box_err( + options.close_timeouts.start_to_close(), + StartActivityError::Other, + )?, + heartbeat_timeout: try_into_or_box_err( + options.heartbeat_timeout, + StartActivityError::Other, + )?, + retry_policy: options.retry_policy.map(Into::into), + input: dc.to_payloads(sc, &input).await?.into_payloads(), + id_reuse_policy: ProtoActivityIdReusePolicy::from(options.id_reuse_policy) + .into(), + id_conflict_policy: ProtoActivityIdConflictPolicy::from( + options.id_conflict_policy, + ) + .into(), + search_attributes: options.search_attributes.map(SearchAttributes::into_proto), + header: options.header, + user_metadata, + priority: Some(options.priority.into()), + start_delay: try_into_or_box_err( + options.start_delay, + StartActivityError::Other, + )?, + ..Default::default() + } + .into_request(), + ) + .await? + .into_inner(); + + Ok(ActivityHandle::new( + client, + options.id, + (!resp.run_id.is_empty()).then_some(resp.run_id), + )) + } + + fn get_activity_handle( + &self, + _activity: A, + id: impl Into, + run_id: Option, + ) -> ActivityHandle + where + Self: Sized, + A: ActivityDefinition, + { + ActivityHandle::new(self.clone(), id.into(), run_id) + } + + fn get_untyped_activity_handle( + &self, + id: impl Into, + run_id: Option, + ) -> ActivityHandle + where + Self: Sized, + { + ActivityHandle::new(self.clone(), id.into(), run_id) + } + + fn list_activities( + &self, + query: impl Into, + _options: ActivityListOptions, + ) -> ListActivitiesStream { + let client = self.clone(); + let namespace = client.namespace(); + let query = query.into(); + + ListActivitiesStream::new(stream::unfold( + Some(vec![]), // empty token for initial query, None if done + move |next_page_token| { + let mut client = client.clone(); + let namespace = namespace.clone(); + let query = query.clone(); + + async move { + // making it more visible that we're terminating stream here + #[allow(clippy::question_mark)] + let Some(token): Option> = next_page_token else { + return None; + }; + + match WorkflowService::list_activity_executions( + &mut client, + ListActivityExecutionsRequest { + namespace, + page_size: 0, // Use server default + next_page_token: token.clone(), + query, + } + .into_request(), + ) + .await + .map(|r| r.into_inner()) + { + Ok(resp) => Some(( + Ok(resp.executions), + (!resp.next_page_token.is_empty()).then_some(resp.next_page_token), + )), + Err(e) => Some((Err(e.into()), Some(token))), + } + } + }, + )) + } + + async fn count_activities( + &self, + query: impl Into, + _options: ActivityCountOptions, + ) -> Result { + let mut client = self.clone(); + let resp = client + .count_activity_executions( + CountActivityExecutionsRequest { + namespace: client.namespace(), + query: query.into(), + } + .into_request(), + ) + .await? + .into_inner(); + Ok(ActivityExecutionCount::from_response(resp)) + } } macro_rules! dbg_panic { @@ -1924,6 +2233,17 @@ macro_rules! dbg_panic { } pub(crate) use dbg_panic; +fn try_into_or_box_err(val: Option, map_err: MapErr) -> Result, E> +where + A: TryInto, + >::Error: Error + Send + Sync + 'static, + MapErr: FnOnce(Box) -> E, +{ + val.map(TryInto::try_into) + .transpose() + .map_err(|e| map_err(Box::from(e))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/client/src/options_structs.rs b/crates/client/src/options_structs.rs index 9dd6f4605..28918bd9a 100644 --- a/crates/client/src/options_structs.rs +++ b/crates/client/src/options_structs.rs @@ -5,7 +5,7 @@ use crate::{ use http::Uri; use std::{collections::HashMap, sync::Arc, time::Duration}; use temporalio_common::{ - RetryPolicy, + ActivityCloseTimeouts, RetryPolicy, data_converters::DataConverter, protos::temporal::api::{ common::{ @@ -13,7 +13,9 @@ use temporalio_common::{ v1::{Header, Payloads}, }, enums::v1::{ - ArchivalState, HistoryEventFilterType, QueryRejectCondition, WorkflowIdConflictPolicy, + ActivityIdConflictPolicy as ProtoActivityIdConflictPolicy, + ActivityIdReusePolicy as ProtoActivityIdReusePolicy, ArchivalState, + HistoryEventFilterType, QueryRejectCondition, WorkflowIdConflictPolicy, WorkflowIdReusePolicy, }, replication::v1::ClusterReplicationConfig, @@ -688,3 +690,179 @@ pub struct WorkflowCountOptions { #[builder(default)] pub rpc_options: RpcOptions, } + +/// Options for starting a standalone activity. +#[derive(Clone, Debug, bon::Builder)] +#[builder(start_fn = new, on(String, into))] +#[non_exhaustive] +pub struct ActivityStartOptions { + /// Task queue to run this activity on. + #[builder(start_fn)] + pub task_queue: String, + /// Activity ID of the started activity. It's recommended to use a meaningful business ID. + #[builder(start_fn)] + pub id: String, + /// Timeouts for activity completion. + /// + /// See [`ActivityCloseTimeouts`] for the meaning of each timeout variant. + #[builder(start_fn)] + pub close_timeouts: ActivityCloseTimeouts, + /// If set, specifies maximum time the activity can wait in the task queue before being picked + /// up by a worker. This timeout is non-retryable. + pub schedule_to_start_timeout: Option, + /// If set, specifies maximum time between successful heartbeats. + pub heartbeat_timeout: Option, + /// Controls how Activity is retried. If not set, the server will assign default retry policy. + #[builder(into)] + pub retry_policy: Option, + /// Priority to use when starting this activity. + #[builder(default)] + pub priority: Priority, + /// Specifies behavior if there's a *closed* activity with the same ID. + #[builder(default)] + pub id_reuse_policy: ActivityIdReusePolicy, + /// Specifies behavior if there's a *running* activity with the same ID. Note that there can + /// only be one running activity for each Activity ID. + #[builder(default)] + pub id_conflict_policy: ActivityIdConflictPolicy, + /// Search attributes for the activity. + pub search_attributes: Option, + /// Headers to include with the start request. + pub header: Option
, + /// Single-line static summary for the activity, shown in the Temporal UI. + pub summary: Option, + /// Multi-line static details for the activity, shown in the Temporal UI. + pub static_details: Option, + /// Time to wait before dispatching the first activity task. + /// This delay is not applied to retry attempts. + pub start_delay: Option, +} + +impl ActivityStartOptions { + /// Returns a builder with `close_timeouts` set to [`ActivityCloseTimeouts::StartToClose`]. + pub fn with_start_to_close_timeout( + task_queue: impl Into, + activity_id: impl Into, + start_to_close_timeout: Duration, + ) -> ActivityStartOptionsBuilder { + Self::new( + task_queue, + activity_id, + ActivityCloseTimeouts::StartToClose(start_to_close_timeout), + ) + } + + /// Returns a builder with `close_timeouts` set to [`ActivityCloseTimeouts::ScheduleToClose`]. + pub fn with_schedule_to_close_timeout( + task_queue: impl Into, + activity_id: impl Into, + schedule_to_close_timeout: Duration, + ) -> ActivityStartOptionsBuilder { + Self::new( + task_queue, + activity_id, + ActivityCloseTimeouts::ScheduleToClose(schedule_to_close_timeout), + ) + } +} + +/// Specifies behavior when starting a standalone activity if there's a *closed* activity with +/// the same ID. See [`ActivityStartOptions::id_reuse_policy`]. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ActivityIdReusePolicy { + #[default] + /// Always allow starting an activity using the same activity ID. This is the default. + AllowDuplicate, + /// Allow starting an activity using the same ID only when the last execution did not complete + /// successfully. + AllowDuplicateFailedOnly, + /// Do not permit re-use of the ID for this activity. + RejectDuplicate, +} + +impl From for ProtoActivityIdReusePolicy { + fn from(value: ActivityIdReusePolicy) -> Self { + match value { + ActivityIdReusePolicy::AllowDuplicate => Self::AllowDuplicate, + ActivityIdReusePolicy::AllowDuplicateFailedOnly => Self::AllowDuplicateFailedOnly, + ActivityIdReusePolicy::RejectDuplicate => Self::RejectDuplicate, + } + } +} + +/// Specifies behavior when starting a standalone activity if there's a *running* activity with +/// the same ID. See [`ActivityStartOptions::id_conflict_policy`]. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ActivityIdConflictPolicy { + #[default] + /// Don't start a new activity; instead return + /// [`StartActivityError::AlreadyStarted`](crate::errors::StartActivityError::AlreadyStarted). + Fail, + /// Don't start a new activity; instead return a handle for the running activity. + UseExisting, +} + +impl From for ProtoActivityIdConflictPolicy { + fn from(value: ActivityIdConflictPolicy) -> Self { + match value { + ActivityIdConflictPolicy::Fail => Self::Fail, + ActivityIdConflictPolicy::UseExisting => Self::UseExisting, + } + } +} + +/// Options for listing activities. +#[derive(Debug, Clone, Default, bon::Builder)] +#[non_exhaustive] +pub struct ActivityListOptions {} + +/// Options for counting activities. +#[derive(Debug, Clone, Default, bon::Builder)] +#[non_exhaustive] +pub struct ActivityCountOptions {} + +/// Controls which optional fields will be requested in +/// [`ActivityHandle::describe`](crate::ActivityHandle::describe) operation. The fields will be +/// present in returned [`ActivityExecutionDescription`](crate::ActivityExecutionDescription), +/// subject to data availability and server support. +/// +/// Note that these fields contain payloads that can be arbitrarily large. It's recommended not to +/// include them unless they're needed. +#[derive(Debug, Clone, Default, bon::Builder)] +#[non_exhaustive] +pub struct ActivityDescribeOptions { + /// If set and the activity received input, the input will be included. + #[builder(default)] + pub include_input: bool, + /// If set and the activity is closed, the activity outcome will be included. + #[builder(default)] + pub include_outcome: bool, + /// If set and the activity sent heartbeat details, the heartbeat details will be included. + #[builder(default)] + pub include_heartbeat_details: bool, + /// If set and the activity has a failed attempt, the last failure will be included. + #[builder(default)] + pub include_last_failure: bool, +} + +/// Options for [`ActivityHandle::cancel`](crate::ActivityHandle::cancel). +#[derive(Debug, Clone, Default, bon::Builder)] +#[builder(on(String, into))] +#[non_exhaustive] +pub struct ActivityCancelOptions { + /// Reason for cancellation. Can be empty. + #[builder(default)] + pub reason: String, +} + +/// Options for [`ActivityHandle::terminate`](crate::ActivityHandle::terminate). +#[derive(Debug, Clone, Default, bon::Builder)] +#[builder(on(String, into))] +#[non_exhaustive] +pub struct ActivityTerminateOptions { + /// Reason for termination. Can be empty. + #[builder(default)] + pub reason: String, +} diff --git a/crates/common-wasm/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs index ffd0aba56..e93735c08 100644 --- a/crates/common-wasm/src/data_converters.rs +++ b/crates/common-wasm/src/data_converters.rs @@ -5,7 +5,8 @@ mod failure_converter; pub use failure_converter::{ ActivityExecutionDecodeHint, ChildWorkflowExecutionDecodeHint, ChildWorkflowStartDecodeHint, - DefaultFailureConverter, FailureConverter, FailureDecodeHint, WorkflowSignalDecodeHint, + DefaultFailureConverter, FailureConverter, FailureDecodeHint, NoopDecodeHint, + WorkflowSignalDecodeHint, }; use crate::protos::temporal::api::common::v1::Payload; diff --git a/crates/common-wasm/src/data_converters/failure_converter.rs b/crates/common-wasm/src/data_converters/failure_converter.rs index 3c17ba3c8..36a36e80f 100644 --- a/crates/common-wasm/src/data_converters/failure_converter.rs +++ b/crates/common-wasm/src/data_converters/failure_converter.rs @@ -59,6 +59,18 @@ pub trait FailureDecodeHint { fn adapt(self, normalized: IncomingError) -> Self::Output; } +/// No-op decode hint; returns the error unchanged. +#[derive(Debug, Clone, Copy)] +pub struct NoopDecodeHint; + +impl FailureDecodeHint for NoopDecodeHint { + type Output = IncomingError; + + fn adapt(self, normalized: IncomingError) -> Self::Output { + normalized + } +} + /// Decode hint for activity execution results. #[derive(Debug, Clone, Copy)] pub struct ActivityExecutionDecodeHint { diff --git a/crates/common-wasm/src/lib.rs b/crates/common-wasm/src/lib.rs index fb3d7b5b7..de539ae12 100644 --- a/crates/common-wasm/src/lib.rs +++ b/crates/common-wasm/src/lib.rs @@ -7,6 +7,8 @@ #[macro_use] extern crate tracing; +use std::time::Duration; + mod activity_definition; pub mod data_converters; pub mod error; @@ -48,3 +50,48 @@ macro_rules! dbg_panic { } #[allow(unused_imports)] pub(crate) use dbg_panic; + +/// Represents Activity schedule-to-close and start-to-close timeouts for the purposes of specifying +/// Activity options. Specifying at least one of them is required, but specifying both is also +/// allowed. Note that this type does not cover all available timeout options for an Activity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivityCloseTimeouts { + /// Total time the Activity is allowed to run, including retries. + ScheduleToClose(Duration), + /// Maximum time of a single Activity execution attempt. Note that the Temporal Server doesn't + /// detect Worker process failures directly. It relies on this timeout to detect that an + /// Activity that didn't complete on time. So this timeout should be as short as the longest + /// possible execution of the Activity body. Potentially long running Activities must specify + /// `heartbeat_timeout` in options and heartbeat from the activity periodically for timely + /// failure detection. + StartToClose(Duration), + /// Applies both execution-attempt and overall-completion bounds. + Both { + /// Total time the Activity is allowed to run, including retries. + schedule_to_close: Duration, + /// Maximum time of a single Activity execution attempt. + start_to_close: Duration, + }, +} + +impl ActivityCloseTimeouts { + /// Returns value of [`Self::ScheduleToClose`] or [`Self::Both::schedule_to_close`]. + pub fn schedule_to_close(&self) -> Option { + match self { + ActivityCloseTimeouts::ScheduleToClose(schedule_to_close) + | ActivityCloseTimeouts::Both { + schedule_to_close, .. + } => Some(*schedule_to_close), + _ => None, + } + } + + /// Returns value of [`Self::StartToClose`] or [`Self::Both::start_to_close`]. + pub fn start_to_close(&self) -> Option { + match self { + ActivityCloseTimeouts::StartToClose(start_to_close) + | ActivityCloseTimeouts::Both { start_to_close, .. } => Some(*start_to_close), + _ => None, + } + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 36a384a28..8fd0038ec 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -18,10 +18,10 @@ pub mod protos; pub mod telemetry; pub mod worker; pub use temporalio_common_wasm::{ - ActivityDefinition, ActivityError, HasWorkflowDefinition, Memo, Priority, QueryDefinition, - RetryPolicy, SignalDefinition, UntypedActivity, UntypedWorkflow, UpdateDefinition, - WorkerDeploymentVersion, WorkflowDefinition, WorkflowExecution, data_converters, error, - search_attributes, + ActivityCloseTimeouts, ActivityDefinition, ActivityError, HasWorkflowDefinition, Memo, + Priority, QueryDefinition, RetryPolicy, SignalDefinition, UntypedActivity, UntypedWorkflow, + UpdateDefinition, WorkerDeploymentVersion, WorkflowDefinition, WorkflowExecution, + data_converters, error, search_attributes, }; macro_rules! dbg_panic { diff --git a/crates/sdk-core/tests/heavy_tests.rs b/crates/sdk-core/tests/heavy_tests.rs index 7f3821e6c..e7096b307 100644 --- a/crates/sdk-core/tests/heavy_tests.rs +++ b/crates/sdk-core/tests/heavy_tests.rs @@ -5,10 +5,9 @@ pub(crate) mod common; #[path = "heavy_tests/fuzzy_workflow.rs"] mod fuzzy_workflow; -use crate::common::get_integ_runtime_options; use common::{ - CoreWfStarter, activity_functions::StdActivities, init_integ_telem, prom_metrics, rand_6_chars, - workflows::LaProblemWorkflow, + CoreWfStarter, activity_functions::StdActivities, get_integ_runtime_options, init_integ_telem, + prom_metrics, rand_6_chars, workflows::LaProblemWorkflow, }; use futures_util::{ StreamExt, @@ -31,12 +30,15 @@ use temporalio_common::{ }; use temporalio_macros::{activities, workflow, workflow_methods}; -use temporalio_common::protos::{ - coresdk::workflow_commands::ActivityCancellationType, - temporal::api::enums::v1::WorkflowIdReusePolicy, +use temporalio_common::{ + ActivityCloseTimeouts, + protos::{ + coresdk::workflow_commands::ActivityCancellationType, + temporal::api::enums::v1::WorkflowIdReusePolicy, + }, }; use temporalio_sdk::{ - ActivityCloseTimeouts, ActivityOptions, SyncWorkflowContext, WorkflowContext, WorkflowResult, + ActivityOptions, SyncWorkflowContext, WorkflowContext, WorkflowResult, activities::{ActivityContext, ActivityError}, workflows, }; diff --git a/crates/sdk-core/tests/integ_tests/async_activity_client_tests.rs b/crates/sdk-core/tests/integ_tests/async_activity_client_tests.rs index b76790a73..55337ce4d 100644 --- a/crates/sdk-core/tests/integ_tests/async_activity_client_tests.rs +++ b/crates/sdk-core/tests/integ_tests/async_activity_client_tests.rs @@ -44,8 +44,8 @@ async fn async_activity_completions( #[derive(Clone)] struct SharedActivityInfo { task_token: Vec, - workflow_id: String, - run_id: String, + workflow_id: Option, + workflow_run_id: Option, activity_id: String, } @@ -76,11 +76,10 @@ async fn async_activity_completions( } let activity_info = ctx.info(); - let wf_exec = activity_info.workflow_execution.as_ref().unwrap(); let info = SharedActivityInfo { task_token: activity_info.task_token.clone(), - workflow_id: wf_exec.workflow_id().to_owned(), - run_id: wf_exec.run_id().to_owned(), + workflow_id: activity_info.workflow_id.clone(), + workflow_run_id: activity_info.workflow_run_id.clone(), activity_id: activity_info.activity_id.clone(), }; let _ = self.info_tx.send(info).await; @@ -161,10 +160,10 @@ async fn async_activity_completions( let info = info_rx.recv().await.expect("should receive activity info"); eprintln!( - "DEBUG: Received activity info - task_token_len={}, workflow_id={}, run_id={}, activity_id={}", + "DEBUG: Received activity info - task_token_len={}, workflow_id={:?}, run_id={:?}, activity_id={}", info.task_token.len(), info.workflow_id, - info.run_id, + info.workflow_run_id, info.activity_id ); @@ -175,7 +174,11 @@ async fn async_activity_completions( } IdentifierType::ById => { eprintln!("DEBUG: Using ById identifier"); - ActivityIdentifier::by_id(info.workflow_id, info.run_id, info.activity_id) + ActivityIdentifier::by_id_workflow( + info.workflow_id.unwrap(), + info.workflow_run_id.unwrap(), + info.activity_id, + ) } }; diff --git a/crates/sdk-core/tests/integ_tests/standalone_activity_tests.rs b/crates/sdk-core/tests/integ_tests/standalone_activity_tests.rs new file mode 100644 index 000000000..1250821ed --- /dev/null +++ b/crates/sdk-core/tests/integ_tests/standalone_activity_tests.rs @@ -0,0 +1,255 @@ +use crate::common::CoreWfStarter; +use futures_util::{FutureExt, StreamExt, pin_mut, stream}; +use std::{ + collections::HashSet, + panic, + panic::{AssertUnwindSafe, resume_unwind}, + sync::Arc, + time::Duration, +}; +use temporalio_client::{ + ActivityCancelOptions, ActivityDescribeOptions, ActivityExecutionInfoLike, + ActivityExecutionStatus, ActivityStartOptions, ActivityStartOptionsBuilder, + ActivityTerminateOptions, Client, NamespacedClient, errors::ActivityResultError, +}; +use temporalio_common::ActivityError; +use temporalio_macros::activities; +use temporalio_sdk::activities::ActivityContext; +use uuid::Uuid; + +const TASK_QUEUE_PREFIX: &str = "standalone_activity_tests"; + +struct Activities; + +#[activities] +impl Activities { + #[activity] + async fn echo(_ctx: ActivityContext, e: String) -> Result { + Ok(e) + } + + #[activity] + async fn wait_for_cancel(self: Arc, ctx: ActivityContext) -> Result<(), ActivityError> { + let mut ticker = tokio::time::interval(Duration::from_millis(100)); + loop { + tokio::select! { biased; + _ = ctx.cancelled() => return Err(ActivityError::Cancelled {details: None}), + _ = ticker.tick() => { let _ = ctx.record_heartbeat(()).await; }, + } + } + } +} + +async fn run_test(test: impl AsyncFnOnce(Client, String)) { + let mut starter = CoreWfStarter::new(TASK_QUEUE_PREFIX); + starter.sdk_config.register_activities(Activities); + let mut worker = starter.worker().await; + let client = starter.get_core_client().await; + let shutdown_handle = worker.inner_mut().shutdown_handle(); + + let worker_fut = worker.inner_mut().run(); + let test_fut = async { + let result = AssertUnwindSafe(test(client, starter.sdk_config.task_queue.clone())) + .catch_unwind() + .await; + shutdown_handle(); + result + }; + pin_mut!(worker_fut); + pin_mut!(test_fut); + + tokio::select! { + test_result = &mut test_fut => { + let worker_result = worker_fut.await; + if let Err(panic) = test_result { + resume_unwind(panic); + } + worker_result.unwrap(); + }, + worker_result = &mut worker_fut => { + worker_result.unwrap(); + if let Err(panic) = test_fut.await { + resume_unwind(panic); + } + } + } +} + +fn test_options(task_queue: String) -> ActivityStartOptionsBuilder { + ActivityStartOptions::with_schedule_to_close_timeout( + task_queue, + Uuid::new_v4(), + Duration::from_secs(60), + ) +} + +#[tokio::test] +async fn get_result() { + run_test(async |client, tq| { + let options = test_options(tq).build(); + let arg = "Hello"; + + let handle = client + .start_activity(Activities::echo, arg.into(), options.clone()) + .await + .unwrap(); + assert_eq!(handle.activity_id(), options.id); + assert!(handle.run_id().is_some()); + assert_eq!(handle.result().await.unwrap(), arg); + + let new_handle = client.get_activity_handle( + Activities::echo, + handle.activity_id(), + handle.run_id().map(Into::into), + ); + assert_eq!(new_handle.result().await.unwrap(), arg); + + let untyped_handle = client + .get_untyped_activity_handle(handle.activity_id(), handle.run_id().map(Into::into)); + assert_eq!( + untyped_handle + .result() + .await + .unwrap() + .to_value::(client.data_converter().payload_converter()), + arg + ); + + let wrong_run_id = loop { + let uuid = Some(Uuid::new_v4().to_string()); + if uuid.as_deref() != handle.run_id() { + break uuid; + } + }; + + let handle_wrong_run_id = + client.get_activity_handle(Activities::echo, handle.activity_id(), wrong_run_id); + assert_matches!( + handle_wrong_run_id.result().await, + Err(ActivityResultError::NotFound(_)) + ); + + let handle_no_run_id = + client.get_activity_handle(Activities::echo, handle.activity_id(), None); + assert_eq!(handle_no_run_id.result().await.unwrap(), arg); + }) + .await; +} + +#[tokio::test] +async fn describe() { + run_test(async |client, tq| { + let options = test_options(tq).build(); + let arg = "Hello"; + + let handle = client + .start_activity(Activities::echo, arg.into(), options.clone()) + .await + .unwrap(); + let result = handle.result().await.unwrap(); + + let desc = handle + .describe( + ActivityDescribeOptions::builder() + .include_input(true) + .include_outcome(true) + .build(), + ) + .await + .unwrap(); + + assert_eq!(desc.activity_id(), options.id); + assert_eq!(Some(desc.activity_run_id()), handle.run_id()); + assert_eq!(desc.status(), ActivityExecutionStatus::Completed); + assert_eq!(desc.input().await.unwrap(), Some(arg.to_string())); + assert_eq!(desc.outcome().await.unwrap().unwrap().unwrap(), result); + }) + .await; +} + +#[tokio::test] +async fn cancel() { + run_test(async |client, tq| { + let reason = "test cancel"; + let handle = client + .start_activity(Activities::wait_for_cancel, (), test_options(tq).build()) + .await + .unwrap(); + handle + .cancel(ActivityCancelOptions::builder().reason(reason).build()) + .await + .unwrap(); + + assert_matches!( + handle.result().await, + Err(ActivityResultError::Cancelled { .. }) + ); + let desc = handle.describe(Default::default()).await.unwrap(); + assert_eq!(desc.status(), ActivityExecutionStatus::Canceled); + assert_eq!(desc.canceled_reason(), Some(reason)); + }) + .await; +} + +#[tokio::test] +async fn terminate() { + run_test(async |client, tq| { + let reason = "test terminate"; + let handle = client + .start_activity(Activities::wait_for_cancel, (), test_options(tq).build()) + .await + .unwrap(); + handle + .terminate(ActivityTerminateOptions::builder().reason(reason).build()) + .await + .unwrap(); + + assert_matches!(handle.result().await, Err(ActivityResultError::Terminated)); + let desc = handle.describe(Default::default()).await.unwrap(); + assert_eq!(desc.status(), ActivityExecutionStatus::Terminated); + }) + .await; +} + +#[tokio::test] +async fn list_and_count() { + run_test(async |client, tq| { + let query = format!("TaskQueue='{tq}'"); + + let started_activity_ids: HashSet<_> = stream::iter(0..3) + .then(async |_| { + client + .start_activity( + Activities::echo, + "Hello".into(), + test_options(tq.clone()).build(), + ) + .await + .unwrap() + .activity_id() + .to_string() + }) + .collect() + .await; + + // in loop because of eventual consistency + loop { + let count = client + .count_activities(query.clone(), Default::default()) + .await + .unwrap(); + if count.count() == started_activity_ids.len() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + let list_activity_ids: HashSet<_> = client + .list_activities(query.clone(), Default::default()) + .map(|a| a.unwrap().activity_id().to_string()) + .collect() + .await; + assert_eq!(list_activity_ids, started_activity_ids); + }) + .await; +} diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs index 8200dcb8f..5b8790cb9 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs @@ -132,7 +132,7 @@ struct ActivityInterceptorRecord { interceptor: &'static str, phase: ActivityInterceptorPhase, activity_type: String, - workflow_type: String, + workflow_type: Option, is_local: bool, input: Option, output: Option, @@ -492,7 +492,7 @@ async fn activity_interceptor_wraps_activity_execution() { interceptor: "outer", phase: ActivityInterceptorPhase::Before, activity_type: "StdActivities::echo".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: false, input: Some(input.clone()), output: None, @@ -502,7 +502,7 @@ async fn activity_interceptor_wraps_activity_execution() { interceptor: "inner", phase: ActivityInterceptorPhase::Before, activity_type: "StdActivities::echo".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: false, input: Some(input.clone()), output: None, @@ -512,7 +512,7 @@ async fn activity_interceptor_wraps_activity_execution() { interceptor: "inner", phase: ActivityInterceptorPhase::After, activity_type: "StdActivities::echo".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: false, input: None, output: Some(input.clone()), @@ -522,7 +522,7 @@ async fn activity_interceptor_wraps_activity_execution() { interceptor: "outer", phase: ActivityInterceptorPhase::After, activity_type: "StdActivities::echo".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: false, input: None, output: Some(input.clone()), @@ -571,7 +571,7 @@ async fn activity_interceptor_wraps_local_activity_execution() { interceptor: "local", phase: ActivityInterceptorPhase::Before, activity_type: "StdActivities::echo".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: true, input: Some(input.clone()), output: None, @@ -581,7 +581,7 @@ async fn activity_interceptor_wraps_local_activity_execution() { interceptor: "local", phase: ActivityInterceptorPhase::After, activity_type: "StdActivities::echo".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: true, input: None, output: Some(input.clone()), @@ -707,7 +707,7 @@ async fn activity_interceptor_observes_activity_error() { interceptor: "failure", phase: ActivityInterceptorPhase::Before, activity_type: "FailingActivities::fail".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: false, input: Some(input), output: None, @@ -717,7 +717,7 @@ async fn activity_interceptor_observes_activity_error() { interceptor: "failure", phase: ActivityInterceptorPhase::After, activity_type: "FailingActivities::fail".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: false, input: None, output: None, @@ -803,7 +803,7 @@ async fn activity_interceptor_observes_activity_panic() { interceptor: "panic", phase: ActivityInterceptorPhase::Before, activity_type: "PanickingActivities::panic_activity".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: false, input: Some(input), output: None, @@ -813,7 +813,7 @@ async fn activity_interceptor_observes_activity_panic() { interceptor: "panic", phase: ActivityInterceptorPhase::After, activity_type: "PanickingActivities::panic_activity".to_owned(), - workflow_type: wf_name.to_owned(), + workflow_type: Some(wf_name.to_owned()), is_local: false, input: None, output: None, diff --git a/crates/sdk-core/tests/main.rs b/crates/sdk-core/tests/main.rs index f88abc713..390791d82 100644 --- a/crates/sdk-core/tests/main.rs +++ b/crates/sdk-core/tests/main.rs @@ -24,6 +24,7 @@ mod integ_tests { mod polling_tests; mod queries_tests; mod schedule_tests; + mod standalone_activity_tests; mod update_tests; mod visibility_tests; mod worker_heartbeat_tests; diff --git a/crates/sdk/src/activities.rs b/crates/sdk/src/activities.rs index 381cea3e8..0ab23bff8 100644 --- a/crates/sdk/src/activities.rs +++ b/crates/sdk/src/activities.rs @@ -72,7 +72,7 @@ use std::{ use temporalio_client::{Client, ClientOptions, Priority, WorkflowExecutionInfo, WorkflowHandle}; pub use temporalio_common::ActivityError; use temporalio_common::{ - ActivityDefinition, HasWorkflowDefinition, RetryPolicy, WorkflowExecution, + ActivityDefinition, HasWorkflowDefinition, RetryPolicy, data_converters::{ DataConverter, DecodablePayloads, GenericPayloadConverter, PayloadConversionError, PayloadConverter, RawValue, SerializationContext, SerializationContextData, @@ -139,6 +139,10 @@ impl ActivityContext { heartbeat_details, client_options.data_converter.payload_converter().clone(), ); + let (workflow_id, workflow_run_id) = workflow_execution + .map(|we| (we.workflow_id, we.run_id)) + .unzip(); + let activity_run_id = (workflow_id.is_none() && !run_id.is_empty()).then_some(run_id); ( ActivityContext { @@ -150,9 +154,10 @@ impl ActivityContext { info: ActivityInfo { task_token, task_queue, - workflow_type, - workflow_namespace, - workflow_execution: workflow_execution.map(Into::into), + workflow_type: (!workflow_type.is_empty()).then_some(workflow_type), + namespace: workflow_namespace, + workflow_id, + workflow_run_id, activity_id, activity_type, heartbeat_timeout: heartbeat_timeout.try_into_or_none(), @@ -165,7 +170,7 @@ impl ActivityContext { retry_policy: retry_policy.map(Into::into), is_local, priority: priority.map(Into::into).unwrap_or_default(), - run_id: (!run_id.is_empty()).then_some(run_id), + activity_run_id, }, }, input, @@ -225,16 +230,17 @@ impl ActivityContext { /// Return a workflow handle for the workflow execution that started this activity, if any. pub fn workflow_handle(&self) -> Option> { - let workflow_execution = self.info.workflow_execution.as_ref()?; - let run_id = (!workflow_execution.run_id().is_empty()) - .then(|| workflow_execution.run_id().to_owned()); + let workflow_id = self.info.workflow_id.clone()?; + let run_id = self.info.workflow_run_id.clone(); + let first_execution_run_id = run_id.clone(); + Some(WorkflowHandle::new( self.client(), WorkflowExecutionInfo { namespace: self.client_options.namespace.clone(), - workflow_id: workflow_execution.workflow_id().to_owned(), - run_id: run_id.clone(), - first_execution_run_id: run_id, + workflow_id, + run_id, + first_execution_run_id, }, )) } @@ -295,12 +301,14 @@ impl ActivityHeartbeatDetails { pub struct ActivityInfo { /// An opaque token representing a specific Activity task. pub task_token: Vec, - /// The type of the workflow that invoked this activity. - pub workflow_type: String, - /// The namespace of the workflow that invoked this activity. - pub workflow_namespace: String, - /// The execution of the workflow that invoked this activity. - pub workflow_execution: Option, + /// The type of the workflow that invoked this activity. None for standalone activities. + pub workflow_type: Option, + /// The namespace of this activity. + pub namespace: String, + /// ID of the workflow that invoked this activity. None for standalone activities. + pub workflow_id: Option, + /// Run ID of the workflow that invoked this activity. None for standalone activities. + pub workflow_run_id: Option, /// The ID of this activity. pub activity_id: String, /// The type of this activity. @@ -326,7 +334,7 @@ pub struct ActivityInfo { /// Priority of this activity. If unset uses [Priority::default]. pub priority: Priority, /// Run ID of this activity execution. Only set for standalone activities. - pub run_id: Option, + pub activity_run_id: Option, } /// Deadline calculation. This is a port of diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 3dfebee73..f4eb74450 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -1395,10 +1395,12 @@ impl ActivityHalf { tokio::spawn(async move { let act_fut = async move { - if let Some(info) = &ctx.info().workflow_execution { - Span::current() - .record("temporalWorkflowID", info.workflow_id()) - .record("temporalRunID", info.run_id()); + let span = Span::current(); + if let Some(workflow_id) = &ctx.info().workflow_id { + span.record("temporalWorkflowID", workflow_id); + } + if let Some(workflow_run_id) = &ctx.info().workflow_run_id { + span.record("temporalRunID", workflow_run_id); } (act_fn)(args, data_converter, ctx, activity_inbound_interceptors).await } diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 3d3d05333..6e994db7e 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -32,22 +32,22 @@ pub use runtime::model::{TimerResult, WorkflowResult, WorkflowTermination}; #[doc(hidden)] pub use runtime::{SdkWakeGuard, is_sdk_wake}; pub use temporalio_common_wasm::{ - Memo, RetryPolicy, + ActivityCloseTimeouts, Memo, RetryPolicy, error::{ ActivityExecutionError, ChildWorkflowExecutionError, ChildWorkflowStartError, RetryState, TimeoutType, WorkflowSignalError, }, }; pub use workflow_context::{ - ActivityCancellationType, ActivityCloseTimeouts, ActivityOptions, BaseWorkflowContext, - CancellableFuture, CancellableFutureWithReason, ChildWorkflowCancellationType, - ChildWorkflowOptions, ContinueAsNewOptions, ContinueAsNewVersioningBehavior, - ExternalWorkflowHandle, LocalActivityOptions, NamespacedWorkflowInfo, - NexusOperationCancellationType, NexusOperationOptions, ParentClosePolicy, - SignalWorkflowOptions, StartChildWorkflowExecutionFailedCause, StartChildWorkflowOutput, - StartedChildWorkflow, StartedNexusOperation, SyncWorkflowContext, TimerOptions, - VersioningIntent, WaitConditionOptions, WorkflowContext, WorkflowContextView, - WorkflowIdReusePolicy, WorkflowRandomValue, + ActivityCancellationType, ActivityOptions, BaseWorkflowContext, CancellableFuture, + CancellableFutureWithReason, ChildWorkflowCancellationType, ChildWorkflowOptions, + ContinueAsNewOptions, ContinueAsNewVersioningBehavior, ExternalWorkflowHandle, + LocalActivityOptions, NamespacedWorkflowInfo, NexusOperationCancellationType, + NexusOperationOptions, ParentClosePolicy, SignalWorkflowOptions, + StartChildWorkflowExecutionFailedCause, StartChildWorkflowOutput, StartedChildWorkflow, + StartedNexusOperation, SyncWorkflowContext, TimerOptions, VersioningIntent, + WaitConditionOptions, WorkflowContext, WorkflowContextView, WorkflowIdReusePolicy, + WorkflowRandomValue, }; #[doc(hidden)] pub use workflow_context::{PatchActivationCallback, PatchActivationCaller}; diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 27ad3cf0b..86cd12e36 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -2,11 +2,11 @@ mod options; mod view; pub use options::{ - ActivityCancellationType, ActivityCloseTimeouts, ActivityOptions, - ChildWorkflowCancellationType, ChildWorkflowOptions, ContinueAsNewOptions, - ContinueAsNewVersioningBehavior, LocalActivityOptions, NexusOperationCancellationType, - NexusOperationOptions, ParentClosePolicy, SignalWorkflowOptions, TimerOptions, - VersioningIntent, WaitConditionOptions, WorkflowIdReusePolicy, + ActivityCancellationType, ActivityOptions, ChildWorkflowCancellationType, ChildWorkflowOptions, + ContinueAsNewOptions, ContinueAsNewVersioningBehavior, LocalActivityOptions, + NexusOperationCancellationType, NexusOperationOptions, ParentClosePolicy, + SignalWorkflowOptions, TimerOptions, VersioningIntent, WaitConditionOptions, + WorkflowIdReusePolicy, }; pub use temporalio_common_wasm::protos::coresdk::child_workflow::StartChildWorkflowExecutionFailedCause; pub use view::{NamespacedWorkflowInfo, WorkflowContextView}; diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index 3a245acf0..6104e06bd 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, time::Duration}; use crate::{MemoValues, WorkflowCancellationToken, runtime::types::ContinueAsNewRequest}; use temporalio_common_wasm::{ - Priority, RetryPolicy, + ActivityCloseTimeouts, Priority, RetryPolicy, data_converters::{ GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, SerializationContextData, @@ -326,12 +326,12 @@ pub struct ActivityOptions { } impl ActivityOptions { - /// Returns a builder with `close_timeout` set to [`ActivityCloseTimeouts::StartToClose`]. + /// Returns a builder with `close_timeouts` set to [`ActivityCloseTimeouts::StartToClose`]. pub fn with_start_to_close_timeout(duration: Duration) -> ActivityOptionsBuilder { Self::with_close_timeouts(ActivityCloseTimeouts::StartToClose(duration)) } - /// Returns a builder with `close_timeout` set to [`ActivityCloseTimeouts::ScheduleToClose`]. + /// Returns a builder with `close_timeouts` set to [`ActivityCloseTimeouts::ScheduleToClose`]. pub fn with_schedule_to_close_timeout(duration: Duration) -> ActivityOptionsBuilder { Self::with_close_timeouts(ActivityCloseTimeouts::ScheduleToClose(duration)) } @@ -351,43 +351,6 @@ impl ActivityOptions { } } -/// The timeouts applied to an activity's completion. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ActivityCloseTimeouts { - /// Total time that a workflow is willing to wait for Activity to complete. - /// `ActivityCloseTimeouts::ScheduleToClose` limits the total time of an Activity's execution - /// including retries (use `ActivityCloseTimeouts::StartToClose` to limit the time of a single - /// attempt). - ScheduleToClose(Duration), - /// Maximum time of a single Activity execution attempt. Note that the Temporal Server doesn't - /// detect Worker process failures directly. It relies on this timeout to detect that an - /// Activity that didn't complete on time. So this timeout should be as short as the longest - /// possible execution of the Activity body. Potentially long running Activities must specify - /// `ActivityOptions::heartbeat_timeout` and heartbeat from the activity periodically for timely - /// failure detection. - StartToClose(Duration), - /// Applies both execution-attempt and overall-completion bounds. - Both { - /// Maximum time of a single Activity execution attempt. - start_to_close: Duration, - /// Total time that a workflow is willing to wait for Activity to complete. - schedule_to_close: Duration, - }, -} - -impl ActivityCloseTimeouts { - fn into_durations(self) -> (Option, Option) { - match self { - Self::ScheduleToClose(schedule_to_close) => (None, Some(schedule_to_close)), - Self::StartToClose(start_to_close) => (Some(start_to_close), None), - Self::Both { - start_to_close, - schedule_to_close, - } => (Some(start_to_close), Some(schedule_to_close)), - } - } -} - impl ActivityOptions { pub(crate) fn into_command( self, @@ -396,8 +359,6 @@ impl ActivityOptions { args: Vec, headers: HashMap, ) -> WorkflowCommand { - let (start_to_close_timeout, schedule_to_close_timeout) = - self.close_timeouts.into_durations(); command_with_metadata( workflow_command::Variant::ScheduleActivity(ScheduleActivity { seq, @@ -406,12 +367,16 @@ impl ActivityOptions { task_queue: self.task_queue.unwrap_or_default(), arguments: args, headers, - schedule_to_close_timeout: schedule_to_close_timeout + schedule_to_close_timeout: self + .close_timeouts + .schedule_to_close() .and_then(|duration| duration.try_into().ok()), schedule_to_start_timeout: self .schedule_to_start_timeout .and_then(|duration| duration.try_into().ok()), - start_to_close_timeout: start_to_close_timeout + start_to_close_timeout: self + .close_timeouts + .start_to_close() .and_then(|duration| duration.try_into().ok()), heartbeat_timeout: self .heartbeat_timeout From 42c7bfe90b8bf66feaf8610f6a9868ab40d760ab Mon Sep 17 00:00:00 2001 From: James Watkins-Harvey Date: Fri, 14 Aug 2026 13:58:56 -0400 Subject: [PATCH 02/87] Scope docker_metrics_with_prometheus query to the test's task queue (#1495) --- crates/sdk-core/tests/integ_tests/metrics_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/sdk-core/tests/integ_tests/metrics_tests.rs b/crates/sdk-core/tests/integ_tests/metrics_tests.rs index 1e266c61c..5da4859da 100644 --- a/crates/sdk-core/tests/integ_tests/metrics_tests.rs +++ b/crates/sdk-core/tests/integ_tests/metrics_tests.rs @@ -960,12 +960,18 @@ async fn docker_metrics_with_prometheus( .await .unwrap(); + let task_queue = starter.get_task_queue().to_string(); eventually( || async { // Query Prometheus API for metrics temporalio_common::telemetry::ensure_default_crypto_provider(); let client = reqwest::Client::new(); - let query = format!("temporal_sdk_{}num_pollers", test_uid.clone()); + // The task queue must be matched in the query rather than asserted on afterwards: this + // runtime's meter is also used by the shared-namespace worker, whose pollers report + // against the worker-commands control queue, and the order series come back in is not + // ours to choose. + let query = + format!("temporal_sdk_{test_uid}num_pollers{{task_queue=\"{task_queue}\"}}"); let response = client .get(PROMETHEUS_QUERY_API) .query(&[("query", query.clone())]) @@ -981,12 +987,6 @@ async fn docker_metrics_with_prometheus( } assert_eq!(data[0]["metric"]["exported_job"], "temporal-core-sdk"); assert_eq!(data[0]["metric"]["job"], "otel-collector"); - assert!( - data[0]["metric"]["task_queue"] - .as_str() - .unwrap() - .starts_with(test_name) - ); } else { bail!("Invalid Prometheus response: {response:?}"); } From b97ece5aef793d0dc359627b6d439c6f7f8a5355 Mon Sep 17 00:00:00 2001 From: James Watkins-Harvey Date: Fri, 14 Aug 2026 14:26:00 -0400 Subject: [PATCH 03/87] Keep the in-Docker environment check offline (#1496) --- .github/workflows/per-pr.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/per-pr.yml b/.github/workflows/per-pr.yml index 019b11bc1..31227e836 100644 --- a/.github/workflows/per-pr.yml +++ b/.github/workflows/per-pr.yml @@ -223,11 +223,23 @@ jobs: )" test -x "$integ_test_binary" test -x "$integ_runner_binary" + # The CLI is cached under a version-derived name, so the container only finds the one + # the previous step downloaded if it asks for the same version. Cargo's `[env]` does not + # reach a directly invoked binary, hence passing the pin, if any, explicitly. + cli_version_override="$( + sed -n 's/^CLI_VERSION_OVERRIDE = "\(.*\)"$/\1/p' .cargo/config.toml + )" + cli_version_env=() + if [ -n "$cli_version_override" ]; then + cli_version_env=(--env "CLI_VERSION_OVERRIDE=$cli_version_override") + fi docker run --rm \ --env TMPDIR="$RUNNER_TEMP" \ --env TEMPORAL_TEST_EXPECT_DOCKER=true \ + "${cli_version_env[@]}" \ --volume "$RUNNER_TEMP:$RUNNER_TEMP" \ --volume "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE" \ + --volume /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro \ --workdir "$GITHUB_WORKSPACE" \ ubuntu:24.04 \ "$integ_runner_binary" --test-executable "$integ_test_binary" \ From c87e2060bea48c1225bf67d0efa5cf784eff8ff2 Mon Sep 17 00:00:00 2001 From: James Watkins-Harvey Date: Fri, 14 Aug 2026 15:11:03 -0400 Subject: [PATCH 04/87] Fix integ test failure for activity cancellation against Server v1.32.0-158.0 (#1498) --- crates/sdk-core/tests/common/mod.rs | 2 ++ .../integ_tests/workflow_tests/activities.rs | 27 +++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index f9a69370b..ed8dd68f7 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -1263,6 +1263,8 @@ pub(crate) fn integ_dev_server_config( "--dynamic-config-value".to_owned(), "frontend.workerCommandsEnabled=true".to_owned(), "--dynamic-config-value".to_owned(), + "system.enableCancelActivityWorkerCommand=true".to_owned(), + "--dynamic-config-value".to_owned(), "matching.rps=12000".to_owned(), "--search-attribute".to_string(), format!("{SEARCH_ATTR_TXT}=Text"), diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs index 5b8790cb9..a3b020853 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs @@ -1,7 +1,8 @@ use crate::{ common::{ - ActivationAssertionsInterceptor, CoreWfStarter, INTEG_CLIENT_IDENTITY, - activity_functions::StdActivities, init_core_and_create_wf, + ActivationAssertionsInterceptor, CLI_VERSION_OVERRIDE_ENV_VAR, CoreWfStarter, + INTEG_CLIENT_IDENTITY, activity_functions::StdActivities, init_core_and_create_wf, + init_integ_telem, }, shared_tests, }; @@ -9,6 +10,7 @@ use anyhow::anyhow; use assert_matches::assert_matches; use futures_util::FutureExt; use std::{ + env, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -66,6 +68,7 @@ use temporalio_sdk_core::{ }, }; use tokio::{join, sync::Semaphore, time::sleep}; +use tracing::warn; #[workflow] #[derive(Default)] @@ -2407,5 +2410,25 @@ async fn immediate_activity_cancelation() { #[case::eager(false)] #[tokio::test] async fn activity_cancel_delivered_without_heartbeat(#[case] disable_eager: bool) { + // Cancellation of activities through Worker Commands was added in Server v1.32.0-158.0, + // but the case for eager activities was initially broken. It got fixed in + // temporalio/temporal#10634, which was released in Server v1.32.0-159.0. + // + // At this time, there is no release of the Temporal CLI that bundles Server + // v1.32.0-159.0. We're pinned on CLI "v1.7.4-standalone-nexus-operations" which + // bundles Server v1.32.0-158.0, and therefore has the broken support for eager + // activity cancellation. That results in the eager activity cancelation test + // failing in CI and local tests against the CLI Dev Server. + // + // FIXME: Remove once we're pinned on a CLI that bundles Server >v1.32.0-159.0. + const CLI_WITHOUT_EAGER_CANCEL_FIX: &str = "v1.7.4-standalone-nexus-operations"; + if !disable_eager + && env::var(CLI_VERSION_OVERRIDE_ENV_VAR).is_ok_and(|v| v == CLI_WITHOUT_EAGER_CANCEL_FIX) + { + // The skip message would go unlogged as no telemetry has been initialized yet. + init_integ_telem(); + warn!("Skipping test: eager activity cancel requires server >= v1.32.0-159.0"); + return; + } shared_tests::activity_cancel_delivered_without_heartbeat(disable_eager).await } From 84be3c1f6c26b027aeac8bbd30fc7ee48f2f4501 Mon Sep 17 00:00:00 2001 From: James Watkins-Harvey Date: Fri, 14 Aug 2026 16:46:04 -0400 Subject: [PATCH 05/87] Tolerate out-of-grammar nexus request-timeout headers (#1497) --- crates/sdk-core/CHANGELOG.md | 5 ++ crates/sdk-core/src/worker/nexus.rs | 105 ++++++++++++++++++++++------ 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index 1fbd3e982..a7cf8d06a 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -57,3 +57,8 @@ relevant information. preserving the resolution ordering recorded in existing histories during replay. * Try-cancel child workflows no longer cause nondeterminism when they complete or fail after their cancellation was requested. +* Nexus tasks are now timed out locally even when the server sends a `request-timeout` header that + falls outside the Nexus duration grammar, such as a negative value for a task whose deadline has + already elapsed, a sub-millisecond unit, or a multi-unit value like `1m30s`. Previously such a + header was ignored entirely, so the handler was never told the task had timed out, and a task + left unanswered could block worker shutdown indefinitely. diff --git a/crates/sdk-core/src/worker/nexus.rs b/crates/sdk-core/src/worker/nexus.rs index b3765fa41..a8e39ce8d 100644 --- a/crates/sdk-core/src/worker/nexus.rs +++ b/crates/sdk-core/src/worker/nexus.rs @@ -566,29 +566,52 @@ fn payloads_too_large_nexus_failure(violation: &PayloadLimitViolation) -> NexusT }) } +/// Parses the value of the nexus `request-timeout` header, which the Nexus spec defines as a +/// positive decimal followed by `ms`, `s`, or `m`. +/// +/// Temporal server formats this header with Go's `time.Duration::String`, which is a superset of +/// that grammar: it also emits sub-millisecond units, hours, multi-unit values such as `1m30s`, +/// and negative values when the task's deadline has already elapsed by the time the task is +/// dispatched (see ). All of those are accepted +/// here, and anything that resolves to a non-positive duration means the deadline is already past, +/// hence a zero timeout. fn parse_request_timeout(timeout: &str) -> Result { - let timeout = timeout.trim(); - let (value, unit) = timeout.split_at( - timeout + let trimmed = timeout.trim(); + let (negative, mut rest) = match trimmed.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, trimmed.strip_prefix('+').unwrap_or(trimmed)), + }; + if rest.is_empty() { + return Err(anyhow!("Invalid timeout format")); + } + + let mut total = Duration::ZERO; + while !rest.is_empty() { + let unit_start = rest .find(|c: char| !c.is_ascii_digit() && c != '.') - .unwrap_or(timeout.len()), - ); - - match unit { - "m" => value - .parse::() - .map(|v| Duration::from_secs_f64(60.0 * v)) - .map_err(Into::into), - "s" => value - .parse::() - .map(Duration::from_secs_f64) - .map_err(Into::into), - "ms" => value - .parse::() - .map_err(anyhow::Error::from) - .and_then(|v| Duration::try_from_secs_f64(v / 1000.0).map_err(Into::into)), - _ => Err(anyhow!("Invalid timeout format")), + .ok_or_else(|| anyhow!("Invalid timeout format"))?; + let (value, after_value) = rest.split_at(unit_start); + let unit_end = after_value + .find(|c: char| c.is_ascii_digit()) + .unwrap_or(after_value.len()); + let (unit, remainder) = after_value.split_at(unit_end); + + let value = value.parse::()?; + let seconds = match unit { + "ns" => value / 1e9, + // Go emits the micro sign, but tolerate the Greek letter and the ASCII form too. + "us" | "µs" | "μs" => value / 1e6, + "ms" => value / 1e3, + "s" => value, + "m" => value * 60.0, + "h" => value * 3600.0, + _ => return Err(anyhow!("Invalid timeout format")), + }; + total += Duration::try_from_secs_f64(seconds)?; + rest = remainder; } + + Ok(if negative { Duration::ZERO } else { total }) } #[cfg(test)] @@ -616,6 +639,48 @@ mod tests { ); } + #[test] + fn parse_request_timeout_go_duration_forms() { + // Units outside the Nexus grammar which Go's `time.Duration::String` may still emit + assert_eq!( + parse_request_timeout("88.458µs").unwrap(), + Duration::from_secs_f64(88.458 / 1e6) + ); + assert_eq!( + parse_request_timeout("125ns").unwrap(), + Duration::from_nanos(125) + ); + assert_eq!( + parse_request_timeout("2h").unwrap(), + Duration::from_secs(7200) + ); + // Multi-unit values + assert_eq!( + parse_request_timeout("1m30.5s").unwrap(), + Duration::from_secs_f64(90.5) + ); + assert_eq!( + parse_request_timeout("1h0m0s").unwrap(), + Duration::from_secs(3600) + ); + // An already-elapsed deadline means the task must be timed out right away + assert_eq!(parse_request_timeout("-88.458µs").unwrap(), Duration::ZERO); + assert_eq!(parse_request_timeout("-1m30s").unwrap(), Duration::ZERO); + assert_eq!(parse_request_timeout("0s").unwrap(), Duration::ZERO); + // Leading and surrounding whitespace, and an explicit positive sign + assert_eq!( + parse_request_timeout(" +10s ").unwrap(), + Duration::from_secs(10) + ); + + for invalid in ["", "-", "10", "10x", "abc", "s", "1s2"] { + assert!( + parse_request_timeout(invalid).is_err(), + "'{invalid}' should not parse" + ); + } + } + #[test] fn payloads_too_large_nexus_failure_is_retryable() { let violation = PayloadLimitViolation { From 4a97144030ca447ae1e1ebf70a2ccaa9887ab30e Mon Sep 17 00:00:00 2001 From: James Watkins-Harvey Date: Fri, 14 Aug 2026 17:10:43 -0400 Subject: [PATCH 06/87] Add Event Groups (Core) (#1308) --- .cargo/config.toml | 3 +- CHANGELOG.md | 1 + crates/common/src/payload_visitor.rs | 8 +- .../workflow_activation.proto | 8 + .../workflow_commands/workflow_commands.proto | 6 + crates/protos/src/protos/mod.rs | 12 +- crates/sdk-core/src/core_tests/updates.rs | 70 +++ crates/sdk-core/src/protosext/mod.rs | 5 +- .../src/worker/workflow/driven_workflow.rs | 18 +- .../machines/local_activity_state_machine.rs | 1 + .../workflow/machines/workflow_machines.rs | 68 ++- .../src/worker/workflow/managed_run.rs | 45 +- crates/sdk-core/src/worker/workflow/mod.rs | 14 +- .../tests/integ_tests/workflow_tests.rs | 1 + .../workflow_tests/event_groups.rs | 426 ++++++++++++++++++ crates/workflow/src/workflow_context.rs | 2 + .../workflow/src/workflow_context/options.rs | 44 +- 17 files changed, 671 insertions(+), 61 deletions(-) create mode 100644 crates/sdk-core/tests/integ_tests/workflow_tests/event_groups.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 7f5daf4df..75811dce3 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,7 @@ [env] # This temporarily overrides the version of the CLI used for integration tests, locally and in CI -# CLI_VERSION_OVERRIDE = "v1.6.3-serverless" +# TEMP: Event Groups requires server >v1.32.0-158.0, for which there's no published CLI release yet. +CLI_VERSION_OVERRIDE = "v1.7.4-standalone-nexus-operations" [alias] # Not sure why --all-features doesn't work diff --git a/CHANGELOG.md b/CHANGELOG.md index efcf8de70..5e5905c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ relevant information. `TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` to change the threshold. * `SignalWorkflowOptions::summary` attaches a single-line summary to a signal sent to another workflow, which the UI and CLI display alongside the resulting history event. +* Core now supports attaching `EventGroupMarker`s to various workflow commands. ### Changed * Cancellation errors propagated after workflow cancellation now complete the workflow as cancelled diff --git a/crates/common/src/payload_visitor.rs b/crates/common/src/payload_visitor.rs index 317d01c50..db20742ae 100644 --- a/crates/common/src/payload_visitor.rs +++ b/crates/common/src/payload_visitor.rs @@ -427,7 +427,7 @@ mod tests { }, ..Default::default() })), - user_metadata: None, + ..Default::default() }], ..Default::default() }, @@ -566,7 +566,7 @@ mod tests { }), }, )), - user_metadata: None, + ..Default::default() }, // ContinueAsNewWorkflowExecution command WorkflowCommand { @@ -586,7 +586,7 @@ mod tests { ..Default::default() }, )), - user_metadata: None, + ..Default::default() }, // StartChildWorkflowExecution command WorkflowCommand { @@ -608,7 +608,7 @@ mod tests { ..Default::default() }, )), - user_metadata: None, + ..Default::default() }, ], ..Default::default() diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto index 43729de90..ab50cd515 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto @@ -211,6 +211,12 @@ message InitializeWorkflow { temporal.api.common.v1.WorkflowExecution root_workflow = 24; // Priority of this workflow execution temporal.api.common.v1.Priority priority = 25; + // Event ID of the `WORKFLOW_EXECUTION_STARTED` history event that triggered this job. + int64 originating_event_id = 26; + // The run id recorded on the `WORKFLOW_EXECUTION_STARTED` event. Unlike the execution's current + // run id, this value is preserved across workflow resets. Mirrors the `original_execution_run_id` + // field from `WorkflowExecutionStartedEventAttributes`. + string original_execution_run_id = 27; } // Notify a workflow that a timer has fired @@ -300,6 +306,8 @@ message SignalWorkflow { string identity = 3; // Headers attached to the signal map headers = 5; + // Event ID of the `WORKFLOW_EXECUTION_SIGNALED` history event that produced this job. + int64 originating_event_id = 6; } // Inform lang what the result of a call to `patched` or similar API should be -- this is always diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto index 468a433b2..9d19defc3 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto @@ -15,6 +15,7 @@ import "temporal/api/common/v1/message.proto"; import "temporal/api/enums/v1/workflow.proto"; import "temporal/api/failure/v1/message.proto"; import "temporal/api/sdk/v1/user_metadata.proto"; +import "temporal/api/sdk/v1/event_group_marker.proto"; import "temporal/sdk/core/child_workflow/child_workflow.proto"; import "temporal/sdk/core/nexus/nexus.proto"; import "temporal/sdk/core/common/common.proto"; @@ -25,6 +26,11 @@ message WorkflowCommand { // per-command basis where applicable. temporal.api.sdk.v1.UserMetadata user_metadata = 100; + // Event group markers attached to the command. These are forwarded onto + // the corresponding server-side Command, and consequently surfaced on the + // resulting HistoryEvent. See `temporal/api/sdk/v1/event_group_marker.proto`. + repeated temporal.api.sdk.v1.EventGroupMarker event_group_markers = 101; + oneof variant { StartTimer start_timer = 1; ScheduleActivity schedule_activity = 2; diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index c7dbfb303..c02672578 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -89,7 +89,7 @@ pub mod coresdk { fn from(v: workflow_command::Variant) -> Self { Self { variant: Some(v), - user_metadata: None, + ..Default::default() } } } @@ -1307,13 +1307,16 @@ pub mod coresdk { } } - impl From for SignalWorkflow { - fn from(a: WorkflowExecutionSignaledEventAttributes) -> Self { + impl From<(WorkflowExecutionSignaledEventAttributes, i64)> for SignalWorkflow { + fn from( + (a, originating_event_id): (WorkflowExecutionSignaledEventAttributes, i64), + ) -> Self { Self { signal_name: a.signal_name, input: Vec::from_payloads(a.input), identity: a.identity, headers: a.header.map(Into::into).unwrap_or_default(), + originating_event_id, } } } @@ -1330,6 +1333,7 @@ pub mod coresdk { workflow_id: String, randomness_seed: u64, start_time: Timestamp, + originating_event_id: i64, ) -> InitializeWorkflow { InitializeWorkflow { workflow_type: attrs.workflow_type.map(|wt| wt.name).unwrap_or_default(), @@ -1363,6 +1367,8 @@ pub mod coresdk { start_time: Some(start_time), root_workflow: attrs.root_workflow_execution, priority: attrs.priority, + originating_event_id, + original_execution_run_id: attrs.original_execution_run_id, } } } diff --git a/crates/sdk-core/src/core_tests/updates.rs b/crates/sdk-core/src/core_tests/updates.rs index 2498d156d..1beb3facc 100644 --- a/crates/sdk-core/src/core_tests/updates.rs +++ b/crates/sdk-core/src/core_tests/updates.rs @@ -322,3 +322,73 @@ async fn replay_with_signal_and_update_same_task() { .await .unwrap(); } + +#[tokio::test] +async fn originating_ids_on_inbound_activation_jobs() { + // Each inbound-job kind that auto-creates an event group marker on the lang side + // is supposed to carry the identifier it needs to construct that marker: + // - `InitializeWorkflow.originating_event_id` = `WorkflowExecutionStarted` event id (1) + // - `SignalWorkflow.originating_event_id` = `WorkflowExecutionSignaled` event id + // - `DoUpdate.id` = the workflow-unique update id (always set) + let mut t = TestHistoryBuilder::default(); + t.add_by_type(EventType::WorkflowExecutionStarted); // 1 + t.add_full_wf_task(); // 2,3,4 + t.add_we_signaled("go", vec![]); // 5 + let signal_event_id = t.current_event_id(); + t.add_full_wf_task(); // 6,7,8 + let accept_id = t.add_update_accepted("upd1", "update"); // 9 + t.add_update_completed(accept_id); // 10 + t.add_workflow_execution_completed(); // 11 + + let mock = MockPollCfg::from_resps(t, [ResponseType::AllHistory]); + let mut mock = build_mock_pollers(mock); + mock.worker_cfg(|wc| wc.max_cached_workflows = 1); + let core = mock_worker(mock); + + let task = core.poll_workflow_activation().await.unwrap(); + assert_matches!( + task.jobs.as_slice(), + [WorkflowActivationJob { + variant: Some(workflow_activation_job::Variant::InitializeWorkflow(init)), + }] => { + assert_eq!(init.originating_event_id, 1); + } + ); + core.complete_workflow_activation(WorkflowActivationCompletion::empty(task.run_id)) + .await + .unwrap(); + + let task = core.poll_workflow_activation().await.unwrap(); + assert_matches!( + task.jobs.as_slice(), + [ + WorkflowActivationJob { + variant: Some(workflow_activation_job::Variant::SignalWorkflow(sig)), + }, + WorkflowActivationJob { + variant: Some(workflow_activation_job::Variant::DoUpdate(upd)), + } + ] => { + assert_eq!(sig.originating_event_id, signal_event_id); + assert_eq!(upd.id, "upd1"); + } + ); + core.complete_workflow_activation(WorkflowActivationCompletion::from_cmds( + task.run_id, + vec![ + UpdateResponse { + protocol_instance_id: "upd1".to_string(), + response: Some(Response::Accepted(())), + } + .into(), + UpdateResponse { + protocol_instance_id: "upd1".to_string(), + response: Some(Response::Completed(Payload::default())), + } + .into(), + CompleteWorkflowExecution { result: None }.into(), + ], + )) + .await + .unwrap(); +} diff --git a/crates/sdk-core/src/protosext/mod.rs b/crates/sdk-core/src/protosext/mod.rs index b2adcde96..819c50a76 100644 --- a/crates/sdk-core/src/protosext/mod.rs +++ b/crates/sdk-core/src/protosext/mod.rs @@ -38,7 +38,7 @@ use temporalio_common::protos::{ failure::v1::Failure, history::v1::{History, HistoryEvent, MarkerRecordedEventAttributes, history_event}, query::v1::WorkflowQuery, - sdk::v1::UserMetadata, + sdk::v1::{EventGroupMarker, UserMetadata}, workflowservice::v1::PollWorkflowTaskQueueResponse, }, utilities::TryIntoOrNone, @@ -323,6 +323,7 @@ pub(crate) struct ValidScheduleLA { pub(crate) local_retry_threshold: Duration, pub(crate) cancellation_type: ActivityCancellationType, pub(crate) user_metadata: Option, + pub(crate) event_group_markers: Vec, } #[derive(Debug, Clone, Copy)] @@ -355,6 +356,7 @@ impl ValidScheduleLA { pub(crate) fn from_schedule_la( v: ScheduleLocalActivity, user_metadata: Option, + event_group_markers: Vec, ) -> Result { let original_schedule_time = v .original_schedule_time @@ -431,6 +433,7 @@ impl ValidScheduleLA { local_retry_threshold, cancellation_type, user_metadata, + event_group_markers, }) } } diff --git a/crates/sdk-core/src/worker/workflow/driven_workflow.rs b/crates/sdk-core/src/worker/workflow/driven_workflow.rs index 4bfff46c6..2910f9ff4 100644 --- a/crates/sdk-core/src/worker/workflow/driven_workflow.rs +++ b/crates/sdk-core/src/worker/workflow/driven_workflow.rs @@ -45,6 +45,7 @@ impl DrivenWorkflow { workflow_id: String, randomness_seed: u64, start_time: Timestamp, + originating_event_id: i64, attribs: WorkflowExecutionStartedEventAttributes, ) { debug!(run_id = %attribs.original_execution_run_id, "Driven WF start"); @@ -55,7 +56,14 @@ impl DrivenWorkflow { retry_policy: attribs.retry_policy.clone(), }; self.send_job( - start_workflow_from_attribs(attribs, workflow_id, randomness_seed, start_time).into(), + start_workflow_from_attribs( + attribs, + workflow_id, + randomness_seed, + start_time, + originating_event_id, + ) + .into(), ); self.started_attrs = Some(started_info); } @@ -89,12 +97,8 @@ impl DrivenWorkflow { /// from a buffer that the language side sinks into when it calls [crate::Core::complete_task] pub(super) fn fetch_workflow_iteration_output(&mut self) -> Vec { let in_cmds = self.incoming_commands.try_recv(); - let in_cmds = in_cmds.unwrap_or_else(|_| { - vec![WFCommand { - variant: WFCommandVariant::NoCommandsFromLang, - metadata: None, - }] - }); + let in_cmds = + in_cmds.unwrap_or_else(|_| vec![WFCommand::new(WFCommandVariant::NoCommandsFromLang)]); debug!(in_cmds = %in_cmds.display(), "wf bridge iteration fetch"); in_cmds } diff --git a/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs index 33dfeea63..1c7815c0e 100644 --- a/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs @@ -731,6 +731,7 @@ impl WFMachinesAdapter for LocalActivityMachine { }; let command = ProtoCommand { user_metadata: self.shared_state.attrs.user_metadata.clone(), + event_group_markers: self.shared_state.attrs.event_group_markers.clone(), ..command::Attributes::RecordMarkerCommandAttributes(marker_data).into() }; responses.push(MachineResponse::IssueNewCommand(command)); diff --git a/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs b/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs index c9297a8b2..ccb71d56f 100644 --- a/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs +++ b/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs @@ -71,7 +71,7 @@ use temporalio_common::{ enums::v1::EventType, history::v1::{HistoryEvent, history_event}, protocol::v1::{Message as ProtocolMessage, message::SequencingId}, - sdk::v1::{UserMetadata, WorkflowTaskCompletedMetadata}, + sdk::v1::{EventGroupMarker, UserMetadata, WorkflowTaskCompletedMetadata}, }, }, worker::WorkerDeploymentVersion, @@ -1008,6 +1008,7 @@ impl WorkflowMachines { self.workflow_id.clone(), str_to_randomness_seed(&attrs.original_execution_run_id), event_dat.event.event_time.unwrap_or_default(), + event_id, attrs, ); } else { @@ -1027,8 +1028,9 @@ impl WorkflowMachines { attrs, )) = event_dat.event.attributes { - self.drive_me - .send_job(workflow_activation::SignalWorkflow::from(attrs).into()); + self.drive_me.send_job( + workflow_activation::SignalWorkflow::from((attrs, event_id)).into(), + ); } else { // err } @@ -1196,7 +1198,10 @@ impl WorkflowMachines { }; self.add_cmd_to_wf_task( new_external_cancel(0, we, attrs.child_workflow_only, attrs.reason), + // FIXME: Wire metadata and group markers from lang's cancellation command, + // through the state machine, into the command we issue here. None, + vec![], CommandIdKind::CoreInternal, ); } @@ -1206,7 +1211,10 @@ impl WorkflowMachines { // workflows by users (but rather, just for them to search with). self.add_cmd_to_wf_task( upsert_search_attrs_internal(attrs), + // FIXME: Wire metadata and group markers from lang's patch command, + // through the state machine, into the command we issue here. None, + vec![], CommandIdKind::NeverResolves, ); } @@ -1313,6 +1321,7 @@ impl WorkflowMachines { self.add_cmd_to_wf_task( new_timer(attrs), cmd.metadata, + cmd.event_group_markers, CommandID::Timer(seq).into(), ); } @@ -1326,6 +1335,7 @@ impl WorkflowMachines { self.replaying, ), cmd.metadata, + cmd.event_group_markers, CommandIdKind::NeverResolves, ); } @@ -1345,15 +1355,20 @@ impl WorkflowMachines { use_compat, ), cmd.metadata, + cmd.event_group_markers, CommandID::Activity(seq).into(), ); } WFCommandVariant::AddLocalActivity(attrs) => { let seq = attrs.seq; - let attrs: ValidScheduleLA = - ValidScheduleLA::from_schedule_la(attrs, cmd.metadata).map_err(|e| { - fatal!("Invalid schedule local activity request (seq {seq}): {e}") - })?; + let attrs: ValidScheduleLA = ValidScheduleLA::from_schedule_la( + attrs, + cmd.metadata, + cmd.event_group_markers, + ) + .map_err(|e| { + fatal!("Invalid schedule local activity request (seq {seq}): {e}") + })?; let (la, mach_resp) = new_local_activity( attrs, self.replaying, @@ -1382,10 +1397,18 @@ impl WorkflowMachines { ); } WFCommandVariant::CompleteWorkflow(attrs) => { - self.add_terminal_command(complete_workflow(attrs), cmd.metadata); + self.add_terminal_command( + complete_workflow(attrs), + cmd.metadata, + cmd.event_group_markers, + ); } WFCommandVariant::FailWorkflow(attrs) => { - self.add_terminal_command(fail_workflow(attrs), cmd.metadata); + self.add_terminal_command( + fail_workflow(attrs), + cmd.metadata, + cmd.event_group_markers, + ); } WFCommandVariant::ContinueAsNew(attrs) => { let attrs = self.augment_continue_as_new_with_current_values(attrs); @@ -1393,10 +1416,18 @@ impl WorkflowMachines { attrs.versioning_intent(), &attrs.task_queue, ); - self.add_terminal_command(continue_as_new(attrs, use_compat), cmd.metadata); + self.add_terminal_command( + continue_as_new(attrs, use_compat), + cmd.metadata, + cmd.event_group_markers, + ); } WFCommandVariant::CancelWorkflow(attrs) => { - self.add_terminal_command(cancel_workflow(attrs), cmd.metadata); + self.add_terminal_command( + cancel_workflow(attrs), + cmd.metadata, + cmd.event_group_markers, + ); } WFCommandVariant::SetPatchMarker(attrs) => { // Do not create commands for change IDs that we have already created commands @@ -1418,6 +1449,7 @@ impl WorkflowMachines { let mkey = self.add_cmd_to_wf_task( patch_machine, cmd.metadata, + cmd.event_group_markers, CommandIdKind::NeverResolves, ); self.process_machine_responses(mkey, other_cmds)?; @@ -1447,6 +1479,7 @@ impl WorkflowMachines { use_compat, ), cmd.metadata, + cmd.event_group_markers, CommandID::ChildWorkflowStart(seq).into(), ); } @@ -1474,6 +1507,7 @@ impl WorkflowMachines { ), ), cmd.metadata, + cmd.event_group_markers, CommandID::CancelExternal(attrs.seq).into(), ); } @@ -1482,6 +1516,7 @@ impl WorkflowMachines { self.add_cmd_to_wf_task( new_external_signal(attrs, &self.worker_config.namespace)?, cmd.metadata, + cmd.event_group_markers, CommandID::SignalExternal(seq).into(), ); } @@ -1501,6 +1536,7 @@ impl WorkflowMachines { self.add_cmd_to_wf_task( modify_workflow_properties(attrs), cmd.metadata, + cmd.event_group_markers, CommandIdKind::NeverResolves, ); } @@ -1523,6 +1559,7 @@ impl WorkflowMachines { self.add_cmd_to_wf_task( NexusOperationMachine::new_scheduled(attrs), cmd.metadata, + cmd.event_group_markers, CommandID::NexusOperation(seq).into(), ); } @@ -1568,8 +1605,9 @@ impl WorkflowMachines { &mut self, machine: NewMachineWithCommand, metadata: Option, + markers: Vec, ) { - let cwfm = self.add_new_command_machine(machine, metadata); + let cwfm = self.add_new_command_machine(machine, metadata, markers); self.workflow_end_time = Some(SystemTime::now()); self.current_wf_task_commands.push_back(cwfm); // Wipe out any pending / executing local activity data since we're about to terminate @@ -1582,9 +1620,10 @@ impl WorkflowMachines { &mut self, machine: NewMachineWithCommand, metadata: Option, + markers: Vec, id: CommandIdKind, ) -> MachineKey { - let mach = self.add_new_command_machine(machine, metadata); + let mach = self.add_new_command_machine(machine, metadata, markers); let key = mach.machine; if let CommandIdKind::LangIssued(id) = id { self.id_to_machine.insert(id, key); @@ -1600,13 +1639,14 @@ impl WorkflowMachines { &mut self, machine: NewMachineWithCommand, metadata: Option, + markers: Vec, ) -> CommandAndMachine { let k = self.all_machines.insert(machine.machine); let cmd = ProtoCommand { command_type: machine.command.as_type() as i32, attributes: Some(machine.command), user_metadata: metadata, - event_group_markers: vec![], + event_group_markers: markers, }; CommandAndMachine { command: cmd, diff --git a/crates/sdk-core/src/worker/workflow/managed_run.rs b/crates/sdk-core/src/worker/workflow/managed_run.rs index 7615f1b83..a15dd2388 100644 --- a/crates/sdk-core/src/worker/workflow/managed_run.rs +++ b/crates/sdk-core/src/worker/workflow/managed_run.rs @@ -661,12 +661,11 @@ impl ManagedRun { warn!(failure=?failure, "Failing workflow due to nondeterminism error"); return self .successful_completion( - vec![WFCommand { - variant: WFCommandVariant::FailWorkflow(FailWorkflowExecution { + vec![WFCommand::new(WFCommandVariant::FailWorkflow( + FailWorkflowExecution { failure: failure.failure, - }), - metadata: None, - }], + }, + ))], vec![], VersioningBehavior::Unspecified, // Doesn't matter since we're failing wf resp_chan, @@ -1905,39 +1904,27 @@ mod tests { use super::*; pub(crate) fn complete() -> WFCommand { - WFCommand { - variant: WFCommandVariant::CompleteWorkflow(CompleteWorkflowExecution { - result: None, - }), - metadata: None, - } + WFCommand::new(WFCommandVariant::CompleteWorkflow( + CompleteWorkflowExecution { result: None }, + )) } pub(crate) fn cancel() -> WFCommand { - WFCommand { - variant: WFCommandVariant::CancelWorkflow(CancelWorkflowExecution {}), - metadata: None, - } + WFCommand::new(WFCommandVariant::CancelWorkflow(CancelWorkflowExecution {})) } pub(crate) fn query_response() -> WFCommand { - WFCommand { - variant: WFCommandVariant::QueryResponse(QueryResult { - query_id: "".into(), - variant: None, - }), - metadata: None, - } + WFCommand::new(WFCommandVariant::QueryResponse(QueryResult { + query_id: "".into(), + variant: None, + })) } pub(crate) fn update_response() -> WFCommand { - WFCommand { - variant: WFCommandVariant::UpdateResponse(UpdateResponse { - protocol_instance_id: "".into(), - response: None, - }), - metadata: None, - } + WFCommand::new(WFCommandVariant::UpdateResponse(UpdateResponse { + protocol_instance_id: "".into(), + response: None, + })) } pub(crate) fn command_types(commands: &[WFCommand]) -> Vec> { diff --git a/crates/sdk-core/src/worker/workflow/mod.rs b/crates/sdk-core/src/worker/workflow/mod.rs index 9b3657e0f..80e18e2d5 100644 --- a/crates/sdk-core/src/worker/workflow/mod.rs +++ b/crates/sdk-core/src/worker/workflow/mod.rs @@ -83,7 +83,7 @@ use temporalio_common::{ failure::v1::{ApplicationFailureInfo, failure::FailureInfo}, protocol::v1::Message as ProtocolMessage, query::v1::WorkflowQuery, - sdk::v1::{UserMetadata, WorkflowTaskCompletedMetadata}, + sdk::v1::{EventGroupMarker, UserMetadata, WorkflowTaskCompletedMetadata}, taskqueue::v1::StickyExecutionAttributes, workflowservice::v1::{PollActivityTaskQueueResponse, get_system_info_response}, }, @@ -1414,6 +1414,17 @@ struct EmptyWorkflowCommandErr; struct WFCommand { variant: WFCommandVariant, metadata: Option, + event_group_markers: Vec, +} + +impl WFCommand { + fn new(variant: WFCommandVariant) -> Self { + Self { + variant, + metadata: None, + event_group_markers: vec![], + } + } } #[derive(Debug, derive_more::From, derive_more::Display)] @@ -1508,6 +1519,7 @@ impl TryFrom for WFCommand { Ok(Self { variant, metadata: c.user_metadata, + event_group_markers: c.event_group_markers, }) } } diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests.rs b/crates/sdk-core/tests/integ_tests/workflow_tests.rs index f2cefa7b4..ea16fb65f 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests.rs @@ -6,6 +6,7 @@ mod client_interactions; mod continue_as_new; mod determinism; mod eager; +mod event_groups; mod interceptors; mod local_activities; mod modify_wf_properties; diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/event_groups.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/event_groups.rs new file mode 100644 index 000000000..8d2ee63a2 --- /dev/null +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/event_groups.rs @@ -0,0 +1,426 @@ +//! Verify that `EventGroupMarker`s attached to lang-side options propagate all the +//! way down to the server-side `Command`s issued by Core. One mocked test per command +//! kind we currently expose `event_group_markers` on: activity, child workflow, timer, +//! local activity. +//! +//! Plus one end-to-end test against a real server, verifying that the markers also +//! land on the resulting `HistoryEvent` (i.e. the server persists what we send). +//! +//! Event Groups are not implemented in the Rust SDK, so these tests build markers as raw +//! protos and set them through the `#[doc(hidden)]` `event_group_markers` option fields, +//! which exist for that purpose only. + +use std::time::Duration; + +use crate::common::{ + CoreWfStarter, activity_functions::StdActivities, build_fake_sdk_with_options, + mock_sdk_cfg_with_options, +}; +use temporalio_client::{UntypedWorkflow, WorkflowStartOptions}; +use temporalio_common::{ + data_converters::RawValue, + protos::{ + coresdk::AsJsonPayloadExt, + temporal::api::{ + enums::v1::{CommandType, EventType}, + sdk::v1::{ + EventGroupMarker, + event_group_marker::{Label, Variant}, + }, + }, + }, +}; +use temporalio_macros::{workflow, workflow_methods}; +use temporalio_sdk::{ + ActivityOptions, ChildWorkflowOptions, LocalActivityOptions, TimerOptions, WorkflowContext, + WorkflowResult, +}; +use temporalio_sdk_core::{ + replay::{DEFAULT_WORKFLOW_TYPE, canned_histories}, + test_help::MockPollCfg, +}; + +#[tokio::test] +async fn pass_event_group_markers_on_schedule_activity() { + let t = canned_histories::single_activity("1"); + let mut mock_cfg = MockPollCfg::from_hist_builder(t); + let wf_id = mock_cfg.hists[0].wf_id.clone(); + let wf_type = DEFAULT_WORKFLOW_TYPE; + let expected_markers = vec![label_marker("activity-group", "activity-group-label")]; + + let expected_for_assert = expected_markers.clone(); + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + asserts + .then(move |wft| { + assert_eq!(wft.commands.len(), 1); + assert_eq!( + wft.commands[0].command_type(), + CommandType::ScheduleActivityTask + ); + assert_eq!(wft.commands[0].event_group_markers, expected_for_assert); + }) + .then(|wft| { + assert_eq!(wft.commands.len(), 1); + assert_eq!( + wft.commands[0].command_type(), + CommandType::CompleteWorkflowExecution + ); + assert!(wft.commands[0].event_group_markers.is_empty()); + }); + }); + + #[workflow] + struct ActivityWithGroupWorkflow { + event_group_markers: Vec, + } + + #[workflow_methods(factory_only)] + impl ActivityWithGroupWorkflow { + #[run(name = DEFAULT_WORKFLOW_TYPE)] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + let event_group_markers = ctx.state(|wf| wf.event_group_markers.clone()); + ctx.execute_activity( + StdActivities::default, + (), + ActivityOptions::with_start_to_close_timeout(Duration::from_secs(5)) + .event_group_markers(event_group_markers) + .build(), + ) + .await?; + Ok(()) + } + } + + let mut worker = mock_sdk_cfg_with_options( + mock_cfg, + |_| {}, + |options| { + options + .register_workflow_with_factory(move || ActivityWithGroupWorkflow { + event_group_markers: expected_markers.clone(), + }) + .unwrap(); + }, + ); + let task_queue = worker.inner_mut().task_queue().to_owned(); + worker + .submit_wf( + wf_type.to_owned(), + vec![], + WorkflowStartOptions::new(task_queue, wf_id.to_owned()).build(), + ) + .await + .unwrap(); + worker.run_until_done().await.unwrap(); +} + +#[tokio::test] +async fn pass_event_group_markers_on_start_child_workflow() { + let wf_id = "1"; + let wf_type = DEFAULT_WORKFLOW_TYPE; + let t = canned_histories::single_child_workflow(wf_id); + let mut mock_cfg = MockPollCfg::from_hist_builder(t); + let expected_markers = vec![label_marker("child-group", "child-group-label")]; + + let expected_for_assert = expected_markers.clone(); + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + asserts + .then(move |wft| { + assert_eq!(wft.commands.len(), 1); + assert_eq!( + wft.commands[0].command_type(), + CommandType::StartChildWorkflowExecution + ); + assert_eq!(wft.commands[0].event_group_markers, expected_for_assert); + }) + .then(|wft| { + assert_eq!(wft.commands.len(), 1); + assert_eq!( + wft.commands[0].command_type(), + CommandType::CompleteWorkflowExecution + ); + assert!(wft.commands[0].event_group_markers.is_empty()); + }); + }); + + #[workflow] + struct ChildWithGroupWorkflow { + child_wf_id: String, + event_group_markers: Vec, + } + + #[workflow_methods(factory_only)] + impl ChildWithGroupWorkflow { + #[run(name = DEFAULT_WORKFLOW_TYPE)] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + let (child_wf_id, event_group_markers) = + ctx.state(|wf| (wf.child_wf_id.clone(), wf.event_group_markers.clone())); + ctx.start_child_workflow( + UntypedWorkflow::new("child"), + RawValue::new(vec![]), + ChildWorkflowOptions::builder() + .workflow_id(child_wf_id) + .event_group_markers(event_group_markers) + .build(), + ) + .await?; + Ok(()) + } + } + + let child_wf_id = wf_id.to_string(); + let event_group_markers_for_wf = expected_markers.clone(); + let mut worker = mock_sdk_cfg_with_options( + mock_cfg, + |_| {}, + |options| { + options + .register_workflow_with_factory(move || ChildWithGroupWorkflow { + child_wf_id: child_wf_id.clone(), + event_group_markers: event_group_markers_for_wf.clone(), + }) + .unwrap(); + }, + ); + let task_queue = worker.inner_mut().task_queue().to_owned(); + worker + .submit_wf( + wf_type.to_owned(), + vec![], + WorkflowStartOptions::new(task_queue, wf_id.to_owned()).build(), + ) + .await + .unwrap(); + worker.run_until_done().await.unwrap(); +} + +#[tokio::test] +async fn pass_event_group_markers_on_start_timer() { + let t = canned_histories::single_timer("1"); + let mut mock_cfg = MockPollCfg::from_hist_builder(t); + let wf_id = mock_cfg.hists[0].wf_id.clone(); + let wf_type = DEFAULT_WORKFLOW_TYPE; + let expected_markers = vec![label_marker("timer-group", "timer-group-label")]; + + let expected_for_assert = expected_markers.clone(); + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + asserts + .then(move |wft| { + assert_eq!(wft.commands.len(), 1); + assert_eq!(wft.commands[0].command_type(), CommandType::StartTimer); + assert_eq!(wft.commands[0].event_group_markers, expected_for_assert); + }) + .then(|wft| { + assert_eq!(wft.commands.len(), 1); + assert_eq!( + wft.commands[0].command_type(), + CommandType::CompleteWorkflowExecution + ); + assert!(wft.commands[0].event_group_markers.is_empty()); + }); + }); + + #[workflow] + struct TimerWithGroupWorkflow { + event_group_markers: Vec, + } + + #[workflow_methods(factory_only)] + impl TimerWithGroupWorkflow { + #[run(name = DEFAULT_WORKFLOW_TYPE)] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + let event_group_markers = ctx.state(|wf| wf.event_group_markers.clone()); + ctx.timer( + TimerOptions::builder(Duration::from_secs(1)) + .event_group_markers(event_group_markers) + .build(), + ) + .await; + Ok(()) + } + } + + let event_group_markers_for_wf = expected_markers.clone(); + let mut worker = mock_sdk_cfg_with_options( + mock_cfg, + |_| {}, + |options| { + options + .register_workflow_with_factory(move || TimerWithGroupWorkflow { + event_group_markers: event_group_markers_for_wf.clone(), + }) + .unwrap(); + }, + ); + let task_queue = worker.inner_mut().task_queue().to_owned(); + worker + .submit_wf( + wf_type.to_owned(), + vec![], + WorkflowStartOptions::new(task_queue, wf_id.to_owned()).build(), + ) + .await + .unwrap(); + worker.run_until_done().await.unwrap(); +} + +/// Local activities pose some particular challenges: the corresponding `RecordMarker` command +/// only gets created at a later point, after the local activity completes execution. +/// server, so instead of a command of their own they produce a `RecordMarker` command that +/// Core synthesizes when the activity resolves. Markers attached to the `ScheduleLocalActivity` +/// command have to survive that indirection and end up on the marker command. +#[tokio::test] +async fn pass_event_group_markers_on_schedule_local_activity() { + let t = canned_histories::single_local_activity("1"); + let mut mock_cfg = MockPollCfg::from_hist_builder(t); + let expected_markers = vec![label_marker("local-activity-group", "local-activity-label")]; + + let expected_for_assert = expected_markers.clone(); + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + // The activity resolves within the same workflow task that scheduled it, so the marker + // command is flushed together with the workflow completion rather than on its own. + asserts.then(move |wft| { + assert_eq!(wft.commands.len(), 2); + assert_eq!(wft.commands[0].command_type(), CommandType::RecordMarker); + assert_eq!(wft.commands[0].event_group_markers, expected_for_assert); + assert_eq!( + wft.commands[1].command_type(), + CommandType::CompleteWorkflowExecution + ); + assert!(wft.commands[1].event_group_markers.is_empty()); + }); + }); + + #[workflow] + struct LocalActivityWithGroupWorkflow { + event_group_markers: Vec, + } + + #[workflow_methods(factory_only)] + impl LocalActivityWithGroupWorkflow { + #[run(name = DEFAULT_WORKFLOW_TYPE)] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + let event_group_markers = ctx.state(|wf| wf.event_group_markers.clone()); + ctx.execute_local_activity( + StdActivities::default, + (), + LocalActivityOptions::builder() + .event_group_markers(event_group_markers) + .build(), + ) + .await?; + Ok(()) + } + } + + // Unlike the tests above, this one drives a plain SDK worker off the canned history rather + // than submitting a workflow: the local activity must actually run for a marker to be + // recorded, so the worker needs the activity implementation registered too. + let mut worker = build_fake_sdk_with_options(mock_cfg, |options| { + options + .register_workflow_with_factory(move || LocalActivityWithGroupWorkflow { + event_group_markers: expected_markers.clone(), + }) + .unwrap() + .register_activities(StdActivities); + }); + worker.run().await.unwrap(); +} + +// Constants used by the real-server test below; defining them at module scope so the +// workflow body and the assertion can construct the same marker independently. +const PERSIST_TEST_MARKER_ID: &str = "persist-test"; +const PERSIST_TEST_MARKER_LABEL: &str = "persist-test-label"; +const PERSIST_TEST_LA_MARKER_ID: &str = "persist-test-la"; +const PERSIST_TEST_LA_MARKER_LABEL: &str = "persist-test-la-label"; + +#[workflow] +#[derive(Default)] +pub(crate) struct ActivityEventGroupPersistsWf; + +#[workflow_methods] +impl ActivityEventGroupPersistsWf { + #[run(name = "event_group_markers_persist_to_history_events")] + pub(crate) async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + ctx.execute_activity( + StdActivities::default, + (), + ActivityOptions::with_start_to_close_timeout(Duration::from_secs(5)) + .event_group_markers(vec![label_marker( + PERSIST_TEST_MARKER_ID, + PERSIST_TEST_MARKER_LABEL, + )]) + .build(), + ) + .await?; + ctx.execute_local_activity( + StdActivities::default, + (), + LocalActivityOptions::builder() + .start_to_close_timeout(Duration::from_secs(5)) + .event_group_markers(vec![label_marker( + PERSIST_TEST_LA_MARKER_ID, + PERSIST_TEST_LA_MARKER_LABEL, + )]) + .build(), + ) + .await?; + Ok(()) + } +} + +/// End-to-end: a marker attached to a command must also land on the resulting history event +/// after the server persists it. Covers both an ordinary activity (`ActivityTaskScheduled`) and +/// a local activity, which surfaces as the `MarkerRecorded` event Core writes on resolution. +#[tokio::test] +async fn event_group_markers_persist_to_history_events() { + let wf_name = "event_group_markers_persist_to_history_events"; + let mut starter = CoreWfStarter::new(wf_name); + starter + .sdk_config + .register_activities(StdActivities) + .register_workflow::() + .unwrap(); + let mut worker = starter.worker().await; + + starter.start_with_worker(wf_name, &mut worker).await; + worker.run_until_done().await.unwrap(); + + let history = starter.get_history().await; + let scheduled_events: Vec<_> = history + .events + .iter() + .filter(|e| e.event_type() == EventType::ActivityTaskScheduled) + .collect(); + assert_eq!(scheduled_events.len(), 1); + assert_eq!( + scheduled_events[0].event_group_markers, + vec![label_marker( + PERSIST_TEST_MARKER_ID, + PERSIST_TEST_MARKER_LABEL + )] + ); + + let marker_events: Vec<_> = history + .events + .iter() + .filter(|e| e.event_type() == EventType::MarkerRecorded) + .collect(); + assert_eq!(marker_events.len(), 1); + assert_eq!( + marker_events[0].event_group_markers, + vec![label_marker( + PERSIST_TEST_LA_MARKER_ID, + PERSIST_TEST_LA_MARKER_LABEL + )] + ); +} + +fn label_marker(id: &str, label: &str) -> EventGroupMarker { + EventGroupMarker { + variant: Some(Variant::Label(Label { + id: id.to_string(), + label: Some(label.as_json_payload().unwrap()), + })), + } as EventGroupMarker +} diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 86cd12e36..012f39411 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -2525,6 +2525,7 @@ impl Future for LATimerBackoffFut { .expect("duration converts ok"), cancellation_token: Some(self.cancellation_token.clone()), summary: None, + event_group_markers: vec![], }); self.timer_fut = Some(Box::pin(timer_f)); self.next_attempt = b.attempt; @@ -3449,6 +3450,7 @@ mod tests { duration: Duration::from_secs(1), cancellation_token: Some(token.clone()), summary: None, + event_group_markers: vec![], }); let mut activity_options = ActivityOptions::start_to_close_timeout(Duration::from_secs(1)); diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index 6104e06bd..492da4033 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -29,7 +29,7 @@ use temporalio_common_wasm::{ ContinueAsNewVersioningBehavior as ProtoContinueAsNewVersioningBehavior, WorkflowIdReusePolicy as ProtoWorkflowIdReusePolicy, }, - sdk::v1::UserMetadata, + sdk::v1::{EventGroupMarker, UserMetadata}, }, }, search_attributes::SearchAttributes, @@ -323,6 +323,13 @@ pub struct ActivityOptions { /// If true, disable eager execution for this activity #[builder(default)] pub do_not_eagerly_execute: bool, + /// Event group markers to attach to the resulting `ScheduleActivityTask` command. + /// + /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists + /// only for internal test purposes. This API *will* change. + #[doc(hidden)] + #[builder(default)] + pub event_group_markers: Vec, } impl ActivityOptions { @@ -390,6 +397,7 @@ impl ActivityOptions { }), self.summary, None, + self.event_group_markers, ) } } @@ -437,6 +445,13 @@ pub struct LocalActivityOptions { pub start_to_close_timeout: Option, /// Single-line summary for this activity that will appear in UI/CLI. pub summary: Option, + /// Event group markers to attach to the resulting `RecordMarker` command. + /// + /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists + /// only for internal test purposes. This API *will* change. + #[doc(hidden)] + #[builder(default)] + pub event_group_markers: Vec, } impl Default for LocalActivityOptions { @@ -483,6 +498,7 @@ impl LocalActivityOptions { }), self.summary, None, + self.event_group_markers, ) } } @@ -524,6 +540,13 @@ pub struct ChildWorkflowOptions { pub search_attributes: Option, /// Priority for the workflow pub priority: Option, + /// Event group markers to attach to the resulting `StartChildWorkflowExecution` command. + /// + /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists + /// only for internal test purposes. This API *will* change. + #[doc(hidden)] + #[builder(default)] + pub event_group_markers: Vec, } impl ChildWorkflowOptions { @@ -576,6 +599,7 @@ impl ChildWorkflowOptions { }), self.static_summary, self.static_details, + self.event_group_markers, ) } } @@ -591,6 +615,13 @@ pub struct TimerOptions { pub cancellation_token: Option, /// Summary of the timer pub summary: Option, + /// Event group markers to attach to the resulting `StartTimer` command. + /// + /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists + /// only for internal test purposes. This API *will* change. + #[doc(hidden)] + #[builder(default)] + pub event_group_markers: Vec, } impl Default for TimerOptions { @@ -621,6 +652,7 @@ impl TimerOptions { }), self.summary, None, + self.event_group_markers, ) } } @@ -641,6 +673,13 @@ pub struct SignalWorkflowOptions { pub cancellation_token: Option, /// Single-line summary for this signal that will appear in UI/CLI. pub summary: Option, + /// Event group markers to attach to the resulting `SignalExternalWorkflowExecution` command. + /// + /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists + /// only for internal test purposes. This API *will* change. + #[doc(hidden)] + #[builder(default)] + pub event_group_markers: Vec, } impl SignalWorkflowOptions { @@ -664,6 +703,7 @@ impl SignalWorkflowOptions { ), self.summary, None, + self.event_group_markers, ) } } @@ -870,10 +910,12 @@ fn command_with_metadata( variant: workflow_command::Variant, summary: Option, details: Option, + markers: Vec, ) -> WorkflowCommand { WorkflowCommand { variant: Some(variant), user_metadata: string_user_metadata(summary, details), + event_group_markers: markers, } } From 00677170aa7dc62d90bf3d6d3d9f97717a49ea77 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 17 Aug 2026 11:05:03 -0700 Subject: [PATCH 07/87] Update API upstream (#1504) --- .../protos/protos/api_upstream/.github/workflows/ci.yml | 2 +- .../api_upstream/.github/workflows/create-release.yml | 6 +++--- .../protos/api_upstream/.github/workflows/push-to-buf.yml | 2 +- .../protos/protos/api_upstream/nexus/workflow-service.wit | 8 +++++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/protos/protos/api_upstream/.github/workflows/ci.yml b/crates/protos/protos/api_upstream/.github/workflows/ci.yml index 79b216936..53cde483c 100644 --- a/crates/protos/protos/api_upstream/.github/workflows/ci.yml +++ b/crates/protos/protos/api_upstream/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: name: ci runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/setup-go@7b8cf10d4e4a01d4992d18a89f4d7dc5a3e6d6f4 # v4.3.0 with: go-version: '^1.21' diff --git a/crates/protos/protos/api_upstream/.github/workflows/create-release.yml b/crates/protos/protos/api_upstream/.github/workflows/create-release.yml index c9c480964..e3822a168 100644 --- a/crates/protos/protos/api_upstream/.github/workflows/create-release.yml +++ b/crates/protos/protos/api_upstream/.github/workflows/create-release.yml @@ -34,7 +34,7 @@ jobs: api_go_commit_sha: ${{ steps.pin_commits.outputs.api_go_commit_sha }} steps: - name: Checkout api - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.inputs.branch }} fetch-depth: 0 @@ -42,7 +42,7 @@ jobs: path: api - name: Checkout api-go - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: temporalio/api-go ref: ${{ github.event.inputs.branch }} @@ -114,7 +114,7 @@ jobs: owner: ${{ github.repository_owner }} - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.prepare-inputs.outputs.api_commit_sha }} token: ${{ steps.generate_token.outputs.token }} diff --git a/crates/protos/protos/api_upstream/.github/workflows/push-to-buf.yml b/crates/protos/protos/api_upstream/.github/workflows/push-to-buf.yml index f2cd0a675..e99912c80 100644 --- a/crates/protos/protos/api_upstream/.github/workflows/push-to-buf.yml +++ b/crates/protos/protos/api_upstream/.github/workflows/push-to-buf.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: bufbuild/buf-action@fd21066df7214747548607aaa45548ba2b9bc1ff # v1.4.0 with: version: 1.49.0 diff --git a/crates/protos/protos/api_upstream/nexus/workflow-service.wit b/crates/protos/protos/api_upstream/nexus/workflow-service.wit index c1c7bc5df..61822d41c 100644 --- a/crates/protos/protos/api_upstream/nexus/workflow-service.wit +++ b/crates/protos/protos/api_upstream/nexus/workflow-service.wit @@ -60,8 +60,8 @@ interface workflow-service { task-timeout: option, /// @nexus.omit identity: placeholder, - /// @nexus.doc "Request ID used to deduplicate workflow start requests." - request-id: option, + /// @nexus.omit + request-id: placeholder, /// @nexus.doc "Behavior when a closed workflow with the same ID exists. Default is allow-duplicate." /// @nexus.proto-field "workflow_id_reuse_policy" /// @nexus.default "allow-duplicate" @@ -104,7 +104,8 @@ interface workflow-service { started: option, /// @nexus.omit signal-link: placeholder, - first-execution-run-id: string, + /// @nexus.omit + first-execution-run-id: placeholder, } /// @nexus.doc @@ -119,6 +120,7 @@ interface workflow-service { /// dotnet-type="Temporalio.Workflows.ExternalWorkflowHandle" /// dotnet="Temporalio.Workflows.Workflow.GetExternalWorkflowHandle(request.Id, result.RunId)" /// @nexus.operation name="SignalWithStartWorkflowExecution" + /// @nexus.serialization-context python="signal_with_start_workflow_serialization_context" /// @nexus.experimental signal-with-start-workflow: func( request: signal-with-start-workflow-request, From 5962c094869d691b78b9732f09851a9183173db9 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 17 Aug 2026 14:23:23 -0400 Subject: [PATCH 08/87] feat(sdk): add activity and workflow test env (#1492) * Prefactor activity execution traits * Prefactor ephemeral server APIs * Add SDK test environments * Address SDK testing review feedback * self review * self review: better heartbeating * fix merge * fixup merge conflict * pr feedback * rename * add back error msg part * pr feedback * support saa --- CHANGELOG.md | 13 + crates/macros/src/activities_definitions.rs | 16 +- crates/sdk-core/Cargo.toml | 2 +- crates/sdk-core/src/ephemeral_server/mod.rs | 284 ++++++-- .../integ_tests/ephemeral_server_tests.rs | 54 +- crates/sdk/Cargo.toml | 2 + crates/sdk/README.md | 32 + crates/sdk/src/activities.rs | 147 +++- crates/sdk/src/lib.rs | 2 + crates/sdk/src/testing.rs | 663 ++++++++++++++++++ 10 files changed, 1119 insertions(+), 96 deletions(-) create mode 100644 crates/sdk/src/testing.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5905c3a..1acdafb6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,13 @@ relevant information. * Client methods for starting and managing execution of Standalone Activities. * `LoggerFormat` for selecting compact, pretty, or JSON Core console log output. Configured log filters continue to apply to JSON output. +* The Rust SDK now has an optional `testing` feature with a typed activity test environment and + local or external workflow test environments. Local workflow environments manage a Temporal CLI + dev server and expose shutdown through their local-server type state. +* Worker heartbeats now report the SDK runtime, hosting environments, operating system, and + architecture once per worker, retrying until the first successful delivery. Runtime options can + disable this reporting, and language SDK bridges can supply their own runtime details. The Rust + SDK exposes separate runtime options that omit bridge-only runtime overrides. * `RpcOptions::builder()` for constructing per-call RPC options. * `DnsLoadBalancingOptions::builder()` for configuring DNS re-resolution intervals. * Experimental plugin APIs for packaging reusable client and worker configuration, including data @@ -82,6 +89,12 @@ relevant information. `OutgoingError`, `OutgoingActivityError`, and `OutgoingWorkflowError` are now non-exhaustive; downstream matches must include a wildcard arm. * Removed `InterceptorWithNext`. Register worker interceptors as an ordered vector instead. +* Ephemeral server APIs now return `EphemeralServerError` instead of `anyhow::Error`, and dev-server + log format and level use the non-exhaustive `DevServerLogFormat` and `DevServerLogLevel` enums. +* Ephemeral server APIs now return the operation-oriented `EphemeralServerError` instead of + `anyhow::Error`. +* Activity macro support now exposes instance requirements through `ExecutableActivity`; the + redundant `HasOnlyStaticMethods` marker trait has been removed. * `Worker::run` now returns `WorkerRunError` instead of `anyhow::Error`. Non-validation failures are reported as `WorkerRunError::Fatal` with a message and source. * `Logger::Console` now requires a `format: Option` field. Use `None` to preserve the diff --git a/crates/macros/src/activities_definitions.rs b/crates/macros/src/activities_definitions.rs index e91595443..b3277280f 100644 --- a/crates/macros/src/activities_definitions.rs +++ b/crates/macros/src/activities_definitions.rs @@ -366,8 +366,8 @@ impl ActivitiesDefinition { }) .collect(); - // Run methods and `ExecutableActivity`/`ActivityImplementer`/`HasOnlyStaticMethods` - // impls only make sense for real activities; definitions skip them entirely. + // Run methods and `ExecutableActivity`/`ActivityImplementer` impls only make sense for + // real activities; definitions skip them entirely. let run_impls: Vec<_> = if is_definitions { Vec::new() } else { @@ -396,14 +396,6 @@ impl ActivitiesDefinition { self.generate_activity_implementer_impl(impl_type, &module_ident) }; - let has_only_static = if !is_definitions && self.activities.iter().all(|a| a.is_static) { - quote! { - impl ::temporalio_sdk::activities::HasOnlyStaticMethods for #impl_type {} - } - } else { - quote! {} - }; - // Generate impl block with consts let const_impl = quote! { impl #impl_type { @@ -427,8 +419,6 @@ impl ActivitiesDefinition { #(#activity_impls)* #implementer_impl - - #has_only_static }; output.into() @@ -608,6 +598,7 @@ impl ActivitiesDefinition { let prefixed_method = format_ident!("__{}", activity.method.sig.ident); let has_input = !activity.input_types.is_empty(); + let is_instance = !activity.is_static; let receiver_pattern = if activity.is_static { quote! { _receiver } @@ -666,6 +657,7 @@ impl ActivitiesDefinition { impl ::temporalio_sdk::activities::ExecutableActivity for #module_ident::#struct_ident { type Implementer = #impl_type; + const REQUIRES_INSTANCE: bool = #is_instance; fn definition() -> Self { #module_ident::#struct_ident diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index 31ff1c508..1348290e7 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -163,7 +163,7 @@ hyper-util = { version = "0.1", features = [ ] } rstest = "0.26" semver = "1.0" -temporalio-sdk = { path = "../sdk", features = ["wasm-workflows"] } +temporalio-sdk = { path = "../sdk", features = ["testing", "wasm-workflows"] } temporalio-common = { path = "../common", version = "0.6", default-features = false } temporalio-workflow = { path = "../workflow" } tokio = { version = "1.47", default-features = false, features = [ diff --git a/crates/sdk-core/src/ephemeral_server/mod.rs b/crates/sdk-core/src/ephemeral_server/mod.rs index 43e09bff0..608d8c7a1 100644 --- a/crates/sdk-core/src/ephemeral_server/mod.rs +++ b/crates/sdk-core/src/ephemeral_server/mod.rs @@ -1,16 +1,16 @@ //! This module implements support for downloading and running ephemeral test //! servers useful for testing. -use anyhow::anyhow; use flate2::read::GzDecoder; use futures_util::StreamExt; use serde::Deserialize; use std::{ + error::Error, fs::OpenOptions, io, path::{Path, PathBuf}, }; -use temporalio_client::{Connection, ConnectionOptions}; +use temporalio_client::{Connection, ConnectionOptions, errors::ClientConnectError}; use tokio::{ task::spawn_blocking, time::{Duration, sleep}, @@ -23,6 +23,98 @@ use zip::read::read_zipfile_from_stream; use std::os::unix::fs::OpenOptionsExt; use std::process::Stdio; +/// Errors encountered while downloading, starting, or stopping an ephemeral server. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum EphemeralServerError { + /// A configured executable path does not exist. + #[error("ephemeral server executable does not exist: {}", path.display())] + ExecutableNotFound { + /// Missing executable path. + path: PathBuf, + }, + /// Downloading or caching an executable failed. + #[error("failed to download ephemeral server executable: {source}")] + Download { + /// Underlying download or cache failure. + #[source] + source: Box, + }, + /// Download metadata, platform support, or archive contents were invalid. + #[error("invalid ephemeral server download: {message}")] + InvalidDownload { + /// Description of the invalid content. + message: String, + /// Underlying validation or archive failure, when available. + #[source] + source: Option>, + }, + /// Starting the server process failed. + #[error("failed to start ephemeral server: {source}")] + ServerStart { + /// Underlying process or target failure. + #[source] + source: Box, + }, + /// The server process did not become available before its startup deadline. + #[error( + "ephemeral server at {target} did not start within {timeout:?}. Make sure another download isn't stuck and delete the temp file." + )] + StartupTimeout { + /// Server target that could not be reached. + target: String, + /// Amount of time spent waiting. + timeout: Duration, + /// Last connection error, when one was observed. + #[source] + last_error: Option, + }, + /// Stopping the server process failed. + #[error("failed to stop ephemeral server: {source}")] + ServerShutdown { + /// Underlying process failure. + #[source] + source: Box, + }, +} + +impl EphemeralServerError { + fn download_error(source: impl Error + Send + Sync + 'static) -> Self { + Self::Download { + source: Box::new(source), + } + } + + fn invalid_download(message: impl Into) -> Self { + Self::InvalidDownload { + message: message.into(), + source: None, + } + } + + fn invalid_download_with_source( + message: impl Into, + source: impl Error + Send + Sync + 'static, + ) -> Self { + Self::InvalidDownload { + message: message.into(), + source: Some(Box::new(source)), + } + } + + fn server_start_error(source: impl Error + Send + Sync + 'static) -> Self { + Self::ServerStart { + source: Box::new(source), + } + } + + fn server_shutdown_error(source: impl Error + Send + Sync + 'static) -> Self { + Self::ServerShutdown { + source: Box::new(source), + } + } +} + /// Configuration for Temporal CLI dev server. #[derive(Debug, Clone, bon::Builder)] #[builder(on(String, into))] @@ -44,7 +136,7 @@ pub struct TemporalDevServerConfig { /// Whether to enable the UI. If ui_port is set, assumes true. #[builder(default)] pub ui: bool, - /// Log format and level + /// Log format and level. #[builder(default = ("pretty".to_owned(), "warn".to_owned()))] pub log: (String, String), /// Additional arguments to Temporal dev server. @@ -54,7 +146,7 @@ pub struct TemporalDevServerConfig { impl TemporalDevServerConfig { /// Start a Temporal CLI dev server. - pub async fn start_server(&self) -> anyhow::Result { + pub async fn start_server(&self) -> Result { self.start_server_with_output(Stdio::inherit(), Stdio::inherit()) .await } @@ -64,7 +156,7 @@ impl TemporalDevServerConfig { &self, output: Stdio, err_output: Stdio, - ) -> anyhow::Result { + ) -> Result { // Get exe path let exe_path = self .exe @@ -74,7 +166,7 @@ impl TemporalDevServerConfig { // Get free port if not already given let port = match self.port { Some(p) => p, - None => get_free_port(&self.ip)?, + None => get_free_port(&self.ip).map_err(EphemeralServerError::server_start_error)?, }; // Build arg set @@ -142,7 +234,7 @@ pub struct TestServerConfig { impl TestServerConfig { /// Start a test server. - pub async fn start_server(&self) -> anyhow::Result { + pub async fn start_server(&self) -> Result { self.start_server_with_output(Stdio::inherit(), Stdio::inherit()) .await } @@ -152,7 +244,7 @@ impl TestServerConfig { &self, output: Stdio, err_output: Stdio, - ) -> anyhow::Result { + ) -> Result { // Get exe path let exe_path = self .exe @@ -162,7 +254,7 @@ impl TestServerConfig { // Get free port if not already given let port = match self.port { Some(p) => p, - None => get_free_port("0.0.0.0")?, + None => get_free_port("0.0.0.0").map_err(EphemeralServerError::server_start_error)?, }; // Build arg set @@ -202,7 +294,7 @@ pub struct EphemeralServer { } impl EphemeralServer { - async fn start(config: EphemeralServerConfig) -> anyhow::Result { + async fn start(config: EphemeralServerConfig) -> Result { // Start process. kill_on_drop ensures the process cannot outlive this // handle if start fails before an EphemeralServer (whose shutdown is // the normal kill path) is returned to the caller. @@ -212,15 +304,18 @@ impl EphemeralServer { .stdout(config.output) .stderr(config.err_output) .kill_on_drop(true) - .spawn()?; + .spawn() + .map_err(EphemeralServerError::server_start_error)?; let target = format!("127.0.0.1:{}", config.port); let target_url = format!("http://{target}"); - let connection_options = ConnectionOptions::new(Url::parse(&target_url)?) - .identity("online_checker".to_owned()) - .client_name("online-checker".to_owned()) - .client_version("0.1.0".to_owned()) - .build(); + let connection_options = ConnectionOptions::new( + Url::parse(&target_url).map_err(EphemeralServerError::server_start_error)?, + ) + .identity("online_checker".to_owned()) + .client_name("online-checker".to_owned()) + .client_version("0.1.0".to_owned()) + .build(); // Try to connect every 100ms for 5s // TODO(cretz): Some other way, e.g. via stdout, to know whether the @@ -243,22 +338,28 @@ impl EphemeralServer { // which does not wait for the kill to complete) so it cannot linger // holding inherited stdout/stderr pipes. let _ = child.kill().await; - Err(anyhow!( - "Failed connecting to test server after 5 seconds, last error: {last_error:?}" - )) + Err(EphemeralServerError::StartupTimeout { + target, + timeout: Duration::from_secs(5), + last_error, + }) } /// Shutdown the server (i.e. kill the child process). This does not attempt /// a kill if the child process appears completed, but such a check is not /// atomic so a kill could still fail as completed if completed just before /// kill. - pub async fn shutdown(&mut self) -> anyhow::Result<()> { + pub async fn shutdown(&mut self) -> Result<(), EphemeralServerError> { // Only kill if there is a PID if self.child.id().is_some() { - Ok(self.child.kill().await?) + self.child + .kill() + .await + .map_err(EphemeralServerError::server_shutdown_error)?; } else { - Ok(()) + return Ok(()); } + Ok(()) } /// Get the process ID of the child. This will be None if the process is @@ -324,12 +425,12 @@ impl EphemeralExe { artifact_name: &str, downloaded_name_prefix: &str, preferred_format: Option<&str>, - ) -> anyhow::Result { + ) -> Result { match self { EphemeralExe::ExistingPath(exe_path) => { let path = PathBuf::from(exe_path); if !path.exists() { - return Err(anyhow!("Exe path does not exist")); + return Err(EphemeralServerError::ExecutableNotFound { path }); } Ok(path) } @@ -370,7 +471,11 @@ impl EphemeralExe { let arch = match std::env::consts::ARCH { "x86_64" => "amd64", "arm" | "aarch64" => "arm64", - other => return Err(anyhow!("Unsupported arch: {other}")), + other => { + return Err(EphemeralServerError::invalid_download(format!( + "unsupported architecture: {other}" + ))); + } }; let mut get_info_params = vec![("arch", arch), ("platform", platform)]; if let Some(format) = preferred_format { @@ -396,9 +501,14 @@ impl EphemeralExe { )) .query(&get_info_params) .send() - .await? - .error_for_status()?; - let info: DownloadInfo = resp.json().await?; + .await + .map_err(EphemeralServerError::download_error)? + .error_for_status() + .map_err(EphemeralServerError::download_error)?; + let info: DownloadInfo = resp + .json() + .await + .map_err(EphemeralServerError::download_error)?; // Attempt download, looping because it could have waited for // concurrent one to finish @@ -478,7 +588,7 @@ async fn lazy_download_exe( file_to_extract: &Path, dest: &Path, already_tried_cleaning_old: bool, -) -> anyhow::Result { +) -> Result { // If it already exists, do not extract if dest.exists() { return Ok(true); @@ -488,7 +598,13 @@ async fn lazy_download_exe( // kind of global lock, we'll just create the file eagerly w/ a temp // filename and delete it on failure or move it on success. If the temp file // already exists, we'll wait a bit and re-run this. - let temp_dest_str = format!("{}{}", dest.to_str().unwrap(), ".downloading"); + let Some(dest_str) = dest.to_str() else { + return Err(EphemeralServerError::invalid_download(format!( + "download path is not UTF-8: {}", + dest.display() + ))); + }; + let temp_dest_str = format!("{dest_str}.downloading"); let temp_dest = Path::new(&temp_dest_str); // Try to open file, using a file mode on unix families #[cfg(target_family = "unix")] @@ -514,32 +630,36 @@ async fn lazy_download_exe( loop { let since_progress = match temp_dest.metadata() { Err(_) => return Ok(false), - Ok(meta) => meta.modified()?.elapsed()?.as_secs(), + Ok(meta) => meta + .modified() + .map_err(EphemeralServerError::download_error)? + .elapsed() + .map_err(EphemeralServerError::download_error)? + .as_secs(), }; if since_progress > DOWNLOAD_STALE_SECS { // No progress for a while; assume the downloader was // abandoned. Reclaim it once; if it goes stale again, fail // loudly rather than looping forever. if already_tried_cleaning_old { - return Err(anyhow!( - "Temp download file at {} made no progress for over {} \ - seconds. Make sure another download isn't stuck and \ - delete the temp file.", + return Err(EphemeralServerError::invalid_download(format!( + "temporary file at {} made no progress for over {} seconds", temp_dest.display(), DOWNLOAD_STALE_SECS, - )); + ))); } - std::fs::remove_file(temp_dest)?; + std::fs::remove_file(temp_dest) + .map_err(EphemeralServerError::download_error)?; return Box::pin(lazy_download_exe(client, uri, file_to_extract, dest, true)) .await; } sleep(Duration::from_secs(1)).await; } } - Err(err) => Err(err.into()), + Err(err) => Err(EphemeralServerError::download_error(err)), // If the dest was added since, just remove temp file Ok(_) if dest.exists() => { - std::fs::remove_file(temp_dest)?; + std::fs::remove_file(temp_dest).map_err(EphemeralServerError::download_error)?; return Ok(true); } // Download and extract the binary @@ -560,7 +680,7 @@ async fn lazy_download_exe( } }?; // Now that file should be dropped, we can rename - std::fs::rename(temp_dest, dest)?; + std::fs::rename(temp_dest, dest).map_err(EphemeralServerError::download_error)?; Ok(true) } @@ -569,10 +689,16 @@ async fn download_and_extract( uri: &str, file_to_extract: &Path, dest: &mut std::fs::File, -) -> anyhow::Result<()> { +) -> Result<(), EphemeralServerError> { // Start download. We are using streaming here to extract the file from the // tarball or zip instead of loading into memory for Cursor/Seek. - let resp = client.get(uri).send().await?.error_for_status()?; + let resp = client + .get(uri) + .send() + .await + .map_err(EphemeralServerError::download_error)? + .error_for_status() + .map_err(EphemeralServerError::download_error)?; // We have to map the error type to an io error let stream = resp .bytes_stream() @@ -586,43 +712,89 @@ async fn download_and_extract( } else if uri.ends_with(".zip") { false } else { - return Err(anyhow!("URI not .tar.gz or .zip")); + return Err(EphemeralServerError::invalid_download(format!( + "archive URL has unsupported format: {uri}" + ))); }; let file_to_extract = file_to_extract.to_path_buf(); - let mut dest = dest.try_clone()?; + let mut dest = dest + .try_clone() + .map_err(EphemeralServerError::download_error)?; - spawn_blocking(move || { + spawn_blocking(move || -> Result<(), EphemeralServerError> { if tarball { - for entry in tar::Archive::new(GzDecoder::new(reader)).entries()? { - let mut entry = entry?; - if entry.path()? == file_to_extract { - std::io::copy(&mut entry, &mut dest)?; + for entry in tar::Archive::new(GzDecoder::new(reader)) + .entries() + .map_err(|source| { + EphemeralServerError::invalid_download_with_source( + "could not read tar archive", + source, + ) + })? + { + let mut entry = entry.map_err(|source| { + EphemeralServerError::invalid_download_with_source( + "could not read tar archive entry", + source, + ) + })?; + if entry.path().map_err(|source| { + EphemeralServerError::invalid_download_with_source( + "tar archive entry path is invalid", + source, + ) + })? == file_to_extract + { + std::io::copy(&mut entry, &mut dest).map_err(|source| { + EphemeralServerError::invalid_download_with_source( + "could not extract tar archive entry", + source, + ) + })?; return Ok(()); } } - Err(anyhow!("Unable to find file in tarball")) + Err(EphemeralServerError::invalid_download( + "requested executable was not found in tar archive", + )) } else { loop { // This is the way to stream a zip file without creating an archive // that requires Seek. - if let Some(mut file) = read_zipfile_from_stream(&mut reader)? { + if let Some(mut file) = read_zipfile_from_stream(&mut reader).map_err(|source| { + EphemeralServerError::invalid_download_with_source( + "could not read ZIP archive", + source, + ) + })? { // If this is the file we're expecting, extract it if file.enclosed_name().as_ref() == Some(&file_to_extract) { - std::io::copy(&mut file, &mut dest)?; + std::io::copy(&mut file, &mut dest).map_err(|source| { + EphemeralServerError::invalid_download_with_source( + "could not extract ZIP archive entry", + source, + ) + })?; return Ok(()); } } else { - return Err(anyhow!("Unable to find file in zip")); + return Err(EphemeralServerError::invalid_download( + "requested executable was not found in ZIP archive", + )); } } } }) - .await? + .await + .map_err(EphemeralServerError::download_error)? } /// Remove the file if it's older than the TTL. Returns true if the current file can be re-used, /// returns false if it was removed or should otherwise be re-downloaded. -fn remove_file_past_ttl(ttl: &Option, dest: &PathBuf) -> Result { +fn remove_file_past_ttl( + ttl: &Option, + dest: &PathBuf, +) -> Result { match ttl { None => return Ok(true), Some(ttl) => { @@ -631,7 +803,7 @@ fn remove_file_past_ttl(ttl: &Option, dest: &PathBuf) -> Result) -> WorkflowResult<()> { + Ok(()) + } +} + +#[tokio::test] +async fn test_workflow_environment_local() { + let env = WorkflowEnvironment::start_local(LocalWorkflowEnvironmentOptions::default()) + .await + .unwrap(); + let runtime = Runtime::new_assume_tokio(Default::default()).unwrap(); + let worker_options = WorkerOptions::new(format!("test-env-{}", rand_6_chars())) + .register_workflow::() + .unwrap() + .build(); + let task_queue = worker_options.task_queue.clone(); + let mut worker = Worker::new(&runtime, env.client().clone(), worker_options).unwrap(); + let shutdown = worker.shutdown_handle(); + let handle = env + .client() + .start_workflow( + TestEnvironmentWorkflow::run, + (), + WorkflowStartOptions::new(task_queue, format!("test-env-{}", rand_6_chars())).build(), + ) + .await + .unwrap(); + + let (worker_result, ()) = tokio::join!(worker.run(), async move { + handle.get_result(Default::default()).await.unwrap(); + shutdown(); + }); + worker_result.unwrap(); + env.shutdown().await.unwrap(); +} + #[tokio::test] async fn temporal_cli_default() { let config = TemporalDevServerConfig::builder() diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index c925bfa96..01a57fc07 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -39,6 +39,7 @@ tokio-stream = { version = "0.1", default-features = false } tracing = "0.1" uuid = { version = "1.18", default-features = false, features = ["v4"] } wasmtime = { version = "44", optional = true, features = ["component-model"] } +url = { version = "2.5", optional = true } [dependencies.temporalio-sdk-core] path = "../sdk-core" @@ -76,6 +77,7 @@ examples = ["serde/derive", "dep:serde_json", "envconfig"] wasm-examples = [] wasm-workflows = ["dep:wasmtime"] dynamic-tls = ["temporalio-client/dynamic-tls"] +testing = ["temporalio-sdk-core/ephemeral-server", "dep:url"] [dependencies.serde_json] version = "1" diff --git a/crates/sdk/README.md b/crates/sdk/README.md index d5ff9a191..ef9bed34f 100644 --- a/crates/sdk/README.md +++ b/crates/sdk/README.md @@ -105,6 +105,36 @@ async fn main() -> Result<(), Box> { } ``` +### Testing + +Enable the `testing` feature to run activities directly or start an isolated Temporal CLI dev +server for workflow tests: + +```toml +temporalio-sdk = { version = "0.6", features = ["testing"] } +``` + +Activity test inputs and outputs are ordinary Rust values. Register an activity implementer when +testing an instance activity: + +```rust +let env = ActivityEnvironment::builder() + .register_activities(MyActivities { counter: Default::default() }) + .build(); + +assert_eq!(env.run(MyActivities::greet, "Rust".to_owned()).await?, "Hello, Rust!"); +``` + +Workflow tests can use a local server with the normal client and worker APIs. Local environments +own their server and expose a consuming shutdown method: + +```rust +let env = WorkflowEnvironment::start_local(LocalWorkflowEnvironmentOptions::default()).await?; +let client = env.client().clone(); +// Construct workflow starters and workers with `client`. +env.shutdown().await?; +``` + ## Crate Features The SDK enables a few convenience integrations by default. Users who want a smaller dependency @@ -120,6 +150,8 @@ temporalio-sdk = { version = "0.3", default-features = false, features = ["envco `temporalio_common::telemetry` for serving SDK metrics from a HTTP endpoint. - `otel` - optional. Adds the OpenTelemetry metrics exporter in `temporalio_common::telemetry` for sending SDK metrics to an OpenTelemetry collector. +- `testing` - optional. Adds activity and workflow test environments, including local Temporal CLI + dev-server lifecycle management. ## Workflows in detail diff --git a/crates/sdk/src/activities.rs b/crates/sdk/src/activities.rs index 0ab23bff8..ebea00a14 100644 --- a/crates/sdk/src/activities.rs +++ b/crates/sdk/src/activities.rs @@ -62,6 +62,8 @@ use futures_util::{ future::{BoxFuture, ready}, }; use prost_types::{Duration, Timestamp}; +#[cfg(feature = "testing")] +use std::any::Any; use std::{ collections::HashMap, fmt::Debug, @@ -88,18 +90,112 @@ use temporalio_common::{ use temporalio_sdk_core::Worker as CoreWorker; use tokio_util::sync::CancellationToken; +#[cfg(feature = "testing")] +pub(crate) type ActivityHeartbeatCallback = Arc) + Send + Sync>; + /// Used within activities to get info, heartbeat management etc. #[derive(Clone)] pub struct ActivityContext { - worker: Arc, - client_options: ClientOptions, + backend: ActivityContextBackend, cancellation_token: CancellationToken, heartbeat_details: ActivityHeartbeatDetails, header_fields: HashMap, info: ActivityInfo, } +#[derive(Clone)] +enum ActivityContextBackend { + Worker { + worker: Arc, + client_options: ClientOptions, + }, + #[cfg(feature = "testing")] + Test { + client: Option, + heartbeat_callback: Option, + }, +} + +impl ActivityContextBackend { + async fn record_heartbeat( + &self, + task_token: &[u8], + details: T, + ) -> Result<(), PayloadConversionError> + where + T: TemporalSerializable + 'static, + { + match self { + Self::Worker { + worker, + client_options, + } => { + let details = client_options + .data_converter + .to_payloads(&SerializationContextData::Activity, &details) + .await?; + worker.record_activity_heartbeat(ActivityHeartbeat { + task_token: task_token.to_vec(), + details, + }); + } + #[cfg(feature = "testing")] + Self::Test { + heartbeat_callback, .. + } => { + if let Some(callback) = heartbeat_callback { + callback(Box::new(details)); + } + } + } + Ok(()) + } + + fn client(&self) -> Client { + match self { + Self::Worker { + worker, + client_options, + } => { + let connection = worker.get_client_connection().expect( + "activity context client is unavailable because the worker was not created from a Temporal client", + ); + Client::new(connection, client_options.clone()) + .expect("client construction from a worker connection should be infallible") + } + #[cfg(feature = "testing")] + Self::Test { client, .. } => client + .as_ref() + .expect("ActivityEnvironment was created without a Client. Pass one during construction to have one availalbe at runtime") + .clone(), + } + } +} + impl ActivityContext { + #[cfg(feature = "testing")] + pub(crate) fn new_for_test( + info: ActivityInfo, + header_fields: HashMap, + payload_converter: PayloadConverter, + cancellation_token: CancellationToken, + heartbeat_details: Vec, + client: Option, + heartbeat_callback: Option, + ) -> Self { + let heartbeat_details = ActivityHeartbeatDetails::new(heartbeat_details, payload_converter); + Self { + backend: ActivityContextBackend::Test { + client, + heartbeat_callback, + }, + cancellation_token, + heartbeat_details, + header_fields, + info, + } + } + pub(crate) fn new( worker: Arc, client_options: ClientOptions, @@ -146,8 +242,10 @@ impl ActivityContext { ( ActivityContext { - worker, - client_options, + backend: ActivityContextBackend::Worker { + worker, + client_options, + }, cancellation_token, heartbeat_details, header_fields, @@ -200,15 +298,9 @@ impl ActivityContext { T: TemporalSerializable + 'static, { if !self.info.is_local { - let details = self - .client_options - .data_converter - .to_payloads(&SerializationContextData::Activity, &details) + self.backend + .record_heartbeat(&self.info.task_token, details) .await?; - self.worker.record_activity_heartbeat(ActivityHeartbeat { - task_token: self.info.task_token.clone(), - details, - }) } Ok(()) } @@ -220,12 +312,7 @@ impl ActivityContext { /// Return a client targeting the same Temporal service and namespace as this activity's worker. pub fn client(&self) -> Client { - let connection = self.worker.get_client_connection().expect( - "activity context client is unavailable because the worker was not created from a \ - Temporal client", - ); - Client::new(connection, self.client_options.clone()) - .expect("client construction from a worker connection should be infallible") + self.backend.client() } /// Return a workflow handle for the workflow execution that started this activity, if any. @@ -233,11 +320,12 @@ impl ActivityContext { let workflow_id = self.info.workflow_id.clone()?; let run_id = self.info.workflow_run_id.clone(); let first_execution_run_id = run_id.clone(); + let client = self.client(); Some(WorkflowHandle::new( - self.client(), + client.clone(), WorkflowExecutionInfo { - namespace: self.client_options.namespace.clone(), + namespace: client.options().namespace.clone(), workflow_id, run_id, first_execution_run_id, @@ -425,15 +513,27 @@ fn call_execute_activity<'a>( } } -#[doc(hidden)] +/// Implemented by `#[activities]` for types that provide activity methods. +/// +/// This trait supports registration and direct execution infrastructure. Applications normally +/// use the generated implementation rather than implementing it manually. pub trait ActivityImplementer { + /// Register every activity method implemented by this type. fn register_all(self: Arc, defs: &mut ActivityDefinitions); } -#[doc(hidden)] +/// Direct execution support generated for each activity marker by `#[activities]`. +/// +/// Applications normally use the generated implementation rather than implementing this trait +/// manually. pub trait ExecutableActivity: ActivityDefinition + Sized { + /// Type containing the activity implementation. type Implementer: ActivityImplementer + Send + Sync + 'static; + /// Whether this activity requires an implementation instance. + const REQUIRES_INSTANCE: bool; + /// Return this activity's definition marker. fn definition() -> Self; + /// Execute the activity with already-typed input. fn execute( receiver: Option>, ctx: ActivityContext, @@ -441,9 +541,6 @@ pub trait ExecutableActivity: ActivityDefinition + Sized { ) -> BoxFuture<'static, Result>; } -#[doc(hidden)] -pub trait HasOnlyStaticMethods {} - /// Contains activity registrations in a form ready for execution by workers. #[derive(Default, Clone)] pub struct ActivityDefinitions { diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index f4eb74450..9a43864cd 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -67,6 +67,8 @@ pub mod interceptors; /// Experimental APIs for configuring clients and workers with reusable plugins. pub mod plugins; pub mod runtime; +#[cfg(feature = "testing")] +pub mod testing; mod workflow_executor; mod workflow_future; pub mod workflow_interceptors; diff --git a/crates/sdk/src/testing.rs b/crates/sdk/src/testing.rs new file mode 100644 index 000000000..bbeef3912 --- /dev/null +++ b/crates/sdk/src/testing.rs @@ -0,0 +1,663 @@ +//! Test environments for running activity code and workflow workers. +//! +//! Activity inputs, outputs, and outbound heartbeat details stay typed. Previous heartbeat details +//! are serialized with the configured [`PayloadConverter`]; payload codecs and failure converters +//! are not used by [`ActivityEnvironment`]. +//! +//! ``` +//! use std::sync::Arc; +//! use temporalio_macros::activities; +//! use temporalio_sdk::{ +//! activities::{ActivityContext, ActivityError}, +//! testing::ActivityEnvironment, +//! }; +//! +//! struct GreetingActivities { +//! greeting: String, +//! } +//! +//! #[activities] +//! impl GreetingActivities { +//! #[activity] +//! async fn greet( +//! self: Arc, +//! _ctx: ActivityContext, +//! name: String, +//! ) -> Result { +//! Ok(format!("{}, {name}!", self.greeting)) +//! } +//! } +//! +//! # async fn example() { +//! let env = ActivityEnvironment::builder() +//! .register_activities(GreetingActivities { +//! greeting: "Hello".to_owned(), +//! }) +//! .build(); +//! +//! assert_eq!( +//! env.run(GreetingActivities::greet, "Temporal".to_owned()) +//! .await +//! .unwrap(), +//! "Hello, Temporal!" +//! ); +//! # } +//! ``` +//! +//! [`WorkflowEnvironment::start_local`] owns a Temporal CLI dev server. Its client can be passed +//! to ordinary workflow starters and workers, while the local-server type state makes shutdown +//! available only on environments that own a server. +//! +//! ```no_run +//! use temporalio_sdk::testing::{LocalWorkflowEnvironmentOptions, WorkflowEnvironment}; +//! +//! # async fn example() -> Result<(), Box> { +//! let env = WorkflowEnvironment::start_local(LocalWorkflowEnvironmentOptions::default()).await?; +//! let client = env.client().clone(); +//! // Construct workflow starters and workers with `client`. +//! # drop(client); +//! env.shutdown().await?; +//! # Ok(()) +//! # } +//! ``` + +use crate::activities::{ + ActivityContext, ActivityDefinitions, ActivityError, ActivityHeartbeatCallback, + ActivityImplementer, ActivityInfo, ExecutableActivity, +}; +use std::{ + any::Any, + collections::HashMap, + path::PathBuf, + sync::Arc, + time::{Duration, SystemTime}, +}; +use temporalio_client::{ + Client, ClientOptions, ConnectionOptions, Priority, errors::ClientConnectError, +}; +use temporalio_common::{ + RetryPolicy, + data_converters::{ + GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, + SerializationContextData, TemporalSerializable, + }, + protos::temporal::api::common::v1::Payload, +}; +use tokio_util::sync::CancellationToken; +use url::Url; + +pub use temporalio_sdk_core::ephemeral_server::{ + EphemeralExe, EphemeralExeVersion, EphemeralServerError, +}; +use temporalio_sdk_core::ephemeral_server::{ + EphemeralServer, TemporalDevServerConfig, default_cached_download, +}; + +type ActivityImplementers = HashMap>; + +/// Options for constructing [`ActivityInfo`] with defaults suitable for an activity test. +#[derive(bon::Builder)] +#[builder( + finish_fn(name = build_internal, vis = ""), + state_mod(vis = "pub"), + on(String, into) +)] +pub struct TestActivityInfoOptions { + #[builder(default = b"test".to_vec())] + task_token: Vec, + #[builder(required, default = Some("test".to_owned()))] + workflow_type: Option, + #[builder(default = "default".to_owned())] + namespace: String, + #[builder(required, default = Some("test".to_owned()))] + workflow_id: Option, + #[builder(required, default = Some("test-run".to_owned()))] + workflow_run_id: Option, + #[builder(default = "test".to_owned())] + activity_id: String, + #[builder(default = "unknown".to_owned())] + activity_type: String, + #[builder(default = "test".to_owned())] + task_queue: String, + heartbeat_timeout: Option, + #[builder(required, default = Some(SystemTime::UNIX_EPOCH))] + scheduled_time: Option, + #[builder(required, default = Some(SystemTime::UNIX_EPOCH))] + started_time: Option, + #[builder( + required, + default = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(1)) + )] + deadline: Option, + #[builder(default = 1)] + attempt: u32, + #[builder(required, default = Some(SystemTime::UNIX_EPOCH))] + current_attempt_scheduled_time: Option, + retry_policy: Option, + #[builder(default)] + is_local: bool, + #[builder(default)] + priority: Priority, + activity_run_id: Option, +} + +impl TestActivityInfoOptionsBuilder { + /// Build activity information from these test options. + pub fn build(self) -> ActivityInfo { + self.build_internal().into() + } +} + +impl From for ActivityInfo { + fn from(options: TestActivityInfoOptions) -> Self { + Self { + task_token: options.task_token, + workflow_type: options.workflow_type, + namespace: options.namespace, + workflow_id: options.workflow_id, + workflow_run_id: options.workflow_run_id, + activity_id: options.activity_id, + activity_type: options.activity_type, + task_queue: options.task_queue, + heartbeat_timeout: options.heartbeat_timeout, + scheduled_time: options.scheduled_time, + started_time: options.started_time, + deadline: options.deadline, + attempt: options.attempt, + current_attempt_scheduled_time: options.current_attempt_scheduled_time, + retry_policy: options.retry_policy, + is_local: options.is_local, + priority: options.priority, + activity_run_id: options.activity_run_id, + } + } +} + +/// Environment for running activity code with a test [`ActivityContext`]. +#[derive(bon::Builder)] +#[builder( + start_fn(name = builder_internal, vis = ""), + state_mod(vis = "pub") +)] +pub struct ActivityEnvironment { + #[builder(field)] + heartbeat_callback: Option, + #[builder(field)] + heartbeat_details: Vec, + #[builder(field)] + implementers: ActivityImplementers, + #[builder( + default, + getter(name = payload_converter_ref, vis = ""), + setters(option_fn(vis = "")) + )] + payload_converter: PayloadConverter, + #[builder(default = TestActivityInfoOptions::builder().build())] + info: ActivityInfo, + #[builder(default)] + headers: HashMap, + client: Option, + #[builder(default = CancellationToken::new())] + cancellation_token: CancellationToken, +} + +impl ActivityEnvironmentBuilder { + /// Register all activities implemented by an instance. + pub fn register_activities(mut self, instance: AI) -> Self + where + AI: ActivityImplementer + Send + Sync + 'static, + { + let instance = Arc::new(instance); + let mut definitions = ActivityDefinitions::default(); + AI::register_all(instance.clone(), &mut definitions); + let instance: Arc = instance; + for activity_type in definitions.names() { + self.implementers.insert(activity_type, instance.clone()); + } + self + } + + /// Observe the typed details supplied to every heartbeat. + pub fn on_heartbeat(mut self, callback: F) -> Self + where + F: Fn(Box) + Send + Sync + 'static, + { + self.heartbeat_callback = Some(Arc::new(callback)); + self + } +} + +impl ActivityEnvironmentBuilder +where + S: activity_environment_builder::State, + S::PayloadConverter: activity_environment_builder::IsSet, +{ + /// Supply heartbeat details from an activity attempt. + /// + /// Accessible via [`ActivityContext::heartbeat_details`]. + pub fn heartbeat_details(mut self, details: T) -> Result + where + T: TemporalSerializable + 'static, + { + let payload_converter = self + .payload_converter_ref() + .expect("payload converter must be set in builder state"); + let context = SerializationContext { + data: &SerializationContextData::Activity, + converter: payload_converter, + }; + self.heartbeat_details = payload_converter.to_payloads(&context, &details)?; + Ok(self) + } +} + +impl ActivityEnvironment { + /// Construct an activity environment builder. + pub fn builder() -> ActivityEnvironmentBuilder { + Self::builder_internal() + } + + /// Construct an activity environment builder using the default payload converter. + pub fn builder_with_default() + -> ActivityEnvironmentBuilder { + Self::builder_internal().payload_converter(PayloadConverter::default()) + } + + /// Run an activity. + pub async fn run( + &self, + activity: A, + input: A::Input, + ) -> Result + where + A: ExecutableActivity, + { + let receiver = if A::REQUIRES_INSTANCE { + let activity_type = activity.name(); + let implementer = self + .implementers + .get(activity_type) + .cloned() + .and_then(|instance| Arc::downcast::(instance).ok()) + .ok_or_else(|| ActivityEnvironmentError::MissingImplementer { + activity_type: activity_type.to_owned(), + })?; + Some(implementer) + } else { + None + }; + let context = ActivityContext::new_for_test( + self.info.clone(), + self.headers.clone(), + self.payload_converter.clone(), + self.cancellation_token.clone(), + self.heartbeat_details.clone(), + self.client.clone(), + self.heartbeat_callback.clone(), + ); + A::execute(receiver, context, input) + .await + .map_err(ActivityEnvironmentError::Activity) + } + + /// Cancel activity contexts created by this environment. + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } +} + +/// Errors produced while running an activity in a test environment. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ActivityEnvironmentError { + /// An instance activity was run without registering its implementer. + #[error("activity `{activity_type}` requires an instance in order to execute")] + MissingImplementer { + /// Activity type that could not be run. + activity_type: String, + }, + /// The activity returned an error. + #[error("activity execution failed: {0:?}")] + Activity(ActivityError), +} + +/// Temporal CLI output format for a local workflow environment. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, derive_more::Display)] +#[non_exhaustive] +pub enum DevServerLogFormat { + /// Human-readable text output. + #[default] + #[display("text")] + Text, + /// JSON output. + #[display("json")] + Json, +} + +/// Temporal CLI logging level for a local workflow environment. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, derive_more::Display)] +#[non_exhaustive] +pub enum DevServerLogLevel { + /// Debug and higher-severity messages. + #[display("debug")] + Debug, + /// Informational and higher-severity messages. + #[display("info")] + Info, + /// Warning and higher-severity messages. + #[default] + #[display("warn")] + Warn, + /// Error messages only. + #[display("error")] + Error, + /// Disable logging. + #[display("never")] + Never, +} + +/// Configuration for a local Temporal CLI dev server and its client. +#[derive(Debug, Clone, bon::Builder)] +#[builder(state_mod(vis = "pub"))] +#[non_exhaustive] +pub struct LocalWorkflowEnvironmentOptions { + /// Options used to create the namespace-bound client. + #[builder(default = ClientOptions::new("default").build())] + pub client_options: ClientOptions, + /// Existing or downloadable Temporal CLI executable. + #[builder(default = default_cached_download())] + pub server_executable: EphemeralExe, + /// Fixed frontend port, or an OS-selected port when absent. + pub port: Option, + /// Whether to start the Temporal UI. + #[builder(default)] + pub ui: bool, + /// Fixed UI port, or the server default when absent. + pub ui_port: Option, + /// SQLite database path, or in-memory storage when absent. + pub database_filename: Option, + /// Dev server log format. + #[builder(default)] + pub log_format: DevServerLogFormat, + /// Dev server log level. + #[builder(default)] + pub log_level: DevServerLogLevel, + /// Additional arguments appended to the Temporal CLI invocation. + #[builder(default)] + pub extra_args: Vec, +} + +impl Default for LocalWorkflowEnvironmentOptions { + fn default() -> Self { + Self::builder().build() + } +} + +/// State for a workflow environment backed by an externally managed server. +#[derive(Debug)] +#[non_exhaustive] +pub struct ExternalServer { + _private: (), +} + +/// State for a workflow environment that starts a local dev server. +#[derive(Debug)] +#[non_exhaustive] +pub struct LocalServer { + server: EphemeralServer, +} + +/// Client environment for workflow tests, parameterized by server ownership. +#[derive(Debug)] +#[non_exhaustive] +pub struct WorkflowEnvironment { + client: Client, + state: S, +} + +impl WorkflowEnvironment { + /// Return the client used by workflow starters and workers in this environment. + pub fn client(&self) -> &Client { + &self.client + } +} + +impl WorkflowEnvironment { + /// Wrap a client connected to an externally managed Temporal server. + pub fn from_client(client: Client) -> Self { + Self { + client, + state: ExternalServer { _private: () }, + } + } +} + +impl WorkflowEnvironment { + /// Start a local Temporal CLI dev server and connect a client to it. + pub async fn start_local( + options: LocalWorkflowEnvironmentOptions, + ) -> Result { + let database_filename = options + .database_filename + .map(|path| { + path.into_os_string().into_string().map_err(|path| { + WorkflowEnvironmentError::InvalidDatabasePath { + path: PathBuf::from(path), + } + }) + }) + .transpose()?; + let server_config = TemporalDevServerConfig::builder() + .exe(options.server_executable) + .namespace(options.client_options.namespace.clone()) + .maybe_port(options.port) + .ui(options.ui) + .maybe_ui_port(options.ui_port) + .maybe_db_filename(database_filename) + .log(( + options.log_format.to_string(), + options.log_level.to_string(), + )) + .extra_args(options.extra_args) + .build(); + let mut server = server_config + .start_server() + .await + .map_err(WorkflowEnvironmentError::ServerStart)?; + let target = Url::parse(&format!("http://{}", server.target)) + .map_err(WorkflowEnvironmentError::InvalidServerTarget)?; + let connection_options = ConnectionOptions::new(target) + .identity("temporalio-sdk-testing".to_owned()) + .client_name("temporalio-sdk".to_owned()) + .client_version(env!("CARGO_PKG_VERSION").to_owned()) + .build(); + let client = match Client::connect(connection_options, options.client_options).await { + Ok(client) => client, + Err(connect) => { + return match server.shutdown().await { + Ok(()) => Err(WorkflowEnvironmentError::ClientConnect(connect)), + Err(shutdown) => Err(WorkflowEnvironmentError::ClientConnectAndShutdown { + connect: Box::new(connect), + shutdown: Box::new(shutdown), + }), + }; + } + }; + Ok(Self { + client, + state: LocalServer { server }, + }) + } + + /// Shut down the local server owned by this environment. + pub async fn shutdown(mut self) -> Result<(), WorkflowEnvironmentError> { + self.state + .server + .shutdown() + .await + .map_err(WorkflowEnvironmentError::ServerShutdown) + } +} + +/// Errors produced while creating or shutting down a workflow test environment. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WorkflowEnvironmentError { + /// The local server could not be started. + #[error("failed to start local Temporal server: {0}")] + ServerStart(#[source] EphemeralServerError), + /// A client could not connect to the newly started server. + #[error("failed to connect client to local Temporal server: {0}")] + ClientConnect(#[source] ClientConnectError), + /// Client connection and subsequent server cleanup both failed. + #[error("failed to connect client ({connect}) and shut down local server ({shutdown})")] + ClientConnectAndShutdown { + /// Client connection failure. + connect: Box, + /// Server cleanup failure. + shutdown: Box, + }, + /// Explicit local server shutdown failed. + #[error("failed to shut down local Temporal server: {0}")] + ServerShutdown(#[source] EphemeralServerError), + /// The local server target could not be represented as a URL. + #[error("invalid local Temporal server target: {0}")] + InvalidServerTarget(#[source] url::ParseError), + /// The configured database path was not valid UTF-8 for the Temporal CLI. + #[error("local Temporal database path is not valid UTF-8: {}", path.display())] + InvalidDatabasePath { + /// Invalid database path. + path: PathBuf, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use temporalio_macros::activities; + + struct TestActivities { + prefix: String, + } + + #[activities] + impl TestActivities { + #[activity] + async fn echo(_ctx: ActivityContext, value: String) -> Result { + Ok(value) + } + + #[activity] + async fn prefixed( + self: Arc, + _ctx: ActivityContext, + value: String, + ) -> Result { + Ok(format!("{}{}", self.prefix, value)) + } + + #[activity] + async fn heartbeat(ctx: ActivityContext, increment: u32) -> Result { + let previous = ctx.heartbeat_details().deserialize::()?.unwrap_or(0); + ctx.record_heartbeat(previous + increment).await?; + Ok(previous) + } + + #[activity] + async fn cancellation_state(ctx: ActivityContext) -> Result { + Ok(ctx.is_cancelled()) + } + } + + struct StaticActivities; + + #[activities] + impl StaticActivities { + #[activity] + async fn echo(_ctx: ActivityContext, value: String) -> Result { + Ok(format!("static:{value}")) + } + } + + #[tokio::test] + async fn runs_static_activities_without_instance() { + let env = ActivityEnvironment::builder().build(); + + assert_eq!( + env.run(StaticActivities::echo, "value".to_owned()) + .await + .unwrap(), + "static:value" + ); + } + + #[tokio::test] + async fn runs_activities_with_instance() { + let env = ActivityEnvironment::builder() + .register_activities(TestActivities { + prefix: "pre:".to_owned(), + }) + .build(); + + assert_eq!( + env.run(TestActivities::echo, "value".to_owned()) + .await + .unwrap(), + "value" + ); + assert_eq!( + env.run(TestActivities::prefixed, "value".to_owned()) + .await + .unwrap(), + "pre:value" + ); + } + + #[tokio::test] + async fn missing_instance_is_an_environment_error() { + let error = ActivityEnvironment::builder() + .build() + .run(TestActivities::prefixed, "value".to_owned()) + .await + .unwrap_err(); + + assert!(matches!( + error, + ActivityEnvironmentError::MissingImplementer { .. } + )); + } + + #[tokio::test] + async fn converts_previous_and_observes_typed_outbound_heartbeat_details() { + let heartbeats = Arc::new(Mutex::new(Vec::new())); + let env = ActivityEnvironment::builder_with_default() + .heartbeat_details(4_u32) + .unwrap() + .on_heartbeat({ + let heartbeats = heartbeats.clone(); + move |details| { + let details = details + .downcast::() + .expect("heartbeat details should retain their concrete type"); + heartbeats.lock().unwrap().push(*details); + } + }) + .build(); + + assert_eq!(env.run(TestActivities::heartbeat, 3).await.unwrap(), 4); + assert_eq!(heartbeats.lock().unwrap().pop(), Some(7)); + } + + #[tokio::test] + async fn cancel_affects_contexts_created_by_environment() { + let env = ActivityEnvironment::builder().build(); + env.cancel(); + + assert!( + env.run(TestActivities::cancellation_state, ()) + .await + .unwrap() + ); + } +} From 46c50fc8540fd3b1a1f1e02ea2fa1291f8ec0c71 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 17 Aug 2026 15:32:54 -0400 Subject: [PATCH 09/87] chore(sdk): release 0.7.0 (#1494) * chore(sdk): release 0.7.0 * Apply suggestions from code review Co-authored-by: Chris Olszewski --- CHANGELOG.md | 2 ++ crates/client/Cargo.toml | 4 ++-- crates/common-wasm/Cargo.toml | 4 ++-- crates/common/Cargo.toml | 6 +++--- crates/macros/Cargo.toml | 2 +- crates/protos/Cargo.toml | 2 +- crates/sdk-core-c-bridge/Cargo.toml | 6 +++--- crates/sdk-core/Cargo.toml | 10 +++++----- crates/sdk/Cargo.toml | 12 ++++++------ crates/workflow/Cargo.toml | 6 +++--- 10 files changed, 28 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1acdafb6c..4a29315a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ relevant information. ## Unreleased +## [0.7.0] - 2026-08-17 + ### Added * Support for running Standalone Activities in Rust SDK Worker. * Client methods for starting and managing execution of Standalone Activities. diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 7659bf876..4ecccda18 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-client" -version = "0.6.0" +version = "0.7.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -57,7 +57,7 @@ serde_json = { workspace = true } [dependencies.temporalio-common] path = "../common" -version = "0.6" +version = "0.7" default-features = false features = ["serde_serialize"] diff --git a/crates/common-wasm/Cargo.toml b/crates/common-wasm/Cargo.toml index 488c56e4f..6104bc73a 100644 --- a/crates/common-wasm/Cargo.toml +++ b/crates/common-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-common-wasm" -version = "0.6.0" +version = "0.7.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -45,7 +45,7 @@ tracing-core = "0.1" url = "2.5" [dependencies.temporalio-protos] path = "../protos" -version = "0.6" +version = "0.7" [lints] workspace = true diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index d09874739..c8bea1bf9 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-common" -version = "0.6.0" +version = "0.7.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -106,12 +106,12 @@ uuid = { version = "1.18", default-features = false, features = ["v4"] } [dependencies.temporalio-protos] path = "../protos" -version = "0.6" +version = "0.7" features = ["grpc-clients"] [dependencies.temporalio-common-wasm] path = "../common-wasm" -version = "0.6" +version = "0.7" [build-dependencies] prost = { workspace = true } diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index a4ec61de9..39b576029 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-macros" -version = "0.6.0" +version = "0.7.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } diff --git a/crates/protos/Cargo.toml b/crates/protos/Cargo.toml index 8e8485c82..ec7b15bed 100644 --- a/crates/protos/Cargo.toml +++ b/crates/protos/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-protos" -version = "0.6.0" +version = "0.7.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } diff --git a/crates/sdk-core-c-bridge/Cargo.toml b/crates/sdk-core-c-bridge/Cargo.toml index 6506823b2..b8e694e14 100644 --- a/crates/sdk-core-c-bridge/Cargo.toml +++ b/crates/sdk-core-c-bridge/Cargo.toml @@ -41,16 +41,16 @@ xz2 = { version = "0.1", optional = true } [dependencies.temporalio-client] path = "../client" -version = "0.6" +version = "0.7" [dependencies.temporalio-sdk-core] path = "../sdk-core" -version = "0.6" +version = "0.7" features = ["ephemeral-server", "otel"] [dependencies.temporalio-common] path = "../common" -version = "0.6" +version = "0.7" features = ["core-based-sdk", "otel"] [dev-dependencies] diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index 1348290e7..49c0195c3 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-sdk-core" -version = "0.6.0" +version = "0.7.0" authors = ["Temporal Technologies Inc. "] edition = "2024" license-file = { workspace = true } @@ -131,19 +131,19 @@ zip = { version = "8.4", optional = true, default-features = false, features = [ # 1st party local deps [dependencies.temporalio-common] path = "../common" -version = "0.6" +version = "0.7" default-features = false features = ["core-telemetry-bridge", "serde_serialize"] [dependencies.temporalio-client] path = "../client" -version = "0.6" +version = "0.7" default-features = false features = ["core-based-sdk"] [dependencies.temporalio-macros] path = "../macros" -version = "0.6" +version = "0.7" [dev-dependencies] assert_matches = "1.5" @@ -164,7 +164,7 @@ hyper-util = { version = "0.1", features = [ rstest = "0.26" semver = "1.0" temporalio-sdk = { path = "../sdk", features = ["testing", "wasm-workflows"] } -temporalio-common = { path = "../common", version = "0.6", default-features = false } +temporalio-common = { path = "../common", version = "0.7", default-features = false } temporalio-workflow = { path = "../workflow" } tokio = { version = "1.47", default-features = false, features = [ "rt", diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 01a57fc07..22e5584cb 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-sdk" -version = "0.6.0" +version = "0.7.0" edition = "2024" authors = ["Spencer Judge "] license-file = { workspace = true } @@ -43,26 +43,26 @@ url = { version = "2.5", optional = true } [dependencies.temporalio-sdk-core] path = "../sdk-core" -version = "0.6" +version = "0.7" default-features = false [dependencies.temporalio-workflow] path = "../workflow" -version = "0.6" +version = "0.7" [dependencies.temporalio-common] path = "../common" -version = "0.6" +version = "0.7" default-features = false [dependencies.temporalio-client] path = "../client" -version = "0.6" +version = "0.7" default-features = false [dependencies.temporalio-macros] path = "../macros" -version = "0.6" +version = "0.7" [dev-dependencies] futures = "0.3" diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index 89bbdaa1c..89c572a17 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-workflow" -version = "0.6.0" +version = "0.7.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -33,11 +33,11 @@ wit-bindgen = { version = "0.57.1", default-features = false, features = ["macro [dependencies.temporalio-common-wasm] path = "../common-wasm" -version = "0.6" +version = "0.7" [dependencies.temporalio-macros] path = "../macros" -version = "0.6" +version = "0.7" [dev-dependencies] rstest = "0.26" From 86d5b8e49ec4ac0d9ac513593b0cd6c2454d81b0 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Tue, 18 Aug 2026 16:05:04 -0700 Subject: [PATCH 10/87] Expose cancel details on workflow cancel & propagate them in Rust SDK (#1501) --- CHANGELOG.md | 4 + .../workflow_commands/workflow_commands.proto | 4 +- crates/protos/src/protos/mod.rs | 4 +- crates/sdk-core/CHANGELOG.md | 2 + .../src/core_tests/workflow_cancels.rs | 2 +- .../src/worker/workflow/managed_run.rs | 4 +- .../tests/integ_tests/metrics_tests.rs | 2 +- .../integ_tests/workflow_tests/cancel_wf.rs | 155 +++++++++++++++++- .../workflow_tests/child_workflows.rs | 14 +- .../tests/integ_tests/workflow_tests/nexus.rs | 6 +- crates/sdk/src/workflow_future.rs | 4 +- crates/sdk/src/workflow_wasm.rs | 4 +- crates/workflow/src/component.rs | 6 +- crates/workflow/src/runtime/instance.rs | 31 +++- crates/workflow/src/runtime/model.rs | 29 +++- crates/workflow/src/runtime/types.rs | 7 +- crates/workflow/src/workflow_interceptors.rs | 19 +++ crates/workflow/wit/types.wit | 2 +- 18 files changed, 261 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a29315a6..fa5ab4867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ relevant information. ### Added * Support for running Standalone Activities in Rust SDK Worker. * Client methods for starting and managing execution of Standalone Activities. +* `WorkflowTermination::cancelled_with_details` for recording structured details when a Workflow + Execution completes as cancelled. * `LoggerFormat` for selecting compact, pretty, or JSON Core console log output. Configured log filters continue to apply to JSON output. * The Rust SDK now has an optional `testing` feature with a typed activity test environment and @@ -78,6 +80,8 @@ relevant information. `WorkerInterceptor::with_workflow_replay_worker`. ### Breaking Changes :boom: +* `WorkflowTermination::Cancelled` now has an optional `details` field. Use + `WorkflowTermination::cancelled()` to construct a cancellation without details. * Changes to `ActivityInfo`: instead of `workflow_namespace`, `workflow_execution` and `run_id`, there is now `namespace`, `workflow_id`, `workflow_run_id` and `activity_run_id`. Also, `workflow_type` is now `Option`. diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto index 9d19defc3..ac4a61664 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto @@ -231,7 +231,9 @@ message ContinueAsNewWorkflowExecution { // Indicate a workflow has completed as cancelled. Generally sent as a response to an activation // containing a cancellation job. -message CancelWorkflowExecution {} +message CancelWorkflowExecution { + temporal.api.common.v1.Payloads details = 1; +} // A request to set/check if a certain patch is present or not message SetPatchMarker { diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index c02672578..7c349ece2 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -2021,9 +2021,9 @@ pub mod temporal { } impl From for command::Attributes { - fn from(_c: workflow_commands::CancelWorkflowExecution) -> Self { + fn from(c: workflow_commands::CancelWorkflowExecution) -> Self { Self::CancelWorkflowExecutionCommandAttributes( - CancelWorkflowExecutionCommandAttributes { details: None }, + CancelWorkflowExecutionCommandAttributes { details: c.details }, ) } } diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index a7cf8d06a..b9747777b 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -36,6 +36,8 @@ relevant information. ### Added * Core console logs can now be emitted as newline-delimited JSON when an SDK selects the JSON log format. Configured log filters continue to apply to JSON output. +* Workflow completion-as-cancelled commands can now carry details for recording on the terminal + history event. * Worker heartbeats now report the SDK runtime, hosting environments, operating system, and architecture once per worker, retrying until the first successful delivery. Runtime options can disable the reporting. diff --git a/crates/sdk-core/src/core_tests/workflow_cancels.rs b/crates/sdk-core/src/core_tests/workflow_cancels.rs index 197f70c37..a5390ce82 100644 --- a/crates/sdk-core/src/core_tests/workflow_cancels.rs +++ b/crates/sdk-core/src/core_tests/workflow_cancels.rs @@ -117,7 +117,7 @@ async fn immediate_cancel() { workflow_activation_job::Variant::InitializeWorkflow(_), workflow_activation_job::Variant::CancelWorkflow(_) ), - vec![CancelWorkflowExecution {}.into()], + vec![CancelWorkflowExecution::default().into()], )], ) .await; diff --git a/crates/sdk-core/src/worker/workflow/managed_run.rs b/crates/sdk-core/src/worker/workflow/managed_run.rs index a15dd2388..603b38acc 100644 --- a/crates/sdk-core/src/worker/workflow/managed_run.rs +++ b/crates/sdk-core/src/worker/workflow/managed_run.rs @@ -1910,7 +1910,9 @@ mod tests { } pub(crate) fn cancel() -> WFCommand { - WFCommand::new(WFCommandVariant::CancelWorkflow(CancelWorkflowExecution {})) + WFCommand::new(WFCommandVariant::CancelWorkflow( + CancelWorkflowExecution::default(), + )) } pub(crate) fn query_response() -> WFCommand { diff --git a/crates/sdk-core/tests/integ_tests/metrics_tests.rs b/crates/sdk-core/tests/integ_tests/metrics_tests.rs index 5da4859da..7039e8b87 100644 --- a/crates/sdk-core/tests/integ_tests/metrics_tests.rs +++ b/crates/sdk-core/tests/integ_tests/metrics_tests.rs @@ -580,7 +580,7 @@ async fn query_of_closed_workflow_doesnt_tick_terminal_metric( failure: Some(Failure::application_failure("I'm ded".to_string(), false)), }.into(), ContinueAsNewWorkflowExecution::default().into(), - CancelWorkflowExecution { }.into() + CancelWorkflowExecution::default().into() )] completion: workflow_command::Variant, ) { diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_wf.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_wf.rs index 9e0f40cf9..c57ad32e5 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_wf.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_wf.rs @@ -38,6 +38,67 @@ impl CancelledWf { } } +#[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)] +struct CancellationDetails { + reason: String, +} + +#[workflow] +#[derive(Default)] +struct CancelledWithDetailsWf; + +#[workflow_methods] +impl CancelledWithDetailsWf { + #[run] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + ctx.cancelled().await; + Err(WorkflowTermination::cancelled_with_details( + CancellationDetails { + reason: "contract expired".to_owned(), + }, + )) + } +} + +#[tokio::test] +async fn workflow_cancellation_records_details() { + let wf_name = "workflow_cancellation_records_details"; + let mut starter = CoreWfStarter::new(wf_name); + starter + .sdk_config + .register_workflow::() + .unwrap(); + let mut worker = starter.worker().await; + let task_queue = starter.get_task_queue().to_owned(); + let wf_handle = worker + .submit_workflow( + CancelledWithDetailsWf::run, + (), + WorkflowStartOptions::new(task_queue, wf_name).build(), + ) + .await + .unwrap(); + + let (cancel_result, worker_result) = tokio::join!( + wf_handle.cancel(WorkflowCancelOptions::default()), + worker.run_until_done() + ); + cancel_result.unwrap(); + worker_result.unwrap(); + + let Err(WorkflowGetResultError::Cancelled { details }) = + wf_handle.get_result(Default::default()).await + else { + panic!("workflow should complete as cancelled"); + }; + assert_eq!( + details.deserialize::().unwrap(), + CancellationDetails { + reason: "contract expired".to_owned(), + } + ); +} + #[tokio::test] async fn cancel_during_timer() { let wf_name = "cancel_during_timer"; @@ -155,13 +216,88 @@ impl CancellationPropagationActivities { let mut heartbeat = tokio::time::interval(Duration::from_millis(100)); loop { tokio::select! { - _ = ctx.cancelled() => return Err(ActivityError::cancelled()), + _ = ctx.cancelled() => return Err(ActivityError::cancelled_with_details( + CancellationDetails { + reason: "operation cancelled".to_owned(), + }, + )), _ = heartbeat.tick() => ctx.record_heartbeat(()).await?, } } } } +#[workflow] +#[derive(Default)] +struct FailedCancellationDetailsWf; + +#[workflow_methods] +impl FailedCancellationDetailsWf { + #[run] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + let err = ctx + .execute_activity( + CancellationPropagationActivities::wait_for_cancellation, + (), + ActivityOptions::with_start_to_close_timeout(Duration::from_secs(30)) + .heartbeat_timeout(Duration::from_secs(1)) + .cancellation_type(ActivityCancellationType::WaitCancellationCompleted) + .build(), + ) + .await + .expect_err("activity should be cancelled"); + Err(err.into()) + } +} + +#[tokio::test] +async fn workflow_failed_cancellation_propagates_details() { + let wf_name = "workflow_failed_cancellation_propagates_details"; + let mut starter = CoreWfStarter::new(wf_name); + let started = Arc::new(Semaphore::new(0)); + starter + .sdk_config + .register_activities(CancellationPropagationActivities { + started: started.clone(), + }); + starter + .sdk_config + .register_workflow::() + .unwrap(); + let mut worker = starter.worker().await; + let task_queue = starter.get_task_queue().to_owned(); + let wf_handle = worker + .submit_workflow( + FailedCancellationDetailsWf::run, + (), + WorkflowStartOptions::new(task_queue, wf_name).build(), + ) + .await + .unwrap(); + + let canceller = async { + let _started = started.acquire().await.unwrap(); + wf_handle + .cancel(WorkflowCancelOptions::default()) + .await + .unwrap(); + }; + let (_, worker_result) = tokio::join!(canceller, worker.run_until_done()); + worker_result.unwrap(); + + let Err(WorkflowGetResultError::Cancelled { details }) = + wf_handle.get_result(Default::default()).await + else { + panic!("workflow should complete as cancelled"); + }; + assert_eq!( + details.deserialize::().unwrap(), + CancellationDetails { + reason: "operation cancelled".to_owned(), + } + ); +} + #[workflow] struct CancellationPropagationChild { started: Arc, @@ -173,7 +309,7 @@ impl CancellationPropagationChild { async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { ctx.state(|wf| wf.started.add_permits(1)); ctx.cancelled().await; - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } #[signal] @@ -304,9 +440,16 @@ async fn workflow_cancellation_propagates_to_operations() { let (_, worker_result) = tokio::join!(canceller, worker.run_until_done()); worker_result.unwrap(); - assert_matches!( - wf_handle.get_result(Default::default()).await, - Err(WorkflowGetResultError::Cancelled { .. }) + let Err(WorkflowGetResultError::Cancelled { details }) = + wf_handle.get_result(Default::default()).await + else { + panic!("workflow should complete as cancelled"); + }; + assert_eq!( + details.deserialize::().unwrap(), + CancellationDetails { + reason: "operation cancelled".to_owned(), + } ); } @@ -319,7 +462,7 @@ impl WfWithTimer { #[run(name = DEFAULT_WORKFLOW_TYPE)] async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { ctx.timer(Duration::from_millis(500)).await; - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } } diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs index 8a37f5b04..07a1ceb1f 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs @@ -146,7 +146,7 @@ impl AbandonedChildBugReproChild { #[run(name = "child_wf")] async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { ctx.cancelled().await; - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } } @@ -567,7 +567,7 @@ impl GrandchildCancelled { #[run(name = "grandchild_wf")] async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { ctx.cancelled().await; - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } } @@ -1100,7 +1100,7 @@ async fn cancel_child_before_started_event() { reason: "parent cancelled".to_string(), } .into(), - CancelWorkflowExecution {}.into(), + CancelWorkflowExecution::default().into(), ], )) .await @@ -1110,7 +1110,7 @@ async fn cancel_child_before_started_event() { let act = core.poll_workflow_activation().await.unwrap(); core.complete_workflow_activation(WorkflowActivationCompletion::from_cmd( act.run_id, - CancelWorkflowExecution {}.into(), + CancelWorkflowExecution::default().into(), )) .await .unwrap(); @@ -1149,7 +1149,7 @@ impl CancelChildBeforeStartedCannedWf { }; assert!(cancelled.raw_details().is_none()); assert!(cancelled.cause().is_none()); - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } } @@ -1189,7 +1189,7 @@ impl CancelChildBeforeStartedParent { // Wait for parent cancellation ctx.cancelled().await; started.cancel(); - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } } @@ -1344,7 +1344,7 @@ impl UnserializableSignalChild { #[run] async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { ctx.cancelled().await; - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } #[signal] diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs index 34dd25f85..256a0ae05 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs @@ -301,7 +301,7 @@ impl AsyncCompleter { Outcome::Succeed => Ok("completed async".to_string()), Outcome::Cancel | Outcome::CancelAfterRecordedBeforeStarted => { ctx.cancelled().await; - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } _ => Err(ApplicationFailure::new("broken").into()), } @@ -629,7 +629,7 @@ impl NexusRootCancellationWf { result.status, Some(nexus_operation_result::Status::Cancelled(_)) ); - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } } @@ -936,7 +936,7 @@ impl AsyncCompleterWf { } ctx.state(|wf| wf.handler_exited_tx.send(true).unwrap()); - Err(WorkflowTermination::Cancelled) + Err(WorkflowTermination::cancelled()) } #[signal(name = "proceed-to-exit")] diff --git a/crates/sdk/src/workflow_future.rs b/crates/sdk/src/workflow_future.rs index 13c5c4751..d6920870d 100644 --- a/crates/sdk/src/workflow_future.rs +++ b/crates/sdk/src/workflow_future.rs @@ -584,10 +584,10 @@ impl WorkflowFuture { ), ); } - TerminalOutcome::Cancelled => { + TerminalOutcome::Cancelled(details) => { self.host.push_command_variant( workflow_command::Variant::CancelWorkflowExecution( - CancelWorkflowExecution {}, + CancelWorkflowExecution { details }, ), ); } diff --git a/crates/sdk/src/workflow_wasm.rs b/crates/sdk/src/workflow_wasm.rs index a936fee69..fd6032bde 100644 --- a/crates/sdk/src/workflow_wasm.rs +++ b/crates/sdk/src/workflow_wasm.rs @@ -341,7 +341,9 @@ impl WorkflowInstance for WasmWorkflowInstance { wit_types::TerminalOutcome::Failed(failure) => { TerminalOutcome::Failed(convert_failure(failure)) } - wit_types::TerminalOutcome::Cancelled => TerminalOutcome::Cancelled, + wit_types::TerminalOutcome::Cancelled(details) => { + TerminalOutcome::Cancelled(details.map(decode_proto)) + } wit_types::TerminalOutcome::ContinueAsNew(req) => { TerminalOutcome::ContinueAsNew(Box::new(decode_proto(req))) } diff --git a/crates/workflow/src/component.rs b/crates/workflow/src/component.rs index 6e3e28029..b7663945d 100644 --- a/crates/workflow/src/component.rs +++ b/crates/workflow/src/component.rs @@ -154,8 +154,10 @@ impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { TerminalOutcome::Failed(failure) => { wit_types::TerminalOutcome::Failed(failure.encode_to_vec()) } - TerminalOutcome::Cancelled => { - wit_types::TerminalOutcome::Cancelled + TerminalOutcome::Cancelled(details) => { + wit_types::TerminalOutcome::Cancelled( + details.map(|details| details.encode_to_vec()), + ) } TerminalOutcome::ContinueAsNew(req) => { wit_types::TerminalOutcome::ContinueAsNew( diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index 101ca20c7..5de12aab7 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -19,7 +19,7 @@ use crate::{ HandleSignalInput, HandleSignalResult, HandleUpdateInput, HandleUpdateResult, InitializeWorkflowInput, InitializeWorkflowOutput, SyncWorkflowInterceptorContext, ValidateUpdateInput, ValidateUpdateResult, WorkflowInterceptor, WorkflowInterceptorContext, - WorkflowInterceptorFuture, WorkflowNext, serialize_workflow_output, + WorkflowInterceptorFuture, WorkflowNext, WorkflowOutputValue, serialize_workflow_output, wrong_workflow_input_type, }, }; @@ -767,7 +767,26 @@ where match result { Ok(result) => Ok(TerminalOutcome::Completed(result)), Err(WorkflowTermination::ContinueAsNew(req)) => Ok(TerminalOutcome::ContinueAsNew(req)), - Err(WorkflowTermination::Cancelled) => Ok(TerminalOutcome::Cancelled), + Err(WorkflowTermination::Cancelled { details }) => { + let details = details + .map(|details| { + (&*details as &dyn WorkflowOutputValue) + .serialize_payloads(&SerializationContext { + data: &SerializationContextData::Workflow, + converter: self.ctx.payload_converter(), + }) + .map(|payloads| Payloads { payloads }) + }) + .transpose() + .map_err(|err| TaskFailure { + failure: Box::new(Failure { + message: format!("Workflow payload conversion failed: {err}"), + ..Default::default() + }), + force_cause: None, + })?; + Ok(TerminalOutcome::Cancelled(details)) + } Err(WorkflowTermination::Evicted) => { panic!("workflow instances must not explicitly return eviction") } @@ -781,9 +800,13 @@ where }) } Err(WorkflowTermination::Failed(err)) => { - if self.base_ctx.cancellation_token().is_cancelled() && err.as_cancelled().is_some() + if self.base_ctx.cancellation_token().is_cancelled() + && let Some(cancelled) = err.as_cancelled() { - return Ok(TerminalOutcome::Cancelled); + let details = cancelled.raw_details().map(|payloads| Payloads { + payloads: payloads.to_vec(), + }); + return Ok(TerminalOutcome::Cancelled(details)); } let failure = self.base_ctx.data_converter().to_failure( &SerializationContextData::Workflow, diff --git a/crates/workflow/src/runtime/model.rs b/crates/workflow/src/runtime/model.rs index 29b740c0a..960f6f92f 100644 --- a/crates/workflow/src/runtime/model.rs +++ b/crates/workflow/src/runtime/model.rs @@ -6,10 +6,11 @@ use crate::{ workflow_context::{ ChildWfCommon, NexusUnblockData, PendingChildWorkflow, StartedNexusOperation, }, + workflow_interceptors::WorkflowOutputValue, }; use temporalio_common_wasm::{ WorkflowDefinition, - data_converters::PayloadConversionError, + data_converters::{PayloadConversionError, TemporalSerializable}, error::{ ActivityExecutionError, ApplicationFailure, ChildWorkflowExecutionError, ChildWorkflowStartError, WorkflowSignalError, @@ -218,10 +219,14 @@ pub type WorkflowResult = Result; /// the current Workflow Task so it can be retried. /// /// Wrap an error in an [`ApplicationFailure`] to explicitly fail the Workflow Execution. -#[derive(Debug, thiserror::Error)] +#[derive(derive_more::Debug, thiserror::Error)] pub enum WorkflowTermination { #[error("Workflow cancelled")] - Cancelled, + Cancelled { + /// Optional cancellation details. + #[debug(skip)] + details: Option>, + }, #[error("Workflow evicted from cache")] Evicted, #[error("Continue as new")] @@ -231,6 +236,22 @@ pub enum WorkflowTermination { } impl WorkflowTermination { + /// Construct a cancelled workflow termination without details. + pub fn cancelled() -> Self { + Self::Cancelled { details: None } + } + + /// Construct a cancelled workflow termination with details that will be converted using the + /// active payload converter. + pub fn cancelled_with_details(details: T) -> Self + where + T: TemporalSerializable + Send + Sync + 'static, + { + Self::Cancelled { + details: Some(Box::new(details)), + } + } + pub fn continue_as_new(can: ContinueAsNewRequest) -> Self { Self::ContinueAsNew(Box::new(can)) } @@ -243,7 +264,7 @@ impl WorkflowTermination { impl From for WorkflowTermination { fn from(_value: WorkflowCancellationError) -> Self { - Self::Cancelled + Self::cancelled() } } diff --git a/crates/workflow/src/runtime/types.rs b/crates/workflow/src/runtime/types.rs index 4d8cb172d..5944a5667 100644 --- a/crates/workflow/src/runtime/types.rs +++ b/crates/workflow/src/runtime/types.rs @@ -7,7 +7,10 @@ use temporalio_common_wasm::protos::{ workflow_activation::{InitializeWorkflow, WorkflowActivation as CoreWorkflowActivation}, workflow_commands::ContinueAsNewWorkflowExecution, }, - temporal::api::{common::v1::Payload, failure::v1::Failure}, + temporal::api::{ + common::v1::{Payload, Payloads}, + failure::v1::Failure, + }, }; #[derive(Clone, Debug, PartialEq)] @@ -89,7 +92,7 @@ pub struct TaskFailure { pub enum TerminalOutcome { Completed(Payload), Failed(WorkflowFailure), - Cancelled, + Cancelled(Option), ContinueAsNew(Box), } diff --git a/crates/workflow/src/workflow_interceptors.rs b/crates/workflow/src/workflow_interceptors.rs index 22bad5210..9e5b19904 100644 --- a/crates/workflow/src/workflow_interceptors.rs +++ b/crates/workflow/src/workflow_interceptors.rs @@ -132,6 +132,11 @@ mod workflow_output_value { &self, context: &SerializationContext<'_>, ) -> Result; + + fn to_workflow_payloads( + &self, + context: &SerializationContext<'_>, + ) -> Result, PayloadConversionError>; } impl Sealed for T @@ -144,6 +149,13 @@ mod workflow_output_value { ) -> Result { context.converter.to_payload(context, self) } + + fn to_workflow_payloads( + &self, + context: &SerializationContext<'_>, + ) -> Result, PayloadConversionError> { + context.converter.to_payloads(context, self) + } } } @@ -174,6 +186,13 @@ impl dyn WorkflowOutputValue { ) -> Result { self.to_workflow_payload(context) } + + pub(crate) fn serialize_payloads( + &self, + context: &SerializationContext<'_>, + ) -> Result, PayloadConversionError> { + self.to_workflow_payloads(context) + } } pub(crate) fn serialize_workflow_output( diff --git a/crates/workflow/wit/types.wit b/crates/workflow/wit/types.wit index f1028d0bb..9560d2d2f 100644 --- a/crates/workflow/wit/types.wit +++ b/crates/workflow/wit/types.wit @@ -76,7 +76,7 @@ record task-failure { variant terminal-outcome { completed(payload), failed(failure), - cancelled, + cancelled(option>), continue-as-new(continue-as-new-request), } From 74bb7536bb9d4efe3986d5ddef566bf7b8a66189 Mon Sep 17 00:00:00 2001 From: Chris Greeno Date: Wed, 19 Aug 2026 14:39:50 +0100 Subject: [PATCH 11/87] feat(client): allow setting memo on workflow start (#1443) * feat(client): allow setting memo on workflow start `WorkflowStartOptions` had no memo field, so there was no way to attach a memo when starting a workflow from the client. The read side and the workflow side already exist - continue-as-new can set one, and `WorkflowExecutionDescription::memo()` reads one back - it was only the client start path that was missing. Adds `memo: Option` and wires it into both `StartWorkflowExecutionRequest` and `SignalWithStartWorkflowExecutionRequest`. Uses the proto type directly, matching the existing `header: Option
` field. Both are `map` and the caller has to convert values either way. Imported as `ProtoMemo` so it doesn't collide with the re-exported `temporalio_common::Memo`, which is the read-side wrapper. Two integ tests in workflow_client_tests.rs: one that a memo set at start comes back through describe, one that it's empty when unset. * feat(client): use MemoValues for memo on workflow start Per review, the client shouldn't ask callers to build the proto Memo themselves. It now takes `MemoValues`, the same type the workflow surface already uses for continue-as-new and `upsert_memo`. `MemoValue`/`MemoValues` move out of `temporalio_workflow` and into common-wasm, next to the existing `Memo` read wrapper. That's where they have to go for both sides to reach them... `temporalio_workflow` depends on common-wasm, not common. Re-exported from `temporalio_common`, `temporalio_workflow` and `temporalio_sdk`, so existing imports still work. `MemoValue` holds its value in an `Arc` now instead of an `Rc`, and wants `Send + Sync`. `WorkflowStartOptions` travels through the interceptor chain inside a `Send` future and an `Rc` can't cross that. Same shape as `DataConverter`, which already erases its converters as `Arc`. Also runs the memo through the payload codec, not just the payload converter. The read side already codec-decodes it... describe and list both run `decode_payloads` over the memo. So converter-only encoding broke the round trip for anyone with a real codec. Caught this after wiring up the tests below, the two client tests fail without it. Client unit tests cover start and signal-with-start under a codec, plus the serialization failure path. Integ tests moved over to `MemoValues`. * Address review feedback on memo `MemoValue` implements `TemporalSerializable` instead of exposing a doc hidden `to_payload`, so callers serialize it through the normal converter path. It also picks up the caller's serialization context now rather than hardcoding `Workflow`. `MemoValues::encode` is gone in favour of documented `get`/`iter`. The three callers build the payload map themselves. That is a few duplicated lines each, but nothing doc hidden in the public API. Test cleanups from the review... dropped the `memo_with` helper, the `assert_ne!` on the raw bytes that the following assertion already covers, and the empty-memo integ test that the client unit test covers. * fixup changelog --------- Co-authored-by: Chris Olszewski --- CHANGELOG.md | 14 ++ crates/client/src/lib.rs | 180 +++++++++++++++++- crates/client/src/options_structs.rs | 5 +- crates/common-wasm/src/lib.rs | 2 +- crates/common-wasm/src/memo.rs | 116 ++++++++++- crates/common/src/lib.rs | 6 +- .../integ_tests/workflow_client_tests.rs | 39 +++- crates/workflow/src/lib.rs | 4 +- crates/workflow/src/memo.rs | 124 ------------ crates/workflow/src/workflow_context.rs | 19 +- .../workflow/src/workflow_context/options.rs | 14 +- 11 files changed, 381 insertions(+), 142 deletions(-) delete mode 100644 crates/workflow/src/memo.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fa5ab4867..c68b45f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,20 @@ relevant information. ## Unreleased +### Added +* `WorkflowStartOptions::memo` attaches a non-indexed memo when starting a workflow, using the + same `MemoValues` type already used by continue-as-new and `WorkflowContext::upsert_memo`. + Values are serialized with the client's payload converter and codec, matching how `describe` + and `list` read them back. +* `MemoValue` and `MemoValues` are now exported from `temporalio_common` as well as + `temporalio_workflow`, so the same types can be used from clients and workflows. + +### Breaking Changes :boom: +* Values stored in a `MemoValue` must now be `Send + Sync`. It previously held its value in an + `Rc` and now uses an `Arc`, so that memos can be built outside a workflow and handed to the + client. Only affects memo values that are themselves non-`Send`/non-`Sync`, such as those + holding an `Rc` or `RefCell`. + ## [0.7.0] - 2026-08-17 ### Added diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index edb8a3751..ba9e568ef 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -134,14 +134,14 @@ use temporalio_common::{ DataConverter, GenericPayloadConverter, PayloadConverter, SerializationContext, SerializationContextData, }, - payload_visitor::decode_payloads, + payload_visitor::{decode_payloads, encode_payloads}, protos::{ coresdk::IntoPayloadsExt, grpc::health::v1::health_client::HealthClient, proto_ts_to_system_time, temporal::api::{ cloud::cloudservice::v1::cloud_service_client::CloudServiceClient, - common::v1::{ActivityType, WorkflowType}, + common::v1::{ActivityType, Memo as ProtoMemo, WorkflowType}, enums::v1::{ ActivityIdConflictPolicy as ProtoActivityIdConflictPolicy, ActivityIdReusePolicy as ProtoActivityIdReusePolicy, TaskQueueKind, @@ -1702,6 +1702,34 @@ where let workflow_id = options.workflow_id.clone(); let task_queue_name = options.task_queue.clone(); + let memo = match options.memo { + Some(memo) => { + let payload_converter = data_converter.payload_converter(); + let context = SerializationContext { + data: &SerializationContextData::Workflow, + converter: payload_converter, + }; + let mut memo = ProtoMemo { + fields: memo + .iter() + .map(|(key, value)| { + payload_converter + .to_payload(&context, value) + .map(|payload| (key.to_owned(), payload)) + }) + .collect::>()?, + }; + encode_payloads( + &mut memo, + data_converter.codec(), + &SerializationContextData::Workflow, + ) + .await?; + Some(memo) + } + None => None, + }; + let user_metadata = if options.static_summary.is_some() || options.static_details.is_some() { @@ -1759,6 +1787,7 @@ where .map(|attributes| attributes.into_proto()), cron_schedule: options.cron_schedule.unwrap_or_default(), retry_policy: options.retry_policy.map(Into::into), + memo, header: options.header.or(start_signal.header), user_metadata, ..Default::default() @@ -1806,6 +1835,7 @@ where links: options.links, completion_callbacks: options.completion_callbacks, priority: Some(options.priority.into()), + memo, header: options.header, user_metadata, ..Default::default() @@ -2851,11 +2881,11 @@ mod tests { mod start_workflow_interceptor_tests { use super::*; - use crate::request_extensions::RetryConfigForCall; + use crate::{request_extensions::RetryConfigForCall, test_helpers::XorCodec}; use parking_lot::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; use temporalio_common::{ - HasWorkflowDefinition, WorkflowDefinition, + HasWorkflowDefinition, MemoValues, WorkflowDefinition, data_converters::{ DefaultFailureConverter, PayloadCodec, PayloadConversionError, SerializationContext, SerializationContextData, TemporalSerializable, @@ -2883,6 +2913,7 @@ mod tests { struct RecordedStart { calls: usize, workflow_type: String, + memo: Option, payloads: Vec, ascii_metadata: Option, binary_metadata: Option>, @@ -2967,6 +2998,7 @@ mod tests { let mut recorded = self.recorded.lock(); recorded.calls += 1; recorded.workflow_type = request.workflow_type.unwrap().name; + recorded.memo = request.memo; recorded.payloads = request.input.unwrap_or_default().payloads; recorded.ascii_metadata = ascii_metadata; recorded.binary_metadata = binary_metadata; @@ -3008,6 +3040,7 @@ mod tests { let mut recorded = self.recorded.lock(); recorded.calls += 1; recorded.workflow_type = request.workflow_type.unwrap().name; + recorded.memo = request.memo; recorded.payloads = request.input.unwrap_or_default().payloads; recorded.ascii_metadata = ascii_metadata; recorded.binary_metadata = binary_metadata; @@ -3196,6 +3229,145 @@ mod tests { ) } + /// A mock client whose data converter uses `codec`, for asserting on what reaches the + /// wire. + fn mock_client_with_codec( + codec: impl PayloadCodec + Send + Sync + 'static, + ) -> (MockStartWorkflowClient, Arc>) { + let recorded = Arc::new(Mutex::new(RecordedStart::default())); + let data_converter = + DataConverter::new(PayloadConverter::default(), DefaultFailureConverter, codec); + ( + MockStartWorkflowClient { + recorded: recorded.clone(), + data_converter, + }, + recorded, + ) + } + + /// Decode a sent memo the same way `describe`/`list` do, and read it back. + async fn read_back(sent: ProtoMemo) -> Memo { + let mut sent = sent; + decode_payloads(&mut sent, &XorCodec, &SerializationContextData::Workflow) + .await + .unwrap(); + Memo::from_raw( + Some(sent), + PayloadConverter::default(), + SerializationContextData::Workflow, + ) + } + + #[tokio::test] + async fn start_workflow_encodes_memo_with_payload_converter_and_codec() { + let (client, recorded) = mock_client_with_codec(XorCodec); + let mut memo = MemoValues::new(); + memo.insert("memo-key", "memo-value".to_owned()); + + client + .start_workflow( + TestWorkflow, + vec!["initial".to_owned()], + WorkflowStartOptions::new("task-queue", "workflow-id") + .memo(memo) + .build(), + ) + .await + .unwrap(); + + let sent = recorded.lock().memo.clone().expect("memo should be sent"); + assert_eq!( + read_back(sent).await.get::("memo-key").unwrap(), + Some("memo-value".to_owned()) + ); + } + + #[tokio::test] + async fn signal_with_start_workflow_encodes_memo() { + let (client, recorded) = mock_client_with_codec(XorCodec); + let mut memo = MemoValues::new(); + memo.insert("memo-key", "memo-value".to_owned()); + + client + .start_workflow( + TestWorkflow, + vec!["initial".to_owned()], + WorkflowStartOptions::new("task-queue", "workflow-id") + .memo(memo) + .start_signal(WorkflowStartSignal::new("some-signal").build()) + .build(), + ) + .await + .unwrap(); + + let sent = recorded.lock().memo.clone().expect("memo should be sent"); + assert_eq!( + read_back(sent).await.get::("memo-key").unwrap(), + Some("memo-value".to_owned()) + ); + } + + #[tokio::test] + async fn start_workflow_without_memo_sends_none() { + let (client, recorded) = mock_client_with_codec(XorCodec); + + client + .start_workflow( + TestWorkflow, + vec!["initial".to_owned()], + WorkflowStartOptions::new("task-queue", "workflow-id").build(), + ) + .await + .unwrap(); + + assert_eq!(recorded.lock().memo, None); + } + + #[tokio::test] + async fn start_workflow_reports_memo_serialization_errors() { + #[derive(Debug)] + struct FailingMemoValue; + + impl TemporalSerializable for FailingMemoValue { + fn to_payload( + &self, + _ctx: &SerializationContext<'_>, + ) -> Result { + Err(PayloadConversionError::EncodingError( + std::io::Error::other("memo serialization failure").into(), + )) + } + } + + let (client, recorded) = mock_client_with_codec(XorCodec); + let mut memo = MemoValues::new(); + memo.insert("invalid", FailingMemoValue); + + let err = client + .start_workflow( + TestWorkflow, + vec!["initial".to_owned()], + WorkflowStartOptions::new("task-queue", "workflow-id") + .memo(memo) + .build(), + ) + .await + .map(|_| ()) + .expect_err("memo serialization errors should be surfaced"); + + assert!( + matches!(err, WorkflowStartError::PayloadConversion(_)), + "expected a payload conversion error, got {err:?}" + ); + assert!( + err.to_string().contains("memo serialization failure"), + "error should surface the underlying cause, got {err}" + ); + // The request must not have been sent. + assert_eq!(recorded.lock().calls, 0); + } + #[tokio::test] async fn interceptors_order_mutate_replace_and_defer_conversion() { let events = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/client/src/options_structs.rs b/crates/client/src/options_structs.rs index 28918bd9a..68f2aeecb 100644 --- a/crates/client/src/options_structs.rs +++ b/crates/client/src/options_structs.rs @@ -5,7 +5,7 @@ use crate::{ use http::Uri; use std::{collections::HashMap, sync::Arc, time::Duration}; use temporalio_common::{ - ActivityCloseTimeouts, RetryPolicy, + ActivityCloseTimeouts, MemoValues, RetryPolicy, data_converters::DataConverter, protos::temporal::api::{ common::{ @@ -441,6 +441,9 @@ pub struct WorkflowStartOptions { /// Headers to include with the start request. pub header: Option
, + /// Non-indexed values attached to the workflow, serialized with the client's data converter. + pub memo: Option, + /// Single-line static summary for the workflow, shown in the Temporal UI. pub static_summary: Option, diff --git a/crates/common-wasm/src/lib.rs b/crates/common-wasm/src/lib.rs index de539ae12..699b35f4b 100644 --- a/crates/common-wasm/src/lib.rs +++ b/crates/common-wasm/src/lib.rs @@ -26,7 +26,7 @@ pub mod worker; mod workflow_definition; pub use activity_definition::{ActivityDefinition, ActivityError, UntypedActivity}; -pub use memo::Memo; +pub use memo::{Memo, MemoValue, MemoValues}; pub use priority::Priority; pub use retry_policy::RetryPolicy; pub use search_attributes::{ diff --git a/crates/common-wasm/src/memo.rs b/crates/common-wasm/src/memo.rs index 58f3cd8ae..6945153f3 100644 --- a/crates/common-wasm/src/memo.rs +++ b/crates/common-wasm/src/memo.rs @@ -1,10 +1,11 @@ use crate::{ data_converters::{ GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, - SerializationContextData, TemporalDeserializable, + SerializationContextData, TemporalDeserializable, TemporalSerializable, }, protos::temporal::api::common::v1::{Memo as ProtoMemo, Payload}, }; +use std::{collections::BTreeMap, sync::Arc}; /// A collection of memo payloads that can be deserialized into typed values. #[derive(Clone, Debug)] @@ -86,6 +87,84 @@ impl Memo { } } +trait SerializableMemoValue: Send + Sync { + fn to_payload( + &self, + context: &SerializationContext<'_>, + ) -> Result; +} + +impl SerializableMemoValue for T +where + T: TemporalSerializable + Send + Sync + 'static, +{ + fn to_payload( + &self, + context: &SerializationContext<'_>, + ) -> Result { + context.converter.to_payload(context, self) + } +} + +/// A typed value used in a workflow memo update. +#[derive(Clone, derive_more::Debug)] +#[non_exhaustive] +pub struct MemoValue { + #[debug(skip)] + value: Arc, +} + +impl MemoValue { + /// Create a memo value that will be serialized with the workflow's data converter. + pub fn new(value: T) -> Self { + Self { + value: Arc::new(value), + } + } +} + +impl TemporalSerializable for MemoValue { + fn to_payload( + &self, + context: &SerializationContext<'_>, + ) -> Result { + self.value.to_payload(context) + } +} + +/// A complete set of memo values for a new workflow execution. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct MemoValues { + values: BTreeMap, +} + +impl MemoValues { + /// Create an empty set of memo values. + pub fn new() -> Self { + Self::default() + } + + /// Add or replace a memo value. + pub fn insert(&mut self, key: impl Into, value: T) -> &mut Self + where + T: TemporalSerializable + Send + Sync + 'static, + { + self.values.insert(key.into(), MemoValue::new(value)); + self + } + + /// Returns the value for `key`, if present. + pub fn get(&self, key: &str) -> Option<&MemoValue> { + self.values.get(key) + } + + /// Iterates over the memo entries in key order. + pub fn iter(&self) -> impl Iterator { + self.values.iter().map(|(key, value)| (key.as_str(), value)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -132,4 +211,39 @@ mod tests { assert!(memo.get::("count").is_err()); } + + #[test] + fn memo_values_serialize_heterogeneous_values() { + let payload_converter = PayloadConverter::default(); + let mut values = MemoValues::new(); + values + .insert("count", 7_u32) + .insert("label", "hello".to_string()); + + let context = SerializationContext { + data: &SerializationContextData::Workflow, + converter: &payload_converter, + }; + let fields = values + .iter() + .map(|(key, value)| { + ( + key.to_owned(), + payload_converter.to_payload(&context, value).unwrap(), + ) + }) + .collect(); + + let memo = Memo::from_raw( + Some(ProtoMemo { fields }), + payload_converter.clone(), + SerializationContextData::Workflow, + ); + + assert_eq!(memo.get::("count").unwrap(), Some(7)); + assert_eq!( + memo.get::("label").unwrap(), + Some("hello".to_string()) + ); + } } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 8fd0038ec..897487b76 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -19,9 +19,9 @@ pub mod telemetry; pub mod worker; pub use temporalio_common_wasm::{ ActivityCloseTimeouts, ActivityDefinition, ActivityError, HasWorkflowDefinition, Memo, - Priority, QueryDefinition, RetryPolicy, SignalDefinition, UntypedActivity, UntypedWorkflow, - UpdateDefinition, WorkerDeploymentVersion, WorkflowDefinition, WorkflowExecution, - data_converters, error, search_attributes, + MemoValue, MemoValues, Priority, QueryDefinition, RetryPolicy, SignalDefinition, + UntypedActivity, UntypedWorkflow, UpdateDefinition, WorkerDeploymentVersion, + WorkflowDefinition, WorkflowExecution, data_converters, error, search_attributes, }; macro_rules! dbg_panic { diff --git a/crates/sdk-core/tests/integ_tests/workflow_client_tests.rs b/crates/sdk-core/tests/integ_tests/workflow_client_tests.rs index f16aabf98..82a23e660 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_client_tests.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_client_tests.rs @@ -13,7 +13,7 @@ use temporalio_client::{ WorkflowCountOptions, WorkflowListOptions, WorkflowStartOptions, WorkflowTerminateOptions, errors::WorkflowStartError, }; -use temporalio_common::data_converters::RawValue; +use temporalio_common::{MemoValues, data_converters::RawValue}; use temporalio_macros::{workflow, workflow_methods}; use temporalio_sdk::{WorkflowContext, WorkflowResult}; @@ -250,3 +250,40 @@ async fn already_started_error_contains_run_id() { .await .unwrap(); } + +#[tokio::test] +async fn start_workflow_with_memo() { + let test_name = "start_workflow_with_memo"; + let mut starter = CoreWfStarter::new(test_name); + let client = starter.get_core_client().await; + let task_queue = starter.get_task_queue().to_owned(); + let wf_id = format!("{test_name}_{}", rand_6_chars()); + + let mut memo = MemoValues::new(); + memo.insert("memo-key", "memo-value".to_string()) + .insert("other-key", 42_u32); + + let handle = client + .start_workflow( + UntypedWorkflow::new(test_name), + RawValue::empty(), + WorkflowStartOptions::new(task_queue, wf_id) + .memo(memo) + .build(), + ) + .await + .unwrap(); + + let desc = handle.describe(Default::default()).await.unwrap(); + let memo = desc.memo(); + assert_eq!( + memo.get::("memo-key").unwrap(), + Some("memo-value".to_string()) + ); + assert_eq!(memo.get::("other-key").unwrap(), Some(42)); + + handle + .terminate(WorkflowTerminateOptions::default()) + .await + .unwrap(); +} diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 6e994db7e..2949aebf3 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -17,7 +17,6 @@ pub mod __private { mod cancellation; #[doc(hidden)] pub mod component; -mod memo; #[doc(hidden)] pub mod runtime; mod workflow_context; @@ -25,14 +24,13 @@ pub mod workflow_interceptors; pub mod workflows; pub use cancellation::{WorkflowCancellationError, WorkflowCancellationToken}; -pub use memo::{MemoValue, MemoValues}; #[doc(hidden)] pub use runtime::model::{CancellableID, UnblockEvent}; pub use runtime::model::{TimerResult, WorkflowResult, WorkflowTermination}; #[doc(hidden)] pub use runtime::{SdkWakeGuard, is_sdk_wake}; pub use temporalio_common_wasm::{ - ActivityCloseTimeouts, Memo, RetryPolicy, + ActivityCloseTimeouts, Memo, MemoValue, MemoValues, RetryPolicy, error::{ ActivityExecutionError, ChildWorkflowExecutionError, ChildWorkflowStartError, RetryState, TimeoutType, WorkflowSignalError, diff --git a/crates/workflow/src/memo.rs b/crates/workflow/src/memo.rs deleted file mode 100644 index 4335c4790..000000000 --- a/crates/workflow/src/memo.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::{collections::BTreeMap, rc::Rc}; - -use temporalio_common_wasm::{ - data_converters::{ - GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, - SerializationContextData, TemporalSerializable, - }, - protos::temporal::api::common::v1::Payload, -}; - -trait SerializableMemoValue { - fn to_payload( - &self, - payload_converter: &PayloadConverter, - ) -> Result; -} - -impl SerializableMemoValue for T -where - T: TemporalSerializable + 'static, -{ - fn to_payload( - &self, - payload_converter: &PayloadConverter, - ) -> Result { - payload_converter.to_payload( - &SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }, - self, - ) - } -} - -/// A typed value used in a workflow memo update. -#[derive(Clone, derive_more::Debug)] -#[non_exhaustive] -pub struct MemoValue { - #[debug(skip)] - value: Rc, -} - -impl MemoValue { - /// Create a memo value that will be serialized with the workflow's data converter. - pub fn new(value: T) -> Self { - Self { - value: Rc::new(value), - } - } - - pub(crate) fn to_payload( - &self, - payload_converter: &PayloadConverter, - ) -> Result { - self.value.to_payload(payload_converter) - } -} - -/// A complete set of memo values for a new workflow execution. -#[derive(Clone, Debug, Default)] -#[non_exhaustive] -pub struct MemoValues { - values: BTreeMap, -} - -impl MemoValues { - /// Create an empty set of memo values. - pub fn new() -> Self { - Self::default() - } - - /// Add or replace a memo value. - pub fn insert(&mut self, key: impl Into, value: T) -> &mut Self - where - T: TemporalSerializable + 'static, - { - self.values.insert(key.into(), MemoValue::new(value)); - self - } - - pub(crate) fn encode( - &self, - payload_converter: &PayloadConverter, - ) -> Result, PayloadConversionError> { - self.values - .iter() - .map(|(key, value)| { - value - .to_payload(payload_converter) - .map(|payload| (key.clone(), payload)) - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use temporalio_common_wasm::{Memo, protos::temporal::api::common::v1::Memo as ProtoMemo}; - - #[test] - fn memo_values_serialize_heterogeneous_values() { - let payload_converter = PayloadConverter::default(); - let mut values = MemoValues::new(); - values - .insert("count", 7_u32) - .insert("label", "hello".to_string()); - - let memo = Memo::from_raw( - Some(ProtoMemo { - fields: values.encode(&payload_converter).unwrap(), - }), - payload_converter, - SerializationContextData::Workflow, - ); - - assert_eq!(memo.get::("count").unwrap(), Some(7)); - assert_eq!( - memo.get::("label").unwrap(), - Some("hello".to_string()) - ); - } -} diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 012f39411..90e9c8db2 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -1727,17 +1727,22 @@ impl SyncWorkflowContext { where K: Into, { + let payload_converter = self.payload_converter(); + let context = SerializationContext { + data: &SerializationContextData::Workflow, + converter: payload_converter, + }; let mut fields = HashMap::new(); let mut local_updates = Vec::new(); for (key, value) in updates { let key = key.into(); let (command_payload, local_payload) = match value { Some(value) => { - let payload = value.to_payload(self.payload_converter())?; + let payload = payload_converter.to_payload(&context, &value)?; (payload.clone(), Some(payload)) } None => ( - MemoValue::new(()).to_payload(self.payload_converter())?, + payload_converter.to_payload(&context, &MemoValue::new(()))?, None, ), }; @@ -4359,7 +4364,15 @@ mod tests { }; let fields = &command.upserted_memo.as_ref().unwrap().fields; let payload_converter = PayloadConverter::default(); - let removal_payload = MemoValue::new(()).to_payload(&payload_converter).unwrap(); + let removal_payload = payload_converter + .to_payload( + &SerializationContext { + data: &SerializationContextData::Workflow, + converter: &payload_converter, + }, + &MemoValue::new(()), + ) + .unwrap(); assert_eq!(fields.get("old"), Some(&removal_payload)); assert_eq!( u32::from_json_payload(fields.get("new").unwrap()).unwrap(), diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index 492da4033..16a50722f 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -870,9 +870,21 @@ impl ContinueAsNewOptions { headers: HashMap, payload_converter: &PayloadConverter, ) -> Result { + let context = SerializationContext { + data: &SerializationContextData::Workflow, + converter: payload_converter, + }; let memo = self .memo - .map(|memo| memo.encode(payload_converter)) + .map(|memo| { + memo.iter() + .map(|(key, value)| { + payload_converter + .to_payload(&context, value) + .map(|payload| (key.to_owned(), payload)) + }) + .collect::, _>>() + }) .transpose()? .unwrap_or_default(); Ok(ContinueAsNewWorkflowExecution { From f5d05758de06c14ba9620a124dd7d4e76f2325ca Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Wed, 19 Aug 2026 09:41:38 -0700 Subject: [PATCH 12/87] Add warning on worker deployment limits (#1500) --- CHANGELOG.md | 3 + crates/sdk-core/CHANGELOG.md | 3 + crates/sdk-core/src/pollers/poll_buffer.rs | 80 +++++++++++++++++++++- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c68b45f81..594490290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,9 @@ relevant information. Non-validation failures are reported as `WorkerRunError::Fatal` with a message and source. ### Fixed +* Rust SDK workers now warn when autoscaling task polling encounters errors continuously for one + minute. Repeated warnings use exponential backoff up to 15-minute intervals and stop after + polling recovers. * Unhandled workflow payload conversion errors now fail the Workflow Task so it can retry instead of failing the Workflow Execution. Workflows may still explicitly handle these errors. * Workers no longer send worker heartbeats or appear in centralized heartbeat reports before diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index b9747777b..0549321db 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -50,6 +50,9 @@ relevant information. are preserved on failure; workers warn when the server does not advertise support. ### Fixed +* Workers now warn when autoscaling task polling encounters errors continuously for one minute. + Repeated warnings use exponential backoff up to 15-minute intervals and stop after polling + recovers. * Workers no longer send worker heartbeats or appear in centralized heartbeat reports before they begin polling. * Ephemeral server processes no longer leak on failed start. diff --git a/crates/sdk-core/src/pollers/poll_buffer.rs b/crates/sdk-core/src/pollers/poll_buffer.rs index 45fe81d03..b354933ae 100644 --- a/crates/sdk-core/src/pollers/poll_buffer.rs +++ b/crates/sdk-core/src/pollers/poll_buffer.rs @@ -59,6 +59,11 @@ const THROTTLE_POLL_BACKOFF: ExponentialBuilder = ExponentialBuilder::new() .with_factor(2.0) .with_max_delay(Duration::from_secs(10)) .without_max_times(); +const PERSISTENT_POLL_ERROR_WARN_BACKOFF: ExponentialBuilder = ExponentialBuilder::new() + .with_min_delay(Duration::from_secs(60)) + .with_factor(2.0) + .with_max_delay(Duration::from_secs(15 * 60)) + .without_max_times(); type PollReceiver = Mutex)>>>; @@ -550,6 +555,7 @@ where ingested_last_period: Default::default(), scale_up_allowed: AtomicBool::new(true), last_successful_poll_time, + persistent_error_warning_state: Default::default(), exponential_backoff: parking_lot::Mutex::new(TASK_POLL_BACKOFF.build()), resource_exhausted_backoff: parking_lot::Mutex::new(THROTTLE_POLL_BACKOFF.build()), }); @@ -616,6 +622,7 @@ struct PollScalerReportHandle { ingested_last_period: AtomicUsize, scale_up_allowed: AtomicBool, last_successful_poll_time: Arc>>, + persistent_error_warning_state: parking_lot::Mutex, // Exponential backoff for normal errors and resource exhausted errors exponential_backoff: parking_lot::Mutex, @@ -634,10 +641,10 @@ impl PollScalerReportHandle { Ok(res) => { self.last_successful_poll_time .store(Some(SystemTime::now())); - // Reset backoff on successful poll *self.exponential_backoff.lock() = TASK_POLL_BACKOFF.build(); *self.resource_exhausted_backoff.lock() = THROTTLE_POLL_BACKOFF.build(); + self.persistent_error_warning_state.lock().reset(); if let PollerBehavior::SimpleMaximum(_) = self.behavior { // We don't do auto-scaling with the simple max @@ -673,6 +680,18 @@ impl PollScalerReportHandle { } Err(e) => { if matches!(self.behavior, PollerBehavior::Autoscaling { .. }) { + if let Some(error_duration) = self + .persistent_error_warning_state + .lock() + .record_error(Instant::now()) + { + warn!( + error = ?e, + ?error_duration, + "Task polling has encountered errors continuously; the worker will continue retrying" + ); + } + // Follow the same backoff logic as the retry client let mut backoff_duration = self .exponential_backoff @@ -854,6 +873,44 @@ where } } +#[derive(Debug)] +struct PersistentPollErrorWarningState { + started_at: Option, + next_warning_at: Option, + warning_backoff: backon::ExponentialBackoff, +} + +impl Default for PersistentPollErrorWarningState { + fn default() -> Self { + Self { + started_at: None, + next_warning_at: None, + warning_backoff: PERSISTENT_POLL_ERROR_WARN_BACKOFF.build(), + } + } +} + +impl PersistentPollErrorWarningState { + fn record_error(&mut self, now: Instant) -> Option { + let Some(started_at) = self.started_at else { + self.started_at = Some(now); + self.next_warning_at = self.warning_backoff.next().map(|delay| now + delay); + return None; + }; + let next_warning_at = self.next_warning_at?; + if now < next_warning_at { + return None; + } + + self.next_warning_at = self.warning_backoff.next().map(|delay| now + delay); + Some(now.saturating_duration_since(started_at)) + } + + fn reset(&mut self) { + *self = Self::default(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1282,6 +1339,7 @@ mod tests { ingested_last_period: Default::default(), scale_up_allowed: AtomicBool::new(true), last_successful_poll_time: Arc::new(AtomicCell::new(None)), + persistent_error_warning_state: Default::default(), exponential_backoff: parking_lot::Mutex::new(TASK_POLL_BACKOFF.build()), resource_exhausted_backoff: parking_lot::Mutex::new(THROTTLE_POLL_BACKOFF.build()), }); @@ -1295,4 +1353,24 @@ mod tests { assert_eq!(handle.target.load(Ordering::Relaxed), expected_target); assert!(!handle.ever_saw_scaling_decision.load(Ordering::Relaxed)); } + + #[test] + fn persistent_poll_error_warning_uses_exponential_backoff_and_resets() { + let mut state = PersistentPollErrorWarningState::default(); + let started_at = Instant::now(); + let minute = Duration::from_secs(60); + + assert_eq!(state.record_error(started_at), None); + assert_eq!( + state.record_error(started_at + minute - Duration::from_secs(1)), + None + ); + for warning_minute in [1, 3, 7, 15, 30, 45] { + let elapsed = minute * warning_minute; + assert_eq!(state.record_error(started_at + elapsed), Some(elapsed)); + } + + state.reset(); + assert_eq!(state.record_error(started_at + minute * 45), None); + } } From 5635811558fa921eb5eae3e2c73b7d81bd7dfc30 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Wed, 19 Aug 2026 10:29:08 -0700 Subject: [PATCH 13/87] Fix CI flake detected by SDK Sentinel (rust) (#1507) --- .../integ_tests/worker_heartbeat_tests.rs | 61 ++++++++++--------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/crates/sdk-core/tests/integ_tests/worker_heartbeat_tests.rs b/crates/sdk-core/tests/integ_tests/worker_heartbeat_tests.rs index 72dad9ad9..c8a3a0987 100644 --- a/crates/sdk-core/tests/integ_tests/worker_heartbeat_tests.rs +++ b/crates/sdk-core/tests/integ_tests/worker_heartbeat_tests.rs @@ -274,43 +274,46 @@ async fn docker_worker_heartbeat_basic(#[values("otel", "prom", "no_metrics")] b let heartbeat_time = AtomicCell::new(None); let test_fut = async { - // Give enough time to ensure heartbeat interval has been hit - tokio::time::sleep(Duration::from_millis(1500)).await; acts_started.notified().await; let client = starter.get_core_client().await; - let mut raw_client = client.clone(); - let workers_list = WorkflowService::list_workers( - &mut raw_client, - ListWorkersRequest { - namespace: client.namespace().to_owned(), - page_size: 100, - next_page_token: Vec::new(), - query: String::new(), - include_system_workers: false, - } - .into_request(), + let raw_client = client.clone(); + let heartbeat = eventually( + || { + let client = client.clone(); + async move { + let heartbeat = list_worker_heartbeats(&client, String::new()) + .await + .into_iter() + .find(|heartbeat| { + heartbeat.worker_instance_key == worker_instance_key.to_string() + }) + .ok_or_else(|| anyhow!("worker heartbeat has not been recorded"))?; + let workflow_tasks = heartbeat + .workflow_task_slots_info + .as_ref() + .map_or(0, |slots| slots.total_processed_tasks); + let activities = heartbeat + .activity_task_slots_info + .as_ref() + .map_or(0, |slots| slots.current_used_slots); + if workflow_tasks == 1 && activities == 1 { + Ok(heartbeat) + } else { + Err(anyhow!( + "Heartbeat not ready: workflow tasks={workflow_tasks}, activities={activities}" + )) + } + } + }, + Duration::from_secs(5), ) .await - .unwrap() - .into_inner(); - #[allow(deprecated)] - let worker_info = workers_list - .workers_info - .iter() - .find(|worker_info| { - if let Some(hb) = worker_info.worker_heartbeat.as_ref() { - hb.worker_instance_key == worker_instance_key.to_string() - } else { - false - } - }) - .unwrap(); - let heartbeat = worker_info.worker_heartbeat.as_ref().unwrap(); + .unwrap(); assert_eq!( heartbeat.worker_instance_key, worker_instance_key.to_string() ); - in_activity_checks(heartbeat, &start_time, &heartbeat_time); + in_activity_checks(&heartbeat, &start_time, &heartbeat_time); acts_done.notify_one(); // Poll until the heartbeat reflects shutdown with the second WFT processed. From c989a542566677750280fb2ce65c2beae667cd53 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 19 Aug 2026 15:46:12 -0400 Subject: [PATCH 14/87] feat(sdk): add WorkflowContext::all_handlers_finished (#1505) --- CHANGELOG.md | 3 + .../workflow_tests/interceptors.rs | 321 +++++++++++++++++- crates/workflow/src/runtime/instance.rs | 29 +- crates/workflow/src/workflow_context.rs | 88 ++++- 4 files changed, 426 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 594490290..3fb112e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ relevant information. ## Unreleased ### Added +* `WorkflowContext::all_handlers_finished` and `SyncWorkflowContext::all_handlers_finished` let + Rust workflows wait for active signal and update handler chains before completing or continuing + as new. * `WorkflowStartOptions::memo` attaches a non-indexed memo when starting a workflow, using the same `MemoValues` type already used by continue-as-new and `WorkflowContext::upsert_memo`. Values are serialized with the client's payload converter and codec, matching how `describe` diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/interceptors.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/interceptors.rs index 88dc5d07f..43989a6e2 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/interceptors.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/interceptors.rs @@ -1,4 +1,4 @@ -use crate::common::{CoreWfStarter, activity_functions::StdActivities}; +use crate::common::{CoreWfStarter, WorkflowHandleExt, activity_functions::StdActivities}; use std::{ future::Future, pin::Pin, @@ -310,6 +310,325 @@ async fn workflow_interceptors_mutate_inputs_and_replace_outputs() { join!(driver, run); } +#[workflow] +#[derive(Default)] +struct AllHandlersFinishedWorkflow { + handler_started: bool, +} + +#[workflow_methods] +impl AllHandlersFinishedWorkflow { + #[run] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { + ctx.wait_condition(|state| state.handler_started).await?; + let handlers_finished = ctx.all_handlers_finished(); + let ctx_clone = ctx.clone(); + ctx.wait_condition(move |_| ctx_clone.all_handlers_finished()) + .await?; + Ok(handlers_finished) + } + + #[signal] + fn sync_signal(&mut self, ctx: &mut SyncWorkflowContext) { + assert!(!ctx.all_handlers_finished()); + self.handler_started = true; + } + + #[signal] + async fn async_signal(ctx: &mut WorkflowContext) { + ctx.state_mut(|state| state.handler_started = true); + } + + #[signal] + fn wake(&mut self, _ctx: &mut SyncWorkflowContext) {} + + #[update_validator(async_update)] + fn validate_async_update( + &self, + _ctx: &WorkflowContextView, + reject: &bool, + ) -> Result<(), Box> { + if *reject { + Err("rejected by validator".into()) + } else { + Ok(()) + } + } + + #[update] + async fn async_update(ctx: &mut WorkflowContext, _reject: bool) { + ctx.state_mut(|state| state.handler_started = true); + } +} + +struct PostHandlerTimerInterceptor; + +impl WorkflowInterceptor for PostHandlerTimerInterceptor { + fn handle_signal<'a>( + &'a self, + ctx: WorkflowInterceptorContext, + input: HandleSignalInput, + next: WorkflowNext< + 'a, + HandleSignalInput, + WorkflowInterceptorFuture<'a, HandleSignalResult>, + >, + ) -> WorkflowInterceptorFuture<'a, HandleSignalResult> { + if input.name() == "wake" { + return next.run(input); + } + WorkflowInterceptorFuture::new(async move { + let result = next.run(input).await; + ctx.timer(Duration::from_millis(1)).await; + result + }) + } + + fn handle_update<'a>( + &'a self, + ctx: WorkflowInterceptorContext, + input: HandleUpdateInput, + next: WorkflowNext< + 'a, + HandleUpdateInput, + WorkflowInterceptorFuture<'a, HandleUpdateResult>, + >, + ) -> WorkflowInterceptorFuture<'a, HandleUpdateResult> { + WorkflowInterceptorFuture::new(async move { + let result = next.run(input).await; + ctx.timer(Duration::from_millis(1)).await; + result + }) + } +} + +#[derive(Clone, Copy)] +enum HandlerKind { + SyncSignal, + AsyncSignal, + Update, +} + +#[rstest::rstest] +#[tokio::test] +async fn all_handlers_finished_waits_for_handler_chain( + #[values( + HandlerKind::SyncSignal, + HandlerKind::AsyncSignal, + HandlerKind::Update + )] + handler_kind: HandlerKind, + #[values(false, true)] with_interceptor: bool, +) { + let mut starter = CoreWfStarter::new("all_handlers_finished_waits_for_handler_chain"); + starter + .sdk_config + .register_workflow::() + .unwrap(); + if with_interceptor { + starter.sdk_config.register_workflow_interceptors(vec![ + WorkflowInterceptorConstructor::new(|_| PostHandlerTimerInterceptor), + ]); + } + let mut worker = starter.worker().await; + + let handle = worker + .submit_workflow( + AllHandlersFinishedWorkflow::run, + (), + WorkflowStartOptions::new( + starter.get_task_queue().to_owned(), + starter.get_wf_id().to_owned(), + ) + .build(), + ) + .await + .unwrap(); + + let driver = async { + match handler_kind { + HandlerKind::SyncSignal => { + handle + .signal( + AllHandlersFinishedWorkflow::sync_signal, + (), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); + } + HandlerKind::AsyncSignal => { + handle + .signal( + AllHandlersFinishedWorkflow::async_signal, + (), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); + } + HandlerKind::Update => { + handle + .execute_update( + AllHandlersFinishedWorkflow::async_update, + false, + WorkflowExecuteUpdateOptions::default(), + ) + .await + .unwrap(); + } + } + assert_eq!( + // If interceptor wasn't registered, no timer was scheduled after the handlers so they should + // be finished after the first `wait_condition`. + !with_interceptor, + handle.get_result(Default::default()).await.unwrap() + ); + }; + let (_, worker_result) = join!(driver, worker.run_until_done()); + worker_result.unwrap(); + handle.fetch_history_and_replay(&mut worker).await.unwrap(); +} + +#[tokio::test] +async fn rejected_update_does_not_leave_a_handler_in_progress() { + let mut starter = CoreWfStarter::new("rejected_update_does_not_leave_a_handler_in_progress"); + starter + .sdk_config + .register_workflow::() + .unwrap() + .register_workflow_interceptors(vec![WorkflowInterceptorConstructor::new(|_| { + PostHandlerTimerInterceptor + })]); + let mut worker = starter.worker().await; + + let handle = worker + .submit_workflow( + AllHandlersFinishedWorkflow::run, + (), + WorkflowStartOptions::new( + starter.get_task_queue().to_owned(), + starter.get_wf_id().to_owned(), + ) + .build(), + ) + .await + .unwrap(); + + let driver = async { + assert!( + handle + .execute_update( + AllHandlersFinishedWorkflow::async_update, + true, + WorkflowExecuteUpdateOptions::default(), + ) + .await + .is_err() + ); + handle + .signal( + AllHandlersFinishedWorkflow::sync_signal, + (), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); + assert!(!handle.get_result(Default::default()).await.unwrap()); + }; + let (_, worker_result) = join!(driver, worker.run_until_done()); + worker_result.unwrap(); +} + +struct NonTemporalPostHandlerInterceptor { + waiting: Arc, + release: Arc, +} + +impl WorkflowInterceptor for NonTemporalPostHandlerInterceptor { + fn handle_signal<'a>( + &'a self, + _ctx: WorkflowInterceptorContext, + input: HandleSignalInput, + next: WorkflowNext< + 'a, + HandleSignalInput, + WorkflowInterceptorFuture<'a, HandleSignalResult>, + >, + ) -> WorkflowInterceptorFuture<'a, HandleSignalResult> { + if input.name() != "async_signal" { + return next.run(input); + } + let waiting = self.waiting.clone(); + let release = self.release.clone(); + WorkflowInterceptorFuture::new(async move { + let result = next.run(input).await; + waiting.notify_one(); + release.notified().await; + result + }) + } +} + +#[tokio::test] +async fn all_handlers_finished_tracks_nondeterministic_futures() { + let mut starter = + CoreWfStarter::new("all_handlers_finished_tracks_non_temporal_interceptor_futures"); + starter.sdk_config.detect_nondeterministic_futures = false; + let waiting = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let waiting_ref = waiting.clone(); + let release_ref = release.clone(); + starter + .sdk_config + .register_workflow::() + .unwrap() + .register_workflow_interceptors(vec![WorkflowInterceptorConstructor::new(move |_| { + NonTemporalPostHandlerInterceptor { + waiting: waiting_ref.clone(), + release: release_ref.clone(), + } + })]); + let mut worker = starter.worker().await; + + let handle = worker + .submit_workflow( + AllHandlersFinishedWorkflow::run, + (), + WorkflowStartOptions::new( + starter.get_task_queue().to_owned(), + starter.get_wf_id().to_owned(), + ) + .build(), + ) + .await + .unwrap(); + + let driver = async { + handle + .signal( + AllHandlersFinishedWorkflow::async_signal, + (), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); + waiting.notified().await; + release.notify_one(); + handle + .signal( + AllHandlersFinishedWorkflow::wake, + (), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); + assert!(!handle.get_result(Default::default()).await.unwrap()); + }; + let (_, worker_result) = join!(driver, worker.run_until_done()); + worker_result.unwrap(); +} + #[workflow] #[derive(Default)] struct InboundInterceptorOrderWorkflow; diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index 5de12aab7..205da6630 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -14,6 +14,7 @@ use crate::{ UpdateRoutineCompletion, UpdateRoutineKind, WorkflowActivation, WorkflowFailure, }, }, + workflow_context::HandlerExecutionGuard, workflow_interceptors::{ ExecuteWorkflowInput, ExecuteWorkflowResult, HandleQueryInput, HandleQueryResult, HandleSignalInput, HandleSignalResult, HandleUpdateInput, HandleUpdateResult, @@ -81,6 +82,7 @@ enum GuestRoutine { struct InterceptedFuture { inner: Fuse>, status: InterceptedFutureStatus, + _handler_execution: Option, } impl InterceptedFuture { @@ -88,6 +90,19 @@ impl InterceptedFuture { Self { inner: inner.fuse(), status, + _handler_execution: None, + } + } + + fn with_handler_execution( + inner: LocalBoxFuture<'static, T>, + status: InterceptedFutureStatus, + handler_execution: HandlerExecutionGuard, + ) -> Self { + Self { + inner: inner.fuse(), + status, + _handler_execution: Some(handler_execution), } } @@ -292,6 +307,7 @@ fn intercepted_signal_future( base_ctx: BaseWorkflowContext, interceptors: Rc<[Arc]>, input: HandleSignalInput, + handler_execution: HandlerExecutionGuard, ) -> InterceptedFuture where W: WorkflowImplementation, @@ -310,7 +326,7 @@ where call_handle_signal(&interceptors, interceptor_ctx, input, next).await } .boxed_local(); - InterceptedFuture::new(future, status) + InterceptedFuture::with_handler_execution(future, status, handler_execution) } fn intercepted_update_future( @@ -318,6 +334,7 @@ fn intercepted_update_future( base_ctx: BaseWorkflowContext, interceptors: Rc<[Arc]>, input: HandleUpdateInput, + handler_execution: HandlerExecutionGuard, ) -> InterceptedFuture where W: WorkflowImplementation, @@ -336,7 +353,7 @@ where call_handle_update(&interceptors, interceptor_ctx, input, next).await } .boxed_local(); - InterceptedFuture::new(future, status) + InterceptedFuture::with_handler_execution(future, status, handler_execution) } impl GuestWorkflowInstance @@ -532,11 +549,13 @@ where let future = match W::decode_signal_input(&name, payloads, converter) { Ok(Some(input)) => { let input = HandleSignalInput::new(name.clone(), input, signal.headers); + let handler_execution = self.base_ctx.track_handler(); let mut future = intercepted_signal_future::( self.ctx.clone(), self.base_ctx.clone(), self.interceptors.clone(), input, + handler_execution, ); if let ConstructionPoll::Ready(result) = Self::poll_for_construction(&self.base_ctx, &mut future)? @@ -580,6 +599,7 @@ where None => return Ok(self.rejection_for_missing_update_handler(name)), }; + let mut handler_execution = None; if run_validator && has_validator { let payloads = Payloads { payloads: input.clone(), @@ -598,6 +618,7 @@ where }; let validation_input = ValidateUpdateInput::new(id.clone(), name.clone(), decoded_input, headers.clone()); + let guard = self.base_ctx.track_handler(); let validation_ctx = SyncWorkflowInterceptorContext::new(self.base_ctx.clone()); let workflow_ctx = self.ctx.clone(); let validation_next = WorkflowNext::new(move |input: ValidateUpdateInput| { @@ -626,6 +647,7 @@ where ))); } } + handler_execution = Some(guard); } let payloads = Payloads { payloads: input }; @@ -633,11 +655,14 @@ where let future = match W::decode_update_input(&name, payloads, converter) { Ok(Some(input)) => { let input = HandleUpdateInput::new(id.clone(), name.clone(), input, headers); + let handler_execution = + handler_execution.unwrap_or_else(|| self.base_ctx.track_handler()); let mut future = intercepted_update_future::( self.ctx.clone(), self.base_ctx.clone(), self.interceptors.clone(), input, + handler_execution, ); if let ConstructionPoll::Ready(result) = Self::poll_for_construction(&self.base_ctx, &mut future)? diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 90e9c8db2..e6085389f 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -485,10 +485,30 @@ struct WorkflowContextInner { data_converter: DataConverter, patch_activation_callback: Option, state_mutated: Cell, + active_handlers: Cell, + condition_wakers: RefCell>, current_waker: RefCell>, workflow_interceptors: Rc<[Arc]>, } +pub(crate) struct HandlerExecutionGuard { + base: BaseWorkflowContext, +} + +impl Drop for HandlerExecutionGuard { + fn drop(&mut self) { + let active_handlers = self.base.inner.active_handlers.get(); + debug_assert!(active_handlers > 0, "handler execution count underflow"); + self.base + .inner + .active_handlers + .set(active_handlers.saturating_sub(1)); + if active_handlers <= 1 { + self.base.wake_condition_waiters(); + } + } +} + /// Identical to [`CancellableID`], but only containing command type and seq number, omitting any reason. #[derive(Eq, Hash, PartialEq)] enum CancellableSeqNum { @@ -545,10 +565,6 @@ pub struct WorkflowContext { sync: SyncWorkflowContext, /// The workflow instance workflow_state: Rc>, - /// Wakers registered by `wait_condition` futures. Drained and woken on - /// every `state_mut` call so that waker-based combinators (e.g. - /// `FuturesOrdered`) re-poll the condition after state changes. - condition_wakers: Rc>>, } impl Clone for WorkflowContext { @@ -556,7 +572,6 @@ impl Clone for WorkflowContext { Self { sync: self.sync.clone(), workflow_state: self.workflow_state.clone(), - condition_wakers: self.condition_wakers.clone(), } } } @@ -623,6 +638,8 @@ impl BaseWorkflowContext { data_converter, patch_activation_callback, state_mutated: Cell::new(false), + active_handlers: Cell::new(0), + condition_wakers: Default::default(), current_waker: RefCell::new(None), workflow_interceptors, }), @@ -644,6 +661,24 @@ impl BaseWorkflowContext { self.inner.state_mutated.set(true); } + pub(crate) fn all_handlers_finished(&self) -> bool { + self.inner.active_handlers.get() == 0 + } + + pub(crate) fn track_handler(&self) -> HandlerExecutionGuard { + self.inner + .active_handlers + .set(self.inner.active_handlers.get() + 1); + HandlerExecutionGuard { base: self.clone() } + } + + fn wake_condition_waiters(&self) { + let _guard = SdkWakeGuard::new(); + for waker in self.inner.condition_wakers.borrow_mut().drain(..) { + waker.wake(); + } + } + pub(crate) fn take_runtime_progress(&self) -> bool { self.inner.runtime.take_progress() } @@ -1424,6 +1459,13 @@ impl SyncWorkflowContext { self.base.inner.shared.borrow().is_replaying_history_events } + /// Returns whether all currently dispatched signal and update handlers have finished. + /// + /// This includes the current handler invocation, if any, and all inbound interceptor work. + pub fn all_handlers_finished(&self) -> bool { + self.base.all_handlers_finished() + } + /// Returns true if the server suggests this workflow should continue-as-new pub fn continue_as_new_suggested(&self) -> bool { self.base @@ -1810,7 +1852,6 @@ impl WorkflowContext { _phantom: PhantomData, }, workflow_state, - condition_wakers: Rc::new(RefCell::new(Vec::new())), } } @@ -1823,7 +1864,6 @@ impl WorkflowContext { _phantom: PhantomData, }, workflow_state: self.workflow_state.clone(), - condition_wakers: self.condition_wakers.clone(), } } @@ -1913,6 +1953,28 @@ impl WorkflowContext { self.sync.is_replaying_history_events() } + /// Returns whether all currently dispatched signal and update handlers have finished. + /// + /// Consider waiting on this condition before completing or continuing as new so in-progress + /// handlers are not interrupted. Use a cloned context in [`Self::wait_condition`]: + /// + /// ```rust + /// # use temporalio_workflow::{WorkflowContext, WorkflowResult}; + /// # struct MyWorkflow; + /// # async fn wait_for_handlers(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + /// let wait_condition_ctx = ctx.clone(); + /// ctx.wait_condition(move |_| wait_condition_ctx.all_handlers_finished()) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// The check includes inbound interceptor work and the current handler invocation, if any. + /// It does not prevent future signal or update handlers from starting. + pub fn all_handlers_finished(&self) -> bool { + self.sync.all_handlers_finished() + } + /// Returns true if the server suggests this workflow should continue-as-new pub fn continue_as_new_suggested(&self) -> bool { self.sync.continue_as_new_suggested() @@ -2124,10 +2186,7 @@ impl WorkflowContext { /// `FuturesOrdered`) re-poll them on the next pass. pub fn state_mut(&self, f: impl FnOnce(&mut W) -> R) -> R { let result = f(&mut *self.workflow_state.borrow_mut()); - let _guard = SdkWakeGuard::new(); - for waker in self.condition_wakers.borrow_mut().drain(..) { - waker.wake(); - } + self.sync.base.wake_condition_waiters(); self.sync.base.set_state_mutated(); result } @@ -2178,7 +2237,12 @@ impl WorkflowContext { } else if cancelled.as_mut().poll(cx).is_ready() { Poll::Ready(Err(WorkflowCancellationError::new(token.reason()))) } else { - self.condition_wakers.borrow_mut().push(cx.waker().clone()); + self.sync + .base + .inner + .condition_wakers + .borrow_mut() + .push(cx.waker().clone()); Poll::Pending } }) From d9efc491cfde59fe2159ba2c6942ebdd83c2808e Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 19 Aug 2026 19:06:52 -0400 Subject: [PATCH 15/87] fix(sdk): typed signal_with_start_workflow (#1508) * fix(sdk): typed signal_with_start_workflow * fix: rebase memo fixes --- CHANGELOG.md | 7 + crates/client/Cargo.toml | 9 +- crates/client/src/interceptors.rs | 120 +++++ crates/client/src/lib.rs | 487 +++++++++++++----- crates/client/src/options_structs.rs | 80 ++- .../client/tests/typed_signal_with_start.rs | 5 + .../mismatched_signal_fail.rs | 45 ++ .../mismatched_signal_fail.stderr | 34 ++ .../integ_tests/workflow_tests/signals.rs | 86 +++- 9 files changed, 698 insertions(+), 175 deletions(-) create mode 100644 crates/client/tests/typed_signal_with_start.rs create mode 100644 crates/client/tests/typed_signal_with_start/mismatched_signal_fail.rs create mode 100644 crates/client/tests/typed_signal_with_start/mismatched_signal_fail.stderr diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb112e13..4425e57b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,13 @@ relevant information. client. Only affects memo values that are themselves non-`Send`/non-`Sync`, such as those holding an `Rc` or `RefCell`. +* `Client::signal_with_start_workflow` starts a workflow and sends a typed signal atomically. + +### Breaking Changes :boom: + +* Signal-with-start is now invoked with `Client::signal_with_start_workflow`; remove uses of + `WorkflowStartOptions::start_signal` and `WorkflowStartSignal`. + ## [0.7.0] - 2026-08-17 ### Added diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 4ecccda18..8773620f9 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -45,7 +45,11 @@ tokio = { version = "1.47", default-features = false, features = [ "sync", "time", ] } -tonic = { workspace = true, default-features = false, features = ["tls-native-roots", "channel", "gzip"] } +tonic = { workspace = true, default-features = false, features = [ + "tls-native-roots", + "channel", + "gzip", +] } tokio-rustls = { version = "0.26", default-features = false } rustls-native-certs = { version = "0.8", optional = true } tower = { version = "0.5", features = ["util"] } @@ -68,6 +72,8 @@ prost = "0.14" prost-types = { workspace = true } rstest = "0.26" tempfile = "3" +temporalio-macros = { path = "../macros", version = "0.7" } +temporalio-workflow = { path = "../workflow", version = "0.7" } tokio = { version = "1.47", default-features = false, features = [ "io-util", "macros", @@ -76,6 +82,7 @@ tokio = { version = "1.47", default-features = false, features = [ "sync", "time", ] } +trybuild = { version = "1.0", features = ["diff"] } [lints] workspace = true diff --git a/crates/client/src/interceptors.rs b/crates/client/src/interceptors.rs index f9b4570f5..a140ef7f4 100644 --- a/crates/client/src/interceptors.rs +++ b/crates/client/src/interceptors.rs @@ -187,6 +187,106 @@ impl StartWorkflowInput { impl_with_args!(StartWorkflowInput); +/// Input to [`ClientInterceptor::signal_with_start_workflow`]. +#[non_exhaustive] +#[derive(derive_more::Debug)] +pub struct SignalWithStartWorkflowInput { + /// The workflow type sent to the server. + pub workflow_type: String, + /// The signal name sent to the workflow. + pub signal_name: String, + /// Options for the workflow start. + pub options: WorkflowStartOptions, + /// Controls for the signal-with-start RPC. + pub rpc_options: crate::RpcOptions, + // These remain type-erased until after interception so interceptors can replace either value + // before the client's payload converter and codec run. + #[debug(skip)] + workflow_args: Box, + #[debug(skip)] + signal_args: Box, +} + +impl SignalWithStartWorkflowInput { + pub(crate) fn new( + workflow_type: String, + workflow_args: W, + signal_name: String, + signal_args: S, + mut options: WorkflowStartOptions, + ) -> Self + where + W: TemporalSerializable + Send + 'static, + S: TemporalSerializable + Send + 'static, + { + let rpc_options = std::mem::take(&mut options.rpc_options); + Self { + workflow_type, + signal_name, + options, + rpc_options, + workflow_args: Box::new(workflow_args), + signal_args: Box::new(signal_args), + } + } + + pub(crate) fn into_parts( + self, + ) -> ( + String, + Box, + String, + Box, + WorkflowStartOptions, + crate::RpcOptions, + ) { + ( + self.workflow_type, + self.workflow_args, + self.signal_name, + self.signal_args, + self.options, + self.rpc_options, + ) + } + + /// Attempt to access the workflow arguments as a concrete type. + pub fn workflow_args_ref(&self) -> Option<&T> { + self.workflow_args.as_any().downcast_ref() + } + + /// Attempt to access the signal arguments as a concrete type. + pub fn signal_args_ref(&self) -> Option<&T> { + self.signal_args.as_any().downcast_ref() + } + + /// Attempt to mutably access the workflow arguments as a concrete type. + pub fn workflow_args_mut(&mut self) -> Option<&mut T> { + self.workflow_args.as_any_mut().downcast_mut() + } + + /// Attempt to mutably access the signal arguments as a concrete type. + pub fn signal_args_mut(&mut self) -> Option<&mut T> { + self.signal_args.as_any_mut().downcast_mut() + } + + /// Replace the workflow arguments before serialization. + pub fn replace_workflow_args(&mut self, args: T) + where + T: TemporalSerializable + Send + 'static, + { + self.workflow_args = Box::new(args); + } + + /// Replace the signal arguments before serialization. + pub fn replace_signal_args(&mut self, args: T) + where + T: TemporalSerializable + Send + 'static, + { + self.signal_args = Box::new(args); + } +} + /// Result of a successful intercepted workflow start. #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] @@ -1062,6 +1162,19 @@ pub trait ClientInterceptor: Send + Sync + 'static { next.run(input) } + /// Intercept a `signal_with_start_workflow` operation. + fn signal_with_start_workflow<'a>( + &'a self, + input: SignalWithStartWorkflowInput, + next: Next< + 'a, + SignalWithStartWorkflowInput, + BoxFuture<'a, Result>, + >, + ) -> BoxFuture<'a, Result> { + next.run(input) + } + /// Intercept a `list_workflows_page` operation. fn list_workflows_page<'a>( &'a self, @@ -1351,6 +1464,13 @@ interceptor_chain!( BoxFuture<'a, Result> ); +interceptor_chain!( + call_signal_with_start_workflow, + signal_with_start_workflow, + SignalWithStartWorkflowInput, + BoxFuture<'a, Result> +); + interceptor_chain!( call_list_workflows_page, list_workflows_page, diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index ba9e568ef..defc88f37 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -58,9 +58,9 @@ pub use interceptors::{ ListSchedulesPageOutput, ListWorkflowsPageInput, ListWorkflowsPageOutput, Next, PauseScheduleInput, PollWorkflowUpdateInput, PollWorkflowUpdateOutput, QueryWorkflowInput, QueryWorkflowOutput, ReportAsyncActivityCancellationInput, SendScheduleUpdateInput, - SignalWorkflowInput, StartWorkflowInput, StartWorkflowOutput, StartWorkflowUpdateInput, - StartWorkflowUpdateOutput, TemporalClientValue, TerminateWorkflowInput, TriggerScheduleInput, - UnpauseScheduleInput, UpdateScheduleInput, + SignalWithStartWorkflowInput, SignalWorkflowInput, StartWorkflowInput, StartWorkflowOutput, + StartWorkflowUpdateInput, StartWorkflowUpdateOutput, TemporalClientValue, + TerminateWorkflowInput, TriggerScheduleInput, UnpauseScheduleInput, UpdateScheduleInput, }; pub use metrics::{LONG_REQUEST_LATENCY_HISTOGRAM_NAME, REQUEST_LATENCY_HISTOGRAM_NAME}; pub use options_structs::*; @@ -129,19 +129,16 @@ use std::{ time::{Duration, SystemTime}, }; use temporalio_common::{ - ActivityDefinition, HasWorkflowDefinition, UntypedActivity, - data_converters::{ - DataConverter, GenericPayloadConverter, PayloadConverter, SerializationContext, - SerializationContextData, - }, - payload_visitor::{decode_payloads, encode_payloads}, + ActivityDefinition, HasWorkflowDefinition, SignalDefinition, UntypedActivity, + data_converters::{DataConverter, SerializationContext, SerializationContextData}, + payload_visitor::decode_payloads, protos::{ coresdk::IntoPayloadsExt, grpc::health::v1::health_client::HealthClient, proto_ts_to_system_time, temporal::api::{ cloud::cloudservice::v1::cloud_service_client::CloudServiceClient, - common::v1::{ActivityType, Memo as ProtoMemo, WorkflowType}, + common::v1::{ActivityType, Payloads, WorkflowType}, enums::v1::{ ActivityIdConflictPolicy as ProtoActivityIdConflictPolicy, ActivityIdReusePolicy as ProtoActivityIdReusePolicy, TaskQueueKind, @@ -1156,6 +1153,34 @@ impl Client { WorkflowClientTrait::start_workflow(self, workflow, input, options).await } + /// Atomically signal a workflow as it starts. + /// + /// The workflow receives the signal before its first workflow task. + pub async fn signal_with_start_workflow( + &self, + workflow: W, + workflow_input: W::Input, + signal: S, + signal_input: S::Input, + options: WorkflowStartOptions, + ) -> Result, WorkflowStartError> + where + W: HasWorkflowDefinition, + W::Input: Send, + S: SignalDefinition, + S::Input: Send, + { + WorkflowClientTrait::signal_with_start_workflow( + self, + workflow, + workflow_input, + signal, + signal_input, + options, + ) + .await + } + /// Get a handle to an existing workflow. /// /// For untyped access, use `get_workflow_handle::(...)`. @@ -1321,6 +1346,22 @@ pub(crate) trait WorkflowClientTrait: NamespacedClient { W: HasWorkflowDefinition, W::Input: Send; + /// Start a workflow and atomically send it a signal. + fn signal_with_start_workflow( + &self, + workflow: W, + workflow_input: W::Input, + signal: S, + signal_input: S::Input, + options: WorkflowStartOptions, + ) -> impl Future, WorkflowStartError>> + where + Self: Sized, + W: HasWorkflowDefinition, + W::Input: Send, + S: SignalDefinition, + S::Input: Send; + /// Get a handle to an existing workflow. `run_id` may be left blank to specify the most recent /// execution having the provided `workflow_id`. /// @@ -1702,106 +1743,11 @@ where let workflow_id = options.workflow_id.clone(); let task_queue_name = options.task_queue.clone(); - let memo = match options.memo { - Some(memo) => { - let payload_converter = data_converter.payload_converter(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; - let mut memo = ProtoMemo { - fields: memo - .iter() - .map(|(key, value)| { - payload_converter - .to_payload(&context, value) - .map(|payload| (key.to_owned(), payload)) - }) - .collect::>()?, - }; - encode_payloads( - &mut memo, - data_converter.codec(), - &SerializationContextData::Workflow, - ) - .await?; - Some(memo) - } - None => None, - }; + let user_metadata = options.user_metadata(); - let user_metadata = if options.static_summary.is_some() - || options.static_details.is_some() - { - let payload_converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; - Some(UserMetadata { - summary: options.static_summary.map(|summary| { - payload_converter.to_payload(&context, &summary).expect( - "String-to-JSON payload serialization is infallible", - ) - }), - details: options.static_details.map(|details| { - payload_converter.to_payload(&context, &details).expect( - "String-to-JSON payload serialization is infallible", - ) - }), - }) - } else { - None - }; + let memo = options.encoded_memo(&data_converter).await?; - let run_id = if let Some(start_signal) = options.start_signal { - let mut request = SignalWithStartWorkflowExecutionRequest { - namespace, - workflow_id: workflow_id.clone(), - workflow_type: Some(WorkflowType { - name: workflow_type, - }), - task_queue: Some(TaskQueue { - name: task_queue_name, - kind: TaskQueueKind::Normal as i32, - normal_name: String::new(), - }), - input: payloads.into_payloads(), - signal_name: start_signal.signal_name, - signal_input: start_signal.input, - identity: client.identity(), - request_id: Uuid::new_v4().to_string(), - workflow_id_reuse_policy: options.id_reuse_policy as i32, - workflow_id_conflict_policy: options.id_conflict_policy as i32, - workflow_execution_timeout: options - .execution_timeout - .and_then(|duration| duration.try_into().ok()), - workflow_run_timeout: options - .run_timeout - .and_then(|duration| duration.try_into().ok()), - workflow_task_timeout: options - .task_timeout - .and_then(|duration| duration.try_into().ok()), - search_attributes: options - .search_attributes - .map(|attributes| attributes.into_proto()), - cron_schedule: options.cron_schedule.unwrap_or_default(), - retry_policy: options.retry_policy.map(Into::into), - memo, - header: options.header.or(start_signal.header), - user_metadata, - ..Default::default() - } - .into_request(); - rpc_options.apply_to(&mut request); - WorkflowService::signal_with_start_workflow_execution( - &mut client, - request, - ) - .await? - .into_inner() - .run_id - } else { + let run_id = { let mut request = StartWorkflowExecutionRequest { namespace, input: payloads.into_payloads(), @@ -1862,9 +1808,144 @@ where } })? .into_inner() - .run_id + .run_id + }; + + Ok(StartWorkflowOutput::new(workflow_id, run_id)) + }) + } + }), + ) + .await?; + let StartWorkflowOutput { + workflow_id, + run_id, + } = interceptor_output; + + Ok(WorkflowHandle::new( + self.clone(), + WorkflowExecutionInfo { + namespace, + workflow_id, + run_id: Some(run_id.clone()), + first_execution_run_id: Some(run_id), + }, + )) + } + + async fn signal_with_start_workflow( + &self, + workflow: W, + workflow_input: W::Input, + signal: S, + signal_input: S::Input, + options: WorkflowStartOptions, + ) -> Result, WorkflowStartError> + where + W: HasWorkflowDefinition, + W::Input: Send, + S: SignalDefinition, + S::Input: Send, + { + let namespace = self.namespace(); + let interceptor_output = interceptors::call_signal_with_start_workflow( + self.client_interceptors(), + SignalWithStartWorkflowInput::new( + workflow.name().to_owned(), + workflow_input, + signal.name().to_owned(), + signal_input, + options, + ), + Next::new({ + let client = (*self).clone(); + move |input: SignalWithStartWorkflowInput| -> BoxFuture< + '_, + Result, + > { + let mut client = client; + Box::pin(async move { + let ( + workflow_type, + workflow_args, + signal_name, + signal_args, + options, + rpc_options, + ) = input.into_parts(); + let data_converter = client.data_converter().clone(); + let payload_converter = data_converter.payload_converter(); + let context = SerializationContext { + data: &SerializationContextData::Workflow, + converter: payload_converter, }; + let workflow_payloads = workflow_args.serialize_payloads(&context); + let signal_payloads = signal_args.serialize_payloads(&context); + drop(workflow_args); + drop(signal_args); + let workflow_payloads = data_converter + .codec() + .encode(&SerializationContextData::Workflow, workflow_payloads?) + .await?; + let signal_payloads = data_converter + .codec() + .encode(&SerializationContextData::Workflow, signal_payloads?) + .await?; + let workflow_id = options.workflow_id.clone(); + let task_queue_name = options.task_queue.clone(); + let user_metadata = options.user_metadata(); + + let memo = options.encoded_memo(&data_converter).await?; + + let mut request = SignalWithStartWorkflowExecutionRequest { + namespace: client.namespace(), + workflow_id: workflow_id.clone(), + workflow_type: Some(WorkflowType { + name: workflow_type, + }), + task_queue: Some(TaskQueue { + name: task_queue_name, + kind: TaskQueueKind::Normal as i32, + normal_name: String::new(), + }), + input: workflow_payloads.into_payloads(), + signal_name, + signal_input: Some(Payloads { + payloads: signal_payloads, + }), + identity: client.identity(), + request_id: Uuid::new_v4().to_string(), + workflow_id_reuse_policy: options.id_reuse_policy as i32, + workflow_id_conflict_policy: options.id_conflict_policy as i32, + workflow_execution_timeout: options + .execution_timeout + .and_then(|duration| duration.try_into().ok()), + workflow_run_timeout: options + .run_timeout + .and_then(|duration| duration.try_into().ok()), + workflow_task_timeout: options + .task_timeout + .and_then(|duration| duration.try_into().ok()), + search_attributes: options + .search_attributes + .map(|attributes| attributes.into_proto()), + cron_schedule: options.cron_schedule.unwrap_or_default(), + retry_policy: options.retry_policy.map(Into::into), + memo, + header: options.header, + user_metadata, + ..Default::default() + } + .into_request(); + rpc_options.apply_to(&mut request); + let run_id = WorkflowService::signal_with_start_workflow_execution( + &mut client, + request, + ) + .await? + .into_inner() + .run_id; Ok(StartWorkflowOutput::new(workflow_id, run_id)) }) } @@ -2885,28 +2966,34 @@ mod tests { use parking_lot::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; use temporalio_common::{ - HasWorkflowDefinition, MemoValues, WorkflowDefinition, + MemoValues, SignalDefinition, data_converters::{ - DefaultFailureConverter, PayloadCodec, PayloadConversionError, - SerializationContext, SerializationContextData, TemporalSerializable, + DefaultFailureConverter, PayloadCodec, PayloadConversionError, PayloadConverter, + SerializationContext, SerializationContextData, TemporalDeserializable, + TemporalSerializable, }, - protos::temporal::api::common::v1::Payload, + protos::temporal::api::common::v1::{Memo as ProtoMemo, Payload}, }; + use temporalio_macros::{workflow, workflow_methods}; + use temporalio_workflow::{SyncWorkflowContext, WorkflowContext, WorkflowResult}; use tonic::{Request, Response}; + #[workflow] + #[derive(Default)] struct TestWorkflow; - impl WorkflowDefinition for TestWorkflow { - type Input = Vec; - type Output = (); - - fn name(&self) -> &str { - "test-workflow" + #[workflow_methods] + impl TestWorkflow { + #[run] + async fn run( + _ctx: &mut WorkflowContext, + _input: Vec, + ) -> WorkflowResult<()> { + Ok(()) } - } - impl HasWorkflowDefinition for TestWorkflow { - type Run = Self; + #[signal] + fn test_signal(&mut self, _ctx: &mut SyncWorkflowContext, _input: Vec) {} } #[derive(Default)] @@ -2915,6 +3002,8 @@ mod tests { workflow_type: String, memo: Option, payloads: Vec, + signal_name: String, + signal_payloads: Vec, ascii_metadata: Option, binary_metadata: Option>, grpc_timeout: Option, @@ -3042,6 +3131,8 @@ mod tests { recorded.workflow_type = request.workflow_type.unwrap().name; recorded.memo = request.memo; recorded.payloads = request.input.unwrap_or_default().payloads; + recorded.signal_name = request.signal_name; + recorded.signal_payloads = request.signal_input.unwrap_or_default().payloads; recorded.ascii_metadata = ascii_metadata; recorded.binary_metadata = binary_metadata; recorded.grpc_timeout = grpc_timeout; @@ -3205,6 +3296,56 @@ mod tests { } } + struct ReplacingSignalWithStartInterceptor; + + impl ClientInterceptor for ReplacingSignalWithStartInterceptor { + fn signal_with_start_workflow<'a>( + &'a self, + mut input: SignalWithStartWorkflowInput, + next: Next< + 'a, + SignalWithStartWorkflowInput, + BoxFuture<'a, Result>, + >, + ) -> BoxFuture<'a, Result> { + assert_eq!( + input.workflow_args_ref::>().unwrap(), + &["workflow".to_owned()] + ); + assert_eq!( + input.signal_args_ref::>().unwrap(), + &["signal".to_owned()] + ); + input.replace_workflow_args(vec!["replaced-workflow".to_owned()]); + input.replace_signal_args(vec!["replaced-signal".to_owned()]); + next.run(input) + } + } + + struct FailingSignal; + + impl SignalDefinition for FailingSignal { + type Workflow = test_workflow::Run; + type Input = FailingSignalInput; + + fn name(&self) -> &str { + "failing-signal" + } + } + + struct FailingSignalInput; + + impl TemporalDeserializable for FailingSignalInput {} + + impl TemporalSerializable for FailingSignalInput { + fn to_payloads( + &self, + _context: &SerializationContext<'_>, + ) -> Result, PayloadConversionError> { + Err(PayloadConversionError::WrongEncoding) + } + } + fn mock_client( interceptors: Vec>, encode_calls: Arc, @@ -3267,7 +3408,7 @@ mod tests { client .start_workflow( - TestWorkflow, + TestWorkflow::run, vec!["initial".to_owned()], WorkflowStartOptions::new("task-queue", "workflow-id") .memo(memo) @@ -3290,12 +3431,13 @@ mod tests { memo.insert("memo-key", "memo-value".to_owned()); client - .start_workflow( - TestWorkflow, + .signal_with_start_workflow( + TestWorkflow::run, vec!["initial".to_owned()], + TestWorkflow::test_signal, + vec!["signal".to_owned()], WorkflowStartOptions::new("task-queue", "workflow-id") .memo(memo) - .start_signal(WorkflowStartSignal::new("some-signal").build()) .build(), ) .await @@ -3314,7 +3456,7 @@ mod tests { client .start_workflow( - TestWorkflow, + TestWorkflow::run, vec!["initial".to_owned()], WorkflowStartOptions::new("task-queue", "workflow-id").build(), ) @@ -3346,7 +3488,7 @@ mod tests { let err = client .start_workflow( - TestWorkflow, + TestWorkflow::run, vec!["initial".to_owned()], WorkflowStartOptions::new("task-queue", "workflow-id") .memo(memo) @@ -3388,7 +3530,7 @@ mod tests { let handle = client .start_workflow( - TestWorkflow, + TestWorkflow::run, vec!["initial".to_owned()], WorkflowStartOptions::new("task-queue", "workflow-id").build(), ) @@ -3424,7 +3566,7 @@ mod tests { ); let handle = client .start_workflow( - TestWorkflow, + TestWorkflow::run, vec!["initial".to_owned()], WorkflowStartOptions::new("task-queue", "ignored-workflow-id").build(), ) @@ -3461,7 +3603,7 @@ mod tests { client .start_workflow( - TestWorkflow, + TestWorkflow::run, vec!["initial".to_owned()], WorkflowStartOptions::new("task-queue", "workflow-id").build(), ) @@ -3484,7 +3626,7 @@ mod tests { client .start_workflow( - TestWorkflow, + TestWorkflow::run, vec!["initial".to_owned()], WorkflowStartOptions::new("task-queue", "workflow-id").build(), ) @@ -3510,7 +3652,7 @@ mod tests { options.rpc_options = rpc_options.clone(); client - .start_workflow(TestWorkflow, vec!["initial".to_owned()], options) + .start_workflow(TestWorkflow::run, vec!["initial".to_owned()], options) .await .unwrap(); @@ -3523,10 +3665,15 @@ mod tests { } let mut options = WorkflowStartOptions::new("task-queue", "signal-workflow-id").build(); - options.start_signal = Some(WorkflowStartSignal::new("signal-name").build()); options.rpc_options = rpc_options; let handle = client - .start_workflow(TestWorkflow, vec!["initial".to_owned()], options) + .signal_with_start_workflow( + TestWorkflow::run, + vec!["initial".to_owned()], + TestWorkflow::test_signal, + vec!["signal".to_owned()], + options, + ) .await .unwrap(); @@ -3536,9 +3683,77 @@ mod tests { assert_eq!(recorded.binary_metadata.as_deref(), Some(&[0, 255][..])); assert_eq!(recorded.grpc_timeout.as_deref(), Some("250000u")); assert_eq!(recorded.retry_options, Some(RetryOptions::no_retries())); + assert_eq!(recorded.signal_name, "test_signal"); + assert_eq!(recorded.signal_payloads.len(), 1); assert_eq!(handle.run_id(), Some("signal-server-run-id")); } + #[tokio::test] + async fn signal_with_start_interceptor_can_replace_both_argument_sets() { + let (client, recorded) = mock_client( + vec![Arc::new(ReplacingSignalWithStartInterceptor)], + Arc::new(AtomicUsize::new(0)), + ); + + client + .signal_with_start_workflow( + TestWorkflow::run, + vec!["workflow".to_owned()], + TestWorkflow::test_signal, + vec!["signal".to_owned()], + WorkflowStartOptions::new("task-queue", "workflow-id").build(), + ) + .await + .unwrap(); + + let data_converter = DataConverter::default(); + let (workflow_payloads, signal_payloads) = { + let recorded = recorded.lock(); + (recorded.payloads.clone(), recorded.signal_payloads.clone()) + }; + assert_eq!( + data_converter + .from_payloads::>( + &SerializationContextData::Workflow, + workflow_payloads, + ) + .await + .unwrap(), + vec!["replaced-workflow".to_owned()] + ); + assert_eq!( + data_converter + .from_payloads::>( + &SerializationContextData::Workflow, + signal_payloads, + ) + .await + .unwrap(), + vec!["replaced-signal".to_owned()] + ); + } + + #[tokio::test] + async fn signal_with_start_payload_conversion_failure_does_not_call_service() { + let (client, recorded) = mock_client(Vec::new(), Arc::new(AtomicUsize::new(0))); + + let result = client + .signal_with_start_workflow( + TestWorkflow::run, + vec!["workflow".to_owned()], + FailingSignal, + FailingSignalInput, + WorkflowStartOptions::new("task-queue", "workflow-id").build(), + ) + .await; + + assert!(matches!( + result, + Err(WorkflowStartError::PayloadConversion(_)) + )); + assert_eq!(recorded.lock().calls, 0); + } + #[test] fn rpc_metadata_combines_with_and_overrides_connection_defaults() { let headers = Arc::new(RwLock::new(ClientHeaders { @@ -3617,7 +3832,7 @@ mod tests { use futures_util::{FutureExt, StreamExt}; use std::sync::atomic::{AtomicUsize, Ordering}; use temporalio_common::{ - data_converters::DefaultFailureConverter, + data_converters::{DefaultFailureConverter, PayloadConverter}, protos::temporal::api::common::v1::{ Memo as ProtoMemo, Payload, WorkflowExecution as ProtoWorkflowExecution, }, diff --git a/crates/client/src/options_structs.rs b/crates/client/src/options_structs.rs index 68f2aeecb..a67133678 100644 --- a/crates/client/src/options_structs.rs +++ b/crates/client/src/options_structs.rs @@ -6,11 +6,15 @@ use http::Uri; use std::{collections::HashMap, sync::Arc, time::Duration}; use temporalio_common::{ ActivityCloseTimeouts, MemoValues, RetryPolicy, - data_converters::DataConverter, + data_converters::{ + DataConverter, GenericPayloadConverter, PayloadConversionError, PayloadConverter, + SerializationContext, SerializationContextData, + }, + payload_visitor::encode_payloads, protos::temporal::api::{ common::{ self, - v1::{Header, Payloads}, + v1::{Header, Memo as ProtoMemo, Payloads}, }, enums::v1::{ ActivityIdConflictPolicy as ProtoActivityIdConflictPolicy, @@ -19,6 +23,7 @@ use temporalio_common::{ WorkflowIdReusePolicy, }, replication::v1::ClusterReplicationConfig, + sdk::v1::UserMetadata, workflowservice::v1::RegisterNamespaceRequest, }, search_attributes::SearchAttributes, @@ -421,10 +426,6 @@ pub struct WorkflowStartOptions { #[builder(into)] pub retry_policy: Option, - /// If set, send a signal to the workflow atomically with start. - /// The workflow will receive this signal before its first task. - pub start_signal: Option, - /// Links to associate with the workflow. Ex: References to a nexus operation. #[builder(default)] pub links: Vec, @@ -455,19 +456,60 @@ pub struct WorkflowStartOptions { pub rpc_options: RpcOptions, } -/// A signal to send atomically when starting a workflow. -/// Use with `WorkflowStartOptions::start_signal` to achieve signal-with-start behavior. -#[derive(Debug, Clone, bon::Builder)] -#[builder(start_fn = new, on(String, into))] -#[non_exhaustive] -pub struct WorkflowStartSignal { - /// Name of the signal to send. - #[builder(start_fn)] - pub signal_name: String, - /// Payload for the signal. - pub input: Option, - /// Headers for the signal. - pub header: Option
, +impl WorkflowStartOptions { + pub(crate) async fn encoded_memo( + &self, + data_converter: &DataConverter, + ) -> Result, PayloadConversionError> { + let Some(memo) = &self.memo else { + return Ok(None); + }; + + let payload_converter = data_converter.payload_converter(); + let context = SerializationContext { + data: &SerializationContextData::Workflow, + converter: payload_converter, + }; + let mut memo = ProtoMemo { + fields: memo + .iter() + .map(|(key, value)| { + payload_converter + .to_payload(&context, value) + .map(|payload| (key.to_owned(), payload)) + }) + .collect::>()?, + }; + encode_payloads( + &mut memo, + data_converter.codec(), + &SerializationContextData::Workflow, + ) + .await?; + Ok(Some(memo)) + } + + pub(crate) fn user_metadata(&self) -> Option { + (self.static_summary.is_some() || self.static_details.is_some()).then(|| { + let payload_converter = PayloadConverter::default(); + let context = SerializationContext { + data: &SerializationContextData::Workflow, + converter: &payload_converter, + }; + UserMetadata { + summary: self.static_summary.as_ref().map(|summary| { + payload_converter + .to_payload(&context, summary) + .expect("String-to-JSON payload serialization is infallible") + }), + details: self.static_details.as_ref().map(|details| { + payload_converter + .to_payload(&context, details) + .expect("String-to-JSON payload serialization is infallible") + }), + } + }) + } } pub use temporalio_common::Priority; diff --git a/crates/client/tests/typed_signal_with_start.rs b/crates/client/tests/typed_signal_with_start.rs new file mode 100644 index 000000000..241b03dd1 --- /dev/null +++ b/crates/client/tests/typed_signal_with_start.rs @@ -0,0 +1,5 @@ +#[test] +fn typed_signal_with_start_build_tests() { + let tests = trybuild::TestCases::new(); + tests.compile_fail("tests/typed_signal_with_start/*_fail.rs"); +} diff --git a/crates/client/tests/typed_signal_with_start/mismatched_signal_fail.rs b/crates/client/tests/typed_signal_with_start/mismatched_signal_fail.rs new file mode 100644 index 000000000..f0f9dea5b --- /dev/null +++ b/crates/client/tests/typed_signal_with_start/mismatched_signal_fail.rs @@ -0,0 +1,45 @@ +use temporalio_client::{Client, WorkflowStartOptions}; +use temporalio_macros::{workflow, workflow_methods}; +use temporalio_workflow::{SyncWorkflowContext, WorkflowContext, WorkflowResult}; + +#[workflow] +#[derive(Default)] +struct FirstWorkflow; + +#[workflow_methods] +impl FirstWorkflow { + #[run] + async fn run(_ctx: &mut WorkflowContext) -> WorkflowResult<()> { + Ok(()) + } + + #[signal] + fn first_signal(&mut self, _ctx: &mut SyncWorkflowContext, _input: String) {} +} + +#[workflow] +#[derive(Default)] +struct SecondWorkflow; + +#[workflow_methods] +impl SecondWorkflow { + #[run] + async fn run(_ctx: &mut WorkflowContext) -> WorkflowResult<()> { + Ok(()) + } + + #[signal] + fn second_signal(&mut self, _ctx: &mut SyncWorkflowContext, _input: String) {} +} + +fn mismatched_signal(client: &Client) { + let _ = client.signal_with_start_workflow( + FirstWorkflow::run, + (), + SecondWorkflow::second_signal, + "signal".to_owned(), + WorkflowStartOptions::new("task-queue", "workflow-id").build(), + ); +} + +fn main() {} diff --git a/crates/client/tests/typed_signal_with_start/mismatched_signal_fail.stderr b/crates/client/tests/typed_signal_with_start/mismatched_signal_fail.stderr new file mode 100644 index 000000000..671664706 --- /dev/null +++ b/crates/client/tests/typed_signal_with_start/mismatched_signal_fail.stderr @@ -0,0 +1,34 @@ +error[E0271]: type mismatch resolving `::Workflow == Run` + --> tests/typed_signal_with_start/mismatched_signal_fail.rs:39:9 + | +36 | let _ = client.signal_with_start_workflow( + | -------------------------- required by a bound introduced by this call +... +39 | SecondWorkflow::second_signal, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ type mismatch resolving `::Workflow == Run` + | +note: expected this to be `first_workflow::Run` + --> tests/typed_signal_with_start/mismatched_signal_fail.rs:24:1 + | +24 | #[workflow_methods] + | ^^^^^^^^^^^^^^^^^^^ + = note: `second_workflow::Run` and `first_workflow::Run` have similar names, but are actually distinct types +note: `second_workflow::Run` is defined in module `crate::second_workflow` of the current crate + --> tests/typed_signal_with_start/mismatched_signal_fail.rs:24:1 + | +24 | #[workflow_methods] + | ^^^^^^^^^^^^^^^^^^^ +note: `first_workflow::Run` is defined in module `crate::first_workflow` of the current crate + --> tests/typed_signal_with_start/mismatched_signal_fail.rs:9:1 + | + 9 | #[workflow_methods] + | ^^^^^^^^^^^^^^^^^^^ +note: required by a bound in `temporalio_client::Client::signal_with_start_workflow` + --> src/lib.rs + | + | pub async fn signal_with_start_workflow( + | -------------------------- required by a bound in this associated function +... + | S: SignalDefinition, + | ^^^^^^^^^^^^^^^^^ required by this bound in `Client::signal_with_start_workflow` + = note: this error originates in the attribute macro `workflow_methods` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/signals.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/signals.rs index ea9d8792a..4c65c6dd0 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/signals.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/signals.rs @@ -1,9 +1,13 @@ use crate::common::{ActivationAssertionsInterceptor, CoreWfStarter}; -use std::collections::HashMap; -use temporalio_client::{WorkflowStartOptions, WorkflowStartSignal}; +use futures::future::BoxFuture; +use std::{collections::HashMap, sync::Arc}; +use temporalio_client::{ + ClientInterceptor, Next, SignalWithStartWorkflowInput, StartWorkflowOutput, + WorkflowStartOptions, errors::WorkflowStartError, +}; use temporalio_common::protos::{ coresdk::{ - AsJsonPayloadExt, IntoPayloadsExt, + AsJsonPayloadExt, workflow_activation::{ ResolveSignalExternalWorkflow, WorkflowActivationJob, workflow_activation_job, }, @@ -21,6 +25,10 @@ use temporalio_macros::{workflow, workflow_methods}; use temporalio_sdk::{ ApplicationFailure, CancellableFuture, ChildWorkflowOptions, SignalWorkflowOptions, SyncWorkflowContext, WorkflowContext, WorkflowResult, + workflow_interceptors::{ + HandleSignalInput, HandleSignalResult, WorkflowInterceptor, WorkflowInterceptorConstructor, + WorkflowInterceptorContext, WorkflowInterceptorFuture, WorkflowNext, + }, }; use temporalio_sdk_core::test_help::MockPollCfg; use uuid::Uuid; @@ -114,14 +122,49 @@ impl SignalWithCreateWfReceiver { } #[signal(name = "signame")] - fn handle_signal(&mut self, ctx: &mut SyncWorkflowContext, input: String) { + fn handle_signal(&mut self, _ctx: &mut SyncWorkflowContext, input: String) { assert_eq!(input, "tada"); - let headers = ctx.headers(); + self.received = true; + } +} + +struct SignalWithStartHeaderClientInterceptor; + +impl ClientInterceptor for SignalWithStartHeaderClientInterceptor { + fn signal_with_start_workflow<'a>( + &'a self, + mut input: SignalWithStartWorkflowInput, + next: Next< + 'a, + SignalWithStartWorkflowInput, + BoxFuture<'a, Result>, + >, + ) -> BoxFuture<'a, Result> { + input.options.header = + Some(HashMap::from([("tupac".to_string(), Payload::from("shakur"))]).into()); + next.run(input) + } +} + +struct SignalHeaderWorkflowInterceptor; + +impl WorkflowInterceptor for SignalHeaderWorkflowInterceptor { + fn handle_signal<'a>( + &'a self, + _ctx: WorkflowInterceptorContext, + input: HandleSignalInput, + next: WorkflowNext< + 'a, + HandleSignalInput, + WorkflowInterceptorFuture<'a, HandleSignalResult>, + >, + ) -> WorkflowInterceptorFuture<'a, HandleSignalResult> { + assert_eq!(input.name(), SIGNAME); assert_eq!( - *headers.get("tupac").expect("tupac header exists"), + *input.headers().get("tupac").expect("tupac header exists"), b"shakur".into() ); - self.received = true; + next.run(input) } } @@ -164,22 +207,27 @@ async fn sends_signal_with_create_wf() { starter .sdk_config .register_workflow::() - .unwrap(); + .unwrap() + .register_workflow_interceptors(vec![WorkflowInterceptorConstructor::new(|_| { + SignalHeaderWorkflowInterceptor + })]); let mut worker = starter.worker().await; - let client = starter.get_core_client().await; - let mut header: HashMap = HashMap::new(); - header.insert("tupac".into(), "shakur".into()); + let mut client = starter.get_core_client().await; + client + .options_mut() + .client_interceptors + .push(Arc::new(SignalWithStartHeaderClientInterceptor)); let task_queue = worker.inner_mut().task_queue().to_string(); - let start_signal = WorkflowStartSignal::new(SIGNAME) - .maybe_input(vec!["tada".to_string().as_json_payload().unwrap()].into_payloads()) - .maybe_header(Some(header.into())) - .build(); - let options = WorkflowStartOptions::new(task_queue, "sends_signal_with_create_wf") - .start_signal(start_signal) - .build(); + let options = WorkflowStartOptions::new(task_queue, "sends_signal_with_create_wf").build(); let handle = client - .start_workflow(SignalWithCreateWfReceiver::run, (), options) + .signal_with_start_workflow( + SignalWithCreateWfReceiver::run, + (), + SignalWithCreateWfReceiver::handle_signal, + "tada".to_string(), + options, + ) .await .expect("request succeeds.qed"); From 5fe40e2fcb7c74b4609137ae7f884ee46e235bde Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Thu, 20 Aug 2026 15:44:57 -0400 Subject: [PATCH 16/87] chore: remove some trybuild tests (#1510) --- crates/sdk-core/tests/activities_procmacro.rs | 1 - .../tests/activities_trybuild/basic_pass.rs | 54 ------- .../activities_trybuild/multi_arg_pass.rs | 48 ------- .../activities_trybuild/no_input_pass.rs | 14 -- .../no_return_type_pass.rs | 19 --- crates/sdk-core/tests/fsm_procmacro.rs | 1 - .../tests/fsm_trybuild/dynamic_dest_pass.rs | 39 ------ .../tests/fsm_trybuild/handler_arg_pass.rs | 30 ---- .../tests/fsm_trybuild/handler_pass.rs | 29 ---- .../tests/fsm_trybuild/medium_complex_pass.rs | 44 ------ .../tests/fsm_trybuild/simple_pass.rs | 30 ---- crates/sdk-core/tests/workflows_procmacro.rs | 1 - .../tests/workflows_trybuild/basic_pass.rs | 49 ------- .../tests/workflows_trybuild/minimal_pass.rs | 21 --- crates/sdk/src/testing.rs | 132 +++++++++++++++++- 15 files changed, 131 insertions(+), 381 deletions(-) delete mode 100644 crates/sdk-core/tests/activities_trybuild/basic_pass.rs delete mode 100644 crates/sdk-core/tests/activities_trybuild/multi_arg_pass.rs delete mode 100644 crates/sdk-core/tests/activities_trybuild/no_input_pass.rs delete mode 100644 crates/sdk-core/tests/activities_trybuild/no_return_type_pass.rs delete mode 100644 crates/sdk-core/tests/fsm_trybuild/dynamic_dest_pass.rs delete mode 100644 crates/sdk-core/tests/fsm_trybuild/handler_arg_pass.rs delete mode 100644 crates/sdk-core/tests/fsm_trybuild/handler_pass.rs delete mode 100644 crates/sdk-core/tests/fsm_trybuild/medium_complex_pass.rs delete mode 100644 crates/sdk-core/tests/fsm_trybuild/simple_pass.rs delete mode 100644 crates/sdk-core/tests/workflows_trybuild/basic_pass.rs delete mode 100644 crates/sdk-core/tests/workflows_trybuild/minimal_pass.rs diff --git a/crates/sdk-core/tests/activities_procmacro.rs b/crates/sdk-core/tests/activities_procmacro.rs index f51371952..01e73fc7a 100644 --- a/crates/sdk-core/tests/activities_procmacro.rs +++ b/crates/sdk-core/tests/activities_procmacro.rs @@ -1,6 +1,5 @@ #[test] fn activities_procmacro_build_tests() { let t = trybuild::TestCases::new(); - t.pass("tests/activities_trybuild/*_pass.rs"); t.compile_fail("tests/activities_trybuild/*_fail.rs"); } diff --git a/crates/sdk-core/tests/activities_trybuild/basic_pass.rs b/crates/sdk-core/tests/activities_trybuild/basic_pass.rs deleted file mode 100644 index ac37a2ca0..000000000 --- a/crates/sdk-core/tests/activities_trybuild/basic_pass.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::sync::Arc; -use temporalio_macros::activities; -use temporalio_sdk::activities::{ActivityContext, ActivityError}; - -pub struct MyActivities; - -#[activities] -impl MyActivities { - #[activity] - pub async fn static_activity( - _ctx: ActivityContext, - _in: String, - ) -> Result { - Ok("Can be static".to_string()) - } - - #[activity] - pub async fn activity( - self: Arc, - _ctx: ActivityContext, - _in: bool, - ) -> Result { - Ok("I'm done!".to_string()) - } - - #[activity] - pub async fn activity_arc_fully_qualified( - self: std::sync::Arc, - _ctx: ActivityContext, - _in: bool, - ) -> Result { - Ok("I'm done!".to_string()) - } - - #[activity] - pub fn sync_activity(_ctx: ActivityContext, _in: bool) -> Result { - Ok("Sync activities are supported too".to_string()) - } -} - -pub struct MyActivitiesStatic; - -#[activities] -impl MyActivitiesStatic { - #[activity] - pub async fn static_activity( - _ctx: ActivityContext, - _in: String, - ) -> Result { - Ok("Can be static".to_string()) - } -} - -fn main() {} diff --git a/crates/sdk-core/tests/activities_trybuild/multi_arg_pass.rs b/crates/sdk-core/tests/activities_trybuild/multi_arg_pass.rs deleted file mode 100644 index 659761197..000000000 --- a/crates/sdk-core/tests/activities_trybuild/multi_arg_pass.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::sync::Arc; -use temporalio_macros::activities; -use temporalio_sdk::activities::{ActivityContext, ActivityError}; - -pub struct MultiArgActivities; - -#[activities] -impl MultiArgActivities { - #[activity] - pub async fn two_args( - _ctx: ActivityContext, - _a: String, - _b: i32, - ) -> Result { - Ok("done".to_string()) - } - - #[activity] - pub async fn three_args( - _ctx: ActivityContext, - _a: String, - _b: i32, - _c: bool, - ) -> Result { - Ok("done".to_string()) - } - - #[activity] - pub async fn instance_two_args( - self: Arc, - _ctx: ActivityContext, - _a: String, - _b: i32, - ) -> Result { - Ok("done".to_string()) - } - - #[activity] - pub fn sync_two_args( - _ctx: ActivityContext, - _a: String, - _b: i32, - ) -> Result { - Ok("done".to_string()) - } -} - -fn main() {} diff --git a/crates/sdk-core/tests/activities_trybuild/no_input_pass.rs b/crates/sdk-core/tests/activities_trybuild/no_input_pass.rs deleted file mode 100644 index f7af64181..000000000 --- a/crates/sdk-core/tests/activities_trybuild/no_input_pass.rs +++ /dev/null @@ -1,14 +0,0 @@ -use temporalio_macros::activities; -use temporalio_sdk::activities::{ActivityContext, ActivityError}; - -pub struct SimpleActivities; - -#[activities] -impl SimpleActivities { - #[activity] - pub async fn no_input_activity(_ctx: ActivityContext) -> Result { - Ok("No input needed".to_string()) - } -} - -fn main() {} diff --git a/crates/sdk-core/tests/activities_trybuild/no_return_type_pass.rs b/crates/sdk-core/tests/activities_trybuild/no_return_type_pass.rs deleted file mode 100644 index b4697aa11..000000000 --- a/crates/sdk-core/tests/activities_trybuild/no_return_type_pass.rs +++ /dev/null @@ -1,19 +0,0 @@ -use temporalio_macros::activities; -use temporalio_sdk::activities::ActivityContext; - -pub struct VoidActivities; - -#[activities] -impl VoidActivities { - #[activity] - pub async fn no_return(_ctx: ActivityContext, _in: String) { - println!("Doing work..."); - } - - #[activity] - pub fn sync_no_return(_ctx: ActivityContext) { - println!("Sync work..."); - } -} - -fn main() {} diff --git a/crates/sdk-core/tests/fsm_procmacro.rs b/crates/sdk-core/tests/fsm_procmacro.rs index 0ce83e788..ec126abbc 100644 --- a/crates/sdk-core/tests/fsm_procmacro.rs +++ b/crates/sdk-core/tests/fsm_procmacro.rs @@ -1,6 +1,5 @@ #[test] fn fsm_procmacro_build_tests() { let t = trybuild::TestCases::new(); - t.pass("tests/fsm_trybuild/*_pass.rs"); t.compile_fail("tests/fsm_trybuild/*_fail.rs"); } diff --git a/crates/sdk-core/tests/fsm_trybuild/dynamic_dest_pass.rs b/crates/sdk-core/tests/fsm_trybuild/dynamic_dest_pass.rs deleted file mode 100644 index 60e8d2142..000000000 --- a/crates/sdk-core/tests/fsm_trybuild/dynamic_dest_pass.rs +++ /dev/null @@ -1,39 +0,0 @@ -#![allow(dead_code)] - -use std::convert::Infallible; -use temporalio_common::fsm_trait::TransitionResult; -use temporalio_macros::fsm; - -fsm! { - name SimpleMachine; command SimpleMachineCommand; error Infallible; - - One --(A(String), foo)--> Two; - One --(A(String), foo)--> Three; - - Two --(B(String), bar)--> One; - Two --(B(String), bar)--> Two; - Two --(B(String), bar)--> Three; -} - -#[derive(Default, Clone)] -pub struct One {} -impl One { - fn foo(self, _: String) -> SimpleMachineTransition { - TransitionResult::ok(vec![], Two {}.into()) - } -} - -#[derive(Default, Clone)] -pub struct Two {} -impl Two { - fn bar(self, _: String) -> SimpleMachineTransition { - TransitionResult::ok(vec![], Three {}.into()) - } -} - -#[derive(Default, Clone)] -pub struct Three {} - -pub enum SimpleMachineCommand {} - -fn main() {} diff --git a/crates/sdk-core/tests/fsm_trybuild/handler_arg_pass.rs b/crates/sdk-core/tests/fsm_trybuild/handler_arg_pass.rs deleted file mode 100644 index 16d4ed9c7..000000000 --- a/crates/sdk-core/tests/fsm_trybuild/handler_arg_pass.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::convert::Infallible; -use temporalio_common::fsm_trait::TransitionResult; -use temporalio_macros::fsm; - -fsm! { - name Simple; command SimpleCommand; error Infallible; - - One --(A(String), on_a)--> Two -} - -#[derive(Default, Clone)] -pub struct One {} -impl One { - fn on_a(self, _: String) -> SimpleTransition { - SimpleTransition::ok(vec![], Two {}) - } -} - -#[derive(Default, Clone)] -pub struct Two {} - -pub enum SimpleCommand {} - -fn main() { - // state enum exists with both states - let _ = SimpleState::One(One {}); - let _ = SimpleState::Two(Two {}); - // Avoid dead code warning - let _ = SimpleEvents::A("yo".to_owned()); -} diff --git a/crates/sdk-core/tests/fsm_trybuild/handler_pass.rs b/crates/sdk-core/tests/fsm_trybuild/handler_pass.rs deleted file mode 100644 index 30d73bf8d..000000000 --- a/crates/sdk-core/tests/fsm_trybuild/handler_pass.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::convert::Infallible; -use temporalio_common::fsm_trait::TransitionResult; -use temporalio_macros::fsm; - -fsm! { - name Simple; command SimpleCommand; error Infallible; - - One --(A, on_a)--> Two -} - -#[derive(Default, Clone)] -pub struct One {} -impl One { - fn on_a(self) -> SimpleTransition { - SimpleTransition::ok(vec![], Two {}) - } -} - -#[derive(Default, Clone)] -pub struct Two {} - -pub enum SimpleCommand {} - -fn main() { - // state enum exists with both states - let _ = SimpleState::One(One {}); - let _ = SimpleState::Two(Two {}); - let _ = SimpleEvents::A; -} diff --git a/crates/sdk-core/tests/fsm_trybuild/medium_complex_pass.rs b/crates/sdk-core/tests/fsm_trybuild/medium_complex_pass.rs deleted file mode 100644 index 6246ec17e..000000000 --- a/crates/sdk-core/tests/fsm_trybuild/medium_complex_pass.rs +++ /dev/null @@ -1,44 +0,0 @@ -#![allow(dead_code)] - -use std::convert::Infallible; -use temporalio_common::fsm_trait::TransitionResult; -use temporalio_macros::fsm; - -fsm! { - name SimpleMachine; command SimpleMachineCommand; error Infallible; - - One --(A(String), foo)--> Two; - One --(B)--> Two; - Two --(B)--> One; - Two --(C, baz)--> One -} - -#[derive(Default, Clone)] -pub struct One {} -impl One { - fn foo(self, _: String) -> SimpleMachineTransition { - TransitionResult::default() - } -} -impl From for One { - fn from(_: Two) -> Self { - One {} - } -} - -#[derive(Default, Clone)] -pub struct Two {} -impl Two { - fn baz(self) -> SimpleMachineTransition { - TransitionResult::default() - } -} -impl From for Two { - fn from(_: One) -> Self { - Two {} - } -} - -pub enum SimpleMachineCommand {} - -fn main() {} diff --git a/crates/sdk-core/tests/fsm_trybuild/simple_pass.rs b/crates/sdk-core/tests/fsm_trybuild/simple_pass.rs deleted file mode 100644 index f090c390d..000000000 --- a/crates/sdk-core/tests/fsm_trybuild/simple_pass.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::convert::Infallible; -use temporalio_common::fsm_trait::TransitionResult; -use temporalio_macros::fsm; - -fsm! { - name SimpleMachine; command SimpleMachineCommand; error Infallible; - - One --(A)--> Two -} - -#[derive(Default, Clone)] -pub struct One {} - -#[derive(Default, Clone)] -pub struct Two {} -impl From for Two { - fn from(_: One) -> Self { - Two {} - } -} - -pub enum SimpleMachineCommand {} - -fn main() { - // state enum exists with both states - let _ = SimpleMachineState::One(One {}); - let _ = SimpleMachineState::Two(Two {}); - // Event enum exists - let _ = SimpleMachineEvents::A; -} diff --git a/crates/sdk-core/tests/workflows_procmacro.rs b/crates/sdk-core/tests/workflows_procmacro.rs index 2481a2304..90ba02dbc 100644 --- a/crates/sdk-core/tests/workflows_procmacro.rs +++ b/crates/sdk-core/tests/workflows_procmacro.rs @@ -1,6 +1,5 @@ #[test] fn workflows_procmacro_build_tests() { let t = trybuild::TestCases::new(); - t.pass("tests/workflows_trybuild/*_pass.rs"); t.compile_fail("tests/workflows_trybuild/*_fail.rs"); } diff --git a/crates/sdk-core/tests/workflows_trybuild/basic_pass.rs b/crates/sdk-core/tests/workflows_trybuild/basic_pass.rs deleted file mode 100644 index 785e8f389..000000000 --- a/crates/sdk-core/tests/workflows_trybuild/basic_pass.rs +++ /dev/null @@ -1,49 +0,0 @@ -use temporalio_macros::{workflow, workflow_methods}; -use temporalio_sdk::{SyncWorkflowContext, WorkflowContext, WorkflowContextView, WorkflowResult}; - -#[workflow] -pub struct MyWorkflow { - counter: u32, -} - -#[workflow_methods] -impl MyWorkflow { - #[init] - pub fn new(_ctx: &WorkflowContextView, _input: String) -> Self { - Self { counter: 0 } - } - - // Async run uses &self - #[run] - pub async fn run(_ctx: &mut WorkflowContext) -> WorkflowResult { - Ok("hi".to_owned()) - } - - // Sync signal uses &mut self - #[signal(name = "increment")] - pub fn increment_counter(&mut self, _ctx: &mut SyncWorkflowContext, amount: u32) { - self.counter += amount; - } - - #[signal] - pub async fn async_signal(_ctx: &mut WorkflowContext) {} - - // Query uses &self with read-only context - #[query] - pub fn get_counter(&self, _ctx: &WorkflowContextView) -> u32 { - self.counter - } - - #[update(name = "double")] - pub fn double_counter(&mut self, _ctx: &mut SyncWorkflowContext) -> u32 { - self.counter *= 2; - self.counter - } - - #[update] - pub async fn async_update(_ctx: &mut WorkflowContext, val: i32) -> i32 { - val * 2 - } -} - -fn main() {} diff --git a/crates/sdk-core/tests/workflows_trybuild/minimal_pass.rs b/crates/sdk-core/tests/workflows_trybuild/minimal_pass.rs deleted file mode 100644 index cde1467dd..000000000 --- a/crates/sdk-core/tests/workflows_trybuild/minimal_pass.rs +++ /dev/null @@ -1,21 +0,0 @@ -use temporalio_macros::{workflow, workflow_methods}; -use temporalio_sdk::{WorkflowContext, WorkflowResult}; - -#[workflow] -pub struct MinimalWorkflow; - -#[workflow_methods] -impl MinimalWorkflow { - #[run] - pub async fn run(_ctx: &mut WorkflowContext) -> WorkflowResult<()> { - Ok(()) - } -} - -impl Default for MinimalWorkflow { - fn default() -> Self { - Self - } -} - -fn main() {} diff --git a/crates/sdk/src/testing.rs b/crates/sdk/src/testing.rs index bbeef3912..e2a4b4c94 100644 --- a/crates/sdk/src/testing.rs +++ b/crates/sdk/src/testing.rs @@ -535,6 +535,7 @@ pub enum WorkflowEnvironmentError { mod tests { use super::*; use std::sync::Mutex; + use temporalio_common::data_converters::{MultiArgs2, MultiArgs3}; use temporalio_macros::activities; struct TestActivities { @@ -550,7 +551,7 @@ mod tests { #[activity] async fn prefixed( - self: Arc, + self: std::sync::Arc, _ctx: ActivityContext, value: String, ) -> Result { @@ -580,6 +581,71 @@ mod tests { } } + struct ActivityMacroShapes; + + #[activities] + impl ActivityMacroShapes { + #[activity] + fn sync(_ctx: ActivityContext, value: bool) -> Result { + Ok(value.to_string()) + } + + #[activity] + async fn no_input(_ctx: ActivityContext) -> Result { + Ok("no input".to_owned()) + } + + #[activity] + async fn async_no_return(_ctx: ActivityContext, _value: String) {} + + #[activity] + fn sync_no_return(_ctx: ActivityContext) {} + } + + struct MultiArgActivities; + + #[activities] + impl MultiArgActivities { + #[activity] + async fn two_args( + _ctx: ActivityContext, + first: String, + second: i32, + ) -> Result { + Ok(format!("{first}:{second}")) + } + + #[activity] + async fn three_args( + _ctx: ActivityContext, + first: String, + second: i32, + third: bool, + ) -> Result { + Ok(format!("{first}:{second}:{third}")) + } + + #[activity] + async fn instance_two_args( + self: Arc, + _ctx: ActivityContext, + first: String, + second: i32, + ) -> Result { + let _ = self; + Ok(format!("{first}:{second}")) + } + + #[activity] + fn sync_two_args( + _ctx: ActivityContext, + first: String, + second: i32, + ) -> Result { + Ok(format!("{first}:{second}")) + } + } + #[tokio::test] async fn runs_static_activities_without_instance() { let env = ActivityEnvironment::builder().build(); @@ -614,6 +680,70 @@ mod tests { ); } + #[tokio::test] + async fn runs_sync_and_unit_output_activities() { + let env = ActivityEnvironment::builder().build(); + + assert_eq!( + env.run(ActivityMacroShapes::sync, true).await.unwrap(), + "true" + ); + assert_eq!( + env.run(ActivityMacroShapes::no_input, ()).await.unwrap(), + "no input" + ); + env.run(ActivityMacroShapes::async_no_return, "value".to_owned()) + .await + .unwrap(); + env.run(ActivityMacroShapes::sync_no_return, ()) + .await + .unwrap(); + } + + #[tokio::test] + async fn runs_multi_argument_activities() { + let env = ActivityEnvironment::builder() + .register_activities(MultiArgActivities) + .build(); + + assert_eq!( + env.run( + MultiArgActivities::two_args, + MultiArgs2("one".to_owned(), 2), + ) + .await + .unwrap(), + "one:2" + ); + assert_eq!( + env.run( + MultiArgActivities::three_args, + MultiArgs3("one".to_owned(), 2, true), + ) + .await + .unwrap(), + "one:2:true" + ); + assert_eq!( + env.run( + MultiArgActivities::instance_two_args, + MultiArgs2("one".to_owned(), 2), + ) + .await + .unwrap(), + "one:2" + ); + assert_eq!( + env.run( + MultiArgActivities::sync_two_args, + MultiArgs2("one".to_owned(), 2), + ) + .await + .unwrap(), + "one:2" + ); + } + #[tokio::test] async fn missing_instance_is_an_environment_error() { let error = ActivityEnvironment::builder() From 090afd3c29350f50f2d501e581c675054655f34e Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Fri, 21 Aug 2026 12:01:25 -0700 Subject: [PATCH 17/87] Add workflow for sentinel comment responses (#1514) --- .../workflows/sdk-sentinel-pr-responder.yml | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/sdk-sentinel-pr-responder.yml diff --git a/.github/workflows/sdk-sentinel-pr-responder.yml b/.github/workflows/sdk-sentinel-pr-responder.yml new file mode 100644 index 000000000..5389ff08a --- /dev/null +++ b/.github/workflows/sdk-sentinel-pr-responder.yml @@ -0,0 +1,57 @@ +name: SDK Sentinel PR responder relay + +on: + issue_comment: + types: [created] + +permissions: {} + +jobs: + relay: + if: >- + github.event.issue.pull_request && + github.event.comment.user.login != 'sdk-sentinel-bot' && + contains(github.event.comment.body, '@sdk-sentinel-bot') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Mint a Sentinel-only dispatch token from the Relay App + id: dispatch-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.SDK_SENTINEL_RELAY_APP_ID }} + private-key: ${{ secrets.SDK_SENTINEL_RELAY_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-sentinel + permission-actions: write + permission-metadata: read + - name: Verify the Sentinel Relay installation + env: + ACTUAL_APP_SLUG: ${{ steps.dispatch-token.outputs.app-slug }} + ACTUAL_INSTALLATION_ID: ${{ steps.dispatch-token.outputs.installation-id }} + EXPECTED_INSTALLATION_ID: ${{ vars.SDK_SENTINEL_RELAY_INSTALLATION_ID }} + run: | + test "$ACTUAL_APP_SLUG" = sdk-sentinel-relay + test "$ACTUAL_INSTALLATION_ID" = "$EXPECTED_INSTALLATION_ID" + - name: Dispatch the trusted central responder + env: + COMMENT_ID: ${{ github.event.comment.id }} + DISPATCH_TOKEN: ${{ steps.dispatch-token.outputs.token }} + PR_NUMBER: ${{ github.event.issue.number }} + TARGET_ID: rust + run: | + payload="$( + jq -cn \ + --arg target "$TARGET_ID" \ + --arg pr_number "$PR_NUMBER" \ + --arg comment_id "$COMMENT_ID" \ + '{ref:"main",inputs:{target:$target,pr_number:$pr_number,comment_id:$comment_id}}' + )" + curl --fail --silent --show-error \ + --request POST \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $DISPATCH_TOKEN" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --data "$payload" \ + https://api.github.com/repos/temporalio/sdk-sentinel/actions/workflows/sdk-pr-responder.yml/dispatches From 5d63f108423c4a2a08fb1072751014ad69eea178 Mon Sep 17 00:00:00 2001 From: James Watkins-Harvey Date: Fri, 21 Aug 2026 17:50:36 -0400 Subject: [PATCH 18/87] Preserve Event Group Markers on cancellation commands (#1512) --- CHANGELOG.md | 1 - .../workflow_activation.proto | 4 +- crates/protos/src/protos/mod.rs | 2 - crates/sdk-core/CHANGELOG.md | 1 + .../sdk-core/src/core_tests/event_groups.rs | 399 ++++++++++++++++++ crates/sdk-core/src/core_tests/mod.rs | 1 + crates/sdk-core/src/core_tests/updates.rs | 88 ++-- .../sdk-core/src/core_tests/workflow_tasks.rs | 32 ++ .../src/worker/workflow/driven_workflow.rs | 10 +- .../machines/activity_state_machine.rs | 38 +- .../machines/child_workflow_state_machine.rs | 25 +- .../machines/nexus_operation_state_machine.rs | 25 +- .../workflow/machines/patch_state_machine.rs | 12 +- .../workflow/machines/timer_state_machine.rs | 43 +- .../workflow/machines/transition_coverage.rs | 7 +- .../workflow/machines/workflow_machines.rs | 141 +++---- crates/sdk-core/src/worker/workflow/mod.rs | 61 ++- crates/workflow/src/workflow_context.rs | 27 +- 18 files changed, 704 insertions(+), 213 deletions(-) create mode 100644 crates/sdk-core/src/core_tests/event_groups.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4425e57b0..9688a4b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,7 +93,6 @@ relevant information. `TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` to change the threshold. * `SignalWorkflowOptions::summary` attaches a single-line summary to a signal sent to another workflow, which the UI and CLI display alongside the resulting history event. -* Core now supports attaching `EventGroupMarker`s to various workflow commands. ### Changed * Cancellation errors propagated after workflow cancellation now complete the workflow as cancelled diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto index ab50cd515..097994ac6 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto @@ -211,12 +211,10 @@ message InitializeWorkflow { temporal.api.common.v1.WorkflowExecution root_workflow = 24; // Priority of this workflow execution temporal.api.common.v1.Priority priority = 25; - // Event ID of the `WORKFLOW_EXECUTION_STARTED` history event that triggered this job. - int64 originating_event_id = 26; // The run id recorded on the `WORKFLOW_EXECUTION_STARTED` event. Unlike the execution's current // run id, this value is preserved across workflow resets. Mirrors the `original_execution_run_id` // field from `WorkflowExecutionStartedEventAttributes`. - string original_execution_run_id = 27; + string original_execution_run_id = 26; } // Notify a workflow that a timer has fired diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index 7c349ece2..11bdbc289 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -1333,7 +1333,6 @@ pub mod coresdk { workflow_id: String, randomness_seed: u64, start_time: Timestamp, - originating_event_id: i64, ) -> InitializeWorkflow { InitializeWorkflow { workflow_type: attrs.workflow_type.map(|wt| wt.name).unwrap_or_default(), @@ -1367,7 +1366,6 @@ pub mod coresdk { start_time: Some(start_time), root_workflow: attrs.root_workflow_execution, priority: attrs.priority, - originating_event_id, original_execution_run_id: attrs.original_execution_run_id, } } diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index 0549321db..4dbd3a8ed 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -43,6 +43,7 @@ relevant information. disable the reporting. * Workers now log a `[TMPRL1104]` warning when a workflow task takes longer than 5 seconds. Set `TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` to change the threshold. +* Core now supports attaching `EventGroupMarker`s to most workflow commands. ### Breaking Changes :boom: * Activity failures now include the latest heartbeat details atomically instead of force-flushing a diff --git a/crates/sdk-core/src/core_tests/event_groups.rs b/crates/sdk-core/src/core_tests/event_groups.rs new file mode 100644 index 000000000..c1c7dc6c9 --- /dev/null +++ b/crates/sdk-core/src/core_tests/event_groups.rs @@ -0,0 +1,399 @@ +use crate::{ + replay::{TestHistoryBuilder, canned_histories, default_act_sched}, + test_help::{MockPollCfg, build_mock_pollers, mock_worker, start_timer_cmd}, +}; +use std::time::Duration; +use temporalio_common::protos::{ + coresdk::{ + AsJsonPayloadExt, + child_workflow::ChildWorkflowCancellationType, + nexus::NexusOperationCancellationType, + workflow_commands::{ + ActivityCancellationType, CancelChildWorkflowExecution, CancelTimer, + CompleteWorkflowExecution, RequestCancelActivity, RequestCancelNexusOperation, + ScheduleActivity, ScheduleNexusOperation, SetPatchMarker, StartChildWorkflowExecution, + WorkflowCommand, workflow_command, + }, + workflow_completion::{WorkflowActivationCompletion, workflow_activation_completion}, + }, + temporal::api::{ + command::v1::Command, + common::v1::Payload, + enums::v1::{CommandType, EventType}, + history::v1::{ + NexusOperationCancelRequestedEventAttributes, NexusOperationCanceledEventAttributes, + NexusOperationScheduledEventAttributes, history_event, + }, + sdk::v1::{ + EventGroupMarker, UserMetadata, + event_group_marker::{Label, Variant}, + }, + }, +}; + +fn plain(cmd: impl Into) -> WorkflowCommand { + cmd.into().into() +} + +/// Tag a command with a marker and a summary both derived from `group`, so that a single name +/// identifies the annotations expected downstream and both fields are checked to travel together. +fn annotate(cmd: impl Into, group: &str) -> WorkflowCommand { + let mut cmd = plain(cmd); + cmd.event_group_markers = vec![EventGroupMarker { + variant: Some(Variant::Label(Label { + id: group.to_string(), + label: Some(group.as_json_payload().unwrap()), + })), + }]; + cmd.user_metadata = Some(UserMetadata { + summary: Some(group.as_json_payload().unwrap()), + details: None, + }); + cmd +} + +#[track_caller] +fn assert_annotated(cmd: &Command, group: &str) { + let expected = annotate(CompleteWorkflowExecution::default(), group); + assert_eq!(cmd.event_group_markers, expected.event_group_markers); + assert_eq!(cmd.user_metadata, expected.user_metadata); +} + +fn complete(run_id: String, cmds: Vec) -> WorkflowActivationCompletion { + WorkflowActivationCompletion { + run_id, + status: Some(workflow_activation_completion::Status::Successful( + cmds.into(), + )), + ..Default::default() + } +} + +#[rstest::rstest] +#[tokio::test] +async fn cancel_timer_command_is_annotated(#[values(false, true)] lang_annotates_cancel: bool) { + let cancelled_timer_seq = 2; + let t = canned_histories::cancel_timer("1", &cancelled_timer_seq.to_string()); + let mut mock_cfg = MockPollCfg::from_hist_builder(t); + let expected_group = if lang_annotates_cancel { + "cancel-group" + } else { + "timer-group" + }; + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + asserts.then(|_| {}).then(move |wft| { + assert_eq!(wft.commands[0].command_type(), CommandType::CancelTimer); + assert_annotated(&wft.commands[0], expected_group); + }); + }); + let mut mock = build_mock_pollers(mock_cfg); + mock.worker_cfg(|wc| wc.max_cached_workflows = 1); + let core = mock_worker(mock); + + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete( + act.run_id, + vec![ + annotate( + start_timer_cmd(cancelled_timer_seq, Duration::from_secs(1)), + "timer-group", + ), + plain(start_timer_cmd(1, Duration::from_secs(1))), + ], + )) + .await + .unwrap(); + + let cancel = CancelTimer { + seq: cancelled_timer_seq, + }; + let cancel = if lang_annotates_cancel { + annotate(cancel, "cancel-group") + } else { + plain(cancel) + }; + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete( + act.run_id, + vec![cancel, plain(CompleteWorkflowExecution::default())], + )) + .await + .unwrap(); +} + +#[rstest::rstest] +#[tokio::test] +async fn cancel_activity_command_is_annotated(#[values(false, true)] lang_annotates_cancel: bool) { + let activity_seq = 1; + let t = canned_histories::cancel_scheduled_activity_with_activity_task_cancel( + "fake_activity", + "signal", + ); + let mut mock_cfg = MockPollCfg::from_hist_builder(t); + let expected_group = if lang_annotates_cancel { + "cancel-group" + } else { + "activity-group" + }; + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + asserts.then(|_| {}).then(move |wft| { + assert_eq!( + wft.commands[0].command_type(), + CommandType::RequestCancelActivityTask + ); + assert_annotated(&wft.commands[0], expected_group); + }); + }); + let mut mock = build_mock_pollers(mock_cfg); + mock.worker_cfg(|wc| wc.max_cached_workflows = 1); + let core = mock_worker(mock); + + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete( + act.run_id, + vec![annotate( + ScheduleActivity { + seq: activity_seq, + activity_id: "fake_activity".to_string(), + cancellation_type: ActivityCancellationType::WaitCancellationCompleted as i32, + ..default_act_sched() + }, + "activity-group", + )], + )) + .await + .unwrap(); + + let cancel = RequestCancelActivity { seq: activity_seq }; + let cancel = if lang_annotates_cancel { + annotate(cancel, "cancel-group") + } else { + plain(cancel) + }; + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete(act.run_id, vec![cancel])) + .await + .unwrap(); + + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete( + act.run_id, + vec![plain(CompleteWorkflowExecution::default())], + )) + .await + .unwrap(); +} + +/// Cancelling a child is doubly indirect: the child machine asks Core to create a whole other +/// machine for the external cancel, and that machine's command is the one the server sees. +#[rstest::rstest] +#[tokio::test] +async fn cancel_child_workflow_command_is_annotated( + #[values(false, true)] lang_annotates_cancel: bool, +) { + let child_wf_id = "child-1"; + let child_seq = 1; + let t = canned_histories::single_child_workflow_try_cancelled(child_wf_id); + let mut mock_cfg = MockPollCfg::from_hist_builder(t); + let expected_group = if lang_annotates_cancel { + "cancel-group" + } else { + "child-group" + }; + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + asserts.then(|_| {}).then(move |wft| { + assert_eq!( + wft.commands[0].command_type(), + CommandType::RequestCancelExternalWorkflowExecution + ); + assert_annotated(&wft.commands[0], expected_group); + }); + }); + let mut mock = build_mock_pollers(mock_cfg); + mock.worker_cfg(|wc| wc.max_cached_workflows = 1); + let core = mock_worker(mock); + + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete( + act.run_id, + vec![annotate( + StartChildWorkflowExecution { + seq: child_seq, + workflow_id: child_wf_id.to_string(), + workflow_type: "child".to_string(), + cancellation_type: ChildWorkflowCancellationType::TryCancel as i32, + ..Default::default() + }, + "child-group", + )], + )) + .await + .unwrap(); + + let cancel = CancelChildWorkflowExecution { + child_workflow_seq: child_seq, + reason: "because".to_string(), + }; + let cancel = if lang_annotates_cancel { + annotate(cancel, "cancel-group") + } else { + plain(cancel) + }; + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete(act.run_id, vec![cancel])) + .await + .unwrap(); + + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete( + act.run_id, + vec![plain(CompleteWorkflowExecution::default())], + )) + .await + .unwrap(); +} + +#[rstest::rstest] +#[tokio::test] +async fn cancel_nexus_operation_command_is_annotated( + #[values(false, true)] lang_annotates_cancel: bool, +) { + let nexus_seq = 1; + let mut t = TestHistoryBuilder::default(); + t.add_by_type(EventType::WorkflowExecutionStarted); + t.add_full_wf_task(); + let scheduled_event_id = t.add(NexusOperationScheduledEventAttributes { + endpoint: "endpoint".to_string(), + service: "service".to_string(), + operation: "operation".to_string(), + ..Default::default() + }); + t.add_we_signaled( + "signal", + vec![Payload { + metadata: Default::default(), + data: b"hello ".to_vec(), + external_payloads: Default::default(), + }], + ); + t.add_full_wf_task(); + t.add( + history_event::Attributes::NexusOperationCancelRequestedEventAttributes( + NexusOperationCancelRequestedEventAttributes { + scheduled_event_id, + ..Default::default() + }, + ), + ); + t.add( + history_event::Attributes::NexusOperationCanceledEventAttributes( + NexusOperationCanceledEventAttributes { + scheduled_event_id, + ..Default::default() + }, + ), + ); + t.add_full_wf_task(); + t.add_workflow_execution_completed(); + + let mut mock_cfg = MockPollCfg::from_hist_builder(t); + let expected_group = if lang_annotates_cancel { + "cancel-group" + } else { + "nexus-group" + }; + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + asserts.then(|_| {}).then(move |wft| { + assert_eq!( + wft.commands[0].command_type(), + CommandType::RequestCancelNexusOperation + ); + assert_annotated(&wft.commands[0], expected_group); + }); + }); + let mut mock = build_mock_pollers(mock_cfg); + mock.worker_cfg(|wc| wc.max_cached_workflows = 1); + let core = mock_worker(mock); + + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete( + act.run_id, + vec![annotate( + ScheduleNexusOperation { + seq: nexus_seq, + endpoint: "endpoint".to_string(), + service: "service".to_string(), + operation: "operation".to_string(), + cancellation_type: NexusOperationCancellationType::WaitCancellationCompleted as i32, + ..Default::default() + }, + "nexus-group", + )], + )) + .await + .unwrap(); + + let cancel = RequestCancelNexusOperation { seq: nexus_seq }; + let cancel = if lang_annotates_cancel { + annotate(cancel, "cancel-group") + } else { + plain(cancel) + }; + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete(act.run_id, vec![cancel])) + .await + .unwrap(); + + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete( + act.run_id, + vec![plain(CompleteWorkflowExecution::default())], + )) + .await + .unwrap(); +} + +/// The `TemporalChangeVersion` upsert exists only to make the patch searchable, so it belongs to +/// the same group as the patch marker rather than to no group at all. +#[tokio::test] +async fn patch_search_attribute_upsert_is_annotated() { + let patch_id = "the-patch"; + let mut t = TestHistoryBuilder::default(); + t.add_by_type(EventType::WorkflowExecutionStarted); + t.add_full_wf_task(); + t.add_has_change_marker(patch_id, false); + t.add_workflow_execution_completed(); + + let mut mock_cfg = MockPollCfg::from_hist_builder(t); + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + asserts.then(|wft| { + assert_eq!(wft.commands[0].command_type(), CommandType::RecordMarker); + assert_annotated(&wft.commands[0], "patch-group"); + assert_eq!( + wft.commands[1].command_type(), + CommandType::UpsertWorkflowSearchAttributes + ); + assert_annotated(&wft.commands[1], "patch-group"); + }); + }); + let mut mock = build_mock_pollers(mock_cfg); + mock.worker_cfg(|wc| wc.max_cached_workflows = 1); + let core = mock_worker(mock); + + let act = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(complete( + act.run_id, + vec![ + annotate( + SetPatchMarker { + patch_id: patch_id.to_string(), + deprecated: false, + }, + "patch-group", + ), + plain(CompleteWorkflowExecution::default()), + ], + )) + .await + .unwrap(); +} diff --git a/crates/sdk-core/src/core_tests/mod.rs b/crates/sdk-core/src/core_tests/mod.rs index 17ac93868..6dd4479bb 100644 --- a/crates/sdk-core/src/core_tests/mod.rs +++ b/crates/sdk-core/src/core_tests/mod.rs @@ -1,4 +1,5 @@ mod activity_tasks; +mod event_groups; mod queries; mod replay_flag; mod updates; diff --git a/crates/sdk-core/src/core_tests/updates.rs b/crates/sdk-core/src/core_tests/updates.rs index 1beb3facc..ea1ec331d 100644 --- a/crates/sdk-core/src/core_tests/updates.rs +++ b/crates/sdk-core/src/core_tests/updates.rs @@ -324,70 +324,42 @@ async fn replay_with_signal_and_update_same_task() { } #[tokio::test] -async fn originating_ids_on_inbound_activation_jobs() { - // Each inbound-job kind that auto-creates an event group marker on the lang side - // is supposed to carry the identifier it needs to construct that marker: - // - `InitializeWorkflow.originating_event_id` = `WorkflowExecutionStarted` event id (1) - // - `SignalWorkflow.originating_event_id` = `WorkflowExecutionSignaled` event id - // - `DoUpdate.id` = the workflow-unique update id (always set) +async fn update_activation_has_update_id() { + let wfid = "fakeid"; let mut t = TestHistoryBuilder::default(); - t.add_by_type(EventType::WorkflowExecutionStarted); // 1 - t.add_full_wf_task(); // 2,3,4 - t.add_we_signaled("go", vec![]); // 5 - let signal_event_id = t.current_event_id(); - t.add_full_wf_task(); // 6,7,8 - let accept_id = t.add_update_accepted("upd1", "update"); // 9 - t.add_update_completed(accept_id); // 10 - t.add_workflow_execution_completed(); // 11 + t.add_by_type(EventType::WorkflowExecutionStarted); + t.add_workflow_task_scheduled_and_started(); - let mock = MockPollCfg::from_resps(t, [ResponseType::AllHistory]); - let mut mock = build_mock_pollers(mock); - mock.worker_cfg(|wc| wc.max_cached_workflows = 1); - let core = mock_worker(mock); + let update_id = "upd-1"; + let mut poll_resp = hist_to_poll_resp(&t, wfid, ResponseType::AllHistory); + poll_resp.add_update_request(update_id, 1); - let task = core.poll_workflow_activation().await.unwrap(); - assert_matches!( - task.jobs.as_slice(), - [WorkflowActivationJob { - variant: Some(workflow_activation_job::Variant::InitializeWorkflow(init)), - }] => { - assert_eq!(init.originating_event_id, 1); - } - ); - core.complete_workflow_activation(WorkflowActivationCompletion::empty(task.run_id)) - .await - .unwrap(); + let mut mock_client = mock_worker_client(); + mock_client + .expect_complete_workflow_task() + .times(1) + .returning(|_| Ok(RespondWorkflowTaskCompletedResponse::default())); + let mh = MockPollCfg::from_resp_batches(wfid, t, [poll_resp], mock_client); + let core = mock_worker(build_mock_pollers(mh)); let task = core.poll_workflow_activation().await.unwrap(); - assert_matches!( - task.jobs.as_slice(), - [ - WorkflowActivationJob { - variant: Some(workflow_activation_job::Variant::SignalWorkflow(sig)), - }, - WorkflowActivationJob { - variant: Some(workflow_activation_job::Variant::DoUpdate(upd)), - } - ] => { - assert_eq!(sig.originating_event_id, signal_event_id); - assert_eq!(upd.id, "upd1"); - } - ); - core.complete_workflow_activation(WorkflowActivationCompletion::from_cmds( + let update = task + .jobs + .iter() + .find_map(|job| match job.variant.as_ref() { + Some(workflow_activation_job::Variant::DoUpdate(update)) => Some(update), + _ => None, + }) + .expect("activation should contain an update"); + assert_eq!(update.id, update_id); + + core.complete_workflow_activation(WorkflowActivationCompletion::from_cmd( task.run_id, - vec![ - UpdateResponse { - protocol_instance_id: "upd1".to_string(), - response: Some(Response::Accepted(())), - } - .into(), - UpdateResponse { - protocol_instance_id: "upd1".to_string(), - response: Some(Response::Completed(Payload::default())), - } - .into(), - CompleteWorkflowExecution { result: None }.into(), - ], + UpdateResponse { + protocol_instance_id: update_id.to_string(), + response: Some(Response::Accepted(())), + } + .into(), )) .await .unwrap(); diff --git a/crates/sdk-core/src/core_tests/workflow_tasks.rs b/crates/sdk-core/src/core_tests/workflow_tasks.rs index dac74c801..b3991b01e 100644 --- a/crates/sdk-core/src/core_tests/workflow_tasks.rs +++ b/crates/sdk-core/src/core_tests/workflow_tasks.rs @@ -783,6 +783,38 @@ async fn simple_timer_fail_wf_execution(hist_batches: &'static [usize]) { .await; } +#[tokio::test] +async fn signal_activation_has_originating_event_id() { + let mut t = TestHistoryBuilder::default(); + t.add_by_type(EventType::WorkflowExecutionStarted); + t.add_full_wf_task(); + t.add_we_signaled("signal", vec![]); + let signal_event_id = t.current_event_id(); + t.add_full_wf_task(); + t.add_workflow_execution_completed(); + + let mock = MockPollCfg::from_resps(t, [ResponseType::AllHistory]); + let mut mock = build_mock_pollers(mock); + mock.worker_cfg(|wc| wc.max_cached_workflows = 1); + let core = mock_worker(mock); + + let task = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(WorkflowActivationCompletion::empty(task.run_id)) + .await + .unwrap(); + + let task = core.poll_workflow_activation().await.unwrap(); + assert_matches!( + task.jobs.as_slice(), + [WorkflowActivationJob { + variant: Some(workflow_activation_job::Variant::SignalWorkflow(signal)), + }] => { + assert_eq!(signal.originating_event_id, signal_event_id); + } + ); + core.complete_execution(&task.run_id).await; +} + #[rstest(hist_batches, case::incremental(&[1, 2]), case::replay(&[2]))] #[tokio::test] async fn two_signals(hist_batches: &'static [usize]) { diff --git a/crates/sdk-core/src/worker/workflow/driven_workflow.rs b/crates/sdk-core/src/worker/workflow/driven_workflow.rs index 2910f9ff4..c9a2b3ed0 100644 --- a/crates/sdk-core/src/worker/workflow/driven_workflow.rs +++ b/crates/sdk-core/src/worker/workflow/driven_workflow.rs @@ -45,7 +45,6 @@ impl DrivenWorkflow { workflow_id: String, randomness_seed: u64, start_time: Timestamp, - originating_event_id: i64, attribs: WorkflowExecutionStartedEventAttributes, ) { debug!(run_id = %attribs.original_execution_run_id, "Driven WF start"); @@ -56,14 +55,7 @@ impl DrivenWorkflow { retry_policy: attribs.retry_policy.clone(), }; self.send_job( - start_workflow_from_attribs( - attribs, - workflow_id, - randomness_seed, - start_time, - originating_event_id, - ) - .into(), + start_workflow_from_attribs(attribs, workflow_id, randomness_seed, start_time).into(), ); self.started_attrs = Some(started_info); } diff --git a/crates/sdk-core/src/worker/workflow/machines/activity_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/activity_state_machine.rs index 15f1842cb..fbf4143ae 100644 --- a/crates/sdk-core/src/worker/workflow/machines/activity_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/activity_state_machine.rs @@ -7,7 +7,10 @@ use super::{ use crate::{ abstractions::dbg_panic, internal_flags::CoreInternalFlags, - worker::workflow::{InternalFlagsRef, fatal, machines::HistEventData, nondeterminism}, + worker::workflow::{ + CommandAnnotations, InternalFlagsRef, ProtoCommandExt, fatal, machines::HistEventData, + nondeterminism, + }, }; use std::convert::{TryFrom, TryInto}; use temporalio_common::protos::{ @@ -112,6 +115,7 @@ impl ActivityMachine { attrs: ScheduleActivity, internal_flags: InternalFlagsRef, use_compatible_version: bool, + annotations: CommandAnnotations, ) -> NewMachineWithCommand { let mut s = Self::from_parts( Created {}.into(), @@ -123,6 +127,7 @@ impl ActivityMachine { scheduled_event_id: 0, started_event_id: 0, cancelled_before_sent: false, + annotations, }, ); OnEventWrapper::on_event_mut(&mut s, ActivityMachineEvents::Schedule) @@ -164,7 +169,11 @@ impl ActivityMachine { } } - pub(super) fn cancel(&mut self) -> Result, MachineError> { + pub(super) fn cancel( + &mut self, + annotations: CommandAnnotations, + ) -> Result, MachineError> { + self.shared_state.annotations.override_with(annotations); if matches!( self.state(), ActivityMachineState::Completed(_) @@ -352,6 +361,7 @@ pub(super) struct SharedState { cancellation_type: ActivityCancellationType, cancelled_before_sent: bool, internal_flags: InternalFlagsRef, + annotations: CommandAnnotations, } #[derive(Default, Clone)] @@ -790,18 +800,14 @@ fn create_request_cancel_activity_task_command( where S: Into, { - let cmd = Command { - command_type: CommandType::RequestCancelActivityTask as i32, - attributes: Some( - command::Attributes::RequestCancelActivityTaskCommandAttributes( - RequestCancelActivityTaskCommandAttributes { - scheduled_event_id: dat.scheduled_event_id, - }, - ), + let cmd = Command::new( + command::Attributes::RequestCancelActivityTaskCommandAttributes( + RequestCancelActivityTaskCommandAttributes { + scheduled_event_id: dat.scheduled_event_id, + }, ), - user_metadata: Default::default(), - event_group_markers: vec![], - }; + dat.annotations.clone(), + ); ActivityMachineTransition::ok( vec![ActivityMachineCommand::RequestCancellation(cmd)], next_state, @@ -937,9 +943,10 @@ mod test { cancellation_type: Default::default(), cancelled_before_sent: false, internal_flags: Rc::new(RefCell::new(InternalFlags::default())), + annotations: Default::default(), }, ); - let cmds = s.cancel().unwrap(); + let cmds = s.cancel(Default::default()).unwrap(); assert_eq!(cmds.len(), 0); assert_eq!(discriminant(&state), discriminant(s.state())); } @@ -956,13 +963,14 @@ mod test { }, Rc::new(RefCell::new(InternalFlags::default())), true, + Default::default(), ); let mut s = if let Machines::ActivityMachine(am) = s.machine { am } else { panic!("Wrong machine type"); }; - let cmds = s.cancel().unwrap(); + let cmds = s.cancel(Default::default()).unwrap(); // We should always be notifying lang that the activity got cancelled, even if it's // abandoned and we aren't telling server assert_matches!( diff --git a/crates/sdk-core/src/worker/workflow/machines/child_workflow_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/child_workflow_state_machine.rs index 80171659f..609619a81 100644 --- a/crates/sdk-core/src/worker/workflow/machines/child_workflow_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/child_workflow_state_machine.rs @@ -5,7 +5,9 @@ use super::{ use crate::{ abstractions::dbg_panic, internal_flags::CoreInternalFlags, - worker::workflow::{InternalFlagsRef, fatal, machines::HistEventData, nondeterminism}, + worker::workflow::{ + CommandAnnotations, InternalFlagsRef, fatal, machines::HistEventData, nondeterminism, + }, }; use itertools::Itertools; use std::{ @@ -445,6 +447,7 @@ pub(super) struct SharedState { cancelled_before_sent: bool, cancel_type: ChildWorkflowCancellationType, internal_flags: InternalFlagsRef, + annotations: CommandAnnotations, } impl SharedState { @@ -462,6 +465,7 @@ impl ChildWorkflowMachine { attribs: StartChildWorkflowExecution, internal_flags: InternalFlagsRef, use_compatible_version: bool, + annotations: CommandAnnotations, ) -> NewMachineWithCommand { let mut s = Self::from_parts( Created {}.into(), @@ -476,6 +480,7 @@ impl ChildWorkflowMachine { initiated_event_id: 0, started_event_id: 0, cancelled_before_sent: false, + annotations, }, ); OnEventWrapper::on_event_mut(&mut s, ChildWorkflowMachineEvents::Schedule) @@ -516,7 +521,9 @@ impl ChildWorkflowMachine { pub(super) fn cancel( &mut self, reason: String, + annotations: CommandAnnotations, ) -> Result, MachineError> { + self.shared_state.annotations.override_with(annotations); let event = ChildWorkflowMachineEvents::Cancel(reason); let vec = OnEventWrapper::on_event_mut(self, event)?; let res = vec @@ -733,8 +740,8 @@ impl WFMachinesAdapter for ChildWorkflowMachine { let mut resps = vec![]; if self.shared_state.cancel_type != ChildWorkflowCancellationType::Abandon { #[allow(deprecated)] - resps.push(MachineResponse::NewCoreOriginatedCommand( - RequestCancelExternalWorkflowExecutionCommandAttributes { + resps.push(MachineResponse::NewCoreOriginatedCommand { + attrs: RequestCancelExternalWorkflowExecutionCommandAttributes { namespace: self.shared_state.namespace.clone(), workflow_id: self.shared_state.workflow_id.clone(), run_id: self.shared_state.run_id.clone(), @@ -743,7 +750,8 @@ impl WFMachinesAdapter for ChildWorkflowMachine { ..Default::default() } .into(), - )) + annotations: self.shared_state.annotations.clone(), + }) } if self.shared_state.resolves_immediately_on_cancel() { resps.push(self.resolve_cancelled_msg().into()) @@ -826,9 +834,12 @@ mod test { cancelled_before_sent: false, cancel_type: Default::default(), internal_flags: Rc::new(RefCell::new(InternalFlags::default())), + annotations: Default::default(), }, ); - let cmds = s.cancel("cancel reason".to_string()).unwrap(); + let cmds = s + .cancel("cancel reason".to_string(), Default::default()) + .unwrap(); assert_eq!(cmds.len(), 0); assert_eq!(discriminant(&state), discriminant(s.state())); } @@ -851,6 +862,7 @@ mod test { cancelled_before_sent: false, cancel_type, internal_flags: Rc::new(RefCell::new(InternalFlags::default())), + annotations: Default::default(), }; let state = Cancelled::default(); let res = state.on_child_workflow_execution_completed(&mut shared, None); @@ -925,10 +937,11 @@ mod test { cancelled_before_sent: false, cancel_type, internal_flags: Rc::new(RefCell::new(InternalFlags::default())), + annotations: Default::default(), }, ); let cmds = s - .cancel("parent cancelled".to_string()) + .cancel("parent cancelled".to_string(), Default::default()) .expect("Cancel in StartEventRecorded should not fail"); assert!( !cmds.is_empty(), diff --git a/crates/sdk-core/src/worker/workflow/machines/nexus_operation_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/nexus_operation_state_machine.rs index b8c744982..b8ad98ecc 100644 --- a/crates/sdk-core/src/worker/workflow/machines/nexus_operation_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/nexus_operation_state_machine.rs @@ -1,6 +1,6 @@ use super::{MachineError, StateMachine, TransitionResult, fsm}; use crate::worker::workflow::{ - WFMachinesError, + CommandAnnotations, ProtoCommandExt, WFMachinesError, machines::{ EventInfo, HistEventData, NewMachineWithCommand, OnEventWrapper, WFMachinesAdapter, workflow_machines::MachineResponse, @@ -17,7 +17,7 @@ use temporalio_common::protos::{ workflow_commands::ScheduleNexusOperation, }, temporal::api::{ - command::v1::{RequestCancelNexusOperationCommandAttributes, command}, + command::v1::{Command, RequestCancelNexusOperationCommandAttributes, command}, common::v1::Payload, enums::v1::{CommandType, EventType}, failure::v1::{self as failure, Failure, failure::FailureInfo}, @@ -129,10 +129,14 @@ pub(super) struct SharedState { cancel_sent: bool, cancel_type: NexusOperationCancellationType, operation_token: Option, + annotations: CommandAnnotations, } impl NexusOperationMachine { - pub(super) fn new_scheduled(attribs: ScheduleNexusOperation) -> NewMachineWithCommand { + pub(super) fn new_scheduled( + attribs: ScheduleNexusOperation, + annotations: CommandAnnotations, + ) -> NewMachineWithCommand { let s = Self::from_parts( ScheduleCommandCreated.into(), SharedState { @@ -145,6 +149,7 @@ impl NexusOperationMachine { cancel_sent: false, cancel_type: attribs.cancellation_type(), operation_token: None, + annotations, }, ); NewMachineWithCommand { @@ -153,7 +158,11 @@ impl NexusOperationMachine { } } - pub(super) fn cancel(&mut self) -> Result, MachineError> { + pub(super) fn cancel( + &mut self, + annotations: CommandAnnotations, + ) -> Result, MachineError> { + self.shared_state.annotations.override_with(annotations); let event = NexusOperationMachineEvents::Cancel; let cmds = OnEventWrapper::on_event_mut(self, event)?; let mach_resps = cmds @@ -640,14 +649,14 @@ impl WFMachinesAdapter for NexusOperationMachine { NexusOperationCommand::IssueCancel => { let mut resps = vec![]; if self.shared_state.cancel_type != NexusOperationCancellationType::Abandon { - resps.push(MachineResponse::IssueNewCommand( + resps.push(MachineResponse::IssueNewCommand(Command::new( command::Attributes::RequestCancelNexusOperationCommandAttributes( RequestCancelNexusOperationCommandAttributes { scheduled_event_id: self.shared_state.scheduled_event_id, }, - ) - .into(), - )) + ), + self.shared_state.annotations.clone(), + ))) } // Immediately resolve abandon/trycancel modes if matches!( diff --git a/crates/sdk-core/src/worker/workflow/machines/patch_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/patch_state_machine.rs index 1e99e6c50..469f53ec1 100644 --- a/crates/sdk-core/src/worker/workflow/machines/patch_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/patch_state_machine.rs @@ -25,7 +25,7 @@ use crate::{ internal_flags::CoreInternalFlags, protosext::HistoryEventExt, worker::workflow::{ - InternalFlagsRef, fatal, + CommandAnnotations, InternalFlagsRef, fatal, machines::{ HistEventData, upsert_search_attributes_state_machine::MAX_SEARCH_ATTR_PAYLOAD_SIZE, }, @@ -87,6 +87,8 @@ pub(super) enum PatchCommand {} /// are guaranteed to return the same value. /// `replaying_when_invoked`: If the workflow is replaying when this invocation occurs, this needs /// to be set to true. +/// `annotations`: Lang's annotations on the patch command. They will be attached to both the +/// RecordMarker command and the search attribute upsert we synthesize alongside the marker. pub(super) fn has_change<'a>( patch_id: String, replaying_when_invoked: bool, @@ -94,6 +96,7 @@ pub(super) fn has_change<'a>( seen_in_peekahead: bool, existing_patch_ids: impl Iterator, internal_flags: InternalFlagsRef, + annotations: CommandAnnotations, ) -> Result<(NewMachineWithCommand, Vec), WFMachinesError> { let shared_state = SharedState { patch_id }; let initial_state = if replaying_when_invoked { @@ -150,12 +153,13 @@ pub(super) fn has_change<'a>( m.insert(VERSION_SEARCH_ATTR_KEY.to_string(), serialized); m }; - vec![MachineResponse::NewCoreOriginatedCommand( - UpsertWorkflowSearchAttributesCommandAttributes { + vec![MachineResponse::NewCoreOriginatedCommand { + attrs: UpsertWorkflowSearchAttributesCommandAttributes { search_attributes: Some(SearchAttributes { indexed_fields }), } .into(), - )] + annotations, + }] } }; diff --git a/crates/sdk-core/src/worker/workflow/machines/timer_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/timer_state_machine.rs index f193fd41d..066de1d8f 100644 --- a/crates/sdk-core/src/worker/workflow/machines/timer_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/timer_state_machine.rs @@ -4,7 +4,10 @@ use super::{ EventInfo, MachineError, NewMachineWithCommand, OnEventWrapper, StateMachine, TransitionResult, WFMachinesAdapter, fsm, workflow_machines::MachineResponse, }; -use crate::worker::workflow::{WFMachinesError, fatal, machines::HistEventData, nondeterminism}; +use crate::worker::workflow::{ + CommandAnnotations, ProtoCommandExt, WFMachinesError, fatal, machines::HistEventData, + nondeterminism, +}; use std::convert::TryFrom; use temporalio_common::protos::{ coresdk::{ @@ -13,7 +16,7 @@ use temporalio_common::protos::{ workflow_commands::{CancelTimer, StartTimer}, }, temporal::api::{ - command::v1::command, + command::v1::{Command, command}, enums::v1::{CommandType, EventType}, history::v1::{TimerFiredEventAttributes, history_event}, }, @@ -58,11 +61,15 @@ pub(super) enum TimerMachineCommand { pub(super) struct SharedState { attrs: StartTimer, cancelled_before_sent: bool, + annotations: CommandAnnotations, } /// Creates a new, scheduled, timer as a [CancellableCommand] -pub(super) fn new_timer(attribs: StartTimer) -> NewMachineWithCommand { - let (timer, add_cmd) = TimerMachine::new_scheduled(attribs); +pub(super) fn new_timer( + attribs: StartTimer, + annotations: CommandAnnotations, +) -> NewMachineWithCommand { + let (timer, add_cmd) = TimerMachine::new_scheduled(attribs, annotations); NewMachineWithCommand { command: add_cmd, machine: timer.into(), @@ -71,29 +78,40 @@ pub(super) fn new_timer(attribs: StartTimer) -> NewMachineWithCommand { impl TimerMachine { /// Create a new timer and immediately schedule it - fn new_scheduled(attribs: StartTimer) -> (Self, command::Attributes) { - let mut s = Self::new(attribs); + fn new_scheduled( + attribs: StartTimer, + annotations: CommandAnnotations, + ) -> (Self, command::Attributes) { + let mut s = Self::new(attribs, annotations); OnEventWrapper::on_event_mut(&mut s, TimerMachineEvents::Schedule) .expect("Scheduling timers doesn't fail"); let cmd = s.shared_state().attrs.into(); (s, cmd) } - fn new(attribs: StartTimer) -> Self { + fn new(attribs: StartTimer, annotations: CommandAnnotations) -> Self { Self::from_parts( Created {}.into(), SharedState { attrs: attribs, cancelled_before_sent: false, + annotations, }, ) } - pub(super) fn cancel(&mut self) -> Result, MachineError> { + pub(super) fn cancel( + &mut self, + annotations: CommandAnnotations, + ) -> Result, MachineError> { + self.shared_state.annotations.override_with(annotations); Ok( match OnEventWrapper::on_event_mut(self, TimerMachineEvents::Cancel)?.pop() { Some(TimerMachineCommand::IssueCancelCmd(cmd)) => { - vec![MachineResponse::IssueNewCommand(cmd.into())] + vec![MachineResponse::IssueNewCommand(Command::new( + cmd, + self.shared_state.annotations.clone(), + ))] } None => vec![], x => panic!("Invalid cancel event response {x:?}"), @@ -257,7 +275,10 @@ impl WFMachinesAdapter for TimerMachine { .into(), ], TimerMachineCommand::IssueCancelCmd(c) => { - vec![MachineResponse::IssueNewCommand(c.into())] + vec![MachineResponse::IssueNewCommand(Command::new( + c, + self.shared_state.annotations.clone(), + ))] } }) } @@ -272,7 +293,7 @@ mod test { fn cancels_ignored_terminal() { for state in [TimerMachineState::Canceled(Canceled {}), Fired {}.into()] { let mut s = TimerMachine::from_parts(state.clone(), Default::default()); - let cmds = s.cancel().unwrap(); + let cmds = s.cancel(Default::default()).unwrap(); assert_eq!(cmds.len(), 0); assert_eq!(discriminant(&state), discriminant(s.state())); } diff --git a/crates/sdk-core/src/worker/workflow/machines/transition_coverage.rs b/crates/sdk-core/src/worker/workflow/machines/transition_coverage.rs index b461d8dd6..b266137fa 100644 --- a/crates/sdk-core/src/worker/workflow/machines/transition_coverage.rs +++ b/crates/sdk-core/src/worker/workflow/machines/transition_coverage.rs @@ -74,8 +74,9 @@ mod machine_coverage_report { fail_workflow_state_machine::FailWorkflowMachine, local_activity_state_machine::LocalActivityMachine, modify_workflow_properties_state_machine::ModifyWorkflowPropertiesMachine, - patch_state_machine::PatchMachine, signal_external_state_machine::SignalExternalMachine, - timer_state_machine::TimerMachine, update_state_machine::UpdateMachine, + nexus_operation_state_machine::NexusOperationMachine, patch_state_machine::PatchMachine, + signal_external_state_machine::SignalExternalMachine, timer_state_machine::TimerMachine, + update_state_machine::UpdateMachine, upsert_search_attributes_state_machine::UpsertSearchAttributesMachine, workflow_task_state_machine::WorkflowTaskMachine, }; @@ -115,6 +116,7 @@ mod machine_coverage_report { let mut upsert_search_attr = UpsertSearchAttributesMachine::visualizer().to_owned(); let mut modify_wf_props = ModifyWorkflowPropertiesMachine::visualizer().to_owned(); let mut update = UpdateMachine::visualizer().to_owned(); + let mut nexus = NexusOperationMachine::visualizer().to_owned(); // This isn't at all efficient but doesn't need to be. // Replace transitions in the vizzes with green color if they are covered. @@ -141,6 +143,7 @@ mod machine_coverage_report { cover_transitions(m, &mut modify_wf_props, coverage) } m @ "UpdateMachine" => cover_transitions(m, &mut update, coverage), + m @ "NexusOperationMachine" => cover_transitions(m, &mut nexus, coverage), m => panic!("Unknown machine {m}"), } } diff --git a/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs b/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs index ccb71d56f..88aea7c53 100644 --- a/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs +++ b/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs @@ -24,9 +24,9 @@ use crate::{ worker::{ ExecutingLAId, LocalActRequest, LocalActivityExecutionResult, LocalActivityResolution, workflow::{ - CommandID, DrivenWorkflow, HistoryUpdate, InternalFlagsRef, LocalResolution, - OutgoingJob, RunBasics, WFCommand, WFCommandVariant, WFMachinesError, - WorkflowStartedInfo, fatal, + CommandAnnotations, CommandID, DrivenWorkflow, HistoryUpdate, InternalFlagsRef, + LocalResolution, OutgoingJob, ProtoCommandExt, RunBasics, WFCommand, WFCommandVariant, + WFMachinesError, WorkflowStartedInfo, fatal, history_update::NextWFT, machines::{ HistEventData, activity_state_machine::ActivityMachine, @@ -64,14 +64,12 @@ use temporalio_common::{ workflow_commands::ContinueAsNewWorkflowExecution, }, temporal::api::{ - command::v1::{ - Command as ProtoCommand, CommandAttributesExt, command::Attributes as ProtoCmdAttrs, - }, + command::v1::{Command as ProtoCommand, command::Attributes as ProtoCmdAttrs}, common::v1::SearchAttributes, enums::v1::EventType, history::v1::{HistoryEvent, history_event}, protocol::v1::{Message as ProtocolMessage, message::SequencingId}, - sdk::v1::{EventGroupMarker, UserMetadata, WorkflowTaskCompletedMetadata}, + sdk::v1::WorkflowTaskCompletedMetadata, }, }, worker::WorkerDeploymentVersion, @@ -201,9 +199,13 @@ pub(super) enum MachineResponse { IssueNewMessage(ProtocolMessage), /// The machine requests the creation of another *different* machine. This acts as if lang /// had replied to the activation with a command, but we use a special set of IDs to avoid - /// collisions. - #[display("NewCoreOriginatedCommand({_0:?})")] - NewCoreOriginatedCommand(ProtoCmdAttrs), + /// collisions. The requesting machine supplies the annotations, since it is the only thing + /// that knows which lang command this one is being issued on behalf of. + #[display("NewCoreOriginatedCommand({attrs:?})")] + NewCoreOriginatedCommand { + attrs: ProtoCmdAttrs, + annotations: CommandAnnotations, + }, #[display("TriggerWFTaskStarted")] TriggerWFTaskStarted { task_started_event_id: i64, @@ -1008,7 +1010,6 @@ impl WorkflowMachines { self.workflow_id.clone(), str_to_randomness_seed(&attrs.original_execution_run_id), event_dat.event.event_time.unwrap_or_default(), - event_id, attrs, ); } else { @@ -1186,7 +1187,7 @@ impl WorkflowMachines { self.message_outbox.push_back(pm); } } - MachineResponse::NewCoreOriginatedCommand(attrs) => match attrs { + MachineResponse::NewCoreOriginatedCommand { attrs, annotations } => match attrs { ProtoCmdAttrs::RequestCancelExternalWorkflowExecutionCommandAttributes( attrs, ) => { @@ -1198,10 +1199,7 @@ impl WorkflowMachines { }; self.add_cmd_to_wf_task( new_external_cancel(0, we, attrs.child_workflow_only, attrs.reason), - // FIXME: Wire metadata and group markers from lang's cancellation command, - // through the state machine, into the command we issue here. - None, - vec![], + annotations, CommandIdKind::CoreInternal, ); } @@ -1211,10 +1209,7 @@ impl WorkflowMachines { // workflows by users (but rather, just for them to search with). self.add_cmd_to_wf_task( upsert_search_attrs_internal(attrs), - // FIXME: Wire metadata and group markers from lang's patch command, - // through the state machine, into the command we issue here. - None, - vec![], + annotations, CommandIdKind::NeverResolves, ); } @@ -1315,13 +1310,16 @@ impl WorkflowMachines { /// server. fn handle_driven_results(&mut self, results: Vec) -> Result<()> { for cmd in results { - match cmd.variant { + let WFCommand { + variant, + annotations, + } = cmd; + match variant { WFCommandVariant::AddTimer(attrs) => { let seq = attrs.seq; self.add_cmd_to_wf_task( - new_timer(attrs), - cmd.metadata, - cmd.event_group_markers, + new_timer(attrs, annotations.clone()), + annotations, CommandID::Timer(seq).into(), ); } @@ -1334,13 +1332,18 @@ impl WorkflowMachines { self.observed_internal_flags.clone(), self.replaying, ), - cmd.metadata, - cmd.event_group_markers, + annotations, CommandIdKind::NeverResolves, ); } WFCommandVariant::CancelTimer(attrs) => { - cancel_machine!(self, CommandID::Timer(attrs.seq), TimerMachine, cancel); + cancel_machine!( + self, + CommandID::Timer(attrs.seq), + TimerMachine, + cancel, + annotations + ); } WFCommandVariant::AddActivity(attrs) => { let seq = attrs.seq; @@ -1353,9 +1356,9 @@ impl WorkflowMachines { attrs, self.observed_internal_flags.clone(), use_compat, + annotations.clone(), ), - cmd.metadata, - cmd.event_group_markers, + annotations, CommandID::Activity(seq).into(), ); } @@ -1363,8 +1366,8 @@ impl WorkflowMachines { let seq = attrs.seq; let attrs: ValidScheduleLA = ValidScheduleLA::from_schedule_la( attrs, - cmd.metadata, - cmd.event_group_markers, + annotations.metadata, + annotations.event_group_markers, ) .map_err(|e| { fatal!("Invalid schedule local activity request (seq {seq}): {e}") @@ -1385,7 +1388,8 @@ impl WorkflowMachines { self, CommandID::Activity(attrs.seq), ActivityMachine, - cancel + cancel, + annotations ); } WFCommandVariant::RequestCancelLocalActivity(attrs) => { @@ -1397,18 +1401,10 @@ impl WorkflowMachines { ); } WFCommandVariant::CompleteWorkflow(attrs) => { - self.add_terminal_command( - complete_workflow(attrs), - cmd.metadata, - cmd.event_group_markers, - ); + self.add_terminal_command(complete_workflow(attrs), annotations); } WFCommandVariant::FailWorkflow(attrs) => { - self.add_terminal_command( - fail_workflow(attrs), - cmd.metadata, - cmd.event_group_markers, - ); + self.add_terminal_command(fail_workflow(attrs), annotations); } WFCommandVariant::ContinueAsNew(attrs) => { let attrs = self.augment_continue_as_new_with_current_values(attrs); @@ -1416,18 +1412,10 @@ impl WorkflowMachines { attrs.versioning_intent(), &attrs.task_queue, ); - self.add_terminal_command( - continue_as_new(attrs, use_compat), - cmd.metadata, - cmd.event_group_markers, - ); + self.add_terminal_command(continue_as_new(attrs, use_compat), annotations); } WFCommandVariant::CancelWorkflow(attrs) => { - self.add_terminal_command( - cancel_workflow(attrs), - cmd.metadata, - cmd.event_group_markers, - ); + self.add_terminal_command(cancel_workflow(attrs), annotations); } WFCommandVariant::SetPatchMarker(attrs) => { // Do not create commands for change IDs that we have already created commands @@ -1445,11 +1433,11 @@ impl WorkflowMachines { .iter() .filter_map(|(k, ci)| ci.created_command.then_some(k.as_str())), self.observed_internal_flags.clone(), + annotations.clone(), )?; let mkey = self.add_cmd_to_wf_task( patch_machine, - cmd.metadata, - cmd.event_group_markers, + annotations, CommandIdKind::NeverResolves, ); self.process_machine_responses(mkey, other_cmds)?; @@ -1477,9 +1465,9 @@ impl WorkflowMachines { attrs, self.observed_internal_flags.clone(), use_compat, + annotations.clone(), ), - cmd.metadata, - cmd.event_group_markers, + annotations, CommandID::ChildWorkflowStart(seq).into(), ); } @@ -1489,7 +1477,8 @@ impl WorkflowMachines { CommandID::ChildWorkflowStart(attrs.child_workflow_seq), ChildWorkflowMachine, cancel, - attrs.reason + attrs.reason, + annotations ); } WFCommandVariant::RequestCancelExternalWorkflow(attrs) => { @@ -1506,8 +1495,7 @@ impl WorkflowMachines { self.run_id, attrs.reason ), ), - cmd.metadata, - cmd.event_group_markers, + annotations, CommandID::CancelExternal(attrs.seq).into(), ); } @@ -1515,8 +1503,7 @@ impl WorkflowMachines { let seq = attrs.seq; self.add_cmd_to_wf_task( new_external_signal(attrs, &self.worker_config.namespace)?, - cmd.metadata, - cmd.event_group_markers, + annotations, CommandID::SignalExternal(seq).into(), ); } @@ -1535,8 +1522,7 @@ impl WorkflowMachines { WFCommandVariant::ModifyWorkflowProperties(attrs) => { self.add_cmd_to_wf_task( modify_workflow_properties(attrs), - cmd.metadata, - cmd.event_group_markers, + annotations, CommandIdKind::NeverResolves, ); } @@ -1557,9 +1543,8 @@ impl WorkflowMachines { WFCommandVariant::ScheduleNexusOperation(attrs) => { let seq = attrs.seq; self.add_cmd_to_wf_task( - NexusOperationMachine::new_scheduled(attrs), - cmd.metadata, - cmd.event_group_markers, + NexusOperationMachine::new_scheduled(attrs, annotations.clone()), + annotations, CommandID::NexusOperation(seq).into(), ); } @@ -1568,7 +1553,8 @@ impl WorkflowMachines { self, CommandID::NexusOperation(attrs.seq), NexusOperationMachine, - cancel + cancel, + annotations ); } WFCommandVariant::NoCommandsFromLang => (), @@ -1604,10 +1590,9 @@ impl WorkflowMachines { fn add_terminal_command( &mut self, machine: NewMachineWithCommand, - metadata: Option, - markers: Vec, + annotations: CommandAnnotations, ) { - let cwfm = self.add_new_command_machine(machine, metadata, markers); + let cwfm = self.add_new_command_machine(machine, annotations); self.workflow_end_time = Some(SystemTime::now()); self.current_wf_task_commands.push_back(cwfm); // Wipe out any pending / executing local activity data since we're about to terminate @@ -1619,11 +1604,10 @@ impl WorkflowMachines { fn add_cmd_to_wf_task( &mut self, machine: NewMachineWithCommand, - metadata: Option, - markers: Vec, + annotations: CommandAnnotations, id: CommandIdKind, ) -> MachineKey { - let mach = self.add_new_command_machine(machine, metadata, markers); + let mach = self.add_new_command_machine(machine, annotations); let key = mach.machine; if let CommandIdKind::LangIssued(id) = id { self.id_to_machine.insert(id, key); @@ -1638,18 +1622,11 @@ impl WorkflowMachines { fn add_new_command_machine( &mut self, machine: NewMachineWithCommand, - metadata: Option, - markers: Vec, + annotations: CommandAnnotations, ) -> CommandAndMachine { let k = self.all_machines.insert(machine.machine); - let cmd = ProtoCommand { - command_type: machine.command.as_type() as i32, - attributes: Some(machine.command), - user_metadata: metadata, - event_group_markers: markers, - }; CommandAndMachine { - command: cmd, + command: ProtoCommand::new(machine.command, annotations), machine: k, } } diff --git a/crates/sdk-core/src/worker/workflow/mod.rs b/crates/sdk-core/src/worker/workflow/mod.rs index 80e18e2d5..dc0380d44 100644 --- a/crates/sdk-core/src/worker/workflow/mod.rs +++ b/crates/sdk-core/src/worker/workflow/mod.rs @@ -75,7 +75,9 @@ use temporalio_common::{ }, }, temporal::api::{ - command::v1::{Command as ProtoCommand, Command, command::Attributes}, + command::v1::{ + Command as ProtoCommand, Command, CommandAttributesExt, command::Attributes, + }, common::v1::{ Memo, MeteringMetadata, RetryPolicy, SearchAttributes, WorkflowExecution, }, @@ -1413,16 +1415,59 @@ struct EmptyWorkflowCommandErr; #[display("{}", variant)] struct WFCommand { variant: WFCommandVariant, - metadata: Option, - event_group_markers: Vec, + annotations: CommandAnnotations, } impl WFCommand { fn new(variant: WFCommandVariant) -> Self { Self { variant, - metadata: None, - event_group_markers: vec![], + annotations: CommandAnnotations::default(), + } + } +} + +/// The lang-supplied decorations that ride along on a [WFCommand] and end up on the [ProtoCommand] +/// we send to the server. They are kept together because a command machine must remember them in +/// order to repeat them on any further command it issues, most notably a cancellation. +#[derive(Debug, Default, Clone, PartialEq)] +struct CommandAnnotations { + metadata: Option, + event_group_markers: Vec, +} + +impl CommandAnnotations { + /// Apply annotations lang attached to a cancellation command on top of the ones the command + /// being cancelled carried. Anything lang set explicitly wins; anything it left out is + /// inherited, which is what makes a cancellation land in the same event group as the command + /// it cancels even when it is issued from somewhere no group is active. + fn override_with(&mut self, other: Self) { + if let Some(other_metadata) = other.metadata { + let metadata = self.metadata.get_or_insert_with(UserMetadata::default); + if let Some(summary) = other_metadata.summary { + metadata.summary = Some(summary); + } + if let Some(details) = other_metadata.details { + metadata.details = Some(details); + } + } + if !other.event_group_markers.is_empty() { + self.event_group_markers = other.event_group_markers; + } + } +} + +trait ProtoCommandExt { + fn new(attributes: Attributes, annotations: CommandAnnotations) -> Self; +} + +impl ProtoCommandExt for ProtoCommand { + fn new(attributes: Attributes, annotations: CommandAnnotations) -> Self { + Self { + command_type: attributes.as_type() as i32, + attributes: Some(attributes), + user_metadata: annotations.metadata, + event_group_markers: annotations.event_group_markers, } } } @@ -1518,8 +1563,10 @@ impl TryFrom for WFCommand { }; Ok(Self { variant, - metadata: c.user_metadata, - event_group_markers: c.event_group_markers, + annotations: CommandAnnotations { + metadata: c.user_metadata, + event_group_markers: c.event_group_markers, + }, }) } } diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index e6085389f..85e1d33fc 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -2594,7 +2594,7 @@ impl Future for LATimerBackoffFut { .expect("duration converts ok"), cancellation_token: Some(self.cancellation_token.clone()), summary: None, - event_group_markers: vec![], + event_group_markers: self.la_opts.event_group_markers.clone(), }); self.timer_fut = Some(Box::pin(timer_f)); self.next_attempt = b.attempt; @@ -3290,6 +3290,7 @@ mod tests { temporal::api::{ common::v1::{Payload, RetryPolicy as ProtoRetryPolicy}, enums::v1::ContinueAsNewVersioningBehavior as ProtoContinueAsNewVersioningBehavior, + sdk::v1::{EventGroupMarker, event_group_marker}, }, }, }; @@ -3734,8 +3735,17 @@ mod tests { Vec::new(), ); let token = WorkflowCancellationToken::new(); + let marker = EventGroupMarker { + variant: Some(event_group_marker::Variant::Label( + event_group_marker::Label { + id: "la-group".to_string(), + label: Some("la-group".as_json_payload().unwrap()), + }, + )), + }; let mut options = LocalActivityOptions { schedule_to_close_timeout: Some(Duration::from_secs(10)), + event_group_markers: vec![marker.clone()], ..Default::default() }; options.cancellation_token = Some(token.clone()); @@ -3759,14 +3769,21 @@ mod tests { token.cancel(); let commands = host.commands.borrow(); - assert!(commands.iter().any(|command| matches!( - &command.variant, - Some(workflow_command::Variant::StartTimer(_)) - ))); assert!(commands.iter().any(|command| matches!( &command.variant, Some(workflow_command::Variant::CancelTimer(_)) ))); + + let start_timer = commands + .iter() + .find(|command| { + matches!( + &command.variant, + Some(workflow_command::Variant::StartTimer(_)) + ) + }) + .expect("backoff StartTimer is issued"); + assert_eq!(start_timer.event_group_markers, [marker]); } #[test] From d913b1f0bb04d37f3010e03de70b74591e038301 Mon Sep 17 00:00:00 2001 From: sdk-sentinel-bot Date: Mon, 24 Aug 2026 10:31:55 -0400 Subject: [PATCH 19/87] Serialize C-bridge ephemeral server tests (#1513) --- crates/sdk-core-c-bridge/src/tests/mod.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/sdk-core-c-bridge/src/tests/mod.rs b/crates/sdk-core-c-bridge/src/tests/mod.rs index eeb118f81..c4554d500 100644 --- a/crates/sdk-core-c-bridge/src/tests/mod.rs +++ b/crates/sdk-core-c-bridge/src/tests/mod.rs @@ -17,7 +17,7 @@ use context::Context; use prost::Message; use std::{ collections::HashMap, - sync::{Arc, LazyLock, Mutex}, + sync::{Arc, LazyLock, Mutex, PoisonError}, }; use temporalio_common::protos::temporal::api::{ failure::v1::Failure, @@ -30,8 +30,13 @@ use temporalio_common::protos::temporal::api::{ mod context; mod utils; +static EPHEMERAL_SERVER_TEST_MUTEX: Mutex<()> = Mutex::new(()); + #[test] fn test_get_system_info() { + let _server_guard = EPHEMERAL_SERVER_TEST_MUTEX + .lock() + .unwrap_or_else(PoisonError::into_inner); Context::with(|context| { context.runtime_new().unwrap(); context @@ -82,6 +87,9 @@ fn rpc_call_exists(context: &Arc, service: RpcService, rpc: &str) -> bo #[test] fn test_missing_rpc_call_has_expected_error_message() { + let _server_guard = EPHEMERAL_SERVER_TEST_MUTEX + .lock() + .unwrap_or_else(PoisonError::into_inner); Context::with(|context| { context.runtime_new().unwrap(); context @@ -146,6 +154,9 @@ fn all_rpc_calls_exist(context: &Arc, service: RpcService, proto: &str) #[test] fn test_all_rpc_calls_exist() { + let _server_guard = EPHEMERAL_SERVER_TEST_MUTEX + .lock() + .unwrap_or_else(PoisonError::into_inner); Context::with(|context| { context.runtime_new().unwrap(); context From 70a7bb55ae9ad8c04e0958a660ce4aa4dbdb92d8 Mon Sep 17 00:00:00 2001 From: sdk-sentinel-bot Date: Mon, 24 Aug 2026 15:20:52 -0400 Subject: [PATCH 20/87] [SDK Sentinel] Stabilize legacy query integration test ordering (#1511) * Stabilize legacy query activation order * Clarify legacy query ordering comment SDK-Sentinel-Request: temporalio/sdk-rust#1511/comment-5398459129 --- .../tests/integ_tests/queries_tests.rs | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/sdk-core/tests/integ_tests/queries_tests.rs b/crates/sdk-core/tests/integ_tests/queries_tests.rs index a4f1c337f..a2fd3663c 100644 --- a/crates/sdk-core/tests/integ_tests/queries_tests.rs +++ b/crates/sdk-core/tests/integ_tests/queries_tests.rs @@ -49,6 +49,13 @@ async fn simple_query_legacy() { .unwrap(); tokio::time::sleep(Duration::from_secs(1)).await; // Query after timer should have fired and there should be new WFT + let timer_task = core.poll_workflow_activation().await.unwrap(); + assert_matches!( + timer_task.jobs.as_slice(), + [WorkflowActivationJob { + variant: Some(workflow_activation_job::Variant::FireTimer(_)), + }] + ); let query_fut = async { WorkflowExecutionInfo { namespace: starter.get_core_client().await.namespace(), @@ -66,19 +73,11 @@ async fn simple_query_legacy() { .unwrap() }; let workflow_completions_future = async { - // Give query a beat to get going + // Let the query reach the server before completing the outstanding timer task so the + // server sends the query activation next. tokio::time::sleep(Duration::from_millis(400)).await; - // This poll *should* have the `queries` field populated, but doesn't, seemingly due to - // a server bug. So, complete the WF task of the first timer firing with empty commands - let task = core.poll_workflow_activation().await.unwrap(); - assert_matches!( - task.jobs.as_slice(), - [WorkflowActivationJob { - variant: Some(workflow_activation_job::Variant::FireTimer(_)), - }] - ); core.complete_workflow_activation(WorkflowActivationCompletion::from_cmds( - task.run_id, + timer_task.run_id, vec![], )) .await From 04ad54629339b8298c06f6636646be568576aaf6 Mon Sep 17 00:00:00 2001 From: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:29:11 -0700 Subject: [PATCH 21/87] feat(metrics): emit `failure_reason` on existing activity failure metrics (#1517) --- CHANGELOG.md | 10 + .../activity_result/activity_result.proto | 24 +++ crates/protos/src/protos/mod.rs | 8 +- crates/sdk-core/CHANGELOG.md | 7 + .../sdk-core/src/core_tests/activity_tasks.rs | 124 +++++++++-- .../sdk-core/src/core_tests/workflow_tasks.rs | 4 +- crates/sdk-core/src/telemetry/metrics.rs | 19 +- crates/sdk-core/src/worker/activities.rs | 123 +++++++++-- .../activities/activity_heartbeat_manager.rs | 2 + .../src/worker/activities/local_activities.rs | 14 +- crates/sdk-core/src/worker/client.rs | 48 ++--- crates/sdk-core/src/worker/client/mocks.rs | 2 + .../machines/activity_state_machine.rs | 1 + .../machines/local_activity_state_machine.rs | 3 +- .../tests/integ_tests/metrics_tests.rs | 194 +++++++++++++++++- .../integ_tests/workflow_tests/activities.rs | 6 +- 16 files changed, 513 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9688a4b77..e54b18e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ relevant information. and `list` read them back. * `MemoValue` and `MemoValues` are now exported from `temporalio_common` as well as `temporalio_workflow`, so the same types can be used from clients and workflows. +* The `temporal_activity_execution_failed` and `temporal_local_activity_execution_failed` worker + metrics now carry a `failure_reason` attribute. Each is now split into one time series per + reason, which may affect existing dashboards. ### Breaking Changes :boom: * Values stored in a `MemoValue` must now be `Send + Sync`. It previously held its value in an @@ -57,6 +60,13 @@ relevant information. * Signal-with-start is now invoked with `Client::signal_with_start_workflow`; remove uses of `WorkflowStartOptions::start_signal` and `WorkflowStartSignal`. +### Fixed +* An activity failure caused by oversized final heartbeat details is now counted in the + `temporal_activity_execution_failed` metric as `failure_reason="PayloadsTooLarge"`. Previously it + was counted under the reason for the failure the activity itself reported, and was not counted at + all when that failure was benign, even though the worker reported a payload-limit failure to the + server. + ## [0.7.0] - 2026-08-17 ### Added diff --git a/crates/protos/protos/local/temporal/sdk/core/activity_result/activity_result.proto b/crates/protos/protos/local/temporal/sdk/core/activity_result/activity_result.proto index 198d5e7b9..3076ea8c1 100644 --- a/crates/protos/protos/local/temporal/sdk/core/activity_result/activity_result.proto +++ b/crates/protos/protos/local/temporal/sdk/core/activity_result/activity_result.proto @@ -37,6 +37,30 @@ message Success { // Used to report activity failure either when executing or resolving message Failure { temporal.api.failure.v1.Failure failure = 1; + // Only meaningful on ActivityExecutionResult (lang -> core); ignored on ActivityResolution, + // which reuses this message. + ActivityTaskFailedCause cause = 2; +} + +/* + * A well-known condition that caused an activity task to fail. Lang reports one alongside the + * failure so core can categorize activity failures instead of treating them all alike; it becomes + * the `failure_reason` metric label, so the set of values is deliberately small and bounded. + */ +enum ActivityTaskFailedCause { + ACTIVITY_TASK_FAILED_CAUSE_UNSPECIFIED = 0; + // A payload-bearing field on a request the worker sent for this activity task exceeded the + // per-field size limit configured on the server for the namespace. + // Check the activity task failure message for more information. + ACTIVITY_TASK_FAILED_CAUSE_PAYLOADS_TOO_LARGE = 1; + // The worker failed to offload a payload to, or retrieve one from, external storage while + // processing this activity task. + // Check the activity task failure message for more information. + ACTIVITY_TASK_FAILED_CAUSE_EXTERNAL_STORAGE_FAILURE = 2; + // The default cause for an activity task failure reported by a worker; a more specific cause + // takes precedence whenever the condition is recognized. + // Check the activity task failure message for more information. + ACTIVITY_TASK_FAILED_CAUSE_ACTIVITY_WORKER_UNHANDLED_FAILURE = 3; } /* diff --git a/crates/protos/src/protos/mod.rs b/crates/protos/src/protos/mod.rs index 11bdbc289..4bbfa5ac5 100644 --- a/crates/protos/src/protos/mod.rs +++ b/crates/protos/src/protos/mod.rs @@ -746,6 +746,7 @@ pub mod coresdk { Self { status: Some(aer::Status::Failed(Failure { failure: Some(fail), + cause: ActivityTaskFailedCause::ActivityWorkerUnhandledFailure as i32, })), } } @@ -830,7 +831,11 @@ pub mod coresdk { Self { status: match r { Ok(p) => Some(aer::Status::Completed(Success { result: Some(p) })), - Err(f) => Some(aer::Status::Failed(Failure { failure: Some(f) })), + Err(f) => Some(aer::Status::Failed(Failure { + failure: Some(f), + cause: ActivityTaskFailedCause::ActivityWorkerUnhandledFailure + as i32, + })), }, } } @@ -866,6 +871,7 @@ pub mod coresdk { match self.status { Some(activity_resolution::Status::Failed(Failure { failure: Some(ref f), + .. })) => f.is_timeout(), _ => None, } diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index 4dbd3a8ed..886d708a3 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -44,6 +44,9 @@ relevant information. * Workers now log a `[TMPRL1104]` warning when a workflow task takes longer than 5 seconds. Set `TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` to change the threshold. * Core now supports attaching `EventGroupMarker`s to most workflow commands. +* The `temporal_activity_execution_failed` and `temporal_local_activity_execution_failed` worker + metrics now carry a `failure_reason` attribute. Each is now split into one time series per + reason, which may affect existing dashboards. ### Breaking Changes :boom: * Activity failures now include the latest heartbeat details atomically instead of force-flushing a @@ -51,6 +54,10 @@ relevant information. are preserved on failure; workers warn when the server does not advertise support. ### Fixed +* An activity failure caused by oversized final heartbeat details is now counted in the + `temporal_activity_execution_failed` metric as `failure_reason="PayloadsTooLarge"`. Previously it + was counted under the reason for the failure the activity itself reported, and was not counted at + all when that failure was benign, even though a payload-limit failure was reported instead. * Workers now warn when autoscaling task polling encounters errors continuously for one minute. Repeated warnings use exponential backoff up to 15-minute intervals and stop after polling recovers. diff --git a/crates/sdk-core/src/core_tests/activity_tasks.rs b/crates/sdk-core/src/core_tests/activity_tasks.rs index 21a563861..a1c0f6533 100644 --- a/crates/sdk-core/src/core_tests/activity_tasks.rs +++ b/crates/sdk-core/src/core_tests/activity_tasks.rs @@ -10,8 +10,8 @@ use crate::{ worker::{ PollerBehavior, WorkerVersioningStrategy, client::{ - WorkerClient, WorkerClientBag, - mocks::{mock_manual_worker_client, mock_worker_client}, + MockWorkerClient, WorkerClient, WorkerClientBag, + mocks::{DEFAULT_TEST_CAPABILITIES, mock_manual_worker_client, mock_worker_client}, }, }, }; @@ -30,6 +30,7 @@ use std::{ use temporalio_client::{ Connection, ConnectionOptions, PayloadErrorLimits, SharedReplaceableClient, callback_based::{CallbackBasedGrpcService, GrpcSuccessResponse}, + worker::ClientWorkerSet, }; use temporalio_common::{ payload_limits::{LimitClass, LimitSeverity, PayloadLimitViolation}, @@ -37,8 +38,8 @@ use temporalio_common::{ coresdk::{ ActivityTaskCompletion, activity_result::{ - ActivityExecutionResult, ActivityResolution, Success, activity_execution_result, - activity_resolution, + self as activity_result, ActivityExecutionResult, ActivityResolution, + ActivityTaskFailedCause, Success, activity_execution_result, activity_resolution, }, activity_task::{ActivityCancelReason, ActivityTask, Cancel, activity_task}, workflow_activation::{ @@ -52,8 +53,8 @@ use temporalio_common::{ }, temporal::api::{ command::v1::{ScheduleActivityTaskCommandAttributes, command::Attributes}, - enums::v1::EventType, - failure::v1::failure::FailureInfo, + enums::v1::{ApplicationErrorCategory, EventType}, + failure::v1::{ApplicationFailureInfo, Failure, failure::FailureInfo}, workflowservice::v1::{ GetSystemInfoResponse, PollActivityTaskQueueResponse, RecordActivityTaskHeartbeatRequest, RecordActivityTaskHeartbeatResponse, @@ -679,7 +680,7 @@ async fn complete_act_with_fail_includes_latest_heartbeat() { }) }); mock_client.expect_fail_activity_task().times(1).returning( - move |_, _, last_heartbeat_details| { + move |_, _, _, last_heartbeat_details| { assert_eq!(last_heartbeat_details.unwrap().payloads[0].data, [last_hb]); Ok(RespondActivityTaskFailedResponse::default()) }, @@ -843,7 +844,7 @@ async fn activity_failure_distinguishes_no_heartbeat_from_empty_heartbeat() { Ok(RecordActivityTaskHeartbeatResponse::default()) }); mock_client.expect_fail_activity_task().times(1).returning( - move |_, _, last_heartbeat_details| { + move |_, _, _, last_heartbeat_details| { if explicit_empty_heartbeat { assert_eq!(last_heartbeat_details.unwrap().payloads, []); } else { @@ -958,7 +959,8 @@ async fn oversized_activity_result_failure_includes_latest_heartbeat() { .times(1) .returning(|_, _| Err(payload_too_large_status())); mock_client.expect_fail_activity_task().times(1).returning( - |_, failure, last_heartbeat_details| { + |_, cause, failure, last_heartbeat_details| { + assert_eq!(cause, ActivityTaskFailedCause::PayloadsTooLarge); assert_payloads_too_large_retryable(&failure); assert_eq!(last_heartbeat_details.unwrap().payloads[0].data, [2]); Ok(RespondActivityTaskFailedResponse::default()) @@ -1005,7 +1007,8 @@ async fn oversized_cancel_details_fails_activity() { .times(1) .returning(|_, _| Err(payload_too_large_status())); mock_client.expect_fail_activity_task().times(1).returning( - |_, failure, last_heartbeat_details| { + |_, cause, failure, last_heartbeat_details| { + assert_eq!(cause, ActivityTaskFailedCause::PayloadsTooLarge); assert_payloads_too_large_retryable(&failure); assert_eq!(last_heartbeat_details.unwrap().payloads[0].data, [2]); Ok(RespondActivityTaskFailedResponse::default()) @@ -1040,6 +1043,102 @@ async fn oversized_cancel_details_fails_activity() { core.drain_activity_poller_and_shutdown().await; } +/// Oversized *final* heartbeat details replace whatever lang reported, so the cause and failure sent +/// to the server describe the payload-limit violation rather than the activity's own error, and the +/// oversized details are dropped so the server does not reject the whole request. +#[tokio::test] +async fn oversized_final_heartbeat_details_replace_reported_failure() { + // Manually created because we need non-default payload error limits, and mockall matches + // expectations in creation order, so they cannot be overridden after `mock_worker_client`. + // This will no longer be needed if https://github.com/asomers/mockall/issues/283 is implemented. + let mut mock_client = MockWorkerClient::new(); + let workers = Arc::new(ClientWorkerSet::new()); + mock_client + .expect_payload_error_limits() + .returning(|| Some(PayloadErrorLimits { blob: 10, memo: 10 })); + mock_client + .expect_capabilities() + .returning(|| Some(*DEFAULT_TEST_CAPABILITIES)); + mock_client + .expect_workers() + .returning(move || workers.clone()); + mock_client.expect_is_mock().returning(|| true); + mock_client + .expect_shutdown_worker() + .returning(|_, _, _, _| Ok(ShutdownWorkerResponse {})); + mock_client + .expect_sdk_name_and_version() + .returning(|| ("test-core".to_string(), "0.0.0".to_string())); + mock_client + .expect_identity() + .returning(|| "test-identity".to_string()); + mock_client + .expect_worker_grouping_key() + .returning(Uuid::new_v4); + mock_client + .expect_worker_instance_key() + .returning(Uuid::new_v4); + mock_client + .expect_set_heartbeat_client_fields() + .returning(|_| {}); + mock_client + .expect_record_activity_heartbeat() + .returning(|_, _| Ok(RecordActivityTaskHeartbeatResponse::default())); + mock_client.expect_fail_activity_task().times(1).returning( + |_, cause, failure, last_heartbeat_details| { + assert_eq!(cause, ActivityTaskFailedCause::PayloadsTooLarge); + assert_payloads_too_large_retryable(&failure); + assert!( + last_heartbeat_details.is_none(), + "oversized details must be dropped" + ); + Ok(RespondActivityTaskFailedResponse::default()) + }, + ); + + let core = mock_worker(MocksHolder::from_client_with_activities( + mock_client, + [PollActivityTaskQueueResponse { + task_token: vec![1], + activity_id: "act1".to_string(), + heartbeat_timeout: Some(prost_dur!(from_secs(10))), + ..Default::default() + } + .into()], + )); + + let act = core.poll_activity_task().await.unwrap(); + core.record_activity_heartbeat(ActivityHeartbeat { + task_token: act.task_token.clone(), + details: vec![vec![0_u8; 1024].into()], + }); + // A benign failure is normally not reported as an execution failure at all; what reaches the + // server here is a payload-limit failure instead, which is not benign. + core.complete_activity_task(ActivityTaskCompletion { + task_token: act.task_token, + result: Some(ActivityExecutionResult { + status: Some(activity_execution_result::Status::Failed( + activity_result::Failure { + failure: Some(Failure { + message: "benign".to_string(), + failure_info: Some(FailureInfo::ApplicationFailureInfo( + ApplicationFailureInfo { + category: ApplicationErrorCategory::Benign as i32, + ..Default::default() + }, + )), + ..Default::default() + }), + cause: ActivityTaskFailedCause::ActivityWorkerUnhandledFailure as i32, + }, + )), + }), + }) + .await + .unwrap(); + core.drain_activity_poller_and_shutdown().await; +} + /// An oversized heartbeat `details` payload must fail the activity task (retryably) and stop the /// running activity with a `Cancelled` cancel — replicating the server, which fails the activity /// task and returns `cancel_requested = true`. @@ -1051,7 +1150,8 @@ async fn oversized_heartbeat_fails_activity() { .times(1) .returning(|_, _| Err(payload_too_large_status())); mock_client.expect_fail_activity_task().times(1).returning( - |_, failure, last_heartbeat_details| { + |_, cause, failure, last_heartbeat_details| { + assert_eq!(cause, ActivityTaskFailedCause::PayloadsTooLarge); assert_payloads_too_large_retryable(&failure); assert!(last_heartbeat_details.is_none()); Ok(RespondActivityTaskFailedResponse::default()) @@ -1456,7 +1556,7 @@ async fn graceful_shutdown(#[values(true, false)] at_max_outstanding: bool) { Ok(RecordActivityTaskHeartbeatResponse::default()) }); mock_client.expect_fail_activity_task().times(3).returning( - |task_token, _, last_heartbeat_details| { + |task_token, _, _, last_heartbeat_details| { if task_token.0 == [1] { assert_eq!(last_heartbeat_details.unwrap().payloads[0].data, [2]); } else { diff --git a/crates/sdk-core/src/core_tests/workflow_tasks.rs b/crates/sdk-core/src/core_tests/workflow_tasks.rs index b3991b01e..93e5f8111 100644 --- a/crates/sdk-core/src/core_tests/workflow_tasks.rs +++ b/crates/sdk-core/src/core_tests/workflow_tasks.rs @@ -317,7 +317,7 @@ async fn scheduled_activity_timeout(hist_batches: &'static [usize]) { seq, result: Some(ActivityResolution { status: Some(activity_resolution::Status::Failed(ar::Failure { - failure: Some(failure) + failure: Some(failure), .. })), }), .. } @@ -370,7 +370,7 @@ async fn started_activity_timeout(hist_batches: &'static [usize]) { seq, result: Some(ActivityResolution { status: Some(activity_resolution::Status::Failed(ar::Failure { - failure: Some(failure) + failure: Some(failure), .. })), }), .. } diff --git a/crates/sdk-core/src/telemetry/metrics.rs b/crates/sdk-core/src/telemetry/metrics.rs index cc569670d..6bff7fd9f 100644 --- a/crates/sdk-core/src/telemetry/metrics.rs +++ b/crates/sdk-core/src/telemetry/metrics.rs @@ -10,7 +10,10 @@ use std::{ time::Duration, }; use temporalio_common::{ - protos::temporal::api::{enums::v1::WorkflowTaskFailedCause, failure::v1::Failure}, + protos::{ + coresdk::activity_result::ActivityTaskFailedCause, + temporal::api::{enums::v1::WorkflowTaskFailedCause, failure::v1::Failure}, + }, telemetry::metrics::{core::*, *}, }; @@ -755,22 +758,26 @@ pub(crate) fn eager(is_eager: bool) -> MetricKeyValue { pub(crate) enum FailureReason { Nondeterminism, Workflow, + Activity, Timeout, NexusOperation(String), NexusHandlerError(String), GrpcMessageTooLarge, PayloadsTooLarge, + ExternalStorageError, } impl Display for FailureReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let str = match self { FailureReason::Nondeterminism => "NonDeterminismError".to_owned(), FailureReason::Workflow => "WorkflowError".to_owned(), + FailureReason::Activity => "ActivityError".to_owned(), FailureReason::Timeout => "timeout".to_owned(), FailureReason::NexusOperation(op) => format!("operation_{op}"), FailureReason::NexusHandlerError(op) => format!("handler_error_{op}"), FailureReason::GrpcMessageTooLarge => "GrpcMessageTooLarge".to_owned(), FailureReason::PayloadsTooLarge => "PayloadsTooLarge".to_owned(), + FailureReason::ExternalStorageError => "ExternalStorageError".to_owned(), }; write!(f, "{str}") } @@ -783,6 +790,16 @@ impl From for FailureReason { } } } +impl From for FailureReason { + fn from(v: ActivityTaskFailedCause) -> Self { + match v { + ActivityTaskFailedCause::PayloadsTooLarge => FailureReason::PayloadsTooLarge, + ActivityTaskFailedCause::ExternalStorageFailure => FailureReason::ExternalStorageError, + ActivityTaskFailedCause::Unspecified + | ActivityTaskFailedCause::ActivityWorkerUnhandledFailure => FailureReason::Activity, + } + } +} pub(crate) fn failure_reason(reason: FailureReason) -> MetricKeyValue { MetricKeyValue::new(KEY_TASK_FAILURE_TYPE, reason.to_string()) } diff --git a/crates/sdk-core/src/worker/activities.rs b/crates/sdk-core/src/worker/activities.rs index 9d881fdfc..495d76a5a 100644 --- a/crates/sdk-core/src/worker/activities.rs +++ b/crates/sdk-core/src/worker/activities.rs @@ -14,7 +14,8 @@ use crate::{ }, pollers::{BoxedActPoller, PermittedTqResp, TrackedPermittedTqResp, new_activity_task_poller}, telemetry::metrics::{ - MetricsContext, activity_type, eager, should_record_failure_metric, workflow_type, + FailureReason, MetricsContext, activity_type, eager, failure_reason, + should_record_failure_metric, workflow_type, }, worker::{ ActivitySlotKind, PollError, @@ -36,13 +37,17 @@ use std::{ }, time::{Duration, Instant, SystemTime}, }; -use temporalio_client::{payload_limit_violation_from, worker::CancelActivityCallback}; +use temporalio_client::{ + PayloadErrorLimits, payload_limit_violation_from, worker::CancelActivityCallback, +}; use temporalio_common::{ - payload_limits::PayloadLimitViolation, + payload_limits::{PayloadLimitViolation, PayloadLimits, validate_known_payload_limits}, protos::{ coresdk::{ ActivityHeartbeat, ActivitySlotInfo, - activity_result::{self as ar, activity_execution_result as aer}, + activity_result::{ + self as ar, ActivityTaskFailedCause, activity_execution_result as aer, + }, activity_task::{ActivityCancelReason, ActivityCancellationDetails, ActivityTask}, }, temporal::api::{ @@ -50,7 +55,9 @@ use temporalio_common::{ failure::v1::{ ApplicationFailureInfo, CanceledFailureInfo, Failure, failure::FailureInfo, }, - workflowservice::v1::PollActivityTaskQueueResponse, + workflowservice::v1::{ + PollActivityTaskQueueResponse, RecordActivityTaskHeartbeatRequest, + }, }, }, }; @@ -363,13 +370,24 @@ impl WorkerActivityTasks { .evict(task_token.clone(), should_flush) .await; - let last_heartbeat_details = act_info - .last_heartbeat_details - .map(|payloads| Payloads { payloads }); - // No need to report activities which we already know the server doesn't care about if !known_not_found { let _flushing_guard = self.completers_lock.read().await; + + let mut last_heartbeat_details = act_info + .last_heartbeat_details + .map(|payloads| Payloads { payloads }); + // Only failure reports carry these to the server, and oversized details would make + // the server reject such a request outright, so drop them and report the violation + // as the failure instead. Checked here rather than in the client so that the + // reported failure, the cause, and the metric can't disagree about what happened. + let heartbeat_details_violation = last_heartbeat_details.as_ref().and_then(|d| { + heartbeat_details_limit_violation(d, client.payload_error_limits()) + }); + if heartbeat_details_violation.is_some() { + last_heartbeat_details = None; + } + let maybe_net_err = match status { aer::Status::WillCompleteAsync(_) => None, aer::Status::Completed(ar::Success { result }) => { @@ -390,10 +408,15 @@ impl WorkerActivityTasks { } Err(e) => { if let Some(violation) = payload_limit_violation_from(&e) { - act_metrics.act_execution_failed(); + act_metrics + .with_new_attrs([failure_reason( + FailureReason::PayloadsTooLarge, + )]) + .act_execution_failed(); client .fail_activity_task( task_token.clone(), + ActivityTaskFailedCause::PayloadsTooLarge, Some(make_payloads_too_large_failure(violation)), last_heartbeat_details.clone(), ) @@ -405,12 +428,42 @@ impl WorkerActivityTasks { } } } - aer::Status::Failed(ar::Failure { failure }) => { - if should_record_failure_metric(&failure) { - act_metrics.act_execution_failed(); - } + aer::Status::Failed(fail) => { + let (cause, failure) = if let Some(violation) = + heartbeat_details_violation.as_ref() + { + // What reaches the server is no longer whatever lang reported, so + // the metric must be recorded even for an otherwise benign failure. + act_metrics + .with_new_attrs([failure_reason(FailureReason::PayloadsTooLarge)]) + .act_execution_failed(); + ( + ActivityTaskFailedCause::PayloadsTooLarge, + Some(make_payloads_too_large_failure(violation)), + ) + } else { + // An SDK reporting no cause recognized nothing more specific, which is + // normalized to unhandled failure so all SDKs do not have to specify it. + let cause = match fail.cause() { + ActivityTaskFailedCause::Unspecified => { + ActivityTaskFailedCause::ActivityWorkerUnhandledFailure + } + c => c, + }; + if should_record_failure_metric(&fail.failure) { + act_metrics + .with_new_attrs([failure_reason(cause.into())]) + .act_execution_failed(); + } + (cause, fail.failure) + }; client - .fail_activity_task(task_token.clone(), failure, last_heartbeat_details) + .fail_activity_task( + task_token.clone(), + cause, + failure, + last_heartbeat_details, + ) .await .err() } @@ -422,10 +475,21 @@ impl WorkerActivityTasks { // We report cancels for graceful shutdown as failures, so we // don't wait for the whole timeout to elapse, which is what would // happen anyway. + let (cause, failure) = match heartbeat_details_violation.as_ref() { + Some(violation) => ( + ActivityTaskFailedCause::PayloadsTooLarge, + make_payloads_too_large_failure(violation), + ), + None => ( + ActivityTaskFailedCause::ActivityWorkerUnhandledFailure, + worker_shutdown_failure(), + ), + }; client .fail_activity_task( task_token.clone(), - Some(worker_shutdown_failure()), + cause, + Some(failure), last_heartbeat_details, ) .await @@ -453,10 +517,15 @@ impl WorkerActivityTasks { Ok(_) => None, Err(e) => { if let Some(violation) = payload_limit_violation_from(&e) { - act_metrics.act_execution_failed(); + act_metrics + .with_new_attrs([failure_reason( + FailureReason::PayloadsTooLarge, + )]) + .act_execution_failed(); client .fail_activity_task( task_token.clone(), + ActivityTaskFailedCause::PayloadsTooLarge, Some(make_payloads_too_large_failure(violation)), last_heartbeat_details, ) @@ -807,6 +876,26 @@ fn worker_shutdown_failure() -> Failure { } } +/// Validates final heartbeat details against the worker's payload error limits, since attaching +/// oversized details to a failure request would make the server reject the request as a whole. +fn heartbeat_details_limit_violation( + details: &Payloads, + limits: Option, +) -> Option { + let limits = limits?; + validate_known_payload_limits( + &RecordActivityTaskHeartbeatRequest { + details: Some(details.clone()), + ..Default::default() + }, + &PayloadLimits { + blob_error: limits.blob, + memo_error: limits.memo, + ..Default::default() + }, + ) +} + /// The failure is deliberately retryable: catching the violation client-side exists precisely to /// turn what the server would hard-fail into a recoverable activity task failure, so fixing and /// redeploying the activity lets the next attempt succeed. diff --git a/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs b/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs index 7295e124d..09da7b99b 100644 --- a/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs +++ b/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs @@ -16,6 +16,7 @@ use temporalio_client::payload_limit_violation_from; use temporalio_common::protos::{ coresdk::{ ActivityHeartbeat, IntoPayloadsExt, + activity_result::ActivityTaskFailedCause, activity_task::{ActivityCancelReason, ActivityCancellationDetails, ActivityTask}, }, temporal::api::{ @@ -201,6 +202,7 @@ impl ActivityHeartbeatManager { if let Err(fe) = sg .fail_activity_task( tt.clone(), + ActivityTaskFailedCause::PayloadsTooLarge, Some(make_payloads_too_large_failure(violation)), None, ) diff --git a/crates/sdk-core/src/worker/activities/local_activities.rs b/crates/sdk-core/src/worker/activities/local_activities.rs index bc5347b2c..7f5b893f5 100644 --- a/crates/sdk-core/src/worker/activities/local_activities.rs +++ b/crates/sdk-core/src/worker/activities/local_activities.rs @@ -2,7 +2,9 @@ use crate::{ MetricsContext, TaskToken, abstractions::{MeteredPermitDealer, OwnedMeteredSemPermit, UsedMeteredSemPermit, dbg_panic}, protosext::ValidScheduleLA, - telemetry::metrics::{activity_type, should_record_failure_metric, workflow_type}, + telemetry::metrics::{ + FailureReason, activity_type, failure_reason, should_record_failure_metric, workflow_type, + }, worker::{LocalActivitySlotKind, workflow::HeartbeatTimeoutMsg}, }; use futures_util::{ @@ -80,6 +82,7 @@ impl LocalActivityExecutionResult { )), ..Default::default() }), + ..Default::default() }) } @@ -612,14 +615,18 @@ impl LocalActivityManager { let outcome = match &status { LocalActivityExecutionResult::Failed(fail) => { if should_record_failure_metric(&fail.failure) { - la_metrics.la_execution_failed() + la_metrics + .with_new_attrs([failure_reason(fail.cause().into())]) + .la_execution_failed() } Outcome::FailurePath { backoff: calc_backoff!(fail), } } LocalActivityExecutionResult::TimedOut(fail) => { - la_metrics.la_execution_failed(); + la_metrics + .with_new_attrs([failure_reason(FailureReason::Timeout)]) + .la_execution_failed(); is_timeout = true; // Start to close timeouts are retryable, other timeout types aren't. if matches!(status.get_timeout_type(), Some(TimeoutType::StartToClose)) { @@ -1284,6 +1291,7 @@ mod tests { )), ..Default::default() }), + ..Default::default() }), ); assert_matches!(res, LACompleteAction::Report { .. }); diff --git a/crates/sdk-core/src/worker/client.rs b/crates/sdk-core/src/worker/client.rs index 4760205bd..fcc87ccd3 100644 --- a/crates/sdk-core/src/worker/client.rs +++ b/crates/sdk-core/src/worker/client.rs @@ -20,7 +20,10 @@ use temporalio_client::{ }; use temporalio_common::protos::{ TaskToken, - coresdk::{workflow_commands::QueryResult, workflow_completion}, + coresdk::{ + activity_result::ActivityTaskFailedCause, workflow_commands::QueryResult, + workflow_completion, + }, temporal::api::{ command::v1::Command, common::v1::{ @@ -211,6 +214,7 @@ pub trait WorkerClient: Sync + Send { async fn fail_activity_task( &self, task_token: TaskToken, + cause: ActivityTaskFailedCause, failure: Option, last_heartbeat_details: Option, ) -> Result; @@ -282,6 +286,10 @@ pub trait WorkerClient: Sync + Send { fn set_heartbeat_client_fields(&self, heartbeat: &mut WorkerHeartbeat); /// Set the worker's payload/memo error limits fn set_payload_error_limits(&self, _limits: Option) {} + /// Get the worker's payload/memo error limits + fn payload_error_limits(&self) -> Option { + None + } } /// Configuration options shared by workflow, activity, and Nexus polling calls @@ -613,34 +621,13 @@ impl WorkerClient for WorkerClientBag { async fn fail_activity_task( &self, task_token: TaskToken, - mut failure: Option, - mut last_heartbeat_details: Option, + // Unused until `RespondActivityTaskFailedRequest` gains a cause field + // (https://github.com/temporalio/api/pull/816). Taken as a parameter regardless so the + // cause is decided next to the failure it describes, as `fail_workflow_task` does. + _cause: ActivityTaskFailedCause, + failure: Option, + last_heartbeat_details: Option, ) -> Result { - let payload_error_limits = self.client.error_limits(); - if let (Some(details), Some(limits)) = - (last_heartbeat_details.as_ref(), payload_error_limits) - { - let heartbeat_request = RecordActivityTaskHeartbeatRequest { - details: Some(details.clone()), - ..Default::default() - }; - let payload_limits = temporalio_common::payload_limits::PayloadLimits { - blob_error: limits.blob, - memo_error: limits.memo, - ..Default::default() - }; - if let Some(violation) = - temporalio_common::payload_limits::validate_known_payload_limits( - &heartbeat_request, - &payload_limits, - ) - { - failure = Some(crate::worker::activities::make_payloads_too_large_failure( - &violation, - )); - last_heartbeat_details = None; - } - } Ok(self .client .clone() @@ -938,6 +925,10 @@ impl WorkerClient for WorkerClientBag { fn set_payload_error_limits(&self, limits: Option) { self.client.set_error_limits(limits); } + + fn payload_error_limits(&self) -> Option { + self.client.error_limits() + } } impl NamespacedClient for WorkerClientBag { @@ -1069,6 +1060,7 @@ mod tests { client .fail_activity_task( TaskToken(vec![1]), + ActivityTaskFailedCause::ActivityWorkerUnhandledFailure, None, Some(last_heartbeat_details.clone()), ) diff --git a/crates/sdk-core/src/worker/client/mocks.rs b/crates/sdk-core/src/worker/client/mocks.rs index 88cf9fbab..4d2efcfe0 100644 --- a/crates/sdk-core/src/worker/client/mocks.rs +++ b/crates/sdk-core/src/worker/client/mocks.rs @@ -23,6 +23,7 @@ pub(crate) static DEFAULT_TEST_CAPABILITIES: &Capabilities = &Capabilities { /// Create a mock client primed with basic necessary expectations pub fn mock_worker_client() -> MockWorkerClient { let mut r = MockWorkerClient::new(); + r.expect_payload_error_limits().returning(|| None); let workers = Arc::new(ClientWorkerSet::new()); r.expect_capabilities() .returning(|| Some(*DEFAULT_TEST_CAPABILITIES)); @@ -113,6 +114,7 @@ mockall::mock! { fn fail_activity_task<'a, 'b>( &self, task_token: TaskToken, + cause: ActivityTaskFailedCause, failure: Option, last_heartbeat_details: Option, ) -> impl Future> + Send + 'b diff --git a/crates/sdk-core/src/worker/workflow/machines/activity_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/activity_state_machine.rs index fbf4143ae..0d9a2dc5f 100644 --- a/crates/sdk-core/src/worker/workflow/machines/activity_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/activity_state_machine.rs @@ -324,6 +324,7 @@ impl WFMachinesAdapter for ActivityMachine { result: Some(ActivityResolution { status: Some(activity_resolution::Status::Failed(ar::Failure { failure: Some(failure), + ..Default::default() })), }), is_local: false, diff --git a/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs index 1c7815c0e..192245271 100644 --- a/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs @@ -134,6 +134,7 @@ impl From for ResolveDat { } else { LocalActivityExecutionResult::Failed(ActFail { failure: Some(fail), + ..Default::default() }) } } @@ -616,7 +617,7 @@ impl WFMachinesAdapter for LocalActivityMachine { maybe_failure = fail.failure; } LocalActivityExecutionResult::Cancelled(Cancellation { failure }) - | LocalActivityExecutionResult::TimedOut(ActFail { failure }) => { + | LocalActivityExecutionResult::TimedOut(ActFail { failure, .. }) => { will_not_run_again = true; maybe_failure = failure; } diff --git a/crates/sdk-core/tests/integ_tests/metrics_tests.rs b/crates/sdk-core/tests/integ_tests/metrics_tests.rs index 7039e8b87..04d09b63d 100644 --- a/crates/sdk-core/tests/integ_tests/metrics_tests.rs +++ b/crates/sdk-core/tests/integ_tests/metrics_tests.rs @@ -25,10 +25,14 @@ use temporalio_client::{ }; use temporalio_common::{ data_converters::RawValue, + payload_limits::{LimitClass, LimitSeverity, PayloadLimitViolation}, protos::{ coresdk::{ ActivityTaskCompletion, - activity_result::ActivityExecutionResult, + activity_result::{ + self as activity_result, ActivityExecutionResult, ActivityTaskFailedCause, + activity_execution_result, + }, nexus::{NexusTaskCompletion, nexus_task, nexus_task_completion}, workflow_activation::{WorkflowActivationJob, workflow_activation_job}, workflow_commands::{ @@ -52,7 +56,9 @@ use temporalio_common::{ request::Variant, start_operation_response, }, }, - workflowservice::v1::{DescribeNamespaceRequest, ListNamespacesRequest}, + workflowservice::v1::{ + DescribeNamespaceRequest, ListNamespacesRequest, PollActivityTaskQueueResponse, + }, }, }, telemetry::{ @@ -80,8 +86,8 @@ use temporalio_sdk_core::{ WorkflowSlotKind, init_worker, prost_dur, replay::TestHistoryBuilder, test_help::{ - MockPollCfg, ResponseType, TemporalMeter, WorkerExt, WorkerTestHelpers, build_mock_pollers, - mock_worker, mock_worker_client, + MockPollCfg, MocksHolder, ResponseType, TemporalMeter, WorkerExt, WorkerTestHelpers, + build_mock_pollers, mock_worker, mock_worker_client, }, }; use tokio::{ @@ -1013,7 +1019,7 @@ async fn activity_metrics() { async fn pass_fail_act(ctx: ActivityContext, i: String) -> Result { match i.as_str() { "pass" => Ok("pass".to_string()), - "cancel" => { + "cancel" | "timeout" => { ctx.cancelled().await; Err(ActivityError::cancelled()) } @@ -1084,7 +1090,23 @@ async fn activity_metrics() { ) .build(), ); - let _ = join!(local_act_pass, local_act_fail); + // Outlives its start-to-close timeout, so core resolves it as timed out rather than + // as the cancel the activity reports once core stops it. + let local_act_timeout = ctx.execute_local_activity( + PassFailActivities::pass_fail_act, + "timeout".to_string(), + LocalActivityOptions::builder() + .start_to_close_timeout(Duration::from_millis(100)) + .retry_policy( + RetryPolicy { + maximum_attempts: 1, + ..Default::default() + } + .into(), + ) + .build(), + ); + let _ = join!(local_act_pass, local_act_fail, local_act_timeout); // TODO: Currently takes a WFT b/c of https://github.com/temporalio/sdk-core/issues/856 local_act_cancel.cancel(); let _ = local_act_cancel.await; @@ -1108,6 +1130,7 @@ async fn activity_metrics() { let wf_type = ActivityMetricsWf::name(); assert!(body.contains(&format!( "temporal_activity_execution_failed{{activity_type=\"pass_fail_act\",\ + failure_reason=\"ActivityError\",\ namespace=\"{NAMESPACE}\",service_name=\"temporal-core-sdk\",\ task_queue=\"{task_queue}\",workflow_type=\"{wf_type}\"}} 1" ))); @@ -1130,10 +1153,18 @@ async fn activity_metrics() { assert!(body.contains(&format!( "temporal_local_activity_total{{activity_type=\"pass_fail_act\",namespace=\"{NAMESPACE}\",\ service_name=\"temporal-core-sdk\",task_queue=\"{task_queue}\",\ - workflow_type=\"{wf_type}\"}} 3" + workflow_type=\"{wf_type}\"}} 4" ))); assert!(body.contains(&format!( "temporal_local_activity_execution_failed{{activity_type=\"pass_fail_act\",\ + failure_reason=\"ActivityError\",\ + namespace=\"{NAMESPACE}\",service_name=\"temporal-core-sdk\",\ + task_queue=\"{task_queue}\",\ + workflow_type=\"{wf_type}\"}} 1" + ))); + assert!(body.contains(&format!( + "temporal_local_activity_execution_failed{{activity_type=\"pass_fail_act\",\ + failure_reason=\"timeout\",\ namespace=\"{NAMESPACE}\",service_name=\"temporal-core-sdk\",\ task_queue=\"{task_queue}\",\ workflow_type=\"{wf_type}\"}} 1" @@ -1148,7 +1179,7 @@ async fn activity_metrics() { "temporal_local_activity_execution_latency_count{{activity_type=\"pass_fail_act\",\ namespace=\"{NAMESPACE}\",service_name=\"temporal-core-sdk\",\ task_queue=\"{task_queue}\",\ - workflow_type=\"{wf_type}\"}} 3" + workflow_type=\"{wf_type}\"}} 4" ))); assert!(body.contains(&format!( "temporal_local_activity_succeed_endtoend_latency_count{{activity_type=\"pass_fail_act\",\ @@ -2095,3 +2126,150 @@ async fn grpc_message_too_large_wf_task_execution_failed_metric_includes_workflo "Expected workflow_type label on metric, got: {metric_line}" ); } + +/// A cause reported by lang must survive to the metric as its own `failure_reason`, rather than +/// being flattened into the catch-all activity reason. +#[tokio::test] +async fn lang_reported_activity_failure_cause_reaches_metric() { + let (telemopts, addr, _aborter) = prom_metrics(None); + let rt = CoreRuntime::new_assume_tokio(get_integ_runtime_options(telemopts)).unwrap(); + let meter = rt.telemetry().get_temporal_metric_meter().unwrap(); + + let mut mock_client = mock_worker_client(); + mock_client + .expect_fail_activity_task() + .times(1) + .returning(|_, cause, _, _| { + assert_eq!(cause, ActivityTaskFailedCause::ExternalStorageFailure); + Ok(Default::default()) + }); + + let mut mock = MocksHolder::from_client_with_activities( + mock_client, + [PollActivityTaskQueueResponse { + task_token: vec![1], + activity_id: "act1".to_string(), + activity_type: Some("act_type".into()), + ..Default::default() + } + .into()], + ); + mock.set_temporal_meter(meter); + let core = mock_worker(mock); + + let act = core.poll_activity_task().await.unwrap(); + core.complete_activity_task(ActivityTaskCompletion { + task_token: act.task_token, + result: Some(ActivityExecutionResult { + status: Some(activity_execution_result::Status::Failed( + activity_result::Failure { + failure: Some(Failure { + message: "storage exploded".to_string(), + ..Default::default() + }), + cause: ActivityTaskFailedCause::ExternalStorageFailure as i32, + }, + )), + }), + }) + .await + .unwrap(); + core.drain_activity_poller_and_shutdown().await; + + let metric_line = eventually( + || { + let endpoint = format!("http://{addr}/metrics"); + async move { + let body = get_text(endpoint).await; + body.lines() + .find(|l| l.starts_with("temporal_activity_execution_failed{")) + .map(ToString::to_string) + .ok_or_else(|| anyhow!("activity_execution_failed metric not found")) + } + }, + Duration::from_secs(5), + ) + .await + .unwrap(); + + assert!( + metric_line.contains("failure_reason=\"ExternalStorageError\""), + "Expected ExternalStorageError failure reason on metric, got: {metric_line}" + ); +} + +/// A payload-limit violation detected while reporting an activity result is core's own doing, so it +/// must reach the metric as its own reason rather than the generic activity one. +#[tokio::test] +async fn payloads_too_large_activity_failure_reaches_metric() { + let (telemopts, addr, _aborter) = prom_metrics(None); + let rt = CoreRuntime::new_assume_tokio(get_integ_runtime_options(telemopts)).unwrap(); + let meter = rt.telemetry().get_temporal_metric_meter().unwrap(); + + let mut mock_client = mock_worker_client(); + mock_client + .expect_complete_activity_task() + .times(1) + .returning(|_, _| { + let violation = PayloadLimitViolation { + path: "result".to_string(), + class: LimitClass::Blob, + severity: LimitSeverity::Error, + size: 1024, + limit: 10, + }; + let mut status = tonic::Status::invalid_argument("Payload size limit exceeded"); + status.set_source(Arc::new(violation)); + Err(status) + }); + mock_client + .expect_fail_activity_task() + .times(1) + .returning(|_, cause, _, _| { + assert_eq!(cause, ActivityTaskFailedCause::PayloadsTooLarge); + Ok(Default::default()) + }); + + let mut mock = MocksHolder::from_client_with_activities( + mock_client, + [PollActivityTaskQueueResponse { + task_token: vec![1], + activity_id: "act1".to_string(), + activity_type: Some("act_type".into()), + ..Default::default() + } + .into()], + ); + mock.set_temporal_meter(meter); + let core = mock_worker(mock); + + let act = core.poll_activity_task().await.unwrap(); + core.complete_activity_task(ActivityTaskCompletion { + task_token: act.task_token, + result: Some(ActivityExecutionResult::ok(vec![0_u8; 1024].into())), + }) + .await + .unwrap(); + core.drain_activity_poller_and_shutdown().await; + + let metric_line = eventually( + || { + let endpoint = format!("http://{addr}/metrics"); + async move { + let body = get_text(endpoint).await; + body.lines() + .find(|l| l.starts_with("temporal_activity_execution_failed{")) + .map(ToString::to_string) + .ok_or_else(|| anyhow!("activity_execution_failed metric not found")) + } + }, + Duration::from_secs(5), + ) + .await + .unwrap(); + + assert!( + metric_line.contains("failure_reason=\"PayloadsTooLarge\""), + "Expected PayloadsTooLarge failure reason on metric, got: {metric_line}" + ); +} diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs index a3b020853..a22e7c7ef 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs @@ -1076,7 +1076,7 @@ async fn activity_non_retryable_failure() { variant: Some(workflow_activation_job::Variant::ResolveActivity( ResolveActivity {seq, result: Some(ActivityResolution{ status: Some(act_res::Status::Failed(activity_result::Failure{ - failure: Some(f), + failure: Some(f), .. }))}),..} )), }, @@ -1143,7 +1143,7 @@ async fn activity_non_retryable_failure_with_error() { variant: Some(workflow_activation_job::Variant::ResolveActivity( ResolveActivity {seq, result: Some(ActivityResolution{ status: Some(act_res::Status::Failed(activity_result::Failure{ - failure: Some(f), + failure: Some(f), .. }))}),..} )), }, @@ -1499,7 +1499,7 @@ async fn started_activity_timeout() { result: Some(ActivityResolution{ status: Some( act_res::Status::Failed( - activity_result::Failure{failure: Some(_)} + activity_result::Failure{failure: Some(_), ..} ) ), .. From 4e6f71fcf9f4be3dee63c9f0c0d699ca160c9254 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 24 Aug 2026 15:14:59 -0700 Subject: [PATCH 22/87] Move changelog release notes helper to its own crate (#1519) * Move changelog release notes helper to its own crate * Select the changelog for release notes --- Cargo.toml | 1 + crates/changelog-release-notes/Cargo.toml | 10 +++++ .../src/main.rs} | 39 +++++++++++++++---- crates/sdk-core/Cargo.toml | 4 -- 4 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 crates/changelog-release-notes/Cargo.toml rename crates/{sdk-core/src/changelog_release_notes.rs => changelog-release-notes/src/main.rs} (91%) diff --git a/Cargo.toml b/Cargo.toml index c9165dbb0..cf6a8884d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/sdk", "crates/workflow", "crates/sdk-core-c-bridge", + "crates/changelog-release-notes", ] resolver = "2" diff --git a/crates/changelog-release-notes/Cargo.toml b/crates/changelog-release-notes/Cargo.toml new file mode 100644 index 000000000..d5ebc3804 --- /dev/null +++ b/crates/changelog-release-notes/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "changelog-release-notes" +version = "0.1.0" +edition = "2024" +license-file = { workspace = true } +publish = false +rust-version = "1.88.0" + +[lints] +workspace = true diff --git a/crates/sdk-core/src/changelog_release_notes.rs b/crates/changelog-release-notes/src/main.rs similarity index 91% rename from crates/sdk-core/src/changelog_release_notes.rs rename to crates/changelog-release-notes/src/main.rs index b8af885ac..884d0f1f1 100644 --- a/crates/sdk-core/src/changelog_release_notes.rs +++ b/crates/changelog-release-notes/src/main.rs @@ -141,8 +141,8 @@ fn update_entries(previous: &Entries, current: BTreeMap> updated } -fn changelog_notes(from: &str, to: &str) -> Result, String> { - let mut entries: Entries = changelog_entries(&git(&["show", &format!("{from}:CHANGELOG.md")])?) +fn changelog_notes(from: &str, to: &str, path: &str) -> Result, String> { + let mut entries: Entries = changelog_entries(&git(&["show", &format!("{from}:{path}")])?) .into_iter() .map(|(header, entries)| { ( @@ -163,12 +163,12 @@ fn changelog_notes(from: &str, to: &str) -> Result, String> { "--reverse", &format!("{from}..{to}"), "--", - "CHANGELOG.md", + path, ])?; for commit in commits.lines().filter(|commit| !commit.is_empty()) { entries = update_entries( &entries, - changelog_entries(&git(&["show", &format!("{commit}:CHANGELOG.md")])?), + changelog_entries(&git(&["show", &format!("{commit}:{path}")])?), ); } let mut categorized: Entries = Entries::new(); @@ -225,7 +225,7 @@ fn link_prs(subject: &str) -> String { output } -fn release_notes(from: &str, to: &str) -> Result, String> { +fn release_notes(from: &str, to: &str, changelog: &str) -> Result, String> { let log = git(&[ "log", "--format=%H%x00%h%x00%s", @@ -235,7 +235,7 @@ fn release_notes(from: &str, to: &str) -> Result, String> { if log.is_empty() { return Ok(Vec::new()); } - let mut output = changelog_notes(from, to)?; + let mut output = changelog_notes(from, to, changelog)?; if !output.is_empty() { output.insert(0, String::new()); output.insert(0, "#### Changelog".into()); @@ -253,6 +253,14 @@ fn release_notes(from: &str, to: &str) -> Result, String> { Ok(output) } +fn changelog_path(changelog: &str) -> Result<&'static str, String> { + match changelog { + "rust" => Ok("CHANGELOG.md"), + "core" => Ok("crates/sdk-core/CHANGELOG.md"), + _ => Err("expected --changelog ".into()), + } +} + fn main() -> Result<(), String> { let mut args = env::args().skip(1); let from = args @@ -265,7 +273,15 @@ fn main() -> Result<(), String> { .filter(|arg| arg == "--to") .and_then(|_| args.next()) .ok_or("expected --to ")?; - println!("{}", release_notes(&from, &to)?.join("\n")); + let changelog = match args.next().as_deref() { + None => "core".to_owned(), + Some("--changelog") => args.next().ok_or("expected --changelog ")?, + Some(_) => return Err("expected --changelog ".into()), + }; + println!( + "{}", + release_notes(&from, &to, changelog_path(&changelog)?)?.join("\n") + ); Ok(()) } @@ -305,6 +321,15 @@ mod tests { assert_eq!(clean_subject(":boom: Change"), "Change"); } + #[test] + fn selects_the_requested_changelog() { + assert_eq!(changelog_path("rust").unwrap(), "CHANGELOG.md"); + assert_eq!( + changelog_path("core").unwrap(), + "crates/sdk-core/CHANGELOG.md" + ); + } + #[test] fn keeps_final_wording_for_introduced_entry() { let previous = Entries::from([( diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index 49c0195c3..48c8c2f07 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -186,10 +186,6 @@ name = "histfetch" path = "src/histfetch.rs" required-features = ["test-utilities"] -[[bin]] -name = "changelog-release-notes" -path = "src/changelog_release_notes.rs" - [[test]] name = "integ_tests" path = "tests/main.rs" From b860e3f14f68af64bd4d0e372b3c78b2ce9ecff1 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 24 Aug 2026 15:23:05 -0700 Subject: [PATCH 23/87] Update api_upstream (#1520) --- .../nexus/deps/nexus-temporal-types/model.wit | 11 ++++ .../api_upstream/nexus/workflow-service.wit | 6 ++- .../api_upstream/openapi/openapiv2.json | 52 +++++++++---------- .../api_upstream/openapi/openapiv3.yaml | 50 +++--------------- .../temporal/api/enums/v1/failed_cause.proto | 4 +- .../api/workflowservice/v1/service.proto | 13 ----- 6 files changed, 51 insertions(+), 85 deletions(-) diff --git a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit index f27eb9e9f..91de3e838 100644 --- a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit +++ b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit @@ -98,6 +98,17 @@ interface model { /// @nexus.type python="collections.abc.Mapping[str, typing.Any]" typescript="Record" dotnet="IReadOnlyDictionary" type memo = placeholder; + /// @nexus.proto "temporal.api.common.v1.Header" typescript-import="@temporalio/proto" + /// @nexus.type + /// python="collections.abc.Mapping[str, typing.Any]" + /// typescript="common.Headers" + /// go="map[string]any" + /// dotnet="IReadOnlyDictionary" + /// dotnet-from="ProtoExtensions.FromHeaderProto" + /// dotnet-to="ProtoExtensions.ToHeaderProto" + /// typescript-import="@temporalio/common" + type header = placeholder; + /// @nexus.proto "temporal.api.common.v1.SearchAttributes" typescript-import="@temporalio/proto" /// @nexus.type /// python="temporalio.common.TypedSearchAttributes" diff --git a/crates/protos/protos/api_upstream/nexus/workflow-service.wit b/crates/protos/protos/api_upstream/nexus/workflow-service.wit index 61822d41c..5ae3814e6 100644 --- a/crates/protos/protos/api_upstream/nexus/workflow-service.wit +++ b/crates/protos/protos/api_upstream/nexus/workflow-service.wit @@ -13,6 +13,7 @@ world system { interface workflow-service { use nexus:temporal-types/model@1.0.0.{ duration, + header, memo, payloads, placeholder, @@ -89,8 +90,9 @@ interface workflow-service { namespace: string, /// @nexus.omit control: placeholder, - /// @nexus.omit - header: placeholder, + /// @nexus.api-omit + /// @nexus.proto-field "header" + headers: option
, /// @nexus.omit links: placeholder, /// @nexus.omit diff --git a/crates/protos/protos/api_upstream/openapi/openapiv2.json b/crates/protos/protos/api_upstream/openapi/openapiv2.json index 1437120e5..1391f113a 100644 --- a/crates/protos/protos/api_upstream/openapi/openapiv2.json +++ b/crates/protos/protos/api_upstream/openapi/openapiv2.json @@ -1451,7 +1451,7 @@ }, "/api/v1/namespaces/{namespace}/current-deployment/{deployment.seriesName}": { "post": { - "summary": "Sets a deployment as the current deployment for its deployment series. Can optionally update\nthe metadata of the deployment as well.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced by `SetWorkerDeploymentCurrentVersion`.", + "summary": "Sets a deployment as the current deployment for its deployment series. Can optionally update\nthe metadata of the deployment as well.\nDeprecated. Replaced by `SetWorkerDeploymentCurrentVersion`.", "operationId": "SetCurrentDeployment2", "responses": { "200": { @@ -1497,7 +1497,7 @@ }, "/api/v1/namespaces/{namespace}/current-deployment/{seriesName}": { "get": { - "summary": "Returns the current deployment (and its info) for a given deployment series.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced by `current_version` returned by `DescribeWorkerDeployment`.", + "summary": "Returns the current deployment (and its info) for a given deployment series.\nDeprecated. Replaced by `current_version` returned by `DescribeWorkerDeployment`.", "operationId": "GetCurrentDeployment2", "responses": { "200": { @@ -1534,7 +1534,7 @@ }, "/api/v1/namespaces/{namespace}/deployments": { "get": { - "summary": "Lists worker deployments in the namespace. Optionally can filter based on deployment series\nname.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced with `ListWorkerDeployments`.", + "summary": "Lists worker deployments in the namespace. Optionally can filter based on deployment series\nname.\nDeprecated. Replaced with `ListWorkerDeployments`.", "operationId": "ListDeployments2", "responses": { "200": { @@ -1586,7 +1586,7 @@ }, "/api/v1/namespaces/{namespace}/deployments/{deployment.seriesName}/{deployment.buildId}": { "get": { - "summary": "Describes a worker deployment.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced with `DescribeWorkerDeploymentVersion`.", + "summary": "Describes a worker deployment.\nDeprecated. Replaced with `DescribeWorkerDeploymentVersion`.", "operationId": "DescribeDeployment2", "responses": { "200": { @@ -1631,7 +1631,7 @@ }, "/api/v1/namespaces/{namespace}/deployments/{deployment.seriesName}/{deployment.buildId}/reachability": { "get": { - "summary": "Returns the reachability level of a worker deployment to help users decide when it is time\nto decommission a deployment. Reachability level is calculated based on the deployment's\n`status` and existing workflows that depend on the given deployment for their execution.\nCalculating reachability is relatively expensive. Therefore, server might return a recently\ncached value. In such a case, the `last_update_time` will inform you about the actual\nreachability calculation time.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced with `DrainageInfo` returned by `DescribeWorkerDeploymentVersion`.", + "summary": "Returns the reachability level of a worker deployment to help users decide when it is time\nto decommission a deployment. Reachability level is calculated based on the deployment's\n`status` and existing workflows that depend on the given deployment for their execution.\nCalculating reachability is relatively expensive. Therefore, server might return a recently\ncached value. In such a case, the `last_update_time` will inform you about the actual\nreachability calculation time.\nDeprecated. Replaced with `DrainageInfo` returned by `DescribeWorkerDeploymentVersion`.", "operationId": "GetDeploymentReachability2", "responses": { "200": { @@ -2849,7 +2849,7 @@ }, "/api/v1/namespaces/{namespace}/worker-deployment-versions/{deploymentVersion.deploymentName}/{deploymentVersion.buildId}": { "get": { - "summary": "Describes a worker deployment version.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Describes a worker deployment version.", "operationId": "DescribeWorkerDeploymentVersion2", "responses": { "200": { @@ -2906,7 +2906,7 @@ ] }, "delete": { - "summary": "Used for manual deletion of Versions. User can delete a Version only when all the\nfollowing conditions are met:\n - It is not the Current or Ramping Version of its Deployment.\n - It has no active pollers (none of the task queues in the Version have pollers)\n - It is not draining (see WorkerDeploymentVersionInfo.drainage_info). This condition\n can be skipped by passing `skip-drainage=true`.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Used for manual deletion of Versions. User can delete a Version only when all the\nfollowing conditions are met:\n - It is not the Current or Ramping Version of its Deployment.\n - It has no active pollers (none of the task queues in the Version have pollers)\n - It is not draining (see WorkerDeploymentVersionInfo.drainage_info). This condition\n can be skipped by passing `skip-drainage=true`.", "operationId": "DeleteWorkerDeploymentVersion2", "responses": { "200": { @@ -3025,7 +3025,7 @@ }, "/api/v1/namespaces/{namespace}/worker-deployment-versions/{deploymentVersion.deploymentName}/{deploymentVersion.buildId}/update-metadata": { "post": { - "summary": "Updates the user-given metadata attached to a Worker Deployment Version.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Updates the user-given metadata attached to a Worker Deployment Version.", "operationId": "UpdateWorkerDeploymentVersionMetadata2", "responses": { "200": { @@ -3131,7 +3131,7 @@ }, "/api/v1/namespaces/{namespace}/worker-deployments": { "get": { - "summary": "Lists all Worker Deployments that are tracked in the Namespace.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Lists all Worker Deployments that are tracked in the Namespace.", "operationId": "ListWorkerDeployments2", "responses": { "200": { @@ -3176,7 +3176,7 @@ }, "/api/v1/namespaces/{namespace}/worker-deployments/{deploymentName}": { "get": { - "summary": "Describes a Worker Deployment.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Describes a Worker Deployment.", "operationId": "DescribeWorkerDeployment2", "responses": { "200": { @@ -3211,7 +3211,7 @@ ] }, "delete": { - "summary": "Deletes records of (an old) Deployment. A deployment can only be deleted if\nit has no Version in it.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Deletes records of (an old) Deployment. A deployment can only be deleted if\nit has no Version in it.", "operationId": "DeleteWorkerDeployment2", "responses": { "200": { @@ -3300,7 +3300,7 @@ }, "/api/v1/namespaces/{namespace}/worker-deployments/{deploymentName}/set-current-version": { "post": { - "summary": "Set/unset the Current Version of a Worker Deployment. Automatically unsets the Ramping\nVersion if it is the Version being set as Current.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Set/unset the Current Version of a Worker Deployment. Automatically unsets the Ramping\nVersion if it is the Version being set as Current.", "operationId": "SetWorkerDeploymentCurrentVersion2", "responses": { "200": { @@ -3390,7 +3390,7 @@ }, "/api/v1/namespaces/{namespace}/worker-deployments/{deploymentName}/set-ramping-version": { "post": { - "summary": "Set/unset the Ramping Version of a Worker Deployment and its ramp percentage. Can be used for\ngradual ramp to unversioned workers too.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Set/unset the Ramping Version of a Worker Deployment and its ramp percentage. Can be used for\ngradual ramp to unversioned workers too.", "operationId": "SetWorkerDeploymentRampingVersion2", "responses": { "200": { @@ -7104,7 +7104,7 @@ }, "/namespaces/{namespace}/current-deployment/{deployment.seriesName}": { "post": { - "summary": "Sets a deployment as the current deployment for its deployment series. Can optionally update\nthe metadata of the deployment as well.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced by `SetWorkerDeploymentCurrentVersion`.", + "summary": "Sets a deployment as the current deployment for its deployment series. Can optionally update\nthe metadata of the deployment as well.\nDeprecated. Replaced by `SetWorkerDeploymentCurrentVersion`.", "operationId": "SetCurrentDeployment", "responses": { "200": { @@ -7150,7 +7150,7 @@ }, "/namespaces/{namespace}/current-deployment/{seriesName}": { "get": { - "summary": "Returns the current deployment (and its info) for a given deployment series.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced by `current_version` returned by `DescribeWorkerDeployment`.", + "summary": "Returns the current deployment (and its info) for a given deployment series.\nDeprecated. Replaced by `current_version` returned by `DescribeWorkerDeployment`.", "operationId": "GetCurrentDeployment", "responses": { "200": { @@ -7187,7 +7187,7 @@ }, "/namespaces/{namespace}/deployments": { "get": { - "summary": "Lists worker deployments in the namespace. Optionally can filter based on deployment series\nname.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced with `ListWorkerDeployments`.", + "summary": "Lists worker deployments in the namespace. Optionally can filter based on deployment series\nname.\nDeprecated. Replaced with `ListWorkerDeployments`.", "operationId": "ListDeployments", "responses": { "200": { @@ -7239,7 +7239,7 @@ }, "/namespaces/{namespace}/deployments/{deployment.seriesName}/{deployment.buildId}": { "get": { - "summary": "Describes a worker deployment.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced with `DescribeWorkerDeploymentVersion`.", + "summary": "Describes a worker deployment.\nDeprecated. Replaced with `DescribeWorkerDeploymentVersion`.", "operationId": "DescribeDeployment", "responses": { "200": { @@ -7284,7 +7284,7 @@ }, "/namespaces/{namespace}/deployments/{deployment.seriesName}/{deployment.buildId}/reachability": { "get": { - "summary": "Returns the reachability level of a worker deployment to help users decide when it is time\nto decommission a deployment. Reachability level is calculated based on the deployment's\n`status` and existing workflows that depend on the given deployment for their execution.\nCalculating reachability is relatively expensive. Therefore, server might return a recently\ncached value. In such a case, the `last_update_time` will inform you about the actual\nreachability calculation time.\nExperimental. This API might significantly change or be removed in a future release.\nDeprecated. Replaced with `DrainageInfo` returned by `DescribeWorkerDeploymentVersion`.", + "summary": "Returns the reachability level of a worker deployment to help users decide when it is time\nto decommission a deployment. Reachability level is calculated based on the deployment's\n`status` and existing workflows that depend on the given deployment for their execution.\nCalculating reachability is relatively expensive. Therefore, server might return a recently\ncached value. In such a case, the `last_update_time` will inform you about the actual\nreachability calculation time.\nDeprecated. Replaced with `DrainageInfo` returned by `DescribeWorkerDeploymentVersion`.", "operationId": "GetDeploymentReachability", "responses": { "200": { @@ -8432,7 +8432,7 @@ }, "/namespaces/{namespace}/worker-deployment-versions/{deploymentVersion.deploymentName}/{deploymentVersion.buildId}": { "get": { - "summary": "Describes a worker deployment version.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Describes a worker deployment version.", "operationId": "DescribeWorkerDeploymentVersion", "responses": { "200": { @@ -8489,7 +8489,7 @@ ] }, "delete": { - "summary": "Used for manual deletion of Versions. User can delete a Version only when all the\nfollowing conditions are met:\n - It is not the Current or Ramping Version of its Deployment.\n - It has no active pollers (none of the task queues in the Version have pollers)\n - It is not draining (see WorkerDeploymentVersionInfo.drainage_info). This condition\n can be skipped by passing `skip-drainage=true`.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Used for manual deletion of Versions. User can delete a Version only when all the\nfollowing conditions are met:\n - It is not the Current or Ramping Version of its Deployment.\n - It has no active pollers (none of the task queues in the Version have pollers)\n - It is not draining (see WorkerDeploymentVersionInfo.drainage_info). This condition\n can be skipped by passing `skip-drainage=true`.", "operationId": "DeleteWorkerDeploymentVersion", "responses": { "200": { @@ -8608,7 +8608,7 @@ }, "/namespaces/{namespace}/worker-deployment-versions/{deploymentVersion.deploymentName}/{deploymentVersion.buildId}/update-metadata": { "post": { - "summary": "Updates the user-given metadata attached to a Worker Deployment Version.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Updates the user-given metadata attached to a Worker Deployment Version.", "operationId": "UpdateWorkerDeploymentVersionMetadata", "responses": { "200": { @@ -8714,7 +8714,7 @@ }, "/namespaces/{namespace}/worker-deployments": { "get": { - "summary": "Lists all Worker Deployments that are tracked in the Namespace.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Lists all Worker Deployments that are tracked in the Namespace.", "operationId": "ListWorkerDeployments", "responses": { "200": { @@ -8759,7 +8759,7 @@ }, "/namespaces/{namespace}/worker-deployments/{deploymentName}": { "get": { - "summary": "Describes a Worker Deployment.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Describes a Worker Deployment.", "operationId": "DescribeWorkerDeployment", "responses": { "200": { @@ -8794,7 +8794,7 @@ ] }, "delete": { - "summary": "Deletes records of (an old) Deployment. A deployment can only be deleted if\nit has no Version in it.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Deletes records of (an old) Deployment. A deployment can only be deleted if\nit has no Version in it.", "operationId": "DeleteWorkerDeployment", "responses": { "200": { @@ -8883,7 +8883,7 @@ }, "/namespaces/{namespace}/worker-deployments/{deploymentName}/set-current-version": { "post": { - "summary": "Set/unset the Current Version of a Worker Deployment. Automatically unsets the Ramping\nVersion if it is the Version being set as Current.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Set/unset the Current Version of a Worker Deployment. Automatically unsets the Ramping\nVersion if it is the Version being set as Current.", "operationId": "SetWorkerDeploymentCurrentVersion", "responses": { "200": { @@ -8973,7 +8973,7 @@ }, "/namespaces/{namespace}/worker-deployments/{deploymentName}/set-ramping-version": { "post": { - "summary": "Set/unset the Ramping Version of a Worker Deployment and its ramp percentage. Can be used for\ngradual ramp to unversioned workers too.\nExperimental. This API might significantly change or be removed in a future release.", + "summary": "Set/unset the Ramping Version of a Worker Deployment and its ramp percentage. Can be used for\ngradual ramp to unversioned workers too.", "operationId": "SetWorkerDeploymentRampingVersion", "responses": { "200": { diff --git a/crates/protos/protos/api_upstream/openapi/openapiv3.yaml b/crates/protos/protos/api_upstream/openapi/openapiv3.yaml index 007d77168..ab3e297e5 100644 --- a/crates/protos/protos/api_upstream/openapi/openapiv3.yaml +++ b/crates/protos/protos/api_upstream/openapi/openapiv3.yaml @@ -1324,7 +1324,6 @@ paths: description: |- Sets a deployment as the current deployment for its deployment series. Can optionally update the metadata of the deployment as well. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced by `SetWorkerDeploymentCurrentVersion`. operationId: SetCurrentDeployment parameters: @@ -1363,7 +1362,6 @@ paths: - WorkflowService description: |- Returns the current deployment (and its info) for a given deployment series. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced by `current_version` returned by `DescribeWorkerDeployment`. operationId: GetCurrentDeployment parameters: @@ -1397,7 +1395,6 @@ paths: description: |- Lists worker deployments in the namespace. Optionally can filter based on deployment series name. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced with `ListWorkerDeployments`. operationId: ListDeployments parameters: @@ -1440,7 +1437,6 @@ paths: - WorkflowService description: |- Describes a worker deployment. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced with `DescribeWorkerDeploymentVersion`. operationId: DescribeDeployment parameters: @@ -1500,7 +1496,6 @@ paths: Calculating reachability is relatively expensive. Therefore, server might return a recently cached value. In such a case, the `last_update_time` will inform you about the actual reachability calculation time. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced with `DrainageInfo` returned by `DescribeWorkerDeploymentVersion`. operationId: GetDeploymentReachability parameters: @@ -2570,9 +2565,7 @@ paths: get: tags: - WorkflowService - description: |- - Describes a worker deployment version. - Experimental. This API might significantly change or be removed in a future release. + description: Describes a worker deployment version. operationId: DescribeWorkerDeploymentVersion parameters: - name: namespace @@ -2637,7 +2630,6 @@ paths: - It has no active pollers (none of the task queues in the Version have pollers) - It is not draining (see WorkerDeploymentVersionInfo.drainage_info). This condition can be skipped by passing `skip-drainage=true`. - Experimental. This API might significantly change or be removed in a future release. operationId: DeleteWorkerDeploymentVersion parameters: - name: namespace @@ -2746,9 +2738,7 @@ paths: : post: tags: - WorkflowService - description: |- - Updates the user-given metadata attached to a Worker Deployment Version. - Experimental. This API might significantly change or be removed in a future release. + description: Updates the user-given metadata attached to a Worker Deployment Version. operationId: UpdateWorkerDeploymentVersionMetadata parameters: - name: namespace @@ -2832,9 +2822,7 @@ paths: get: tags: - WorkflowService - description: |- - Lists all Worker Deployments that are tracked in the Namespace. - Experimental. This API might significantly change or be removed in a future release. + description: Lists all Worker Deployments that are tracked in the Namespace. operationId: ListWorkerDeployments parameters: - name: namespace @@ -2869,9 +2857,7 @@ paths: get: tags: - WorkflowService - description: |- - Describes a Worker Deployment. - Experimental. This API might significantly change or be removed in a future release. + description: Describes a Worker Deployment. operationId: DescribeWorkerDeployment parameters: - name: namespace @@ -2945,7 +2931,6 @@ paths: description: |- Deletes records of (an old) Deployment. A deployment can only be deleted if it has no Version in it. - Experimental. This API might significantly change or be removed in a future release. operationId: DeleteWorkerDeployment parameters: - name: namespace @@ -2983,7 +2968,6 @@ paths: description: |- Set/unset the Current Version of a Worker Deployment. Automatically unsets the Ramping Version if it is the Version being set as Current. - Experimental. This API might significantly change or be removed in a future release. operationId: SetWorkerDeploymentCurrentVersion parameters: - name: namespace @@ -3060,7 +3044,6 @@ paths: description: |- Set/unset the Ramping Version of a Worker Deployment and its ramp percentage. Can be used for gradual ramp to unversioned workers too. - Experimental. This API might significantly change or be removed in a future release. operationId: SetWorkerDeploymentRampingVersion parameters: - name: namespace @@ -6422,7 +6405,6 @@ paths: description: |- Sets a deployment as the current deployment for its deployment series. Can optionally update the metadata of the deployment as well. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced by `SetWorkerDeploymentCurrentVersion`. operationId: SetCurrentDeployment parameters: @@ -6461,7 +6443,6 @@ paths: - WorkflowService description: |- Returns the current deployment (and its info) for a given deployment series. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced by `current_version` returned by `DescribeWorkerDeployment`. operationId: GetCurrentDeployment parameters: @@ -6495,7 +6476,6 @@ paths: description: |- Lists worker deployments in the namespace. Optionally can filter based on deployment series name. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced with `ListWorkerDeployments`. operationId: ListDeployments parameters: @@ -6538,7 +6518,6 @@ paths: - WorkflowService description: |- Describes a worker deployment. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced with `DescribeWorkerDeploymentVersion`. operationId: DescribeDeployment parameters: @@ -6598,7 +6577,6 @@ paths: Calculating reachability is relatively expensive. Therefore, server might return a recently cached value. In such a case, the `last_update_time` will inform you about the actual reachability calculation time. - Experimental. This API might significantly change or be removed in a future release. Deprecated. Replaced with `DrainageInfo` returned by `DescribeWorkerDeploymentVersion`. operationId: GetDeploymentReachability parameters: @@ -7610,9 +7588,7 @@ paths: get: tags: - WorkflowService - description: |- - Describes a worker deployment version. - Experimental. This API might significantly change or be removed in a future release. + description: Describes a worker deployment version. operationId: DescribeWorkerDeploymentVersion parameters: - name: namespace @@ -7677,7 +7653,6 @@ paths: - It has no active pollers (none of the task queues in the Version have pollers) - It is not draining (see WorkerDeploymentVersionInfo.drainage_info). This condition can be skipped by passing `skip-drainage=true`. - Experimental. This API might significantly change or be removed in a future release. operationId: DeleteWorkerDeploymentVersion parameters: - name: namespace @@ -7786,9 +7761,7 @@ paths: : post: tags: - WorkflowService - description: |- - Updates the user-given metadata attached to a Worker Deployment Version. - Experimental. This API might significantly change or be removed in a future release. + description: Updates the user-given metadata attached to a Worker Deployment Version. operationId: UpdateWorkerDeploymentVersionMetadata parameters: - name: namespace @@ -7872,9 +7845,7 @@ paths: get: tags: - WorkflowService - description: |- - Lists all Worker Deployments that are tracked in the Namespace. - Experimental. This API might significantly change or be removed in a future release. + description: Lists all Worker Deployments that are tracked in the Namespace. operationId: ListWorkerDeployments parameters: - name: namespace @@ -7909,9 +7880,7 @@ paths: get: tags: - WorkflowService - description: |- - Describes a Worker Deployment. - Experimental. This API might significantly change or be removed in a future release. + description: Describes a Worker Deployment. operationId: DescribeWorkerDeployment parameters: - name: namespace @@ -7985,7 +7954,6 @@ paths: description: |- Deletes records of (an old) Deployment. A deployment can only be deleted if it has no Version in it. - Experimental. This API might significantly change or be removed in a future release. operationId: DeleteWorkerDeployment parameters: - name: namespace @@ -8023,7 +7991,6 @@ paths: description: |- Set/unset the Current Version of a Worker Deployment. Automatically unsets the Ramping Version if it is the Version being set as Current. - Experimental. This API might significantly change or be removed in a future release. operationId: SetWorkerDeploymentCurrentVersion parameters: - name: namespace @@ -8100,7 +8067,6 @@ paths: description: |- Set/unset the Ramping Version of a Worker Deployment and its ramp percentage. Can be used for gradual ramp to unversioned workers too. - Experimental. This API might significantly change or be removed in a future release. operationId: SetWorkerDeploymentRampingVersion parameters: - name: namespace diff --git a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto index f8809fd4e..81cbde73e 100644 --- a/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto +++ b/crates/protos/protos/api_upstream/temporal/api/enums/v1/failed_cause.proto @@ -139,8 +139,8 @@ enum ResourceExhaustedCause { enum ResourceExhaustedScope { RESOURCE_EXHAUSTED_SCOPE_UNSPECIFIED = 0; - // Exhausted resource is a system-level resource. - RESOURCE_EXHAUSTED_SCOPE_NAMESPACE = 1; // Exhausted resource is a namespace-level resource. + RESOURCE_EXHAUSTED_SCOPE_NAMESPACE = 1; + // Exhausted resource is a system-level resource. RESOURCE_EXHAUSTED_SCOPE_SYSTEM = 2; } diff --git a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto index 768553bd9..34f6a73f6 100644 --- a/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto +++ b/crates/protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto @@ -953,7 +953,6 @@ service WorkflowService { } // Describes a worker deployment. - // Experimental. This API might significantly change or be removed in a future release. // Deprecated. Replaced with `DescribeWorkerDeploymentVersion`. rpc DescribeDeployment (DescribeDeploymentRequest) returns (DescribeDeploymentResponse) { option (google.api.http) = { @@ -965,7 +964,6 @@ service WorkflowService { } // Describes a worker deployment version. - // Experimental. This API might significantly change or be removed in a future release. rpc DescribeWorkerDeploymentVersion (DescribeWorkerDeploymentVersionRequest) returns (DescribeWorkerDeploymentVersionResponse) { option (google.api.http) = { get: "/namespaces/{namespace}/worker-deployment-versions/{deployment_version.deployment_name}/{deployment_version.build_id}" @@ -981,7 +979,6 @@ service WorkflowService { // Lists worker deployments in the namespace. Optionally can filter based on deployment series // name. - // Experimental. This API might significantly change or be removed in a future release. // Deprecated. Replaced with `ListWorkerDeployments`. rpc ListDeployments (ListDeploymentsRequest) returns (ListDeploymentsResponse) { option (google.api.http) = { @@ -998,7 +995,6 @@ service WorkflowService { // Calculating reachability is relatively expensive. Therefore, server might return a recently // cached value. In such a case, the `last_update_time` will inform you about the actual // reachability calculation time. - // Experimental. This API might significantly change or be removed in a future release. // Deprecated. Replaced with `DrainageInfo` returned by `DescribeWorkerDeploymentVersion`. rpc GetDeploymentReachability (GetDeploymentReachabilityRequest) returns (GetDeploymentReachabilityResponse) { option (google.api.http) = { @@ -1010,7 +1006,6 @@ service WorkflowService { } // Returns the current deployment (and its info) for a given deployment series. - // Experimental. This API might significantly change or be removed in a future release. // Deprecated. Replaced by `current_version` returned by `DescribeWorkerDeployment`. rpc GetCurrentDeployment (GetCurrentDeploymentRequest) returns (GetCurrentDeploymentResponse) { option (google.api.http) = { @@ -1023,7 +1018,6 @@ service WorkflowService { // Sets a deployment as the current deployment for its deployment series. Can optionally update // the metadata of the deployment as well. - // Experimental. This API might significantly change or be removed in a future release. // Deprecated. Replaced by `SetWorkerDeploymentCurrentVersion`. rpc SetCurrentDeployment (SetCurrentDeploymentRequest) returns (SetCurrentDeploymentResponse) { option (google.api.http) = { @@ -1038,7 +1032,6 @@ service WorkflowService { // Set/unset the Current Version of a Worker Deployment. Automatically unsets the Ramping // Version if it is the Version being set as Current. - // Experimental. This API might significantly change or be removed in a future release. rpc SetWorkerDeploymentCurrentVersion (SetWorkerDeploymentCurrentVersionRequest) returns (SetWorkerDeploymentCurrentVersionResponse) { option (google.api.http) = { post: "/namespaces/{namespace}/worker-deployments/{deployment_name}/set-current-version" @@ -1055,7 +1048,6 @@ service WorkflowService { } // Describes a Worker Deployment. - // Experimental. This API might significantly change or be removed in a future release. rpc DescribeWorkerDeployment (DescribeWorkerDeploymentRequest) returns (DescribeWorkerDeploymentResponse) { option (google.api.http) = { get: "/namespaces/{namespace}/worker-deployments/{deployment_name}" @@ -1071,7 +1063,6 @@ service WorkflowService { // Deletes records of (an old) Deployment. A deployment can only be deleted if // it has no Version in it. - // Experimental. This API might significantly change or be removed in a future release. rpc DeleteWorkerDeployment (DeleteWorkerDeploymentRequest) returns (DeleteWorkerDeploymentResponse) { option (google.api.http) = { delete: "/namespaces/{namespace}/worker-deployments/{deployment_name}" @@ -1092,7 +1083,6 @@ service WorkflowService { // - It has no active pollers (none of the task queues in the Version have pollers) // - It is not draining (see WorkerDeploymentVersionInfo.drainage_info). This condition // can be skipped by passing `skip-drainage=true`. - // Experimental. This API might significantly change or be removed in a future release. rpc DeleteWorkerDeploymentVersion (DeleteWorkerDeploymentVersionRequest) returns (DeleteWorkerDeploymentVersionResponse) { option (google.api.http) = { delete: "/namespaces/{namespace}/worker-deployment-versions/{deployment_version.deployment_name}/{deployment_version.build_id}" @@ -1108,7 +1098,6 @@ service WorkflowService { // Set/unset the Ramping Version of a Worker Deployment and its ramp percentage. Can be used for // gradual ramp to unversioned workers too. - // Experimental. This API might significantly change or be removed in a future release. rpc SetWorkerDeploymentRampingVersion (SetWorkerDeploymentRampingVersionRequest) returns (SetWorkerDeploymentRampingVersionResponse) { option (google.api.http) = { post: "/namespaces/{namespace}/worker-deployments/{deployment_name}/set-ramping-version" @@ -1125,7 +1114,6 @@ service WorkflowService { } // Lists all Worker Deployments that are tracked in the Namespace. - // Experimental. This API might significantly change or be removed in a future release. rpc ListWorkerDeployments (ListWorkerDeploymentsRequest) returns (ListWorkerDeploymentsResponse) { option (google.api.http) = { get: "/namespaces/{namespace}/worker-deployments" @@ -1192,7 +1180,6 @@ service WorkflowService { } // Updates the user-given metadata attached to a Worker Deployment Version. - // Experimental. This API might significantly change or be removed in a future release. rpc UpdateWorkerDeploymentVersionMetadata (UpdateWorkerDeploymentVersionMetadataRequest) returns (UpdateWorkerDeploymentVersionMetadataResponse) { option (google.api.http) = { post: "/namespaces/{namespace}/worker-deployment-versions/{deployment_version.deployment_name}/{deployment_version.build_id}/update-metadata" From 096387e71ec868297ad3ae6dd814011833ae658d Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Tue, 25 Aug 2026 12:06:20 -0400 Subject: [PATCH 24/87] chore(ci): let dependabot skip cloud tests (#1528) --- .github/workflows/per-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/per-pr.yml b/.github/workflows/per-pr.yml index 31227e836..4e1921618 100644 --- a/.github/workflows/per-pr.yml +++ b/.github/workflows/per-pr.yml @@ -171,7 +171,7 @@ jobs: - run: cargo integ-test -t wasm_workflow_tests -- --test-threads 1 cloud-tests: - if: github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-rust' + if: (github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-rust') && github.actor != 'dependabot[bot]' name: Cloud tests env: TEMPORAL_CLOUD_ADDRESS: https://${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 From 8bdcb75490e0d080c4c8b4bddeb83cb3cba13519 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Tue, 25 Aug 2026 09:44:08 -0700 Subject: [PATCH 25/87] Thread counters_total_suffix through prometheus exporter (#1531) --- CHANGELOG.md | 2 + .../common/src/telemetry/prometheus_meter.rs | 45 ++++++++++++++++++- .../common/src/telemetry/prometheus_server.rs | 1 + crates/sdk-core/CHANGELOG.md | 2 + .../tests/integ_tests/metrics_tests.rs | 10 ++++- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e54b18e45..fffe4b316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ relevant information. `WorkflowStartOptions::start_signal` and `WorkflowStartSignal`. ### Fixed +* The Prometheus exporter now respects `PrometheusExporterOptions::counters_total_suffix`, + appending `_total` to counter metric names when enabled. * An activity failure caused by oversized final heartbeat details is now counted in the `temporal_activity_execution_failed` metric as `failure_reason="PayloadsTooLarge"`. Previously it was counted under the reason for the failure the activity itself reported, and was not counted at diff --git a/crates/common/src/telemetry/prometheus_meter.rs b/crates/common/src/telemetry/prometheus_meter.rs index 82f8a7725..7df30f288 100644 --- a/crates/common/src/telemetry/prometheus_meter.rs +++ b/crates/common/src/telemetry/prometheus_meter.rs @@ -483,6 +483,7 @@ impl MetricAttributable> for PromHistogramF64 { #[derive(Debug)] pub struct CorePrometheusMeter { registry: Registry, + counters_total_suffix: bool, use_seconds_for_durations: bool, unit_suffix: bool, bucket_overrides: crate::telemetry::HistogramBucketOverrides, @@ -491,12 +492,14 @@ pub struct CorePrometheusMeter { impl CorePrometheusMeter { pub(super) fn new( registry: Registry, + counters_total_suffix: bool, use_seconds_for_durations: bool, unit_suffix: bool, bucket_overrides: crate::telemetry::HistogramBucketOverrides, ) -> Self { Self { registry, + counters_total_suffix, use_seconds_for_durations, unit_suffix, bucket_overrides, @@ -558,7 +561,10 @@ impl CoreMeter for CorePrometheusMeter { } fn counter(&self, params: MetricParameters) -> Counter { - let metric_name = params.name.to_string(); + let mut metric_name = params.name.to_string(); + if self.counters_total_suffix { + metric_name.push_str("_total"); + } Counter::new(Arc::new(PromMetric::::new( metric_name, params.description.to_string(), @@ -676,6 +682,7 @@ mod tests { metrics::{MetricKeyValue, NewAttributes, WORKFLOW_E2E_LATENCY_HISTOGRAM_NAME}, }; use prometheus::{Encoder, TextEncoder}; + use rstest::rstest; #[test] fn test_prometheus_meter_dynamic_labels() { @@ -684,6 +691,7 @@ mod tests { registry.clone(), false, false, + false, HistogramBucketOverrides::default(), ); @@ -717,6 +725,34 @@ mod tests { ); } + #[rstest] + #[case(false, "test_counter")] + #[case(true, "test_counter_total")] + fn counter_total_suffix_option_controls_counter_names( + #[case] counters_total_suffix: bool, + #[case] expected_name: &str, + ) { + let registry = Registry::new(HashMap::new()); + let meter = CorePrometheusMeter::new( + registry.clone(), + counters_total_suffix, + false, + false, + HistogramBucketOverrides::default(), + ); + let counter = meter.counter(MetricParameters { + name: "test_counter".into(), + description: "A test counter metric".into(), + unit: "".into(), + }); + + counter.adds(1); + + let output = output_string(®istry); + let expected_sample = format!("{expected_name} 1"); + assert!(output.lines().any(|line| line == expected_sample)); + } + #[test] fn test_extend_attributes() { let registry = Registry::new(HashMap::new()); @@ -724,6 +760,7 @@ mod tests { registry.clone(), false, false, + false, HistogramBucketOverrides::default(), ); @@ -763,6 +800,7 @@ mod tests { registry.clone(), false, false, + false, HistogramBucketOverrides::default(), ); @@ -787,6 +825,7 @@ mod tests { let registry_s = Registry::new(HashMap::new()); let meter_s = CorePrometheusMeter::new( registry_s.clone(), + false, true, false, HistogramBucketOverrides::default(), @@ -817,6 +856,7 @@ mod tests { registry.clone(), false, false, + false, HistogramBucketOverrides::default(), ); let counter = meter.counter(MetricParameters { @@ -841,6 +881,7 @@ mod tests { registry.clone(), false, false, + false, HistogramBucketOverrides::default(), ); @@ -875,6 +916,7 @@ mod tests { registry.clone(), false, false, + false, HistogramBucketOverrides::default(), ); let dashes = meter.counter(MetricParameters { @@ -896,6 +938,7 @@ mod tests { registry.clone(), false, false, + false, HistogramBucketOverrides::default(), ); let dashes = meter.counter(MetricParameters { diff --git a/crates/common/src/telemetry/prometheus_server.rs b/crates/common/src/telemetry/prometheus_server.rs index dd95d3e9e..bb54dd50e 100644 --- a/crates/common/src/telemetry/prometheus_server.rs +++ b/crates/common/src/telemetry/prometheus_server.rs @@ -29,6 +29,7 @@ pub fn start_prometheus_metric_exporter( let meter = Arc::new( crate::telemetry::prometheus_meter::CorePrometheusMeter::new( srv.registry().clone(), + opts.counters_total_suffix, opts.use_seconds_for_durations, opts.unit_suffix, opts.histogram_bucket_overrides, diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index 886d708a3..e62dfe6cc 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -54,6 +54,8 @@ relevant information. are preserved on failure; workers warn when the server does not advertise support. ### Fixed +* The Prometheus exporter now appends `_total` to counter metric names when an SDK enables the + counter suffix option. * An activity failure caused by oversized final heartbeat details is now counted in the `temporal_activity_execution_failed` metric as `failure_reason="PayloadsTooLarge"`. Previously it was counted under the reason for the failure the activity itself reported, and was not counted at diff --git a/crates/sdk-core/tests/integ_tests/metrics_tests.rs b/crates/sdk-core/tests/integ_tests/metrics_tests.rs index 04d09b63d..755396ed0 100644 --- a/crates/sdk-core/tests/integ_tests/metrics_tests.rs +++ b/crates/sdk-core/tests/integ_tests/metrics_tests.rs @@ -105,12 +105,14 @@ pub(crate) async fn get_text(endpoint: String) -> String { #[rstest::rstest] #[tokio::test] async fn prometheus_metrics_exported( + #[values(true, false)] counters_total_suffix: bool, #[values(true, false)] use_seconds_latency: bool, #[values(true, false)] custom_buckets: bool, ) { let opts = PrometheusExporterOptions::builder() .global_tags(HashMap::from([("global".to_string(), "hi!".to_string())])) .socket_addr(ANY_PORT.parse().unwrap()) + .counters_total_suffix(counters_total_suffix) .use_seconds_for_durations(use_seconds_latency) .histogram_bucket_overrides(if custom_buckets { HistogramBucketOverrides { @@ -159,8 +161,12 @@ async fn prometheus_metrics_exported( operation=\"GetSystemInfo\",service_name=\"temporal-core-sdk\",global=\"hi!\",le=\"50\"}" )); } - // Verify counter names are appropriate (don't end w/ '_total') - assert!(body.contains("temporal_request{")); + let request_metric_name = if counters_total_suffix { + "temporal_request_total" + } else { + "temporal_request" + }; + assert!(body.contains(&format!("{request_metric_name}{{"))); // Verify non-temporal metrics meter does not prefix let mm = rt.telemetry().get_metric_meter().unwrap(); let g = mm.gauge(MetricParameters::from("mygauge")); From 3037a0bbeb5c9bd458c8cd4196e040d51b012d40 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Wed, 26 Aug 2026 09:56:31 -0700 Subject: [PATCH 26/87] Allow including LA arguments in marker (#1532) * Allow including LA arguments in marker * Expose option in Rust SDK --- CHANGELOG.md | 2 + .../workflow_commands/workflow_commands.proto | 4 ++ crates/sdk-core/CHANGELOG.md | 2 + crates/sdk-core/src/protosext/mod.rs | 2 + .../machines/local_activity_state_machine.rs | 34 +++++---- .../workflow_tests/local_activities.rs | 72 ++++++++++++++++++- .../workflow/src/workflow_context/options.rs | 34 +++++++++ 7 files changed, 137 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fffe4b316..dc88e4b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ relevant information. ## Unreleased ### Added +* `LocalActivityOptions::include_arguments_into_marker` allows Rust workflows to opt in to + recording local activity arguments in Workflow history. * `WorkflowContext::all_handlers_finished` and `SyncWorkflowContext::all_handlers_finished` let Rust workflows wait for active signal and update handler chains before completing or continuing as new. diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto index ac4a61664..03e172216 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto @@ -148,6 +148,10 @@ message ScheduleLocalActivity { // confirmed. Lang should default this to `WAIT_CANCELLATION_COMPLETED`, even though proto // will default to `TRY_CANCEL` automatically. ActivityCancellationType cancellation_type = 13; + // If set, the local activity arguments will be included in the resulting marker under the + // `input` key. This is disabled by default to avoid increasing history size unless the lang + // SDK explicitly chooses to expose it. + bool include_arguments_into_marker = 14; } enum ActivityCancellationType { diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index e62dfe6cc..97934d63b 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -34,6 +34,8 @@ relevant information. ## Unreleased ### Added +* Language SDKs can opt in to recording local activity arguments in the local activity marker's + `input` detail. * Core console logs can now be emitted as newline-delimited JSON when an SDK selects the JSON log format. Configured log filters continue to apply to JSON output. * Workflow completion-as-cancelled commands can now carry details for recording on the terminal diff --git a/crates/sdk-core/src/protosext/mod.rs b/crates/sdk-core/src/protosext/mod.rs index 819c50a76..afa489aa3 100644 --- a/crates/sdk-core/src/protosext/mod.rs +++ b/crates/sdk-core/src/protosext/mod.rs @@ -322,6 +322,7 @@ pub(crate) struct ValidScheduleLA { pub(crate) retry_policy: ValidatedRetryPolicy, pub(crate) local_retry_threshold: Duration, pub(crate) cancellation_type: ActivityCancellationType, + pub(crate) include_arguments_into_marker: bool, pub(crate) user_metadata: Option, pub(crate) event_group_markers: Vec, } @@ -432,6 +433,7 @@ impl ValidScheduleLA { retry_policy, local_retry_threshold, cancellation_type, + include_arguments_into_marker: v.include_arguments_into_marker, user_metadata, event_group_markers, }) diff --git a/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs index 192245271..9b86f008a 100644 --- a/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs @@ -32,6 +32,7 @@ use temporalio_common::protos::{ }, temporal::api::{ command::v1::{Command as ProtoCommand, RecordMarkerCommandAttributes, command}, + common::v1::Payloads, enums::v1::{CommandType, EventType, RetryState}, failure::v1::{Failure, failure::FailureInfo}, }, @@ -713,20 +714,29 @@ impl WFMachinesAdapter for LocalActivityMachine { } if record_marker { + let mut details = build_local_activity_marker_details( + LocalActivityMarkerData { + seq: self.shared_state.attrs.seq, + attempt, + activity_id: self.shared_state.attrs.activity_id.clone(), + activity_type: self.shared_state.attrs.activity_type.clone(), + complete_time: complete_time.map(Into::into), + backoff, + original_schedule_time: original_schedule_time.map(Into::into), + }, + maybe_ok_result, + ); + if self.shared_state.attrs.include_arguments_into_marker { + details.insert( + "input".to_string(), + Payloads { + payloads: self.shared_state.attrs.arguments.clone(), + }, + ); + } let marker_data = RecordMarkerCommandAttributes { marker_name: LOCAL_ACTIVITY_MARKER_NAME.to_string(), - details: build_local_activity_marker_details( - LocalActivityMarkerData { - seq: self.shared_state.attrs.seq, - attempt, - activity_id: self.shared_state.attrs.activity_id.clone(), - activity_type: self.shared_state.attrs.activity_type.clone(), - complete_time: complete_time.map(Into::into), - backoff, - original_schedule_time: original_schedule_time.map(Into::into), - }, - maybe_ok_result, - ), + details, header: None, failure: maybe_failure, }; diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs index 5e4de4c42..cbaa5873e 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs @@ -40,7 +40,7 @@ use temporalio_common::{ }, temporal::api::{ command::v1::{RecordMarkerCommandAttributes, command}, - common::v1::RetryPolicy, + common::v1::{Payload, RetryPolicy}, enums::v1::{ CommandType, EventType, TimeoutType as ProtoTimeoutType, WorkflowTaskFailedCause, }, @@ -2981,6 +2981,76 @@ async fn one_la_success(#[case] replay: bool, #[case] completes_ok: bool) { worker.run().await.unwrap(); } +#[rstest] +#[case::excluded(false)] +#[case::included(true)] +#[tokio::test] +async fn local_activity_marker_optionally_includes_arguments(#[case] include_arguments: bool) { + let mut history = TestHistoryBuilder::default(); + history.add_by_type(EventType::WorkflowExecutionStarted); + history.add_workflow_task_scheduled_and_started(); + + let arguments: Vec = vec![b"first".into(), b"second".into()]; + let expected_arguments = arguments.clone(); + let mut mock_cfg = MockPollCfg::from_hist_builder(history); + mock_cfg.make_poll_stream_interminable = true; + mock_cfg.completion_asserts_from_expectations(|mut asserts| { + asserts.then(move |wft| { + assert_eq!(wft.commands.len(), 2); + let marker = assert_matches!( + wft.commands[0].attributes.as_ref(), + Some(command::Attributes::RecordMarkerCommandAttributes(marker)) => marker + ); + let marker_input = marker.details.get("input"); + if include_arguments { + assert_eq!(marker_input.unwrap().payloads, expected_arguments); + } else { + assert!(marker_input.is_none()); + } + assert_eq!( + wft.commands[1].command_type(), + CommandType::CompleteWorkflowExecution + ); + }); + }); + let core = mock_worker(build_mock_pollers(mock_cfg)); + + let activation = core.poll_workflow_activation().await.unwrap(); + core.complete_workflow_activation(WorkflowActivationCompletion::from_cmd( + activation.run_id, + ScheduleLocalActivity { + seq: 1, + activity_id: "1".to_string(), + activity_type: "test_act".to_string(), + arguments, + start_to_close_timeout: Some(prost_dur!(from_secs(30))), + include_arguments_into_marker: include_arguments, + ..Default::default() + } + .into(), + )) + .await + .unwrap(); + + let activity_task = core.poll_activity_task().await.unwrap(); + core.complete_activity_task(ActivityTaskCompletion { + task_token: activity_task.task_token, + result: Some(ActivityExecutionResult::ok(b"result".into())), + }) + .await + .unwrap(); + + let resolution = core.poll_workflow_activation().await.unwrap(); + assert_matches!( + resolution.jobs.as_slice(), + [WorkflowActivationJob { + variant: Some(workflow_activation_job::Variant::ResolveActivity(_)), + }] + ); + core.complete_execution(&resolution.run_id).await; + core.drain_pollers_and_shutdown().await; +} + #[workflow] #[derive(Default)] struct TwoLaWf; diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index 16a50722f..c6051269f 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -426,6 +426,12 @@ pub struct LocalActivityOptions { /// How the activity will cancel #[builder(default)] pub cancel_type: ActivityCancellationType, + /// Whether to record the local activity's serialized arguments in the local activity marker. + /// + /// Enabling this makes the arguments visible in Workflow history and increases its size. + /// Defaults to `false`. + #[builder(default)] + pub include_arguments_into_marker: bool, /// Cancellation token for this local activity. `None` inherits workflow cancellation. pub cancellation_token: Option, /// Indicates how long the caller is willing to wait for local activity completion. Limits how @@ -486,6 +492,7 @@ impl LocalActivityOptions { .timer_backoff_threshold .and_then(|duration| duration.try_into().ok()), cancellation_type: ProtoActivityCancellationType::from(self.cancel_type).into(), + include_arguments_into_marker: self.include_arguments_into_marker, schedule_to_close_timeout: self .schedule_to_close_timeout .and_then(|duration| duration.try_into().ok()), @@ -1075,6 +1082,33 @@ mod tests { ); } + #[test] + fn local_activity_arguments_marker_option_maps_to_command() { + let default_command = LocalActivityOptions::default().into_command( + 1, + "test".to_string(), + vec![], + HashMap::new(), + ); + let enabled_command = LocalActivityOptions::builder() + .include_arguments_into_marker(true) + .build() + .into_command(1, "test".to_string(), vec![], HashMap::new()); + + let Some(workflow_command::Variant::ScheduleLocalActivity(default_command)) = + default_command.variant + else { + panic!("expected ScheduleLocalActivity command"); + }; + let Some(workflow_command::Variant::ScheduleLocalActivity(enabled_command)) = + enabled_command.variant + else { + panic!("expected ScheduleLocalActivity command"); + }; + assert!(!default_command.include_arguments_into_marker); + assert!(enabled_command.include_arguments_into_marker); + } + #[test] fn child_workflow_run_timeout_uses_run_timeout_field() { let opts = ChildWorkflowOptions { From be2be0af901656ed4bb75d0d042b037c29308a4a Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Wed, 26 Aug 2026 14:25:24 -0400 Subject: [PATCH 27/87] Run integration tests against envconfig-backed Cloud namespaces (#1483) * Add envconfig integration test mode * Add isolated Cloud namespace helper * Test envconfig harness in isolated Cloud namespace * Clarify envconfig snapshot comment * Move Cloud namespace commands into integration runner --- .github/workflows/per-pr.yml | 54 ++++- README.md | 15 ++ crates/sdk-core/tests/cloud_namespace/mod.rs | 200 +++++++++++++++++++ crates/sdk-core/tests/common/mod.rs | 27 ++- crates/sdk-core/tests/runner.rs | 51 ++++- 5 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 crates/sdk-core/tests/cloud_namespace/mod.rs diff --git a/.github/workflows/per-pr.yml b/.github/workflows/per-pr.yml index 4e1921618..11325303a 100644 --- a/.github/workflows/per-pr.yml +++ b/.github/workflows/per-pr.yml @@ -171,13 +171,14 @@ jobs: - run: cargo integ-test -t wasm_workflow_tests -- --test-threads 1 cloud-tests: - if: (github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-rust') && github.actor != 'dependabot[bot]' + if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-rust') name: Cloud tests env: TEMPORAL_CLOUD_ADDRESS: https://${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} TEMPORAL_CLIENT_CERT: ${{ secrets.TEMPORAL_CLIENT_CERT }} TEMPORAL_CLIENT_KEY: ${{ secrets.TEMPORAL_CLIENT_KEY }} + TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1 timeout-minutes: ${{ github.ref == 'refs/heads/main' && 25 || 20 }} runs-on: ubuntu-latest steps: @@ -186,6 +187,57 @@ jobs: with: mise-install-args: protoc - run: cargo test --features=test-utilities --test cloud_tests + - name: Generate Cloud test certificates + run: | + umask 077 + cert_dir="$RUNNER_TEMP/cloud-test-certs" + mkdir "$cert_dir" + openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$cert_dir/ca.key" -out "$cert_dir/ca.pem" \ + -subj '/CN=Temporal Rust SDK Cloud CI CA' + openssl req -newkey rsa:2048 -nodes \ + -keyout "$cert_dir/client.key" -out "$cert_dir/client.csr" \ + -subj '/CN=Temporal Rust SDK Cloud CI' + openssl x509 -req -days 1 -in "$cert_dir/client.csr" \ + -CA "$cert_dir/ca.pem" -CAkey "$cert_dir/ca.key" -CAcreateserial \ + -out "$cert_dir/client.pem" -extfile <(printf 'extendedKeyUsage=clientAuth') + { + echo "TEMPORAL_CLOUD_CLIENT_CA_PATH=$cert_dir/ca.pem" + echo "TEMPORAL_TLS_CLIENT_CERT_PATH=$cert_dir/client.pem" + echo "TEMPORAL_TLS_CLIENT_KEY_PATH=$cert_dir/client.key" + } >> "$GITHUB_ENV" + - name: Create Cloud namespace + id: create-cloud-namespace + env: + TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + run: cargo integ-test cloud-namespace create + - name: Run existing integration test against Cloud + timeout-minutes: 20 + env: + TEMPORAL_ADDRESS: ${{ steps.create-cloud-namespace.outputs.namespace }}.tmprl.cloud:7233 + TEMPORAL_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }} + run: | + cargo integ-test -s envconfig -- \ + integ_tests::workflow_tests::timers::timer_workflow_workflow_driver --exact \ + 2>&1 | tee cloud-integration.log + - name: Delete Cloud namespace + id: delete-cloud-namespace + if: ${{ always() && steps.create-cloud-namespace.outputs.namespace != '' }} + continue-on-error: true + env: + TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + run: cargo integ-test cloud-namespace delete "${{ steps.create-cloud-namespace.outputs.namespace }}" + - name: Report Cloud namespace cleanup failure + if: ${{ always() && steps.delete-cloud-namespace.outcome == 'failure' }} + run: echo "::warning title=Cloud namespace cleanup failed::Failed to delete Cloud namespace ${{ steps.create-cloud-namespace.outputs.namespace }}" + - name: Upload Cloud test output + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cloud-integration-tests-output + path: cloud-integration.log + if-no-files-found: ignore + retention-days: 14 docker-integ-tests: name: Docker integ tests diff --git a/README.md b/README.md index e1ab5ae63..829853b1a 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,21 @@ You can build and test the project using cargo: Run integ tests with `cargo integ-test`. By default it will start an ephemeral server. You can also use an already-running server by passing `-s external`. +To run target-compatible integration tests against a client configured with +[envconfig](./crates/client/README.md), pass `-s envconfig` and select a test suitable for that +target: + +```bash +TEMPORAL_ADDRESS=namespace.account.tmprl.cloud:7233 \ +TEMPORAL_NAMESPACE=namespace.account \ +TEMPORAL_API_KEY=... \ +cargo integ-test -s envconfig -- \ + integ_tests::workflow_tests::timers::timer_workflow_workflow_driver --exact +``` + +`TEMPORAL_CONFIG_FILE` and `TEMPORAL_PROFILE` can select a TOML profile instead. The harness does +not start, configure, or clean up the target server or namespace in this mode. + Run load tests with `cargo test --test heavy_tests`. NOTE: Integration tests should pass locally, if running on MacOS and you see integration tests consistently failing diff --git a/crates/sdk-core/tests/cloud_namespace/mod.rs b/crates/sdk-core/tests/cloud_namespace/mod.rs new file mode 100644 index 000000000..78590e884 --- /dev/null +++ b/crates/sdk-core/tests/cloud_namespace/mod.rs @@ -0,0 +1,200 @@ +use anyhow::{Context, bail}; +use std::{collections::HashMap, env, fs::OpenOptions, io::Write, time::Duration}; +use temporalio_client::{Connection, ConnectionOptions, TlsOptions, grpc::CloudService}; +use temporalio_common::protos::temporal::api::cloud::{ + cloudservice::v1::{ + CreateNamespaceRequest, DeleteNamespaceRequest, GetAsyncOperationRequest, + GetNamespaceRequest, + }, + namespace::v1::{MtlsAuthSpec, NamespaceSpec, ReplicaSpec}, + operation::v1::{AsyncOperation, async_operation}, +}; +use tokio::time::Instant; +use tonic::IntoRequest; +use url::Url; +use uuid::Uuid; + +const CLOUD_OPS_ADDRESS: &str = "https://saas-api.tmprl.cloud:443"; +const CLOUD_REGION: &str = "aws-ca-central-1"; +const OPERATION_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const DEFAULT_POLL_DELAY: Duration = Duration::from_secs(10); +const MIN_POLL_DELAY: Duration = Duration::from_secs(1); + +pub(crate) async fn create_namespace() -> anyhow::Result<()> { + let namespace_name = format!( + "sdk-rust-ci-{}-{}", + required_env("GITHUB_RUN_ID")?, + required_env("GITHUB_RUN_ATTEMPT")? + ); + let accepted_client_ca = tokio::fs::read(required_env("TEMPORAL_CLOUD_CLIENT_CA_PATH")?) + .await + .context("failed to read the Cloud test CA certificate")?; + let connection = cloud_connection().await?; + let mut client = connection.cloud_service(); + let response = client + .create_namespace( + CreateNamespaceRequest { + spec: Some(NamespaceSpec { + name: namespace_name, + retention_days: 1, + mtls_auth: Some(MtlsAuthSpec { + accepted_client_ca, + enabled: true, + ..Default::default() + }), + replicas: vec![ReplicaSpec { + region: CLOUD_REGION.to_owned(), + }], + ..Default::default() + }), + async_operation_id: Uuid::new_v4().to_string(), + ..Default::default() + } + .into_request(), + ) + .await + .context("failed to create Cloud namespace")? + .into_inner(); + + if response.namespace.is_empty() { + bail!("create namespace response did not include a namespace"); + } + append_github_output("namespace", &response.namespace)?; + wait_for_operation( + client.as_mut(), + response + .async_operation + .context("create namespace response did not include an operation")?, + ) + .await +} + +pub(crate) async fn delete_namespace(namespace: String) -> anyhow::Result<()> { + let connection = cloud_connection().await?; + let mut client = connection.cloud_service(); + let existing = client + .get_namespace( + GetNamespaceRequest { + namespace: namespace.clone(), + } + .into_request(), + ) + .await + .context("failed to read Cloud namespace before deletion")? + .into_inner(); + let resource_version = existing + .namespace + .map(|namespace| namespace.resource_version) + .filter(|version| !version.is_empty()) + .context("Cloud namespace did not include a resource version")?; + let response = client + .delete_namespace( + DeleteNamespaceRequest { + namespace, + resource_version, + async_operation_id: Uuid::new_v4().to_string(), + } + .into_request(), + ) + .await + .context("failed to delete Cloud namespace")? + .into_inner(); + wait_for_operation( + client.as_mut(), + response + .async_operation + .context("delete namespace response did not include an operation")?, + ) + .await +} + +async fn cloud_connection() -> anyhow::Result { + let api_version = required_env("TEMPORAL_CLIENT_CLOUD_API_VERSION")?; + let options = ConnectionOptions::new(Url::parse(CLOUD_OPS_ADDRESS)?) + .api_key(required_env("TEMPORAL_CLIENT_CLOUD_API_KEY")?) + .headers(HashMap::from([( + "temporal-cloud-api-version".to_owned(), + api_version, + )])) + .tls_options(TlsOptions::default()) + // The Cloud Operations endpoint does not expose the Workflow Service probe. + .skip_get_system_info(true) + .build(); + Connection::connect(options) + .await + .context("failed to connect to the Cloud Operations API") +} + +async fn wait_for_operation( + client: &mut dyn CloudService, + operation: AsyncOperation, +) -> anyhow::Result<()> { + if operation.id.is_empty() { + bail!("Cloud operation response did not include an ID"); + } + let operation_id = operation.id; + let deadline = Instant::now() + OPERATION_TIMEOUT; + + loop { + let operation = client + .get_async_operation( + GetAsyncOperationRequest { + async_operation_id: operation_id.clone(), + } + .into_request(), + ) + .await? + .into_inner() + .async_operation + .with_context(|| { + format!("Cloud operation {operation_id} response did not include an operation") + })?; + let state = async_operation::State::try_from(operation.state) + .with_context(|| format!("Cloud operation {operation_id} had an unknown state"))?; + + match state { + async_operation::State::Fulfilled => return Ok(()), + async_operation::State::Failed + | async_operation::State::Cancelled + | async_operation::State::Rejected => { + bail!( + "Cloud operation {operation_id} {}: {}", + state.as_str_name(), + operation.failure_reason + ); + } + async_operation::State::Unspecified + | async_operation::State::Pending + | async_operation::State::InProgress => {} + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + bail!("timed out waiting for Cloud operation {operation_id}"); + } + let delay = operation + .check_duration + .and_then(|duration| Duration::try_from(duration).ok()) + .unwrap_or(DEFAULT_POLL_DELAY) + .max(MIN_POLL_DELAY) + .min(remaining); + tokio::time::sleep(delay).await; + } +} + +fn append_github_output(name: &str, value: &str) -> anyhow::Result<()> { + let output_path = required_env("GITHUB_OUTPUT")?; + let mut output = OpenOptions::new() + .create(true) + .append(true) + .open(output_path) + .context("failed to open GITHUB_OUTPUT")?; + writeln!(output, "{name}={value}").context("failed to write GITHUB_OUTPUT") +} + +fn required_env(name: &str) -> anyhow::Result { + env::var(name) + .ok() + .filter(|value| !value.is_empty()) + .with_context(|| format!("missing required environment variable {name}")) +} diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index ed8dd68f7..86147964e 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -23,7 +23,7 @@ use std::{ path::PathBuf, str::FromStr, sync::{ - Arc, + Arc, LazyLock, atomic::{AtomicBool, Ordering}, }, time::{Duration, Instant}, @@ -32,6 +32,7 @@ use temporalio_client::{ Client, ClientOptions, ClientTlsOptions, Connection, ConnectionOptions, GrpcCompression, NamespacedClient, TlsOptions, UntypedWorkflow, UntypedWorkflowHandle, WorkflowExecutionInfo, WorkflowGetResultOptions, WorkflowHandle, WorkflowStartOptions, + envconfig::LoadClientConfigProfileOptions, errors::{WorkflowGetResultError, WorkflowStartError}, grpc::WorkflowService, }; @@ -77,6 +78,7 @@ pub(crate) const INTEG_SERVER_TARGET_ENV_VAR: &str = "TEMPORAL_SERVICE_ADDRESS"; pub(crate) const INTEG_NAMESPACE_ENV_VAR: &str = "TEMPORAL_NAMESPACE"; pub(crate) const INTEG_USE_TLS_ENV_VAR: &str = "TEMPORAL_USE_TLS"; pub(crate) const INTEG_API_KEY: &str = "TEMPORAL_API_KEY_PATH"; +pub(crate) const TEST_ENV_CONFIG_SERVER_ENV_VAR: &str = "TEMPORAL_TEST_ENV_CONFIG_SERVER"; pub(crate) static SEARCH_ATTR_TXT: &str = "CustomTextField"; pub(crate) static SEARCH_ATTR_INT: &str = "CustomIntField"; /// If set, turn export traces and metrics to the OTel collector at the given URL @@ -91,6 +93,17 @@ pub(crate) const INTEG_CLIENT_IDENTITY: &str = "integ_tester"; pub(crate) const INTEG_CLIENT_NAME: &str = "temporal-core"; pub(crate) const INTEG_CLIENT_VERSION: &str = "0.1.0"; +// Envconfig can read TOML profiles and TLS credentials from files. Load one immutable snapshot so +// concurrently-created clients and workers cannot observe different configuration during a test +// run. +static ENV_CONFIG_CLIENT_CONFIG: LazyLock<(ConnectionOptions, String)> = LazyLock::new(|| { + let (mut connection_options, client_options) = + ClientOptions::load_from_config(LoadClientConfigProfileOptions::default()) + .unwrap_or_else(|err| panic!("Failed to load integration test envconfig: {err}")); + connection_options.identity = INTEG_CLIENT_IDENTITY.to_string(); + (connection_options, client_options.namespace) +}); + /// Create a worker instance which will use the provided test name to base the task queue and wf id /// upon. Returns the instance. pub(crate) async fn init_core_and_create_wf(test_name: &str) -> CoreWfStarter { @@ -101,7 +114,12 @@ pub(crate) async fn init_core_and_create_wf(test_name: &str) -> CoreWfStarter { } pub(crate) fn integ_namespace() -> String { - env::var(INTEG_NAMESPACE_ENV_VAR).unwrap_or(NAMESPACE.to_string()) + if env::var_os(TEST_ENV_CONFIG_SERVER_ENV_VAR).is_some() { + let (_, namespace) = &*ENV_CONFIG_CLIENT_CONFIG; + namespace.clone() + } else { + env::var(INTEG_NAMESPACE_ENV_VAR).unwrap_or(NAMESPACE.to_string()) + } } pub(crate) fn integ_worker_config(tq: &str) -> WorkerConfig { @@ -908,6 +926,11 @@ impl TestWorkerCompletionIceptor { } /// Returns the connection options used to connect to the server used for integration tests. pub(crate) fn get_integ_server_options() -> ConnectionOptions { + if env::var_os(TEST_ENV_CONFIG_SERVER_ENV_VAR).is_some() { + let (connection_options, _) = &*ENV_CONFIG_CLIENT_CONFIG; + return connection_options.clone(); + } + let temporal_server_address = env::var(INTEG_SERVER_TARGET_ENV_VAR) .unwrap_or_else(|_| "http://localhost:7233".to_owned()); let url = Url::try_from(&*temporal_server_address).unwrap(); diff --git a/crates/sdk-core/tests/runner.rs b/crates/sdk-core/tests/runner.rs index 427a3d90f..131793020 100644 --- a/crates/sdk-core/tests/runner.rs +++ b/crates/sdk-core/tests/runner.rs @@ -1,11 +1,13 @@ +mod cloud_namespace; + // All non-main.rs tests ignore dead common code so that the linter doesn't complain about about it. #[allow(dead_code)] mod common; use crate::common::integ_dev_server_config; use anyhow::{anyhow, bail}; -use clap::Parser; -use common::INTEG_SERVER_TARGET_ENV_VAR; +use clap::{Parser, Subcommand}; +use common::{INTEG_SERVER_TARGET_ENV_VAR, TEST_ENV_CONFIG_SERVER_ENV_VAR}; use std::{ env, path::{Path, PathBuf}, @@ -22,6 +24,9 @@ const INTEG_TEST_SERVER_USED_ENV_VAR: &str = "INTEG_TEST_SERVER_ON"; #[derive(clap::Parser)] #[command(author, version, about, long_about = None)] struct Cli { + #[command(subcommand)] + command: Option, + /// Test harness to run. Anything defined as a `[[test]]` in core's `Cargo.toml` is valid. #[arg(short, long, default_value = "integ_tests")] test_name: String, @@ -46,6 +51,23 @@ struct Cli { harness_args: Vec, } +#[derive(Subcommand)] +enum RunnerCommand { + /// Manage an isolated Temporal Cloud namespace for integration tests + CloudNamespace { + #[command(subcommand)] + command: CloudNamespaceCommand, + }, +} + +#[derive(Subcommand)] +enum CloudNamespaceCommand { + /// Create a namespace and write its full name to GITHUB_OUTPUT + Create, + /// Delete a namespace and wait for deletion to finish + Delete { namespace: String }, +} + #[derive(Copy, Clone, PartialEq, Eq, clap::ValueEnum)] enum ServerKind { /// Use Temporal-cli @@ -54,11 +76,15 @@ enum ServerKind { TestServer, /// Do not automatically start any server External, + /// Load the server connection configuration from envconfig without starting a server + #[value(name = "envconfig")] + EnvConfig, } #[tokio::main] async fn main() -> Result<(), anyhow::Error> { let Cli { + command, test_name, server_kind, cargo_test_args, @@ -66,6 +92,14 @@ async fn main() -> Result<(), anyhow::Error> { just_build, harness_args, } = Cli::parse(); + if let Some(RunnerCommand::CloudNamespace { command }) = command { + return match command { + CloudNamespaceCommand::Create => cloud_namespace::create_namespace().await, + CloudNamespaceCommand::Delete { namespace } => { + cloud_namespace::delete_namespace(namespace).await + } + }; + } let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); // Try building first, so that we error early on build failures & don't start server // Unclear why --all-features doesn't work here @@ -135,6 +169,12 @@ async fn main() -> Result<(), anyhow::Error> { println!("========================================================"); (None, vec![]) } + ServerKind::EnvConfig => { + println!("========================================================"); + println!("Not starting up a server. Loading its configuration from envconfig."); + println!("========================================================"); + (None, vec![(TEST_ENV_CONFIG_SERVER_ENV_VAR, "true")]) + } }; let mut cmd = if let Some(test_executable) = test_executable { @@ -159,7 +199,12 @@ async fn main() -> Result<(), anyhow::Error> { format!("http://{}", &srv.target), ); } - let status = cmd.envs(envs).current_dir(project_root()).status().await?; + let status = cmd + .env_remove(TEST_ENV_CONFIG_SERVER_ENV_VAR) + .envs(envs) + .current_dir(project_root()) + .status() + .await?; if let Some(mut srv) = server { srv.shutdown().await?; From 8598d5becd24f0d0d90604f883bc56b4a1294b10 Mon Sep 17 00:00:00 2001 From: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:23:46 -0700 Subject: [PATCH 28/87] feat: workflow command pagination (#1515) --- .cargo/config.toml | 5 +- CHANGELOG.md | 1 + crates/sdk-core/CHANGELOG.md | 1 + .../sdk-core/src/core_tests/activity_tasks.rs | 4 +- crates/sdk-core/src/core_tests/queries.rs | 8 +- crates/sdk-core/src/core_tests/updates.rs | 4 +- crates/sdk-core/src/core_tests/workers.rs | 8 +- .../sdk-core/src/core_tests/workflow_tasks.rs | 32 +- crates/sdk-core/src/replay/mod.rs | 8 +- crates/sdk-core/src/telemetry/metrics.rs | 2 + .../sdk-core/src/test_help/integ_helpers.rs | 2 +- crates/sdk-core/src/worker/client.rs | 889 +++++++++++++++++- crates/sdk-core/src/worker/client/mocks.rs | 1 + crates/sdk-core/src/worker/mod.rs | 22 +- crates/sdk-core/src/worker/workflow/mod.rs | 55 +- crates/sdk-core/tests/common/mod.rs | 4 + .../tests/integ_tests/worker_tests.rs | 53 ++ 17 files changed, 1050 insertions(+), 49 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 75811dce3..12252757d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,7 +1,8 @@ [env] # This temporarily overrides the version of the CLI used for integration tests, locally and in CI -# TEMP: Event Groups requires server >v1.32.0-158.0, for which there's no published CLI release yet. -CLI_VERSION_OVERRIDE = "v1.7.4-standalone-nexus-operations" +# TEMP: workflow task completion pagination requires server >=v1.32.0-162.0, for which there's no +# published stable CLI release yet. +CLI_VERSION_OVERRIDE = "v1.8.3-server-1.32.0-162.0" [alias] # Not sure why --all-features doesn't work diff --git a/CHANGELOG.md b/CHANGELOG.md index dc88e4b1b..8d4b4f193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ relevant information. * The `temporal_activity_execution_failed` and `temporal_local_activity_execution_failed` worker metrics now carry a `failure_reason` attribute. Each is now split into one time series per reason, which may affect existing dashboards. +* Workflow task completions larger than the gRPC request size limit are now paginated automatically when the namespace supports it. Paginated workflow task completions require Temporal Server 1.32.0 or later. ### Breaking Changes :boom: * Values stored in a `MemoValue` must now be `Send + Sync`. It previously held its value in an diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index 97934d63b..1e13fb20f 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -49,6 +49,7 @@ relevant information. * The `temporal_activity_execution_failed` and `temporal_local_activity_execution_failed` worker metrics now carry a `failure_reason` attribute. Each is now split into one time series per reason, which may affect existing dashboards. +* Workflow task completions larger than the gRPC request size limit are now paginated automatically when the namespace supports it. Paginated workflow task completions require Temporal Server 1.32.0 or later. ### Breaking Changes :boom: * Activity failures now include the latest heartbeat details atomically instead of force-flushing a diff --git a/crates/sdk-core/src/core_tests/activity_tasks.rs b/crates/sdk-core/src/core_tests/activity_tasks.rs index a1c0f6533..a5a25e8c0 100644 --- a/crates/sdk-core/src/core_tests/activity_tasks.rs +++ b/crates/sdk-core/src/core_tests/activity_tasks.rs @@ -1282,7 +1282,7 @@ async fn no_eager_activities_requested_when_worker_options_disable_it( let mut mock = mock_worker_client(); mock.expect_complete_workflow_task() .times(1) - .returning(move |req| { + .returning(move |req, _| { // Store the number of eager activities requested to be checked below let count = req .commands @@ -1369,7 +1369,7 @@ async fn activity_tasks_from_completion_are_delivered() { let mut mock = mock_worker_client(); mock.expect_complete_workflow_task() .times(1) - .returning(move |req| { + .returning(move |req, _| { // Store the number of eager activities requested to be checked below let count = req .commands diff --git a/crates/sdk-core/src/core_tests/queries.rs b/crates/sdk-core/src/core_tests/queries.rs index fc70dcd17..a099223b8 100644 --- a/crates/sdk-core/src/core_tests/queries.rs +++ b/crates/sdk-core/src/core_tests/queries.rs @@ -468,7 +468,7 @@ async fn query_cache_miss_causes_page_fetch_dont_reply_wft_too_early( mock_client .expect_complete_workflow_task() .times(1) - .returning(|resp| { + .returning(|resp, _| { // Verify both the complete command and the query response are sent assert_eq!(resp.commands.len(), 1); assert_eq!(resp.query_responses.len(), 1); @@ -549,7 +549,7 @@ async fn query_replay_with_continue_as_new_doesnt_reply_empty_command() { mock_client .expect_complete_workflow_task() .times(1) - .returning(|resp| { + .returning(|resp, _| { // Verify both the complete command and the query response are sent assert_eq!(resp.commands.len(), 1); assert_eq!(resp.query_responses.len(), 1); @@ -754,7 +754,7 @@ async fn new_query_fail() { mock_client .expect_complete_workflow_task() .times(1) - .returning(|resp| { + .returning(|resp, _| { // Verify there is a failed query response along w/ start timer cmd assert_eq!(resp.commands.len(), 1); assert_matches!( @@ -1043,7 +1043,7 @@ async fn queries_arent_lost_in_buffer_void(#[values(false, true)] buffered_becau let mut mock = mock_worker_client(); mock.expect_complete_workflow_task() - .returning(|_| Ok(Default::default())); + .returning(|_, _| Ok(Default::default())); mock.expect_respond_legacy_query() .times(2) .returning(|_, _| Ok(Default::default())); diff --git a/crates/sdk-core/src/core_tests/updates.rs b/crates/sdk-core/src/core_tests/updates.rs index ea1ec331d..2c254d619 100644 --- a/crates/sdk-core/src/core_tests/updates.rs +++ b/crates/sdk-core/src/core_tests/updates.rs @@ -110,7 +110,7 @@ async fn initial_request_sent_back(#[values(false, true)] reject: bool) { mock_client .expect_complete_workflow_task() .times(1) - .returning(move |mut resp| { + .returning(move |mut resp, _| { let msg = resp.messages.pop().unwrap(); let orig_req = if reject { let acceptance = msg.body.unwrap().to_msg::().unwrap(); @@ -338,7 +338,7 @@ async fn update_activation_has_update_id() { mock_client .expect_complete_workflow_task() .times(1) - .returning(|_| Ok(RespondWorkflowTaskCompletedResponse::default())); + .returning(|_, _| Ok(RespondWorkflowTaskCompletedResponse::default())); let mh = MockPollCfg::from_resp_batches(wfid, t, [poll_resp], mock_client); let core = mock_worker(build_mock_pollers(mh)); diff --git a/crates/sdk-core/src/core_tests/workers.rs b/crates/sdk-core/src/core_tests/workers.rs index 01d858910..d5d324870 100644 --- a/crates/sdk-core/src/core_tests/workers.rs +++ b/crates/sdk-core/src/core_tests/workers.rs @@ -146,7 +146,7 @@ async fn worker_shutdown_during_poll_doesnt_deadlock() { let mut mock_client = mock_worker_client(); mock_client .expect_complete_workflow_task() - .returning(|_| Ok(RespondWorkflowTaskCompletedResponse::default())); + .returning(|_, _| Ok(RespondWorkflowTaskCompletedResponse::default())); let worker = mock_worker(MocksHolder::from_mock_worker(mock_client, mw)); let pollfut = worker.poll_workflow_activation(); let shutdownfut = async { @@ -206,7 +206,7 @@ async fn complete_with_task_not_found_during_shutdown() { let mut mock = mock_worker_client(); mock.expect_complete_workflow_task() .times(1) - .returning(|_| Err(tonic::Status::not_found("Workflow task not found."))); + .returning(|_, _| Err(tonic::Status::not_found("Workflow task not found."))); let mh = MockPollCfg::from_resp_batches("fakeid", t, [1], mock); let core = mock_worker(build_mock_pollers(mh)); @@ -277,7 +277,7 @@ async fn worker_does_not_panic_on_retry_exhaustion_of_nonfatal_net_err() { // Return a failure that counts as retryable, and hence we want to be swallowed mock.expect_complete_workflow_task() .times(1) - .returning(|_| Err(tonic::Status::internal("Some retryable error"))); + .returning(|_, _| Err(tonic::Status::internal("Some retryable error"))); let mut mh = MockPollCfg::from_resp_batches("fakeid", t, [1.into(), ResponseType::AllHistory], mock); mh.enforce_correct_number_of_polls = false; @@ -1291,7 +1291,7 @@ async fn graceful_shutdown_sends_shutdown_worker_rpc_during_initiate() { }); mock_client .expect_complete_workflow_task() - .returning(|_| Ok(RespondWorkflowTaskCompletedResponse::default())); + .returning(|_, _| Ok(RespondWorkflowTaskCompletedResponse::default())); // Polls block until shutdown_worker RPC releases them (simulating server holding polls // open until it receives the ShutdownWorker signal) diff --git a/crates/sdk-core/src/core_tests/workflow_tasks.rs b/crates/sdk-core/src/core_tests/workflow_tasks.rs index 93e5f8111..698093334 100644 --- a/crates/sdk-core/src/core_tests/workflow_tasks.rs +++ b/crates/sdk-core/src/core_tests/workflow_tasks.rs @@ -1137,9 +1137,9 @@ async fn sends_appropriate_sticky_task_queue_responses() { let t = canned_histories::single_timer("1"); let mut mock = mock_worker_client(); mock.expect_complete_workflow_task() - .withf(|comp| comp.sticky_attributes.is_some()) + .withf(|comp, _| comp.sticky_attributes.is_some()) .times(1) - .returning(|_| Ok(Default::default())); + .returning(|_, _| Ok(Default::default())); mock.expect_complete_workflow_task().times(0); let mut mock = single_hist_mock_sg(wfid, t, [1], mock, false); mock.worker_cfg(|wc| wc.max_cached_workflows = 10); @@ -1222,7 +1222,7 @@ async fn buffered_work_drained_on_shutdown() { ); let mut mock = mock_worker_client(); mock.expect_complete_workflow_task() - .returning(|_| Ok(RespondWorkflowTaskCompletedResponse::default())); + .returning(|_, _| Ok(RespondWorkflowTaskCompletedResponse::default())); let mut mock = MocksHolder::from_wft_stream(mock, stream::iter(tasks)); // Cache on to avoid being super repetitive mock.worker_cfg(|wc| wc.max_cached_workflows = 10); @@ -1396,10 +1396,10 @@ async fn lang_slower_than_wft_timeouts() { let mut mock = mock_worker_client(); mock.expect_complete_workflow_task() .times(1) - .returning(|_| Err(tonic::Status::not_found("Workflow task not found."))); + .returning(|_, _| Err(tonic::Status::not_found("Workflow task not found."))); mock.expect_complete_workflow_task() .times(1) - .returning(|_| Ok(Default::default())); + .returning(|_, _| Ok(Default::default())); let mut mock = single_hist_mock_sg(wfid, t, [1, 1], mock, true); let tasksmap = mock.outstanding_task_map.clone().unwrap(); mock.worker_cfg(|wc| { @@ -1815,10 +1815,10 @@ async fn tasks_from_completion_are_delivered() { }; mock.expect_complete_workflow_task() .times(1) - .returning(move |_| Ok(complete_resp.clone())); + .returning(move |_, _| Ok(complete_resp.clone())); mock.expect_complete_workflow_task() .times(1) - .returning(|_| Ok(Default::default())); + .returning(|_, _| Ok(Default::default())); let mut mock = single_hist_mock_sg(wfid, t, [1], mock, true); mock.worker_cfg(|wc| wc.max_cached_workflows = 2); let core = mock_worker(mock); @@ -1861,10 +1861,10 @@ async fn pagination_works_with_tasks_from_completion() { }; mock.expect_complete_workflow_task() .times(1) - .returning(move |_| Ok(complete_resp.clone())); + .returning(move |_, _| Ok(complete_resp.clone())); mock.expect_complete_workflow_task() .times(1) - .returning(|_| Ok(Default::default())); + .returning(|_, _| Ok(Default::default())); let get_exec_resp: GetWorkflowExecutionHistoryResponse = t.get_full_history_info().unwrap().into(); @@ -1910,7 +1910,7 @@ async fn poll_faster_than_complete_wont_overflow_cache() { mock_client .expect_complete_workflow_task() .times(3) - .returning(|_| Ok(Default::default())); + .returning(|_, _| Ok(Default::default())); let mut mock_cfg = MockPollCfg::new(tasks, true, 0); mock_cfg.mock_client = mock_client; let mut mock = build_mock_pollers(mock_cfg); @@ -2167,7 +2167,7 @@ async fn no_race_acquiring_permits() { .returning(move |_, _| async move { Ok(Default::default()) }.boxed()); mock_client .expect_complete_workflow_task() - .returning(|_| async move { Ok(Default::default()) }.boxed()); + .returning(|_, _| async move { Ok(Default::default()) }.boxed()); let worker = Worker::new_test( { @@ -2253,7 +2253,7 @@ async fn continue_as_new_preserves_some_values() { }; mock_client .expect_complete_workflow_task() - .returning(move |mut c| { + .returning(move |mut c, _| { let cmd = c.commands.pop().unwrap().attributes.unwrap(); if let Attributes::ContinueAsNewWorkflowExecutionCommandAttributes(a) = cmd { assert_eq!(a.workflow_type.unwrap().name, "meow"); @@ -2821,7 +2821,7 @@ async fn poller_wont_run_ahead_of_task_slots() { .returning(move |_, _| Ok(bunch_of_first_tasks.next().unwrap())); mock_client .expect_complete_workflow_task() - .returning(|_| Ok(Default::default())); + .returning(|_, _| Ok(Default::default())); let worker = Worker::new_test( { @@ -2931,7 +2931,7 @@ async fn use_compatible_version_flag( #[allow(deprecated)] mock_client .expect_complete_workflow_task() - .returning(move |mut c| { + .returning(move |mut c, _| { let can_cmd = c.commands.pop().unwrap().attributes.unwrap(); match can_cmd { Attributes::ContinueAsNewWorkflowExecutionCommandAttributes(a) => { @@ -3007,7 +3007,7 @@ async fn slot_provider_cant_hand_out_more_permits_than_cache_size() { .returning(move |_, _| Ok(bunch_of_first_tasks.next().unwrap())); mock_client .expect_complete_workflow_task() - .returning(|_| Ok(Default::default())); + .returning(|_, _| Ok(Default::default())); struct EndlessSupplier {} #[async_trait::async_trait] @@ -3169,7 +3169,7 @@ async fn both_normal_and_sticky_pollers_poll_concurrently() { let cc = Arc::clone(&counters); mock_client .expect_complete_workflow_task() - .returning(move |completion| { + .returning(move |completion, _| { if completion.task_token.0.ends_with(b"normal") { cc.normal_slots_active_count.fetch_sub(1, Ordering::Relaxed); } else { diff --git a/crates/sdk-core/src/replay/mod.rs b/crates/sdk-core/src/replay/mod.rs index ea38736b5..6e4cadc50 100644 --- a/crates/sdk-core/src/replay/mod.rs +++ b/crates/sdk-core/src/replay/mod.rs @@ -129,9 +129,11 @@ where .boxed() }); - client.expect_complete_workflow_task().returning(move |_a| { - async move { Ok(RespondWorkflowTaskCompletedResponse::default()) }.boxed() - }); + client + .expect_complete_workflow_task() + .returning(move |_a, _b| { + async move { Ok(RespondWorkflowTaskCompletedResponse::default()) }.boxed() + }); client .expect_fail_workflow_task() .returning(move |_, _, _| { diff --git a/crates/sdk-core/src/telemetry/metrics.rs b/crates/sdk-core/src/telemetry/metrics.rs index 6bff7fd9f..58bb35b19 100644 --- a/crates/sdk-core/src/telemetry/metrics.rs +++ b/crates/sdk-core/src/telemetry/metrics.rs @@ -765,6 +765,7 @@ pub(crate) enum FailureReason { GrpcMessageTooLarge, PayloadsTooLarge, ExternalStorageError, + RequestTooLarge, } impl Display for FailureReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -778,6 +779,7 @@ impl Display for FailureReason { FailureReason::GrpcMessageTooLarge => "GrpcMessageTooLarge".to_owned(), FailureReason::PayloadsTooLarge => "PayloadsTooLarge".to_owned(), FailureReason::ExternalStorageError => "ExternalStorageError".to_owned(), + FailureReason::RequestTooLarge => "RequestTooLarge".to_owned(), }; write!(f, "{str}") } diff --git a/crates/sdk-core/src/test_help/integ_helpers.rs b/crates/sdk-core/src/test_help/integ_helpers.rs index 5847f83df..8ae653bff 100644 --- a/crates/sdk-core/src/test_help/integ_helpers.rs +++ b/crates/sdk-core/src/test_help/integ_helpers.rs @@ -808,7 +808,7 @@ pub fn build_mock_pollers(mut cfg: MockPollCfg) -> MocksHolder { } else if cfg.completion_mock_fn.is_some() { expect_completes.times(1..); } - expect_completes.returning(move |comp| { + expect_completes.returning(move |comp, _| { let r = if let Some(ass) = cfg.completion_mock_fn.as_mut() { // tee hee ass(&comp) diff --git a/crates/sdk-core/src/worker/client.rs b/crates/sdk-core/src/worker/client.rs index fcc87ccd3..9bf882aa4 100644 --- a/crates/sdk-core/src/worker/client.rs +++ b/crates/sdk-core/src/worker/client.rs @@ -5,7 +5,10 @@ use crate::{ protosext::legacy_query_failure, worker::{WorkerVersioningStrategy, worker_control_task_queue}, }; +use backon::{BackoffBuilder, ExponentialBuilder}; +use futures_util::{StreamExt, TryStreamExt, stream}; use parking_lot::Mutex; +use prost::Message; use prost_types::Duration as PbDuration; use std::{ collections::HashMap, @@ -24,6 +27,7 @@ use temporalio_common::protos::{ activity_result::ActivityTaskFailedCause, workflow_commands::QueryResult, workflow_completion, }, + google::rpc::Status as RpcStatus, temporal::api::{ command::v1::Command, common::v1::{ @@ -35,6 +39,7 @@ use temporalio_common::protos::{ TaskQueueKind, TaskQueueType, VersioningBehavior, WorkerVersioningMode, WorkflowTaskFailedCause, }, + errordetails::v1::WorkflowTaskCompletionBufferLostFailure, failure::v1::Failure, nexus::{self, v1::NexusTaskFailure}, protocol::v1::Message as ProtocolMessage, @@ -45,11 +50,142 @@ use temporalio_common::protos::{ workflowservice::v1::{get_system_info_response::Capabilities, *}, }, }; -use tonic::IntoRequest; +use tokio::time::sleep; +use tokio_util::sync::CancellationToken; +use tonic::{IntoRequest, metadata::MetadataValue}; use uuid::Uuid; type Result = std::result::Result; +/// Maximum encoded size of a single completion page, kept below the ~4 MiB gRPC frame limit. This +/// per-page cap is distinct from the server's namespace-wide limit on the recombined completion +/// size. +/// +/// Pages are packed by summing command body sizes only; the 512 KiB of headroom below 4 MiB absorbs +/// everything that sum omits: the per-request overhead (task token, identity, namespace) and the +/// per-command wire framing (a field tag plus a length varint, up to 6 bytes each). At the server's +/// default per-workflow history-count limit (~51,200 events), worst-case framing is ~300 KiB, so +/// this headroom covers even a page of many tiny commands and lets us skip per-command accounting. +const MAX_WFT_COMPLETION_PAGE_SIZE: usize = 4 * 1024 * 1024 - 512 * 1024; +// Conservative heuristic, not a tuned value: caps the client-side burst (concurrent request bodies +// and streams); the cost is only extra serial rounds for completions over this many pages. +const MAX_CONCURRENT_WFT_COMPLETION_PAGES: usize = 3; +// Backoff between resends of lost pages. Values are a conservative heuristic, not tuned; +// `without_max_times` leaves the number of resends to the loop (bounded by a stale token or +// shutdown), not the backoff. +const WFT_COMPLETION_PAGE_RESEND_BACKOFF: ExponentialBuilder = ExponentialBuilder::new() + .with_min_delay(Duration::from_millis(100)) + .with_factor(2.0) + .with_max_delay(Duration::from_secs(5)) + .without_max_times(); +/// Marker set on the error returned when a completion is failed proactively for exceeding the +/// namespace's recombined completion-size limit, so the workflow layer reports it as +/// `REQUEST_TOO_LARGE`. +pub(crate) static REQUEST_TOO_LARGE_KEY: &str = "request-too-large"; + +/// How a workflow task completion should be delivered, produced by [paginate_wft_completion]. +enum WftCompletionPages { + /// Send as a single request: it fits within a page, or it cannot be split. + Single(RespondWorkflowTaskCompletedRequest), + /// The server buffers only the commands of intermediate pages, so all messages and metadata + /// ride on the final page. + Paginated { + intermediate_pages: Vec, + final_page: RespondWorkflowTaskCompletedRequest, + }, +} + +/// Split a completion that may exceed `max_page_bytes` into pages that each stay under it, by +/// distributing its commands across intermediate pages in order. +/// +/// Falls back to [WftCompletionPages::Single] when the request already fits, has no commands to +/// distribute, or has a single command that alone exceeds a page (which the server then rejects). +fn paginate_wft_completion( + mut request: RespondWorkflowTaskCompletedRequest, + max_page_bytes: usize, +) -> WftCompletionPages { + if request.encoded_len() <= max_page_bytes { + return WftCompletionPages::Single(request); + } + + let intermediate_template = RespondWorkflowTaskCompletedRequest { + task_token: request.task_token.clone(), + identity: request.identity.clone(), + namespace: request.namespace.clone(), + intermediate_page: true, + ..Default::default() + }; + + // Pages are packed purely by command body size; MAX_WFT_COMPLETION_PAGE_SIZE reserves headroom + // for the per-request and per-command overhead this ignores. Only commands can be split across + // pages, so pagination cannot help when there are none, or when a single command alone exceeds + // a page. + if request.commands.is_empty() + || request + .commands + .iter() + .any(|c| c.encoded_len() > max_page_bytes) + { + return WftCompletionPages::Single(request); + } + + let commands = std::mem::take(&mut request.commands); + let mut intermediate = Vec::new(); + let mut current = Vec::new(); + let mut current_len = 0; + for command in commands { + let command_len = command.encoded_len(); + if !current.is_empty() && current_len + command_len > max_page_bytes { + let mut page = intermediate_template.clone(); + page.commands = std::mem::take(&mut current); + page.page_number = intermediate.len() as i32; + intermediate.push(page); + current_len = 0; + } + current_len += command_len; + current.push(command); + } + if !current.is_empty() { + let mut page = intermediate_template.clone(); + page.commands = current; + page.page_number = intermediate.len() as i32; + intermediate.push(page); + } + + request.page_number = intermediate.len() as i32; + request.intermediate_page = false; + WftCompletionPages::Paginated { + intermediate_pages: intermediate, + final_page: request, + } +} + +/// Returns true if `status` carries a `WorkflowTaskCompletionBufferLostFailure` detail, the +/// server's signal that it dropped the buffered pages and they must be resent from page 0. +fn is_workflow_task_completion_buffer_lost(status: &tonic::Status) -> bool { + RpcStatus::decode(status.details()) + .map(|rpc_status| { + rpc_status.details.iter().any(|detail| { + detail + .to_msg::() + .is_ok() + }) + }) + .unwrap_or(false) +} + +/// Wraps a completion page in a request that opts out of the client-layer retry for buffer loss, +/// which `complete_workflow_task` recovers itself by resending every page. +fn wft_completion_page_request( + page: RespondWorkflowTaskCompletedRequest, +) -> tonic::Request { + let mut request = page.into_request(); + request.extensions_mut().insert(NoRetryOnMatching { + predicate: is_workflow_task_completion_buffer_lost, + }); + request +} + /// The result of a legacy query sent via `respond_legacy_query`. pub enum LegacyQueryResult { /// The query handler returned a result successfully. @@ -185,6 +321,7 @@ pub trait WorkerClient: Sync + Send { async fn complete_workflow_task( &self, request: WorkflowTaskCompletion, + shutdown_token: CancellationToken, ) -> Result; /// Complete an activity task async fn complete_activity_task( @@ -465,7 +602,10 @@ impl WorkerClient for WorkerClientBag { async fn complete_workflow_task( &self, request: WorkflowTaskCompletion, + shutdown_token: CancellationToken, ) -> Result { + let pagination_enabled = request.pagination_enabled; + let wft_completion_size_limit = request.wft_completion_size_limit; #[allow(deprecated)] // want to list all fields explicitly let request = RespondWorkflowTaskCompletedRequest { task_token: request.task_token.into(), @@ -507,17 +647,98 @@ impl WorkerClient for WorkerClientBag { worker_instance_key: self.worker_instance_key.to_string(), worker_control_task_queue: self.worker_control_task_queue(), resource_id: Default::default(), - // Pagination fields: default to a single, final page. Pagination logic will - // populate these when splitting large completions. page_number: 0, intermediate_page: false, }; - Ok(self - .client - .clone() - .respond_workflow_task_completed(request.into_request()) - .await? - .into_inner()) + + let pages = if pagination_enabled { + paginate_wft_completion(request, MAX_WFT_COMPLETION_PAGE_SIZE) + } else { + WftCompletionPages::Single(request) + }; + let (intermediate_pages, final_page) = match pages { + WftCompletionPages::Single(request) => { + return Ok(self + .client + .clone() + .respond_workflow_task_completed(request.into_request()) + .await? + .into_inner()); + } + WftCompletionPages::Paginated { + intermediate_pages, + final_page, + } => (intermediate_pages, final_page), + }; + + // The server rejects the completion with REQUEST_TOO_LARGE and terminates the workflow once + // the buffered command bytes exceed the namespace limit, so fail here instead of sending + // doomed pages. Only buffered command bytes count toward that limit, not messages or + // metadata, which aren't buffered. + if let Some(limit) = wft_completion_size_limit { + let buffered_command_bytes: usize = intermediate_pages + .iter() + .flat_map(|page| page.commands.iter()) + .map(|command| command.encoded_len()) + .sum(); + if buffered_command_bytes > limit { + let mut status = tonic::Status::resource_exhausted( + "workflow task completion exceeds the namespace's recombined size limit", + ); + status + .metadata_mut() + .insert(REQUEST_TOO_LARGE_KEY, MetadataValue::from(0)); + return Err(status); + } + } + + // Buffer loss is transient, so resend the whole set from page 0 with exponential backoff. + // The server bounds the loop: once the task times out it starts a new attempt, and the next + // resend fails the token check with a non-buffer-lost error. Worker shutdown ends the loop + // sooner, so a stream of buffer losses can't hold shutdown's drain open until the task times + // out (a completion that is not resending still drains normally). Recovery has to live here + // because the client's retry layer would resend only the single failed page, which cannot + // rebuild the buffer the server dropped; it is told to pass buffer loss straight through + // (see `wft_completion_page_request`). + let mut backoff = WFT_COMPLETION_PAGE_RESEND_BACKOFF.build(); + loop { + let send_all = async { + // Cancel in-flight pages on the first error rather than awaiting them: any failure + // means we fail the task or resend from page 0, so the rest is wasted work. + stream::iter(intermediate_pages.iter().cloned()) + .map(|page| { + let mut client = self.client.clone(); + async move { + client + .respond_workflow_task_completed(wft_completion_page_request(page)) + .await + } + }) + .buffer_unordered(MAX_CONCURRENT_WFT_COMPLETION_PAGES) + .try_collect::>() + .await?; + // The final page must be sent only after every intermediate page has been + // buffered: it triggers the server-side merge, which requires pages 0..N-1 to all + // be present and otherwise returns a buffer-lost error. + self.client + .clone() + .respond_workflow_task_completed(wft_completion_page_request( + final_page.clone(), + )) + .await + }; + match send_all.await { + Ok(response) => return Ok(response.into_inner()), + Err(e) if is_workflow_task_completion_buffer_lost(&e) => { + let delay = backoff.next().expect("resend backoff is unbounded"); + tokio::select! { + _ = shutdown_token.cancelled() => return Err(e), + _ = sleep(delay) => {} + } + } + Err(e) => return Err(e), + } + } } async fn complete_activity_task( @@ -943,7 +1164,7 @@ impl NamespacedClient for WorkerClientBag { /// A version of [RespondWorkflowTaskCompletedRequest] that will finish being filled out by the /// server client -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] pub struct WorkflowTaskCompletion { /// The task token that would've been received from polling for a workflow activation pub task_token: TaskToken, @@ -965,6 +1186,13 @@ pub struct WorkflowTaskCompletion { pub metering_metadata: MeteringMetadata, /// Versioning behavior of the workflow, if any. pub versioning_behavior: VersioningBehavior, + /// Whether the namespace permits paginating this completion across multiple page requests when + /// it would otherwise exceed the server's gRPC request size limit. + pub pagination_enabled: bool, + /// The namespace's limit on the recombined size of a paginated completion, if the server + /// advertises one. A paginated completion larger than this is rejected server-side with + /// `REQUEST_TOO_LARGE`, so the worker fails it proactively instead of sending doomed pages. + pub wft_completion_size_limit: Option, } #[derive(Clone, Default)] @@ -1208,4 +1436,645 @@ mod tests { ); } } + + mod pagination { + use super::*; + use temporalio_common::protos::temporal::api::{ + command::v1::{CompleteWorkflowExecutionCommandAttributes, command}, + common::v1::{Payload, Payloads}, + errordetails::v1::WorkflowExecutionAlreadyStartedFailure, + }; + + fn command_with_payload(data_size: usize) -> Command { + Command { + attributes: Some( + command::Attributes::CompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes { + result: Some(Payloads { + payloads: vec![Payload { + metadata: Default::default(), + data: vec![0u8; data_size], + ..Default::default() + }], + }), + }, + ), + ), + ..Default::default() + } + } + + fn request_with(commands: Vec) -> RespondWorkflowTaskCompletedRequest { + RespondWorkflowTaskCompletedRequest { + task_token: b"task-token".to_vec(), + identity: "identity".to_string(), + namespace: "namespace".to_string(), + commands, + ..Default::default() + } + } + + #[test] + fn completion_within_limit_is_a_single_final_page() { + let request = request_with(vec![command_with_payload(16)]); + let WftCompletionPages::Single(page) = paginate_wft_completion(request, 4096) else { + panic!("expected a single page"); + }; + assert_eq!(page.page_number, 0); + assert!(!page.intermediate_page); + assert_eq!(page.commands.len(), 1); + } + + #[test] + fn large_completion_splits_commands_across_pages() { + let max = 1024; + let command_count = 6; + let commands: Vec<_> = (0..command_count) + .map(|_| command_with_payload(400)) + .collect(); + let request = request_with(commands); + assert!(request.encoded_len() > max); + + let WftCompletionPages::Paginated { + intermediate_pages: intermediate, + final_page, + } = paginate_wft_completion(request, max) + else { + panic!("expected multiple pages"); + }; + + assert!(!final_page.intermediate_page); + assert!(final_page.commands.is_empty()); + assert_eq!(final_page.page_number as usize, intermediate.len()); + assert!(final_page.encoded_len() <= max); + assert_eq!(final_page.task_token, b"task-token"); + + let mut total_commands = 0; + for (idx, page) in intermediate.iter().enumerate() { + assert!(page.intermediate_page); + assert_eq!(page.page_number as usize, idx); + assert_eq!(page.task_token, b"task-token"); + assert!( + page.encoded_len() <= max, + "intermediate page {idx} over limit" + ); + total_commands += page.commands.len(); + } + // Every command is preserved exactly once across the intermediate pages. + assert_eq!(total_commands, command_count); + } + + #[test] + fn single_command_larger_than_a_page_is_not_split() { + let max = 1024; + let request = request_with(vec![command_with_payload(4096)]); + // Cannot be split, so it is left as one (oversized) request for the server to reject. + let WftCompletionPages::Single(page) = paginate_wft_completion(request, max) else { + panic!("expected a single page"); + }; + assert_eq!(page.commands.len(), 1); + assert!(!page.intermediate_page); + } + + // Pack the detail with `Any::from_msg` so its `type_url` is derived from the message name, + // the way the server sets it, rather than a hand-written string. + fn status_with_detail(detail: &M) -> tonic::Status { + let rpc_status = RpcStatus { + code: tonic::Code::Aborted as i32, + message: String::new(), + details: vec![prost_types::Any::from_msg(detail).expect("detail encodes")], + }; + tonic::Status::with_details(tonic::Code::Aborted, "", rpc_status.encode_to_vec().into()) + } + + #[test] + fn detects_buffer_lost_failure_detail() { + let status = status_with_detail(&WorkflowTaskCompletionBufferLostFailure {}); + assert!(is_workflow_task_completion_buffer_lost(&status)); + + let unrelated = tonic::Status::new(tonic::Code::Internal, "boom"); + assert!(!is_workflow_task_completion_buffer_lost(&unrelated)); + } + + #[test] + fn buffer_lost_detection_ignores_unrelated_detail() { + // A different error detail carried on the same gRPC code must not be mistaken for a + // buffer-lost failure. + let status = status_with_detail(&WorkflowExecutionAlreadyStartedFailure { + start_request_id: "req".to_string(), + run_id: "run".to_string(), + ..Default::default() + }); + assert!(!is_workflow_task_completion_buffer_lost(&status)); + } + + #[tokio::test] + async fn paginated_completion_sends_ordered_pages_sharing_a_token() { + let captured = Arc::new(Mutex::new(Vec::new())); + let captured_clone = captured.clone(); + let service_override = CallbackBasedGrpcService { + callback: Arc::new(move |request| { + let captured = captured_clone.clone(); + Box::pin(async move { + let proto = match request.rpc.as_str() { + "GetSystemInfo" => GetSystemInfoResponse { + capabilities: Some(Capabilities::default()), + ..Default::default() + } + .encode_to_vec(), + "RespondWorkflowTaskCompleted" => { + captured.lock().unwrap().push( + RespondWorkflowTaskCompletedRequest::decode(request.proto) + .expect("completion request is valid"), + ); + RespondWorkflowTaskCompletedResponse::default().encode_to_vec() + } + rpc => panic!("unexpected RPC: {rpc}"), + }; + Ok(GrpcSuccessResponse { + headers: Default::default(), + proto, + }) + }) + }), + }; + let connection = Connection::connect( + ConnectionOptions::new(url::Url::parse("http://localhost:7233").unwrap()) + .service_override(service_override) + .dns_load_balancing(None) + .build(), + ) + .await + .unwrap(); + let client = WorkerClientBag::new( + SharedReplaceableClient::new(connection), + "namespace".to_string(), + WorkerVersioningStrategy::LegacyBuildIdBased { + build_id: "test-build".to_string(), + }, + Uuid::new_v4(), + ); + + // Roughly 4 MiB of commands forces splitting under the ~3 MiB page target. + let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); + let completion = WorkflowTaskCompletion { + task_token: TaskToken(b"shared-token".to_vec()), + commands, + messages: vec![], + sticky_attributes: None, + query_responses: vec![], + return_new_workflow_task: false, + force_create_new_workflow_task: false, + sdk_metadata: Default::default(), + metering_metadata: Default::default(), + versioning_behavior: VersioningBehavior::Unspecified, + pagination_enabled: true, + wft_completion_size_limit: None, + }; + client + .complete_workflow_task(completion, CancellationToken::new()) + .await + .unwrap(); + + let sent = captured.lock().unwrap(); + assert!( + sent.len() >= 2, + "expected multiple pages, got {}", + sent.len() + ); + // Every page shares the one task token. + assert!(sent.iter().all(|r| r.task_token == b"shared-token")); + // Exactly one final page, numbered after all the intermediate ones. + let finals: Vec<_> = sent.iter().filter(|r| !r.intermediate_page).collect(); + assert_eq!(finals.len(), 1); + assert_eq!(finals[0].page_number as usize, sent.len() - 1); + assert!(finals[0].commands.is_empty()); + // Intermediate pages carry sequential page numbers 0..N-1. + let mut intermediate_numbers: Vec<_> = sent + .iter() + .filter(|r| r.intermediate_page) + .map(|r| r.page_number) + .collect(); + intermediate_numbers.sort_unstable(); + assert_eq!( + intermediate_numbers, + (0..(sent.len() as i32 - 1)).collect::>() + ); + } + + #[tokio::test] + async fn failed_page_cancels_other_inflight_pages() { + // Page 0 fails immediately; every other intermediate page hangs forever. The call can + // only return if the failed page short-circuits the send and the hung pages are + // dropped (cancelled) rather than awaited. + let never = Arc::new(tokio::sync::Notify::new()); + let never_cb = never.clone(); + let service_override = CallbackBasedGrpcService { + callback: Arc::new(move |request| { + let never = never_cb.clone(); + Box::pin(async move { + match request.rpc.as_str() { + "GetSystemInfo" => Ok(GrpcSuccessResponse { + headers: Default::default(), + proto: GetSystemInfoResponse { + capabilities: Some(Capabilities::default()), + ..Default::default() + } + .encode_to_vec(), + }), + "RespondWorkflowTaskCompleted" => { + let page = + RespondWorkflowTaskCompletedRequest::decode(request.proto) + .expect("completion request is valid"); + if page.intermediate_page && page.page_number == 0 { + // InvalidArgument is non-retryable, so it is forwarded at once. + Err(tonic::Status::new(tonic::Code::InvalidArgument, "boom")) + } else { + never.notified().await; + unreachable!("a cancelled page must not resume"); + } + } + rpc => panic!("unexpected RPC: {rpc}"), + } + }) + }), + }; + let connection = Connection::connect( + ConnectionOptions::new(url::Url::parse("http://localhost:7233").unwrap()) + .service_override(service_override) + .dns_load_balancing(None) + .build(), + ) + .await + .unwrap(); + let client = WorkerClientBag::new( + SharedReplaceableClient::new(connection), + "namespace".to_string(), + WorkerVersioningStrategy::LegacyBuildIdBased { + build_id: "test-build".to_string(), + }, + Uuid::new_v4(), + ); + + // Enough commands to yield at least two intermediate pages (one fails, one hangs). + let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); + let completion = WorkflowTaskCompletion { + task_token: TaskToken(b"shared-token".to_vec()), + commands, + messages: vec![], + sticky_attributes: None, + query_responses: vec![], + return_new_workflow_task: false, + force_create_new_workflow_task: false, + sdk_metadata: Default::default(), + metering_metadata: Default::default(), + versioning_behavior: VersioningBehavior::Unspecified, + pagination_enabled: true, + wft_completion_size_limit: None, + }; + + // Without cancellation this would hang on the never-completing page; the timeout guards + // against that regression instead of relying on a sleep. + let outcome = tokio::time::timeout( + Duration::from_secs(10), + client.complete_workflow_task(completion, CancellationToken::new()), + ) + .await + .expect("completion resolved without waiting on the hung page"); + assert!( + outcome.is_err(), + "the failed page should surface as an error" + ); + } + + #[tokio::test] + async fn completion_over_namespace_limit_fails_proactively_without_sending() { + let sent = Arc::new(Mutex::new(0usize)); + let sent_cb = sent.clone(); + let service_override = CallbackBasedGrpcService { + callback: Arc::new(move |request| { + let sent = sent_cb.clone(); + Box::pin(async move { + let proto = match request.rpc.as_str() { + "GetSystemInfo" => GetSystemInfoResponse { + capabilities: Some(Capabilities::default()), + ..Default::default() + } + .encode_to_vec(), + "RespondWorkflowTaskCompleted" => { + *sent.lock().unwrap() += 1; + RespondWorkflowTaskCompletedResponse::default().encode_to_vec() + } + rpc => panic!("unexpected RPC: {rpc}"), + }; + Ok(GrpcSuccessResponse { + headers: Default::default(), + proto, + }) + }) + }), + }; + let connection = Connection::connect( + ConnectionOptions::new(url::Url::parse("http://localhost:7233").unwrap()) + .service_override(service_override) + .dns_load_balancing(None) + .build(), + ) + .await + .unwrap(); + let client = WorkerClientBag::new( + SharedReplaceableClient::new(connection), + "namespace".to_string(), + WorkerVersioningStrategy::LegacyBuildIdBased { + build_id: "test-build".to_string(), + }, + Uuid::new_v4(), + ); + + // ~4 MiB total (so it would be paginated) but the namespace caps the recombined size + // at 1 MiB, so the server would reject it, and the worker must fail it without sending. + let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); + let completion = WorkflowTaskCompletion { + task_token: TaskToken(b"shared-token".to_vec()), + commands, + messages: vec![], + sticky_attributes: None, + query_responses: vec![], + return_new_workflow_task: false, + force_create_new_workflow_task: false, + sdk_metadata: Default::default(), + metering_metadata: Default::default(), + versioning_behavior: VersioningBehavior::Unspecified, + pagination_enabled: true, + wft_completion_size_limit: Some(1024 * 1024), + }; + let err = client + .complete_workflow_task(completion, CancellationToken::new()) + .await + .expect_err("completion over the namespace limit must fail"); + assert!(err.metadata().contains_key(REQUEST_TOO_LARGE_KEY)); + assert_eq!(*sent.lock().unwrap(), 0, "no pages should have been sent"); + } + + #[tokio::test] + async fn buffer_loss_resends_all_pages_until_it_succeeds() { + // The server reports buffer loss on the final page of the first two attempts, then + // accepts the third. The whole set must be resent from page 0 each time, and the + // completion must ultimately succeed. Because buffer loss is marked non-retryable at the + // client layer, each attempt sends the final page exactly once, so a count of three + // proves the resend loop, not the client's retry policy, did the retrying. + let final_attempts = Arc::new(Mutex::new(0usize)); + let final_attempts_cb = final_attempts.clone(); + let service_override = CallbackBasedGrpcService { + callback: Arc::new(move |request| { + let final_attempts = final_attempts_cb.clone(); + Box::pin(async move { + match request.rpc.as_str() { + "GetSystemInfo" => Ok(GrpcSuccessResponse { + headers: Default::default(), + proto: GetSystemInfoResponse { + capabilities: Some(Capabilities::default()), + ..Default::default() + } + .encode_to_vec(), + }), + "RespondWorkflowTaskCompleted" => { + let page = + RespondWorkflowTaskCompletedRequest::decode(request.proto) + .expect("completion request is valid"); + if !page.intermediate_page { + let mut attempts = final_attempts.lock().unwrap(); + *attempts += 1; + if *attempts <= 2 { + return Err(status_with_detail( + &WorkflowTaskCompletionBufferLostFailure {}, + )); + } + } + Ok(GrpcSuccessResponse { + headers: Default::default(), + proto: RespondWorkflowTaskCompletedResponse::default() + .encode_to_vec(), + }) + } + rpc => panic!("unexpected RPC: {rpc}"), + } + }) + }), + }; + let connection = Connection::connect( + ConnectionOptions::new(url::Url::parse("http://localhost:7233").unwrap()) + .service_override(service_override) + .dns_load_balancing(None) + .build(), + ) + .await + .unwrap(); + let client = WorkerClientBag::new( + SharedReplaceableClient::new(connection), + "namespace".to_string(), + WorkerVersioningStrategy::LegacyBuildIdBased { + build_id: "test-build".to_string(), + }, + Uuid::new_v4(), + ); + + let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); + let completion = WorkflowTaskCompletion { + task_token: TaskToken(b"shared-token".to_vec()), + commands, + messages: vec![], + sticky_attributes: None, + query_responses: vec![], + return_new_workflow_task: false, + force_create_new_workflow_task: false, + sdk_metadata: Default::default(), + metering_metadata: Default::default(), + versioning_behavior: VersioningBehavior::Unspecified, + pagination_enabled: true, + wft_completion_size_limit: None, + }; + client + .complete_workflow_task(completion, CancellationToken::new()) + .await + .expect("completion eventually succeeds after the buffer is re-established"); + assert_eq!( + *final_attempts.lock().unwrap(), + 3, + "the final page is sent once per resend, with no client-layer retry" + ); + } + + #[tokio::test] + async fn shutdown_stops_buffer_loss_resends() { + // The server never re-establishes the buffer, so without shutdown handling the resend + // loop would run until the task times out on the server, potentially minutes, holding + // shutdown open. A cancelled shutdown token must end it promptly instead. The token is + // cancelled before the call, so the first attempt still sends fully (in-flight work is + // never abandoned) and the loop bails as soon as that attempt reports buffer loss. + let final_attempts = Arc::new(Mutex::new(0usize)); + let final_attempts_cb = final_attempts.clone(); + let service_override = CallbackBasedGrpcService { + callback: Arc::new(move |request| { + let final_attempts = final_attempts_cb.clone(); + Box::pin(async move { + match request.rpc.as_str() { + "GetSystemInfo" => Ok(GrpcSuccessResponse { + headers: Default::default(), + proto: GetSystemInfoResponse { + capabilities: Some(Capabilities::default()), + ..Default::default() + } + .encode_to_vec(), + }), + "RespondWorkflowTaskCompleted" => { + let page = + RespondWorkflowTaskCompletedRequest::decode(request.proto) + .expect("completion request is valid"); + if page.intermediate_page { + Ok(GrpcSuccessResponse { + headers: Default::default(), + proto: RespondWorkflowTaskCompletedResponse::default() + .encode_to_vec(), + }) + } else { + *final_attempts.lock().unwrap() += 1; + Err(status_with_detail( + &WorkflowTaskCompletionBufferLostFailure {}, + )) + } + } + rpc => panic!("unexpected RPC: {rpc}"), + } + }) + }), + }; + let connection = Connection::connect( + ConnectionOptions::new(url::Url::parse("http://localhost:7233").unwrap()) + .service_override(service_override) + .dns_load_balancing(None) + .build(), + ) + .await + .unwrap(); + let client = WorkerClientBag::new( + SharedReplaceableClient::new(connection), + "namespace".to_string(), + WorkerVersioningStrategy::LegacyBuildIdBased { + build_id: "test-build".to_string(), + }, + Uuid::new_v4(), + ); + + let shutdown_token = CancellationToken::new(); + shutdown_token.cancel(); + let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); + let completion = WorkflowTaskCompletion { + task_token: TaskToken(b"shared-token".to_vec()), + commands, + messages: vec![], + sticky_attributes: None, + query_responses: vec![], + return_new_workflow_task: false, + force_create_new_workflow_task: false, + sdk_metadata: Default::default(), + metering_metadata: Default::default(), + versioning_behavior: VersioningBehavior::Unspecified, + pagination_enabled: true, + wft_completion_size_limit: None, + }; + + let err = tokio::time::timeout( + Duration::from_secs(10), + client.complete_workflow_task(completion, shutdown_token), + ) + .await + .expect("shutdown ends the resend loop instead of waiting for the server timeout") + .expect_err("the last buffer-loss error is surfaced"); + assert!(is_workflow_task_completion_buffer_lost(&err)); + assert_eq!( + *final_attempts.lock().unwrap(), + 1, + "the first attempt still sends; shutdown prevents any resend" + ); + } + + #[tokio::test] + async fn cancelled_shutdown_does_not_interrupt_successful_completion() { + // The shutdown token is only consulted while resending after buffer loss. A completion + // that never hits buffer loss must still succeed even when the worker is shutting down, + // so graceful drain can finish outstanding completions rather than abandon them. + let final_pages = Arc::new(Mutex::new(0usize)); + let final_pages_cb = final_pages.clone(); + let service_override = CallbackBasedGrpcService { + callback: Arc::new(move |request| { + let final_pages = final_pages_cb.clone(); + Box::pin(async move { + let proto = match request.rpc.as_str() { + "GetSystemInfo" => GetSystemInfoResponse { + capabilities: Some(Capabilities::default()), + ..Default::default() + } + .encode_to_vec(), + "RespondWorkflowTaskCompleted" => { + let page = + RespondWorkflowTaskCompletedRequest::decode(request.proto) + .expect("completion request is valid"); + if !page.intermediate_page { + *final_pages.lock().unwrap() += 1; + } + RespondWorkflowTaskCompletedResponse::default().encode_to_vec() + } + rpc => panic!("unexpected RPC: {rpc}"), + }; + Ok(GrpcSuccessResponse { + headers: Default::default(), + proto, + }) + }) + }), + }; + let connection = Connection::connect( + ConnectionOptions::new(url::Url::parse("http://localhost:7233").unwrap()) + .service_override(service_override) + .dns_load_balancing(None) + .build(), + ) + .await + .unwrap(); + let client = WorkerClientBag::new( + SharedReplaceableClient::new(connection), + "namespace".to_string(), + WorkerVersioningStrategy::LegacyBuildIdBased { + build_id: "test-build".to_string(), + }, + Uuid::new_v4(), + ); + + let shutdown_token = CancellationToken::new(); + shutdown_token.cancel(); + let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); + let completion = WorkflowTaskCompletion { + task_token: TaskToken(b"shared-token".to_vec()), + commands, + messages: vec![], + sticky_attributes: None, + query_responses: vec![], + return_new_workflow_task: false, + force_create_new_workflow_task: false, + sdk_metadata: Default::default(), + metering_metadata: Default::default(), + versioning_behavior: VersioningBehavior::Unspecified, + pagination_enabled: true, + wft_completion_size_limit: None, + }; + + client + .complete_workflow_task(completion, shutdown_token) + .await + .expect("a completion without buffer loss succeeds despite shutdown"); + // The paginated path ran to its final page rather than being cut short. + assert_eq!(*final_pages.lock().unwrap(), 1); + } + } } diff --git a/crates/sdk-core/src/worker/client/mocks.rs b/crates/sdk-core/src/worker/client/mocks.rs index 4d2efcfe0..421662899 100644 --- a/crates/sdk-core/src/worker/client/mocks.rs +++ b/crates/sdk-core/src/worker/client/mocks.rs @@ -87,6 +87,7 @@ mockall::mock! { fn complete_workflow_task<'a, 'b>( &self, request: WorkflowTaskCompletion, + shutdown_token: CancellationToken, ) -> impl Future> + Send + 'b where 'a: 'b, Self: 'b; diff --git a/crates/sdk-core/src/worker/mod.rs b/crates/sdk-core/src/worker/mod.rs index ac57070ad..5efc10f9f 100644 --- a/crates/sdk-core/src/worker/mod.rs +++ b/crates/sdk-core/src/worker/mod.rs @@ -528,6 +528,25 @@ impl NamespaceCapabilities { self.capabilities() .is_some_and(|capabilities| capabilities.worker_commands) } + + /// Returns true if the namespace accepts paginated `RespondWorkflowTaskCompleted` requests, so + /// large completions may be split across multiple page requests sharing one task token. + pub fn workflow_task_completion_pagination(&self) -> bool { + self.capabilities() + .is_some_and(|capabilities| capabilities.workflow_task_completion_pagination) + } + + /// The namespace's limit on the recombined size of a paginated workflow task completion, if one + /// is configured. `None` when unset (the server advertises `0` for no explicit limit). + pub fn workflow_task_completion_size_limit(&self) -> Option { + self.description + .get() + .and_then(|description| description.namespace_info.as_ref()) + .and_then(|namespace_info| namespace_info.limits.as_ref()) + .map(|limits| limits.workflow_task_completion_size_limit_error) + .filter(|limit| *limit > 0) + .map(|limit| limit as usize) + } } /// Resolve the effective poller behavior. When no behavior was configured (`None`), pollers are @@ -977,7 +996,7 @@ impl Worker { shutdown_token.child_token(), Some(move |np| np_metrics.record_num_pollers(np)), nexus_last_suc_poll_time, - capabilities, + capabilities.clone(), shared_namespace_worker, )) as BoxedNexusPoller) } else { @@ -1077,6 +1096,7 @@ impl Worker { shutdown_token: shutdown_token.child_token(), metrics, server_capabilities: client.capabilities().unwrap_or_default(), + namespace_capabilities: capabilities.clone(), sdk_name: sdk_name_and_ver.0, sdk_version: sdk_name_and_ver.1, default_versioning_behavior: config diff --git a/crates/sdk-core/src/worker/workflow/mod.rs b/crates/sdk-core/src/worker/workflow/mod.rs index dc0380d44..b0da392a3 100644 --- a/crates/sdk-core/src/worker/workflow/mod.rs +++ b/crates/sdk-core/src/worker/workflow/mod.rs @@ -29,9 +29,10 @@ use crate::{ }, worker::{ ActivitySlotKind, CompleteWfError, LocalActRequest, LocalActivityExecutionResult, - LocalActivityResolution, PollError, PostActivateHookData, WorkflowSlotKind, + LocalActivityResolution, NamespaceCapabilities, PollError, PostActivateHookData, + WorkflowSlotKind, activities::{ActivitiesFromWFTsHandle, LocalActivityManager}, - client::{LegacyQueryResult, WorkerClient, WorkflowTaskCompletion}, + client::{LegacyQueryResult, REQUEST_TOO_LARGE_KEY, WorkerClient, WorkflowTaskCompletion}, workflow::{ history_update::HistoryPaginator, machines::MachineError, @@ -139,6 +140,8 @@ pub(crate) struct Workflows { local_act_mgr: Option>, ever_polled: AtomicBool, default_versioning_behavior: Option, + namespace_capabilities: Arc, + shutdown_token: CancellationToken, } pub(crate) struct WorkflowBasics { @@ -146,6 +149,7 @@ pub(crate) struct WorkflowBasics { pub(crate) shutdown_token: CancellationToken, pub(crate) metrics: MetricsContext, pub(crate) server_capabilities: get_system_info_response::Capabilities, + pub(crate) namespace_capabilities: Arc, pub(crate) sdk_name: String, pub(crate) sdk_version: String, pub(crate) default_versioning_behavior: Option, @@ -185,6 +189,8 @@ impl Workflows { .worker_config .max_eager_activity_reservations_per_workflow_task; let default_versioning_behavior = basics.default_versioning_behavior; + let namespace_capabilities = basics.namespace_capabilities.clone(); + let shutdown_token = basics.shutdown_token.clone(); let extracted_wft_stream = WFTExtractor::build( client.clone(), basics.worker_config.fetching_concurrency, @@ -276,6 +282,8 @@ impl Workflows { local_act_mgr, ever_polled: AtomicBool::new(false), default_versioning_behavior, + namespace_capabilities, + shutdown_token, } } @@ -407,6 +415,12 @@ impl Workflows { nonfirst_local_activity_execution_attempts, }, versioning_behavior, + pagination_enabled: self + .namespace_capabilities + .workflow_task_completion_pagination(), + wft_completion_size_limit: self + .namespace_capabilities + .workflow_task_completion_size_limit(), }; let sticky_attrs = self.sticky_attrs.clone(); // Do not return new WFT if we would not cache, because returned new WFTs are @@ -418,7 +432,11 @@ impl Workflows { let mut reset_last_started_to = None; self.handle_wft_reporting_errs(run_id, || async { - match self.client.complete_workflow_task(completion).await { + match self + .client + .complete_workflow_task(completion, self.shutdown_token.clone()) + .await + { Ok(response) => { if let Some(record) = maybe_record_terminal_metric.take() { record(&run_metrics); @@ -437,7 +455,17 @@ impl Workflows { Err(e) => { let cause_reason_failure = if e .metadata() - .contains_key(MESSAGE_TOO_LARGE_KEY) + .contains_key(REQUEST_TOO_LARGE_KEY) + && attempt < 2 + { + // Completion exceeds the namespace's recombined size limit, so the + // worker failed it proactively rather than sending doomed pages. + Some(( + WorkflowTaskFailedCause::RequestTooLarge, + FailureReason::RequestTooLarge, + make_request_too_large_failure(), + )) + } else if e.metadata().contains_key(MESSAGE_TOO_LARGE_KEY) && attempt < 2 { // gRPC message too large from server; skip on nonfirst attempts to @@ -1853,6 +1881,25 @@ fn make_grpc_message_too_large_failure() -> Failure { } } +fn make_request_too_large_failure() -> Failure { + Failure { + failure: Some( + temporalio_common::protos::temporal::api::failure::v1::Failure { + message: "Workflow task completion exceeds the namespace size limit".to_string(), + failure_info: Some(FailureInfo::ApplicationFailureInfo( + ApplicationFailureInfo { + r#type: "RequestTooLarge".to_string(), + non_retryable: true, + ..Default::default() + }, + )), + ..Default::default() + }, + ), + force_cause: WorkflowTaskFailedCause::RequestTooLarge as i32, + } +} + fn make_payloads_too_large_failure(violation: &PayloadLimitViolation) -> Failure { Failure { failure: Some( diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index 86147964e..99cec6c2d 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -1288,6 +1288,10 @@ pub(crate) fn integ_dev_server_config( "--dynamic-config-value".to_owned(), "system.enableCancelActivityWorkerCommand=true".to_owned(), "--dynamic-config-value".to_owned(), + "history.enableWorkflowTaskCompletionPagination=true".to_owned(), + "--dynamic-config-value".to_owned(), + "system.transactionSizeLimit=33554432".to_owned(), + "--dynamic-config-value".to_owned(), "matching.rps=12000".to_owned(), "--search-attribute".to_string(), format!("{SEARCH_ATTR_TXT}=Text"), diff --git a/crates/sdk-core/tests/integ_tests/worker_tests.rs b/crates/sdk-core/tests/integ_tests/worker_tests.rs index 39dd07609..a93406243 100644 --- a/crates/sdk-core/tests/integ_tests/worker_tests.rs +++ b/crates/sdk-core/tests/integ_tests/worker_tests.rs @@ -313,6 +313,59 @@ async fn grpc_message_too_large_test() { shared_tests::grpc_message_too_large().await } +#[workflow] +#[derive(Default)] +struct PaginatedCompletionWf; + +#[workflow_methods] +impl PaginatedCompletionWf { + #[run] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + // Schedule many activities in a single workflow task so the completion (~5 MiB across the + // commands) exceeds the per-page limit and must be paginated. Each input is well under the + // per-blob size limit, so it's the aggregate completion size that drives pagination. + let input = "a".repeat(400 * 1024); + let mut futs = vec![]; + for _ in 0..13 { + futs.push(ctx.execute_activity( + StdActivities::echo, + input.clone(), + ActivityOptions::start_to_close_timeout(Duration::from_secs(30)), + )); + } + temporalio_sdk::workflows::join_all(futs).await; + Ok(()) + } +} + +/// A workflow task completion too large for a single gRPC request is split into pages that the +/// server buffers and reassembles; the workflow then completes normally. Local-lane only: it needs +/// the dev server's `history.enableWorkflowTaskCompletionPagination` and a raised +/// `system.transactionSizeLimit`. +#[tokio::test] +async fn workflow_task_completion_pagination_test() { + let wf_name = "wft_completion_pagination"; + let mut starter = CoreWfStarter::new_cloud_or_local(wf_name, "") + .await + .unwrap(); + starter + .sdk_config + .register_workflow::() + .unwrap(); + starter.sdk_config.register_activities(StdActivities); + let mut worker = starter.worker().await; + let handle = worker + .submit_workflow( + PaginatedCompletionWf::run, + (), + starter.workflow_options.clone(), + ) + .await + .unwrap(); + worker.run_until_done().await.unwrap(); + handle.get_result(Default::default()).await.unwrap(); +} + // Serializes to between the default blob error limit (2 MiB) and the gRPC transport limit (4 MiB). const OVERSIZE_PAYLOAD_BYTES: usize = 3 * 1024 * 1024; From 02f96015be4fda3df951b064e7f13dddd560cf3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:06:57 -0400 Subject: [PATCH 29/87] build(deps): update base64 requirement from 0.22 to 0.23 (#1525) Updates the requirements on [base64](https://github.com/marshallpierce/rust-base64) to permit the latest version. - [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md) - [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.22.0...v0.23.1) --- updated-dependencies: - dependency-name: base64 dependency-version: 0.23.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- crates/client/Cargo.toml | 2 +- crates/protos/Cargo.toml | 2 +- crates/sdk-core-c-bridge/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 8773620f9..6ae5d4c14 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -24,7 +24,7 @@ dynamic-tls = ["dep:rustls-native-certs"] anyhow = "1.0" async-trait = "0.1" backon = { version = "1.6", default-features = false } -base64 = "0.22" +base64 = "0.23" bon = { version = "3", default-features = false, features = ["alloc"] } derive_more = { workspace = true } dyn-clone = "1.0" diff --git a/crates/protos/Cargo.toml b/crates/protos/Cargo.toml index ec7b15bed..7974f7ed1 100644 --- a/crates/protos/Cargo.toml +++ b/crates/protos/Cargo.toml @@ -19,7 +19,7 @@ grpc-clients = ["tonic/channel"] [dependencies] anyhow = "1.0" -base64 = "0.22" +base64 = "0.23" derive_more = { workspace = true } http = "1" prost = { workspace = true } diff --git a/crates/sdk-core-c-bridge/Cargo.toml b/crates/sdk-core-c-bridge/Cargo.toml index b8e694e14..7ebfed98b 100644 --- a/crates/sdk-core-c-bridge/Cargo.toml +++ b/crates/sdk-core-c-bridge/Cargo.toml @@ -54,7 +54,7 @@ version = "0.7" features = ["core-based-sdk", "otel"] [dev-dependencies] -base64 = "0.22" +base64 = "0.23" futures-util = "0.3" thiserror = { workspace = true } From feb722f564506e80251ed0a0ad6f2d7d058eac90 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Thu, 27 Aug 2026 09:22:28 -0400 Subject: [PATCH 30/87] fix(sdk): mark remaining public types with `non_exhaustive` (#1536) * chore(sdk): mark incoming error as non_exhaustive * chore(sdk/envconfig): add new SDK owned envconfig structs instead of reexporting from core * chore(sdk): mark schedule aspects with nonexhaustive * chore(sdk): remove legacy ActExtiValue * chore(sdk): move FailOnNondeterminismInterceptor to tests * chore(sdk): non_exhaust data converter types * chore(sdk): mark SerializationContext as non_exhaustive * chore(sdk): make TaskToken bytes private * chore(sdk): make ActivityCloseTimeouts non exhaustive * chore(sdk): mark workflow execution info with non_exhaustive * chore(sdk): WorkerCallbacks is nonexhaustive * chore(sdk): make Priority and WorkflowDeploymentVersion nonexhaustive * changelog fixups * chore(sdk): mark envconfig as non_exhaustive * fix lint * fix docs * . --- CHANGELOG.md | 15 ++ crates/client/src/async_activity_handle.rs | 15 +- crates/client/src/envconfig.rs | 122 +++++++------ crates/client/src/grpc.rs | 10 +- crates/client/src/lib.rs | 10 +- crates/client/src/options_structs.rs | 12 +- crates/client/src/schedules.rs | 2 + crates/client/src/worker.rs | 69 +++++--- crates/client/src/workflow_handle.rs | 148 ++++++++-------- crates/common-wasm/src/data_converters.rs | 81 +++------ .../src/data_converters/failure_converter.rs | 27 +-- crates/common-wasm/src/error.rs | 14 +- crates/common-wasm/src/lib.rs | 15 +- crates/common-wasm/src/memo.rs | 23 +-- crates/common-wasm/src/priority.rs | 4 +- crates/common-wasm/src/search_attributes.rs | 10 +- crates/common-wasm/src/worker.rs | 4 +- crates/common/src/envconfig.rs | 19 +- crates/common/src/worker.rs | 10 +- crates/macros/src/workflow_definitions.rs | 6 +- crates/protos/src/protos/task_token.rs | 7 +- crates/sdk-core-c-bridge/src/envconfig.rs | 59 ++++--- crates/sdk-core-c-bridge/src/worker.rs | 8 +- crates/sdk-core/CHANGELOG.md | 14 ++ .../sdk-core/src/core_tests/activity_tasks.rs | 3 +- .../sdk-core/src/core_tests/workflow_tasks.rs | 2 +- crates/sdk-core/src/histfetch.rs | 13 +- crates/sdk-core/src/protosext/mod.rs | 2 +- .../sdk-core/src/test_help/integ_helpers.rs | 2 +- crates/sdk-core/src/worker/activities.rs | 15 +- .../activities/activity_heartbeat_manager.rs | 2 +- .../src/worker/activities/local_activities.rs | 22 +-- crates/sdk-core/src/worker/client.rs | 38 ++-- crates/sdk-core/src/worker/heartbeat.rs | 28 ++- crates/sdk-core/src/worker/mod.rs | 32 ++-- crates/sdk-core/src/worker/nexus.rs | 6 +- .../workflow/machines/workflow_machines.rs | 8 +- crates/sdk-core/tests/common/mod.rs | 83 +++++---- crates/sdk-core/tests/heavy_tests.rs | 50 +++--- .../tests/integ_tests/metrics_tests.rs | 27 ++- .../tests/integ_tests/polling_tests.rs | 38 ++-- .../tests/integ_tests/queries_tests.rs | 162 +++++++++--------- .../tests/integ_tests/update_tests.rs | 73 ++++---- .../integ_tests/worker_heartbeat_tests.rs | 26 ++- .../integ_tests/worker_versioning_tests.rs | 60 ++++--- .../tests/integ_tests/workflow_tests.rs | 106 ++++++------ .../workflow_tests/local_activities.rs | 34 ++-- .../workflow_tests/modify_wf_properties.rs | 19 +- .../tests/integ_tests/workflow_tests/nexus.rs | 5 +- crates/sdk-core/tests/manual_tests.rs | 65 ++++--- .../sdk-core/tests/shared_tests/priority.rs | 54 +++--- crates/sdk/src/activities.rs | 22 +-- crates/sdk/src/interceptors.rs | 22 +-- crates/sdk/src/lib.rs | 33 +--- crates/sdk/src/testing.rs | 6 +- crates/sdk/src/workflow_registry.rs | 6 +- crates/workflow/src/runtime/entry.rs | 5 +- crates/workflow/src/runtime/instance.rs | 18 +- crates/workflow/src/workflow_context.rs | 72 +++----- .../workflow/src/workflow_context/options.rs | 26 ++- crates/workflow/src/workflow_interceptors.rs | 5 +- 61 files changed, 924 insertions(+), 970 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4b4f193..f79c53e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,21 @@ relevant information. * Workflow task completions larger than the gRPC request size limit are now paginated automatically when the namespace supports it. Paginated workflow task completions require Temporal Server 1.32.0 or later. ### Breaking Changes :boom: +* The following types are now non-exhaustive: `Priority`, `WorkerDeploymentVersion`, + `WorkerCallbacks`, `WorkflowExecutionInfo`, `ActivityCloseTimeouts`, + `ActivityExecutionDecodeHint`, child-workflow and signal decode hints, + `SerializationContext`, `SerializationContextData`, `PayloadConverter`, `IncomingError`, + `ScheduleSpec`, and `ScheduleOverlapPolicy`. Construct structs using their respective builders + or constructors (`WorkerCallbacks::new`, `ActivityExecutionDecodeHint::new`, or + `SerializationContext::new`); use `Default` for `PayloadConverter`; and add wildcard branches + when matching enums. +* Renamed `ActivityCloseTimeouts::Both` to `ActivityCloseTimeouts::ScheduleAndStartToClose`. +* Removed the unused `ActExitValue` type. Use `ActivityError::WillCompleteAsync` to mark an + activity for asynchronous completion. +* Removed the test-only `FailOnNondeterminismInterceptor` from the public Rust SDK API. +* Environment configuration values (`DataSource`, `ClientConfig`, and related profile, TLS, and + codec types) are now non-exhaustive. Use their `bon` builders to construct configuration structs, + and add a wildcard branch when matching `DataSource`. * Values stored in a `MemoValue` must now be `Send + Sync`. It previously held its value in an `Rc` and now uses an `Arc`, so that memos can be built outside a workflow and handed to the client. Only affects memo values that are themselves non-`Send`/non-`Sync`, such as those diff --git a/crates/client/src/async_activity_handle.rs b/crates/client/src/async_activity_handle.rs index 4f7ac257b..17a326c08 100644 --- a/crates/client/src/async_activity_handle.rs +++ b/crates/client/src/async_activity_handle.rs @@ -35,10 +35,8 @@ async fn encode_optional_value( }; let unencoded_payloads = { let payload_converter = data_converter.payload_converter(); - let context = SerializationContext { - data: &SerializationContextData::Activity, - converter: payload_converter, - }; + let context = + SerializationContext::new(&SerializationContextData::Activity, payload_converter); value.serialize_payloads(&context)? }; drop(value); @@ -165,7 +163,7 @@ impl AsyncActivityHandle { let result = encode_optional_value(result, client.data_converter()).await?; if let ActivityIdentifier::TaskToken(token) = identifier { let mut request = RespondActivityTaskCompletedRequest { - task_token: token.0, + task_token: token.into_inner(), result, identity: client.identity(), namespace: client.namespace(), @@ -252,7 +250,7 @@ impl AsyncActivityHandle { encode_optional_value(details, &data_converter).await?; if let ActivityIdentifier::TaskToken(token) = identifier { let mut request = RespondActivityTaskFailedRequest { - task_token: token.0, + task_token: token.into_inner(), failure: Some(failure), identity: client.identity(), namespace: client.namespace(), @@ -323,7 +321,7 @@ impl AsyncActivityHandle { let details = encode_optional_value(details, client.data_converter()).await?; if let ActivityIdentifier::TaskToken(token) = identifier { let mut request = RespondActivityTaskCanceledRequest { - task_token: token.0, + task_token: token.into_inner(), details, identity: client.identity(), namespace: client.namespace(), @@ -391,7 +389,7 @@ impl AsyncActivityHandle { let details = encode_optional_value(details, client.data_converter()).await?; if let ActivityIdentifier::TaskToken(token) = identifier { let mut request = RecordActivityTaskHeartbeatRequest { - task_token: token.0, + task_token: token.into_inner(), details, identity: client.identity(), namespace: client.namespace(), @@ -440,6 +438,7 @@ impl AsyncActivityHandle { /// Response from a heartbeat call. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct ActivityHeartbeatResponse { /// True if the activity has been asked to cancel itself. pub cancel_requested: bool, diff --git a/crates/client/src/envconfig.rs b/crates/client/src/envconfig.rs index c1360bf69..4eebbf8e0 100644 --- a/crates/client/src/envconfig.rs +++ b/crates/client/src/envconfig.rs @@ -117,6 +117,7 @@ impl TryFrom for ConnectionOptions { tls, codec: _, grpc_meta, + .. } = profile; let has_api_key = api_key.is_some(); @@ -147,6 +148,10 @@ fn resolve_datasource(data_source: DataSource) -> Result, std::io::Error match data_source { DataSource::Path(path) => fs::read(path), DataSource::Data(data) => Ok(data), + _ => Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "unsupported envconfig data source", + )), } } @@ -184,21 +189,17 @@ mod tests { #[case] expected: &str, ) { let tls = enable_tls.then(ClientConfigTLS::default); - let profile = ClientConfigProfile { - address: address.map(str::to_string), - tls, - ..Default::default() - }; + let profile = ClientConfigProfile::builder() + .maybe_address(address.map(str::to_string)) + .maybe_tls(tls) + .build(); let conn: ConnectionOptions = profile.try_into().unwrap(); assert_eq!(conn.target.as_str(), expected); } #[test] fn invalid_address_errors() { - let profile = ClientConfigProfile { - address: Some("://bad".to_string()), - ..Default::default() - }; + let profile = ClientConfigProfile::builder().address("://bad").build(); assert!(ConnectionOptions::try_from(profile).is_err()); } @@ -233,20 +234,16 @@ mod tests { let mut meta = HashMap::new(); meta.insert("x-custom".to_string(), "value".to_string()); meta.insert("another".to_string(), "header".to_string()); - let profile = ClientConfigProfile { - grpc_meta: meta.clone(), - ..Default::default() - }; + let profile = ClientConfigProfile::builder() + .grpc_meta(meta.clone()) + .build(); let conn: ConnectionOptions = profile.try_into().unwrap(); assert_eq!(conn.headers.unwrap(), meta); } #[test] fn api_key_populates_field() { - let profile = ClientConfigProfile { - api_key: Some("my-key".to_string()), - ..Default::default() - }; + let profile = ClientConfigProfile::builder().api_key("my-key").build(); let conn: ConnectionOptions = profile.try_into().unwrap(); assert_eq!(conn.api_key.as_deref(), Some("my-key")); } @@ -264,28 +261,27 @@ mod tests { #[case] api_key: Option<&str>, #[case] expect_tls: bool, ) { - let profile = ClientConfigProfile { - api_key: api_key.map(str::to_string), - tls: tls_disabled.map(|disabled| ClientConfigTLS { - disabled, - ..Default::default() - }), - ..Default::default() - }; + let profile = ClientConfigProfile::builder() + .maybe_api_key(api_key.map(str::to_string)) + .maybe_tls( + tls_disabled + .map(|disabled| ClientConfigTLS::builder().maybe_disabled(disabled).build()), + ) + .build(); let conn: ConnectionOptions = profile.try_into().unwrap(); assert_eq!(conn.tls_options.is_some(), expect_tls); } #[test] fn data_source_certs() { - let profile = ClientConfigProfile { - tls: Some(ClientConfigTLS { - client_cert: Some(DataSource::Data(b"cert-data".to_vec())), - client_key: Some(DataSource::Data(b"key-data".to_vec())), - ..Default::default() - }), - ..Default::default() - }; + let profile = ClientConfigProfile::builder() + .tls( + ClientConfigTLS::builder() + .client_cert(DataSource::Data(b"cert-data".to_vec())) + .client_key(DataSource::Data(b"key-data".to_vec())) + .build(), + ) + .build(); let conn: ConnectionOptions = profile.try_into().unwrap(); let tls = conn.tls_options.unwrap(); let mtls = tls.client_tls_options.unwrap(); @@ -300,14 +296,14 @@ mod tests { std::fs::write(&cert_path, b"file-cert").unwrap(); std::fs::write(&key_path, b"file-key").unwrap(); - let profile = ClientConfigProfile { - tls: Some(ClientConfigTLS { - client_cert: Some(DataSource::Path(cert_path.to_str().unwrap().to_string())), - client_key: Some(DataSource::Path(key_path.to_str().unwrap().to_string())), - ..Default::default() - }), - ..Default::default() - }; + let profile = ClientConfigProfile::builder() + .tls( + ClientConfigTLS::builder() + .client_cert(DataSource::Path(cert_path.to_str().unwrap().to_string())) + .client_key(DataSource::Path(key_path.to_str().unwrap().to_string())) + .build(), + ) + .build(); let conn: ConnectionOptions = profile.try_into().unwrap(); let tls = conn.tls_options.unwrap(); let mtls = tls.client_tls_options.unwrap(); @@ -317,13 +313,13 @@ mod tests { #[test] fn server_ca_cert() { - let profile = ClientConfigProfile { - tls: Some(ClientConfigTLS { - server_ca_cert: Some(DataSource::Data(b"ca-data".to_vec())), - ..Default::default() - }), - ..Default::default() - }; + let profile = ClientConfigProfile::builder() + .tls( + ClientConfigTLS::builder() + .server_ca_cert(DataSource::Data(b"ca-data".to_vec())) + .build(), + ) + .build(); let conn: ConnectionOptions = profile.try_into().unwrap(); let tls = conn.tls_options.unwrap(); assert_eq!(tls.server_root_ca_cert.unwrap(), b"ca-data"); @@ -331,13 +327,13 @@ mod tests { #[test] fn server_name_sni() { - let profile = ClientConfigProfile { - tls: Some(ClientConfigTLS { - server_name: Some("my.server.com".to_string()), - ..Default::default() - }), - ..Default::default() - }; + let profile = ClientConfigProfile::builder() + .tls( + ClientConfigTLS::builder() + .server_name("my.server.com") + .build(), + ) + .build(); let conn: ConnectionOptions = profile.try_into().unwrap(); let tls = conn.tls_options.unwrap(); assert_eq!(tls.domain.as_deref(), Some("my.server.com")); @@ -350,14 +346,14 @@ mod tests { #[case] client_cert: Option, #[case] client_key: Option, ) { - let profile = ClientConfigProfile { - tls: Some(ClientConfigTLS { - client_cert, - client_key, - ..Default::default() - }), - ..Default::default() - }; + let profile = ClientConfigProfile::builder() + .tls( + ClientConfigTLS::builder() + .maybe_client_cert(client_cert) + .maybe_client_key(client_key) + .build(), + ) + .build(); assert!(ConnectionOptions::try_from(profile).is_err()); } diff --git a/crates/client/src/grpc.rs b/crates/client/src/grpc.rs index e0506f715..1b9f02a65 100644 --- a/crates/client/src/grpc.rs +++ b/crates/client/src/grpc.rs @@ -2293,10 +2293,12 @@ mod tests { } } - let deployment_opts = WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: "test-deployment".to_string(), - build_id: "test-build-123".to_string(), - }) + let deployment_opts = WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name("test-deployment".to_string()) + .build_id("test-build-123".to_string()) + .build(), + ) .use_worker_versioning(use_worker_versioning) .build(); diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index defc88f37..405808fc5 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -1727,10 +1727,7 @@ where let data_converter = client.data_converter().clone(); let unencoded_payloads = { let payload_converter = data_converter.payload_converter(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let context = SerializationContext::new(&SerializationContextData::Workflow, payload_converter); args.serialize_payloads(&context) }; drop(args); @@ -1875,10 +1872,7 @@ where ) = input.into_parts(); let data_converter = client.data_converter().clone(); let payload_converter = data_converter.payload_converter(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let context = SerializationContext::new(&SerializationContextData::Workflow, payload_converter); let workflow_payloads = workflow_args.serialize_payloads(&context); let signal_payloads = signal_args.serialize_payloads(&context); drop(workflow_args); diff --git a/crates/client/src/options_structs.rs b/crates/client/src/options_structs.rs index a67133678..9b4b6716c 100644 --- a/crates/client/src/options_structs.rs +++ b/crates/client/src/options_structs.rs @@ -466,10 +466,8 @@ impl WorkflowStartOptions { }; let payload_converter = data_converter.payload_converter(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let context = + SerializationContext::new(&SerializationContextData::Workflow, payload_converter); let mut memo = ProtoMemo { fields: memo .iter() @@ -492,10 +490,8 @@ impl WorkflowStartOptions { pub(crate) fn user_metadata(&self) -> Option { (self.static_summary.is_some() || self.static_details.is_some()).then(|| { let payload_converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; + let context = + SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); UserMetadata { summary: self.static_summary.as_ref().map(|summary| { payload_converter diff --git a/crates/client/src/schedules.rs b/crates/client/src/schedules.rs index 819f161f4..de2ca2f39 100644 --- a/crates/client/src/schedules.rs +++ b/crates/client/src/schedules.rs @@ -214,6 +214,7 @@ impl ScheduleAction { /// set here will use their proto defaults on the server. #[derive(Debug, Clone, Default, PartialEq, bon::Builder)] #[builder(on(String, into))] +#[non_exhaustive] pub struct ScheduleSpec { /// Interval-based triggers (e.g., every 1 hour). #[builder(default)] @@ -766,6 +767,7 @@ impl ScheduleDescription { /// Controls what happens when a scheduled workflow would overlap with a running one. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] pub enum ScheduleOverlapPolicy { /// Use the server default (currently Skip). #[default] diff --git a/crates/client/src/worker.rs b/crates/client/src/worker.rs index 5cd2f06fb..87b32180f 100644 --- a/crates/client/src/worker.rs +++ b/crates/client/src/worker.rs @@ -230,11 +230,11 @@ impl ClientWorkerSetImpl { }; shared_worker.register_callback( worker_instance_key, - WorkerCallbacks { - heartbeat: heartbeat_callback, - heartbeat_success: worker.heartbeat_success_callback(), - cancel_activity: worker.cancel_activity_callback(), - }, + WorkerCallbacks::new( + heartbeat_callback, + worker.heartbeat_success_callback(), + worker.cancel_activity_callback(), + ), ); } @@ -511,6 +511,7 @@ pub type HeartbeatSuccessCallback = Arc; pub type CancelActivityCallback = Arc bool + Send + Sync>; /// Bundles all per-worker callbacks registered with the SharedNamespaceWorker. +#[non_exhaustive] pub struct WorkerCallbacks { /// Callback to collect heartbeat data from the worker. pub heartbeat: HeartbeatCallback, @@ -520,6 +521,21 @@ pub struct WorkerCallbacks { pub cancel_activity: Option, } +impl WorkerCallbacks { + /// Creates a callback bundle for a worker. + pub fn new( + heartbeat: HeartbeatCallback, + heartbeat_success: Option, + cancel_activity: Option, + ) -> Self { + Self { + heartbeat, + heartbeat_success, + cancel_activity, + } + } +} + /// Represents a complete worker that can handle both slot management /// and worker heartbeat functionality. #[cfg_attr(test, mockall::automock)] @@ -688,10 +704,12 @@ mod tests { .expect_task_queue() .return_const(task_queue.clone()); failing_worker.expect_deployment_options().return_const( - WorkerDeploymentOptions::new(temporalio_common::worker::WorkerDeploymentVersion { - deployment_name: "test-deployment".to_string(), - build_id: "build-fail".to_string(), - }) + WorkerDeploymentOptions::new( + temporalio_common::worker::WorkerDeploymentVersion::builder() + .deployment_name("test-deployment".to_string()) + .build_id("build-fail".to_string()) + .build(), + ) .use_worker_versioning(true) .build(), ); @@ -722,13 +740,14 @@ mod tests { succeeding_worker .expect_task_queue() .return_const(task_queue.clone()); - let success_deployment_options = - WorkerDeploymentOptions::new(temporalio_common::worker::WorkerDeploymentVersion { - deployment_name: "test-deployment".to_string(), - build_id: "build-success".to_string(), - }) - .use_worker_versioning(true) - .build(); + let success_deployment_options = WorkerDeploymentOptions::new( + temporalio_common::worker::WorkerDeploymentVersion::builder() + .deployment_name("test-deployment".to_string()) + .build_id("build-success".to_string()) + .build(), + ) + .use_worker_versioning(true) + .build(); succeeding_worker .expect_deployment_options() .return_const(success_deployment_options.clone()); @@ -785,10 +804,12 @@ mod tests { .expect_task_queue() .return_const(task_queue.clone()); failing_worker.expect_deployment_options().return_const( - WorkerDeploymentOptions::new(temporalio_common::worker::WorkerDeploymentVersion { - deployment_name: "test-deployment".to_string(), - build_id: "build-fail".to_string(), - }) + WorkerDeploymentOptions::new( + temporalio_common::worker::WorkerDeploymentVersion::builder() + .deployment_name("test-deployment".to_string()) + .build_id("build-fail".to_string()) + .build(), + ) .use_worker_versioning(true) .build(), ); @@ -996,10 +1017,10 @@ mod tests { .returning(move || { build_id_for_closure.as_ref().map(|build_id| { WorkerDeploymentOptions::new( - temporalio_common::worker::WorkerDeploymentVersion { - deployment_name: deployment_name.clone(), - build_id: build_id.clone(), - }, + temporalio_common::worker::WorkerDeploymentVersion::builder() + .deployment_name(deployment_name.clone()) + .build_id(build_id.clone()) + .build(), ) .use_worker_versioning(true) .build() diff --git a/crates/client/src/workflow_handle.rs b/crates/client/src/workflow_handle.rs index e561ba367..90df4da61 100644 --- a/crates/client/src/workflow_handle.rs +++ b/crates/client/src/workflow_handle.rs @@ -63,10 +63,7 @@ fn decode_user_metadata( user_metadata: Option, ) -> Result { let payload_converter = PayloadConverter::default(); - let context = SerializationContext { - data: context, - converter: &payload_converter, - }; + let context = SerializationContext::new(context, &payload_converter); let (summary, details) = user_metadata .map(|metadata| (metadata.summary, metadata.details)) .unwrap_or_default(); @@ -415,7 +412,9 @@ impl WorkflowHandle { } /// Holds needed information to refer to a specific workflow run, or workflow execution chain -#[derive(Debug, Clone)] +#[derive(Debug, Clone, bon::Builder)] +#[builder(on(String, into), state_mod(vis = "pub"))] +#[non_exhaustive] pub struct WorkflowExecutionInfo { /// Namespace the workflow lives in. pub namespace: String, @@ -715,10 +714,7 @@ where let data_converter = client.data_converter().clone(); let unencoded_payloads = { let payload_converter = data_converter.payload_converter(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let context = SerializationContext::new(&SerializationContextData::Workflow, payload_converter); args.serialize_payloads(&context) }; drop(args); @@ -786,10 +782,7 @@ where let data_converter = client.data_converter().clone(); let unencoded_payloads = { let payload_converter = data_converter.payload_converter(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let context = SerializationContext::new(&SerializationContextData::Workflow, payload_converter); args.serialize_payloads(&context) }; drop(args); @@ -900,75 +893,74 @@ where Next::new({ let mut client = self.client.clone(); move |input: StartWorkflowUpdateInput| -> BoxFuture< - '_, - Result, - > { - Box::pin(async move { - let (workflow_id, run_id, update_name, args, options) = input.into_parts(); - let data_converter = client.data_converter().clone(); - let unencoded_payloads = { - let payload_converter = data_converter.payload_converter(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, + '_, + Result, + > { + Box::pin(async move { + let (workflow_id, run_id, update_name, args, options) = + input.into_parts(); + let data_converter = client.data_converter().clone(); + let unencoded_payloads = { + let payload_converter = data_converter.payload_converter(); + let context = SerializationContext::new( + &SerializationContextData::Workflow, + payload_converter, + ); + args.serialize_payloads(&context) }; - args.serialize_payloads(&context) - }; - drop(args); - let payloads = data_converter - .codec() - .encode(&SerializationContextData::Workflow, unencoded_payloads?) - .await?; - let update_id = options - .update_id - .unwrap_or_else(|| Uuid::new_v4().to_string()); - let mut request = UpdateWorkflowExecutionRequest { - namespace: client.namespace(), - workflow_execution: Some(ProtoWorkflowExecution { - workflow_id: workflow_id.clone(), - run_id, - }), - wait_policy: Some(WaitPolicy { - lifecycle_stage: - UpdateWorkflowExecutionLifecycleStage::Accepted.into(), - }), - request: Some(update::v1::Request { - meta: Some(update::v1::Meta { - update_id: update_id.clone(), - identity: client.identity(), + drop(args); + let payloads = data_converter + .codec() + .encode(&SerializationContextData::Workflow, unencoded_payloads?) + .await?; + let update_id = options + .update_id + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let mut request = UpdateWorkflowExecutionRequest { + namespace: client.namespace(), + workflow_execution: Some(ProtoWorkflowExecution { + workflow_id: workflow_id.clone(), + run_id, + }), + wait_policy: Some(WaitPolicy { + lifecycle_stage: + UpdateWorkflowExecutionLifecycleStage::Accepted.into(), }), - input: Some(update::v1::Input { - header: options.header, - name: update_name, - args: Some(Payloads { payloads }), + request: Some(update::v1::Request { + meta: Some(update::v1::Meta { + update_id: update_id.clone(), + identity: client.identity(), + }), + input: Some(update::v1::Input { + header: options.header, + name: update_name, + args: Some(Payloads { payloads }), + }), + ..Default::default() }), ..Default::default() - }), - ..Default::default() - } - .into_request(); - options.rpc_options.apply_to(&mut request); - let response = WorkflowService::update_workflow_execution( - &mut client, - request, - ) - .await - .map_err(WorkflowUpdateError::from_status)? - .into_inner(); - let run_id = response - .update_ref - .as_ref() - .and_then(|reference| reference.workflow_execution.as_ref()) - .map(|execution| execution.run_id.clone()) - .filter(|run_id| !run_id.is_empty()); - Ok(StartWorkflowUpdateOutput::new( - update_id, - workflow_id, - run_id, - response.outcome, - )) - }) - } + } + .into_request(); + options.rpc_options.apply_to(&mut request); + let response = + WorkflowService::update_workflow_execution(&mut client, request) + .await + .map_err(WorkflowUpdateError::from_status)? + .into_inner(); + let run_id = response + .update_ref + .as_ref() + .and_then(|reference| reference.workflow_execution.as_ref()) + .map(|execution| execution.run_id.clone()) + .filter(|run_id| !run_id.is_empty()); + Ok(StartWorkflowUpdateOutput::new( + update_id, + workflow_id, + run_id, + response.outcome, + )) + }) + } }), ) .await?; diff --git a/crates/common-wasm/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs index e93735c08..65375b07d 100644 --- a/crates/common-wasm/src/data_converters.rs +++ b/crates/common-wasm/src/data_converters.rs @@ -51,10 +51,7 @@ impl DataConverter { data: &SerializationContextData, val: &T, ) -> Result { - let context = SerializationContext { - data, - converter: &self.payload_converter, - }; + let context = SerializationContext::new(data, &self.payload_converter); let payload = self.payload_converter.to_payload(&context, val)?; let encoded = self.codec.encode(data, vec![payload]).await?; encoded @@ -69,10 +66,7 @@ impl DataConverter { data: &SerializationContextData, payload: Payload, ) -> Result { - let context = SerializationContext { - data, - converter: &self.payload_converter, - }; + let context = SerializationContext::new(data, &self.payload_converter); let decoded = self.codec.decode(data, vec![payload]).await?; let payload = decoded .into_iter() @@ -87,10 +81,7 @@ impl DataConverter { data: &SerializationContextData, val: &T, ) -> Result, PayloadConversionError> { - let context = SerializationContext { - data, - converter: &self.payload_converter, - }; + let context = SerializationContext::new(data, &self.payload_converter); let payloads = self.payload_converter.to_payloads(&context, val)?; self.codec.encode(data, payloads).await } @@ -101,10 +92,7 @@ impl DataConverter { data: &SerializationContextData, payloads: Vec, ) -> Result { - let context = SerializationContext { - data, - converter: &self.payload_converter, - }; + let context = SerializationContext::new(data, &self.payload_converter); let decoded = self.codec.decode(data, payloads).await?; self.payload_converter.from_payloads(&context, decoded) } @@ -150,6 +138,7 @@ impl DataConverter { /// Data about the serialization context, indicating where the serialization is occurring. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum SerializationContextData { /// Serialization is occurring in a workflow context. Workflow, @@ -164,14 +153,24 @@ pub enum SerializationContextData { /// Context for serialization operations, including the kind of context and the /// payload converter for nested serialization. #[derive(Clone, Copy)] +#[non_exhaustive] pub struct SerializationContext<'a> { /// The kind of serialization context (workflow, activity, etc.). pub data: &'a SerializationContextData, /// Allows nested types to serialize their contents using the same converter. pub converter: &'a PayloadConverter, } + +impl<'a> SerializationContext<'a> { + /// Creates a serialization context for the given execution context and payload converter. + pub fn new(data: &'a SerializationContextData, converter: &'a PayloadConverter) -> Self { + Self { data, converter } + } +} + /// Converts values to and from [`Payload`]s using different encoding strategies. #[derive(Clone)] +#[non_exhaustive] pub enum PayloadConverter { /// Uses a serde-based converter for encoding/decoding. Serde(Arc), @@ -355,10 +354,7 @@ impl DecodablePayloads { &self, ) -> Result { self.payload_converter.from_payloads( - &SerializationContext { - data: &self.context, - converter: &self.payload_converter, - }, + &SerializationContext::new(&self.context, &self.payload_converter), self.payloads.clone(), ) } @@ -402,10 +398,7 @@ impl RawValue { RawValue::new(vec![ converter .to_payload( - &SerializationContext { - data: &SerializationContextData::None, - converter, - }, + &SerializationContext::new(&SerializationContextData::None, converter), value, ) .unwrap(), @@ -416,10 +409,7 @@ impl RawValue { pub fn to_value(self, converter: &PayloadConverter) -> T { converter .from_payload( - &SerializationContext { - data: &SerializationContextData::None, - converter, - }, + &SerializationContext::new(&SerializationContextData::None, converter), self.payloads.into_iter().next().unwrap(), ) .unwrap() @@ -822,10 +812,7 @@ mod tests { #[test] fn test_empty_payloads_as_unit_type() { let converter = PayloadConverter::default(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); let empty_payloads: Vec = vec![]; let result: Result<(), _> = converter.from_payloads(&ctx, empty_payloads); @@ -836,10 +823,7 @@ mod tests { #[test] fn test_unit_type_roundtrip_serde() { let converter = PayloadConverter::serde_json(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); let payloads = converter.to_payloads(&ctx, &()).unwrap(); assert!(payloads.is_empty()); @@ -851,10 +835,7 @@ mod tests { #[test] fn test_unit_composite_roundtrip() { let converter = PayloadConverter::default(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); let payloads = converter.to_payloads(&ctx, &()).unwrap(); assert!(payloads.is_empty()); @@ -866,10 +847,7 @@ mod tests { #[test] fn test_unit_to_payload_roundtrip() { let converter = PayloadConverter::default(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); let mut payloads = vec![converter.to_payload(&ctx, &()).unwrap()]; assert!(is_unit_payloads(&payloads)); @@ -882,10 +860,7 @@ mod tests { #[test] fn test_unit_use_wrappers_returns_wrong_encoding() { let converter = PayloadConverter::UseWrappers; - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); let result = converter.to_payloads(&ctx, &()); assert!( @@ -897,10 +872,7 @@ mod tests { #[test] fn multi_args_round_trip() { let converter = PayloadConverter::default(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); let args = MultiArgs2("hello".to_string(), 42i32); let payloads = converter.to_payloads(&ctx, &args).unwrap(); @@ -920,10 +892,7 @@ mod tests { let converter = PayloadConverter::default(); let payloads = converter .to_payloads( - &SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }, + &SerializationContext::new(&SerializationContextData::Workflow, &converter), value, ) .unwrap(); diff --git a/crates/common-wasm/src/data_converters/failure_converter.rs b/crates/common-wasm/src/data_converters/failure_converter.rs index 36a36e80f..c0149c344 100644 --- a/crates/common-wasm/src/data_converters/failure_converter.rs +++ b/crates/common-wasm/src/data_converters/failure_converter.rs @@ -73,11 +73,19 @@ impl FailureDecodeHint for NoopDecodeHint { /// Decode hint for activity execution results. #[derive(Debug, Clone, Copy)] +#[non_exhaustive] pub struct ActivityExecutionDecodeHint { /// Whether the workflow-side resolution was cancelled rather than failed. pub cancelled: bool, } +impl ActivityExecutionDecodeHint { + /// Creates a decode hint for an activity resolution. + pub fn new(cancelled: bool) -> Self { + Self { cancelled } + } +} + impl FailureDecodeHint for ActivityExecutionDecodeHint { type Output = ActivityExecutionError; @@ -114,7 +122,8 @@ impl FailureDecodeHint for ActivityExecutionDecodeHint { } /// Decode hint for child-workflow start results. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] pub struct ChildWorkflowStartDecodeHint; impl FailureDecodeHint for ChildWorkflowStartDecodeHint { @@ -140,7 +149,8 @@ impl FailureDecodeHint for ChildWorkflowStartDecodeHint { } /// Decode hint for child-workflow execution results. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] pub struct ChildWorkflowExecutionDecodeHint; impl FailureDecodeHint for ChildWorkflowExecutionDecodeHint { @@ -161,7 +171,8 @@ impl FailureDecodeHint for ChildWorkflowExecutionDecodeHint { } /// Decode hint for workflow signal failures. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] pub struct WorkflowSignalDecodeHint; impl FailureDecodeHint for WorkflowSignalDecodeHint { @@ -685,10 +696,7 @@ mod tests { let converter = PayloadConverter::default(); let details: String = converter .from_payloads( - &SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }, + &SerializationContext::new(&SerializationContextData::Workflow, &converter), payloads, ) .unwrap(); @@ -718,10 +726,7 @@ mod tests { let converter = PayloadConverter::default(); let payloads = converter .to_payloads( - &SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }, + &SerializationContext::new(&SerializationContextData::Workflow, &converter), &"detail", ) .unwrap(); diff --git a/crates/common-wasm/src/error.rs b/crates/common-wasm/src/error.rs index fe47a923e..3e5a420a6 100644 --- a/crates/common-wasm/src/error.rs +++ b/crates/common-wasm/src/error.rs @@ -123,13 +123,7 @@ where payload_converter: &PayloadConverter, context: &SerializationContextData, ) -> Result, PayloadConversionError> { - payload_converter.to_payloads( - &SerializationContext { - data: context, - converter: payload_converter, - }, - self, - ) + payload_converter.to_payloads(&SerializationContext::new(context, payload_converter), self) } } @@ -522,6 +516,7 @@ impl From for OutgoingWorkflowError { /// A normalized incoming Temporal failure decoded from a protobuf [`Failure`]. #[derive(Debug)] +#[non_exhaustive] pub enum IncomingError { /// A decoded application failure. Application(ApplicationFailure), @@ -1386,10 +1381,7 @@ mod tests { let converter = PayloadConverter::default(); let details: String = converter .from_payloads( - &SerializationContext { - data: &SerializationContextData::None, - converter: &converter, - }, + &SerializationContext::new(&SerializationContextData::None, &converter), payloads, ) .unwrap(); diff --git a/crates/common-wasm/src/lib.rs b/crates/common-wasm/src/lib.rs index 699b35f4b..aec5f97d3 100644 --- a/crates/common-wasm/src/lib.rs +++ b/crates/common-wasm/src/lib.rs @@ -55,6 +55,7 @@ pub(crate) use dbg_panic; /// Activity options. Specifying at least one of them is required, but specifying both is also /// allowed. Note that this type does not cover all available timeout options for an Activity. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum ActivityCloseTimeouts { /// Total time the Activity is allowed to run, including retries. ScheduleToClose(Duration), @@ -66,7 +67,7 @@ pub enum ActivityCloseTimeouts { /// failure detection. StartToClose(Duration), /// Applies both execution-attempt and overall-completion bounds. - Both { + ScheduleAndStartToClose { /// Total time the Activity is allowed to run, including retries. schedule_to_close: Duration, /// Maximum time of a single Activity execution attempt. @@ -75,22 +76,26 @@ pub enum ActivityCloseTimeouts { } impl ActivityCloseTimeouts { - /// Returns value of [`Self::ScheduleToClose`] or [`Self::Both::schedule_to_close`]. + /// Returns value of [`Self::ScheduleToClose`] or + /// [`Self::ScheduleAndStartToClose::schedule_to_close`]. pub fn schedule_to_close(&self) -> Option { match self { ActivityCloseTimeouts::ScheduleToClose(schedule_to_close) - | ActivityCloseTimeouts::Both { + | ActivityCloseTimeouts::ScheduleAndStartToClose { schedule_to_close, .. } => Some(*schedule_to_close), _ => None, } } - /// Returns value of [`Self::StartToClose`] or [`Self::Both::start_to_close`]. + /// Returns value of [`Self::StartToClose`] or + /// [`Self::ScheduleAndStartToClose::start_to_close`]. pub fn start_to_close(&self) -> Option { match self { ActivityCloseTimeouts::StartToClose(start_to_close) - | ActivityCloseTimeouts::Both { start_to_close, .. } => Some(*start_to_close), + | ActivityCloseTimeouts::ScheduleAndStartToClose { start_to_close, .. } => { + Some(*start_to_close) + } _ => None, } } diff --git a/crates/common-wasm/src/memo.rs b/crates/common-wasm/src/memo.rs index 6945153f3..8ee88af51 100644 --- a/crates/common-wasm/src/memo.rs +++ b/crates/common-wasm/src/memo.rs @@ -42,10 +42,7 @@ impl Memo { }; self.payload_converter .from_payload( - &SerializationContext { - data: &self.context, - converter: &self.payload_converter, - }, + &SerializationContext::new(&self.context, &self.payload_converter), payload.clone(), ) .map(Some) @@ -173,10 +170,8 @@ mod tests { #[test] fn memo_decodes_serialized_values() { let payload_converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; + let context = + SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); let payload = payload_converter.to_payload(&context, &7_u32).unwrap(); let raw = ProtoMemo { fields: HashMap::from([("count".to_owned(), payload.clone())]), @@ -196,10 +191,8 @@ mod tests { #[test] fn memo_reports_deserialization_errors() { let payload_converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; + let context = + SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); let payload = payload_converter.to_payload(&context, &7_u32).unwrap(); let memo = Memo::from_raw( Some(ProtoMemo { @@ -220,10 +213,8 @@ mod tests { .insert("count", 7_u32) .insert("label", "hello".to_string()); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; + let context = + SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); let fields = values .iter() .map(|(key, value)| { diff --git a/crates/common-wasm/src/priority.rs b/crates/common-wasm/src/priority.rs index 85a382506..c5da0271d 100644 --- a/crates/common-wasm/src/priority.rs +++ b/crates/common-wasm/src/priority.rs @@ -19,7 +19,9 @@ use crate::protos::temporal::api::common; /// The overall semantics of Priority are: /// (more will be added here later) /// 1. First, consider "priority_key": lower number goes first. -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, bon::Builder)] +#[builder(on(String, into), state_mod(vis = "pub"))] +#[non_exhaustive] pub struct Priority { /// Priority key is a positive integer from 1 to n, where smaller integers /// correspond to higher priorities (tasks run sooner). In general, tasks in diff --git a/crates/common-wasm/src/search_attributes.rs b/crates/common-wasm/src/search_attributes.rs index 78f1b0388..f3136c053 100644 --- a/crates/common-wasm/src/search_attributes.rs +++ b/crates/common-wasm/src/search_attributes.rs @@ -248,10 +248,7 @@ fn encode_json_search_attr( indexed_value_type: IndexedValueType, ) -> Result { let converter = PayloadConverter::serde_json(); - let context = SerializationContext { - data: &SerializationContextData::None, - converter: &converter, - }; + let context = SerializationContext::new(&SerializationContextData::None, &converter); let mut payload = converter.to_payload(&context, value)?; payload.metadata.insert( TYPE_METADATA_KEY.to_string(), @@ -267,10 +264,7 @@ fn decode_json_search_attr( payload: &Payload, ) -> Result { let converter = PayloadConverter::serde_json(); - let context = SerializationContext { - data: &SerializationContextData::None, - converter: &converter, - }; + let context = SerializationContext::new(&SerializationContextData::None, &converter); Ok(converter.from_payload(&context, payload.clone())?) } diff --git a/crates/common-wasm/src/worker.rs b/crates/common-wasm/src/worker.rs index bc133e1d4..c02f79088 100644 --- a/crates/common-wasm/src/worker.rs +++ b/crates/common-wasm/src/worker.rs @@ -4,7 +4,9 @@ use crate::protos::{coresdk, temporal}; use std::str::FromStr; /// Identifies a specific version of a worker deployment. -#[derive(Clone, Debug, Eq, PartialEq, Hash)] +#[derive(Clone, Debug, Eq, PartialEq, Hash, bon::Builder)] +#[builder(on(String, into), state_mod(vis = "pub"))] +#[non_exhaustive] pub struct WorkerDeploymentVersion { /// Name of the deployment pub deployment_name: String, diff --git a/crates/common/src/envconfig.rs b/crates/common/src/envconfig.rs index 1116d017e..9501b8c91 100644 --- a/crates/common/src/envconfig.rs +++ b/crates/common/src/envconfig.rs @@ -101,6 +101,7 @@ impl From for ConfigError { /// A source for configuration or a TLS certificate/key, from a path or raw data. #[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] pub enum DataSource { /// A filesystem path to the data. Path(String), @@ -109,14 +110,18 @@ pub enum DataSource { } /// ClientConfig represents a client config file. -#[derive(Debug, Clone, PartialEq, Default)] +#[derive(Debug, Clone, PartialEq, Default, bon::Builder)] +#[non_exhaustive] pub struct ClientConfig { /// Profiles, keyed by profile name + #[builder(default)] pub profiles: HashMap, } /// ClientConfigProfile is profile-level configuration for a client. -#[derive(Debug, Clone, PartialEq, Default)] +#[derive(Debug, Clone, PartialEq, Default, bon::Builder)] +#[builder(on(String, into))] +#[non_exhaustive] pub struct ClientConfigProfile { /// Client address pub address: Option, @@ -137,11 +142,14 @@ pub struct ClientConfigProfile { /// Client gRPC metadata (aka headers). When loading from TOML and env var, or writing to TOML, the keys are /// lowercased and underscores are replaced with hyphens. This is used for deduplicating/overriding too, so manually /// set values that are not normalized may not get overridden when applying environment variables. + #[builder(default)] pub grpc_meta: HashMap, } /// ClientConfigTLS is TLS configuration for a client. -#[derive(Debug, Clone, PartialEq, Default)] +#[derive(Debug, Clone, PartialEq, Default, bon::Builder)] +#[builder(on(String, into))] +#[non_exhaustive] pub struct ClientConfigTLS { /// If Some(true), TLS is explicitly disabled. If Some(false), TLS is explicitly enabled. /// If None, TLS behavior depends on other factors (API key presence, etc.) @@ -160,11 +168,14 @@ pub struct ClientConfigTLS { pub server_name: Option, /// True if host verification should be skipped + #[builder(default)] pub disable_host_verification: bool, } /// Codec configuration for a client -#[derive(Debug, Clone, PartialEq, Default)] +#[derive(Debug, Clone, PartialEq, Default, bon::Builder)] +#[builder(on(String, into))] +#[non_exhaustive] pub struct ClientConfigCodec { /// Remote endpoint for the codec pub endpoint: Option, diff --git a/crates/common/src/worker.rs b/crates/common/src/worker.rs index 9852053c0..ff9bf26e4 100644 --- a/crates/common/src/worker.rs +++ b/crates/common/src/worker.rs @@ -135,10 +135,12 @@ pub struct WorkerDeploymentOptions { impl WorkerDeploymentOptions { /// Create deployment options from just a build ID, without opting into worker versioning. pub fn from_build_id(build_id: String) -> Self { - Self::new(WorkerDeploymentVersion { - deployment_name: "".to_owned(), - build_id, - }) + Self::new( + WorkerDeploymentVersion::builder() + .deployment_name("") + .build_id(build_id) + .build(), + ) .build() } } diff --git a/crates/macros/src/workflow_definitions.rs b/crates/macros/src/workflow_definitions.rs index f73fc63c6..a4d2254be 100644 --- a/crates/macros/src/workflow_definitions.rs +++ b/crates/macros/src/workflow_definitions.rs @@ -75,10 +75,10 @@ fn generate_decode_arm( ) -> TokenStream2 { quote! { #handler_name => { - let ctx = ::temporalio_workflow::common::data_converters::SerializationContext { - data: &::temporalio_workflow::common::data_converters::SerializationContextData::Workflow, + let ctx = ::temporalio_workflow::common::data_converters::SerializationContext::new( + &::temporalio_workflow::common::data_converters::SerializationContextData::Workflow, converter, - }; + ); let input: #input_type = <::temporalio_workflow::common::data_converters::PayloadConverter as ::temporalio_workflow::common::data_converters::GenericPayloadConverter>::from_payloads( converter, &ctx, diff --git a/crates/protos/src/protos/task_token.rs b/crates/protos/src/protos/task_token.rs index 1b7dc036c..4a2a55fee 100644 --- a/crates/protos/src/protos/task_token.rs +++ b/crates/protos/src/protos/task_token.rs @@ -17,9 +17,14 @@ static LOCAL_ACT_TASK_TOKEN_PREFIX: &[u8] = b"local_act_"; serde::Deserialize, )] /// Type-safe wrapper for task token bytes -pub struct TaskToken(pub Vec); +pub struct TaskToken(Vec); impl TaskToken { + /// Consumes this token and returns its underlying bytes. + pub fn into_inner(self) -> Vec { + self.0 + } + /// Task tokens for local activities are always prefixed with a special sigil so they can /// be identified easily pub fn new_local_activity_token(unique_data: impl IntoIterator) -> Self { diff --git a/crates/sdk-core-c-bridge/src/envconfig.rs b/crates/sdk-core-c-bridge/src/envconfig.rs index 48c611d03..aa52e65fb 100644 --- a/crates/sdk-core-c-bridge/src/envconfig.rs +++ b/crates/sdk-core-c-bridge/src/envconfig.rs @@ -56,11 +56,17 @@ struct ClientEnvConfig { profiles: HashMap, } -impl From for ClientEnvConfig { - fn from(c: CoreClientConfig) -> Self { - Self { - profiles: c.profiles.into_iter().map(|(k, v)| (k, v.into())).collect(), - } +impl TryFrom for ClientEnvConfig { + type Error = String; + + fn try_from(c: CoreClientConfig) -> Result { + Ok(Self { + profiles: c + .profiles + .into_iter() + .map(|(name, profile)| Ok((name, profile.try_into()?))) + .collect::>()?, + }) } } @@ -80,16 +86,18 @@ struct ClientEnvConfigProfile { grpc_meta: HashMap, } -impl From for ClientEnvConfigProfile { - fn from(c: CoreClientConfigProfile) -> Self { - Self { +impl TryFrom for ClientEnvConfigProfile { + type Error = String; + + fn try_from(c: CoreClientConfigProfile) -> Result { + Ok(Self { address: c.address, namespace: c.namespace, api_key: c.api_key, - tls: c.tls.map(Into::into), + tls: c.tls.map(TryInto::try_into).transpose()?, codec: c.codec.map(Into::into), grpc_meta: c.grpc_meta, - } + }) } } @@ -107,15 +115,17 @@ struct ClientEnvConfigTLS { client_key: Option, } -impl From for ClientEnvConfigTLS { - fn from(c: CoreClientConfigTLS) -> Self { - Self { +impl TryFrom for ClientEnvConfigTLS { + type Error = String; + + fn try_from(c: CoreClientConfigTLS) -> Result { + Ok(Self { disabled: c.disabled, server_name: c.server_name, - server_ca_cert: c.server_ca_cert.map(Into::into), - client_cert: c.client_cert.map(Into::into), - client_key: c.client_key.map(Into::into), - } + server_ca_cert: c.server_ca_cert.map(TryInto::try_into).transpose()?, + client_cert: c.client_cert.map(TryInto::try_into).transpose()?, + client_key: c.client_key.map(TryInto::try_into).transpose()?, + }) } } @@ -144,9 +154,11 @@ struct DataSource { data: Option>, } -impl From for DataSource { - fn from(c: CoreDataSource) -> Self { - match c { +impl TryFrom for DataSource { + type Error = String; + + fn try_from(c: CoreDataSource) -> Result { + Ok(match c { CoreDataSource::Path(p) => Self { path: Some(p), data: None, @@ -155,7 +167,8 @@ impl From for DataSource { path: None, data: Some(d), }, - } + _ => return Err("Unsupported envconfig data source".to_string()), + }) } } @@ -227,7 +240,7 @@ pub extern "C" fn temporal_core_client_env_config_load( let core_config = envconfig::load_client_config(load_options, env_vars_map.as_ref()) .map_err(|e| e.to_string())?; - Ok(core_config.into()) + core_config.try_into() }; match result() { @@ -289,7 +302,7 @@ pub extern "C" fn temporal_core_client_env_config_profile_load( let profile = envconfig::load_client_config_profile(load_options, env_vars_map.as_ref()) .map_err(|e| e.to_string())?; - Ok(profile.into()) + profile.try_into() }; match result() { diff --git a/crates/sdk-core-c-bridge/src/worker.rs b/crates/sdk-core-c-bridge/src/worker.rs index 7130d5f38..328845562 100644 --- a/crates/sdk-core-c-bridge/src/worker.rs +++ b/crates/sdk-core-c-bridge/src/worker.rs @@ -1198,10 +1198,10 @@ impl TryFrom<&WorkerOptions> for temporalio_sdk_core::WorkerConfig { }; temporalio_sdk_core::WorkerVersioningStrategy::WorkerDeploymentBased( temporalio_common::worker::WorkerDeploymentOptions::new( - temporalio_common::worker::WorkerDeploymentVersion { - deployment_name: dopts.version.deployment_name.to_string(), - build_id: dopts.version.build_id.to_string(), - }, + temporalio_common::worker::WorkerDeploymentVersion::builder() + .deployment_name(dopts.version.deployment_name.to_string()) + .build_id(dopts.version.build_id.to_string()) + .build(), ) .use_worker_versioning(dopts.use_worker_versioning) .maybe_default_versioning_behavior(dvb) diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index 1e13fb20f..7ff9eef10 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -52,6 +52,20 @@ relevant information. * Workflow task completions larger than the gRPC request size limit are now paginated automatically when the namespace supports it. Paginated workflow task completions require Temporal Server 1.32.0 or later. ### Breaking Changes :boom: +* The following types are now non-exhaustive: `Priority`, `WorkerDeploymentVersion`, + `WorkerCallbacks`, `WorkflowExecutionInfo`, `ActivityCloseTimeouts`, + `ActivityExecutionDecodeHint`, child-workflow and signal decode hints, + `SerializationContext`, `SerializationContextData`, `PayloadConverter`, `IncomingError`, + `ScheduleSpec`, and `ScheduleOverlapPolicy`. Construct structs using their respective builders + or constructors (`WorkerCallbacks::new`, `ActivityExecutionDecodeHint::new`, or + `SerializationContext::new`); use `Default` for `PayloadConverter`; and add wildcard branches + when matching enums. +* Renamed `ActivityCloseTimeouts::Both` to `ActivityCloseTimeouts::ScheduleAndStartToClose`. +* Removed the unused `ActExitValue` type. Use `ActivityError::WillCompleteAsync` to mark an + activity for asynchronous completion. +* Removed the test-only `FailOnNondeterminismInterceptor` from the public API. +* `TaskToken` no longer exposes its underlying bytes directly. Use `TaskToken::into_inner()` to + consume a token into its bytes. * Activity failures now include the latest heartbeat details atomically instead of force-flushing a throttled heartbeat first. Temporal Server 1.16.0 or newer is required to guarantee those details are preserved on failure; workers warn when the server does not advertise support. diff --git a/crates/sdk-core/src/core_tests/activity_tasks.rs b/crates/sdk-core/src/core_tests/activity_tasks.rs index a5a25e8c0..82bd599a5 100644 --- a/crates/sdk-core/src/core_tests/activity_tasks.rs +++ b/crates/sdk-core/src/core_tests/activity_tasks.rs @@ -19,6 +19,7 @@ use futures_util::FutureExt; use itertools::Itertools; use prost::Message; use std::{ + borrow::Borrow, collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, future, sync::{ @@ -1557,7 +1558,7 @@ async fn graceful_shutdown(#[values(true, false)] at_max_outstanding: bool) { }); mock_client.expect_fail_activity_task().times(3).returning( |task_token, _, _, last_heartbeat_details| { - if task_token.0 == [1] { + if task_token.borrow() == [1] { assert_eq!(last_heartbeat_details.unwrap().payloads[0].data, [2]); } else { assert!(last_heartbeat_details.is_none()); diff --git a/crates/sdk-core/src/core_tests/workflow_tasks.rs b/crates/sdk-core/src/core_tests/workflow_tasks.rs index 698093334..29f346c6d 100644 --- a/crates/sdk-core/src/core_tests/workflow_tasks.rs +++ b/crates/sdk-core/src/core_tests/workflow_tasks.rs @@ -3170,7 +3170,7 @@ async fn both_normal_and_sticky_pollers_poll_concurrently() { mock_client .expect_complete_workflow_task() .returning(move |completion, _| { - if completion.task_token.0.ends_with(b"normal") { + if completion.task_token.into_inner().ends_with(b"normal") { cc.normal_slots_active_count.fetch_sub(1, Ordering::Relaxed); } else { cc.sticky_slots_active_count.fetch_sub(1, Ordering::Relaxed); diff --git a/crates/sdk-core/src/histfetch.rs b/crates/sdk-core/src/histfetch.rs index 16791a37c..dc7214d5c 100644 --- a/crates/sdk-core/src/histfetch.rs +++ b/crates/sdk-core/src/histfetch.rs @@ -24,13 +24,12 @@ async fn main() -> Result<(), anyhow::Error> { .nth(1) .expect("must provide workflow id as only argument"); let run_id = std::env::args().nth(2).filter(|s| !s.is_empty()); - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wf_id.clone(), - run_id, - first_execution_run_id: None, - } - .bind_untyped(client); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wf_id.clone()) + .maybe_run_id(run_id) + .build() + .bind_untyped(client); let events = handle .fetch_history(WorkflowFetchHistoryOptions::default()) .await? diff --git a/crates/sdk-core/src/protosext/mod.rs b/crates/sdk-core/src/protosext/mod.rs index afa489aa3..9f0b77348 100644 --- a/crates/sdk-core/src/protosext/mod.rs +++ b/crates/sdk-core/src/protosext/mod.rs @@ -121,7 +121,7 @@ impl TryFrom for ValidPollWFTQResponse { let messages = messages.into_iter().map(TryInto::try_into).try_collect()?; Ok(Self { - task_token: TaskToken(task_token), + task_token: task_token.into(), task_queue: tq.name, workflow_execution, workflow_type: workflow_type.name, diff --git a/crates/sdk-core/src/test_help/integ_helpers.rs b/crates/sdk-core/src/test_help/integ_helpers.rs index 8ae653bff..0e446be95 100644 --- a/crates/sdk-core/src/test_help/integ_helpers.rs +++ b/crates/sdk-core/src/test_help/integ_helpers.rs @@ -752,7 +752,7 @@ pub fn build_mock_pollers(mut cfg: MockPollCfg) -> MocksHolder { let rid = t.workflow_execution.as_ref().unwrap().run_id.clone(); if !outstanding.has_run(&rid) { let t = tasks.pop_front().unwrap(); - outstanding.put_token(rid, TaskToken(t.task_token.clone())); + outstanding.put_token(rid, t.task_token.clone().into()); resp = Some(t); break; } diff --git a/crates/sdk-core/src/worker/activities.rs b/crates/sdk-core/src/worker/activities.rs index 495d76a5a..483c5bf7c 100644 --- a/crates/sdk-core/src/worker/activities.rs +++ b/crates/sdk-core/src/worker/activities.rs @@ -568,8 +568,9 @@ impl WorkerActivityTasks { // TODO: Propagate these back as cancels. Silent fails is too nonobvious let (heartbeat_timeout, timeout_resetter) = { let mut outstanding_activity_tasks = self.outstanding_activity_tasks.lock(); + let task_token: TaskToken = details.task_token.clone().into(); let at_info = outstanding_activity_tasks - .get_mut(&TaskToken(details.task_token.clone())) + .get_mut(&task_token) .ok_or(ActivityHeartbeatError::UnknownActivity)?; at_info.last_heartbeat_details = Some(details.details.clone()); (at_info.heartbeat_timeout, at_info.timeout_resetter.clone()) @@ -680,7 +681,7 @@ where details.known_not_found = true; } Some(Ok(ActivityTask::cancel_from_ids( - next_pc.task_token.0, + next_pc.task_token.into_inner(), next_pc.reason, next_pc.details, ))) @@ -1146,13 +1147,13 @@ mod tests { shutdown_token.cancel(); // Need to complete the tasks so shutdown will resolve atm.complete( - TaskToken(t1.task_token), + t1.task_token.into(), ActivityExecutionResult::ok(vec![1].into()).status.unwrap(), mock_client.as_ref(), ) .await; atm.complete( - TaskToken(t2.task_token), + t2.task_token.into(), ActivityExecutionResult::ok(vec![1].into()).status.unwrap(), mock_client.as_ref(), ) @@ -1215,7 +1216,7 @@ mod tests { // Make sure it didn't take wayyy too long. Our long timeouts specified above are huge assert!(start.elapsed() < Duration::from_secs(5)); atm.complete( - TaskToken(t.task_token), + t.task_token.into(), ActivityExecutionResult::fail("unimportant".into()) .status .unwrap(), @@ -1280,7 +1281,7 @@ mod tests { join!(heartbeater, poller); atm.complete( - TaskToken(t.task_token), + t.task_token.into(), ActivityExecutionResult::fail("unimportant".into()) .status .unwrap(), @@ -1348,7 +1349,7 @@ mod tests { assert!(activity_task.is_timeout()); atm.complete( - TaskToken(t.task_token), + t.task_token.into(), ActivityExecutionResult::fail("unimportant".into()) .status .unwrap(), diff --git a/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs b/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs index 09da7b99b..fdba8bd3e 100644 --- a/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs +++ b/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs @@ -254,7 +254,7 @@ impl ActivityHeartbeatManager { ) -> Result<(), ActivityHeartbeatError> { self.heartbeat_tx .send(HeartbeatAction::SendHeartbeat(ValidActivityHeartbeat { - task_token: TaskToken(hb.task_token), + task_token: hb.task_token.into(), details: hb.details, throttle_interval, timeout_resetter, diff --git a/crates/sdk-core/src/worker/activities/local_activities.rs b/crates/sdk-core/src/worker/activities/local_activities.rs index 7f5b893f5..4b0869336 100644 --- a/crates/sdk-core/src/worker/activities/local_activities.rs +++ b/crates/sdk-core/src/worker/activities/local_activities.rs @@ -531,7 +531,7 @@ impl LocalActivityManager { ]) .la_executed(); return Some(NextPendingLAAction::Dispatch(ActivityTask { - task_token: tt.0, + task_token: tt.into_inner(), variant: Some(activity_task::Variant::Start(Start { workflow_namespace: self.namespace.clone(), workflow_type: new_la.workflow_type, @@ -665,7 +665,7 @@ impl LocalActivityManager { // We want to generate a cancel task if the reason for failure was a timeout. let task = if is_timeout { Some(ActivityTask::cancel_from_ids( - task_token.clone().0, + task_token.clone().into_inner(), ActivityCancelReason::TimedOut, ActivityTask::primary_reason_to_cancellation_details( ActivityCancelReason::TimedOut, @@ -825,7 +825,7 @@ impl LocalActivityManager { self.cancels_req_tx .send(CancelOrTimeout::Cancel(ActivityTask::cancel_from_ids( - lai.task_token.0.clone(), + lai.task_token.clone().into_inner(), ActivityCancelReason::Cancelled, ActivityTask::primary_reason_to_cancellation_details( ActivityCancelReason::Cancelled, @@ -1062,7 +1062,7 @@ mod tests { activity_task::Variant::Start(Start {activity_id, ..}) if activity_id == i.to_string() ); - let next_tt = TaskToken(next.task_token); + let next_tt: TaskToken = next.task_token.into(); let complete_branch = async { lam.complete( &next_tt, @@ -1097,7 +1097,7 @@ mod tests { lam.workflows_have_shutdown(); let task = lam.next_pending().await.unwrap().unwrap(); - let task_token = TaskToken(task.task_token); + let task_token: TaskToken = task.task_token.into(); lam.complete( &task_token, LocalActivityExecutionResult::Completed(Default::default()), @@ -1121,7 +1121,7 @@ mod tests { .into()]); let next = lam.next_pending().await.unwrap().unwrap(); - let tt = TaskToken(next.task_token); + let tt: TaskToken = next.task_token.into(); tokio::select! { biased; @@ -1242,7 +1242,7 @@ mod tests { .into()]); let next = lam.next_pending().await.unwrap().unwrap(); - let tt = TaskToken(next.task_token); + let tt: TaskToken = next.task_token.into(); let res = lam.complete( &tt, LocalActivityExecutionResult::Failed(Default::default()), @@ -1277,7 +1277,7 @@ mod tests { .into()]); let next = lam.next_pending().await.unwrap().unwrap(); - let tt = TaskToken(next.task_token); + let tt: TaskToken = next.task_token.into(); let res = lam.complete( &tt, LocalActivityExecutionResult::Failed(ActFail { @@ -1325,7 +1325,7 @@ mod tests { .into()]); let next = lam.next_pending().await.unwrap().unwrap(); - let tt = TaskToken(next.task_token); + let tt: TaskToken = next.task_token.into(); lam.complete( &tt, LocalActivityExecutionResult::Failed(Default::default()), @@ -1372,7 +1372,7 @@ mod tests { .into()]); let next = lam.next_pending().await.unwrap().unwrap(); - let tt = TaskToken(next.task_token); + let tt: TaskToken = next.task_token.into(); lam.complete( &tt, LocalActivityExecutionResult::Failed(Default::default()), @@ -1524,7 +1524,7 @@ mod tests { let spinfail = || async { for _ in 1..=10 { let next = lam.next_pending().await.unwrap().unwrap(); - let tt = TaskToken(next.task_token); + let tt: TaskToken = next.task_token.into(); lam.complete( &tt, LocalActivityExecutionResult::Failed(Default::default()), diff --git a/crates/sdk-core/src/worker/client.rs b/crates/sdk-core/src/worker/client.rs index 9bf882aa4..a9977efb9 100644 --- a/crates/sdk-core/src/worker/client.rs +++ b/crates/sdk-core/src/worker/client.rs @@ -752,7 +752,7 @@ impl WorkerClient for WorkerClientBag { .respond_activity_task_completed( #[allow(deprecated)] // want to list all fields explicitly RespondActivityTaskCompletedRequest { - task_token: task_token.0, + task_token: task_token.into_inner(), result, identity: self.identity(), namespace: self.namespace.clone(), @@ -780,7 +780,7 @@ impl WorkerClient for WorkerClientBag { RespondNexusTaskCompletedRequest { namespace: self.namespace.clone(), identity: self.identity(), - task_token: task_token.0, + task_token: task_token.into_inner(), response: Some(response), poller_group_id: Default::default(), } @@ -800,7 +800,7 @@ impl WorkerClient for WorkerClientBag { .clone() .record_activity_task_heartbeat( RecordActivityTaskHeartbeatRequest { - task_token: task_token.0, + task_token: task_token.into_inner(), details, identity: self.identity(), namespace: self.namespace.clone(), @@ -823,7 +823,7 @@ impl WorkerClient for WorkerClientBag { .respond_activity_task_canceled( #[allow(deprecated)] // want to list all fields explicitly RespondActivityTaskCanceledRequest { - task_token: task_token.0, + task_token: task_token.into_inner(), details, identity: self.identity(), namespace: self.namespace.clone(), @@ -855,7 +855,7 @@ impl WorkerClient for WorkerClientBag { .respond_activity_task_failed( #[allow(deprecated)] // want to list all fields explicitly RespondActivityTaskFailedRequest { - task_token: task_token.0, + task_token: task_token.into_inner(), failure, identity: self.identity(), namespace: self.namespace.clone(), @@ -880,7 +880,7 @@ impl WorkerClient for WorkerClientBag { ) -> Result { #[allow(deprecated)] // want to list all fields explicitly let request = RespondWorkflowTaskFailedRequest { - task_token: task_token.0, + task_token: task_token.into_inner(), cause: cause as i32, failure, identity: self.identity(), @@ -919,7 +919,7 @@ impl WorkerClient for WorkerClientBag { RespondNexusTaskFailedRequest { namespace: self.namespace.clone(), identity: self.identity(), - task_token: task_token.0, + task_token: task_token.into_inner(), failure, error, poller_group_id: Default::default(), @@ -1287,7 +1287,7 @@ mod tests { client .fail_activity_task( - TaskToken(vec![1]), + vec![1].into(), ActivityTaskFailedCause::ActivityWorkerUnhandledFailure, None, Some(last_heartbeat_details.clone()), @@ -1308,10 +1308,12 @@ mod tests { ( "deployment", WorkerVersioningStrategy::WorkerDeploymentBased( - WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: "deployment".to_string(), - build_id: "deployment-build".to_string(), - }) + WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name("deployment".to_string()) + .build_id("deployment-build".to_string()) + .build(), + ) .use_worker_versioning(true) .build(), ), @@ -1618,7 +1620,7 @@ mod tests { // Roughly 4 MiB of commands forces splitting under the ~3 MiB page target. let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); let completion = WorkflowTaskCompletion { - task_token: TaskToken(b"shared-token".to_vec()), + task_token: b"shared-token".to_vec().into(), commands, messages: vec![], sticky_attributes: None, @@ -1719,7 +1721,7 @@ mod tests { // Enough commands to yield at least two intermediate pages (one fails, one hangs). let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); let completion = WorkflowTaskCompletion { - task_token: TaskToken(b"shared-token".to_vec()), + task_token: b"shared-token".to_vec().into(), commands, messages: vec![], sticky_attributes: None, @@ -1795,7 +1797,7 @@ mod tests { // at 1 MiB, so the server would reject it, and the worker must fail it without sending. let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); let completion = WorkflowTaskCompletion { - task_token: TaskToken(b"shared-token".to_vec()), + task_token: b"shared-token".to_vec().into(), commands, messages: vec![], sticky_attributes: None, @@ -1881,7 +1883,7 @@ mod tests { let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); let completion = WorkflowTaskCompletion { - task_token: TaskToken(b"shared-token".to_vec()), + task_token: b"shared-token".to_vec().into(), commands, messages: vec![], sticky_attributes: None, @@ -1970,7 +1972,7 @@ mod tests { shutdown_token.cancel(); let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); let completion = WorkflowTaskCompletion { - task_token: TaskToken(b"shared-token".to_vec()), + task_token: b"shared-token".to_vec().into(), commands, messages: vec![], sticky_attributes: None, @@ -2055,7 +2057,7 @@ mod tests { shutdown_token.cancel(); let commands: Vec<_> = (0..8).map(|_| command_with_payload(512 * 1024)).collect(); let completion = WorkflowTaskCompletion { - task_token: TaskToken(b"shared-token".to_vec()), + task_token: b"shared-token".to_vec().into(), commands, messages: vec![], sticky_attributes: None, diff --git a/crates/sdk-core/src/worker/heartbeat.rs b/crates/sdk-core/src/worker/heartbeat.rs index fedcf28f2..b3fa1047e 100644 --- a/crates/sdk-core/src/worker/heartbeat.rs +++ b/crates/sdk-core/src/worker/heartbeat.rs @@ -303,7 +303,7 @@ async fn handle_worker_command_task( for command in &exec_req.commands { let result_type = match &command.r#type { Some(WorkerCommandType::CancelActivity(cancel_cmd)) => { - let tt = TaskToken(cancel_cmd.task_token.clone()); + let tt: TaskToken = cancel_cmd.task_token.clone().into(); let cancel_callbacks: Vec<_> = callbacks_map .read() .values() @@ -437,24 +437,20 @@ mod tests { .unwrap(); shared_worker.register_callback( Uuid::new_v4(), - WorkerCallbacks { - heartbeat: Arc::new(|| None), - heartbeat_success: None, - cancel_activity: None, - }, + WorkerCallbacks::new(Arc::new(|| None), None, None), ); shared_worker.register_callback( started_worker_key, - WorkerCallbacks { - heartbeat: Arc::new(move || { + WorkerCallbacks::new( + Arc::new(move || { Some(WorkerHeartbeat { worker_instance_key: started_worker_key.to_string(), ..Default::default() }) }), - heartbeat_success: None, - cancel_activity: None, - }, + None, + None, + ), ); tokio::time::timeout(Duration::from_secs(5), recorded_rx) @@ -659,16 +655,16 @@ mod tests { let callback_tx = Mutex::new(Some(callback_tx)); shared_worker.register_callback( Uuid::new_v4(), - WorkerCallbacks { - heartbeat: Arc::new(move || { + WorkerCallbacks::new( + Arc::new(move || { if let Some(tx) = callback_tx.lock().unwrap().take() { let _ = tx.send(()); } None }), - heartbeat_success: None, - cancel_activity: None, - }, + None, + None, + ), ); tokio::time::timeout(Duration::from_secs(5), callback_rx) .await diff --git a/crates/sdk-core/src/worker/mod.rs b/crates/sdk-core/src/worker/mod.rs index 5efc10f9f..88d20ec48 100644 --- a/crates/sdk-core/src/worker/mod.rs +++ b/crates/sdk-core/src/worker/mod.rs @@ -319,16 +319,16 @@ impl WorkerConfig { pub(crate) fn computed_deployment_version(&self) -> Option { let wdv = match self.versioning_strategy { - WorkerVersioningStrategy::None { ref build_id } => WorkerDeploymentVersion { - deployment_name: "".to_owned(), - build_id: build_id.clone(), - }, + WorkerVersioningStrategy::None { ref build_id } => WorkerDeploymentVersion::builder() + .deployment_name("") + .build_id(build_id.clone()) + .build(), WorkerVersioningStrategy::WorkerDeploymentBased(ref opts) => opts.version.clone(), WorkerVersioningStrategy::LegacyBuildIdBased { ref build_id } => { - WorkerDeploymentVersion { - deployment_name: "".to_owned(), - build_id: build_id.clone(), - } + WorkerDeploymentVersion::builder() + .deployment_name("") + .build_id(build_id.clone()) + .build() } }; if wdv.is_empty() { None } else { Some(wdv) } @@ -1412,7 +1412,7 @@ impl Worker { /// options. pub fn record_activity_heartbeat(&self, details: ActivityHeartbeat) { if let Some(at_mgr) = self.task_subsystems.at_task_mgr.as_ref() { - let tt = TaskToken(details.task_token.clone()); + let tt: TaskToken = details.task_token.clone().into(); if let Err(e) = at_mgr.record_heartbeat(details) { warn!(task_token = %tt, details = ?e, "Activity heartbeat failed."); } @@ -1428,7 +1428,7 @@ impl Worker { &self, completion: ActivityTaskCompletion, ) -> Result<(), CompleteActivityError> { - let task_token = TaskToken(completion.task_token); + let task_token: TaskToken = completion.task_token.into(); let status = if let Some(s) = completion.result.and_then(|r| r.status) { s } else { @@ -1553,7 +1553,7 @@ impl Worker { reason: "Nexus completion had empty status field".to_owned(), }); }; - let tt = TaskToken(completion.task_token); + let tt: TaskToken = completion.task_token.into(); tracing::Span::current().record("task_token", tt.to_string()); tracing::Span::current().record("status", status.to_string()); @@ -2799,10 +2799,12 @@ mod tests { .namespace("default") .task_queue("test-queue") .versioning_strategy(WorkerVersioningStrategy::WorkerDeploymentBased( - WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: "deployment".to_string(), - build_id: "1.0".to_string(), - }) + WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name("deployment") + .build_id("1.0") + .build(), + ) .default_versioning_behavior(VersioningBehavior::AutoUpgrade.into()) .build(), )) diff --git a/crates/sdk-core/src/worker/nexus.rs b/crates/sdk-core/src/worker/nexus.rs index a8e39ce8d..b4b60aced 100644 --- a/crates/sdk-core/src/worker/nexus.rs +++ b/crates/sdk-core/src/worker/nexus.rs @@ -398,7 +398,7 @@ where } } - let tt = TaskToken(t.resp.task_token.clone()); + let tt: TaskToken = t.resp.task_token.clone().into(); let mut timeout_task = None; let mut request_deadline: Option = None; if let Some(timeout_str) = t @@ -418,7 +418,7 @@ where "Timing out nexus task due to elapsed local timeout timer" ); let _ = cancels_tx.send(CancelNexusTask { - task_token: tt_clone.0, + task_token: tt_clone.into_inner(), reason: NexusTaskCancelReason::TimedOut.into(), }); })); @@ -508,7 +508,7 @@ where tokio::time::sleep(gp).await; for (tt, _) in outstanding_task_clone.lock().iter() { let _ = cancels_tx_clone.send(CancelNexusTask { - task_token: tt.0.clone(), + task_token: tt.clone().into_inner(), reason: NexusTaskCancelReason::WorkerShutdown.into(), }); } diff --git a/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs b/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs index 88aea7c53..8d4b8f54e 100644 --- a/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs +++ b/crates/sdk-core/src/worker/workflow/machines/workflow_machines.rs @@ -592,10 +592,10 @@ impl WorkflowMachines { (*$me.observed_internal_flags) .borrow_mut() .add_from_complete($wtc); - let mut combined_ver = WorkerDeploymentVersion { - deployment_name: "".to_string(), - build_id: "".to_string(), - }; + let mut combined_ver = WorkerDeploymentVersion::builder() + .deployment_name("") + .build_id("") + .build(); #[allow(deprecated)] if let Some(bid) = $wtc.worker_version.as_ref().map(|wv| &wv.build_id) { combined_ver.build_id = bid.to_string(); diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index 99cec6c2d..b333f37b5 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -41,7 +41,7 @@ use temporalio_common::{ data_converters::{DataConverter, RawValue}, protos::{ coresdk::{ - workflow_activation::WorkflowActivation, + workflow_activation::{WorkflowActivation, remove_from_cache::EvictionReason}, workflow_completion::WorkflowActivationCompletion, }, temporal::api::{ @@ -56,9 +56,7 @@ use temporalio_common::{ }; use temporalio_sdk::{ Worker, WorkerOptions, - interceptors::{ - FailOnNondeterminismInterceptor, ReturnWorkflowExitValueInterceptor, WorkerInterceptor, - }, + interceptors::{ReturnWorkflowExitValueInterceptor, WorkerInterceptor}, }; #[cfg(any(feature = "test-utilities", test))] pub(crate) use temporalio_sdk_core::test_help::NAMESPACE; @@ -104,6 +102,25 @@ static ENV_CONFIG_CLIENT_CONFIG: LazyLock<(ConnectionOptions, String)> = LazyLoc (connection_options, client_options.namespace) }); +/// Causes test workers to fail immediately when Core evicts a workflow for nondeterminism. +pub(crate) struct FailOnNondeterminismInterceptor {} + +#[async_trait::async_trait(?Send)] +impl WorkerInterceptor for FailOnNondeterminismInterceptor { + async fn on_workflow_activation( + &self, + activation: &WorkflowActivation, + ) -> Result<(), anyhow::Error> { + if matches!( + activation.eviction_reason(), + Some(EvictionReason::Nondeterminism) + ) { + bail!("Workflow is being evicted because of nondeterminism! {activation}"); + } + Ok(()) + } +} + /// Create a worker instance which will use the provided test name to base the task queue and wf id /// upon. Returns the instance. pub(crate) async fn init_core_and_create_wf(test_name: &str) -> CoreWfStarter { @@ -141,10 +158,12 @@ pub(crate) fn integ_worker_config(tq: &str) -> WorkerConfig { pub(crate) fn integ_sdk_config(tq: &str) -> WorkerOptions { WorkerOptions::new(tq) .deployment_options( - WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: "".to_owned(), - build_id: "test_build_id".to_owned(), - }) + WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name("".to_owned()) + .build_id("test_build_id".to_owned()) + .build(), + ) .build(), ) .build() @@ -767,12 +786,13 @@ impl TestWorker { } let wfid = options.workflow_id.clone(); let handle = c.start_workflow(workflow, input, options).await?; - self.started_workflows.lock().push(WorkflowExecutionInfo { - namespace: c.namespace(), - workflow_id: wfid, - run_id: handle.info().run_id.clone(), - first_execution_run_id: None, - }); + self.started_workflows.lock().push( + WorkflowExecutionInfo::builder() + .namespace(c.namespace()) + .workflow_id(wfid) + .maybe_run_id(handle.info().run_id.clone()) + .build(), + ); Ok(handle) } @@ -781,16 +801,18 @@ impl TestWorker { wf_id: impl Into, run_id: Option, ) { - self.started_workflows.lock().push(WorkflowExecutionInfo { - namespace: self - .client - .as_ref() - .map(|c| c.namespace()) - .unwrap_or(NAMESPACE.to_owned()), - workflow_id: wf_id.into(), - run_id, - first_execution_run_id: None, - }); + self.started_workflows.lock().push( + WorkflowExecutionInfo::builder() + .namespace( + self.client + .as_ref() + .map(|c| c.namespace()) + .unwrap_or(NAMESPACE.to_owned()), + ) + .workflow_id(wf_id.into()) + .maybe_run_id(run_id) + .build(), + ); } /// Runs until all expected workflows have completed and then shuts down the worker @@ -865,12 +887,13 @@ impl TestWorkerSubmitterHandle { ) .await?; let run_id = handle.run_id().unwrap().to_string(); - self.started_workflows.lock().push(WorkflowExecutionInfo { - namespace: self.client.namespace(), - workflow_id: wfid, - run_id: Some(run_id.clone()), - first_execution_run_id: None, - }); + self.started_workflows.lock().push( + WorkflowExecutionInfo::builder() + .namespace(self.client.namespace()) + .workflow_id(wfid) + .maybe_run_id(Some(run_id.clone())) + .build(), + ); Ok(run_id) } } diff --git a/crates/sdk-core/tests/heavy_tests.rs b/crates/sdk-core/tests/heavy_tests.rs index e7096b307..a87ed7164 100644 --- a/crates/sdk-core/tests/heavy_tests.rs +++ b/crates/sdk-core/tests/heavy_tests.rs @@ -59,10 +59,12 @@ impl ActivityLoadWf { .execute_activity( StdActivities::echo, input_str.clone(), - ActivityOptions::with_close_timeouts(ActivityCloseTimeouts::Both { - start_to_close: Duration::from_secs(8), - schedule_to_close: Duration::from_secs(8), - }) + ActivityOptions::with_close_timeouts( + ActivityCloseTimeouts::ScheduleAndStartToClose { + start_to_close: Duration::from_secs(8), + schedule_to_close: Duration::from_secs(8), + }, + ) .activity_id("act-1".to_string()) .task_queue(tq) .schedule_to_start_timeout(Duration::from_secs(8)) @@ -336,20 +338,19 @@ async fn evict_while_la_running_no_interference() { subfs.push(async move { tokio::time::sleep(Duration::from_secs(1)).await; cw.request_workflow_eviction(&run_id); - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wf_id, - run_id: Some(run_id), - first_execution_run_id: None, - } - .bind_untyped(client) - .signal( - UntypedSignal::new("whaatever"), - RawValue::empty(), - WorkflowSignalOptions::default(), - ) - .await - .unwrap(); + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wf_id) + .maybe_run_id(Some(run_id)) + .build() + .bind_untyped(client) + .signal( + UntypedSignal::new("whaatever"), + RawValue::empty(), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); }); } let runf = async { @@ -400,13 +401,12 @@ async fn can_paginate_long_history() { let run_id = handle.run_id().unwrap().to_owned(); let client = starter.get_core_client().await; tokio::spawn(async move { - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wf_name.into(), - run_id: Some(run_id), - first_execution_run_id: None, - } - .bind_untyped(client); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wf_name) + .maybe_run_id(Some(run_id)) + .build() + .bind_untyped(client); loop { for _ in 0..10 { handle diff --git a/crates/sdk-core/tests/integ_tests/metrics_tests.rs b/crates/sdk-core/tests/integ_tests/metrics_tests.rs index 755396ed0..3c7b85af3 100644 --- a/crates/sdk-core/tests/integ_tests/metrics_tests.rs +++ b/crates/sdk-core/tests/integ_tests/metrics_tests.rs @@ -658,20 +658,19 @@ async fn query_of_closed_workflow_doesnt_tick_terminal_metric( // Query the now-closed workflow let client = starter.get_core_client().await; let queryer = async { - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: starter.get_wf_id().to_string(), - run_id: Some(run_id), - first_execution_run_id: None, - } - .bind_untyped(client.clone()) - .query( - UntypedQuery::new("fake_query"), - RawValue::empty(), - WorkflowQueryOptions::default(), - ) - .await - .unwrap(); + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(starter.get_wf_id().to_string()) + .maybe_run_id(Some(run_id)) + .build() + .bind_untyped(client.clone()) + .query( + UntypedQuery::new("fake_query"), + RawValue::empty(), + WorkflowQueryOptions::default(), + ) + .await + .unwrap(); }; let query_reply = async { // Need to re-complete b/c replay diff --git a/crates/sdk-core/tests/integ_tests/polling_tests.rs b/crates/sdk-core/tests/integ_tests/polling_tests.rs index 648bf6dee..74900aaad 100644 --- a/crates/sdk-core/tests/integ_tests/polling_tests.rs +++ b/crates/sdk-core/tests/integ_tests/polling_tests.rs @@ -205,16 +205,15 @@ async fn switching_worker_client_changes_poll() { worker.complete_execution(&act1.run_id).await; worker.handle_eviction().await; info!("Waiting on first workflow complete"); - WorkflowExecutionInfo { - namespace: client1.namespace(), - workflow_id: "my-workflow-1".into(), - run_id: Some(wf1_run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(client1.clone()) - .get_result(Default::default()) - .await - .unwrap(); + WorkflowExecutionInfo::builder() + .namespace(client1.namespace()) + .workflow_id("my-workflow-1") + .maybe_run_id(Some(wf1_run_id.clone())) + .build() + .bind_untyped(client1.clone()) + .get_result(Default::default()) + .await + .unwrap(); // Swap client, poll for next task, confirm it's second wf, and respond w/ empty info!("Replacing client and polling again"); @@ -224,16 +223,15 @@ async fn switching_worker_client_changes_poll() { worker.complete_execution(&act2.run_id).await; worker.handle_eviction().await; info!("Waiting on second workflow complete"); - WorkflowExecutionInfo { - namespace: client2.namespace(), - workflow_id: "my-workflow-2".into(), - run_id: Some(wf2_run_id), - first_execution_run_id: None, - } - .bind_untyped(client2.clone()) - .get_result(Default::default()) - .await - .unwrap(); + WorkflowExecutionInfo::builder() + .namespace(client2.namespace()) + .workflow_id("my-workflow-2") + .maybe_run_id(Some(wf2_run_id)) + .build() + .bind_untyped(client2.clone()) + .get_result(Default::default()) + .await + .unwrap(); // Shutdown workers and servers drain_pollers_and_shutdown(&worker).await; diff --git a/crates/sdk-core/tests/integ_tests/queries_tests.rs b/crates/sdk-core/tests/integ_tests/queries_tests.rs index a2fd3663c..5eff39dae 100644 --- a/crates/sdk-core/tests/integ_tests/queries_tests.rs +++ b/crates/sdk-core/tests/integ_tests/queries_tests.rs @@ -57,20 +57,19 @@ async fn simple_query_legacy() { }] ); let query_fut = async { - WorkflowExecutionInfo { - namespace: starter.get_core_client().await.namespace(), - workflow_id, - run_id: Some(task.run_id.to_string()), - first_execution_run_id: None, - } - .bind_untyped(starter.get_core_client().await.clone()) - .query( - UntypedQuery::new("myquery"), - RawValue::empty(), - WorkflowQueryOptions::default(), - ) - .await - .unwrap() + WorkflowExecutionInfo::builder() + .namespace(starter.get_core_client().await.namespace()) + .workflow_id(workflow_id) + .maybe_run_id(Some(task.run_id.to_string())) + .build() + .bind_untyped(starter.get_core_client().await.clone()) + .query( + UntypedQuery::new("myquery"), + RawValue::empty(), + WorkflowQueryOptions::default(), + ) + .await + .unwrap() }; let workflow_completions_future = async { // Let the query reach the server before completing the outstanding timer task so the @@ -196,20 +195,19 @@ async fn query_after_execution_complete(#[case] do_evict: bool) { for _ in 0..3 { let gw = starter.get_core_client().await.clone(); let query_fut = async move { - let q_resp: RawValue = WorkflowExecutionInfo { - namespace: gw.namespace(), - workflow_id: workflow_id.to_string(), - run_id: Some(run_id.to_string()), - first_execution_run_id: None, - } - .bind_untyped(gw.clone()) - .query( - UntypedQuery::new("myquery"), - RawValue::empty(), - WorkflowQueryOptions::default(), - ) - .await - .unwrap(); + let q_resp: RawValue = WorkflowExecutionInfo::builder() + .namespace(gw.namespace()) + .workflow_id(workflow_id.to_string()) + .maybe_run_id(Some(run_id.to_string())) + .build() + .bind_untyped(gw.clone()) + .query( + UntypedQuery::new("myquery"), + RawValue::empty(), + WorkflowQueryOptions::default(), + ) + .await + .unwrap(); // Ensure query response is as expected assert_eq!(q_resp.payloads[0].data, query_resp); }; @@ -238,20 +236,19 @@ async fn fail_legacy_query(#[case] with_nde: bool) { core.complete_execution(&task.run_id).await; core.handle_eviction().await; let query_fut = async { - WorkflowExecutionInfo { - namespace: starter.get_core_client().await.namespace(), - workflow_id: workflow_id.to_string(), - run_id: Some(task.run_id.to_string()), - first_execution_run_id: None, - } - .bind_untyped(starter.get_core_client().await.clone()) - .query( - UntypedQuery::new("myquery"), - RawValue::empty(), - WorkflowQueryOptions::default(), - ) - .await - .unwrap_err() + WorkflowExecutionInfo::builder() + .namespace(starter.get_core_client().await.namespace()) + .workflow_id(workflow_id.to_string()) + .maybe_run_id(Some(task.run_id.to_string())) + .build() + .bind_untyped(starter.get_core_client().await.clone()) + .query( + UntypedQuery::new("myquery"), + RawValue::empty(), + WorkflowQueryOptions::default(), + ) + .await + .unwrap_err() }; let query_responder = async { // Have to replay first since we've evicted @@ -310,20 +307,19 @@ async fn multiple_concurrent_queries_no_new_history() { let client = starter.get_core_client().await; let num_queries = 10; let query_futs = (1..=num_queries).map(|_| async { - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: workflow_id.to_string(), - run_id: Some(task.run_id.to_string()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()) - .query( - UntypedQuery::new("myquery"), - RawValue::empty(), - WorkflowQueryOptions::default(), - ) - .await - .unwrap(); + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id.to_string()) + .maybe_run_id(Some(task.run_id.to_string())) + .build() + .bind_untyped(client.clone()) + .query( + UntypedQuery::new("myquery"), + RawValue::empty(), + WorkflowQueryOptions::default(), + ) + .await + .unwrap(); }); let complete_fut = async { for _ in 1..=num_queries { @@ -380,20 +376,19 @@ async fn queries_handled_before_next_wft() { let client = starter.get_core_client().await; // Send two queries so that one of them is buffered let query_futs = (1..=2).map(|_| async { - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: workflow_id.to_string(), - run_id: Some(task.run_id.to_string()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()) - .query( - UntypedQuery::new("myquery"), - RawValue::empty(), - WorkflowQueryOptions::default(), - ) - .await - .unwrap(); + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id.to_string()) + .maybe_run_id(Some(task.run_id.to_string())) + .build() + .bind_untyped(client.clone()) + .query( + UntypedQuery::new("myquery"), + RawValue::empty(), + WorkflowQueryOptions::default(), + ) + .await + .unwrap(); }); let complete_fut = async { let task = core.poll_workflow_activation().await.unwrap(); @@ -405,20 +400,19 @@ async fn queries_handled_before_next_wft() { ); // While handling the first query, signal the workflow so a new WFT is generated and the // second query is still in the buffer - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: workflow_id.to_string(), - run_id: Some(task.run_id.to_string()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()) - .signal( - UntypedSignal::new("blah"), - RawValue::empty(), - WorkflowSignalOptions::default(), - ) - .await - .unwrap(); + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id.to_string()) + .maybe_run_id(Some(task.run_id.to_string())) + .build() + .bind_untyped(client.clone()) + .signal( + UntypedSignal::new("blah"), + RawValue::empty(), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); tokio::time::sleep(Duration::from_millis(500)).await; core.complete_workflow_activation(WorkflowActivationCompletion::from_cmd( task.run_id, diff --git a/crates/sdk-core/tests/integ_tests/update_tests.rs b/crates/sdk-core/tests/integ_tests/update_tests.rs index 5f4f2491e..29fae53ee 100644 --- a/crates/sdk-core/tests/integ_tests/update_tests.rs +++ b/crates/sdk-core/tests/integ_tests/update_tests.rs @@ -160,17 +160,16 @@ async fn reapplied_updates_due_to_reset() { assert_eq!(post_reset_run_id, reset_response.run_id); // Make sure replay works - let events = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: workflow_id.to_string(), - run_id: Some(post_reset_run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()) - .fetch_history(Default::default()) - .await - .unwrap() - .into_events(); + let events = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id.to_string()) + .maybe_run_id(Some(post_reset_run_id.clone())) + .build() + .bind_untyped(client.clone()) + .fetch_history(Default::default()) + .await + .unwrap() + .into_events(); let with_id = HistoryForReplay::new(events, workflow_id.to_string()); let replay_worker = init_core_replay_preloaded(workflow_id, [with_id]); @@ -205,13 +204,12 @@ async fn send_and_handle_update( .await .unwrap(); - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: workflow_id.to_string(), - run_id: Some(act.run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id.to_string()) + .maybe_run_id(Some(act.run_id.clone())) + .build() + .bind_untyped(client.clone()); // Send the update to the server let update_task = async { @@ -314,13 +312,12 @@ async fn update_rejection() { .await .unwrap(); - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: workflow_id.clone(), - run_id: Some(res.run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id.clone()) + .maybe_run_id(Some(res.run_id.clone())) + .build() + .bind_untyped(client.clone()); // Send the update to the server let update_task = async { @@ -393,13 +390,12 @@ async fn update_insta_complete(#[values(true, false)] accept_first: bool) { .await .unwrap(); - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id, - run_id: Some(res.run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id) + .maybe_run_id(Some(res.run_id.clone())) + .build() + .bind_untyped(client.clone()); // Send the update to the server let (update_task, stop_wait_update) = future::abortable(async { @@ -486,13 +482,12 @@ async fn update_complete_after_accept_without_new_task() { .await .unwrap(); - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id, - run_id: Some(res.run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id) + .maybe_run_id(Some(res.run_id.clone())) + .build() + .bind_untyped(client.clone()); // Send the update to the server let update_task = async { diff --git a/crates/sdk-core/tests/integ_tests/worker_heartbeat_tests.rs b/crates/sdk-core/tests/integ_tests/worker_heartbeat_tests.rs index c8a3a0987..0e7bd78aa 100644 --- a/crates/sdk-core/tests/integ_tests/worker_heartbeat_tests.rs +++ b/crates/sdk-core/tests/integ_tests/worker_heartbeat_tests.rs @@ -767,26 +767,24 @@ async fn worker_heartbeat_sticky_cache_miss() { HISTORY_WF2_ACTIVITY_STARTED.notified().await; HISTORY_WF1_ACTIVITY_FINISH.notify_one(); - let handle1 = WorkflowExecutionInfo { - namespace: client_for_orchestrator.namespace(), - workflow_id: wf1_id, - run_id: Some(wf1_run), - first_execution_run_id: None, - } - .bind_untyped(client_for_orchestrator.clone()); + let handle1 = WorkflowExecutionInfo::builder() + .namespace(client_for_orchestrator.namespace()) + .workflow_id(wf1_id) + .maybe_run_id(Some(wf1_run)) + .build() + .bind_untyped(client_for_orchestrator.clone()); handle1 .get_result(Default::default()) .await .expect("wf1 result"); HISTORY_WF2_ACTIVITY_FINISH.notify_one(); - let handle2 = WorkflowExecutionInfo { - namespace: client_for_orchestrator.namespace(), - workflow_id: wf2_id, - run_id: Some(wf2_run), - first_execution_run_id: None, - } - .bind_untyped(client_for_orchestrator.clone()); + let handle2 = WorkflowExecutionInfo::builder() + .namespace(client_for_orchestrator.namespace()) + .workflow_id(wf2_id) + .maybe_run_id(Some(wf2_run)) + .build() + .bind_untyped(client_for_orchestrator.clone()); handle2 .get_result(Default::default()) .await diff --git a/crates/sdk-core/tests/integ_tests/worker_versioning_tests.rs b/crates/sdk-core/tests/integ_tests/worker_versioning_tests.rs index 735ee6348..f7cc90114 100644 --- a/crates/sdk-core/tests/integ_tests/worker_versioning_tests.rs +++ b/crates/sdk-core/tests/integ_tests/worker_versioning_tests.rs @@ -38,10 +38,10 @@ async fn sets_deployment_info_on_task_responses(#[values(true, false)] use_defau let wf_type = "sets_deployment_info_on_task_responses"; let mut starter = CoreWfStarter::new(wf_type); let deploy_name = format!("deployment-{}", starter.get_task_queue()); - let version = WorkerDeploymentVersion { - deployment_name: deploy_name.clone(), - build_id: "1.0".to_string(), - }; + let version = WorkerDeploymentVersion::builder() + .deployment_name(deploy_name.clone()) + .build_id("1.0".to_string()) + .build(); starter.sdk_config.deployment_options = WorkerDeploymentOptions::new(version.clone()) .use_worker_versioning(true) .default_versioning_behavior(VersioningBehavior::AutoUpgrade) @@ -173,10 +173,12 @@ async fn activity_has_deployment_stamp() { let wf_name = "activity_has_deployment_stamp"; let mut starter = CoreWfStarter::new(wf_name); let deploy_name = format!("deployment-{}", starter.get_task_queue()); - starter.sdk_config.deployment_options = WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: deploy_name.clone(), - build_id: "1.0".to_string(), - }) + starter.sdk_config.deployment_options = WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name(deploy_name.clone()) + .build_id("1.0".to_string()) + .build(), + ) .use_worker_versioning(true) .default_versioning_behavior(VersioningBehavior::AutoUpgrade) .build(); @@ -268,10 +270,12 @@ async fn versioning_off_with_custom_build_id() { let wf_type = "versioning_off_with_custom_build_id"; let mut starter = CoreWfStarter::new(wf_type); let build_id = "my-custom-build-id-1.0"; - starter.sdk_config.deployment_options = WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: format!("deployment-{}", starter.get_task_queue()), - build_id: build_id.to_string(), - }) + starter.sdk_config.deployment_options = WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name(format!("deployment-{}", starter.get_task_queue())) + .build_id(build_id.to_string()) + .build(), + ) .build(); starter.set_core_task_types(WorkerTaskTypes::workflow_only()); let core = starter.get_core_worker().await; @@ -360,14 +364,14 @@ async fn continue_as_new_auto_upgrade_uses_current_deployment_version() { let wf_type = "continue_as_new_auto_upgrade_uses_current_deployment_version"; let mut starter = CoreWfStarter::new(wf_type); let deploy_name = format!("deployment-{}", starter.get_task_queue()); - let v1 = WorkerDeploymentVersion { - deployment_name: deploy_name.clone(), - build_id: "1.0".to_string(), - }; - let v2 = WorkerDeploymentVersion { - deployment_name: deploy_name.clone(), - build_id: "2.0".to_string(), - }; + let v1 = WorkerDeploymentVersion::builder() + .deployment_name(deploy_name.clone()) + .build_id("1.0".to_string()) + .build(); + let v2 = WorkerDeploymentVersion::builder() + .deployment_name(deploy_name.clone()) + .build_id("2.0".to_string()) + .build(); starter.sdk_config.deployment_options = versioned_worker_options(v1.clone()); let mut starter2 = starter.clone_no_worker(); starter2.sdk_config.deployment_options = versioned_worker_options(v2.clone()); @@ -487,14 +491,14 @@ async fn continue_as_new_use_ramping_version_uses_ramping_deployment_version() { let wf_type = "continue_as_new_use_ramping_version_uses_ramping_deployment_version"; let mut starter = CoreWfStarter::new(wf_type); let deploy_name = format!("deployment-{}", starter.get_task_queue()); - let v1 = WorkerDeploymentVersion { - deployment_name: deploy_name.clone(), - build_id: "1.0".to_string(), - }; - let v2 = WorkerDeploymentVersion { - deployment_name: deploy_name.clone(), - build_id: "2.0".to_string(), - }; + let v1 = WorkerDeploymentVersion::builder() + .deployment_name(deploy_name.clone()) + .build_id("1.0".to_string()) + .build(); + let v2 = WorkerDeploymentVersion::builder() + .deployment_name(deploy_name.clone()) + .build_id("2.0".to_string()) + .build(); starter.sdk_config.deployment_options = versioned_worker_options(v1.clone()); let mut starter2 = starter.clone_no_worker(); starter2.sdk_config.deployment_options = versioned_worker_options(v2.clone()); diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests.rs b/crates/sdk-core/tests/integ_tests/workflow_tests.rs index ea16fb65f..6677ffc87 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests.rs @@ -241,13 +241,12 @@ async fn signal_workflow() { .unwrap(); // Send the signals to the server - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: workflow_id.clone(), - run_id: Some(res.run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id.clone()) + .maybe_run_id(Some(res.run_id.clone())) + .build() + .bind_untyped(client.clone()); handle .signal( UntypedSignal::new(signal_id_1), @@ -342,20 +341,19 @@ async fn signal_workflow_signal_not_handled_on_workflow_completion() { // Send the signal to the server let sig_client = starter.get_core_client().await; - WorkflowExecutionInfo { - namespace: sig_client.namespace(), - workflow_id: workflow_id.clone(), - run_id: Some(res.run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(sig_client.clone()) - .signal( - UntypedSignal::new(signal_id_1), - RawValue::empty(), - WorkflowSignalOptions::default(), - ) - .await - .unwrap(); + WorkflowExecutionInfo::builder() + .namespace(sig_client.namespace()) + .workflow_id(workflow_id.clone()) + .maybe_run_id(Some(res.run_id.clone())) + .build() + .bind_untyped(sig_client.clone()) + .signal( + UntypedSignal::new(signal_id_1), + RawValue::empty(), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); // Send completion - not having seen a poll response with a signal in it yet (unhandled // command error will be logged as a warning and an eviction will be issued) @@ -424,13 +422,12 @@ async fn wft_timeout_doesnt_create_unsolvable_autocomplete() { // Before polling for a task again, we start and complete the activity and send the // corresponding signals. let ac_task = core.poll_activity_task().await.unwrap(); - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wf_id.to_string(), - run_id: Some(wf_task.run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wf_id.to_string()) + .maybe_run_id(Some(wf_task.run_id.clone())) + .build() + .bind_untyped(client.clone()); // Send the signals to the server & resolve activity -- sometimes this happens too fast sleep(Duration::from_millis(200)).await; handle @@ -561,16 +558,20 @@ async fn deployment_version_correct_in_wf_info(#[values(true, false)] use_only_b let wf_type = "deployment_version_correct_in_wf_info"; let mut starter = CoreWfStarter::new(wf_type); starter.sdk_config.deployment_options = if use_only_build_id { - WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: "".to_string(), - build_id: "1.0".to_string(), - }) + WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name("".to_string()) + .build_id("1.0".to_string()) + .build(), + ) .build() } else { - WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: "deployment-1".to_string(), - build_id: "1.0".to_string(), - }) + WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name("deployment-1".to_string()) + .build_id("1.0".to_string()) + .build(), + ) .build() }; starter.set_core_task_types(WorkerTaskTypes::workflow_only()); @@ -603,13 +604,12 @@ async fn deployment_version_correct_in_wf_info(#[values(true, false)] use_only_b .unwrap(); // Ensure a query on first wft also sees the correct id - let query_handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: workflow_id.clone(), - run_id: Some(res.run_id.clone()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()); + let query_handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(workflow_id.clone()) + .maybe_run_id(Some(res.run_id.clone())) + .build() + .bind_untyped(client.clone()); let query_fut = async { query_handle .query( @@ -677,16 +677,20 @@ async fn deployment_version_correct_in_wf_info(#[values(true, false)] use_only_b let mut starter = starter.clone_no_worker(); starter.sdk_config.deployment_options = if use_only_build_id { - WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: "".to_string(), - build_id: "2.0".to_string(), - }) + WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name("".to_string()) + .build_id("2.0".to_string()) + .build(), + ) .build() } else { - WorkerDeploymentOptions::new(WorkerDeploymentVersion { - deployment_name: "deployment-1".to_string(), - build_id: "2.0".to_string(), - }) + WorkerDeploymentOptions::new( + WorkerDeploymentVersion::builder() + .deployment_name("deployment-1".to_string()) + .build_id("2.0".to_string()) + .build(), + ) .build() }; diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs index cbaa5873e..cedfbc212 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs @@ -1,7 +1,7 @@ use crate::common::{ - ActivationAssertionsInterceptor, CoreWfStarter, WorkflowHandleExt, - activity_functions::StdActivities, history_from_proto_binary, init_core_replay_preloaded, - workflows::LaProblemWorkflow, + ActivationAssertionsInterceptor, CoreWfStarter, FailOnNondeterminismInterceptor, + WorkflowHandleExt, activity_functions::StdActivities, history_from_proto_binary, + init_core_replay_preloaded, workflows::LaProblemWorkflow, }; use anyhow::anyhow; use crossbeam_queue::SegQueue; @@ -56,7 +56,7 @@ use temporalio_sdk::{ CancellableFuture, LocalActivityOptions, TimeoutType, Worker, WorkflowContext, WorkflowContextView, WorkflowResult, activities::{ActivityContext, ActivityError}, - interceptors::{FailOnNondeterminismInterceptor, WorkerInterceptor}, + interceptors::WorkerInterceptor, }; use temporalio_sdk_core::{ PollError, TunerHolder, prost_dur, @@ -944,13 +944,12 @@ async fn repro_nondeterminism_with_timer_bug() { .unwrap(); worker.run_until_done().await.unwrap(); let client = starter.get_core_client().await; - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wf_name.into(), - run_id: Some(handle.run_id().unwrap().to_string()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wf_name) + .maybe_run_id(Some(handle.run_id().unwrap().to_string())) + .build() + .bind_untyped(client.clone()); handle.fetch_history_and_replay(&mut worker).await.unwrap(); } @@ -1111,13 +1110,12 @@ async fn la_resolve_same_time_as_other_cancel() { .unwrap(); worker.run_until_done().await.unwrap(); let client = starter.get_core_client().await; - let handle = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wf_name.into(), - run_id: Some(handle.run_id().unwrap().to_string()), - first_execution_run_id: None, - } - .bind_untyped(client.clone()); + let handle = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wf_name) + .maybe_run_id(Some(handle.run_id().unwrap().to_string())) + .build() + .bind_untyped(client.clone()); handle.fetch_history_and_replay(&mut worker).await.unwrap(); } diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/modify_wf_properties.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/modify_wf_properties.rs index ff7a1db16..e3174496b 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/modify_wf_properties.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/modify_wf_properties.rs @@ -64,16 +64,15 @@ async fn sends_modify_wf_props() { worker.run_until_done().await.unwrap(); let client = starter.get_core_client().await; - let description = WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wf_id.to_string(), - run_id: Some(run_id), - first_execution_run_id: None, - } - .bind_untyped(client.clone()) - .describe(WorkflowDescribeOptions::default()) - .await - .unwrap(); + let description = WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wf_id.to_string()) + .maybe_run_id(Some(run_id)) + .build() + .bind_untyped(client.clone()) + .describe(WorkflowDescribeOptions::default()) + .await + .unwrap(); assert_eq!( description.memo().get::(FIELD_A).unwrap(), Some("enchi".to_string()) diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs index 256a0ae05..3dc997d97 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs @@ -348,10 +348,7 @@ async fn nexus_async( let submitter = worker.get_submitter_handle(); let converter = PayloadConverter::default(); - let ser_ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let ser_ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); let wf_handle = worker .submit_workflow( NexusAsyncWf::run, diff --git a/crates/sdk-core/tests/manual_tests.rs b/crates/sdk-core/tests/manual_tests.rs index 02c5b45ab..18e6d5c2f 100644 --- a/crates/sdk-core/tests/manual_tests.rs +++ b/crates/sdk-core/tests/manual_tests.rs @@ -171,13 +171,12 @@ async fn poller_load_spiky() { .await .unwrap(); workflow_handles.push( - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wfid, - run_id: Some(rid), - first_execution_run_id: None, - } - .bind_untyped(client.clone()), + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wfid) + .maybe_run_id(Some(rid)) + .build() + .bind_untyped(client.clone()), ); } info!("Done starting workflows"); @@ -209,13 +208,12 @@ async fn poller_load_spiky() { .await .unwrap(); workflow_handles.push( - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wfid, - run_id: Some(rid), - first_execution_run_id: None, - } - .bind_untyped(client.clone()), + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wfid) + .maybe_run_id(Some(rid)) + .build() + .bind_untyped(client.clone()), ); } stream::iter(workflow_handles) @@ -308,13 +306,12 @@ async fn poller_load_sustained() { .await .unwrap(); workflow_handles.push( - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wfid, - run_id: Some(rid), - first_execution_run_id: None, - } - .bind_untyped(client.clone()), + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wfid) + .maybe_run_id(Some(rid)) + .build() + .bind_untyped(client.clone()), ); } info!("Done starting workflows"); @@ -392,13 +389,12 @@ async fn poller_load_spike_then_sustained() { .await .unwrap(); workflow_handles.push( - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wfid, - run_id: Some(rid), - first_execution_run_id: None, - } - .bind_untyped(client.clone()), + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wfid) + .maybe_run_id(Some(rid)) + .build() + .bind_untyped(client.clone()), ); } info!("Done starting workflows"); @@ -429,13 +425,12 @@ async fn poller_load_spike_then_sustained() { .await .unwrap(); workflow_handles.push( - WorkflowExecutionInfo { - namespace: client.namespace(), - workflow_id: wfid, - run_id: Some(rid), - first_execution_run_id: None, - } - .bind_untyped(client.clone()), + WorkflowExecutionInfo::builder() + .namespace(client.namespace()) + .workflow_id(wfid) + .maybe_run_id(Some(rid)) + .build() + .bind_untyped(client.clone()), ); tokio::time::sleep(Duration::from_secs(1)).await; } diff --git a/crates/sdk-core/tests/shared_tests/priority.rs b/crates/sdk-core/tests/shared_tests/priority.rs index dc02337dd..c032e7b71 100644 --- a/crates/sdk-core/tests/shared_tests/priority.rs +++ b/crates/sdk-core/tests/shared_tests/priority.rs @@ -19,11 +19,11 @@ pub(crate) async fn priority_values_sent_to_server() { } else { return; }; - starter.workflow_options.priority = Priority { - priority_key: Some(1), - fairness_key: Some("fair-wf".to_string()), - fairness_weight: Some(4.2), - }; + starter.workflow_options.priority = Priority::builder() + .priority_key(1) + .fairness_key("fair-wf") + .fairness_weight(4.2) + .build(); let child_type = "child-wf"; struct PriorityActivities; @@ -33,11 +33,11 @@ pub(crate) async fn priority_values_sent_to_server() { async fn echo(ctx: ActivityContext, echo_me: String) -> Result { assert_eq!( ctx.info().priority, - Priority { - priority_key: Some(5), - fairness_key: Some("fair-act".to_string()), - fairness_weight: Some(1.1) - } + Priority::builder() + .priority_key(5) + .fairness_key("fair-act") + .fairness_weight(1.1) + .build() ); Ok(echo_me) } @@ -60,11 +60,13 @@ pub(crate) async fn priority_values_sent_to_server() { RawValue::new(vec![]), ChildWorkflowOptions::builder() .workflow_id(format!("{}-child", ctx.task_queue())) - .priority(Priority { - priority_key: Some(4), - fairness_key: Some("fair-child".to_string()), - fairness_weight: Some(1.23), - }) + .priority( + Priority::builder() + .priority_key(4) + .fairness_key("fair-child") + .fairness_weight(1.23) + .build(), + ) .build(), ) .await?; @@ -72,11 +74,13 @@ pub(crate) async fn priority_values_sent_to_server() { PriorityActivities::echo, "hello".to_string(), ActivityOptions::with_start_to_close_timeout(Duration::from_secs(5)) - .priority(Priority { - priority_key: Some(5), - fairness_key: Some("fair-act".to_string()), - fairness_weight: Some(1.1), - }) + .priority( + Priority::builder() + .priority_key(5) + .fairness_key("fair-act") + .fairness_weight(1.1) + .build(), + ) .do_not_eagerly_execute(true) .build(), ); @@ -96,11 +100,11 @@ pub(crate) async fn priority_values_sent_to_server() { async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { assert_eq!( ctx.info().priority(), - Priority { - priority_key: Some(4), - fairness_key: Some("fair-child".to_string()), - fairness_weight: Some(1.23) - } + Priority::builder() + .priority_key(4) + .fairness_key("fair-child") + .fairness_weight(1.23) + .build() ); Ok(()) } diff --git a/crates/sdk/src/activities.rs b/crates/sdk/src/activities.rs index ebea00a14..60d43f5ea 100644 --- a/crates/sdk/src/activities.rs +++ b/crates/sdk/src/activities.rs @@ -324,12 +324,12 @@ impl ActivityContext { Some(WorkflowHandle::new( client.clone(), - WorkflowExecutionInfo { - namespace: client.options().namespace.clone(), - workflow_id, - run_id, - first_execution_run_id, - }, + WorkflowExecutionInfo::builder() + .namespace(client.options().namespace.clone()) + .workflow_id(workflow_id) + .maybe_run_id(run_id) + .maybe_first_execution_run_id(first_execution_run_id) + .build(), )) } @@ -573,10 +573,7 @@ impl ActivityDefinitions { // Codec application happens at the SDK/Core boundary, so activity // implementations work with the payload converter directly. let pc = dc.payload_converter(); - let ctx = SerializationContext { - data: &SerializationContextData::Activity, - converter: pc, - }; + let ctx = SerializationContext::new(&SerializationContextData::Activity, pc); let input: AD::Input = pc.from_payloads(&ctx, payloads)?; let input = ExecuteActivityInput::new(c, Box::new(input)); let leaf = activity_inbound_base::(instance); @@ -691,10 +688,7 @@ mod test { let payload_converter = PayloadConverter::default(); let payload = payload_converter .to_payload( - &SerializationContext { - data: &SerializationContextData::Activity, - converter: &payload_converter, - }, + &SerializationContext::new(&SerializationContextData::Activity, &payload_converter), &"progress".to_owned(), ) .unwrap(); diff --git a/crates/sdk/src/interceptors.rs b/crates/sdk/src/interceptors.rs index 166cd0240..221758309 100644 --- a/crates/sdk/src/interceptors.rs +++ b/crates/sdk/src/interceptors.rs @@ -4,7 +4,6 @@ use crate::{ Worker, WorkerRunError, activities::{ActivityContext, ActivityError, ActivityInfo}, }; -use anyhow::bail; use futures_util::future::{BoxFuture, LocalBoxFuture}; use std::{ any::Any, @@ -17,7 +16,7 @@ use temporalio_common::{ }, protos::{ coresdk::{ - workflow_activation::{WorkflowActivation, remove_from_cache::EvictionReason}, + workflow_activation::WorkflowActivation, workflow_completion::WorkflowActivationCompletion, }, temporal::api::common::v1::Payload, @@ -260,25 +259,6 @@ pub trait ActivityInboundInterceptor: Send + Sync + 'static { } } -/// An interceptor which causes the worker's run function to exit early if nondeterminism errors are -/// encountered -pub struct FailOnNondeterminismInterceptor {} -#[async_trait::async_trait(?Send)] -impl WorkerInterceptor for FailOnNondeterminismInterceptor { - async fn on_workflow_activation( - &self, - activation: &WorkflowActivation, - ) -> Result<(), anyhow::Error> { - if matches!( - activation.eviction_reason(), - Some(EvictionReason::Nondeterminism) - ) { - bail!("Workflow is being evicted because of nondeterminism! {activation}"); - } - Ok(()) - } -} - /// An interceptor that allows you to fetch the exit value of the workflow if and when it is set #[derive(Default)] pub struct ReturnWorkflowExitValueInterceptor { diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 9a43864cd..2b07638d2 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -41,10 +41,12 @@ //! //! let worker_options = WorkerOptions::new("task_queue") //! .deployment_options( -//! WorkerDeploymentOptions::new(WorkerDeploymentVersion { -//! deployment_name: "my_deployment".to_owned(), -//! build_id: "my_build_id".to_owned(), -//! }) +//! WorkerDeploymentOptions::new( +//! WorkerDeploymentVersion::builder() +//! .deployment_name("my_deployment") +//! .build_id("my_build_id") +//! .build(), +//! ) //! .build(), //! ) //! .register_activities(MyActivities) @@ -137,7 +139,7 @@ use temporalio_common::{ protos::{ TaskToken, coresdk::{ - ActivityTaskCompletion, AsJsonPayloadExt, + ActivityTaskCompletion, activity_result::ActivityExecutionResult, activity_task::{ActivityTask, activity_task}, workflow_activation::{WorkflowActivation, workflow_activation_job::Variant}, @@ -1413,10 +1415,8 @@ impl ActivityHalf { // Codec application happens at the SDK/Core boundary, so activity // implementations work with the payload converter directly. let pc = codec_data_converter.payload_converter(); - let ctx = SerializationContext { - data: &SerializationContextData::Activity, - converter: pc, - }; + let ctx = + SerializationContext::new(&SerializationContextData::Activity, pc); match output.serialize_payload(&ctx) { Ok(payload) => ActivityExecutionResult::ok(payload), Err(err) => { @@ -1451,21 +1451,6 @@ impl ActivityHalf { } } -/// Activity functions may return these values when exiting -#[derive(Debug)] -pub enum ActExitValue { - /// Completion requires an asynchronous callback - WillCompleteAsync, - /// Finish with a result - Normal(T), -} - -impl From for ActExitValue { - fn from(t: T) -> Self { - Self::Normal(t) - } -} - /// Attempts to turn caught panics into something printable fn panic_formatter(panic: Box) -> Box { _panic_formatter::<&str>(panic) diff --git a/crates/sdk/src/testing.rs b/crates/sdk/src/testing.rs index e2a4b4c94..365bba0e9 100644 --- a/crates/sdk/src/testing.rs +++ b/crates/sdk/src/testing.rs @@ -242,10 +242,8 @@ where let payload_converter = self .payload_converter_ref() .expect("payload converter must be set in builder state"); - let context = SerializationContext { - data: &SerializationContextData::Activity, - converter: payload_converter, - }; + let context = + SerializationContext::new(&SerializationContextData::Activity, payload_converter); self.heartbeat_details = payload_converter.to_payloads(&context, &details)?; Ok(self) } diff --git a/crates/sdk/src/workflow_registry.rs b/crates/sdk/src/workflow_registry.rs index 62e6b1f85..92d793aae 100644 --- a/crates/sdk/src/workflow_registry.rs +++ b/crates/sdk/src/workflow_registry.rs @@ -126,10 +126,8 @@ impl WorkflowDefinitions { let factory = Arc::new(move |input| { let (payloads, payload_converter, base_ctx) = workflow_input_parts(input); - let ser_ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; + let ser_ctx = + SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); let input: ::Input = payload_converter.from_payloads(&ser_ctx, payloads)?; diff --git a/crates/workflow/src/runtime/entry.rs b/crates/workflow/src/runtime/entry.rs index f886ffc67..390253895 100644 --- a/crates/workflow/src/runtime/entry.rs +++ b/crates/workflow/src/runtime/entry.rs @@ -288,10 +288,7 @@ pub(crate) fn serialize_output( output: &O, converter: &PayloadConverter, ) -> Result { - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, converter); converter.to_payload(&ctx, output).map_err(Into::into) } diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index 205da6630..c3417ef31 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -367,10 +367,7 @@ where ) -> Result, PayloadConversionError> { let view = base_ctx.view(); let interceptors = base_ctx.workflow_interceptors(); - let ser_ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let ser_ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); let input = converter.from_payloads(&ser_ctx, payloads)?; let (init_input, run_input) = if W::INIT_TAKES_INPUT { (Some(input), None) @@ -453,10 +450,7 @@ where } let converter = PayloadConverter::default(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); QueryResponse { result: converter .to_payload( @@ -796,10 +790,10 @@ where let details = details .map(|details| { (&*details as &dyn WorkflowOutputValue) - .serialize_payloads(&SerializationContext { - data: &SerializationContextData::Workflow, - converter: self.ctx.payload_converter(), - }) + .serialize_payloads(&SerializationContext::new( + &SerializationContextData::Workflow, + self.ctx.payload_converter(), + )) .map(|payloads| Payloads { payloads }) }) .transpose() diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 85e1d33fc..d82b79a8b 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -858,10 +858,8 @@ impl BaseWorkflowContext { } }; let payload_converter = base_ctx.inner.data_converter.payload_converter(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let ctx = + SerializationContext::new(&SerializationContextData::Workflow, payload_converter); match payload_converter.to_payloads(&ctx, &input) { Ok(payloads) => { let cancellation_token = opts @@ -954,10 +952,8 @@ impl BaseWorkflowContext { } }; let payload_converter = base_ctx.inner.data_converter.payload_converter(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let ctx = + SerializationContext::new(&SerializationContextData::Workflow, payload_converter); match payload_converter.to_payloads(&ctx, &input) { Ok(payloads) => { let cancellation_token = opts @@ -1038,10 +1034,8 @@ impl BaseWorkflowContext { } }; let payload_converter = base_ctx.inner.data_converter.payload_converter(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let ctx = + SerializationContext::new(&SerializationContextData::Workflow, payload_converter); let payloads = match payload_converter.to_payloads(&ctx, &input) { Ok(payloads) => payloads, Err(err) => { @@ -1192,10 +1186,8 @@ impl BaseWorkflowContext { } }; let payload_converter = base_ctx.data_converter().payload_converter(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let ctx = + SerializationContext::new(&SerializationContextData::Workflow, payload_converter); let payloads = match payload_converter.to_payloads(&ctx, &input) { Ok(payloads) => payloads, Err(err) => { @@ -1543,10 +1535,7 @@ impl SyncWorkflowContext { Err(_) => return Err(outbound_type_error("continue-as-new input").into()), }; let pc = base_ctx.data_converter().payload_converter(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: pc, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, pc); let arguments = pc .to_payloads(&ctx, &*input) .map_err(WorkflowTermination::from)?; @@ -1770,10 +1759,8 @@ impl SyncWorkflowContext { K: Into, { let payload_converter = self.payload_converter(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let context = + SerializationContext::new(&SerializationContextData::Workflow, payload_converter); let mut fields = HashMap::new(); let mut local_updates = Vec::new(); for (key, value) in updates { @@ -2685,7 +2672,7 @@ where message: "Activity completed without a status".to_string(), ..Default::default() }, - ActivityExecutionDecodeHint { cancelled: false }, + ActivityExecutionDecodeHint::new(false), ) .expect("synthetic activity failure should decode") })?; @@ -2693,10 +2680,10 @@ where match status { activity_resolution::Status::Completed(success) => { let payload = success.result.unwrap_or_default(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: data_converter.payload_converter(), - }; + let ctx = SerializationContext::new( + &SerializationContextData::Workflow, + data_converter.payload_converter(), + ); data_converter .payload_converter() .from_payload::(&ctx, payload) @@ -2705,12 +2692,12 @@ where activity_resolution::Status::Failed(f) => Err(data_converter.to_error( &SerializationContextData::Workflow, f.failure.unwrap_or_default(), - ActivityExecutionDecodeHint { cancelled: false }, + ActivityExecutionDecodeHint::new(false), )?), activity_resolution::Status::Cancelled(c) => Err(data_converter.to_error( &SerializationContextData::Workflow, c.failure.unwrap_or_default(), - ActivityExecutionDecodeHint { cancelled: true }, + ActivityExecutionDecodeHint::new(true), )?), activity_resolution::Status::Backoff(_) => { panic!("DoBackoff should be handled by LATimerBackoffFut") @@ -2857,17 +2844,17 @@ where .to_string(), ..Default::default() }, - ChildWorkflowExecutionDecodeHint, + ChildWorkflowExecutionDecodeHint::default(), ) .expect("synthetic child workflow failure should decode") })?; match status { child_workflow_result::Status::Completed(success) => { let payloads = success.result.into_iter().collect(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: data_converter.payload_converter(), - }; + let ctx = SerializationContext::new( + &SerializationContextData::Workflow, + data_converter.payload_converter(), + ); data_converter .payload_converter() .from_payloads::(&ctx, payloads) @@ -2876,13 +2863,13 @@ where child_workflow_result::Status::Failed(f) => Err(data_converter.to_error( &SerializationContextData::Workflow, f.failure.unwrap_or_default(), - ChildWorkflowExecutionDecodeHint, + ChildWorkflowExecutionDecodeHint::default(), )?), child_workflow_result::Status::Cancelled(c) => Err(data_converter .to_error( &SerializationContextData::Workflow, c.failure.unwrap_or_default(), - ChildWorkflowExecutionDecodeHint, + ChildWorkflowExecutionDecodeHint::default(), )?), } }), @@ -3004,7 +2991,7 @@ where Err(base_ctx.data_converter().to_error( &SerializationContextData::Workflow, c.failure.unwrap_or_default(), - ChildWorkflowStartDecodeHint, + ChildWorkflowStartDecodeHint::default(), )?) } }), @@ -3081,7 +3068,7 @@ where Poll::Ready(Err(failure)) => Poll::Ready(Err(data_converter.to_error( &SerializationContextData::Workflow, failure, - WorkflowSignalDecodeHint, + WorkflowSignalDecodeHint::default(), )?)), }, SignalChildFut::Terminated => panic!("polled after termination"), @@ -4447,10 +4434,7 @@ mod tests { let payload_converter = PayloadConverter::default(); let removal_payload = payload_converter .to_payload( - &SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }, + &SerializationContext::new(&SerializationContextData::Workflow, &payload_converter), &MemoValue::new(()), ) .unwrap(); diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index c6051269f..03de9ff34 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -877,10 +877,8 @@ impl ContinueAsNewOptions { headers: HashMap, payload_converter: &PayloadConverter, ) -> Result { - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: payload_converter, - }; + let context = + SerializationContext::new(&SerializationContextData::Workflow, payload_converter); let memo = self .memo .map(|memo| { @@ -943,10 +941,7 @@ fn string_user_metadata(summary: Option, details: Option) -> Opt return None; } let converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; + let context = SerializationContext::new(&SerializationContextData::Workflow, &converter); Some(UserMetadata { summary: summary.map(|value| { converter @@ -1064,13 +1059,14 @@ mod tests { #[test] fn activity_options_both_close_timeouts_map_to_command() { - let req = ActivityOptions::with_close_timeouts(ActivityCloseTimeouts::Both { - start_to_close: Duration::from_secs(3), - schedule_to_close: Duration::from_secs(8), - }) - .cancellation_type(ActivityCancellationType::Abandon) - .build() - .into_command(7, "test".to_string(), vec![], HashMap::new()); + let req = + ActivityOptions::with_close_timeouts(ActivityCloseTimeouts::ScheduleAndStartToClose { + start_to_close: Duration::from_secs(3), + schedule_to_close: Duration::from_secs(8), + }) + .cancellation_type(ActivityCancellationType::Abandon) + .build() + .into_command(7, "test".to_string(), vec![], HashMap::new()); let Some(workflow_command::Variant::ScheduleActivity(req)) = req.variant else { panic!("expected ScheduleActivity command"); }; diff --git a/crates/workflow/src/workflow_interceptors.rs b/crates/workflow/src/workflow_interceptors.rs index 9e5b19904..1713b6319 100644 --- a/crates/workflow/src/workflow_interceptors.rs +++ b/crates/workflow/src/workflow_interceptors.rs @@ -199,10 +199,7 @@ pub(crate) fn serialize_workflow_output( output: &dyn WorkflowOutputValue, converter: &PayloadConverter, ) -> Result { - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter, - }; + let ctx = SerializationContext::new(&SerializationContextData::Workflow, converter); output.serialize_payload(&ctx) } From 35e39135a1cc24ea30595e98199f8d9817d70ee4 Mon Sep 17 00:00:00 2001 From: sdk-sentinel-bot Date: Thu, 27 Aug 2026 13:16:09 -0400 Subject: [PATCH 31/87] Stabilize heartbeat timeout codec test (#1539) --- crates/sdk-core/tests/integ_tests/data_converter_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs index 7bf9d1660..253f46db5 100644 --- a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs +++ b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs @@ -121,7 +121,7 @@ impl FailurePayloadActivities { "codec-heartbeat-details".to_string(), ))) .await?; - tokio::time::sleep(Duration::from_secs(2)).await; + ctx.cancelled().await; Ok(()) } } From 3ada2144da65530316539be825099ed9887fd51d Mon Sep 17 00:00:00 2001 From: Jaanus Varus Date: Thu, 27 Aug 2026 18:22:30 +0100 Subject: [PATCH 32/87] feat(client): add update-with-start support (#1438) * feat(client): add update-with-start support Closes #1136 * pr feedback --------- Co-authored-by: Chris Olszewski --- CHANGELOG.md | 7 + crates/client/src/errors.rs | 247 ++++- crates/client/src/grpc.rs | 18 +- crates/client/src/interceptors.rs | 132 ++- crates/client/src/lib.rs | 969 +++++++++++++++--- crates/client/src/options_structs.rs | 138 +++ crates/client/src/workflow_handle.rs | 119 ++- crates/protos/src/protos/utilities.rs | 6 + crates/sdk-core/CHANGELOG.md | 3 + .../tests/integ_tests/update_tests.rs | 172 +++- 10 files changed, 1645 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f79c53e29..5fdd5c1f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,12 @@ relevant information. metrics now carry a `failure_reason` attribute. Each is now split into one time series per reason, which may affect existing dashboards. * Workflow task completions larger than the gRPC request size limit are now paginated automatically when the namespace supports it. Paginated workflow task completions require Temporal Server 1.32.0 or later. +* Update-with-Start support: `Client::start_update_with_start_workflow` and + `Client::execute_update_with_start_workflow` start a workflow and send it an update in one atomic + operation. `WorkflowUpdateWithStartOptions` requires an ID conflict policy (use `UseExisting` to + attach an update to an already-running workflow), provides distinct start and update headers, + and controls the atomic RPC. The operation can be intercepted via + `ClientInterceptor::update_with_start_workflow`. ### Breaking Changes :boom: * The following types are now non-exhaustive: `Priority`, `WorkerDeploymentVersion`, @@ -81,6 +87,7 @@ relevant information. ### Fixed * The Prometheus exporter now respects `PrometheusExporterOptions::counters_total_suffix`, appending `_total` to counter metric names when enabled. +* Workflow start requests now include the client's identity. * An activity failure caused by oversized final heartbeat details is now counted in the `temporal_activity_execution_failed` metric as `failure_reason="PayloadsTooLarge"`. Previously it was counted under the reason for the failure the activity itself reported, and was not counted at diff --git a/crates/client/src/errors.rs b/crates/client/src/errors.rs index 9dcc630c2..336a6698c 100644 --- a/crates/client/src/errors.rs +++ b/crates/client/src/errors.rs @@ -6,10 +6,16 @@ use temporalio_common::{ data_converters::{DecodablePayloads, PayloadConversionError}, error::{IncomingError, TimeoutType}, protos::{ + google::rpc::Status as RpcStatus, temporal::api::{ - errordetails::v1::ActivityExecutionAlreadyStartedFailure, failure::v1::Failure, + errordetails::v1::{ + ActivityExecutionAlreadyStartedFailure, MultiOperationExecutionFailure, + WorkflowExecutionAlreadyStartedFailure, + multi_operation_execution_failure::OperationStatus, + }, + failure::v1::Failure, }, - utilities::decode_status_detail, + utilities::{decode_status_detail, encode_status_details}, }, }; use tonic::Code; @@ -109,6 +115,22 @@ pub enum WorkflowStartError { Rpc(#[from] tonic::Status), } +impl WorkflowStartError { + pub(crate) fn from_status(status: tonic::Status) -> Self { + if status.code() == Code::AlreadyExists { + let run_id = + decode_status_detail::(status.details()) + .map(|failure| failure.run_id); + Self::AlreadyStarted { + run_id, + source: status, + } + } else { + Self::Rpc(status) + } + } +} + /// Errors returned by query operations on [crate::WorkflowHandle]. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -182,6 +204,79 @@ impl WorkflowUpdateError { } } +/// Errors returned by update-with-start operations +/// (see [crate::Client::start_update_with_start_workflow]). +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WorkflowUpdateWithStartError { + /// The start operation failed. + #[error("Workflow start failed: {0}")] + Start(#[source] WorkflowStartError), + + /// The update operation failed, or waiting for the update result failed. + #[error("Workflow update failed: {0}")] + Update(#[source] WorkflowUpdateError), + + /// Error serializing the workflow input or update arguments. + #[error("Payload conversion error: {0}")] + PayloadConversion(#[from] PayloadConversionError), + + /// An RPC error from the server that could not be attributed to either operation. + #[error("Server error: {0}")] + Rpc(tonic::Status), + + /// Other errors. + #[error(transparent)] + Other(#[from] Box), +} + +const MULTI_OPERATION_ABORTED_NAME: &str = "temporal.api.failure.v1.MultiOperationExecutionAborted"; + +/// Reconstruct a standalone gRPC status from a multi-operation `OperationStatus`, re-encoding +/// its details so the operation-specific failure information stays available to callers. +fn operation_status_to_tonic(op_status: OperationStatus) -> tonic::Status { + let code = Code::from(op_status.code); + let details = encode_status_details(&RpcStatus { + code: op_status.code, + message: op_status.message.clone(), + details: op_status.details, + }); + tonic::Status::with_details(code, op_status.message, details.into()) +} + +impl WorkflowUpdateWithStartError { + /// A multi-operation failure carries one status per operation; all operations except the + /// failed one are marked aborted. Attribute the error to the operation that actually failed + /// (index 0 is the start operation, index 1 the update). + pub(crate) fn from_status(status: tonic::Status) -> Self { + let Some(failure) = + decode_status_detail::(status.details()) + else { + return Self::Rpc(status); + }; + let culprit = failure + .statuses + .into_iter() + .enumerate() + .find(|(_, op_status)| { + op_status.code != Code::Ok as i32 + && !op_status + .details + .iter() + .any(|detail| detail.type_url.ends_with(MULTI_OPERATION_ABORTED_NAME)) + }); + match culprit { + Some((0, op_status)) => Self::Start(WorkflowStartError::from_status( + operation_status_to_tonic(op_status), + )), + Some((_, op_status)) => Self::Update(WorkflowUpdateError::from_status( + operation_status_to_tonic(op_status), + )), + None => Self::Rpc(status), + } + } +} + /// Errors returned by workflow get_result operations. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -460,3 +555,151 @@ impl From for ActivityResultError { } } } + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use prost::Message; + use temporalio_common::protos::{ + temporal::api::{ + errordetails::v1::NotFoundFailure, failure::v1::MultiOperationExecutionAborted, + }, + utilities::pack_any, + }; + + fn multi_op_status(code: Code, statuses: Vec) -> tonic::Status { + let failure = MultiOperationExecutionFailure { statuses }; + let rpc_status = RpcStatus { + code: code as i32, + message: "multi-op failure".to_owned(), + details: vec![ + pack_any( + "type.googleapis.com/temporal.api.errordetails.v1.MultiOperationExecutionFailure" + .to_owned(), + &failure, + ) + .unwrap(), + ], + }; + tonic::Status::with_details(code, "multi-op failure", rpc_status.encode_to_vec().into()) + } + + fn aborted_status() -> OperationStatus { + OperationStatus { + code: Code::Aborted as i32, + message: "aborted".to_owned(), + details: vec![ + pack_any( + "type.googleapis.com/temporal.api.failure.v1.MultiOperationExecutionAborted" + .to_owned(), + &MultiOperationExecutionAborted {}, + ) + .unwrap(), + ], + } + } + + #[test] + fn update_with_start_error_attributes_start_already_started() { + let status = multi_op_status( + Code::AlreadyExists, + vec![ + OperationStatus { + code: Code::AlreadyExists as i32, + message: "already started".to_owned(), + details: vec![ + pack_any( + "type.googleapis.com/temporal.api.errordetails.v1.WorkflowExecutionAlreadyStartedFailure" + .to_owned(), + &WorkflowExecutionAlreadyStartedFailure { + run_id: "existing-run".to_owned(), + ..Default::default() + }, + ) + .unwrap(), + ], + }, + aborted_status(), + ], + ); + + let err = WorkflowUpdateWithStartError::from_status(status); + assert_matches!( + err, + WorkflowUpdateWithStartError::Start(WorkflowStartError::AlreadyStarted { + run_id: Some(run_id), + .. + }) if run_id == "existing-run" + ); + } + + #[test] + fn update_with_start_error_attributes_update_failure() { + let status = multi_op_status( + Code::NotFound, + vec![ + aborted_status(), + OperationStatus { + code: Code::NotFound as i32, + message: "no such workflow".to_owned(), + details: vec![ + pack_any( + "type.googleapis.com/temporal.api.errordetails.v1.NotFoundFailure" + .to_owned(), + &NotFoundFailure { + current_cluster: "here".to_owned(), + ..Default::default() + }, + ) + .unwrap(), + ], + }, + ], + ); + + let err = WorkflowUpdateWithStartError::from_status(status); + let inner = assert_matches!( + err, + WorkflowUpdateWithStartError::Update(WorkflowUpdateError::NotFound(status)) => status + ); + assert_eq!(inner.message(), "no such workflow"); + // The operation's own failure details must survive reconstruction of the inner status. + let detail = decode_status_detail::(inner.details()) + .expect("operation details must be preserved"); + assert_eq!(detail.current_cluster, "here"); + } + + #[test] + fn update_with_start_error_skips_successful_start() { + let status = multi_op_status( + Code::NotFound, + vec![ + OperationStatus { + code: Code::Ok as i32, + message: String::new(), + details: vec![], + }, + OperationStatus { + code: Code::NotFound as i32, + message: "update failed".to_owned(), + details: vec![], + }, + ], + ); + + let err = WorkflowUpdateWithStartError::from_status(status); + assert_matches!( + err, + WorkflowUpdateWithStartError::Update(WorkflowUpdateError::NotFound(status)) + if status.message() == "update failed" + ); + } + + #[test] + fn update_with_start_error_without_details_is_rpc() { + let err = + WorkflowUpdateWithStartError::from_status(tonic::Status::new(Code::Internal, "boom")); + assert_matches!(err, WorkflowUpdateWithStartError::Rpc(status) if status.code() == Code::Internal); + } +} diff --git a/crates/client/src/grpc.rs b/crates/client/src/grpc.rs index 1b9f02a65..0ec015c02 100644 --- a/crates/client/src/grpc.rs +++ b/crates/client/src/grpc.rs @@ -1342,8 +1342,22 @@ proxier! { ExecuteMultiOperationRequest, ExecuteMultiOperationResponse, |r| { - let labels = namespaced_request!(r); - r.extensions_mut().insert(labels); + let mut labels = namespaced_request!(r); + if let Some(execute_multi_operation_request::operation::Operation::StartWorkflow( + start_req, + )) = r + .get_ref() + .operations + .first() + .and_then(|op| op.operation.as_ref()) + { + labels.task_q(start_req.task_queue.clone()); + } + let exts = r.extensions_mut(); + exts.insert(labels); + // Update-with-start blocks until the update reaches the requested wait stage, so it + // must be retried/timed out like other user long-polls. + exts.insert(IsUserLongPoll); } ); ( diff --git a/crates/client/src/interceptors.rs b/crates/client/src/interceptors.rs index a140ef7f4..abaf291d5 100644 --- a/crates/client/src/interceptors.rs +++ b/crates/client/src/interceptors.rs @@ -4,10 +4,10 @@ use crate::{ ActivityHeartbeatResponse, ActivityIdentifier, WorkflowCancelOptions, WorkflowCountOptions, WorkflowDescribeOptions, WorkflowFetchHistoryOptions, WorkflowQueryOptions, WorkflowSignalOptions, WorkflowStartError, WorkflowStartOptions, WorkflowStartUpdateOptions, - WorkflowTerminateOptions, + WorkflowTerminateOptions, WorkflowUpdateWithStartOptions, errors::{ AsyncActivityError, ClientError, WorkflowInteractionError, WorkflowQueryError, - WorkflowUpdateError, + WorkflowUpdateError, WorkflowUpdateWithStartError, }, schedules::{ CreateScheduleOptions, ScheduleBackfill, ScheduleError, ScheduleOverlapPolicy, @@ -635,6 +635,114 @@ impl StartWorkflowUpdateOutput { } } +/// Input to [`ClientInterceptor::update_with_start_workflow`]. +#[non_exhaustive] +#[derive(derive_more::Debug)] +pub struct UpdateWithStartWorkflowInput { + /// The workflow type sent to the server. + pub workflow_type: String, + /// Update name sent to the workflow. + pub update_name: String, + /// Options for the atomic start-and-update operation. + pub options: WorkflowUpdateWithStartOptions, + /// Controls for the multi-operation RPC. + pub rpc_options: crate::RpcOptions, + #[debug(skip)] + pub(crate) workflow_args: Box, + #[debug(skip)] + pub(crate) update_args: Box, +} + +impl UpdateWithStartWorkflowInput { + pub(crate) fn new( + workflow_type: String, + workflow_args: WA, + update_name: String, + update_args: UA, + mut options: WorkflowUpdateWithStartOptions, + ) -> Self + where + WA: TemporalSerializable + Send + 'static, + UA: TemporalSerializable + Send + 'static, + { + let rpc_options = std::mem::take(&mut options.rpc_options); + Self { + workflow_type, + update_name, + options, + rpc_options, + workflow_args: Box::new(workflow_args), + update_args: Box::new(update_args), + } + } + + /// Attempt to access the workflow start arguments as a concrete type. + pub fn workflow_args_ref(&self) -> Option<&T> { + self.workflow_args.as_any().downcast_ref() + } + + /// Attempt to mutably access the workflow start arguments as a concrete type. + pub fn workflow_args_mut(&mut self) -> Option<&mut T> { + self.workflow_args.as_any_mut().downcast_mut() + } + + /// Replace the workflow start arguments with another serializable value. + pub fn replace_workflow_args(&mut self, args: T) + where + T: TemporalSerializable + Send + 'static, + { + self.workflow_args = Box::new(args); + } + + /// Attempt to access the update arguments as a concrete type. + pub fn update_args_ref(&self) -> Option<&T> { + self.update_args.as_any().downcast_ref() + } + + /// Attempt to mutably access the update arguments as a concrete type. + pub fn update_args_mut(&mut self) -> Option<&mut T> { + self.update_args.as_any_mut().downcast_mut() + } + + /// Replace the update arguments with another serializable value. + pub fn replace_update_args(&mut self, args: T) + where + T: TemporalSerializable + Send + 'static, + { + self.update_args = Box::new(args); + } +} + +/// Result of an intercepted update-with-start operation. +#[non_exhaustive] +#[derive(Clone, Debug)] +pub struct UpdateWithStartWorkflowOutput { + /// Workflow ID used by the operation. + pub workflow_id: String, + /// Update ID used by the operation. + pub update_id: String, + /// Run ID associated with the update, when available. + pub run_id: Option, + /// Outcome returned when the requested wait stage completed the update. + pub known_outcome: Option, +} + +impl UpdateWithStartWorkflowOutput { + pub(crate) fn new( + workflow_id: impl Into, + update_id: impl Into, + run_id: Option, + known_outcome: Option, + ) -> Self { + Self { + workflow_id: workflow_id.into(), + update_id: update_id.into(), + run_id, + known_outcome, + } + } +} + /// Input to [`ClientInterceptor::poll_workflow_update`]. #[non_exhaustive] #[derive(Clone, Debug)] @@ -1262,6 +1370,19 @@ pub trait ClientInterceptor: Send + Sync + 'static { next.run(input) } + /// Intercept an `update_with_start_workflow` operation. + fn update_with_start_workflow<'a>( + &'a self, + input: UpdateWithStartWorkflowInput, + next: Next< + 'a, + UpdateWithStartWorkflowInput, + BoxFuture<'a, Result>, + >, + ) -> BoxFuture<'a, Result> { + next.run(input) + } + /// Intercept a `poll_workflow_update` operation. fn poll_workflow_update<'a>( &'a self, @@ -1520,6 +1641,13 @@ interceptor_chain!( BoxFuture<'a, Result> ); +interceptor_chain!( + call_update_with_start_workflow, + update_with_start_workflow, + UpdateWithStartWorkflowInput, + BoxFuture<'a, Result> +); + interceptor_chain!( call_poll_workflow_update, poll_workflow_update, diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 405808fc5..4218aeeae 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -61,6 +61,7 @@ pub use interceptors::{ SignalWithStartWorkflowInput, SignalWorkflowInput, StartWorkflowInput, StartWorkflowOutput, StartWorkflowUpdateInput, StartWorkflowUpdateOutput, TemporalClientValue, TerminateWorkflowInput, TriggerScheduleInput, UnpauseScheduleInput, UpdateScheduleInput, + UpdateWithStartWorkflowInput, UpdateWithStartWorkflowOutput, }; pub use metrics::{LONG_REQUEST_LATENCY_HISTOGRAM_NAME, REQUEST_LATENCY_HISTOGRAM_NAME}; pub use options_structs::*; @@ -115,7 +116,11 @@ use crate::{ worker::ClientWorkerSet, }; use errors::*; -use futures_util::{future::BoxFuture, stream, stream::Stream}; +use futures_util::{ + future::{BoxFuture, try_join}, + stream, + stream::Stream, +}; use http::Uri; use parking_lot::RwLock; use std::{ @@ -129,7 +134,7 @@ use std::{ time::{Duration, SystemTime}, }; use temporalio_common::{ - ActivityDefinition, HasWorkflowDefinition, SignalDefinition, UntypedActivity, + ActivityDefinition, HasWorkflowDefinition, SignalDefinition, UntypedActivity, UpdateDefinition, data_converters::{DataConverter, SerializationContext, SerializationContextData}, payload_visitor::decode_payloads, protos::{ @@ -138,23 +143,24 @@ use temporalio_common::{ proto_ts_to_system_time, temporal::api::{ cloud::cloudservice::v1::cloud_service_client::CloudServiceClient, - common::v1::{ActivityType, Payloads, WorkflowType}, + common::v1::{ActivityType, Memo as ProtoMemo, Payloads, WorkflowType}, enums::v1::{ ActivityIdConflictPolicy as ProtoActivityIdConflictPolicy, ActivityIdReusePolicy as ProtoActivityIdReusePolicy, TaskQueueKind, + UpdateWorkflowExecutionLifecycleStage, }, - errordetails::v1::WorkflowExecutionAlreadyStartedFailure, operatorservice::v1::operator_service_client::OperatorServiceClient, sdk::v1::UserMetadata, taskqueue::v1::TaskQueue, testservice::v1::test_service_client::TestServiceClient, workflow::v1 as workflow, workflowservice::v1::{ - count_workflow_executions_response, workflow_service_client::WorkflowServiceClient, - *, + count_workflow_executions_response, + execute_multi_operation_request::operation::Operation as MultiOperationRequest, + execute_multi_operation_response::response::Response as MultiOperationResponse, + workflow_service_client::WorkflowServiceClient, *, }, }, - utilities::decode_status_detail, }, search_attributes::{SearchAttributeError, SearchAttributeValue, SearchAttributes}, }; @@ -1181,6 +1187,64 @@ impl Client { .await } + /// Start a workflow and send it an update as a single atomic operation. + /// + /// Returns once the update has been accepted by the workflow, yielding a + /// [`WorkflowUpdateHandle`] that can be used to wait for the update result. + pub async fn start_update_with_start_workflow( + &self, + workflow: W, + workflow_input: W::Input, + update: U, + update_input: U::Input, + options: WorkflowUpdateWithStartOptions, + ) -> Result, WorkflowUpdateWithStartError> + where + W: HasWorkflowDefinition, + W::Input: Send, + U: UpdateDefinition, + U::Input: Send, + { + WorkflowClientTrait::start_update_with_start_workflow( + self, + workflow, + workflow_input, + update, + update_input, + options, + ) + .await + } + + /// Start a workflow and send it an update as a single atomic operation, waiting for the + /// update to complete and returning its result. + /// + /// See [Client::start_update_with_start_workflow] for details on option requirements. + pub async fn execute_update_with_start_workflow( + &self, + workflow: W, + workflow_input: W::Input, + update: U, + update_input: U::Input, + options: WorkflowUpdateWithStartOptions, + ) -> Result + where + W: HasWorkflowDefinition, + W::Input: Send, + U: UpdateDefinition, + U::Input: Send, + { + WorkflowClientTrait::execute_update_with_start_workflow( + self, + workflow, + workflow_input, + update, + update_input, + options, + ) + .await + } + /// Get a handle to an existing workflow. /// /// For untyped access, use `get_workflow_handle::(...)`. @@ -1362,6 +1426,40 @@ pub(crate) trait WorkflowClientTrait: NamespacedClient { S: SignalDefinition, S::Input: Send; + /// Start a workflow and send it an update as a single atomic operation, returning once the + /// update reaches the requested wait stage. + fn start_update_with_start_workflow( + &self, + workflow: W, + workflow_input: W::Input, + update: U, + update_input: U::Input, + options: WorkflowUpdateWithStartOptions, + ) -> impl Future, WorkflowUpdateWithStartError>> + where + Self: Sized, + W: HasWorkflowDefinition, + W::Input: Send, + U: UpdateDefinition, + U::Input: Send; + + /// Start a workflow and send it an update as a single atomic operation, waiting for the + /// update to complete and returning its result. + fn execute_update_with_start_workflow( + &self, + workflow: W, + workflow_input: W::Input, + update: U, + update_input: U::Input, + options: WorkflowUpdateWithStartOptions, + ) -> impl Future> + where + Self: Sized, + W: HasWorkflowDefinition, + W::Input: Send, + U: UpdateDefinition, + U::Input: Send; + /// Get a handle to an existing workflow. `run_id` may be left blank to specify the most recent /// execution having the provided `workflow_id`. /// @@ -1697,6 +1795,57 @@ impl WorkflowCountAggregationGroup { } } +// Keep the common fields used by start RPC variants in one place so their option handling does +// not drift as new fields are added. +fn build_start_workflow_request( + client: &impl NamespacedClient, + workflow_type: String, + input: Option, + memo: Option, + options: WorkflowStartOptions, +) -> StartWorkflowExecutionRequest { + let user_metadata = options.user_metadata(); + StartWorkflowExecutionRequest { + namespace: client.namespace(), + input, + workflow_id: options.workflow_id, + workflow_type: Some(WorkflowType { + name: workflow_type, + }), + task_queue: Some(TaskQueue { + name: options.task_queue, + kind: TaskQueueKind::Unspecified as i32, + normal_name: String::new(), + }), + identity: client.identity(), + request_id: Uuid::new_v4().to_string(), + workflow_id_reuse_policy: options.id_reuse_policy as i32, + workflow_id_conflict_policy: options.id_conflict_policy as i32, + workflow_execution_timeout: options + .execution_timeout + .and_then(|duration| duration.try_into().ok()), + workflow_run_timeout: options + .run_timeout + .and_then(|duration| duration.try_into().ok()), + workflow_task_timeout: options + .task_timeout + .and_then(|duration| duration.try_into().ok()), + search_attributes: options + .search_attributes + .map(|attributes| attributes.into_proto()), + cron_schedule: options.cron_schedule.unwrap_or_default(), + request_eager_execution: options.enable_eager_workflow_start, + retry_policy: options.retry_policy.map(Into::into), + links: options.links, + completion_callbacks: options.completion_callbacks, + priority: Some(options.priority.into()), + memo, + header: options.header, + user_metadata, + ..Default::default() + } +} + impl WorkflowClientTrait for T where T: WorkflowService + NamespacedClient + Clone + Send + Sync + 'static, @@ -1736,77 +1885,23 @@ where .codec() .encode(&SerializationContextData::Workflow, unencoded_payloads?) .await?; - let namespace = client.namespace(); let workflow_id = options.workflow_id.clone(); - let task_queue_name = options.task_queue.clone(); - - let user_metadata = options.user_metadata(); - let memo = options.encoded_memo(&data_converter).await?; - - let run_id = { - let mut request = StartWorkflowExecutionRequest { - namespace, - input: payloads.into_payloads(), - workflow_id: workflow_id.clone(), - workflow_type: Some(WorkflowType { - name: workflow_type, - }), - task_queue: Some(TaskQueue { - name: task_queue_name, - kind: TaskQueueKind::Unspecified as i32, - normal_name: String::new(), - }), - request_id: Uuid::new_v4().to_string(), - workflow_id_reuse_policy: options.id_reuse_policy as i32, - workflow_id_conflict_policy: options.id_conflict_policy as i32, - workflow_execution_timeout: options - .execution_timeout - .and_then(|duration| duration.try_into().ok()), - workflow_run_timeout: options - .run_timeout - .and_then(|duration| duration.try_into().ok()), - workflow_task_timeout: options - .task_timeout - .and_then(|duration| duration.try_into().ok()), - search_attributes: options - .search_attributes - .map(|attributes| attributes.into_proto()), - cron_schedule: options.cron_schedule.unwrap_or_default(), - request_eager_execution: options.enable_eager_workflow_start, - retry_policy: options.retry_policy.map(Into::into), - links: options.links, - completion_callbacks: options.completion_callbacks, - priority: Some(options.priority.into()), - memo, - header: options.header, - user_metadata, - ..Default::default() - } - .into_request(); - rpc_options.apply_to(&mut request); - client - .start_workflow_execution(request) - .await - .map_err(|status| { - if status.code() == Code::AlreadyExists { - let run_id = decode_status_detail::< - WorkflowExecutionAlreadyStartedFailure, - >( - status.details() - ) - .map(|failure| failure.run_id); - WorkflowStartError::AlreadyStarted { - run_id, - source: status, - } - } else { - WorkflowStartError::Rpc(status) - } - })? - .into_inner() - .run_id - }; + let mut request = build_start_workflow_request( + &client, + workflow_type, + payloads.into_payloads(), + memo, + options, + ) + .into_request(); + rpc_options.apply_to(&mut request); + let run_id = client + .start_workflow_execution(request) + .await + .map_err(WorkflowStartError::from_status)? + .into_inner() + .run_id; Ok(StartWorkflowOutput::new(workflow_id, run_id)) }) @@ -1886,49 +1981,45 @@ where .encode(&SerializationContextData::Workflow, signal_payloads?) .await?; let workflow_id = options.workflow_id.clone(); - let task_queue_name = options.task_queue.clone(); - - let user_metadata = options.user_metadata(); - let memo = options.encoded_memo(&data_converter).await?; - + let mut start_request = build_start_workflow_request( + &client, + workflow_type, + workflow_payloads.into_payloads(), + memo, + options, + ); + if let Some(task_queue) = &mut start_request.task_queue { + task_queue.kind = TaskQueueKind::Normal as i32; + } let mut request = SignalWithStartWorkflowExecutionRequest { - namespace: client.namespace(), - workflow_id: workflow_id.clone(), - workflow_type: Some(WorkflowType { - name: workflow_type, - }), - task_queue: Some(TaskQueue { - name: task_queue_name, - kind: TaskQueueKind::Normal as i32, - normal_name: String::new(), - }), - input: workflow_payloads.into_payloads(), + namespace: start_request.namespace, + workflow_id: start_request.workflow_id, + workflow_type: start_request.workflow_type, + task_queue: start_request.task_queue, + input: start_request.input, + workflow_execution_timeout: start_request.workflow_execution_timeout, + workflow_run_timeout: start_request.workflow_run_timeout, + workflow_task_timeout: start_request.workflow_task_timeout, + identity: start_request.identity, + request_id: start_request.request_id, + workflow_id_reuse_policy: start_request.workflow_id_reuse_policy, + workflow_id_conflict_policy: start_request.workflow_id_conflict_policy, signal_name, signal_input: Some(Payloads { payloads: signal_payloads, }), - identity: client.identity(), - request_id: Uuid::new_v4().to_string(), - workflow_id_reuse_policy: options.id_reuse_policy as i32, - workflow_id_conflict_policy: options.id_conflict_policy as i32, - workflow_execution_timeout: options - .execution_timeout - .and_then(|duration| duration.try_into().ok()), - workflow_run_timeout: options - .run_timeout - .and_then(|duration| duration.try_into().ok()), - workflow_task_timeout: options - .task_timeout - .and_then(|duration| duration.try_into().ok()), - search_attributes: options - .search_attributes - .map(|attributes| attributes.into_proto()), - cron_schedule: options.cron_schedule.unwrap_or_default(), - retry_policy: options.retry_policy.map(Into::into), - memo, - header: options.header, - user_metadata, + retry_policy: start_request.retry_policy, + cron_schedule: start_request.cron_schedule, + memo: start_request.memo, + search_attributes: start_request.search_attributes, + header: start_request.header, + workflow_start_delay: start_request.workflow_start_delay, + user_metadata: start_request.user_metadata, + links: start_request.links, + versioning_override: start_request.versioning_override, + priority: start_request.priority, + time_skipping_config: start_request.time_skipping_config, ..Default::default() } .into_request(); @@ -1962,6 +2053,212 @@ where )) } + async fn start_update_with_start_workflow( + &self, + workflow: W, + workflow_input: W::Input, + update: U, + update_input: U::Input, + options: WorkflowUpdateWithStartOptions, + ) -> Result, WorkflowUpdateWithStartError> + where + W: HasWorkflowDefinition, + W::Input: Send, + U: UpdateDefinition, + U::Input: Send, + { + let output = interceptors::call_update_with_start_workflow( + self.client_interceptors(), + UpdateWithStartWorkflowInput::new( + workflow.name().to_owned(), + workflow_input, + update.name().to_owned(), + update_input, + options, + ), + Next::new({ + let client = (*self).clone(); + move |input: UpdateWithStartWorkflowInput| -> BoxFuture< + '_, + Result, + > { + let mut client = client; + Box::pin(async move { + let UpdateWithStartWorkflowInput { + workflow_type, + update_name, + options, + rpc_options, + workflow_args, + update_args, + } = input; + let (start_options, update_id, update_header) = options.into_parts(); + + let data_converter = client.data_converter().clone(); + let (unencoded_workflow_payloads, unencoded_update_payloads) = { + let payload_converter = data_converter.payload_converter(); + let context = SerializationContext::new( + &SerializationContextData::Workflow, + payload_converter, + ); + ( + workflow_args.serialize_payloads(&context), + update_args.serialize_payloads(&context), + ) + }; + drop(workflow_args); + drop(update_args); + // The codec may do expensive work per call (e.g. remote encryption), so + // encode both payload sets concurrently. + let (workflow_payloads, update_payloads) = try_join( + data_converter.codec().encode( + &SerializationContextData::Workflow, + unencoded_workflow_payloads?, + ), + data_converter.codec().encode( + &SerializationContextData::Workflow, + unencoded_update_payloads?, + ), + ) + .await?; + + let namespace = client.namespace(); + let workflow_id = start_options.workflow_id.clone(); + let memo = start_options.encoded_memo(&data_converter).await?; + let start_request = build_start_workflow_request( + &client, + workflow_type, + workflow_payloads.into_payloads(), + memo, + start_options, + ); + + let update_id = update_id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let update_request = workflow_handle::build_update_workflow_request( + namespace.clone(), + client.identity(), + workflow_id.clone(), + String::new(), + update_id.clone(), + update_name, + update_header, + update_payloads, + ); + + let request = ExecuteMultiOperationRequest { + namespace, + operations: vec![ + execute_multi_operation_request::Operation { + operation: Some(MultiOperationRequest::StartWorkflow( + start_request, + )), + }, + execute_multi_operation_request::Operation { + operation: Some(MultiOperationRequest::UpdateWorkflow( + update_request, + )), + }, + ], + resource_id: workflow_id.clone(), + }; + + let (start_response, update_response) = loop { + let mut rpc_request = request.clone().into_request(); + rpc_options.apply_to(&mut rpc_request); + let response = + WorkflowService::execute_multi_operation(&mut client, rpc_request) + .await + .map_err(WorkflowUpdateWithStartError::from_status)? + .into_inner(); + + let [start_response, update_response]: [_; 2] = + response.responses.try_into().map_err(|_| { + WorkflowUpdateWithStartError::Other( + "Server response did not include exactly two operation \ + responses" + .into(), + ) + })?; + let ( + Some(MultiOperationResponse::StartWorkflow(start_response)), + Some(MultiOperationResponse::UpdateWorkflow(update_response)), + ) = (start_response.response, update_response.response) + else { + return Err(WorkflowUpdateWithStartError::Other( + "Server response did not include start and update operation \ + responses in request order" + .into(), + )); + }; + + if update_response.stage + < UpdateWorkflowExecutionLifecycleStage::Accepted as i32 + { + continue; + } + break (start_response, update_response); + }; + + let run_id = update_response + .update_ref + .as_ref() + .and_then(|reference| reference.workflow_execution.as_ref()) + .map(|execution| execution.run_id.clone()) + .filter(|run_id| !run_id.is_empty()) + .or_else(|| { + (!start_response.run_id.is_empty()).then_some(start_response.run_id) + }); + Ok(UpdateWithStartWorkflowOutput::new( + workflow_id, + update_id, + run_id, + update_response.outcome, + )) + }) + } + }), + ) + .await?; + Ok(WorkflowUpdateHandle::new( + self.clone(), + output.update_id, + output.workflow_id, + output.run_id, + output.known_outcome, + )) + } + + async fn execute_update_with_start_workflow( + &self, + workflow: W, + workflow_input: W::Input, + update: U, + update_input: U::Input, + options: WorkflowUpdateWithStartOptions, + ) -> Result + where + W: HasWorkflowDefinition, + W::Input: Send, + U: UpdateDefinition, + U::Input: Send, + { + let rpc_options = options.rpc_options.clone(); + let update_handle = WorkflowClientTrait::start_update_with_start_workflow( + self, + workflow, + workflow_input, + update, + update_input, + options, + ) + .await?; + let result = update_handle + .get_result(rpc_options) + .await + .map_err(WorkflowUpdateWithStartError::Update)?; + Ok(result) + } + fn get_workflow_handle( &self, workflow_id: impl Into, @@ -2966,7 +3263,9 @@ mod tests { SerializationContext, SerializationContextData, TemporalDeserializable, TemporalSerializable, }, - protos::temporal::api::common::v1::{Memo as ProtoMemo, Payload}, + protos::temporal::api::common::v1::{ + Link, Memo as ProtoMemo, Payload, Priority as ProtoPriority, + }, }; use temporalio_macros::{workflow, workflow_methods}; use temporalio_workflow::{SyncWorkflowContext, WorkflowContext, WorkflowResult}; @@ -2998,6 +3297,9 @@ mod tests { payloads: Vec, signal_name: String, signal_payloads: Vec, + identity: String, + links: Vec, + priority: Option, ascii_metadata: Option, binary_metadata: Option>, grpc_timeout: Option, @@ -3083,6 +3385,9 @@ mod tests { recorded.workflow_type = request.workflow_type.unwrap().name; recorded.memo = request.memo; recorded.payloads = request.input.unwrap_or_default().payloads; + recorded.identity = request.identity; + recorded.links = request.links; + recorded.priority = request.priority; recorded.ascii_metadata = ascii_metadata; recorded.binary_metadata = binary_metadata; recorded.grpc_timeout = grpc_timeout; @@ -3127,6 +3432,9 @@ mod tests { recorded.payloads = request.input.unwrap_or_default().payloads; recorded.signal_name = request.signal_name; recorded.signal_payloads = request.signal_input.unwrap_or_default().payloads; + recorded.identity = request.identity; + recorded.links = request.links; + recorded.priority = request.priority; recorded.ascii_metadata = ascii_metadata; recorded.binary_metadata = binary_metadata; recorded.grpc_timeout = grpc_timeout; @@ -3820,6 +4128,439 @@ mod tests { } } + mod update_with_start_tests { + use super::*; + use assert_matches::assert_matches; + use parking_lot::Mutex; + use std::collections::VecDeque; + use temporalio_common::{ + UpdateDefinition, WorkflowDefinition, + data_converters::{GenericPayloadConverter, PayloadConverter}, + protos::temporal::api::{ + common::v1::{ + Header, Payload, Payloads, WorkflowExecution as ProtoWorkflowExecution, + }, + enums::v1::{UpdateWorkflowExecutionLifecycleStage, WorkflowIdConflictPolicy}, + update::v1::{ + Input as UpdateInput, Meta as UpdateMeta, Outcome, Request as UpdateRequest, + UpdateRef, WaitPolicy, outcome, + }, + }, + }; + use tonic::{Request, Response}; + + struct TestWorkflow; + + impl WorkflowDefinition for TestWorkflow { + type Input = String; + type Output = (); + + fn name(&self) -> &str { + "test-workflow" + } + } + + impl HasWorkflowDefinition for TestWorkflow { + type Run = Self; + } + + struct TestUpdate; + + impl UpdateDefinition for TestUpdate { + type Workflow = TestWorkflow; + type Input = String; + type Output = String; + + fn name(&self) -> &str { + "test-update" + } + } + + fn successful_multi_operation_response( + stage: UpdateWorkflowExecutionLifecycleStage, + ) -> ExecuteMultiOperationResponse { + let outcome = (stage == UpdateWorkflowExecutionLifecycleStage::Completed).then(|| { + let payload_converter = PayloadConverter::default(); + let result_payloads = payload_converter + .to_payloads( + &SerializationContext::new( + &SerializationContextData::Workflow, + &payload_converter, + ), + &"update-result".to_owned(), + ) + .unwrap(); + Outcome { + value: Some(outcome::Value::Success(Payloads { + payloads: result_payloads, + })), + } + }); + ExecuteMultiOperationResponse { + responses: vec![ + execute_multi_operation_response::Response { + response: Some(MultiOperationResponse::StartWorkflow( + StartWorkflowExecutionResponse { + run_id: "started-run-id".to_owned(), + first_execution_run_id: "first-run-id".to_owned(), + started: true, + ..Default::default() + }, + )), + }, + execute_multi_operation_response::Response { + response: Some(MultiOperationResponse::UpdateWorkflow( + UpdateWorkflowExecutionResponse { + update_ref: Some(UpdateRef { + workflow_execution: Some(ProtoWorkflowExecution { + workflow_id: "workflow-id".to_owned(), + run_id: "update-run-id".to_owned(), + }), + update_id: "server-update-id".to_owned(), + }), + outcome, + stage: stage as i32, + ..Default::default() + }, + )), + }, + ], + } + } + + #[derive(Clone)] + struct MockMultiOperationClient { + recorded: Arc>>, + responses: Arc>>, + call_count: Arc>, + interceptors: Vec>, + } + + impl MockMultiOperationClient { + fn new( + interceptors: Vec>, + responses: impl IntoIterator, + ) -> Self { + Self { + recorded: Arc::new(Mutex::new(None)), + responses: Arc::new(Mutex::new(responses.into_iter().collect())), + call_count: Arc::new(Mutex::new(0)), + interceptors, + } + } + } + + impl NamespacedClient for MockMultiOperationClient { + fn namespace(&self) -> String { + "test-namespace".to_owned() + } + + fn identity(&self) -> String { + "test-identity".to_owned() + } + + fn client_interceptors(&self) -> &[Arc] { + &self.interceptors + } + } + + impl WorkflowService for MockMultiOperationClient { + fn execute_multi_operation( + &mut self, + request: Request, + ) -> futures_util::future::BoxFuture< + '_, + Result, tonic::Status>, + > { + *self.recorded.lock() = Some(request.into_inner()); + *self.call_count.lock() += 1; + let response = self.responses.lock().pop_front().unwrap_or_else(|| { + successful_multi_operation_response( + UpdateWorkflowExecutionLifecycleStage::Completed, + ) + }); + Box::pin(async { Ok(Response::new(response)) }) + } + } + + fn update_with_start_options( + conflict_policy: WorkflowIdConflictPolicy, + ) -> WorkflowUpdateWithStartOptions { + WorkflowUpdateWithStartOptions::new("task-queue", "workflow-id", conflict_policy) + .build() + } + + #[tokio::test] + async fn update_with_start_builds_multi_operation_request() { + let client = MockMultiOperationClient::new(Vec::new(), []); + let recorded = client.recorded.clone(); + + let start_header = Header { + fields: HashMap::from([("start-header".to_owned(), Payload::default())]), + }; + let update_header = Header { + fields: HashMap::from([("update-header".to_owned(), Payload::default())]), + }; + let update_handle = client + .start_update_with_start_workflow( + TestWorkflow, + "workflow-input".to_owned(), + TestUpdate, + "update-input".to_owned(), + WorkflowUpdateWithStartOptions::new( + "task-queue", + "workflow-id", + WorkflowIdConflictPolicy::UseExisting, + ) + .update_id("my-update-id".to_owned()) + .start_header(start_header.clone()) + .update_header(update_header.clone()) + .build(), + ) + .await + .unwrap(); + + let payload_converter = PayloadConverter::default(); + let context = + SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); + let workflow_payloads = payload_converter + .to_payloads(&context, &"workflow-input".to_owned()) + .unwrap(); + let update_payloads = payload_converter + .to_payloads(&context, &"update-input".to_owned()) + .unwrap(); + + let request = recorded.lock().take().unwrap(); + let request_id = assert_matches!( + &request.operations[0].operation, + Some(execute_multi_operation_request::operation::Operation::StartWorkflow(r)) => r + ) + .request_id + .clone(); + assert_eq!( + request, + ExecuteMultiOperationRequest { + namespace: "test-namespace".to_owned(), + operations: vec![ + execute_multi_operation_request::Operation { + operation: Some(MultiOperationRequest::StartWorkflow( + StartWorkflowExecutionRequest { + namespace: "test-namespace".to_owned(), + workflow_id: "workflow-id".to_owned(), + workflow_type: Some(WorkflowType { + name: "test-workflow".to_owned(), + }), + task_queue: Some(TaskQueue { + name: "task-queue".to_owned(), + ..Default::default() + }), + input: Some(Payloads { + payloads: workflow_payloads, + }), + request_id, + identity: "test-identity".to_owned(), + workflow_id_conflict_policy: + WorkflowIdConflictPolicy::UseExisting as i32, + header: Some(start_header), + priority: Some(Default::default()), + ..Default::default() + }, + )), + }, + execute_multi_operation_request::Operation { + operation: Some(MultiOperationRequest::UpdateWorkflow( + UpdateWorkflowExecutionRequest { + namespace: "test-namespace".to_owned(), + workflow_execution: Some(ProtoWorkflowExecution { + workflow_id: "workflow-id".to_owned(), + run_id: String::new(), + }), + wait_policy: Some(WaitPolicy { + lifecycle_stage: + UpdateWorkflowExecutionLifecycleStage::Accepted as i32, + }), + request: Some(UpdateRequest { + meta: Some(UpdateMeta { + update_id: "my-update-id".to_owned(), + identity: "test-identity".to_owned(), + }), + input: Some(UpdateInput { + header: Some(update_header), + name: "test-update".to_owned(), + args: Some(Payloads { + payloads: update_payloads, + }), + }), + ..Default::default() + }), + ..Default::default() + }, + )), + }, + ], + resource_id: "workflow-id".to_owned(), + } + ); + + assert_eq!(update_handle.id(), "my-update-id"); + assert_eq!(update_handle.workflow_run_id(), Some("update-run-id")); + // The outcome came back with the multi-operation response, so no poll RPC is needed + // (the mock would fail it). + let result: String = update_handle + .get_result(RpcOptions::default()) + .await + .unwrap(); + assert_eq!(result, "update-result"); + } + + #[tokio::test] + async fn update_with_start_retries_until_update_is_accepted() { + let client = MockMultiOperationClient::new( + Vec::new(), + [ + successful_multi_operation_response( + UpdateWorkflowExecutionLifecycleStage::Unspecified, + ), + successful_multi_operation_response( + UpdateWorkflowExecutionLifecycleStage::Accepted, + ), + ], + ); + let call_count = client.call_count.clone(); + + let update_handle = client + .start_update_with_start_workflow( + TestWorkflow, + "workflow-input".to_owned(), + TestUpdate, + "update-input".to_owned(), + update_with_start_options(WorkflowIdConflictPolicy::Fail), + ) + .await + .unwrap(); + + assert_eq!(*call_count.lock(), 2); + assert_eq!(update_handle.workflow_run_id(), Some("update-run-id")); + } + + #[tokio::test] + async fn update_with_start_rejects_malformed_operation_responses() { + let mut missing_response = successful_multi_operation_response( + UpdateWorkflowExecutionLifecycleStage::Accepted, + ); + missing_response.responses[0] = execute_multi_operation_response::Response::default(); + let mut extra_response = successful_multi_operation_response( + UpdateWorkflowExecutionLifecycleStage::Accepted, + ); + extra_response + .responses + .push(execute_multi_operation_response::Response::default()); + let mut wrong_order = successful_multi_operation_response( + UpdateWorkflowExecutionLifecycleStage::Accepted, + ); + wrong_order.responses.swap(0, 1); + + for response in [missing_response, extra_response, wrong_order] { + let client = MockMultiOperationClient::new(Vec::new(), [response]); + let result = client + .start_update_with_start_workflow( + TestWorkflow, + "workflow-input".to_owned(), + TestUpdate, + "update-input".to_owned(), + update_with_start_options(WorkflowIdConflictPolicy::Fail), + ) + .await; + assert!(matches!( + result, + Err(WorkflowUpdateWithStartError::Other(_)) + )); + } + } + + #[tokio::test] + async fn update_with_start_interceptor_can_mutate_args() { + struct ReplaceArgsInterceptor; + + impl ClientInterceptor for ReplaceArgsInterceptor { + fn update_with_start_workflow<'a>( + &'a self, + mut input: UpdateWithStartWorkflowInput, + next: Next< + 'a, + UpdateWithStartWorkflowInput, + BoxFuture< + 'a, + Result, + >, + >, + ) -> BoxFuture< + 'a, + Result, + > { + assert_eq!( + input.workflow_args_ref::().unwrap(), + "workflow-input" + ); + input.replace_workflow_args("replaced-workflow-input".to_owned()); + *input.update_args_mut::().unwrap() = + "replaced-update-input".to_owned(); + next.run(input) + } + } + + let client = MockMultiOperationClient::new(vec![Arc::new(ReplaceArgsInterceptor)], []); + let recorded = client.recorded.clone(); + + client + .start_update_with_start_workflow( + TestWorkflow, + "workflow-input".to_owned(), + TestUpdate, + "update-input".to_owned(), + update_with_start_options(WorkflowIdConflictPolicy::Fail), + ) + .await + .unwrap(); + + let request = recorded.lock().take().unwrap(); + let start_request = assert_matches!( + &request.operations[0].operation, + Some(execute_multi_operation_request::operation::Operation::StartWorkflow(r)) => r + ); + let workflow_input: String = client + .data_converter() + .from_payloads( + &SerializationContextData::Workflow, + start_request.input.clone().unwrap().payloads, + ) + .await + .unwrap(); + assert_eq!(workflow_input, "replaced-workflow-input"); + let update_request = assert_matches!( + &request.operations[1].operation, + Some(execute_multi_operation_request::operation::Operation::UpdateWorkflow(r)) => r + ); + let update_input: String = client + .data_converter() + .from_payloads( + &SerializationContextData::Workflow, + update_request + .request + .clone() + .unwrap() + .input + .unwrap() + .args + .unwrap() + .payloads, + ) + .await + .unwrap(); + assert_eq!(update_input, "replaced-update-input"); + } + } + mod list_workflows_tests { use super::*; use crate::test_helpers::{FailingCodec, XorCodec}; diff --git a/crates/client/src/options_structs.rs b/crates/client/src/options_structs.rs index 9b4b6716c..5ac8941bd 100644 --- a/crates/client/src/options_structs.rs +++ b/crates/client/src/options_structs.rs @@ -508,6 +508,133 @@ impl WorkflowStartOptions { } } +/// Options for starting a workflow and sending it an update in one atomic operation. +/// +/// See [crate::Client::start_update_with_start_workflow] and +/// [crate::Client::execute_update_with_start_workflow]. +#[derive(Debug, Clone, bon::Builder)] +#[builder(start_fn = new, on(String, into))] +#[non_exhaustive] +pub struct WorkflowUpdateWithStartOptions { + /// The task queue to run the workflow on. + #[builder(start_fn)] + pub task_queue: String, + + /// The workflow ID. + #[builder(start_fn)] + pub workflow_id: String, + + /// How to resolve a conflict with an already-running workflow. This is required so callers + /// explicitly choose whether an update may attach to an existing workflow. + #[builder(start_fn)] + pub id_conflict_policy: WorkflowIdConflictPolicy, + + /// The policy for reusing the workflow ID after a workflow closes. + #[builder(default)] + pub id_reuse_policy: WorkflowIdReusePolicy, + + /// The workflow execution timeout. + pub execution_timeout: Option, + + /// The workflow run timeout. + pub run_timeout: Option, + + /// The workflow task timeout. + pub task_timeout: Option, + + /// Search attributes for the workflow. + pub search_attributes: Option, + + /// The workflow retry policy. + #[builder(into)] + pub retry_policy: Option, + + /// Links to associate with the workflow. + #[builder(default)] + pub links: Vec, + + /// Callbacks invoked when the workflow completes. + #[builder(default)] + pub completion_callbacks: Vec, + + /// Priority for the workflow. Defaults to all-inherited (empty). + #[builder(default)] + pub priority: Priority, + + /// Headers to include with the start operation. + pub start_header: Option
, + + /// Headers to include with the update operation. + pub update_header: Option
, + + /// Non-indexed values attached to the workflow, serialized with the client's data converter. + pub memo: Option, + + /// Single-line static summary for the workflow, shown in the Temporal UI. + pub static_summary: Option, + + /// Multi-line static details for the workflow, shown in the Temporal UI. + pub static_details: Option, + + /// Update ID for idempotency. If not provided, a UUID will be generated. + pub update_id: Option, + + /// Controls for the multi-operation RPC and, when executing the update, subsequent polling. + #[builder(default)] + pub rpc_options: RpcOptions, +} + +impl WorkflowUpdateWithStartOptions { + pub(crate) fn into_parts(self) -> (WorkflowStartOptions, Option, Option
) { + let Self { + task_queue, + workflow_id, + id_conflict_policy, + id_reuse_policy, + execution_timeout, + run_timeout, + task_timeout, + search_attributes, + retry_policy, + links, + completion_callbacks, + priority, + start_header, + update_header, + memo, + static_summary, + static_details, + update_id, + rpc_options: _, + } = self; + ( + WorkflowStartOptions { + task_queue, + workflow_id, + id_reuse_policy, + id_conflict_policy, + execution_timeout, + run_timeout, + task_timeout, + cron_schedule: None, + search_attributes, + enable_eager_workflow_start: false, + retry_policy, + links, + completion_callbacks, + priority, + header: start_header, + memo, + static_summary, + static_details, + rpc_options: RpcOptions::default(), + }, + update_id, + update_header, + ) + } +} + pub use temporalio_common::Priority; /// Options for fetching workflow results @@ -711,6 +838,17 @@ pub struct WorkflowStartUpdateOptions { pub rpc_options: RpcOptions, } +impl From for WorkflowStartUpdateOptions { + /// Execute-update is start-update followed by waiting for the update result. + fn from(options: WorkflowExecuteUpdateOptions) -> Self { + Self::builder() + .maybe_update_id(options.update_id) + .maybe_header(options.header) + .rpc_options(options.rpc_options) + .build() + } +} + /// Options for listing workflows. #[derive(Debug, Clone, Default, bon::Builder)] #[non_exhaustive] diff --git a/crates/client/src/workflow_handle.rs b/crates/client/src/workflow_handle.rs index 90df4da61..075c4f51a 100644 --- a/crates/client/src/workflow_handle.rs +++ b/crates/client/src/workflow_handle.rs @@ -28,7 +28,7 @@ use temporalio_common::{ coresdk::FromPayloadsExt, proto_ts_to_system_time, temporal::api::{ - common::v1::{Payload, Payloads, WorkflowExecution as ProtoWorkflowExecution}, + common::v1::{Header, Payload, Payloads, WorkflowExecution as ProtoWorkflowExecution}, enums::v1::{HistoryEventFilterType, UpdateWorkflowExecutionLifecycleStage}, history::{ self, @@ -525,6 +525,45 @@ impl UpdateDefinition for UntypedUpdate { } } +/// Shared by [WorkflowHandle::start_update] and the client's update-with-start, which sends the +/// same update request as one of its operations. Update starts always wait for the update to be +/// accepted; results are waited on separately via the update handle. +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_update_workflow_request( + namespace: String, + identity: String, + workflow_id: String, + run_id: String, + update_id: String, + update_name: String, + header: Option
, + payloads: Vec, +) -> UpdateWorkflowExecutionRequest { + UpdateWorkflowExecutionRequest { + namespace, + workflow_execution: Some(ProtoWorkflowExecution { + workflow_id, + run_id, + }), + wait_policy: Some(WaitPolicy { + lifecycle_stage: UpdateWorkflowExecutionLifecycleStage::Accepted.into(), + }), + request: Some(update::v1::Request { + meta: Some(update::v1::Meta { + update_id, + identity, + }), + input: Some(update::v1::Input { + header, + name: update_name, + args: Some(Payloads { payloads }), + }), + ..Default::default() + }), + ..Default::default() + } +} + impl WorkflowHandle where CT: WorkflowService + Clone, @@ -854,17 +893,7 @@ where U::Output: 'static, { let rpc_options = options.rpc_options.clone(); - let handle = self - .start_update( - update, - input, - WorkflowStartUpdateOptions::builder() - .maybe_update_id(options.update_id) - .maybe_header(options.header) - .rpc_options(rpc_options.clone()) - .build(), - ) - .await?; + let handle = self.start_update(update, input, options.into()).await?; handle.get_result(rpc_options).await } @@ -916,30 +945,16 @@ where let update_id = options .update_id .unwrap_or_else(|| Uuid::new_v4().to_string()); - let mut request = UpdateWorkflowExecutionRequest { - namespace: client.namespace(), - workflow_execution: Some(ProtoWorkflowExecution { - workflow_id: workflow_id.clone(), - run_id, - }), - wait_policy: Some(WaitPolicy { - lifecycle_stage: - UpdateWorkflowExecutionLifecycleStage::Accepted.into(), - }), - request: Some(update::v1::Request { - meta: Some(update::v1::Meta { - update_id: update_id.clone(), - identity: client.identity(), - }), - input: Some(update::v1::Input { - header: options.header, - name: update_name, - args: Some(Payloads { payloads }), - }), - ..Default::default() - }), - ..Default::default() - } + let mut request = build_update_workflow_request( + client.namespace(), + client.identity(), + workflow_id.clone(), + run_id, + update_id.clone(), + update_name, + options.header, + payloads, + ) .into_request(); options.rpc_options.apply_to(&mut request); let response = @@ -965,14 +980,13 @@ where ) .await?; - Ok(WorkflowUpdateHandle { - client: self.client.clone(), - update_id: output.update_id, - workflow_id: output.workflow_id, - run_id: output.run_id.or_else(|| self.info().run_id.clone()), - known_outcome: output.known_outcome, - _output: PhantomData, - }) + Ok(WorkflowUpdateHandle::new( + self.client.clone(), + output.update_id, + output.workflow_id, + output.run_id.or_else(|| self.info().run_id.clone()), + output.known_outcome, + )) } /// Request cancellation of this workflow. @@ -1228,6 +1242,23 @@ pub struct WorkflowUpdateHandle { } impl WorkflowUpdateHandle { + pub(crate) fn new( + client: CT, + update_id: String, + workflow_id: String, + run_id: Option, + known_outcome: Option, + ) -> Self { + Self { + client, + update_id, + workflow_id, + run_id, + known_outcome, + _output: PhantomData, + } + } + /// Get the update ID. pub fn id(&self) -> &str { &self.update_id diff --git a/crates/protos/src/protos/utilities.rs b/crates/protos/src/protos/utilities.rs index 65f5c7950..94a7de306 100644 --- a/crates/protos/src/protos/utilities.rs +++ b/crates/protos/src/protos/utilities.rs @@ -37,6 +37,12 @@ pub fn decode_status_detail(details: &[u8]) -> Option { T::decode(first_detail.value.as_slice()).ok() } +/// Encode a `google.rpc.Status` into the serialized bytes format carried by +/// `grpc-status-details-bin` (as expected by `tonic::Status::with_details`). +pub fn encode_status_details(status: &super::google::rpc::Status) -> Vec { + status.encode_to_vec() +} + /// Given a header map, lowercase all the keys and return it as a new map. /// Any keys that are duplicated after lowercasing will clobber each other in undefined ordering. pub fn normalize_http_headers(headers: HashMap) -> HashMap { diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index 7ff9eef10..67eb2285d 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -73,6 +73,9 @@ relevant information. ### Fixed * The Prometheus exporter now appends `_total` to counter metric names when an SDK enables the counter suffix option. +* Update-with-start `ExecuteMultiOperation` calls now use Core's long-poll timeout instead of the + normal RPC timeout, avoiding premature failures while waiting for an update to reach its + requested stage. * An activity failure caused by oversized final heartbeat details is now counted in the `temporal_activity_execution_failed` metric as `failure_reason="PayloadsTooLarge"`. Previously it was counted under the reason for the failure the activity itself reported, and was not counted at diff --git a/crates/sdk-core/tests/integ_tests/update_tests.rs b/crates/sdk-core/tests/integ_tests/update_tests.rs index 29fae53ee..f1225cb0d 100644 --- a/crates/sdk-core/tests/integ_tests/update_tests.rs +++ b/crates/sdk-core/tests/integ_tests/update_tests.rs @@ -15,7 +15,9 @@ use std::{ use temporalio_client::{ Client, NamespacedClient, UntypedSignal, UntypedUpdate, UntypedWorkflow, WorkflowExecuteUpdateOptions, WorkflowExecutionInfo, WorkflowSignalOptions, - WorkflowStartOptions, errors::WorkflowUpdateError, grpc::WorkflowService, + WorkflowStartOptions, WorkflowUpdateWithStartOptions, + errors::{WorkflowStartError, WorkflowUpdateError, WorkflowUpdateWithStartError}, + grpc::WorkflowService, }; use temporalio_common::{ data_converters::RawValue, @@ -33,10 +35,11 @@ use temporalio_common::{ }, temporal::api::{ common::v1::WorkflowExecution, - enums::v1::{EventType, ResetReapplyType}, + enums::v1::{EventType, ResetReapplyType, WorkflowIdConflictPolicy}, workflowservice::v1::{ResetStickyTaskQueueRequest, ResetWorkflowExecutionRequest}, }, }, + worker::WorkerTaskTypes, }; use temporalio_macros::{activities, workflow, workflow_methods}; use temporalio_sdk::{ @@ -1522,3 +1525,168 @@ async fn update_lost_on_activity_mismatch() { join!(update, runner); handle.fetch_history_and_replay(&mut worker).await.unwrap(); } + +#[workflow] +#[derive(Default)] +struct UpdateWithStartWf { + done: bool, +} + +#[workflow_methods] +impl UpdateWithStartWf { + #[run] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + ctx.wait_condition(|s| s.done).await?; + Ok(()) + } + + #[update] + async fn do_update( + _ctx: &mut WorkflowContext, + arg: String, + ) -> Result> { + if arg == "reject" { + return Err(anyhow!("update rejected").into()); + } + Ok(format!("hello {arg}")) + } + + #[signal] + fn done_signal(&mut self, _ctx: &mut SyncWorkflowContext, _: ()) { + self.done = true; + } +} + +#[derive(Clone, Copy)] +enum UpdateWithStartScenario { + StartAndGetHandle, + ExecuteOnExisting, + UpdateFailure, + StartConflict, +} + +#[rstest::rstest] +#[case::start_and_get_handle(UpdateWithStartScenario::StartAndGetHandle)] +#[case::execute_on_existing(UpdateWithStartScenario::ExecuteOnExisting)] +#[case::update_failure(UpdateWithStartScenario::UpdateFailure)] +#[case::start_conflict(UpdateWithStartScenario::StartConflict)] +#[tokio::test] +async fn update_with_start(#[case] scenario: UpdateWithStartScenario) { + let mut starter = CoreWfStarter::new("update_with_start"); + starter + .sdk_config + .register_workflow::() + .unwrap(); + starter.set_core_task_types(WorkerTaskTypes::workflow_only()); + let mut worker = starter.worker().await; + let client = starter.get_core_client().await; + let task_queue = starter.get_task_queue().to_owned(); + let wf_id = starter.get_wf_id().to_owned(); + + let existing_run_id = if matches!(scenario, UpdateWithStartScenario::StartAndGetHandle) { + None + } else { + let handle = worker + .submit_workflow( + UpdateWithStartWf::run, + (), + WorkflowStartOptions::new(task_queue.clone(), wf_id.clone()).build(), + ) + .await + .unwrap(); + Some(handle.run_id().unwrap().to_owned()) + }; + + let core_worker = worker.core_worker(); + let interactions = async { + let options = |conflict_policy| { + WorkflowUpdateWithStartOptions::new(task_queue.clone(), wf_id.clone(), conflict_policy) + .execution_timeout(Duration::from_secs(60 * 5)) + .build() + }; + match scenario { + UpdateWithStartScenario::StartAndGetHandle => { + let update_handle = client + .start_update_with_start_workflow( + UpdateWithStartWf::run, + (), + UpdateWithStartWf::do_update, + "world".to_owned(), + options(WorkflowIdConflictPolicy::Fail), + ) + .await + .unwrap(); + assert!(update_handle.workflow_run_id().is_some()); + assert_eq!( + update_handle.get_result(Default::default()).await.unwrap(), + "hello world" + ); + } + UpdateWithStartScenario::ExecuteOnExisting => { + let result = client + .execute_update_with_start_workflow( + UpdateWithStartWf::run, + (), + UpdateWithStartWf::do_update, + "again".to_owned(), + options(WorkflowIdConflictPolicy::UseExisting), + ) + .await + .unwrap(); + assert_eq!(result, "hello again"); + } + UpdateWithStartScenario::UpdateFailure => { + let error = client + .execute_update_with_start_workflow( + UpdateWithStartWf::run, + (), + UpdateWithStartWf::do_update, + "reject".to_owned(), + options(WorkflowIdConflictPolicy::UseExisting), + ) + .await + .expect_err("rejected update must be returned as an update failure"); + assert_matches!( + error, + WorkflowUpdateWithStartError::Update(WorkflowUpdateError::Failed(failure)) + if failure.message.contains("update rejected") + ); + } + UpdateWithStartScenario::StartConflict => { + let error = client + .execute_update_with_start_workflow( + UpdateWithStartWf::run, + (), + UpdateWithStartWf::do_update, + "unused".to_owned(), + options(WorkflowIdConflictPolicy::Fail), + ) + .await + .expect_err("update-with-start must fail against a running workflow"); + assert_matches!( + error, + WorkflowUpdateWithStartError::Start(WorkflowStartError::AlreadyStarted { + run_id: Some(run_id), + .. + }) if existing_run_id.as_deref() == Some(run_id.as_str()) + ); + } + } + + let wf_handle = client.get_workflow_handle::(wf_id); + wf_handle + .signal( + UpdateWithStartWf::done_signal, + (), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); + wf_handle.get_result(Default::default()).await.unwrap(); + core_worker.initiate_shutdown(); + }; + let run = async { + worker.inner_mut().run().await.unwrap(); + }; + join!(interactions, run); +} From db635f0f21b0168a01f993e0f1b6c0f1983ec2bc Mon Sep 17 00:00:00 2001 From: David Hyde Date: Thu, 27 Aug 2026 12:44:37 -0500 Subject: [PATCH 33/87] Fix non-sticky WFT poller starvation with small workflow cache (#1534) --- CHANGELOG.md | 5 + crates/sdk-core/CHANGELOG.md | 5 + crates/sdk-core/src/abstractions.rs | 5 + .../src/worker/workflow/wft_poller.rs | 94 +++++++++++++++++-- 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fdd5c1f7..ddcfc111e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,11 @@ relevant information. was counted under the reason for the failure the activity itself reported, and was not counted at all when that failure was benign, even though the worker reported a payload-limit failure to the server. +* Workers configured with a small `max_cached_workflows` no longer briefly stop accepting new + workflows. Sticky workflow-task pollers could consume every workflow-cache permit and starve the + non-sticky poller, so the worker would stop picking up new workflows until a poll timed out (up to + ~60s). The poll balancer now reserves a non-sticky slot against the cache size rather than the + slot-supplier size. ## [0.7.0] - 2026-08-17 diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index 67eb2285d..bfb8e04f9 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -97,3 +97,8 @@ relevant information. already elapsed, a sub-millisecond unit, or a multi-unit value like `1m30s`. Previously such a header was ignored entirely, so the handler was never told the task had timed out, and a task left unanswered could block worker shutdown indefinitely. +* Workers with a small workflow cache no longer briefly stop accepting new workflows. Sticky + workflow-task pollers could consume every workflow-cache permit and starve the non-sticky poller, + so the worker would stop picking up new workflows until a poll timed out (up to ~60s). The poll + balancer now reserves a non-sticky slot against the workflow cache size rather than the slot + supplier size. diff --git a/crates/sdk-core/src/abstractions.rs b/crates/sdk-core/src/abstractions.rs index 204f2e025..d08186ec5 100644 --- a/crates/sdk-core/src/abstractions.rs +++ b/crates/sdk-core/src/abstractions.rs @@ -108,6 +108,11 @@ where self.supplier.available_slots() } + /// Hard cap on extant permits (the workflow cache size), if any. + pub(crate) fn max_permits(&self) -> Option { + self.max_permits + } + pub(crate) fn slot_supplier_kind(&self) -> &SlotSupplierKind { &self.slot_supplier_kind } diff --git a/crates/sdk-core/src/worker/workflow/wft_poller.rs b/crates/sdk-core/src/worker/workflow/wft_poller.rs index 06f78a6a8..a55626a07 100644 --- a/crates/sdk-core/src/worker/workflow/wft_poller.rs +++ b/crates/sdk-core/src/worker/workflow/wft_poller.rs @@ -43,9 +43,13 @@ pub(crate) fn make_wft_poller( &capabilities, ); let wft_poller_shared = if sticky_queue_name.is_some() { - Some(Arc::new(WFTPollerShared::new( - wft_slots.available_permits(), - ))) + // Balance on the limit `acquire_owned` actually enforces (min of slot supplier and cache + // size). Using only the slot supplier lets a small cache starve the non-sticky poller. + let balance_limit = [wft_slots.available_permits(), wft_slots.max_permits()] + .into_iter() + .flatten() + .min(); + Some(Arc::new(WFTPollerShared::new(balance_limit))) } else { None }; @@ -260,11 +264,21 @@ pub(crate) fn validate_wft( mod tests { use super::*; use crate::{ - abstractions::tests::fixed_size_permit_dealer, pollers::MockPermittedPollBuffer, - test_help::mock_poller, worker::WorkflowSlotKind, + abstractions::tests::fixed_size_permit_dealer, + pollers::MockPermittedPollBuffer, + replay::TestHistoryBuilder, + test_help::{ResponseType, hist_to_poll_resp, mock_poller, test_worker_cfg}, + worker::{ + PollerBehavior, WorkflowSlotKind, client::mocks::mock_manual_worker_client, + tuner::FixedSizeSlotSupplier, + }, + }; + use futures_util::{FutureExt, StreamExt, pin_mut}; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, }; - use futures_util::{StreamExt, pin_mut}; - use std::sync::Arc; + use temporalio_common::protos::temporal::api::enums::v1::EventType; #[tokio::test] async fn poll_timeouts_do_not_produce_responses() { @@ -303,6 +317,72 @@ mod tests { assert_matches!(stream.next().await, None); } + /// Cache (`max_permits`) of 2 under a supplier of 10: the balancer must reserve a poll slot + /// against the cache, not the supplier, or a saturated sticky poller starves the non-sticky one. + /// Sticky long-polls forever (holding permits) while non-sticky repeatedly times out and must + /// re-acquire; without the reservation sticky recaptures every freed permit, so non-sticky never + /// delivers its eventual real task. (A single fresh-start poll won't reproduce this: a free + /// permit is always available at startup.) + #[tokio::test] + async fn small_cache_does_not_starve_nonsticky_poller() { + let mut t = TestHistoryBuilder::default(); + t.add_by_type(EventType::WorkflowExecutionStarted); + t.add_full_wf_task(); + let real_task = hist_to_poll_resp(&t, "wf-id", ResponseType::AllHistory).resp; + + // Non-sticky times out (empty response) this many times before its real task, giving the + // sticky poller ample opportunity to grab every freed permit if nothing is reserved. + let nonsticky_timeouts = Arc::new(AtomicUsize::new(0)); + let mut client = mock_manual_worker_client(); + client + .expect_poll_workflow_task() + .returning(move |_po, wfo| { + if wfo.sticky_queue_name.is_some() { + // Sticky poll never returns -> holds its permit for the test's duration. + std::future::pending().boxed() + } else if nonsticky_timeouts.fetch_add(1, Ordering::SeqCst) < 20 { + // Poll timeout: empty response releases the permit so it must be re-acquired. + async { Ok(PollWorkflowTaskQueueResponse::default()) }.boxed() + } else { + let real_task = real_task.clone(); + async move { Ok(real_task) }.boxed() + } + }); + + let wft_slots = MeteredPermitDealer::::new( + Arc::new(FixedSizeSlotSupplier::new(10)), + MetricsContext::no_op(), + Some(2), + Arc::new(Default::default()), + None, + ); + // Poller max > 1 so the sticky poller alone could otherwise claim every permit. + let cfg = { + let mut cfg = test_worker_cfg().build().unwrap(); + cfg.workflow_task_poller_behavior = Some(PollerBehavior::SimpleMaximum(5_usize)); + cfg + }; + + let client: Arc = Arc::new(client); + let stream = make_wft_poller( + &cfg, + &Some("sticky-q".to_string()), + &client, + &MetricsContext::no_op(), + &CancellationToken::new(), + &wft_slots, + Arc::new(AtomicCell::new(None)), + Arc::new(AtomicCell::new(None)), + Arc::new(NamespaceCapabilities::default()), + ); + pin_mut!(stream); + let got = tokio::time::timeout(std::time::Duration::from_secs(10), stream.next()) + .await + .expect("non-sticky poll must be delivered; a small cache is starving it") + .expect("stream should yield a task"); + assert!(got.is_ok()); + } + #[tokio::test] async fn poll_errors_do_produce_responses() { let mut mock_poller = mock_poller(); From 97e55aae723ec19fc3435052054fce0f59516fe7 Mon Sep 17 00:00:00 2001 From: Ian Wilkinson Date: Thu, 27 Aug 2026 20:06:18 +0100 Subject: [PATCH 34/87] Fix worker shutdown racing in-flight activity completions (#1538) --- CHANGELOG.md | 6 + crates/sdk-core/CHANGELOG.md | 5 + .../sdk-core/src/core_tests/activity_tasks.rs | 150 +++++++++++++++++- crates/sdk-core/src/worker/activities.rs | 52 ++++-- 4 files changed, 196 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddcfc111e..4aff02ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,12 @@ relevant information. `WorkflowStartOptions::start_signal` and `WorkflowStartSignal`. ### Fixed +* `Worker` shutdown no longer loses an activity result it was still reporting to the server. If + shutdown raced such a completion — most likely while the activity's final heartbeat RPC was + still in flight — the worker could strand the completion forever: debug builds panicked with + `Waiting for all slot permits to release took too long!`, and release builds logged that error + and dropped the result, leaving the server to time the activity out before retrying it. + Shutdown now drains in-flight completions first. * The Prometheus exporter now respects `PrometheusExporterOptions::counters_total_suffix`, appending `_total` to counter metric names when enabled. * Workflow start requests now include the client's identity. diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index bfb8e04f9..23b23e67c 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -71,6 +71,11 @@ relevant information. are preserved on failure; workers warn when the server does not advertise support. ### Fixed +* Worker shutdown now drains activity completions that are still flushing their result to the + server before finishing. Previously such a completion — typically one whose final heartbeat RPC + was still in flight — could be permanently stranded by shutdown: the activity's result was + never reported (the server had to time the attempt out before retrying it), and workers missed + shutdown's slot-permit release deadline, panicking in debug builds. * The Prometheus exporter now appends `_total` to counter metric names when an SDK enables the counter suffix option. * Update-with-start `ExecuteMultiOperation` calls now use Core's long-poll timeout instead of the diff --git a/crates/sdk-core/src/core_tests/activity_tasks.rs b/crates/sdk-core/src/core_tests/activity_tasks.rs index 82bd599a5..85156e1da 100644 --- a/crates/sdk-core/src/core_tests/activity_tasks.rs +++ b/crates/sdk-core/src/core_tests/activity_tasks.rs @@ -1,5 +1,6 @@ use crate::{ - ActivityHeartbeat, CompleteActivityError, Worker, advance_fut, job_assert, prost_dur, + ActivityHeartbeat, CompleteActivityError, PollError, Worker, advance_fut, job_assert, + prost_dur, replay::{TestHistoryBuilder, canned_histories}, test_help::{ FakeWfResponses, MockPollCfg, MockWorkerInputs, MocksHolder, QueueResponse, WorkerExt, @@ -18,6 +19,7 @@ use crate::{ use futures_util::FutureExt; use itertools::Itertools; use prost::Message; +use rstest::rstest; use std::{ borrow::Borrow, collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, @@ -67,7 +69,11 @@ use temporalio_common::{ }, worker::WorkerTaskTypes, }; -use tokio::{join, sync::oneshot, time::sleep}; +use tokio::{ + join, + sync::{Notify, oneshot}, + time::sleep, +}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -663,6 +669,146 @@ async fn can_heartbeat_acts_during_shutdown() { core.drain_activity_poller_and_shutdown().await; } +#[derive(PartialEq)] +enum HungRpc { + Heartbeat, + Fail, + Complete, +} + +// Worker-level regression test for the shutdown/completion race: an activity is completed while +// one of its server calls is still in flight, so the completion has left the outstanding map but +// still owns its slot permit. Shutdown must wait for the completion to finish reporting rather +// than tear down the heartbeat manager under it (which stranded the completion forever) and then +// trip the slot-permit release deadline. The hung-heartbeat case reproduces the original race +// (eviction defers its ack until the in-flight heartbeat returns); the hung-failure and +// hung-success cases pin the same invariant when the completion is parked on the result RPC +// itself. +#[rstest] +#[case::heartbeat_rpc_hangs(HungRpc::Heartbeat)] +#[case::fail_rpc_hangs(HungRpc::Fail)] +#[case::complete_rpc_hangs(HungRpc::Complete)] +#[tokio::test] +async fn worker_shutdown_awaits_activity_completion_flushing_result(#[case] hung: HungRpc) { + let rpc_entered = Arc::new(Notify::new()); + let rpc_release = Arc::new(Notify::new()); + let result_reported = Arc::new(AtomicBool::new(false)); + + let mut mock_client = mock_manual_worker_client(); + if hung == HungRpc::Heartbeat { + let entered = rpc_entered.clone(); + let release = rpc_release.clone(); + mock_client + .expect_record_activity_heartbeat() + .times(1) + .returning(move |_, _| { + let entered = entered.clone(); + let release = release.clone(); + async move { + entered.notify_one(); + release.notified().await; + Ok(RecordActivityTaskHeartbeatResponse::default()) + } + .boxed() + }); + } + let entered = rpc_entered.clone(); + let release = rpc_release.clone(); + let result_reported_clone = result_reported.clone(); + if hung == HungRpc::Complete { + mock_client + .expect_complete_activity_task() + .times(1) + .returning(move |_, _| { + let entered = entered.clone(); + let release = release.clone(); + let result_reported = result_reported_clone.clone(); + async move { + entered.notify_one(); + release.notified().await; + result_reported.store(true, Ordering::SeqCst); + Ok(RespondActivityTaskCompletedResponse::default()) + } + .boxed() + }); + } else { + let hold_fail_rpc = hung == HungRpc::Fail; + mock_client + .expect_fail_activity_task() + .times(1) + .returning(move |_, _, _, _| { + let entered = entered.clone(); + let release = release.clone(); + let result_reported = result_reported_clone.clone(); + async move { + if hold_fail_rpc { + entered.notify_one(); + release.notified().await; + } + result_reported.store(true, Ordering::SeqCst); + Ok(RespondActivityTaskFailedResponse::default()) + } + .boxed() + }); + } + + let core = mock_worker(MocksHolder::from_client_with_activities( + mock_client, + [PollActivityTaskQueueResponse { + task_token: vec![1], + activity_id: "act1".to_string(), + heartbeat_timeout: Some(prost_dur!(from_secs(100))), + ..Default::default() + } + .into()], + )); + + let act = core.poll_activity_task().await.unwrap(); + if hung == HungRpc::Heartbeat { + core.record_activity_heartbeat(ActivityHeartbeat { + task_token: act.task_token.clone(), + details: vec![], + }); + } + + let result = if hung == HungRpc::Complete { + ActivityExecutionResult::ok(vec![1].into()) + } else { + ActivityExecutionResult::fail("retry me".into()) + }; + join!( + async { + core.complete_activity_task(ActivityTaskCompletion { + task_token: act.task_token.clone(), + result: Some(result), + }) + .await + .unwrap(); + }, + async { + // Only begin shutdown once the hung RPC is in flight — for the heartbeat case that + // parks the completion's eviction behind it, the window in which shutdown used to + // slip through. + rpc_entered.notified().await; + core.initiate_shutdown(); + let shutdown_fut = async { + assert_matches!( + core.poll_activity_task().await.unwrap_err(), + PollError::ShutDown + ); + core.shutdown().await; + }; + advance_fut!(shutdown_fut); + rpc_release.notify_one(); + shutdown_fut.await; + assert!( + result_reported.load(Ordering::SeqCst), + "worker shutdown completed before the activity's result was reported to server" + ); + } + ); +} + /// Rapid heartbeats are not force-flushed before failure. The failure request carries the latest /// details atomically instead. #[tokio::test] diff --git a/crates/sdk-core/src/worker/activities.rs b/crates/sdk-core/src/worker/activities.rs index 483c5bf7c..7bb23792d 100644 --- a/crates/sdk-core/src/worker/activities.rs +++ b/crates/sdk-core/src/worker/activities.rs @@ -9,8 +9,8 @@ pub(crate) use local_activities::{ use crate::{ TaskToken, abstractions::{ - ClosableMeteredPermitDealer, MeteredPermitDealer, TrackedOwnedMeteredSemPermit, - UsedMeteredSemPermit, + ActiveCounter, ClosableMeteredPermitDealer, MeteredPermitDealer, + TrackedOwnedMeteredSemPermit, UsedMeteredSemPermit, }, pollers::{BoxedActPoller, PermittedTqResp, TrackedPermittedTqResp, new_activity_task_poller}, telemetry::metrics::{ @@ -66,6 +66,7 @@ use tokio::{ sync::{ Mutex, Notify, mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}, + watch, }, task::JoinHandle, }; @@ -183,8 +184,13 @@ pub(crate) struct WorkerActivityTasks { max_heartbeat_throttle_interval: Duration, default_heartbeat_throttle_interval: Duration, - /// Wakes every time an activity is removed from the outstanding map - complete_notify: Arc, + /// Counts completions which have already taken their task out of + /// `outstanding_activity_tasks` but are still flushing the result to server. Such a + /// completion still owns the activity's slot permit, so shutdown must not treat the empty + /// map as "all activities finished" while this is nonzero — otherwise the heartbeat manager + /// can be torn down out from under an in-flight eviction (stranding it forever) and worker + /// shutdown can proceed while the result was never reported. + completions_in_flight: watch::Sender, /// Token to notify when poll returned a shutdown error poll_returned_shutdown_token: CancellationToken, /// Used to inject external cancellations (e.g. from nexus worker commands) @@ -226,7 +232,7 @@ impl WorkerActivityTasks { let (cancels_tx, cancels_rx) = unbounded_channel(); let external_cancels_tx = cancels_tx.clone(); let heartbeat_manager = ActivityHeartbeatManager::new(client, cancels_tx.clone()); - let complete_notify = Arc::new(Notify::new()); + let (completions_in_flight, completions_in_flight_rx) = watch::channel(0); let source_stream = stream::select_with_strategy( UnboundedReceiverStream::new(cancels_rx).map(ActivityTaskSource::from), starts_stream.map(|a| ActivityTaskSource::from(Box::new(a))), @@ -237,7 +243,7 @@ impl WorkerActivityTasks { source_stream, outstanding_tasks: outstanding_activity_tasks.clone(), start_tasks_stream_complete, - complete_notify: complete_notify.clone(), + completions_in_flight: completions_in_flight_rx, grace_period: graceful_shutdown, cancels_tx, local_timeout_buffer, @@ -252,7 +258,7 @@ impl WorkerActivityTasks { heartbeat_manager, activity_task_stream: Mutex::new(activity_task_stream.boxed()), eager_activities_semaphore, - complete_notify, + completions_in_flight, metrics, max_heartbeat_throttle_interval, default_heartbeat_throttle_interval, @@ -341,6 +347,12 @@ impl WorkerActivityTasks { status: aer::Status, client: &dyn WorkerClient, ) { + // Counted before taking the task out of the outstanding map so shutdown can never + // observe the map empty without also seeing this completion in flight. Declared first so + // it drops after `act_info` — and thus after the slot permit — even if this future is + // cancelled or panics mid-completion. + let _completion_guard = + ActiveCounter::::new(self.completions_in_flight.clone(), None); let act_info = { let mut outstanding_activity_tasks = self.outstanding_activity_tasks.lock(); outstanding_activity_tasks.remove(&task_token) @@ -556,8 +568,6 @@ impl WorkerActivityTasks { &task_token ); } - - self.complete_notify.notify_waiters(); } /// Attempt to record an activity heartbeat @@ -636,7 +646,7 @@ struct ActivityTaskStream { source_stream: SrcStrm, outstanding_tasks: OutstandingActMap, start_tasks_stream_complete: CancellationToken, - complete_notify: Arc, + completions_in_flight: watch::Receiver, grace_period: Option, cancels_tx: UnboundedSender, /// The extra time we'll wait for local timeouts before firing them, to avoid racing with server @@ -815,11 +825,23 @@ where join!( async { self.start_tasks_stream_complete.cancelled().await; - while { - let outstanding_tasks = outstanding_tasks_clone.lock(); - !outstanding_tasks.is_empty() - } { - self.complete_notify.notified().await + let mut completions_in_flight = self.completions_in_flight; + loop { + let no_outstanding = outstanding_tasks_clone.lock().is_empty(); + // An empty map alone isn't "all activities finished": completions + // flushing to server have already left the map but still hold their + // slot permit, and still need the heartbeat manager alive. Tasks only + // ever leave the map inside a counted completion, so every relevant + // transition ends in a counter change and waiting on the counter + // alone can't miss one. + if no_outstanding && *completions_in_flight.borrow_and_update() == 0 { + break; + } + if completions_in_flight.changed().await.is_err() { + // Sender closed: the manager (and any completion guards, which + // hold sender clones) are gone, so nothing further can flush. + break; + } } // If we were waiting for the grace period but everything already finished, // we don't need to keep waiting. From 95b63453c18c3cb79a882a1213a4686cb86c6153 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Thu, 27 Aug 2026 17:04:11 -0400 Subject: [PATCH 35/87] feat(sdk): add `WorkflowHandle::get_update_handle` (#1540) --- CHANGELOG.md | 2 ++ crates/client/src/workflow_handle.rs | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aff02ee8..60ab0096e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ relevant information. ### Added * `LocalActivityOptions::include_arguments_into_marker` allows Rust workflows to opt in to recording local activity arguments in Workflow history. +* `WorkflowHandle::get_update_handle` creates a typed handle for an existing Workflow Update from + its update ID, allowing callers to wait for the result independently of the original handle. * `WorkflowContext::all_handlers_finished` and `SyncWorkflowContext::all_handlers_finished` let Rust workflows wait for active signal and update handler chains before completing or continuing as new. diff --git a/crates/client/src/workflow_handle.rs b/crates/client/src/workflow_handle.rs index 075c4f51a..5ba521132 100644 --- a/crates/client/src/workflow_handle.rs +++ b/crates/client/src/workflow_handle.rs @@ -989,6 +989,29 @@ where )) } + /// Get a handle to an existing update. + /// + /// The update definition determines the result type. The returned handle uses this workflow + /// handle's workflow and run IDs and does not validate the update ID until + /// [`get_result`](WorkflowUpdateHandle::get_result) is called. + pub fn get_update_handle( + &self, + update: U, + update_id: impl Into, + ) -> WorkflowUpdateHandle + where + U: UpdateDefinition, + { + let _ = update; + WorkflowUpdateHandle::new( + self.client.clone(), + update_id.into(), + self.info.workflow_id.clone(), + self.info.run_id.clone(), + None, + ) + } + /// Request cancellation of this workflow. pub async fn cancel(&self, opts: WorkflowCancelOptions) -> Result<(), WorkflowInteractionError> where From 3393e5763d21b6cb8eda6b3e223bfcbee882f81c Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Thu, 27 Aug 2026 15:07:12 -0700 Subject: [PATCH 36/87] Use shared changelog workflow (#1535) --- .github/workflows/changelog.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/changelog.yml diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 000000000..e5554a225 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,14 @@ +name: Changelog + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + pull-requests: read + +jobs: + changelog-check: + name: Changelog checkpoint + uses: temporalio/.github/.github/workflows/changelog.yml@d8129c755fbd1d3f18c5a7a5420d5754acc63e3f From ec334fa900c2566cbde3b1a05bcbc1f5724fd692 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:28:13 -0400 Subject: [PATCH 37/87] build(deps): bump hoverkraft-tech/compose-action from 2.6.0 to 3.1.0 (#1522) Bumps [hoverkraft-tech/compose-action](https://github.com/hoverkraft-tech/compose-action) from 2.6.0 to 3.1.0. - [Release notes](https://github.com/hoverkraft-tech/compose-action/releases) - [Commits](https://github.com/hoverkraft-tech/compose-action/compare/d2bee4f07e8ca410d6b196d00f90c12e7d48c33a...ee6af68587292d72db67743171809c19787df4c9) --- updated-dependencies: - dependency-name: hoverkraft-tech/compose-action dependency-version: 3.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/per-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/per-pr.yml b/.github/workflows/per-pr.yml index 11325303a..b66f3e12d 100644 --- a/.github/workflows/per-pr.yml +++ b/.github/workflows/per-pr.yml @@ -255,7 +255,7 @@ jobs: with: mise-install-args: protoc - name: Start container for otel-collector and prometheus - uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 + uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0 with: compose-file: ./etc/docker/docker-compose-ci.yaml - run: cargo integ-test docker_ From b16adf5661f650af706e32f91eb3a0016b44419e Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 31 Aug 2026 12:11:33 -0400 Subject: [PATCH 38/87] chore(docs): add missing readmes (#1551) --- crates/common-wasm/README.md | 8 ++++++++ crates/common/README.md | 8 ++++++++ crates/macros/README.md | 8 ++++++++ crates/protos/README.md | 11 +++++++++++ crates/sdk-core/README.md | 11 +++++++++++ crates/workflow/README.md | 8 ++++++++ 6 files changed, 54 insertions(+) create mode 100644 crates/common-wasm/README.md create mode 100644 crates/common/README.md create mode 100644 crates/macros/README.md create mode 100644 crates/protos/README.md create mode 100644 crates/sdk-core/README.md create mode 100644 crates/workflow/README.md diff --git a/crates/common-wasm/README.md b/crates/common-wasm/README.md new file mode 100644 index 000000000..722c68f06 --- /dev/null +++ b/crates/common-wasm/README.md @@ -0,0 +1,8 @@ +# `temporalio-common-wasm` + +[![crates.io](https://img.shields.io/crates/v/temporalio-common-wasm.svg)](https://crates.io/crates/temporalio-common-wasm) +[![docs.rs](https://docs.rs/temporalio-common-wasm/badge.svg)](https://docs.rs/temporalio-common-wasm) + +Part of [Temporal](https://temporal.io)'s [Rust SDK](https://github.com/temporalio/sdk-rust). + +WASM-safe shared types, serialization, and protobuf support for authoring Temporal Workflows. diff --git a/crates/common/README.md b/crates/common/README.md new file mode 100644 index 000000000..f4fe4cee6 --- /dev/null +++ b/crates/common/README.md @@ -0,0 +1,8 @@ +# `temporalio-common` + +[![crates.io](https://img.shields.io/crates/v/temporalio-common.svg)](https://crates.io/crates/temporalio-common) +[![docs.rs](https://docs.rs/temporalio-common/badge.svg)](https://docs.rs/temporalio-common) + +Part of [Temporal](https://temporal.io)'s [Rust SDK](https://github.com/temporalio/sdk-rust). + +Shared types and functionality used by Temporal SDK Core, the Rust Client, and the Rust SDK. diff --git a/crates/macros/README.md b/crates/macros/README.md new file mode 100644 index 000000000..7ad6f207f --- /dev/null +++ b/crates/macros/README.md @@ -0,0 +1,8 @@ +# `temporalio-macros` + +[![crates.io](https://img.shields.io/crates/v/temporalio-macros.svg)](https://crates.io/crates/temporalio-macros) +[![docs.rs](https://docs.rs/temporalio-macros/badge.svg)](https://docs.rs/temporalio-macros) + +Part of [Temporal](https://temporal.io)'s [Rust SDK](https://github.com/temporalio/sdk-rust). + +Procedural macros for defining Temporal Workflows and Activities in Rust. diff --git a/crates/protos/README.md b/crates/protos/README.md new file mode 100644 index 000000000..6977da733 --- /dev/null +++ b/crates/protos/README.md @@ -0,0 +1,11 @@ +# `temporalio-protos` + +[![crates.io](https://img.shields.io/crates/v/temporalio-protos.svg)](https://crates.io/crates/temporalio-protos) +[![docs.rs](https://docs.rs/temporalio-protos/badge.svg)](https://docs.rs/temporalio-protos) + +Part of [Temporal](https://temporal.io)'s [Rust SDK](https://github.com/temporalio/sdk-rust). + +Compiled protobuf definitions for Temporal APIs and SDK Core protocols. + +Most Rust SDK users should use [`temporalio-client`](https://crates.io/crates/temporalio-client) or +[`temporalio-sdk`](https://crates.io/crates/temporalio-sdk) instead. diff --git a/crates/sdk-core/README.md b/crates/sdk-core/README.md new file mode 100644 index 000000000..18528a6f9 --- /dev/null +++ b/crates/sdk-core/README.md @@ -0,0 +1,11 @@ +# `temporalio-sdk-core` + +[![crates.io](https://img.shields.io/crates/v/temporalio-sdk-core.svg)](https://crates.io/crates/temporalio-sdk-core) +[![docs.rs](https://docs.rs/temporalio-sdk-core/badge.svg)](https://docs.rs/temporalio-sdk-core) + +Part of [Temporal](https://temporal.io)'s [Rust SDK](https://github.com/temporalio/sdk-rust). + +The shared worker runtime used to build Temporal language SDKs. + +Its APIs are intended for SDK implementers and may change without notice. Rust application authors +should use [`temporalio-sdk`](https://crates.io/crates/temporalio-sdk) instead. diff --git a/crates/workflow/README.md b/crates/workflow/README.md new file mode 100644 index 000000000..297dcbbcc --- /dev/null +++ b/crates/workflow/README.md @@ -0,0 +1,8 @@ +# `temporalio-workflow` + +[![crates.io](https://img.shields.io/crates/v/temporalio-workflow.svg)](https://crates.io/crates/temporalio-workflow) +[![docs.rs](https://docs.rs/temporalio-workflow/badge.svg)](https://docs.rs/temporalio-workflow) + +Part of [Temporal](https://temporal.io)'s [Rust SDK](https://github.com/temporalio/sdk-rust). + +APIs and runtime support for authoring Temporal Workflows in native Rust and WASM components. From f9d3d5b4a441980cea55bd88af001b9ef4dca72d Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 31 Aug 2026 13:51:42 -0400 Subject: [PATCH 39/87] fix(sdk): encode None as binary/null (#1543) --- CHANGELOG.md | 2 + crates/common-wasm/src/data_converters.rs | 100 ++++++++++++++---- .../tests/integ_tests/data_converter_tests.rs | 54 ++++++++++ 3 files changed, 135 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ab0096e..2908ae62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,8 @@ relevant information. `Waiting for all slot permits to release took too long!`, and release builds logged that error and dropped the result, leaving the server to time the activity out before retrying it. Shutdown now drains in-flight completions first. +* The default payload converter now serializes Serde `null` values such as `Option::None` as + `binary/null` and accepts both `binary/null` and legacy `json/plain` null payloads. * The Prometheus exporter now respects `PrometheusExporterOptions::counters_total_suffix`, appending `_total` to counter metric names when enabled. * Workflow start requests now include the client's identity. diff --git a/crates/common-wasm/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs index 65375b07d..254d05c27 100644 --- a/crates/common-wasm/src/data_converters.rs +++ b/crates/common-wasm/src/data_converters.rs @@ -488,15 +488,7 @@ impl GenericPayloadConverter for PayloadConverter { ) -> Result { // If a single payload is explicitly needed for `()`, then produce a null payload if std::any::TypeId::of::() == std::any::TypeId::of::<()>() { - return Ok(Payload { - metadata: { - let mut hm = HashMap::new(); - hm.insert("encoding".to_string(), b"binary/null".to_vec()); - hm - }, - data: vec![], - external_payloads: vec![], - }); + return Ok(binary_null_payload()); } let mut payloads = self.to_payloads(context, val)?; if payloads.len() != 1 { @@ -578,17 +570,31 @@ impl GenericPayloadConverter for PayloadConverter { } } +fn binary_null_payload() -> Payload { + Payload { + metadata: { + let mut hm = HashMap::new(); + hm.insert("encoding".to_string(), b"binary/null".to_vec()); + hm + }, + data: vec![], + external_payloads: vec![], + } +} + +fn is_binary_null_payload(payload: &Payload) -> bool { + payload.data.is_empty() + && payload + .metadata + .get("encoding") + .map(|encoding| encoding == b"binary/null") + .unwrap_or(false) +} + fn is_unit_payloads(payloads: &[Payload]) -> bool { match payloads { [] => true, - [payload] => { - payload.data.is_empty() - && payload - .metadata - .get("encoding") - .map(|encoding| encoding == b"binary/null") - .unwrap_or(false) - } + [payload] => is_binary_null_payload(payload), _ => false, } } @@ -629,6 +635,9 @@ impl ErasedSerdePayloadConverter for SerdeJsonPayloadConverter { ) -> Result { let as_json = serde_json::to_vec(value) .map_err(|e| PayloadConversionError::EncodingError(e.into()))?; + if as_json.as_slice() == b"null" { + return Ok(binary_null_payload()); + } Ok(Payload { metadata: { let mut hm = HashMap::new(); @@ -646,11 +655,14 @@ impl ErasedSerdePayloadConverter for SerdeJsonPayloadConverter { payload: Payload, ) -> Result>, PayloadConversionError> { let encoding = payload.metadata.get("encoding").map(|v| v.as_slice()); - if encoding != Some(b"json/plain".as_slice()) { + let json_v = if encoding == Some(b"json/plain".as_slice()) { + serde_json::from_slice(&payload.data) + .map_err(|e| PayloadConversionError::EncodingError(Box::new(e)))? + } else if encoding == Some(b"binary/null".as_slice()) { + serde_json::Value::Null + } else { return Err(PayloadConversionError::WrongEncoding); - } - let json_v: serde_json::Value = serde_json::from_slice(&payload.data) - .map_err(|e| PayloadConversionError::EncodingError(Box::new(e)))?; + }; Ok(Box::new(::erase(json_v))) } } @@ -882,6 +894,52 @@ mod tests { assert_eq!(result, args); } + #[test] + fn option_none_uses_binary_null_and_accepts_legacy_json_null() { + let converter = PayloadConverter::default(); + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + + let payloads = converter + .to_payloads(&ctx, &Option::::None) + .unwrap(); + assert_eq!(payloads.len(), 1); + assert!(is_binary_null_payload(&payloads[0])); + + let result: Option = converter + .from_payload(&ctx, payloads.into_iter().next().unwrap()) + .unwrap(); + assert_eq!(result, None); + + let legacy_json_null = Payload { + metadata: HashMap::from([("encoding".to_string(), b"json/plain".to_vec())]), + data: b"null".to_vec(), + external_payloads: vec![], + }; + let result: Option = converter.from_payload(&ctx, legacy_json_null).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn non_null_option_retains_json_encoding() { + let converter = PayloadConverter::default(); + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + + let payload = converter + .to_payload(&ctx, &Some("value".to_string())) + .unwrap(); + assert_eq!(payload.metadata.get("encoding").unwrap(), b"json/plain"); + assert_eq!(payload.data, br#""value""#); + } + + #[test] + fn empty_payloads_do_not_decode_as_option() { + let converter = PayloadConverter::default(); + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + + let result: Result, _> = converter.from_payloads(&ctx, vec![]); + assert!(matches!(result, Err(PayloadConversionError::WrongEncoding))); + } + #[test] fn multi_args_from_tuple() { let args: MultiArgs2 = ("hello".to_string(), 42i32).into(); diff --git a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs index 253f46db5..a688d2072 100644 --- a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs +++ b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs @@ -756,6 +756,60 @@ async fn multi_args_serializes_as_multiple_payloads() { assert_eq!(second_payload_data, 42); } +#[workflow] +#[derive(Default)] +struct BinaryNullWorkflow; + +#[workflow_methods] +impl BinaryNullWorkflow { + #[run] + async fn run(_ctx: &mut WorkflowContext, input: Option) -> WorkflowResult<()> { + assert_eq!(input, None); + Ok(()) + } +} + +#[tokio::test] +async fn option_none_workflow_input_is_recorded_as_binary_null() { + let wf_name = BinaryNullWorkflow::name(); + let mut starter = CoreWfStarter::new(wf_name); + starter + .sdk_config + .register_workflow::() + .unwrap(); + let mut worker = starter.worker().await; + let handle = worker + .submit_workflow( + BinaryNullWorkflow::run, + Option::::None, + WorkflowStartOptions::new(starter.get_task_queue(), wf_name).build(), + ) + .await + .unwrap(); + worker.run_until_done().await.unwrap(); + + let events = handle + .fetch_history(Default::default()) + .await + .unwrap() + .into_events(); + let input = events + .iter() + .find_map(|event| match event.attributes.as_ref() { + Some(Attributes::WorkflowExecutionStartedEventAttributes(attributes)) => { + attributes.input.as_ref() + } + _ => None, + }) + .unwrap(); + assert_eq!(input.payloads.len(), 1); + assert_eq!( + input.payloads[0].metadata.get("encoding").unwrap(), + b"binary/null" + ); + assert!(input.payloads[0].data.is_empty()); +} + /// A codec that XORs payload data with a key and tracks encode/decode operations. struct XorCodec { key: u8, From 762e2b86eaf957b3043c7273ff60d22f9f5c7738 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 31 Aug 2026 13:51:43 -0400 Subject: [PATCH 40/87] fix(sdk): encode Vec as binary/plain (#1547) * feat(sdk): add Binary wrapper type for binary/plain encoding * fix(sdk): properly serialize Vec --- CHANGELOG.md | 2 + crates/common-wasm/src/data_converters.rs | 106 +++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2908ae62e..dc1746c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,8 @@ relevant information. non-sticky poller, so the worker would stop picking up new workflows until a poll timed out (up to ~60s). The poll balancer now reserves a non-sticky slot against the cache size rather than the slot-supplier size. +* The default payload converter now encodes `Vec` and `Option>` as `binary/plain` when + present. ## [0.7.0] - 2026-08-17 diff --git a/crates/common-wasm/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs index 254d05c27..9381d30c9 100644 --- a/crates/common-wasm/src/data_converters.rs +++ b/crates/common-wasm/src/data_converters.rs @@ -516,6 +516,15 @@ impl GenericPayloadConverter for PayloadConverter { // no payloads for it. if std::any::TypeId::of::() == std::any::TypeId::of::<()>() { Ok(Vec::new()) + } else if let Some(bytes) = (val as &dyn std::any::Any).downcast_ref::>() { + Ok(vec![binary_plain_payload(bytes.clone())]) + } else if let Some(bytes) = + (val as &dyn std::any::Any).downcast_ref::>>() + { + Ok(vec![match bytes { + Some(bytes) => binary_plain_payload(bytes.clone()), + None => binary_null_payload(), + }]) } else { Ok(vec![pc.to_payload(context.data, val.as_serde()?)?]) } @@ -553,7 +562,24 @@ impl GenericPayloadConverter for PayloadConverter { if payloads.len() != 1 { return Err(PayloadConversionError::WrongEncoding); } - T::from_serde(pc.as_ref(), context, payloads.into_iter().next().unwrap()) + let payload = payloads.into_iter().next().unwrap(); + if std::any::TypeId::of::() == std::any::TypeId::of::>() + && is_binary_plain_payload(&payload) + { + let boxed: Box = Box::new(payload.data); + return Ok(*boxed.downcast::().unwrap()); + } + if std::any::TypeId::of::() == std::any::TypeId::of::>>() { + if is_binary_plain_payload(&payload) { + let boxed: Box = Box::new(Some(payload.data)); + return Ok(*boxed.downcast::().unwrap()); + } + if is_binary_null_payload(&payload) { + let boxed: Box = Box::new(None::>); + return Ok(*boxed.downcast::().unwrap()); + } + } + T::from_serde(pc.as_ref(), context, payload) } PayloadConverter::UseWrappers => T::from_payloads(context, payloads), PayloadConverter::Composite(composite) => { @@ -570,6 +596,21 @@ impl GenericPayloadConverter for PayloadConverter { } } +fn binary_plain_payload(data: Vec) -> Payload { + Payload { + metadata: HashMap::from([("encoding".to_string(), b"binary/plain".to_vec())]), + data, + external_payloads: vec![], + } +} + +fn is_binary_plain_payload(payload: &Payload) -> bool { + payload + .metadata + .get("encoding") + .is_some_and(|encoding| encoding == b"binary/plain") +} + fn binary_null_payload() -> Payload { Payload { metadata: { @@ -870,7 +911,7 @@ mod tests { } #[test] - fn test_unit_use_wrappers_returns_wrong_encoding() { + fn use_wrappers_returns_wrong_encoding_for_standard_types() { let converter = PayloadConverter::UseWrappers; let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); @@ -879,6 +920,12 @@ mod tests { matches!(result, Err(PayloadConversionError::WrongEncoding)), "{result:?}" ); + + let result = converter.to_payloads(&ctx, &vec![1_u8, 2, 3]); + assert!( + matches!(result, Err(PayloadConversionError::WrongEncoding)), + "{result:?}" + ); } #[test] @@ -931,6 +978,61 @@ mod tests { assert_eq!(payload.data, br#""value""#); } + #[test] + fn vec_u8_uses_binary_plain_and_accepts_legacy_json() { + let converter = PayloadConverter::default(); + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + + let payload = converter.to_payload(&ctx, &vec![0_u8, 1, 2, 255]).unwrap(); + assert_eq!(payload.metadata.get("encoding").unwrap(), b"binary/plain"); + assert_eq!(payload.data, vec![0, 1, 2, 255]); + + let result: Vec = converter.from_payload(&ctx, payload).unwrap(); + assert_eq!(result, vec![0, 1, 2, 255]); + + let legacy_json = Payload { + metadata: HashMap::from([("encoding".to_string(), b"json/plain".to_vec())]), + data: b"[3,2,1]".to_vec(), + external_payloads: vec![], + }; + let result: Vec = converter.from_payload(&ctx, legacy_json).unwrap(); + assert_eq!(result, vec![3, 2, 1]); + } + + #[test] + fn option_vec_u8_uses_binary_encodings_and_accepts_legacy_json() { + let converter = PayloadConverter::default(); + let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + + let payload = converter.to_payload(&ctx, &Some(vec![1_u8, 2, 3])).unwrap(); + assert_eq!(payload.metadata.get("encoding").unwrap(), b"binary/plain"); + let result: Option> = converter.from_payload(&ctx, payload).unwrap(); + assert_eq!(result, Some(vec![1, 2, 3])); + + let payload = converter + .to_payload(&ctx, &Option::>::None) + .unwrap(); + assert!(is_binary_null_payload(&payload)); + let result: Option> = converter.from_payload(&ctx, payload).unwrap(); + assert_eq!(result, None); + + let legacy_json = Payload { + metadata: HashMap::from([("encoding".to_string(), b"json/plain".to_vec())]), + data: b"[3,2,1]".to_vec(), + external_payloads: vec![], + }; + let result: Option> = converter.from_payload(&ctx, legacy_json).unwrap(); + assert_eq!(result, Some(vec![3, 2, 1])); + + let legacy_json_null = Payload { + metadata: HashMap::from([("encoding".to_string(), b"json/plain".to_vec())]), + data: b"null".to_vec(), + external_payloads: vec![], + }; + let result: Option> = converter.from_payload(&ctx, legacy_json_null).unwrap(); + assert_eq!(result, None); + } + #[test] fn empty_payloads_do_not_decode_as_option() { let converter = PayloadConverter::default(); From 79bb0ae21aead04431d3227dd7934dae565dfe60 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 31 Aug 2026 13:51:45 -0400 Subject: [PATCH 41/87] chore(sdk): refactor data converter (#1549) * chore(sdk): clean up data converter special cases * chore: dedupe dataconverter tests --- crates/common-wasm/src/data_converters.rs | 427 +++++++----------- .../src/data_converters/well_known.rs | 123 +++++ 2 files changed, 293 insertions(+), 257 deletions(-) create mode 100644 crates/common-wasm/src/data_converters/well_known.rs diff --git a/crates/common-wasm/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs index 9381d30c9..b250e3087 100644 --- a/crates/common-wasm/src/data_converters.rs +++ b/crates/common-wasm/src/data_converters.rs @@ -2,17 +2,21 @@ //! serialization related functionality. mod failure_converter; +mod well_known; pub use failure_converter::{ ActivityExecutionDecodeHint, ChildWorkflowExecutionDecodeHint, ChildWorkflowStartDecodeHint, DefaultFailureConverter, FailureConverter, FailureDecodeHint, NoopDecodeHint, WorkflowSignalDecodeHint, }; +use well_known::{BINARY_NULL_ENCODING_VAL, WellKnownType, binary_null_payload}; -use crate::protos::temporal::api::common::v1::Payload; +use crate::protos::{ENCODING_PAYLOAD_KEY, JSON_ENCODING_VAL, temporal::api::common::v1::Payload}; use futures::{FutureExt, future::BoxFuture}; use std::{collections::HashMap, sync::Arc}; +const PROTOBUF_ENCODING_VAL: &str = "binary/protobuf"; + /// Combines a [`PayloadConverter`], [`FailureConverter`], and [`PayloadCodec`] to handle all /// serialization needs for communicating with the Temporal server. #[derive(Clone)] @@ -486,23 +490,55 @@ impl GenericPayloadConverter for PayloadConverter { context: &SerializationContext<'_>, val: &T, ) -> Result { - // If a single payload is explicitly needed for `()`, then produce a null payload - if std::any::TypeId::of::() == std::any::TypeId::of::<()>() { - return Ok(binary_null_payload()); - } - let mut payloads = self.to_payloads(context, val)?; - if payloads.len() != 1 { - return Err(PayloadConversionError::WrongEncoding); + match self { + PayloadConverter::Serde(pc) => { + if let Some(well_known_type) = WellKnownType::of::() { + Ok(well_known_type.to_payload(val)) + } else { + pc.to_payload(context.data, val.as_serde()?) + } + } + PayloadConverter::UseWrappers => T::to_payload(val, context), + PayloadConverter::Composite(composite) => { + for converter in &composite.converters { + match converter.to_payload(context, val) { + Ok(payload) => return Ok(payload), + Err(PayloadConversionError::WrongEncoding) => continue, + Err(e) => return Err(e), + } + } + Err(PayloadConversionError::WrongEncoding) + } } - Ok(payloads.pop().unwrap()) } fn from_payload( &self, context: &SerializationContext<'_>, - payload: Payload, + mut payload: Payload, ) -> Result { - self.from_payloads(context, vec![payload]) + match self { + PayloadConverter::Serde(pc) => { + if let Some(well_known_type) = WellKnownType::of::() { + payload = match well_known_type.try_from_payload(payload) { + Ok(value) => return Ok(value), + Err(payload) => payload, + }; + } + T::from_serde(pc.as_ref(), context, payload) + } + PayloadConverter::UseWrappers => T::from_payload(context, payload), + PayloadConverter::Composite(composite) => { + for converter in &composite.converters { + match converter.from_payload(context, payload.clone()) { + Ok(value) => return Ok(value), + Err(PayloadConversionError::WrongEncoding) => continue, + Err(e) => return Err(e), + } + } + Err(PayloadConversionError::WrongEncoding) + } + } } fn to_payloads( @@ -512,19 +548,8 @@ impl GenericPayloadConverter for PayloadConverter { ) -> Result, PayloadConversionError> { match self { PayloadConverter::Serde(pc) => { - // Since Rust SDK uses () to denote no input, we must match other SDKs by producing - // no payloads for it. - if std::any::TypeId::of::() == std::any::TypeId::of::<()>() { - Ok(Vec::new()) - } else if let Some(bytes) = (val as &dyn std::any::Any).downcast_ref::>() { - Ok(vec![binary_plain_payload(bytes.clone())]) - } else if let Some(bytes) = - (val as &dyn std::any::Any).downcast_ref::>>() - { - Ok(vec![match bytes { - Some(bytes) => binary_plain_payload(bytes.clone()), - None => binary_null_payload(), - }]) + if let Some(well_known_type) = WellKnownType::of::() { + Ok(well_known_type.to_payloads(val)) } else { Ok(vec![pc.to_payload(context.data, val.as_serde()?)?]) } @@ -546,39 +571,20 @@ impl GenericPayloadConverter for PayloadConverter { fn from_payloads( &self, context: &SerializationContext<'_>, - payloads: Vec, + mut payloads: Vec, ) -> Result { - // Accept empty payloads (no args) and a single binary/null payload (result from a - // workflow/update with () return type as (). - if std::any::TypeId::of::() == std::any::TypeId::of::<()>() - && is_unit_payloads(&payloads) - { - let boxed: Box = Box::new(()); - return Ok(*boxed.downcast::().unwrap()); - } - match self { PayloadConverter::Serde(pc) => { + if let Some(well_known_type) = WellKnownType::of::() { + payloads = match well_known_type.try_from_payloads(payloads) { + Ok(value) => return Ok(value), + Err(payloads) => payloads, + }; + } if payloads.len() != 1 { return Err(PayloadConversionError::WrongEncoding); } let payload = payloads.into_iter().next().unwrap(); - if std::any::TypeId::of::() == std::any::TypeId::of::>() - && is_binary_plain_payload(&payload) - { - let boxed: Box = Box::new(payload.data); - return Ok(*boxed.downcast::().unwrap()); - } - if std::any::TypeId::of::() == std::any::TypeId::of::>>() { - if is_binary_plain_payload(&payload) { - let boxed: Box = Box::new(Some(payload.data)); - return Ok(*boxed.downcast::().unwrap()); - } - if is_binary_null_payload(&payload) { - let boxed: Box = Box::new(None::>); - return Ok(*boxed.downcast::().unwrap()); - } - } T::from_serde(pc.as_ref(), context, payload) } PayloadConverter::UseWrappers => T::from_payloads(context, payloads), @@ -596,50 +602,6 @@ impl GenericPayloadConverter for PayloadConverter { } } -fn binary_plain_payload(data: Vec) -> Payload { - Payload { - metadata: HashMap::from([("encoding".to_string(), b"binary/plain".to_vec())]), - data, - external_payloads: vec![], - } -} - -fn is_binary_plain_payload(payload: &Payload) -> bool { - payload - .metadata - .get("encoding") - .is_some_and(|encoding| encoding == b"binary/plain") -} - -fn binary_null_payload() -> Payload { - Payload { - metadata: { - let mut hm = HashMap::new(); - hm.insert("encoding".to_string(), b"binary/null".to_vec()); - hm - }, - data: vec![], - external_payloads: vec![], - } -} - -fn is_binary_null_payload(payload: &Payload) -> bool { - payload.data.is_empty() - && payload - .metadata - .get("encoding") - .map(|encoding| encoding == b"binary/null") - .unwrap_or(false) -} - -fn is_unit_payloads(payloads: &[Payload]) -> bool { - match payloads { - [] => true, - [payload] => is_binary_null_payload(payload), - _ => false, - } -} - // TODO [rust-sdk-branch]: Potentially allow opt-out / no-serde compile flags impl TemporalSerializable for T where @@ -682,7 +644,10 @@ impl ErasedSerdePayloadConverter for SerdeJsonPayloadConverter { Ok(Payload { metadata: { let mut hm = HashMap::new(); - hm.insert("encoding".to_string(), b"json/plain".to_vec()); + hm.insert( + ENCODING_PAYLOAD_KEY.to_string(), + JSON_ENCODING_VAL.as_bytes().to_vec(), + ); hm }, data: as_json, @@ -695,11 +660,14 @@ impl ErasedSerdePayloadConverter for SerdeJsonPayloadConverter { _: &SerializationContextData, payload: Payload, ) -> Result>, PayloadConversionError> { - let encoding = payload.metadata.get("encoding").map(|v| v.as_slice()); - let json_v = if encoding == Some(b"json/plain".as_slice()) { + let encoding = payload + .metadata + .get(ENCODING_PAYLOAD_KEY) + .map(|v| v.as_slice()); + let json_v = if encoding == Some(JSON_ENCODING_VAL.as_bytes()) { serde_json::from_slice(&payload.data) .map_err(|e| PayloadConversionError::EncodingError(Box::new(e)))? - } else if encoding == Some(b"binary/null".as_slice()) { + } else if encoding == Some(BINARY_NULL_ENCODING_VAL.as_bytes()) { serde_json::Value::Null } else { return Err(PayloadConversionError::WrongEncoding); @@ -738,7 +706,10 @@ where Ok(Payload { metadata: { let mut hm = HashMap::new(); - hm.insert("encoding".to_string(), b"binary/protobuf".to_vec()); + hm.insert( + ENCODING_PAYLOAD_KEY.to_string(), + PROTOBUF_ENCODING_VAL.as_bytes().to_vec(), + ); hm }, data: as_proto, @@ -757,8 +728,8 @@ where where Self: Sized, { - let encoding = p.metadata.get("encoding").map(|v| v.as_slice()); - if encoding != Some(b"binary/protobuf".as_slice()) { + let encoding = p.metadata.get(ENCODING_PAYLOAD_KEY).map(|v| v.as_slice()); + if encoding != Some(PROTOBUF_ENCODING_VAL.as_bytes()) { return Err(PayloadConversionError::WrongEncoding); } T::decode(p.data.as_slice()) @@ -861,20 +832,11 @@ impl_multi_args!(MultiArgs6; 6; 0: A, 1: B, 2: C, 3: D, 4: E, 5: F); #[cfg(test)] mod tests { use super::*; + use crate::data_converters::well_known::BINARY_PLAIN_ENCODING_VAL; + use rstest::rstest; #[test] - fn test_empty_payloads_as_unit_type() { - let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); - - let empty_payloads: Vec = vec![]; - let result: Result<(), _> = converter.from_payloads(&ctx, empty_payloads); - - assert!(result.is_ok(), "Empty payloads should deserialize as ()"); - } - - #[test] - fn test_unit_type_roundtrip_serde() { + fn unit_payloads_roundtrip() { let converter = PayloadConverter::serde_json(); let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); @@ -885,29 +847,79 @@ mod tests { assert_eq!(result, ()); } - #[test] - fn test_unit_composite_roundtrip() { + #[rstest] + #[case::unit((), BINARY_NULL_ENCODING_VAL, b"")] + #[case::none_string(Option::::None, BINARY_NULL_ENCODING_VAL, b"")] + #[case::some_string( + Some("value".to_string()), + JSON_ENCODING_VAL, + br#""value""# + )] + #[case::bytes(vec![0_u8, 1, 2, 255], BINARY_PLAIN_ENCODING_VAL, &[0, 1, 2, 255])] + #[case::some_bytes( + Some(vec![1_u8, 2, 3]), + BINARY_PLAIN_ENCODING_VAL, + &[1, 2, 3] + )] + #[case::none_bytes(Option::>::None, BINARY_NULL_ENCODING_VAL, b"")] + fn value_encodes_as( + #[case] value: T, + #[case] expected_encoding: &str, + #[case] expected_data: &[u8], + ) where + T: TemporalSerializable + std::fmt::Debug + 'static, + { let converter = PayloadConverter::default(); let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); - let payloads = converter.to_payloads(&ctx, &()).unwrap(); - assert!(payloads.is_empty()); + let payload = converter.to_payload(&ctx, &value).unwrap(); - let result: () = converter.from_payloads(&ctx, payloads).unwrap(); - assert_eq!(result, ()); - } - - #[test] - fn test_unit_to_payload_roundtrip() { + assert_eq!( + payload.metadata.get(ENCODING_PAYLOAD_KEY).unwrap(), + expected_encoding.as_bytes() + ); + assert_eq!(payload.data, expected_data); + } + + #[rstest] + #[case::unit(BINARY_NULL_ENCODING_VAL, b"", ())] + #[case::none_string(BINARY_NULL_ENCODING_VAL, b"", Option::::None)] + #[case::legacy_none_string(JSON_ENCODING_VAL, b"null", Option::::None)] + #[case::bytes(BINARY_PLAIN_ENCODING_VAL, &[0, 1, 2, 255], vec![0_u8, 1, 2, 255])] + #[case::legacy_bytes(JSON_ENCODING_VAL, b"[3,2,1]", vec![3_u8, 2, 1])] + #[case::some_bytes( + BINARY_PLAIN_ENCODING_VAL, + &[1, 2, 3], + Some(vec![1_u8, 2, 3]) + )] + #[case::none_bytes(BINARY_NULL_ENCODING_VAL, b"", Option::>::None)] + #[case::legacy_some_bytes( + JSON_ENCODING_VAL, + b"[3,2,1]", + Some(vec![3_u8, 2, 1]) + )] + #[case::legacy_none_bytes(JSON_ENCODING_VAL, b"null", Option::>::None)] + fn payload_decodes_as(#[case] encoding: &str, #[case] data: &[u8], #[case] expected: T) + where + T: TemporalDeserializable + std::fmt::Debug + PartialEq + 'static, + { let converter = PayloadConverter::default(); let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); - let mut payloads = vec![converter.to_payload(&ctx, &()).unwrap()]; - assert!(is_unit_payloads(&payloads)); - let result: () = converter - .from_payload(&ctx, payloads.pop().unwrap()) + let actual: T = converter + .from_payload( + &ctx, + Payload { + metadata: HashMap::from([( + ENCODING_PAYLOAD_KEY.to_string(), + encoding.as_bytes().to_vec(), + )]), + data: data.to_vec(), + external_payloads: vec![], + }, + ) .unwrap(); - assert_eq!(result, ()); + assert_eq!(actual, expected); } #[test] @@ -915,6 +927,12 @@ mod tests { let converter = PayloadConverter::UseWrappers; let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let result = converter.to_payload(&ctx, &()); + assert!( + matches!(result, Err(PayloadConversionError::WrongEncoding)), + "{result:?}" + ); + let result = converter.to_payloads(&ctx, &()); assert!( matches!(result, Err(PayloadConversionError::WrongEncoding)), @@ -926,6 +944,12 @@ mod tests { matches!(result, Err(PayloadConversionError::WrongEncoding)), "{result:?}" ); + + let result: Result<(), _> = converter.from_payload(&ctx, binary_null_payload()); + assert!( + matches!(result, Err(PayloadConversionError::WrongEncoding)), + "{result:?}" + ); } #[test] @@ -941,98 +965,6 @@ mod tests { assert_eq!(result, args); } - #[test] - fn option_none_uses_binary_null_and_accepts_legacy_json_null() { - let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); - - let payloads = converter - .to_payloads(&ctx, &Option::::None) - .unwrap(); - assert_eq!(payloads.len(), 1); - assert!(is_binary_null_payload(&payloads[0])); - - let result: Option = converter - .from_payload(&ctx, payloads.into_iter().next().unwrap()) - .unwrap(); - assert_eq!(result, None); - - let legacy_json_null = Payload { - metadata: HashMap::from([("encoding".to_string(), b"json/plain".to_vec())]), - data: b"null".to_vec(), - external_payloads: vec![], - }; - let result: Option = converter.from_payload(&ctx, legacy_json_null).unwrap(); - assert_eq!(result, None); - } - - #[test] - fn non_null_option_retains_json_encoding() { - let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); - - let payload = converter - .to_payload(&ctx, &Some("value".to_string())) - .unwrap(); - assert_eq!(payload.metadata.get("encoding").unwrap(), b"json/plain"); - assert_eq!(payload.data, br#""value""#); - } - - #[test] - fn vec_u8_uses_binary_plain_and_accepts_legacy_json() { - let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); - - let payload = converter.to_payload(&ctx, &vec![0_u8, 1, 2, 255]).unwrap(); - assert_eq!(payload.metadata.get("encoding").unwrap(), b"binary/plain"); - assert_eq!(payload.data, vec![0, 1, 2, 255]); - - let result: Vec = converter.from_payload(&ctx, payload).unwrap(); - assert_eq!(result, vec![0, 1, 2, 255]); - - let legacy_json = Payload { - metadata: HashMap::from([("encoding".to_string(), b"json/plain".to_vec())]), - data: b"[3,2,1]".to_vec(), - external_payloads: vec![], - }; - let result: Vec = converter.from_payload(&ctx, legacy_json).unwrap(); - assert_eq!(result, vec![3, 2, 1]); - } - - #[test] - fn option_vec_u8_uses_binary_encodings_and_accepts_legacy_json() { - let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); - - let payload = converter.to_payload(&ctx, &Some(vec![1_u8, 2, 3])).unwrap(); - assert_eq!(payload.metadata.get("encoding").unwrap(), b"binary/plain"); - let result: Option> = converter.from_payload(&ctx, payload).unwrap(); - assert_eq!(result, Some(vec![1, 2, 3])); - - let payload = converter - .to_payload(&ctx, &Option::>::None) - .unwrap(); - assert!(is_binary_null_payload(&payload)); - let result: Option> = converter.from_payload(&ctx, payload).unwrap(); - assert_eq!(result, None); - - let legacy_json = Payload { - metadata: HashMap::from([("encoding".to_string(), b"json/plain".to_vec())]), - data: b"[3,2,1]".to_vec(), - external_payloads: vec![], - }; - let result: Option> = converter.from_payload(&ctx, legacy_json).unwrap(); - assert_eq!(result, Some(vec![3, 2, 1])); - - let legacy_json_null = Payload { - metadata: HashMap::from([("encoding".to_string(), b"json/plain".to_vec())]), - data: b"null".to_vec(), - external_payloads: vec![], - }; - let result: Option> = converter.from_payload(&ctx, legacy_json_null).unwrap(); - assert_eq!(result, None); - } - #[test] fn empty_payloads_do_not_decode_as_option() { let converter = PayloadConverter::default(); @@ -1048,49 +980,30 @@ mod tests { assert_eq!(args, MultiArgs2("hello".to_string(), 42)); } - fn decodable_from_value(value: &T) -> DecodablePayloads { + #[rstest] + #[case::string("hello".to_string())] + #[case::some_string(Some("hello".to_string()))] + #[case::none_string(Option::::None)] + #[case::unit(())] + #[case::strings(vec!["hello".to_string(), "world".to_string()])] + #[case::bytes(vec![1_u8, 2, 3])] + #[case::some_bytes(Some(vec![1_u8, 2, 3]))] + #[case::none_bytes(Option::>::None)] + fn decodable_payloads_roundtrip(#[case] value: T) + where + T: TemporalSerializable + TemporalDeserializable + std::fmt::Debug + PartialEq + 'static, + { let converter = PayloadConverter::default(); let payloads = converter .to_payloads( &SerializationContext::new(&SerializationContextData::Workflow, &converter), - value, + &value, ) .unwrap(); - DecodablePayloads::new(payloads, converter, SerializationContextData::Workflow) - } - #[test] - fn decodable_payloads_roundtrip_string() { - let payloads = decodable_from_value(&"hello".to_string()); - - let result: String = payloads.deserialize().unwrap(); - - assert_eq!(result, "hello"); - } - - #[test] - fn decodable_payloads_roundtrip_option_string() { - let payloads = decodable_from_value(&Some("hello".to_string())); - - let result: Option = payloads.deserialize().unwrap(); - - assert_eq!(result, Some("hello".to_string())); - } - - #[test] - fn decodable_payloads_roundtrip_unit() { - let payloads = decodable_from_value(&()); - - let result: () = payloads.deserialize().unwrap(); - - assert_eq!(result, ()); - } - - #[test] - fn decodable_payloads_roundtrip_vec_string() { - let payloads = decodable_from_value(&vec!["hello".to_string(), "world".to_string()]); - - let result: Vec = payloads.deserialize().unwrap(); + let payloads = + DecodablePayloads::new(payloads, converter, SerializationContextData::Workflow); - assert_eq!(result, vec!["hello".to_string(), "world".to_string()]); + let result: T = payloads.deserialize().unwrap(); + assert_eq!(result, value); } } diff --git a/crates/common-wasm/src/data_converters/well_known.rs b/crates/common-wasm/src/data_converters/well_known.rs new file mode 100644 index 000000000..9420d3c92 --- /dev/null +++ b/crates/common-wasm/src/data_converters/well_known.rs @@ -0,0 +1,123 @@ +use crate::protos::{ENCODING_PAYLOAD_KEY, temporal::api::common::v1::Payload}; +use std::{ + any::{Any, TypeId}, + collections::HashMap, +}; + +pub(super) const BINARY_PLAIN_ENCODING_VAL: &str = "binary/plain"; +pub(super) const BINARY_NULL_ENCODING_VAL: &str = "binary/null"; + +#[derive(Clone, Copy)] +pub(super) enum WellKnownType { + Unit, + Bytes, + OptionalBytes, +} + +impl WellKnownType { + pub(super) fn of() -> Option { + let type_id = TypeId::of::(); + if type_id == TypeId::of::<()>() { + Some(Self::Unit) + } else if type_id == TypeId::of::>() { + Some(Self::Bytes) + } else if type_id == TypeId::of::>>() { + Some(Self::OptionalBytes) + } else { + None + } + } + + pub(super) fn to_payload(self, value: &T) -> Payload { + let value = value as &dyn Any; + match self { + Self::Unit => binary_null_payload(), + Self::Bytes => binary_plain_payload(value.downcast_ref::>().unwrap().clone()), + Self::OptionalBytes => match value.downcast_ref::>>().unwrap() { + Some(bytes) => binary_plain_payload(bytes.clone()), + None => binary_null_payload(), + }, + } + } + + pub(super) fn to_payloads(self, value: &T) -> Vec { + match self { + Self::Unit => Vec::new(), + _ => vec![self.to_payload(value)], + } + } + + pub(super) fn try_from_payload(self, payload: Payload) -> Result { + match self { + Self::Unit if is_binary_null_payload(&payload) => Ok(downcast_well_known(())), + Self::Bytes if is_binary_plain_payload(&payload) => { + Ok(downcast_well_known(payload.data)) + } + Self::OptionalBytes if is_binary_plain_payload(&payload) => { + Ok(downcast_well_known(Some(payload.data))) + } + Self::OptionalBytes if is_binary_null_payload(&payload) => { + Ok(downcast_well_known(None::>)) + } + _ => Err(payload), + } + } + + pub(super) fn try_from_payloads( + self, + mut payloads: Vec, + ) -> Result> { + if matches!(self, Self::Unit) && payloads.is_empty() { + return Ok(downcast_well_known(())); + } + if payloads.len() != 1 { + return Err(payloads); + } + match self.try_from_payload(payloads.pop().unwrap()) { + Ok(value) => Ok(value), + Err(payload) => Err(vec![payload]), + } + } +} + +fn downcast_well_known(value: impl Any) -> T { + let value: Box = Box::new(value); + *value.downcast::().ok().unwrap() +} + +fn binary_plain_payload(data: Vec) -> Payload { + Payload { + metadata: HashMap::from([( + ENCODING_PAYLOAD_KEY.to_string(), + BINARY_PLAIN_ENCODING_VAL.as_bytes().to_vec(), + )]), + data, + external_payloads: vec![], + } +} + +fn is_binary_plain_payload(payload: &Payload) -> bool { + payload + .metadata + .get(ENCODING_PAYLOAD_KEY) + .is_some_and(|encoding| encoding == BINARY_PLAIN_ENCODING_VAL.as_bytes()) +} + +pub(super) fn binary_null_payload() -> Payload { + Payload { + metadata: HashMap::from([( + ENCODING_PAYLOAD_KEY.to_string(), + BINARY_NULL_ENCODING_VAL.as_bytes().to_vec(), + )]), + data: vec![], + external_payloads: vec![], + } +} + +pub(super) fn is_binary_null_payload(payload: &Payload) -> bool { + payload.data.is_empty() + && payload + .metadata + .get(ENCODING_PAYLOAD_KEY) + .is_some_and(|encoding| encoding == BINARY_NULL_ENCODING_VAL.as_bytes()) +} From 48e363df340052d83f8df9ebb8d1706c51020a99 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 31 Aug 2026 14:25:55 -0400 Subject: [PATCH 42/87] feat(sdk): make fetching workflow history lazy (#1552) --- CHANGELOG.md | 6 + crates/client/src/lib.rs | 2 +- crates/client/src/workflow_handle.rs | 450 +++++++++++++----- crates/sdk-core/src/histfetch.rs | 4 +- crates/sdk-core/tests/common/mod.rs | 6 +- .../tests/integ_tests/client_tests.rs | 1 + .../tests/integ_tests/data_converter_tests.rs | 11 +- .../tests/integ_tests/polling_tests.rs | 4 +- .../tests/integ_tests/update_tests.rs | 12 +- .../integ_tests/workflow_replayer_tests.rs | 110 +++-- .../integ_tests/workflow_tests/activities.rs | 7 +- .../workflow_tests/child_workflows.rs | 9 +- .../workflow_tests/local_activities.rs | 4 +- .../integ_tests/workflow_tests/patches.rs | 31 +- crates/sdk-core/tests/shared_tests/mod.rs | 2 +- .../sdk-core/tests/shared_tests/priority.rs | 4 +- crates/sdk/src/workflow_replayer.rs | 77 ++- 17 files changed, 527 insertions(+), 213 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1746c76..b6c8e30aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,12 @@ relevant information. `ClientInterceptor::update_with_start_workflow`. ### Breaking Changes :boom: +* `WorkflowHandle::fetch_history` now returns a lazy `WorkflowHistory` stream instead + of eagerly fetching every history page. Use `WorkflowHistory::into_events` if eager fetching + is desired. +* `WorkflowHistory::to_json` is now async, `WorkflowHistoryError` reports fetch and JSON conversion failures; and the eager `events`, + `Clone`, and `From for History` APIs have been removed. Replay results expose + their eagerly fetched events through `ReplayHistory`. * The following types are now non-exhaustive: `Priority`, `WorkerDeploymentVersion`, `WorkerCallbacks`, `WorkflowExecutionInfo`, `ActivityCloseTimeouts`, `ActivityExecutionDecodeHint`, child-workflow and signal decode hints, diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 4218aeeae..d01d9f37c 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -102,7 +102,7 @@ pub use tonic; pub use workflow_handle::{ UntypedQuery, UntypedSignal, UntypedUpdate, UntypedWorkflow, UntypedWorkflowHandle, WorkflowExecutionDescription, WorkflowExecutionInfo, WorkflowExecutionResult, WorkflowHandle, - WorkflowHistory, WorkflowHistoryJsonError, WorkflowResultDetails, WorkflowUpdateHandle, + WorkflowHistory, WorkflowHistoryError, WorkflowResultDetails, WorkflowUpdateHandle, }; pub use workflow_status::WorkflowExecutionStatus; diff --git a/crates/client/src/workflow_handle.rs b/crates/client/src/workflow_handle.rs index 5ba521132..927c451f9 100644 --- a/crates/client/src/workflow_handle.rs +++ b/crates/client/src/workflow_handle.rs @@ -13,8 +13,14 @@ use crate::{ grpc::WorkflowService, interceptors, }; -use futures_util::future::BoxFuture; -use std::{fmt::Debug, marker::PhantomData}; +use futures_util::{TryStreamExt, future::BoxFuture, stream, stream::Stream}; +use std::{ + collections::VecDeque, + fmt::Debug, + marker::PhantomData, + pin::Pin, + task::{Context, Poll}, +}; pub use temporalio_common::UntypedWorkflow; use temporalio_common::{ HasWorkflowDefinition, QueryDefinition, SignalDefinition, UpdateDefinition, WorkflowDefinition, @@ -328,53 +334,69 @@ impl WorkflowExecutionDescription { } } -// TODO [rust-sdk-branch]: Could implment stream a-la ListWorkflowsStream -/// Workflow execution history returned by `WorkflowHandle::fetch_history`. -#[derive(Debug, Clone)] +/// Workflow execution history returned by [`WorkflowHandle::fetch_history`]. +/// +/// Events and their containing pages are fetched lazily as this stream is polled. Use +/// [`into_events`](Self::into_events) to fetch and collect all events at once. +#[derive(derive_more::Debug)] pub struct WorkflowHistory { - events: Vec, + #[debug(skip)] + inner: Pin> + Send>>, workflow_id: Option, } -impl From for history::v1::History { - fn from(h: WorkflowHistory) -> Self { - Self { events: h.events } - } -} - -/// Error converting a workflow history to or from JSON. -#[derive(Debug, thiserror::Error)] -#[error("failed to convert workflow history JSON: {0}")] -pub struct WorkflowHistoryJsonError(#[from] serde_json::Error); -impl WorkflowHistory { - fn new(events: Vec, workflow_id: Option) -> Self { +impl From for WorkflowHistory { + fn from(history: history::v1::History) -> Self { + let workflow_id = + history + .events + .first() + .and_then(|event| match event.attributes.as_ref() { + Some(Attributes::WorkflowExecutionStartedEventAttributes(attributes)) + if !attributes.workflow_id.is_empty() => + { + Some(attributes.workflow_id.clone()) + } + _ => None, + }); Self { - events, + inner: Box::pin(stream::iter(history.events.into_iter().map(Ok))), workflow_id, } } +} + +impl Stream for WorkflowHistory { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.inner.as_mut().poll_next(cx) + } +} +/// Error fetching or converting a workflow history. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WorkflowHistoryError { + /// Fetching the workflow history failed. + #[error("failed to fetch workflow history: {0}")] + Fetch(#[from] WorkflowInteractionError), + /// Converting the workflow history JSON failed. + #[error("failed to convert workflow history JSON: {0}")] + Json(#[from] serde_json::Error), +} + +impl WorkflowHistory { /// Decode a workflow history from JSON bytes. - pub fn from_json(bytes: &[u8]) -> Result { + pub fn from_json(bytes: &[u8]) -> Result { let history: History = serde_json::from_slice(bytes)?; - let workflow_id = history - .events - .first() - .and_then(|event| match event.attributes.as_ref() { - Some(Attributes::WorkflowExecutionStartedEventAttributes(attributes)) => { - Some(attributes) - } - _ => None, - }) - .map(|attributes| attributes.workflow_id.clone()) - .filter(|wfid| !wfid.is_empty()); - Ok(Self::new(history.events, workflow_id)) + Ok(history.into()) } - /// Encode this workflow history as JSON bytes. - pub fn to_json(&self) -> Result, WorkflowHistoryJsonError> { + /// Fetch all remaining events and encode this workflow history as JSON bytes. + pub async fn to_json(self) -> Result, WorkflowHistoryError> { Ok(serde_json::to_vec(&History { - events: self.events.clone(), + events: self.into_events().await?, })?) } @@ -383,14 +405,9 @@ impl WorkflowHistory { self.workflow_id.as_deref() } - /// The history events. - pub fn events(&self) -> &[HistoryEvent] { - &self.events - } - - /// Consume the history and return the events. - pub fn into_events(self) -> Vec { - self.events + /// Fetch all remaining history pages and collect their events. + pub async fn into_events(self) -> Result, WorkflowInteractionError> { + self.inner.try_collect().await } } @@ -594,7 +611,7 @@ where opts: WorkflowGetResultOptions, ) -> Result where - CT: WorkflowService + NamespacedClient + Clone, + CT: WorkflowService + NamespacedClient + Clone + 'static, { let raw = self.get_result_raw(opts).await?; match raw { @@ -619,7 +636,7 @@ where opts: WorkflowGetResultOptions, ) -> Result, WorkflowInteractionError> where - CT: WorkflowService + NamespacedClient + Clone, + CT: WorkflowService + NamespacedClient + Clone + 'static, { let mut run_id = self.info.run_id.clone().unwrap_or_default(); let fetch_opts = WorkflowFetchHistoryOptions::builder() @@ -630,8 +647,8 @@ where .build(); loop { - let history = self.fetch_history_for_run(&run_id, &fetch_opts).await?; - let mut events = history.into_events(); + let history = self.fetch_history_for_run(&run_id, fetch_opts.clone()); + let mut events = history.into_events().await?; if events.is_empty() { continue; @@ -1163,91 +1180,123 @@ where .await .map_err(WorkflowInteractionError::from) } - /// Fetch workflow execution history. - pub async fn fetch_history( - &self, - opts: WorkflowFetchHistoryOptions, - ) -> Result + /// Fetch workflow execution history as a lazy stream. + /// + /// No request is sent until the returned stream is polled. + pub fn fetch_history(&self, opts: WorkflowFetchHistoryOptions) -> WorkflowHistory where - CT: NamespacedClient, + CT: NamespacedClient + 'static, { let run_id = self.info.run_id.clone().unwrap_or_default(); - self.fetch_history_for_run(&run_id, &opts).await + self.fetch_history_for_run(&run_id, opts) } - /// Fetch history for a specific run_id, handling pagination. - async fn fetch_history_for_run( + fn fetch_history_for_run( &self, run_id: &str, - opts: &WorkflowFetchHistoryOptions, - ) -> Result + opts: WorkflowFetchHistoryOptions, + ) -> WorkflowHistory where - CT: NamespacedClient, + CT: NamespacedClient + 'static, { - let mut all_events = Vec::new(); - let mut next_page_token = vec![]; + let client = self.client.clone(); + let workflow_id = self.info.workflow_id.clone(); + let history_workflow_id = workflow_id.clone(); + let run_id = run_id.to_string(); + + let stream = stream::unfold( + (Vec::new(), VecDeque::new(), false), + move |(mut next_page_token, mut buffer, mut exhausted)| { + let client = client.clone(); + let workflow_id = workflow_id.clone(); + let run_id = run_id.clone(); + let opts = opts.clone(); + + async move { + loop { + if let Some(event) = buffer.pop_front() { + return Some((Ok(event), (next_page_token, buffer, exhausted))); + } - loop { - let output = interceptors::call_fetch_workflow_history_page( - self.client.client_interceptors(), - FetchWorkflowHistoryPageInput { - workflow_id: self.info.workflow_id.clone(), - run_id: run_id.to_string(), - next_page_token, - options: opts.clone(), - }, - Next::new({ - let mut client = self.client.clone(); - move |input: FetchWorkflowHistoryPageInput| -> BoxFuture< - '_, - Result, - > { - Box::pin(async move { - let mut request = GetWorkflowExecutionHistoryRequest { - namespace: client.namespace(), - execution: Some(ProtoWorkflowExecution { - workflow_id: input.workflow_id, - run_id: input.run_id, - }), - next_page_token: input.next_page_token, - skip_archival: input.options.skip_archival, - wait_new_event: input.options.wait_new_event, - history_event_filter_type: input.options.event_filter_type as i32, - ..Default::default() + if exhausted { + return None; + } + + let output = interceptors::call_fetch_workflow_history_page( + client.client_interceptors(), + FetchWorkflowHistoryPageInput { + workflow_id: workflow_id.clone(), + run_id: run_id.clone(), + next_page_token: next_page_token.clone(), + options: opts.clone(), + }, + Next::new({ + let mut rpc_client = client.clone(); + move |input: FetchWorkflowHistoryPageInput| -> BoxFuture< + '_, + Result< + FetchWorkflowHistoryPageOutput, + WorkflowInteractionError, + >, + > { + Box::pin(async move { + let mut request = GetWorkflowExecutionHistoryRequest { + namespace: rpc_client.namespace(), + execution: Some(ProtoWorkflowExecution { + workflow_id: input.workflow_id, + run_id: input.run_id, + }), + next_page_token: input.next_page_token, + skip_archival: input.options.skip_archival, + wait_new_event: input.options.wait_new_event, + history_event_filter_type: input + .options + .event_filter_type + as i32, + ..Default::default() + } + .into_request(); + input.options.rpc_options.apply_to(&mut request); + let response = + WorkflowService::get_workflow_execution_history( + &mut rpc_client, + request, + ) + .await + .map_err(WorkflowInteractionError::from_status)? + .into_inner(); + Ok(FetchWorkflowHistoryPageOutput::new( + response + .history + .map(|history| history.events) + .unwrap_or_default(), + response.next_page_token, + )) + }) + } + }), + ) + .await; + + match output { + Ok(output) => { + exhausted = output.next_page_token.is_empty(); + next_page_token = output.next_page_token; + buffer = output.events.into(); } - .into_request(); - input.options.rpc_options.apply_to(&mut request); - let response = WorkflowService::get_workflow_execution_history( - &mut client, - request, - ) - .await - .map_err(WorkflowInteractionError::from_status)? - .into_inner(); - Ok(FetchWorkflowHistoryPageOutput::new( - response - .history - .map(|history| history.events) - .unwrap_or_default(), - response.next_page_token, - )) - }) + Err(error) => { + return Some((Err(error), (next_page_token, buffer, true))); + } + } } - }), - ) - .await?; + } + }, + ); - all_events.extend(output.events); - if output.next_page_token.is_empty() { - break; - } - next_page_token = output.next_page_token; + WorkflowHistory { + inner: Box::pin(stream), + workflow_id: Some(history_workflow_id), } - - Ok(WorkflowHistory::new( - all_events, - Some(self.info.workflow_id.clone()), - )) } } @@ -1385,8 +1434,15 @@ where #[cfg(test)] mod tests { use super::*; - use crate::test_helpers::XorCodec; - use std::collections::HashMap; + use crate::{ClientInterceptor, test_helpers::XorCodec}; + use futures_util::{FutureExt, StreamExt}; + use std::{ + collections::{HashMap, VecDeque}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + }; use temporalio_common::{ data_converters::DefaultFailureConverter, protos::temporal::api::{ @@ -1395,11 +1451,13 @@ mod tests { history::v1::WorkflowExecutionStartedEventAttributes, sdk::v1::UserMetadata, workflow::v1::WorkflowExecutionConfig, + workflowservice::v1::GetWorkflowExecutionHistoryResponse, }, }; + use tonic::{Request, Response}; - #[test] - fn workflow_history_workflow_id_roundtrips() { + #[tokio::test] + async fn workflow_history_workflow_id_roundtrips() { let event = HistoryEvent { event_id: 1, attributes: Some(Attributes::WorkflowExecutionStartedEventAttributes( @@ -1411,14 +1469,158 @@ mod tests { )), ..Default::default() }; - let history = WorkflowHistory::new(vec![event], None); + let history = WorkflowHistory { + inner: Box::pin(stream::iter(std::iter::once(Ok(event)))), + workflow_id: None, + }; - let bytes = history.to_json().unwrap(); + let bytes = history.to_json().await.unwrap(); let decoded = WorkflowHistory::from_json(&bytes).unwrap(); assert_eq!(decoded.workflow_id(), Some("workflow-id")); } + #[derive(Clone)] + struct MockHistoryClient { + responses: Arc>>>, + calls: Arc, + interceptors: Vec>, + } + + impl NamespacedClient for MockHistoryClient { + fn namespace(&self) -> String { + "test-namespace".to_owned() + } + + fn identity(&self) -> String { + "test-identity".to_owned() + } + + fn client_interceptors(&self) -> &[Arc] { + &self.interceptors + } + } + + impl WorkflowService for MockHistoryClient { + fn get_workflow_execution_history( + &mut self, + _request: Request, + ) -> BoxFuture<'_, Result, tonic::Status>> + { + self.calls.fetch_add(1, Ordering::SeqCst); + let response = self.responses.lock().unwrap().pop_front().unwrap(); + async move { response.map(Response::new) }.boxed() + } + } + + struct CountingHistoryInterceptor(Arc); + + impl ClientInterceptor for CountingHistoryInterceptor { + fn fetch_workflow_history_page<'a>( + &'a self, + input: FetchWorkflowHistoryPageInput, + next: Next< + 'a, + FetchWorkflowHistoryPageInput, + BoxFuture<'a, Result>, + >, + ) -> BoxFuture<'a, Result> + { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(input) + } + } + + fn history_response( + event_ids: impl IntoIterator, + next_page_token: &[u8], + ) -> GetWorkflowExecutionHistoryResponse { + GetWorkflowExecutionHistoryResponse { + history: Some(History { + events: event_ids + .into_iter() + .map(|event_id| HistoryEvent { + event_id, + ..Default::default() + }) + .collect(), + }), + next_page_token: next_page_token.to_vec(), + ..Default::default() + } + } + + fn history_handle( + responses: impl IntoIterator>, + calls: Arc, + interceptors: Vec>, + ) -> WorkflowHandle { + WorkflowHandle::new( + MockHistoryClient { + responses: Arc::new(Mutex::new(responses.into_iter().collect())), + calls, + interceptors, + }, + WorkflowExecutionInfo { + namespace: "test-namespace".to_owned(), + workflow_id: "workflow-id".to_owned(), + run_id: Some("run-id".to_owned()), + first_execution_run_id: None, + }, + ) + } + + #[tokio::test] + async fn workflow_history_fetches_pages_lazily() { + let calls = Arc::new(AtomicUsize::new(0)); + let interceptor_calls = Arc::new(AtomicUsize::new(0)); + let handle = history_handle( + [ + Ok(history_response([], b"second-page")), + Ok(history_response([1, 2], b"third-page")), + Ok(history_response([3], b"")), + ], + calls.clone(), + vec![Arc::new(CountingHistoryInterceptor( + interceptor_calls.clone(), + ))], + ); + + let mut history = handle.fetch_history(WorkflowFetchHistoryOptions::default()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + + assert_eq!(history.next().await.unwrap().unwrap().event_id, 1); + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!(history.next().await.unwrap().unwrap().event_id, 2); + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!(history.next().await.unwrap().unwrap().event_id, 3); + assert_eq!(calls.load(Ordering::SeqCst), 3); + assert!(history.next().await.is_none()); + assert_eq!(interceptor_calls.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn workflow_history_yields_page_error_then_ends() { + let calls = Arc::new(AtomicUsize::new(0)); + let handle = history_handle( + [ + Ok(history_response([1], b"second-page")), + Err(tonic::Status::unavailable("history unavailable")), + ], + calls.clone(), + Vec::new(), + ); + let mut history = handle.fetch_history(WorkflowFetchHistoryOptions::default()); + + assert_eq!(history.next().await.unwrap().unwrap().event_id, 1); + assert!(matches!( + history.next().await.unwrap(), + Err(WorkflowInteractionError::Rpc(status)) if status.code() == tonic::Code::Unavailable + )); + assert!(history.next().await.is_none()); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn workflow_result_details_support_typed_decoding() { let converter = DataConverter::new( diff --git a/crates/sdk-core/src/histfetch.rs b/crates/sdk-core/src/histfetch.rs index dc7214d5c..d6700e4cc 100644 --- a/crates/sdk-core/src/histfetch.rs +++ b/crates/sdk-core/src/histfetch.rs @@ -32,8 +32,8 @@ async fn main() -> Result<(), anyhow::Error> { .bind_untyped(client); let events = handle .fetch_history(WorkflowFetchHistoryOptions::default()) - .await? - .into_events(); + .into_events() + .await?; let hist = History { events }; // Serialize history to file let byteified = hist.encode_to_vec(); diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index b333f37b5..274982ac9 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -614,9 +614,9 @@ impl CoreWfStarter { let events = client .get_workflow_handle::(self.get_wf_id()) .fetch_history(Default::default()) + .into_events() .await - .unwrap() - .into_events(); + .unwrap(); History { events } } @@ -1079,7 +1079,7 @@ where worker: &mut TestWorker, ) -> Result, anyhow::Error> { let wf_id = self.info().workflow_id.clone(); - let events = self.fetch_history(Default::default()).await?.into_events(); + let events = self.fetch_history(Default::default()).into_events().await?; let with_id = HistoryForReplay::new(events, wf_id); let replay_worker = init_core_replay_preloaded(worker.inner.task_queue(), [with_id]); worker.inner.with_new_core_worker(Arc::new(replay_worker)); diff --git a/crates/sdk-core/tests/integ_tests/client_tests.rs b/crates/sdk-core/tests/integ_tests/client_tests.rs index d893b5a2f..9a4cd606a 100644 --- a/crates/sdk-core/tests/integ_tests/client_tests.rs +++ b/crates/sdk-core/tests/integ_tests/client_tests.rs @@ -463,6 +463,7 @@ async fn namespace_header_attached_to_relevant_calls() { let _ = client .get_workflow_handle::("hi") .fetch_history(Default::default()) + .into_events() .await; let val = header_rx.recv().await.unwrap(); assert_eq!(namespace, val); diff --git a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs index a688d2072..8a0444acc 100644 --- a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs +++ b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs @@ -441,9 +441,12 @@ async fn custom_failure_converter_fallback_applied_to_activity_panic_failures() worker.run_until_done().await.unwrap(); handle.get_result(Default::default()).await.unwrap(); - let history = handle.fetch_history(Default::default()).await.unwrap(); - let activity_failure = history + let history = handle + .fetch_history(Default::default()) .into_events() + .await + .unwrap(); + let activity_failure = history .into_iter() .find_map(|event| match event.attributes { Some(Attributes::ActivityTaskFailedEventAttributes(attrs)) => attrs.failure, @@ -717,9 +720,9 @@ async fn multi_args_serializes_as_multiple_payloads() { let events = client .get_workflow_handle::(wf_name) .fetch_history(Default::default()) + .into_events() .await - .unwrap() - .into_events(); + .unwrap(); let workflow_started_event = events .iter() diff --git a/crates/sdk-core/tests/integ_tests/polling_tests.rs b/crates/sdk-core/tests/integ_tests/polling_tests.rs index 74900aaad..f39a1b70d 100644 --- a/crates/sdk-core/tests/integ_tests/polling_tests.rs +++ b/crates/sdk-core/tests/integ_tests/polling_tests.rs @@ -323,9 +323,9 @@ async fn small_workflow_slots_and_pollers(#[values(false, true)] use_autoscaling .await .get_workflow_handle::(&wf2id) .fetch_history(Default::default()) + .into_events() .await - .unwrap() - .into_events(); + .unwrap(); let any_task_timeouts = events .iter() .any(|e| e.event_type() == EventType::WorkflowTaskTimedOut); diff --git a/crates/sdk-core/tests/integ_tests/update_tests.rs b/crates/sdk-core/tests/integ_tests/update_tests.rs index f1225cb0d..765005307 100644 --- a/crates/sdk-core/tests/integ_tests/update_tests.rs +++ b/crates/sdk-core/tests/integ_tests/update_tests.rs @@ -90,9 +90,9 @@ async fn update_workflow(#[values(FailUpdate::Yes, FailUpdate::No)] will_fail: F let events = client .get_workflow_handle::(workflow_id) .fetch_history(Default::default()) + .into_events() .await - .unwrap() - .into_events(); + .unwrap(); let with_id = HistoryForReplay::new(events, workflow_id.to_string()); let replay_worker = init_core_replay_preloaded(workflow_id, [with_id]); // Init workflow comes by itself @@ -170,9 +170,9 @@ async fn reapplied_updates_due_to_reset() { .build() .bind_untyped(client.clone()) .fetch_history(Default::default()) + .into_events() .await - .unwrap() - .into_events(); + .unwrap(); let with_id = HistoryForReplay::new(events, workflow_id.to_string()); let replay_worker = init_core_replay_preloaded(workflow_id, [with_id]); @@ -363,9 +363,9 @@ async fn update_rejection() { let events = client .get_workflow_handle::(&workflow_id) .fetch_history(Default::default()) + .into_events() .await - .unwrap() - .into_events(); + .unwrap(); let has_update_event = events.iter().any(|e| { matches!( e.event_type(), diff --git a/crates/sdk-core/tests/integ_tests/workflow_replayer_tests.rs b/crates/sdk-core/tests/integ_tests/workflow_replayer_tests.rs index c50a81527..a1432eaaa 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_replayer_tests.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_replayer_tests.rs @@ -11,7 +11,7 @@ use temporalio_client::{ PluginError, WorkflowHistory, WorkflowQueryOptions, WorkflowStartOptions, WorkflowTerminateOptions, errors::WorkflowGetResultError, }; -use temporalio_common::protos::temporal::api::enums::v1::EventType; +use temporalio_common::protos::temporal::api::{enums::v1::EventType, history::v1::History}; use temporalio_macros::{activities, workflow, workflow_methods}; use temporalio_sdk::{ ActivityOptions, ApplicationFailure, SimplePlugin, WorkerPlugin, WorkerRunError, @@ -137,21 +137,29 @@ async fn workflow_replayer_replays_completed_workflow() { handle.get_result(Default::default()).await.unwrap(), "Hello, Temporal!" ); - let history = handle.fetch_history(Default::default()).await.unwrap(); - let history_from_json = WorkflowHistory::from_json(&history.to_json().unwrap()).unwrap(); - assert_eq!(history_from_json.workflow_id(), history.workflow_id()); + let history = handle.fetch_history(Default::default()); + let workflow_id = history.workflow_id().map(str::to_owned); + let history_json = history.to_json().await.unwrap(); + let history_from_json = WorkflowHistory::from_json(&history_json).unwrap(); + assert_eq!(history_from_json.workflow_id(), workflow_id.as_deref()); let replayer = replayer(); - replayer - .replay_workflow(history_from_json.clone()) - .await - .unwrap(); + replayer.replay_workflow(history_from_json).await.unwrap(); let results = replayer - .replay_workflows([history_from_json.clone(), history_from_json]) + .replay_workflows([ + WorkflowHistory::from_json(&history_json).unwrap(), + WorkflowHistory::from_json(&history_json).unwrap(), + ]) .await .unwrap(); assert_eq!(results.len(), 2); assert!(results.iter().all(|result| result.replay_failure.is_none())); + let cloned_result = results[0].clone(); + assert_eq!( + cloned_result.history.workflow_id(), + Some(starter.get_wf_id()) + ); + assert!(!cloned_result.history.events().is_empty()); } #[tokio::test] @@ -192,7 +200,14 @@ async fn workflow_replayer_replays_incomplete_workflow() { ) .await .unwrap(); - let history = handle.fetch_history(Default::default()).await.unwrap(); + let history: WorkflowHistory = History { + events: handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(), + } + .into(); handle .terminate(WorkflowTerminateOptions::default()) .await @@ -230,7 +245,7 @@ async fn workflow_replayer_replays_failed_workflow() { handle.get_result(Default::default()).await, Err(WorkflowGetResultError::Failed(_)) )); - let history = handle.fetch_history(Default::default()).await.unwrap(); + let history = handle.fetch_history(Default::default()); replayer().replay_workflow(history).await.unwrap(); } @@ -256,16 +271,30 @@ async fn workflow_replayer_reports_nondeterminism() { .unwrap(); worker.run_until_done().await.unwrap(); - let history = handle.fetch_history(Default::default()).await.unwrap(); + let events = handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(); let replayer = replayer(); assert!(matches!( - replayer.replay_workflow(history.clone()).await, + replayer + .replay_workflow( + History { + events: events.clone(), + } + .into() + ) + .await, Err(WorkflowReplayError::Replay( WorkflowReplayFailure::Nondeterminism { .. } )) )); - let results = replayer.replay_workflows([history]).await.unwrap(); + let results = replayer + .replay_workflows([History { events }.into()]) + .await + .unwrap(); assert!(matches!( results[0].replay_failure, Some(WorkflowReplayFailure::Nondeterminism { .. }) @@ -295,12 +324,15 @@ async fn workflow_replayer_replays_history_with_workflow_task_failure() { let fetch_failed_history = async { let history = eventually( || async { - let history = handle.fetch_history(Default::default()).await.unwrap(); - history - .events() + let events = handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(); + events .iter() .any(|event| event.event_type() == EventType::WorkflowTaskFailed) - .then_some(history) + .then_some(History { events }.into()) .ok_or("workflow task failure not yet recorded") }, Duration::from_secs(10), @@ -353,14 +385,8 @@ async fn workflow_replayer_returns_ordered_results_for_multiple_histories() { .unwrap(); worker.run_until_done().await.unwrap(); - let successful_history = successful_handle - .fetch_history(Default::default()) - .await - .unwrap(); - let nondeterministic_history = nondeterministic_handle - .fetch_history(Default::default()) - .await - .unwrap(); + let successful_history = successful_handle.fetch_history(Default::default()); + let nondeterministic_history = nondeterministic_handle.fetch_history(Default::default()); let results = replayer() .replay_workflows([successful_history, nondeterministic_history]) @@ -441,7 +467,11 @@ async fn workflow_replayer_applies_plugins() { .await .unwrap(); worker.run_until_done().await.unwrap(); - let history = handle.fetch_history(Default::default()).await.unwrap(); + let events = handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(); let configure_calls = Arc::new(AtomicUsize::new(0)); let replayer = WorkflowReplayer::new( @@ -460,7 +490,15 @@ async fn workflow_replayer_applies_plugins() { .count(), 1 ); - replayer.replay_workflow(history.clone()).await.unwrap(); + replayer + .replay_workflow( + History { + events: events.clone(), + } + .into(), + ) + .await + .unwrap(); assert_eq!(configure_calls.load(Ordering::Relaxed), 1); let run_calls = Arc::new(AtomicUsize::new(0)); @@ -477,7 +515,16 @@ async fn workflow_replayer_applies_plugins() { ) .unwrap(); replayer - .replay_workflows([history.clone(), history.clone()]) + .replay_workflows([ + History { + events: events.clone(), + } + .into(), + History { + events: events.clone(), + } + .into(), + ]) .await .unwrap(); assert_eq!(run_calls.load(Ordering::Relaxed), 0); @@ -495,5 +542,8 @@ async fn workflow_replayer_applies_plugins() { .build(), ) .unwrap(); - replayer.replay_workflow(history).await.unwrap(); + replayer + .replay_workflow(History { events }.into()) + .await + .unwrap(); } diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs index a22e7c7ef..0f8d5c9de 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs @@ -352,9 +352,12 @@ async fn propagated_activity_input_conversion_failure_fails_workflow_task() { worker.run_until_done().await.unwrap(); handle.get_result(Default::default()).await.unwrap(); - let history = handle.fetch_history(Default::default()).await.unwrap(); + let history = handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(); let workflow_task_failures: Vec<_> = history - .events() .iter() .filter(|event| event.event_type() == EventType::WorkflowTaskFailed) .collect(); diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs index 07a1ceb1f..262da5a44 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs @@ -284,10 +284,10 @@ async fn abandoned_child_resolves_post_cancel() { client.get_workflow_handle::("abandoned-child-resolve-post-cancel"); let history = child_handle .fetch_history(Default::default()) + .into_events() .await .unwrap(); let wft_failures: Vec<_> = history - .events() .iter() .filter(|e| { matches!( @@ -1235,9 +1235,12 @@ async fn cancel_child_wf_before_started_event_real_server() { // Verify no unexpected workflow task failures in history. The bug manifests as a WFT failure // with a nondeterminism error. UnhandledCommand failures are acceptable since the server // may reject a cancel command if it races with the child workflow start. - let history = handle.fetch_history(Default::default()).await.unwrap(); + let history = handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(); let unexpected_wft_failures: Vec<_> = history - .events() .iter() .filter(|e| { if let Some(history_event::Attributes::WorkflowTaskFailedEventAttributes(attrs)) = diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs index cedfbc212..570fe33a8 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs @@ -4384,9 +4384,9 @@ async fn replay_out_of_order_local_activity_markers_is_deterministic() { let workflow_id = handle.info().workflow_id.clone(); let events = handle .fetch_history(Default::default()) + .into_events() .await - .unwrap() - .into_events(); + .unwrap(); let marker_order = events .iter() .filter_map(|event| match event.attributes.as_ref() { diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/patches.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/patches.rs index 5b443f3a1..87d4c6ec7 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/patches.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/patches.rs @@ -212,8 +212,12 @@ async fn patch_activation_callback_is_memoized_across_replay() { (false, false) ); assert_eq!(callback_calls.load(Ordering::Relaxed), 1); - let history = handle.fetch_history(Default::default()).await.unwrap(); - assert!(!history.events().iter().any(|event| matches!( + let history = handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(); + assert!(!history.iter().any(|event| matches!( &event.attributes, Some(EventAttributes::MarkerRecordedEventAttributes(attrs)) if attrs.marker_name == PATCH_MARKER_NAME @@ -305,8 +309,12 @@ async fn declined_patch_can_roll_out_to_old_worker() { }); run_result.unwrap(); assert_eq!(callback_calls.load(Ordering::Relaxed), 1); - let history = handle.fetch_history(Default::default()).await.unwrap(); - assert!(!history.events().iter().any(|event| matches!( + let history = handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(); + assert!(!history.iter().any(|event| matches!( &event.attributes, Some(EventAttributes::MarkerRecordedEventAttributes(attrs)) if attrs.marker_name == PATCH_MARKER_NAME @@ -369,8 +377,12 @@ async fn activated_patch_replays_without_consulting_declining_callback() { }); run_result.unwrap(); assert_eq!(activated_calls.load(Ordering::Relaxed), 1); - let history = handle.fetch_history(Default::default()).await.unwrap(); - assert!(history.events().iter().any(|event| matches!( + let history = handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(); + assert!(history.iter().any(|event| matches!( &event.attributes, Some(EventAttributes::MarkerRecordedEventAttributes(attrs)) if attrs.marker_name == PATCH_MARKER_NAME @@ -1179,9 +1191,12 @@ async fn patch_marker_size_overflow_replay_is_deterministic() { // Confirm that the original execution did in fact hit the size limit: the last upsert SA // event in history must contain fewer than the total number of patches issued by the workflow. - let history = handle.fetch_history(Default::default()).await.unwrap(); + let history = handle + .fetch_history(Default::default()) + .into_events() + .await + .unwrap(); let last_upsert_patches = history - .events() .iter() .rev() .find_map(|e| match &e.attributes { diff --git a/crates/sdk-core/tests/shared_tests/mod.rs b/crates/sdk-core/tests/shared_tests/mod.rs index 61a6ae402..833710170 100644 --- a/crates/sdk-core/tests/shared_tests/mod.rs +++ b/crates/sdk-core/tests/shared_tests/mod.rs @@ -383,10 +383,10 @@ pub(crate) async fn shutdown_during_active_timer_activity_workflows() { let history = client .get_workflow_handle::(wf_id) .fetch_history(WorkflowFetchHistoryOptions::default()) + .into_events() .await .unwrap(); let bad_events: Vec<_> = history - .events() .iter() .filter(|e| match &e.attributes { Some(history_event::Attributes::WorkflowTaskFailedEventAttributes(f)) diff --git a/crates/sdk-core/tests/shared_tests/priority.rs b/crates/sdk-core/tests/shared_tests/priority.rs index c032e7b71..92ce162c1 100644 --- a/crates/sdk-core/tests/shared_tests/priority.rs +++ b/crates/sdk-core/tests/shared_tests/priority.rs @@ -135,9 +135,9 @@ pub(crate) async fn priority_values_sent_to_server() { .unwrap(); let events = handle .fetch_history(Default::default()) + .into_events() .await - .unwrap() - .into_events(); + .unwrap(); let workflow_init_event = events .iter() .find_map(|e| { diff --git a/crates/sdk/src/workflow_replayer.rs b/crates/sdk/src/workflow_replayer.rs index d84c4c436..57732ca65 100644 --- a/crates/sdk/src/workflow_replayer.rs +++ b/crates/sdk/src/workflow_replayer.rs @@ -12,7 +12,9 @@ use std::{ collections::{HashMap, HashSet}, sync::Arc, }; -use temporalio_client::{ClientOptions, PluginApplyError, WorkflowHistory}; +use temporalio_client::{ + ClientOptions, PluginApplyError, WorkflowHistory, errors::WorkflowInteractionError, +}; use temporalio_common::{ WorkflowDefinition, data_converters::DataConverter, @@ -21,7 +23,7 @@ use temporalio_common::{ WorkflowActivation, remove_from_cache::EvictionReason, workflow_activation_job::Variant as ActivationVariant, }, - temporal::api::history::v1::History, + temporal::api::history::v1::{History, HistoryEvent}, }, }; use temporalio_sdk_core::{ @@ -258,12 +260,39 @@ pub enum WorkflowReplayFailure { }, } +/// Eagerly fetched workflow history returned after replay. +#[derive(Clone, Debug)] +pub struct ReplayHistory { + events: Vec, + /// Workflow ID when it is known. + workflow_id: Option, +} + +impl ReplayHistory { + fn new(events: Vec, workflow_id: Option) -> Self { + Self { + events, + workflow_id, + } + } + + /// The history events. + pub fn events(&self) -> &[HistoryEvent] { + &self.events + } + + /// The history events. + pub fn workflow_id(&self) -> Option<&str> { + self.workflow_id.as_deref() + } +} + /// Outcome of replaying one workflow history. #[derive(Clone, Debug)] #[non_exhaustive] pub struct WorkflowReplayResult { /// History supplied to the replayer. - pub history: WorkflowHistory, + pub history: ReplayHistory, /// Replay failure, or `None` when the workflow code is compatible with the history. pub replay_failure: Option, } @@ -272,6 +301,9 @@ pub struct WorkflowReplayResult { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum WorkflowReplayError { + /// Fetching a streamed workflow history failed. + #[error(transparent)] + History(#[from] WorkflowInteractionError), /// The replay worker could not be created or run. #[error(transparent)] Worker(#[from] WorkflowReplayWorkerError), @@ -362,27 +394,26 @@ impl WorkflowReplayer { return Ok(Vec::new()); } - let mut results = histories - .into_iter() - .map(|history| WorkflowReplayResult { - history, + let mut results = Vec::with_capacity(histories.len()); + let mut core_histories = Vec::with_capacity(histories.len()); + for history in histories { + let workflow_id = history.workflow_id().map(str::to_owned); + let replay_workflow_id = workflow_id + .as_deref() + .unwrap_or(DEFAULT_REPLAY_WORKFLOW_ID) + .to_owned(); + let events = history.into_events().await?; + core_histories.push(HistoryForReplay::new( + History { + events: events.clone(), + }, + replay_workflow_id, + )); + results.push(WorkflowReplayResult { + history: ReplayHistory::new(events, workflow_id), replay_failure: None, - }) - .collect::>(); - let core_histories: Vec<_> = results - .iter() - .map(|result| { - HistoryForReplay::new( - History { - events: result.history.events().to_vec(), - }, - result - .history - .workflow_id() - .unwrap_or(DEFAULT_REPLAY_WORKFLOW_ID), - ) - }) - .collect(); + }); + } let recorded_outcomes = Arc::new(Mutex::new(Vec::new())); let observer = ReplayOutcomeInterceptor { From 0f605f253e23821dd12f483d52da1a687cb09e52 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 31 Aug 2026 16:08:29 -0400 Subject: [PATCH 43/87] feat(sdk): add encode_common_attributes to DefaultFailureConverter (#1541) * feat(sdk): add encode_common_attributes to DefaultFailureConverter * self review * fix bad merge --- CHANGELOG.md | 5 + .../src/activity/activity_execution_info.rs | 14 +- crates/client/src/activity/activity_handle.rs | 145 +++++++++++++- crates/client/src/lib.rs | 15 +- crates/client/src/schedules.rs | 4 +- crates/client/src/workflow_handle.rs | 4 +- crates/common-wasm/src/data_converters.rs | 4 +- .../src/data_converters/failure_converter.rs | 185 +++++++++++++++--- crates/common-wasm/src/error.rs | 10 +- crates/common/src/payload_visitor.rs | 2 +- .../tests/integ_tests/data_converter_tests.rs | 18 +- .../tests/integ_tests/plugin_tests.rs | 2 +- crates/sdk/src/lib.rs | 4 +- 13 files changed, 355 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c8e30aa..8b9844560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ relevant information. ## Unreleased ### Added +* `DefaultFailureConverter::new(true)` moves failure messages and stack traces into encoded + attributes so payload codecs can encrypt them. * `LocalActivityOptions::include_arguments_into_marker` allows Rust workflows to opt in to recording local activity arguments in Workflow history. * `WorkflowHandle::get_update_handle` creates a typed handle for an existing Workflow Update from @@ -59,6 +61,7 @@ relevant information. `ClientInterceptor::update_with_start_workflow`. ### Breaking Changes :boom: +* `DefaultFailureConverter` is no longer a unit struct. Use `DefaultFailureConverter::default()` instead. * `WorkflowHandle::fetch_history` now returns a lazy `WorkflowHistory` stream instead of eagerly fetching every history page. Use `WorkflowHistory::into_events` if eager fetching is desired. @@ -99,6 +102,8 @@ relevant information. `Waiting for all slot permits to release took too long!`, and release builds logged that error and dropped the result, leaving the server to time the activity out before retrying it. Shutdown now drains in-flight completions first. +* Standalone activity result and describe APIs now apply the configured payload codec when + decoding failures, so encoded failure attributes and details are restored correctly. * The default payload converter now serializes Serde `null` values such as `Option::None` as `binary/null` and accepts both `binary/null` and legacy `json/plain` null payloads. * The Prometheus exporter now respects `PrometheusExporterOptions::counters_total_suffix`, diff --git a/crates/client/src/activity/activity_execution_info.rs b/crates/client/src/activity/activity_execution_info.rs index 7997ef69d..21f4c2220 100644 --- a/crates/client/src/activity/activity_execution_info.rs +++ b/crates/client/src/activity/activity_execution_info.rs @@ -11,6 +11,7 @@ use temporalio_common::{ TemporalDeserializable, }, error::IncomingError, + payload_visitor::decode_payloads, protos::{ proto_ts_to_system_time, temporal::api::{ @@ -187,18 +188,25 @@ impl ActivityExecutionDescription where ActivityT: ActivityDefinition, { - pub(crate) fn new( + pub(crate) async fn new( data_converter: DataConverter, serialization_context: SerializationContextData, response: DescribeActivityExecutionResponse, ) -> Result> { - let Some(raw_info) = response.info else { + let Some(mut raw_info) = response.info else { return Err("info missing in describe response".into()); }; + if let Some(failure) = raw_info.last_failure.as_mut() { + decode_payloads(failure, data_converter.codec(), &serialization_context).await?; + } + let mut raw_outcome = response.outcome.and_then(|o| o.value); + if let Some(ActivityExecutionOutcomeValue::Failure(failure)) = raw_outcome.as_mut() { + decode_payloads(failure, data_converter.codec(), &serialization_context).await?; + } Ok(Self { raw_info, raw_input: response.input, - raw_outcome: response.outcome.and_then(|o| o.value), + raw_outcome, data_converter, serialization_context, _phantom: PhantomData, diff --git a/crates/client/src/activity/activity_handle.rs b/crates/client/src/activity/activity_handle.rs index bb90b2aea..f992c1a23 100644 --- a/crates/client/src/activity/activity_handle.rs +++ b/crates/client/src/activity/activity_handle.rs @@ -8,6 +8,7 @@ use std::marker::PhantomData; use temporalio_common::{ ActivityDefinition, data_converters::{DecodablePayloads, NoopDecodeHint, SerializationContextData}, + payload_visitor::decode_payloads, protos::temporal::api::{ activity::v1::{ActivityExecutionOutcome, activity_execution_outcome}, failure::v1::failure::FailureInfo, @@ -104,7 +105,8 @@ where activity_execution_outcome::Value::Result(payloads) => { Ok(dc.from_payloads(&ctx, payloads.payloads).await?) } - activity_execution_outcome::Value::Failure(failure) => { + activity_execution_outcome::Value::Failure(mut failure) => { + decode_payloads(&mut failure, dc.codec(), &ctx).await?; Err(match failure.failure_info { Some(FailureInfo::CanceledFailureInfo(info)) => { let payloads = info.details.unwrap_or_default().payloads; @@ -156,7 +158,8 @@ where client.data_converter().clone(), SerializationContextData::Activity, resp, - )?) + ) + .await?) } /// Requests cancellation of the activity. Does not wait for the cancellation to complete. @@ -205,3 +208,141 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::XorCodec; + use futures_util::future::BoxFuture; + use temporalio_common::{ + UntypedActivity, + data_converters::{DataConverter, DefaultFailureConverter, PayloadConverter}, + error::{ApplicationFailure, OutgoingActivityError, OutgoingError}, + payload_visitor::encode_payloads, + protos::temporal::api::{ + activity::v1::ActivityExecutionInfo, + failure::v1::Failure, + workflowservice::v1::{ + DescribeActivityExecutionResponse, PollActivityExecutionResponse, + }, + }, + }; + use tonic::{Request, Response, Status}; + + #[derive(Clone)] + struct MockActivityClient { + data_converter: DataConverter, + failure: Failure, + } + + impl NamespacedClient for MockActivityClient { + fn namespace(&self) -> String { + "test-namespace".to_owned() + } + + fn identity(&self) -> String { + "test-identity".to_owned() + } + + fn data_converter(&self) -> &DataConverter { + &self.data_converter + } + } + + impl WorkflowService for MockActivityClient { + fn poll_activity_execution( + &mut self, + _request: Request, + ) -> BoxFuture<'_, Result, Status>> { + let failure = self.failure.clone(); + Box::pin(async move { + Ok(Response::new(PollActivityExecutionResponse { + outcome: Some(ActivityExecutionOutcome { + value: Some(activity_execution_outcome::Value::Failure(failure)), + ..Default::default() + }), + ..Default::default() + })) + }) + } + + fn describe_activity_execution( + &mut self, + _request: Request, + ) -> BoxFuture<'_, Result, Status>> { + let failure = self.failure.clone(); + Box::pin(async move { + Ok(Response::new(DescribeActivityExecutionResponse { + info: Some(ActivityExecutionInfo { + last_failure: Some(failure.clone()), + ..Default::default() + }), + outcome: Some(ActivityExecutionOutcome { + value: Some(activity_execution_outcome::Value::Failure(failure)), + ..Default::default() + }), + ..Default::default() + })) + }) + } + } + + async fn activity_client_with_encoded_failure() -> MockActivityClient { + let data_converter = DataConverter::new( + PayloadConverter::default(), + DefaultFailureConverter::new(true), + XorCodec, + ); + let context = SerializationContextData::Activity; + let mut failure = data_converter.to_failure( + &context, + OutgoingError::Activity(OutgoingActivityError::Application(Box::new( + ApplicationFailure::new(anyhow::anyhow!("private message")), + ))), + ); + encode_payloads(&mut failure, data_converter.codec(), &context) + .await + .unwrap(); + MockActivityClient { + data_converter, + failure, + } + } + + #[tokio::test] + async fn result_decodes_failure_attributes_with_codec() { + let handle = ActivityHandle::<_, UntypedActivity>::new( + activity_client_with_encoded_failure().await, + "activity-id".to_owned(), + None, + ); + + let ActivityResultError::ActivityFailed(error) = handle.result().await.unwrap_err() else { + panic!("expected failed activity"); + }; + assert_eq!(error.failure().message, "private message"); + } + + #[tokio::test] + async fn describe_decodes_failure_attributes_with_codec() { + let handle = ActivityHandle::<_, UntypedActivity>::new( + activity_client_with_encoded_failure().await, + "activity-id".to_owned(), + None, + ); + + let description = handle + .describe( + ActivityDescribeOptions::builder() + .include_outcome(true) + .include_last_failure(true) + .build(), + ) + .await + .unwrap(); + let outcome = description.outcome().await.unwrap().unwrap().unwrap_err(); + let last_failure = description.last_failure().unwrap().unwrap(); + assert_eq!(outcome.failure().message, "private message"); + assert_eq!(last_failure.failure().message, "private message"); + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index d01d9f37c..6dee58310 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -3655,7 +3655,7 @@ mod tests { let recorded = Arc::new(Mutex::new(RecordedStart::default())); let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), CountingCodec { encode_calls: encode_calls.clone(), }, @@ -3678,8 +3678,11 @@ mod tests { codec: impl PayloadCodec + Send + Sync + 'static, ) -> (MockStartWorkflowClient, Arc>) { let recorded = Arc::new(Mutex::new(RecordedStart::default())); - let data_converter = - DataConverter::new(PayloadConverter::default(), DefaultFailureConverter, codec); + let data_converter = DataConverter::new( + PayloadConverter::default(), + DefaultFailureConverter::default(), + codec, + ); ( MockStartWorkflowClient { recorded: recorded.clone(), @@ -3888,7 +3891,7 @@ mod tests { let recorded = Arc::new(Mutex::new(RecordedStart::default())); let data_converter = DataConverter::new( PayloadConverter::UseWrappers, - DefaultFailureConverter, + DefaultFailureConverter::default(), CountingCodec { encode_calls: encode_calls.clone(), }, @@ -4776,7 +4779,7 @@ mod tests { async fn list_workflows_exposes_typed_memo() { let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), XorCodec, ); let memo_payload = data_converter @@ -4816,7 +4819,7 @@ mod tests { total_workflows: 1, data_converter: DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), FailingCodec, ), memo_payload: Some(Payload::default()), diff --git a/crates/client/src/schedules.rs b/crates/client/src/schedules.rs index de2ca2f39..5e3723c52 100644 --- a/crates/client/src/schedules.rs +++ b/crates/client/src/schedules.rs @@ -1682,7 +1682,7 @@ mod tests { fn data_converter_with_codec() -> DataConverter { DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), XorCodec, ) } @@ -2036,7 +2036,7 @@ mod tests { }, data_converter: DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), FailingCodec, ), ..Default::default() diff --git a/crates/client/src/workflow_handle.rs b/crates/client/src/workflow_handle.rs index 927c451f9..08a695f97 100644 --- a/crates/client/src/workflow_handle.rs +++ b/crates/client/src/workflow_handle.rs @@ -1625,7 +1625,7 @@ mod tests { async fn workflow_result_details_support_typed_decoding() { let converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), XorCodec, ); let payloads = converter @@ -1663,7 +1663,7 @@ mod tests { async fn workflow_description_memo_uses_saved_converter() { let converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), XorCodec, ); let encoded = converter diff --git a/crates/common-wasm/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs index b250e3087..25b85e81b 100644 --- a/crates/common-wasm/src/data_converters.rs +++ b/crates/common-wasm/src/data_converters.rs @@ -6,7 +6,7 @@ mod well_known; pub use failure_converter::{ ActivityExecutionDecodeHint, ChildWorkflowExecutionDecodeHint, ChildWorkflowStartDecodeHint, - DefaultFailureConverter, FailureConverter, FailureDecodeHint, NoopDecodeHint, + CommonAttributes, DefaultFailureConverter, FailureConverter, FailureDecodeHint, NoopDecodeHint, WorkflowSignalDecodeHint, }; use well_known::{BINARY_NULL_ENCODING_VAL, WellKnownType, binary_null_payload}; @@ -748,7 +748,7 @@ impl Default for DataConverter { fn default() -> Self { Self::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), DefaultPayloadCodec, ) } diff --git a/crates/common-wasm/src/data_converters/failure_converter.rs b/crates/common-wasm/src/data_converters/failure_converter.rs index c0149c344..59b241ed2 100644 --- a/crates/common-wasm/src/data_converters/failure_converter.rs +++ b/crates/common-wasm/src/data_converters/failure_converter.rs @@ -9,7 +9,10 @@ //! [`FailureDecodeHint`] implementations adapt that normalized value into the caller-facing error //! type they expect. -use super::{PayloadConversionError, PayloadConverter, SerializationContextData}; +use super::{ + GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, + SerializationContextData, +}; use crate::{ error::{ ActivityExecutionError, ActivityFailureError, ApplicationFailure, CancelledError, @@ -48,7 +51,35 @@ pub trait FailureConverter { } /// Default failure converter. -pub struct DefaultFailureConverter; +pub struct DefaultFailureConverter { + encode_common_attributes: bool, +} + +impl DefaultFailureConverter { + /// Creates a failure converter, optionally moving failure messages and stack traces into + /// encoded attributes. + pub const fn new(encode_common_attributes: bool) -> Self { + Self { + encode_common_attributes, + } + } +} + +impl Default for DefaultFailureConverter { + fn default() -> Self { + Self::new(false) + } +} + +/// Failure attributes that can be moved into an encoded payload. +#[derive(serde::Deserialize, serde::Serialize)] +#[non_exhaustive] +pub struct CommonAttributes { + /// Failure message. + pub message: String, + /// Failure stack trace. + pub stack_trace: String, +} /// Adapts a normalized incoming failure into a caller-facing error surface. pub trait FailureDecodeHint { @@ -217,12 +248,21 @@ impl FailureConverter for DefaultFailureConverter { signal.encode_failure(payload_converter, context) } }; - encoded.unwrap_or_else(|converter_error| { + let mut failure = encoded.unwrap_or_else(|converter_error| { Failure::application_failure( failed_error_conversion_message(&original_error, &converter_error), false, ) - }) + }); + if self.encode_common_attributes + && encode_common_attributes(&mut failure, payload_converter, context).is_err() + { + failure = Failure::application_failure( + "Failed encoding failure attributes".to_owned(), + false, + ); + } + failure } fn to_error( @@ -476,11 +516,39 @@ fn encode_failed_error_conversion( } } +fn encode_common_attributes( + failure: &mut Failure, + payload_converter: &PayloadConverter, + context: &SerializationContextData, +) -> Result<(), PayloadConversionError> { + if let Some(cause) = failure.cause.as_deref_mut() { + encode_common_attributes(cause, payload_converter, context)?; + } + failure.encoded_attributes = Some(payload_converter.to_payload( + &SerializationContext::new(context, payload_converter), + &CommonAttributes { + message: std::mem::take(&mut failure.message), + stack_trace: std::mem::take(&mut failure.stack_trace), + }, + )?); + failure.message = "Encoded failure".to_owned(); + Ok(()) +} + fn decode_failure( - failure: Failure, + mut failure: Failure, payload_converter: &PayloadConverter, context: &SerializationContextData, ) -> IncomingError { + if let Some(encoded_attributes) = failure.encoded_attributes.clone() + && let Ok(attributes) = payload_converter.from_payload::( + &SerializationContext::new(context, payload_converter), + encoded_attributes, + ) + { + failure.message = attributes.message; + failure.stack_trace = attributes.stack_trace; + } let cause = failure .cause .clone() @@ -624,7 +692,7 @@ mod tests { } fn convert(err: OutgoingWorkflowError) -> Failure { - DefaultFailureConverter.to_failure( + DefaultFailureConverter::default().to_failure( OutgoingError::Workflow(err), &PayloadConverter::default(), &SerializationContextData::Workflow, @@ -634,7 +702,7 @@ mod tests { fn data_converter() -> crate::data_converters::DataConverter { crate::data_converters::DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), crate::data_converters::DefaultPayloadCodec, ) } @@ -705,7 +773,7 @@ mod tests { #[test] fn application_failures_surface_detail_encoding_errors_with_original_message() { - let failure = DefaultFailureConverter.to_failure( + let failure = DefaultFailureConverter::default().to_failure( OutgoingError::Workflow(OutgoingWorkflowError::Application(Box::new( ApplicationFailure::builder(anyhow::anyhow!("app boom")) .details(AlwaysFailsSerialize) @@ -741,7 +809,7 @@ mod tests { ..Default::default() }; - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error(failure, &converter, &SerializationContextData::Workflow) .unwrap(); @@ -885,7 +953,7 @@ mod tests { )); assert!(cause.cause.is_none()); - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error( converted.clone(), &PayloadConverter::default(), @@ -907,6 +975,79 @@ mod tests { assert!(wrapper.cause().is_none()); } + #[test] + fn failure_converter_encodes_and_decodes_cause_chain() { + let payload_converter = PayloadConverter::default(); + let converter = DefaultFailureConverter::new(true); + let context = SerializationContextData::Workflow; + let failure = Failure { + message: "outer message".to_owned(), + stack_trace: "outer stack trace".to_owned(), + cause: Some(Box::new(Failure { + message: "inner message".to_owned(), + stack_trace: "inner stack trace".to_owned(), + failure_info: Some(FailureInfo::ApplicationFailureInfo( + ApplicationFailureInfo::default(), + )), + ..Default::default() + })), + failure_info: Some(FailureInfo::ActivityFailureInfo( + ActivityFailureInfo::default(), + )), + ..Default::default() + }; + let activity_error = ActivityExecutionError::Failed(ActivityFailureError::new( + failure, + ActivityFailureInfo::default(), + None, + )); + + let failure = converter.to_failure( + OutgoingError::Workflow(OutgoingWorkflowError::ActivityExecution(Box::new( + activity_error, + ))), + &payload_converter, + &context, + ); + + assert_eq!(failure.message, "Encoded failure"); + assert_eq!(failure.cause.as_ref().unwrap().message, "Encoded failure"); + assert!(failure.stack_trace.is_empty()); + assert!(failure.cause.as_ref().unwrap().stack_trace.is_empty()); + let payload_context = SerializationContext::new(&context, &payload_converter); + let outer_attributes: CommonAttributes = payload_converter + .from_payload( + &payload_context, + failure.encoded_attributes.clone().unwrap(), + ) + .unwrap(); + let inner_attributes: CommonAttributes = payload_converter + .from_payload( + &payload_context, + failure + .cause + .as_ref() + .unwrap() + .encoded_attributes + .clone() + .unwrap(), + ) + .unwrap(); + assert_eq!(outer_attributes.message, "outer message"); + assert_eq!(inner_attributes.message, "inner message"); + assert_eq!(outer_attributes.stack_trace, "outer stack trace"); + assert_eq!(inner_attributes.stack_trace, "inner stack trace"); + + let decoded = DefaultFailureConverter::default() + .to_error(failure, &payload_converter, &context) + .unwrap(); + assert_eq!(decoded.failure().message, "outer message"); + assert_eq!(decoded.failure().stack_trace, "outer stack trace"); + let cause = decoded.cause().unwrap().failure(); + assert_eq!(cause.message, "inner message"); + assert_eq!(cause.stack_trace, "inner stack trace"); + } + #[test] fn start_failed_child_workflow_errors_fall_back_to_application_failures() { let failure = convert(OutgoingWorkflowError::ChildWorkflowStart(Box::new( @@ -937,7 +1078,7 @@ mod tests { ..Default::default() }; - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error( failure.clone(), &PayloadConverter::default(), @@ -970,7 +1111,7 @@ mod tests { ..Default::default() }; - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error( failure.clone(), &PayloadConverter::default(), @@ -996,7 +1137,7 @@ mod tests { ..Default::default() }; - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error( failure.clone(), &PayloadConverter::default(), @@ -1014,7 +1155,7 @@ mod tests { assert_eq!(reencoded.message, failure.message); assert_eq!(reencoded.cause.as_deref(), failure.cause.as_deref()); - let decoded_reencoded = DefaultFailureConverter + let decoded_reencoded = DefaultFailureConverter::default() .to_error( reencoded, &PayloadConverter::default(), @@ -1044,7 +1185,7 @@ mod tests { ..Default::default() }; - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error( failure.clone(), &PayloadConverter::default(), @@ -1115,7 +1256,7 @@ mod tests { ..Default::default() }; - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error( failure.clone(), &PayloadConverter::default(), @@ -1152,7 +1293,7 @@ mod tests { }; let data_converter = crate::data_converters::DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), crate::data_converters::DefaultPayloadCodec, ); @@ -1254,7 +1395,7 @@ mod tests { ..Default::default() }; - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error( failure.clone(), &PayloadConverter::default(), @@ -1290,7 +1431,7 @@ mod tests { ..Default::default() }; - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error( failure.clone(), &PayloadConverter::default(), @@ -1463,7 +1604,7 @@ mod tests { #[test] fn outgoing_cancelled_activity_errors_encode_to_cancelled_failures() { - let failure = DefaultFailureConverter.to_failure( + let failure = DefaultFailureConverter::default().to_failure( OutgoingError::Activity(OutgoingActivityError::Cancelled { details: None }), &PayloadConverter::default(), &SerializationContextData::Activity, @@ -1478,7 +1619,7 @@ mod tests { #[test] fn outgoing_cancelled_activity_errors_encode_serializable_details_with_payload_converter() { - let failure = DefaultFailureConverter.to_failure( + let failure = DefaultFailureConverter::default().to_failure( OutgoingError::Activity(OutgoingActivityError::Cancelled { details: Some("detail".to_string().into()), }), @@ -1486,7 +1627,7 @@ mod tests { &SerializationContextData::Activity, ); - let err = DefaultFailureConverter + let err = DefaultFailureConverter::default() .to_error( failure, &PayloadConverter::default(), diff --git a/crates/common-wasm/src/error.rs b/crates/common-wasm/src/error.rs index 3e5a420a6..42e77b251 100644 --- a/crates/common-wasm/src/error.rs +++ b/crates/common-wasm/src/error.rs @@ -1271,7 +1271,7 @@ mod tests { ..Default::default() }; - let decoded = DefaultFailureConverter + let decoded = DefaultFailureConverter::default() .to_error( failure, &PayloadConverter::default(), @@ -1316,7 +1316,7 @@ mod tests { ..Default::default() }], }; - let failure = DefaultFailureConverter.to_failure( + let failure = DefaultFailureConverter::default().to_failure( OutgoingError::Workflow(OutgoingWorkflowError::Application(Box::new( ApplicationFailure::builder(anyhow::anyhow!("oops")) .type_name("MyType".to_owned()) @@ -1346,7 +1346,7 @@ mod tests { data: b"details".to_vec(), ..Default::default() }; - let failure = DefaultFailureConverter.to_failure( + let failure = DefaultFailureConverter::default().to_failure( OutgoingError::Workflow(OutgoingWorkflowError::Application(Box::new( ApplicationFailure::builder(anyhow::anyhow!("oops")) .details(RawValue::new(vec![payload.clone()])) @@ -1364,7 +1364,7 @@ mod tests { #[test] fn builder_accepts_serializable_details() { - let failure = DefaultFailureConverter.to_failure( + let failure = DefaultFailureConverter::default().to_failure( OutgoingError::Workflow(OutgoingWorkflowError::Application(Box::new( ApplicationFailure::builder(anyhow::anyhow!("oops")) .details("details".to_string()) @@ -1390,7 +1390,7 @@ mod tests { #[test] fn application_failure_encoding_surfaces_detail_encoding_errors() { - let failure = DefaultFailureConverter.to_failure( + let failure = DefaultFailureConverter::default().to_failure( OutgoingError::Workflow(OutgoingWorkflowError::Application(Box::new( ApplicationFailure::builder(anyhow::anyhow!("oops")) .details(AlwaysFailsSerialize) diff --git a/crates/common/src/payload_visitor.rs b/crates/common/src/payload_visitor.rs index db20742ae..89ea251e1 100644 --- a/crates/common/src/payload_visitor.rs +++ b/crates/common/src/payload_visitor.rs @@ -797,7 +797,7 @@ mod tests { #[tokio::test] async fn test_encode_failure_encodes_application_failure_details() { - let mut failure = DefaultFailureConverter.to_failure( + let mut failure = DefaultFailureConverter::default().to_failure( OutgoingError::Workflow(OutgoingWorkflowError::Application(Box::new( ApplicationFailure::builder(anyhow::anyhow!("app boom")) .details(crate::data_converters::RawValue::new(vec![make_payload( diff --git a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs index 8a0444acc..6033771a7 100644 --- a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs +++ b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs @@ -159,7 +159,7 @@ impl FailureConverter for FailingFailureConverter { payload_converter: &PayloadConverter, context: &SerializationContextData, ) -> Result { - DefaultFailureConverter.to_error(failure, payload_converter, context) + DefaultFailureConverter::default().to_error(failure, payload_converter, context) } } @@ -793,9 +793,9 @@ async fn option_none_workflow_input_is_recorded_as_binary_null() { let events = handle .fetch_history(Default::default()) + .into_events() .await - .unwrap() - .into_events(); + .unwrap(); let input = events .iter() .find_map(|event| match event.attributes.as_ref() { @@ -1018,7 +1018,7 @@ async fn codec_errors_fail_tasks_and_retry(#[case] failure_point: CodecFailurePo let connection = get_integ_connection(None).await; let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), codec.clone(), ); let client_opts = ClientOptions::new(integ_namespace()) @@ -1066,7 +1066,7 @@ async fn codec_encodes_and_decodes_payloads() { let connection = get_integ_connection(None).await; let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), codec.clone(), ); let client_opts = ClientOptions::new(integ_namespace()) @@ -1124,7 +1124,7 @@ async fn describe_decodes_workflow_payload_fields() { let connection = get_integ_connection(None).await; let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), codec.clone(), ); let client_opts = ClientOptions::new(integ_namespace()) @@ -1202,7 +1202,7 @@ async fn describe_decodes_user_metadata_with_ungated_xor_codec() { let connection = get_integ_connection(None).await; let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), codec.clone(), ); let client_opts = ClientOptions::new(integ_namespace()) @@ -1266,7 +1266,7 @@ async fn codec_roundtrips_activity_cancellation_details() { let connection = get_integ_connection(None).await; let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), codec.clone(), ); let client_opts = ClientOptions::new(integ_namespace()) @@ -1315,7 +1315,7 @@ async fn codec_roundtrips_activity_heartbeat_timeout_details() { let connection = get_integ_connection(None).await; let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), codec.clone(), ); let client_opts = ClientOptions::new(integ_namespace()) diff --git a/crates/sdk-core/tests/integ_tests/plugin_tests.rs b/crates/sdk-core/tests/integ_tests/plugin_tests.rs index 8325c706f..b3d98bd0e 100644 --- a/crates/sdk-core/tests/integ_tests/plugin_tests.rs +++ b/crates/sdk-core/tests/integ_tests/plugin_tests.rs @@ -218,7 +218,7 @@ async fn simple_plugin_configures_working_client_and_worker() { let worker_interceptor_calls = Arc::new(AtomicUsize::new(0)); let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), CountingPayloadCodec { encode_calls: encode_calls.clone(), decode_calls: decode_calls.clone(), diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 2b07638d2..3a12c1f28 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -1583,7 +1583,7 @@ mod tests { let codec = Arc::new(FailingEncodeCodec::default()); let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), codec.clone(), ); let mut completion = WorkflowActivationCompletion::from_cmd( @@ -1614,7 +1614,7 @@ mod tests { let codec = Arc::new(FailingEncodeCodec::default()); let data_converter = DataConverter::new( PayloadConverter::default(), - DefaultFailureConverter, + DefaultFailureConverter::default(), codec.clone(), ); let mut completion = ActivityTaskCompletion { From 857248b25eefee15d6d5fe4574161e5e8287f94a Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Mon, 31 Aug 2026 14:25:57 -0700 Subject: [PATCH 44/87] Update api_upstream (#1545) --- crates/protos/protos/api_upstream/Makefile | 8 ++-- crates/protos/protos/api_upstream/buf.lock | 19 +++------- crates/protos/protos/api_upstream/buf.yaml | 29 +++++++-------- .../nexus/deps/nexus-temporal-types/model.wit | 37 ++++++++++++------- .../api_upstream/nexus/workflow-service.wit | 4 +- 5 files changed, 49 insertions(+), 48 deletions(-) diff --git a/crates/protos/protos/api_upstream/Makefile b/crates/protos/protos/api_upstream/Makefile index d43b05677..f6fcf899d 100644 --- a/crates/protos/protos/api_upstream/Makefile +++ b/crates/protos/protos/api_upstream/Makefile @@ -97,7 +97,7 @@ api-linter-install: buf-install: printf $(COLOR) "Install/update buf..." - go install github.com/bufbuild/buf/cmd/buf@v1.27.0 + go install github.com/bufbuild/buf/cmd/buf@v1.49.0 ##### Sync external proto dependencies ##### sync-nexus-annotations: @@ -112,12 +112,12 @@ api-linter: $(STAMPDIR): mkdir $@ -$(STAMPDIR)/buf-mod-prune: $(STAMPDIR) buf.yaml +$(STAMPDIR)/buf-dep-prune: $(STAMPDIR) buf.yaml printf $(COLOR) "Pruning buf module" - buf mod prune + buf dep prune touch $@ -buf-lint: $(STAMPDIR)/buf-mod-prune +buf-lint: $(STAMPDIR)/buf-dep-prune printf $(COLOR) "Run buf linter..." (cd $(PROTO_ROOT) && buf lint) diff --git a/crates/protos/protos/api_upstream/buf.lock b/crates/protos/protos/api_upstream/buf.lock index f43352bf2..e0559987a 100644 --- a/crates/protos/protos/api_upstream/buf.lock +++ b/crates/protos/protos/api_upstream/buf.lock @@ -1,18 +1,9 @@ # Generated by buf. DO NOT EDIT. -version: v1 +version: v2 deps: - - remote: buf.build - owner: googleapis - repository: googleapis + - name: buf.build/googleapis/googleapis commit: 004180b77378443887d3b55cabc00384 - digest: shake256:d26c7c2fd95f0873761af33ca4a0c0d92c8577122b6feb74eb3b0a57ebe47a98ab24a209a0e91945ac4c77204e9da0c2de0020b2cedc27bdbcdea6c431eec69b - - remote: buf.build - owner: grpc-ecosystem - repository: grpc-gateway - commit: 6467306b4f624747aaf6266762ee7a1c - digest: shake256:833d648b99b9d2c18b6882ef41aaeb113e76fc38de20dda810c588d133846e6593b4da71b388bcd921b1c7ab41c7acf8f106663d7301ae9e82ceab22cf64b1b7 - - remote: buf.build - owner: temporalio - repository: nexus-annotations + digest: b5:e8f475fe3330f31f5fd86ac689093bcd274e19611a09db91f41d637cb9197881ce89882b94d13a58738e53c91c6e4bae7dc1feba85f590164c975a89e25115dc + - name: buf.build/temporalio/nexus-annotations commit: 599b78404fbe4e78b833d527a1d0da40 - digest: shake256:1f41ef11ccbf31d7318b0fe1915550ba6567c99dc94694d60b117fc1ffc756290ba9766c58b403986f079e2b861b42538e5f8cf0495f744cd390d223b81854ca + digest: b5:feb0298a2e7e60058a5dee533e166e152bd0c3b9f776170946fba80737722022a39a65b132af1028d150d2c5bc52990694f60fbcf89fbb9693f7a6b7803d9203 diff --git a/crates/protos/protos/api_upstream/buf.yaml b/crates/protos/protos/api_upstream/buf.yaml index 2f2fa5389..d00ec17a7 100644 --- a/crates/protos/protos/api_upstream/buf.yaml +++ b/crates/protos/protos/api_upstream/buf.yaml @@ -1,24 +1,23 @@ -version: v1 -name: buf.build/temporalio/api +version: v2 +modules: + - path: . + name: buf.build/temporalio/api + excludes: + # Vendored for api-linter (can't read the BSR); excluded so buf sees them once. + - google + - nexusannotations deps: - - buf.build/grpc-ecosystem/grpc-gateway - buf.build/googleapis/googleapis - buf.build/temporalio/nexus-annotations -build: - excludes: - # Buf won't accept a local dependency on the google protos but we need them - # to run api-linter, so just tell buf it ignore it - - google - # Same for nexusannotations - local copy for api-linter, BSR dep for buf - - nexusannotations -breaking: +lint: use: - - WIRE_JSON + - STANDARD ignore: + - cmd - google -lint: + disallow_comment_ignores: true +breaking: use: - - DEFAULT + - WIRE_JSON ignore: - google - - cmd diff --git a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit index 91de3e838..9909d34c6 100644 --- a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit +++ b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit @@ -9,15 +9,28 @@ interface model { /// python="typing.Any" /// typescript="common.Payload" /// dotnet="object?" + /// dotnet-from="ProtoExtensions.FromPayload" /// dotnet-to="ProtoExtensions.ToPayload" /// typescript-import="@temporalio/common" type payload = placeholder; /// @nexus.proto "temporal.api.common.v1.Payloads" /// typescript-import="@temporalio/proto" - /// @nexus.type dotnet="IReadOnlyCollection" dotnet-to="ProtoExtensions.ToPayloads" + /// @nexus.type dotnet="IReadOnlyCollection" dotnet-from="ProtoExtensions.FromPayloads" dotnet-to="ProtoExtensions.ToPayloads" type payloads = list; + /// Temporal failure represented by the target SDK's native exception/error type. + /// The SDK failure converter owns the recursive cause and failure-info structure. + /// @nexus.proto "temporal.api.failure.v1.Failure" typescript-import="@temporalio/proto" + /// @nexus.type + /// python="BaseException" + /// typescript="Error" + /// go="error" + /// dotnet="System.Exception" + /// dotnet-from="ProtoExtensions.FromFailureProto" + /// dotnet-to="ProtoExtensions.ToFailureProto" + type failure = placeholder; + /// Callable result annotation for workflow functions. /// @nexus.type /// python="collections.abc.Awaitable[WorkflowResult]" @@ -50,6 +63,7 @@ interface model { /// python="str" /// typescript="string" /// dotnet="string" + /// dotnet-from="ProtoExtensions.FromWorkflowTypeProto" /// dotnet-to="ProtoExtensions.ToWorkflowTypeProto" type workflow-type = placeholder; @@ -83,6 +97,7 @@ interface model { /// python="temporalio.common.RetryPolicy" /// typescript="common.RetryPolicy" /// dotnet="Temporalio.Common.RetryPolicy" + /// dotnet-from="ProtoExtensions.FromRetryPolicyProto" /// typescript-import="@temporalio/common" type retry-policy = placeholder; @@ -91,11 +106,12 @@ interface model { /// python="str" /// typescript="string" /// dotnet="string" + /// dotnet-from="ProtoExtensions.FromTaskQueueProto" /// dotnet-to="ProtoExtensions.ToTaskQueueProto" type task-queue = placeholder; /// @nexus.proto "temporal.api.common.v1.Memo" typescript-import="@temporalio/proto" - /// @nexus.type python="collections.abc.Mapping[str, typing.Any]" typescript="Record" dotnet="IReadOnlyDictionary" + /// @nexus.type python="collections.abc.Mapping[str, typing.Any]" typescript="Record" dotnet="IReadOnlyDictionary" dotnet-from="ProtoExtensions.FromMemoProto" type memo = placeholder; /// @nexus.proto "temporal.api.common.v1.Header" typescript-import="@temporalio/proto" @@ -114,6 +130,7 @@ interface model { /// python="temporalio.common.TypedSearchAttributes" /// typescript="common.TypedSearchAttributes" /// dotnet="Temporalio.Common.SearchAttributeCollection" + /// dotnet-from="ProtoExtensions.FromSearchAttributesProto" /// typescript-import="@temporalio/common" type search-attributes = placeholder; @@ -122,6 +139,7 @@ interface model { /// python="temporalio.common.Priority" /// typescript="common.Priority" /// dotnet="Temporalio.Common.Priority" + /// dotnet-from="ProtoExtensions.FromPriorityProto" /// typescript-import="@temporalio/common" type priority = placeholder; @@ -130,6 +148,7 @@ interface model { /// python="temporalio.common.VersioningOverride" /// typescript="common.VersioningOverride" /// dotnet="Temporalio.Common.VersioningOverride" + /// dotnet-from="ProtoExtensions.FromVersioningOverrideProto" /// typescript-import="@temporalio/common" type versioning-override = placeholder; @@ -138,6 +157,7 @@ interface model { /// python="datetime.timedelta" /// typescript="common.Duration" /// dotnet="System.TimeSpan" + /// dotnet-from="ProtoExtensions.FromDurationProto" /// typescript-import="@temporalio/common" type duration = placeholder; @@ -147,12 +167,7 @@ interface model { /// typescript="common.WorkflowIdReusePolicy" /// dotnet="Temporalio.Api.Enums.V1.WorkflowIdReusePolicy" /// typescript-import="@temporalio/common" - enum workflow-id-reuse-policy { - allow-duplicate, - allow-duplicate-failed-only, - reject-duplicate, - terminate-if-running, - } + type workflow-id-reuse-policy = placeholder; /// @nexus.proto "temporal.api.enums.v1.WorkflowIdConflictPolicy" typescript-import="@temporalio/proto" /// @nexus.type @@ -160,11 +175,7 @@ interface model { /// typescript="common.WorkflowIdConflictPolicy" /// dotnet="Temporalio.Api.Enums.V1.WorkflowIdConflictPolicy" /// typescript-import="@temporalio/common" - enum workflow-id-conflict-policy { - fail, - use-existing, - terminate-existing, - } + type workflow-id-conflict-policy = placeholder; /// @nexus.proto "temporal.api.sdk.v1.UserMetadata" typescript-import="@temporalio/proto" /// @nexus.flatten-in-api diff --git a/crates/protos/protos/api_upstream/nexus/workflow-service.wit b/crates/protos/protos/api_upstream/nexus/workflow-service.wit index 5ae3814e6..190aae88c 100644 --- a/crates/protos/protos/api_upstream/nexus/workflow-service.wit +++ b/crates/protos/protos/api_upstream/nexus/workflow-service.wit @@ -86,7 +86,7 @@ interface workflow-service { /// @nexus.proto-field "workflow_start_delay" start-delay: option, user-metadata: option, - /// @nexus.source python="workflow_namespace" typescript="workflowNamespace" dotnet="TemporalWorkflowContext.WorkflowNamespace" + /// @nexus.source python="workflow_namespace()" typescript="workflowNamespace()" go="workflow.GetInfo(ctx).Namespace" dotnet="TemporalWorkflowContext.WorkflowNamespace()" namespace: string, /// @nexus.omit control: placeholder, @@ -122,7 +122,7 @@ interface workflow-service { /// dotnet-type="Temporalio.Workflows.ExternalWorkflowHandle" /// dotnet="Temporalio.Workflows.Workflow.GetExternalWorkflowHandle(request.Id, result.RunId)" /// @nexus.operation name="SignalWithStartWorkflowExecution" - /// @nexus.serialization-context python="signal_with_start_workflow_serialization_context" + /// @nexus.serialization-context python="signal_with_start_workflow_serialization_context" dotnet="WorkflowServiceSerializationContexts.SignalWithStartWorkflow" /// @nexus.experimental signal-with-start-workflow: func( request: signal-with-start-workflow-request, From 88af5c877a14069b43cf11d2461287b47dd1fe87 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 31 Aug 2026 17:27:23 -0400 Subject: [PATCH 45/87] chore(sdk): make serialization context extensible (#1553) --- CHANGELOG.md | 2 + crates/client/src/activity/activity_handle.rs | 10 +- crates/client/src/async_activity_handle.rs | 18 +- crates/client/src/lib.rs | 98 +++--- crates/client/src/options_structs.rs | 13 +- crates/client/src/schedules.rs | 39 ++- crates/client/src/workflow_handle.rs | 88 ++++-- crates/common-wasm/src/data_converters.rs | 83 ++++- .../src/data_converters/failure_converter.rs | 63 ++-- crates/common-wasm/src/error.rs | 10 +- crates/common-wasm/src/memo.rs | 19 +- crates/common/src/payload_visitor.rs | 75 ++--- crates/macros/src/workflow_definitions.rs | 5 +- .../tests/integ_tests/data_converter_tests.rs | 8 +- .../tests/integ_tests/workflow_tests/nexus.rs | 5 +- crates/sdk/src/activities.rs | 26 +- crates/sdk/src/lib.rs | 36 ++- crates/sdk/src/testing.rs | 8 +- crates/sdk/src/workflow_registry.rs | 7 +- crates/workflow/src/runtime/entry.rs | 5 +- crates/workflow/src/runtime/instance.rs | 18 +- crates/workflow/src/workflow_context.rs | 283 ++++++++++-------- .../workflow/src/workflow_context/options.rs | 9 +- crates/workflow/src/workflow_context/view.rs | 4 +- crates/workflow/src/workflow_interceptors.rs | 4 +- 25 files changed, 586 insertions(+), 350 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b9844560..bf73dc35c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,8 @@ relevant information. or constructors (`WorkerCallbacks::new`, `ActivityExecutionDecodeHint::new`, or `SerializationContext::new`); use `Default` for `PayloadConverter`; and add wildcard branches when matching enums. +* `SerializationContextData::{Workflow, Activity, Nexus}` now contain corresponding context + structs. `SerializationContextData` is no longer `Copy`. * Renamed `ActivityCloseTimeouts::Both` to `ActivityCloseTimeouts::ScheduleAndStartToClose`. * Removed the unused `ActExitValue` type. Use `ActivityError::WillCompleteAsync` to mark an activity for asynchronous completion. diff --git a/crates/client/src/activity/activity_handle.rs b/crates/client/src/activity/activity_handle.rs index f992c1a23..e8d51af3a 100644 --- a/crates/client/src/activity/activity_handle.rs +++ b/crates/client/src/activity/activity_handle.rs @@ -7,7 +7,9 @@ use crate::{ use std::marker::PhantomData; use temporalio_common::{ ActivityDefinition, - data_converters::{DecodablePayloads, NoopDecodeHint, SerializationContextData}, + data_converters::{ + ActivitySerializationContext, DecodablePayloads, NoopDecodeHint, SerializationContextData, + }, payload_visitor::decode_payloads, protos::temporal::api::{ activity::v1::{ActivityExecutionOutcome, activity_execution_outcome}, @@ -99,7 +101,7 @@ where }; let dc = client.data_converter(); - let ctx = SerializationContextData::Activity; + let ctx = SerializationContextData::Activity(ActivitySerializationContext::new()); return match outcome { activity_execution_outcome::Value::Result(payloads) => { @@ -156,7 +158,7 @@ where Ok(ActivityExecutionDescription::new( client.data_converter().clone(), - SerializationContextData::Activity, + SerializationContextData::Activity(ActivitySerializationContext::new()), resp, ) .await?) @@ -293,7 +295,7 @@ mod tests { DefaultFailureConverter::new(true), XorCodec, ); - let context = SerializationContextData::Activity; + let context = SerializationContextData::Activity(ActivitySerializationContext::new()); let mut failure = data_converter.to_failure( &context, OutgoingError::Activity(OutgoingActivityError::Application(Box::new( diff --git a/crates/client/src/async_activity_handle.rs b/crates/client/src/async_activity_handle.rs index 17a326c08..b84abc121 100644 --- a/crates/client/src/async_activity_handle.rs +++ b/crates/client/src/async_activity_handle.rs @@ -7,7 +7,10 @@ use crate::{ }; use futures_util::future::BoxFuture; use temporalio_common::{ - data_converters::{SerializationContext, SerializationContextData, TemporalSerializable}, + data_converters::{ + ActivitySerializationContext, SerializationContext, SerializationContextData, + TemporalSerializable, + }, error::{ApplicationFailure, OutgoingActivityError, OutgoingError}, payload_visitor::encode_payloads, protos::{ @@ -35,14 +38,17 @@ async fn encode_optional_value( }; let unencoded_payloads = { let payload_converter = data_converter.payload_converter(); - let context = - SerializationContext::new(&SerializationContextData::Activity, payload_converter); + let context_data = SerializationContextData::Activity(ActivitySerializationContext::new()); + let context = SerializationContext::new(&context_data, payload_converter); value.serialize_payloads(&context)? }; drop(value); let payloads = data_converter .codec() - .encode(&SerializationContextData::Activity, unencoded_payloads) + .encode( + &SerializationContextData::Activity(ActivitySerializationContext::new()), + unencoded_payloads, + ) .await?; Ok(Some(Payloads { payloads })) } @@ -235,7 +241,7 @@ impl AsyncActivityHandle { input.into_parts(); let data_converter = client.data_converter().clone(); let mut failure = data_converter.to_failure( - &SerializationContextData::Activity, + &SerializationContextData::Activity(ActivitySerializationContext::new()), OutgoingError::Activity(OutgoingActivityError::Application(Box::new( application_failure, ))), @@ -243,7 +249,7 @@ impl AsyncActivityHandle { encode_payloads( &mut failure, data_converter.codec(), - &SerializationContextData::Activity, + &SerializationContextData::Activity(ActivitySerializationContext::new()), ) .await?; let last_heartbeat_details = diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 6dee58310..81701a8bc 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -135,7 +135,10 @@ use std::{ }; use temporalio_common::{ ActivityDefinition, HasWorkflowDefinition, SignalDefinition, UntypedActivity, UpdateDefinition, - data_converters::{DataConverter, SerializationContext, SerializationContextData}, + data_converters::{ + ActivitySerializationContext, DataConverter, SerializationContext, + SerializationContextData, WorkflowSerializationContext, + }, payload_visitor::decode_payloads, protos::{ coresdk::IntoPayloadsExt, @@ -1659,7 +1662,7 @@ impl WorkflowExecution { Memo::from_raw( self.raw.memo.clone(), self.data_converter.payload_converter().clone(), - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) } @@ -1876,14 +1879,18 @@ where let data_converter = client.data_converter().clone(); let unencoded_payloads = { let payload_converter = data_converter.payload_converter(); - let context = SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ); + let context = + SerializationContext::new(&context_data, payload_converter); args.serialize_payloads(&context) }; drop(args); let payloads = data_converter .codec() - .encode(&SerializationContextData::Workflow, unencoded_payloads?) + .encode(&SerializationContextData::Workflow(WorkflowSerializationContext::new()), unencoded_payloads?) .await?; let workflow_id = options.workflow_id.clone(); let memo = options.encoded_memo(&data_converter).await?; @@ -1967,18 +1974,21 @@ where ) = input.into_parts(); let data_converter = client.data_converter().clone(); let payload_converter = data_converter.payload_converter(); - let context = SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ); + let context = SerializationContext::new(&context_data, payload_converter); let workflow_payloads = workflow_args.serialize_payloads(&context); let signal_payloads = signal_args.serialize_payloads(&context); drop(workflow_args); drop(signal_args); let workflow_payloads = data_converter .codec() - .encode(&SerializationContextData::Workflow, workflow_payloads?) + .encode(&SerializationContextData::Workflow(WorkflowSerializationContext::new()), workflow_payloads?) .await?; let signal_payloads = data_converter .codec() - .encode(&SerializationContextData::Workflow, signal_payloads?) + .encode(&SerializationContextData::Workflow(WorkflowSerializationContext::new()), signal_payloads?) .await?; let workflow_id = options.workflow_id.clone(); let memo = options.encoded_memo(&data_converter).await?; @@ -2097,10 +2107,11 @@ where let data_converter = client.data_converter().clone(); let (unencoded_workflow_payloads, unencoded_update_payloads) = { let payload_converter = data_converter.payload_converter(); - let context = SerializationContext::new( - &SerializationContextData::Workflow, - payload_converter, + let context_data = SerializationContextData::Workflow( + WorkflowSerializationContext::new(), ); + let context = + SerializationContext::new(&context_data, payload_converter); ( workflow_args.serialize_payloads(&context), update_args.serialize_payloads(&context), @@ -2112,11 +2123,15 @@ where // encode both payload sets concurrently. let (workflow_payloads, update_payloads) = try_join( data_converter.codec().encode( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), unencoded_workflow_payloads?, ), data_converter.codec().encode( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), unencoded_update_payloads?, ), ) @@ -2364,7 +2379,9 @@ where && let Err(err) = decode_payloads( memo, data_converter.codec(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), ) .await { @@ -2460,7 +2477,7 @@ where { let mut client = self.clone(); let dc = client.data_converter(); - let sc = &SerializationContextData::Activity; + let sc = &SerializationContextData::Activity(ActivitySerializationContext::new()); let user_metadata = { let summary = match &options.summary { @@ -3695,13 +3712,17 @@ mod tests { /// Decode a sent memo the same way `describe`/`list` do, and read it back. async fn read_back(sent: ProtoMemo) -> Memo { let mut sent = sent; - decode_payloads(&mut sent, &XorCodec, &SerializationContextData::Workflow) - .await - .unwrap(); + decode_payloads( + &mut sent, + &XorCodec, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + ) + .await + .unwrap(); Memo::from_raw( Some(sent), PayloadConverter::default(), - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) } @@ -3856,7 +3877,10 @@ mod tests { }; let replacement: String = client .data_converter() - .from_payloads(&SerializationContextData::Workflow, payloads) + .from_payloads( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + payloads, + ) .await .unwrap(); assert_eq!(replacement, "replacement"); @@ -4019,7 +4043,7 @@ mod tests { assert_eq!( data_converter .from_payloads::>( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), workflow_payloads, ) .await @@ -4029,7 +4053,7 @@ mod tests { assert_eq!( data_converter .from_payloads::>( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), signal_payloads, ) .await @@ -4184,15 +4208,18 @@ mod tests { ) -> ExecuteMultiOperationResponse { let outcome = (stage == UpdateWorkflowExecutionLifecycleStage::Completed).then(|| { let payload_converter = PayloadConverter::default(); - let result_payloads = payload_converter - .to_payloads( - &SerializationContext::new( - &SerializationContextData::Workflow, - &payload_converter, - ), - &"update-result".to_owned(), - ) - .unwrap(); + let result_payloads = + payload_converter + .to_payloads( + &SerializationContext::new( + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), + &payload_converter, + ), + &"update-result".to_owned(), + ) + .unwrap(); Outcome { value: Some(outcome::Value::Success(Payloads { payloads: result_payloads, @@ -4324,8 +4351,9 @@ mod tests { .unwrap(); let payload_converter = PayloadConverter::default(); - let context = - SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); + let context_data = + SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let context = SerializationContext::new(&context_data, &payload_converter); let workflow_payloads = payload_converter .to_payloads(&context, &"workflow-input".to_owned()) .unwrap(); @@ -4534,7 +4562,7 @@ mod tests { let workflow_input: String = client .data_converter() .from_payloads( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), start_request.input.clone().unwrap().payloads, ) .await @@ -4547,7 +4575,7 @@ mod tests { let update_input: String = client .data_converter() .from_payloads( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), update_request .request .clone() @@ -4784,7 +4812,7 @@ mod tests { ); let memo_payload = data_converter .to_payload( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), &"memo-value".to_owned(), ) .await diff --git a/crates/client/src/options_structs.rs b/crates/client/src/options_structs.rs index 5ac8941bd..0dc66b915 100644 --- a/crates/client/src/options_structs.rs +++ b/crates/client/src/options_structs.rs @@ -8,7 +8,7 @@ use temporalio_common::{ ActivityCloseTimeouts, MemoValues, RetryPolicy, data_converters::{ DataConverter, GenericPayloadConverter, PayloadConversionError, PayloadConverter, - SerializationContext, SerializationContextData, + SerializationContext, SerializationContextData, WorkflowSerializationContext, }, payload_visitor::encode_payloads, protos::temporal::api::{ @@ -466,8 +466,8 @@ impl WorkflowStartOptions { }; let payload_converter = data_converter.payload_converter(); - let context = - SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let context = SerializationContext::new(&context_data, payload_converter); let mut memo = ProtoMemo { fields: memo .iter() @@ -481,7 +481,7 @@ impl WorkflowStartOptions { encode_payloads( &mut memo, data_converter.codec(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await?; Ok(Some(memo)) @@ -490,8 +490,9 @@ impl WorkflowStartOptions { pub(crate) fn user_metadata(&self) -> Option { (self.static_summary.is_some() || self.static_details.is_some()).then(|| { let payload_converter = PayloadConverter::default(); - let context = - SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); + let context_data = + SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let context = SerializationContext::new(&context_data, &payload_converter); UserMetadata { summary: self.static_summary.as_ref().map(|summary| { payload_converter diff --git a/crates/client/src/schedules.rs b/crates/client/src/schedules.rs index 5e3723c52..1e58e9f58 100644 --- a/crates/client/src/schedules.rs +++ b/crates/client/src/schedules.rs @@ -17,7 +17,7 @@ use temporalio_common::{ HasWorkflowDefinition, data_converters::{ DataConverter, PayloadConversionError, SerializationContextData, TemporalDeserializable, - TemporalSerializable, + TemporalSerializable, WorkflowSerializationContext, }, payload_visitor::decode_payloads, protos::{ @@ -102,7 +102,11 @@ impl ScheduleWorkflowInput { dc: &DataConverter, ) -> Result, PayloadConversionError> { let ScheduleWorkflowInputRepr::Deferred(v) = self.repr; - v.to_payloads(dc, &SerializationContextData::Workflow).await + v.to_payloads( + dc, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + ) + .await } } @@ -541,7 +545,10 @@ impl ScheduleDescriptionStartWorkflowAction { match &self.input { Some(input) => self .data_converter - .from_payloads(&SerializationContextData::Workflow, input.payloads.clone()) + .from_payloads( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + input.payloads.clone(), + ) .await .map(Some), None => Ok(None), @@ -726,7 +733,7 @@ impl ScheduleDescription { crate::Memo::from_raw( self.raw.memo.clone(), self.data_converter.payload_converter().clone(), - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) } @@ -979,7 +986,7 @@ impl ScheduleSummary { crate::Memo::from_raw( self.raw.memo.clone(), self.data_converter.payload_converter().clone(), - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) } @@ -1088,7 +1095,7 @@ where decode_payloads( memo, self.client.data_converter().codec(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await?; } @@ -1143,7 +1150,7 @@ where decode_payloads( &mut response, handle.client.data_converter().codec(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await?; let description = ScheduleDescription::new( @@ -1608,7 +1615,9 @@ where && let Err(err) = decode_payloads( memo, data_converter.codec(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), ) .await { @@ -1969,7 +1978,7 @@ mod tests { let data_converter = data_converter_with_codec(); let memo_payload = data_converter .to_payload( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), &"memo-value".to_owned(), ) .await @@ -2000,7 +2009,7 @@ mod tests { let data_converter = DataConverter::default(); let memo_payload = data_converter .to_payload( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), &"memo-value".to_owned(), ) .await @@ -2509,7 +2518,10 @@ mod tests { let data_converter = DataConverter::default(); let expected = MultiArgs2("hello".to_string(), 42i32); let payloads = data_converter - .to_payloads(&SerializationContextData::Workflow, &expected) + .to_payloads( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &expected, + ) .await .unwrap(); let desc = ScheduleDescription::new( @@ -2542,7 +2554,10 @@ mod tests { let data_converter = DataConverter::default(); let expected: String = "not-an-int".to_string(); let payloads = data_converter - .to_payloads(&SerializationContextData::Workflow, &expected) + .to_payloads( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &expected, + ) .await .unwrap(); let desc = schedule_description_from_response(describe_response_with_start_workflow(Some( diff --git a/crates/client/src/workflow_handle.rs b/crates/client/src/workflow_handle.rs index 08a695f97..ceb26487e 100644 --- a/crates/client/src/workflow_handle.rs +++ b/crates/client/src/workflow_handle.rs @@ -27,6 +27,7 @@ use temporalio_common::{ data_converters::{ DataConverter, DecodablePayloads, GenericPayloadConverter, PayloadConversionError, PayloadConverter, RawValue, SerializationContext, SerializationContextData, + WorkflowSerializationContext, }, error::IncomingError, payload_visitor::decode_payloads, @@ -99,13 +100,16 @@ impl WorkflowResultDetails { ) -> Result { let payloads = data_converter .codec() - .decode(&SerializationContextData::Workflow, payloads) + .decode( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + payloads, + ) .await?; Ok(Self { payloads: DecodablePayloads::new( payloads, data_converter.payload_converter().clone(), - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ), }) } @@ -177,11 +181,13 @@ impl WorkflowExecutionDescription { decode_payloads( &mut raw_description, data_converter.codec(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await?; - let decoded_metadata = - decode_user_metadata(&SerializationContextData::Workflow, raw_user_metadata)?; + let decoded_metadata = decode_user_metadata( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + raw_user_metadata, + )?; let history_length_raw = raw_description .workflow_execution_info .as_ref() @@ -261,7 +267,7 @@ impl WorkflowExecutionDescription { crate::Memo::from_raw( self.workflow_info().memo.clone(), self.data_converter.payload_converter().clone(), - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) } @@ -675,7 +681,7 @@ where .and_then(|p| p.payloads.into_iter().next()) .unwrap_or_default(); let result: W::Output = dc - .from_payload(&SerializationContextData::Workflow, payload) + .from_payload(&SerializationContextData::Workflow(WorkflowSerializationContext::new()), payload) .await?; Ok(WorkflowExecutionResult::Succeeded(result)) } @@ -685,13 +691,13 @@ where decode_payloads( &mut failure, dc.codec(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await?; let error = dc.failure_converter().to_error( failure, dc.payload_converter(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), )?; Ok(WorkflowExecutionResult::Failed(error)) } @@ -770,13 +776,17 @@ where let data_converter = client.data_converter().clone(); let unencoded_payloads = { let payload_converter = data_converter.payload_converter(); - let context = SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ); + let context = + SerializationContext::new(&context_data, payload_converter); args.serialize_payloads(&context) }; drop(args); let payloads = data_converter .codec() - .encode(&SerializationContextData::Workflow, unencoded_payloads?) + .encode(&SerializationContextData::Workflow(WorkflowSerializationContext::new()), unencoded_payloads?) .await?; let mut request = SignalWorkflowExecutionRequest { namespace: client.namespace(), @@ -838,13 +848,17 @@ where let data_converter = client.data_converter().clone(); let unencoded_payloads = { let payload_converter = data_converter.payload_converter(); - let context = SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ); + let context = + SerializationContext::new(&context_data, payload_converter); args.serialize_payloads(&context) }; drop(args); let payloads = data_converter .codec() - .encode(&SerializationContextData::Workflow, unencoded_payloads?) + .encode(&SerializationContextData::Workflow(WorkflowSerializationContext::new()), unencoded_payloads?) .await?; let mut request = QueryWorkflowRequest { namespace: client.namespace(), @@ -891,7 +905,10 @@ where self.client .data_converter() - .from_payloads(&SerializationContextData::Workflow, result_payloads) + .from_payloads( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + result_payloads, + ) .await .map_err(WorkflowQueryError::from) } @@ -948,16 +965,22 @@ where let data_converter = client.data_converter().clone(); let unencoded_payloads = { let payload_converter = data_converter.payload_converter(); - let context = SerializationContext::new( - &SerializationContextData::Workflow, - payload_converter, + let context_data = SerializationContextData::Workflow( + WorkflowSerializationContext::new(), ); + let context = + SerializationContext::new(&context_data, payload_converter); args.serialize_payloads(&context) }; drop(args); let payloads = data_converter .codec() - .encode(&SerializationContextData::Workflow, unencoded_payloads?) + .encode( + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), + unencoded_payloads?, + ) .await?; let update_id = options .update_id @@ -1418,7 +1441,10 @@ where Some(update::v1::outcome::Value::Success(success)) => self .client .data_converter() - .from_payloads(&SerializationContextData::Workflow, success.payloads) + .from_payloads( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + success.payloads, + ) .await .map_err(WorkflowUpdateError::from), Some(update::v1::outcome::Value::Failure(failure)) => { @@ -1630,7 +1656,7 @@ mod tests { ); let payloads = converter .to_payloads( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), &"workflow-result-details".to_owned(), ) .await @@ -1668,7 +1694,7 @@ mod tests { ); let encoded = converter .to_payload( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), &"memo-value".to_owned(), ) .await @@ -1699,19 +1725,31 @@ mod tests { async fn workflow_description_accessors_expose_decoded_fields() { let converter = DataConverter::default(); let memo_payload = converter - .to_payload(&SerializationContextData::Workflow, &"memo-value") + .to_payload( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &"memo-value", + ) .await .unwrap(); let search_attr_payload = converter - .to_payload(&SerializationContextData::Workflow, &"search-value") + .to_payload( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &"search-value", + ) .await .unwrap(); let summary_payload = converter - .to_payload(&SerializationContextData::Workflow, &"workflow summary") + .to_payload( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &"workflow summary", + ) .await .unwrap(); let details_payload = converter - .to_payload(&SerializationContextData::Workflow, &"workflow details") + .to_payload( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &"workflow details", + ) .await .unwrap(); let description = WorkflowExecutionDescription::new( diff --git a/crates/common-wasm/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs index 25b85e81b..9fc5b3c1c 100644 --- a/crates/common-wasm/src/data_converters.rs +++ b/crates/common-wasm/src/data_converters.rs @@ -140,16 +140,61 @@ impl DataConverter { } } +/// Data available when serializing in a workflow context. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct WorkflowSerializationContext {} + +#[allow(clippy::new_without_default)] +impl WorkflowSerializationContext { + /// Creates an empty workflow serialization context. + /// + /// **Experimental:** This constructor may change when workflow context data is added. + pub fn new() -> Self { + Self {} + } +} + +/// Data available when serializing in an activity context. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct ActivitySerializationContext {} + +#[allow(clippy::new_without_default)] +impl ActivitySerializationContext { + /// Creates an empty activity serialization context. + /// + /// **Experimental:** This constructor may change when activity context data is added. + pub fn new() -> Self { + Self {} + } +} + +/// Data available when serializing in a Nexus context. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct NexusSerializationContext {} + +#[allow(clippy::new_without_default)] +impl NexusSerializationContext { + /// Creates an empty Nexus serialization context. + /// + /// **Experimental:** This constructor may change when Nexus context data is added. + pub fn new() -> Self { + Self {} + } +} + /// Data about the serialization context, indicating where the serialization is occurring. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum SerializationContextData { /// Serialization is occurring in a workflow context. - Workflow, + Workflow(WorkflowSerializationContext), /// Serialization is occurring in an activity context. - Activity, + Activity(ActivitySerializationContext), /// Serialization is occurring in a nexus context. - Nexus, + Nexus(NexusSerializationContext), /// No specific serialization context. None, } @@ -838,7 +883,8 @@ mod tests { #[test] fn unit_payloads_roundtrip() { let converter = PayloadConverter::serde_json(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, &converter); let payloads = converter.to_payloads(&ctx, &()).unwrap(); assert!(payloads.is_empty()); @@ -870,7 +916,8 @@ mod tests { T: TemporalSerializable + std::fmt::Debug + 'static, { let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, &converter); let payload = converter.to_payload(&ctx, &value).unwrap(); @@ -904,7 +951,8 @@ mod tests { T: TemporalDeserializable + std::fmt::Debug + PartialEq + 'static, { let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, &converter); let actual: T = converter .from_payload( @@ -925,7 +973,8 @@ mod tests { #[test] fn use_wrappers_returns_wrong_encoding_for_standard_types() { let converter = PayloadConverter::UseWrappers; - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, &converter); let result = converter.to_payload(&ctx, &()); assert!( @@ -955,7 +1004,8 @@ mod tests { #[test] fn multi_args_round_trip() { let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, &converter); let args = MultiArgs2("hello".to_string(), 42i32); let payloads = converter.to_payloads(&ctx, &args).unwrap(); @@ -968,7 +1018,8 @@ mod tests { #[test] fn empty_payloads_do_not_decode_as_option() { let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, &converter); let result: Result, _> = converter.from_payloads(&ctx, vec![]); assert!(matches!(result, Err(PayloadConversionError::WrongEncoding))); @@ -996,12 +1047,18 @@ mod tests { let converter = PayloadConverter::default(); let payloads = converter .to_payloads( - &SerializationContext::new(&SerializationContextData::Workflow, &converter), + &SerializationContext::new( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &converter, + ), &value, ) .unwrap(); - let payloads = - DecodablePayloads::new(payloads, converter, SerializationContextData::Workflow); + let payloads = DecodablePayloads::new( + payloads, + converter, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), + ); let result: T = payloads.deserialize().unwrap(); assert_eq!(result, value); diff --git a/crates/common-wasm/src/data_converters/failure_converter.rs b/crates/common-wasm/src/data_converters/failure_converter.rs index 59b241ed2..f12b7c6d5 100644 --- a/crates/common-wasm/src/data_converters/failure_converter.rs +++ b/crates/common-wasm/src/data_converters/failure_converter.rs @@ -597,7 +597,10 @@ fn decode_failure( mod tests { use super::*; use crate::{ - data_converters::{GenericPayloadConverter, SerializationContext}, + data_converters::{ + ActivitySerializationContext, GenericPayloadConverter, SerializationContext, + WorkflowSerializationContext, + }, error::ApplicationErrorCategory, protos::temporal::api::{ common::v1::{Payload, Payloads}, @@ -695,7 +698,7 @@ mod tests { DefaultFailureConverter::default().to_failure( OutgoingError::Workflow(err), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) } @@ -764,7 +767,10 @@ mod tests { let converter = PayloadConverter::default(); let details: String = converter .from_payloads( - &SerializationContext::new(&SerializationContextData::Workflow, &converter), + &SerializationContext::new( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &converter, + ), payloads, ) .unwrap(); @@ -780,7 +786,7 @@ mod tests { .build(), ))), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ); assert_eq!( @@ -794,7 +800,10 @@ mod tests { let converter = PayloadConverter::default(); let payloads = converter .to_payloads( - &SerializationContext::new(&SerializationContextData::Workflow, &converter), + &SerializationContext::new( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &converter, + ), &"detail", ) .unwrap(); @@ -810,7 +819,11 @@ mod tests { }; let decoded = DefaultFailureConverter::default() - .to_error(failure, &converter, &SerializationContextData::Workflow) + .to_error( + failure, + &converter, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + ) .unwrap(); let IncomingError::Application(app) = decoded else { @@ -957,7 +970,7 @@ mod tests { .to_error( converted.clone(), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); @@ -979,7 +992,7 @@ mod tests { fn failure_converter_encodes_and_decodes_cause_chain() { let payload_converter = PayloadConverter::default(); let converter = DefaultFailureConverter::new(true); - let context = SerializationContextData::Workflow; + let context = SerializationContextData::Workflow(WorkflowSerializationContext::new()); let failure = Failure { message: "outer message".to_owned(), stack_trace: "outer stack trace".to_owned(), @@ -1082,7 +1095,7 @@ mod tests { .to_error( failure.clone(), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); @@ -1115,7 +1128,7 @@ mod tests { .to_error( failure.clone(), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); @@ -1141,7 +1154,7 @@ mod tests { .to_error( failure.clone(), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); @@ -1159,7 +1172,7 @@ mod tests { .to_error( reencoded, &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); let IncomingError::Application(roundtripped) = decoded_reencoded else { @@ -1189,7 +1202,7 @@ mod tests { .to_error( failure.clone(), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); @@ -1260,7 +1273,7 @@ mod tests { .to_error( failure.clone(), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); @@ -1299,7 +1312,7 @@ mod tests { let decoded = data_converter .to_error( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), failure.clone(), ActivityExecutionDecodeHint { cancelled: false }, ) @@ -1350,7 +1363,7 @@ mod tests { ) { let decoded = data_converter() .to_error( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), failure.clone(), ActivityExecutionDecodeHint { cancelled: true }, ) @@ -1399,7 +1412,7 @@ mod tests { .to_error( failure.clone(), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); @@ -1435,7 +1448,7 @@ mod tests { .to_error( failure.clone(), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); @@ -1472,7 +1485,7 @@ mod tests { }; let decoded = data_converter() .to_error( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), failure.clone(), ChildWorkflowExecutionDecodeHint, ) @@ -1521,7 +1534,7 @@ mod tests { ) { let decoded = data_converter() .to_error( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), failure.clone(), ChildWorkflowExecutionDecodeHint, ) @@ -1553,7 +1566,7 @@ mod tests { }; let decoded = data_converter() .to_error( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), failure.clone(), ChildWorkflowStartDecodeHint, ) @@ -1581,7 +1594,7 @@ mod tests { }; let decoded = data_converter() .to_error( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), failure.clone(), WorkflowSignalDecodeHint, ) @@ -1607,7 +1620,7 @@ mod tests { let failure = DefaultFailureConverter::default().to_failure( OutgoingError::Activity(OutgoingActivityError::Cancelled { details: None }), &PayloadConverter::default(), - &SerializationContextData::Activity, + &SerializationContextData::Activity(ActivitySerializationContext::new()), ); assert_eq!(failure.message, "Activity cancelled"); @@ -1624,14 +1637,14 @@ mod tests { details: Some("detail".to_string().into()), }), &PayloadConverter::default(), - &SerializationContextData::Activity, + &SerializationContextData::Activity(ActivitySerializationContext::new()), ); let err = DefaultFailureConverter::default() .to_error( failure, &PayloadConverter::default(), - &SerializationContextData::Activity, + &SerializationContextData::Activity(ActivitySerializationContext::new()), ) .unwrap(); let cancelled = err.as_cancelled().unwrap(); diff --git a/crates/common-wasm/src/error.rs b/crates/common-wasm/src/error.rs index 42e77b251..282655280 100644 --- a/crates/common-wasm/src/error.rs +++ b/crates/common-wasm/src/error.rs @@ -364,7 +364,7 @@ impl ApplicationFailure { FailurePayloads::from(DecodablePayloads::new( details.payloads, payload_converter.clone(), - *context, + context.clone(), )) }), failure: Some(failure), @@ -730,7 +730,7 @@ impl TimeoutError { cause: cause.map(Box::new), timeout_type: TimeoutType::from_raw(failure_info.timeout_type), last_heartbeat_details: failure_info.last_heartbeat_details.map(|details| { - DecodablePayloads::new(details.payloads, payload_converter.clone(), *context) + DecodablePayloads::new(details.payloads, payload_converter.clone(), context.clone()) }), } } @@ -781,7 +781,7 @@ impl CancelledError { failure, cause: cause.map(Box::new), details: failure_info.details.map(|details| { - DecodablePayloads::new(details.payloads, payload_converter.clone(), *context) + DecodablePayloads::new(details.payloads, payload_converter.clone(), context.clone()) }), } } @@ -1238,7 +1238,7 @@ mod tests { use crate::{ data_converters::{ DefaultFailureConverter, FailureConverter, GenericPayloadConverter, PayloadConverter, - SerializationContext, SerializationContextData, + SerializationContext, SerializationContextData, WorkflowSerializationContext, }, protos::temporal::api::{ common::v1::Payload, @@ -1275,7 +1275,7 @@ mod tests { .to_error( failure, &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .unwrap(); let IncomingError::Activity(activity) = decoded else { diff --git a/crates/common-wasm/src/memo.rs b/crates/common-wasm/src/memo.rs index 8ee88af51..7ba28d589 100644 --- a/crates/common-wasm/src/memo.rs +++ b/crates/common-wasm/src/memo.rs @@ -165,13 +165,14 @@ impl MemoValues { #[cfg(test)] mod tests { use super::*; + use crate::data_converters::WorkflowSerializationContext; use std::collections::HashMap; #[test] fn memo_decodes_serialized_values() { let payload_converter = PayloadConverter::default(); - let context = - SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let context = SerializationContext::new(&context_data, &payload_converter); let payload = payload_converter.to_payload(&context, &7_u32).unwrap(); let raw = ProtoMemo { fields: HashMap::from([("count".to_owned(), payload.clone())]), @@ -179,7 +180,7 @@ mod tests { let memo = Memo::from_raw( Some(raw.clone()), payload_converter, - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ); assert_eq!(memo.get::("count").unwrap(), Some(7)); @@ -191,15 +192,15 @@ mod tests { #[test] fn memo_reports_deserialization_errors() { let payload_converter = PayloadConverter::default(); - let context = - SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let context = SerializationContext::new(&context_data, &payload_converter); let payload = payload_converter.to_payload(&context, &7_u32).unwrap(); let memo = Memo::from_raw( Some(ProtoMemo { fields: HashMap::from([("count".to_owned(), payload)]), }), payload_converter, - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ); assert!(memo.get::("count").is_err()); @@ -213,8 +214,8 @@ mod tests { .insert("count", 7_u32) .insert("label", "hello".to_string()); - let context = - SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let context = SerializationContext::new(&context_data, &payload_converter); let fields = values .iter() .map(|(key, value)| { @@ -228,7 +229,7 @@ mod tests { let memo = Memo::from_raw( Some(ProtoMemo { fields }), payload_converter.clone(), - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ); assert_eq!(memo.get::("count").unwrap(), Some(7)); diff --git a/crates/common/src/payload_visitor.rs b/crates/common/src/payload_visitor.rs index 89ea251e1..f9b1845a3 100644 --- a/crates/common/src/payload_visitor.rs +++ b/crates/common/src/payload_visitor.rs @@ -208,30 +208,33 @@ include!(concat!(env!("OUT_DIR"), "/payload_visitor_impl.rs")); #[cfg(test)] mod tests { use super::*; - use crate::protos::{ - coresdk::{ - activity_result::{ - ActivityResolution, Success, activity_resolution::Status as ActivityStatus, - }, - workflow_activation::{ - InitializeWorkflow, ResolveActivity, WorkflowActivation, WorkflowActivationJob, - workflow_activation_job::Variant, - }, - workflow_commands::{ - ContinueAsNewWorkflowExecution, ScheduleActivity, StartChildWorkflowExecution, - UpsertWorkflowSearchAttributes, WorkflowCommand, - workflow_command::Variant as CmdVariant, + use crate::{ + data_converters::WorkflowSerializationContext, + protos::{ + coresdk::{ + activity_result::{ + ActivityResolution, Success, activity_resolution::Status as ActivityStatus, + }, + workflow_activation::{ + InitializeWorkflow, ResolveActivity, WorkflowActivation, WorkflowActivationJob, + workflow_activation_job::Variant, + }, + workflow_commands::{ + ContinueAsNewWorkflowExecution, ScheduleActivity, StartChildWorkflowExecution, + UpsertWorkflowSearchAttributes, WorkflowCommand, + workflow_command::Variant as CmdVariant, + }, + workflow_completion::{ + WorkflowActivationCompletion, workflow_activation_completion::Status, + }, }, - workflow_completion::{ - WorkflowActivationCompletion, workflow_activation_completion::Status, + temporal::api::{ + common::v1::{Memo, SearchAttributes}, + failure::v1::failure::FailureInfo, + workflow::v1::WorkflowExecutionInfo, + workflowservice::v1::DescribeWorkflowExecutionResponse, }, }, - temporal::api::{ - common::v1::{Memo, SearchAttributes}, - failure::v1::failure::FailureInfo, - workflow::v1::WorkflowExecutionInfo, - workflowservice::v1::DescribeWorkflowExecutionResponse, - }, }; use futures::FutureExt; use std::{ @@ -438,7 +441,7 @@ mod tests { encode_payloads( &mut completion, &MarkingCodec, - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await .unwrap(); @@ -482,7 +485,7 @@ mod tests { decode_payloads( &mut activation, &MarkingCodec, - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await .unwrap(); @@ -521,7 +524,7 @@ mod tests { decode_payloads( &mut activation, &MarkingCodec, - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await .unwrap(); @@ -620,7 +623,7 @@ mod tests { encode_payloads( &mut completion, &MarkingCodec, - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await .unwrap(); @@ -698,7 +701,7 @@ mod tests { decode_payloads( &mut response, &MarkingCodec, - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await .unwrap(); @@ -728,7 +731,7 @@ mod tests { encode_payloads( &mut payload, &MarkingCodec, - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await .unwrap(); @@ -743,7 +746,7 @@ mod tests { decode_payloads( &mut payload, &MarkingCodec, - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await .unwrap(); @@ -760,7 +763,7 @@ mod tests { encode_payloads( &mut payloads, &MarkingCodec, - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await .unwrap(); @@ -787,9 +790,13 @@ mod tests { ..Default::default() }; - let err = decode_payloads(&mut activation, &codec, &SerializationContextData::Workflow) - .await - .unwrap_err(); + let err = decode_payloads( + &mut activation, + &codec, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + ) + .await + .unwrap_err(); assert_eq!(err.to_string(), "Encoding error: visitor decode failed"); assert_eq!(codec.decode_calls.load(Ordering::SeqCst), 1); @@ -806,13 +813,13 @@ mod tests { .build(), ))), &PayloadConverter::default(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ); encode_payloads( &mut failure, &MarkingCodec, - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await .unwrap(); diff --git a/crates/macros/src/workflow_definitions.rs b/crates/macros/src/workflow_definitions.rs index a4d2254be..5fdbb64ce 100644 --- a/crates/macros/src/workflow_definitions.rs +++ b/crates/macros/src/workflow_definitions.rs @@ -75,8 +75,11 @@ fn generate_decode_arm( ) -> TokenStream2 { quote! { #handler_name => { + let context_data = ::temporalio_workflow::common::data_converters::SerializationContextData::Workflow( + ::temporalio_workflow::common::data_converters::WorkflowSerializationContext::new() + ); let ctx = ::temporalio_workflow::common::data_converters::SerializationContext::new( - &::temporalio_workflow::common::data_converters::SerializationContextData::Workflow, + &context_data, converter, ); let input: #input_type = <::temporalio_workflow::common::data_converters::PayloadConverter as ::temporalio_workflow::common::data_converters::GenericPayloadConverter>::from_payloads( diff --git a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs index 6033771a7..8e045ace9 100644 --- a/crates/sdk-core/tests/integ_tests/data_converter_tests.rs +++ b/crates/sdk-core/tests/integ_tests/data_converter_tests.rs @@ -934,10 +934,10 @@ impl PayloadCodec for FailOnceCodec { (self.failure_point, context), ( CodecFailurePoint::WorkflowEncode, - SerializationContextData::Workflow + SerializationContextData::Workflow(_) ) | ( CodecFailurePoint::ActivityEncode, - SerializationContextData::Activity + SerializationContextData::Activity(_) ) ); let marker = if matches!(self.failure_point, CodecFailurePoint::WorkflowEncode) { @@ -977,10 +977,10 @@ impl PayloadCodec for FailOnceCodec { (self.failure_point, context), ( CodecFailurePoint::WorkflowDecode, - SerializationContextData::Workflow + SerializationContextData::Workflow(_) ) | ( CodecFailurePoint::ActivityDecode, - SerializationContextData::Activity + SerializationContextData::Activity(_) ) ); let matches_payload = payloads.iter().any(|payload| { diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs index 3dc997d97..cbd5add25 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs @@ -17,7 +17,7 @@ use temporalio_client::{ use temporalio_common::{ data_converters::{ GenericPayloadConverter, PayloadConverter, RawValue, SerializationContext, - SerializationContextData, + SerializationContextData, WorkflowSerializationContext, }, protos::{ coresdk::{ @@ -348,7 +348,8 @@ async fn nexus_async( let submitter = worker.get_submitter_handle(); let converter = PayloadConverter::default(); - let ser_ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ser_ctx = SerializationContext::new(&context_data, &converter); let wf_handle = worker .submit_workflow( NexusAsyncWf::run, diff --git a/crates/sdk/src/activities.rs b/crates/sdk/src/activities.rs index 60d43f5ea..fb86f2f53 100644 --- a/crates/sdk/src/activities.rs +++ b/crates/sdk/src/activities.rs @@ -76,9 +76,9 @@ pub use temporalio_common::ActivityError; use temporalio_common::{ ActivityDefinition, HasWorkflowDefinition, RetryPolicy, data_converters::{ - DataConverter, DecodablePayloads, GenericPayloadConverter, PayloadConversionError, - PayloadConverter, RawValue, SerializationContext, SerializationContextData, - TemporalDeserializable, TemporalSerializable, + ActivitySerializationContext, DataConverter, DecodablePayloads, GenericPayloadConverter, + PayloadConversionError, PayloadConverter, RawValue, SerializationContext, + SerializationContextData, TemporalDeserializable, TemporalSerializable, }, error::ApplicationFailure, protos::{ @@ -132,7 +132,10 @@ impl ActivityContextBackend { } => { let details = client_options .data_converter - .to_payloads(&SerializationContextData::Activity, &details) + .to_payloads( + &SerializationContextData::Activity(ActivitySerializationContext::new()), + &details, + ) .await?; worker.record_activity_heartbeat(ActivityHeartbeat { task_token: task_token.to_vec(), @@ -356,7 +359,7 @@ impl ActivityHeartbeatDetails { payloads: DecodablePayloads::new( payloads, payload_converter, - SerializationContextData::Activity, + SerializationContextData::Activity(ActivitySerializationContext::new()), ), } } @@ -573,7 +576,9 @@ impl ActivityDefinitions { // Codec application happens at the SDK/Core boundary, so activity // implementations work with the payload converter directly. let pc = dc.payload_converter(); - let ctx = SerializationContext::new(&SerializationContextData::Activity, pc); + let context_data = + SerializationContextData::Activity(ActivitySerializationContext::new()); + let ctx = SerializationContext::new(&context_data, pc); let input: AD::Input = pc.from_payloads(&ctx, payloads)?; let input = ExecuteActivityInput::new(c, Box::new(input)); let leaf = activity_inbound_base::(instance); @@ -658,11 +663,11 @@ pub(crate) fn activity_error_to_core_result( ) -> ActivityExecutionResult { match err { ActivityError::Application(app) => ActivityExecutionResult::fail(dc.to_failure( - &SerializationContextData::Activity, + &SerializationContextData::Activity(ActivitySerializationContext::new()), OutgoingError::Activity(OutgoingActivityError::Application(app)), )), ActivityError::Cancelled { details } => ActivityExecutionResult::cancel(dc.to_failure( - &SerializationContextData::Activity, + &SerializationContextData::Activity(ActivitySerializationContext::new()), OutgoingError::Activity(OutgoingActivityError::Cancelled { details }), )), ActivityError::WillCompleteAsync => ActivityExecutionResult::will_complete_async(), @@ -688,7 +693,10 @@ mod test { let payload_converter = PayloadConverter::default(); let payload = payload_converter .to_payload( - &SerializationContext::new(&SerializationContextData::Activity, &payload_converter), + &SerializationContext::new( + &SerializationContextData::Activity(ActivitySerializationContext::new()), + &payload_converter, + ), &"progress".to_owned(), ) .unwrap(); diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 3a12c1f28..c3a97c046 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -134,7 +134,10 @@ use std::{ use temporalio_client::{Client, ClientOptions, NamespacedClient}; use temporalio_common::{ ActivityDefinition, WorkflowDefinition, - data_converters::{DataConverter, SerializationContext, SerializationContextData}, + data_converters::{ + ActivitySerializationContext, DataConverter, SerializationContext, + SerializationContextData, WorkflowSerializationContext, + }, payload_visitor::{decode_payloads, encode_payloads}, protos::{ TaskToken, @@ -740,7 +743,7 @@ async fn encode_workflow_completion( if let Err(err) = encode_payloads( completion, data_converter.codec(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await { @@ -763,7 +766,7 @@ async fn encode_activity_completion( if let Err(err) = encode_payloads( completion, data_converter.codec(), - &SerializationContextData::Activity, + &SerializationContextData::Activity(ActivitySerializationContext::new()), ) .await { @@ -1016,7 +1019,7 @@ impl Worker { if let Err(err) = decode_payloads( &mut activation, common.data_converter.codec(), - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) .await { @@ -1099,12 +1102,15 @@ impl Worker { message: "activity polling failed".to_owned(), source: Box::new(source), })?; - if let Err(err) = decode_payloads( - &mut activity, - common.data_converter.codec(), - &SerializationContextData::Activity, - ) - .await + if let Err(err) = + decode_payloads( + &mut activity, + common.data_converter.codec(), + &SerializationContextData::Activity( + ActivitySerializationContext::new(), + ), + ) + .await { error!(error = %err, "Failed decoding activity task"); let mut completion = ActivityTaskCompletion { @@ -1142,7 +1148,9 @@ impl Worker { task_token, }) => { let failure = common.data_converter.to_failure( - &SerializationContextData::Activity, + &SerializationContextData::Activity( + ActivitySerializationContext::new(), + ), OutgoingError::Activity(OutgoingActivityError::Application( ApplicationFailure::builder(source) .type_name("NotFoundError".to_owned()) @@ -1415,8 +1423,10 @@ impl ActivityHalf { // Codec application happens at the SDK/Core boundary, so activity // implementations work with the payload converter directly. let pc = codec_data_converter.payload_converter(); - let ctx = - SerializationContext::new(&SerializationContextData::Activity, pc); + let context_data = SerializationContextData::Activity( + ActivitySerializationContext::new(), + ); + let ctx = SerializationContext::new(&context_data, pc); match output.serialize_payload(&ctx) { Ok(payload) => ActivityExecutionResult::ok(payload), Err(err) => { diff --git a/crates/sdk/src/testing.rs b/crates/sdk/src/testing.rs index 365bba0e9..264a63029 100644 --- a/crates/sdk/src/testing.rs +++ b/crates/sdk/src/testing.rs @@ -78,8 +78,8 @@ use temporalio_client::{ use temporalio_common::{ RetryPolicy, data_converters::{ - GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, - SerializationContextData, TemporalSerializable, + ActivitySerializationContext, GenericPayloadConverter, PayloadConversionError, + PayloadConverter, SerializationContext, SerializationContextData, TemporalSerializable, }, protos::temporal::api::common::v1::Payload, }; @@ -242,8 +242,8 @@ where let payload_converter = self .payload_converter_ref() .expect("payload converter must be set in builder state"); - let context = - SerializationContext::new(&SerializationContextData::Activity, payload_converter); + let context_data = SerializationContextData::Activity(ActivitySerializationContext::new()); + let context = SerializationContext::new(&context_data, payload_converter); self.heartbeat_details = payload_converter.to_payloads(&context, &details)?; Ok(self) } diff --git a/crates/sdk/src/workflow_registry.rs b/crates/sdk/src/workflow_registry.rs index 92d793aae..e1b57d9af 100644 --- a/crates/sdk/src/workflow_registry.rs +++ b/crates/sdk/src/workflow_registry.rs @@ -5,7 +5,7 @@ use temporalio_common::{ WorkflowDefinition, data_converters::{ DataConverter, GenericPayloadConverter, PayloadConverter, SerializationContext, - SerializationContextData, + SerializationContextData, WorkflowSerializationContext, }, protos::{ coresdk::workflow_activation::InitializeWorkflow, temporal::api::common::v1::Payload, @@ -126,8 +126,9 @@ impl WorkflowDefinitions { let factory = Arc::new(move |input| { let (payloads, payload_converter, base_ctx) = workflow_input_parts(input); - let ser_ctx = - SerializationContext::new(&SerializationContextData::Workflow, &payload_converter); + let context_data = + SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ser_ctx = SerializationContext::new(&context_data, &payload_converter); let input: ::Input = payload_converter.from_payloads(&ser_ctx, payloads)?; diff --git a/crates/workflow/src/runtime/entry.rs b/crates/workflow/src/runtime/entry.rs index 390253895..b39cc07e5 100644 --- a/crates/workflow/src/runtime/entry.rs +++ b/crates/workflow/src/runtime/entry.rs @@ -14,7 +14,7 @@ use temporalio_common_wasm::{ QueryDefinition, SignalDefinition, UpdateDefinition, WorkflowDefinition, data_converters::{ GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, - SerializationContextData, TemporalSerializable, + SerializationContextData, TemporalSerializable, WorkflowSerializationContext, }, protos::temporal::api::{ common::v1::{Payload, Payloads}, @@ -288,7 +288,8 @@ pub(crate) fn serialize_output( output: &O, converter: &PayloadConverter, ) -> Result { - let ctx = SerializationContext::new(&SerializationContextData::Workflow, converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, converter); converter.to_payload(&ctx, output).map_err(Into::into) } diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index c3417ef31..aa9e9d13f 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -44,7 +44,7 @@ use temporalio_common_wasm::{ WorkflowDefinition, data_converters::{ GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, - SerializationContextData, + SerializationContextData, WorkflowSerializationContext, }, error::{ApplicationFailure, OutgoingError, OutgoingWorkflowError}, protos::{ @@ -367,7 +367,8 @@ where ) -> Result, PayloadConversionError> { let view = base_ctx.view(); let interceptors = base_ctx.workflow_interceptors(); - let ser_ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ser_ctx = SerializationContext::new(&context_data, &converter); let input = converter.from_payloads(&ser_ctx, payloads)?; let (init_input, run_input) = if W::INIT_TAKES_INPUT { (Some(input), None) @@ -450,7 +451,8 @@ where } let converter = PayloadConverter::default(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, &converter); QueryResponse { result: converter .to_payload( @@ -480,14 +482,14 @@ where } }; self.base_ctx.data_converter().to_failure( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), OutgoingError::Workflow(outgoing), ) } fn message_to_failure(&self, message: String) -> Failure { self.base_ctx.data_converter().to_failure( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), OutgoingError::Workflow(OutgoingWorkflowError::Application(Box::new( ApplicationFailure::new(message), ))), @@ -791,7 +793,9 @@ where .map(|details| { (&*details as &dyn WorkflowOutputValue) .serialize_payloads(&SerializationContext::new( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), self.ctx.payload_converter(), )) .map(|payloads| Payloads { payloads }) @@ -828,7 +832,7 @@ where return Ok(TerminalOutcome::Cancelled(details)); } let failure = self.base_ctx.data_converter().to_failure( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), temporalio_common_wasm::error::OutgoingError::Workflow(err), ); Ok(TerminalOutcome::Failed(Box::new(failure))) diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index d82b79a8b..152f00fd0 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -65,7 +65,7 @@ use temporalio_common_wasm::{ ActivityExecutionDecodeHint, ChildWorkflowExecutionDecodeHint, ChildWorkflowStartDecodeHint, DataConverter, GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, SerializationContextData, - TemporalDeserializable, WorkflowSignalDecodeHint, + TemporalDeserializable, WorkflowSerializationContext, WorkflowSignalDecodeHint, }, error::{ ActivityExecutionError, ChildWorkflowExecutionError, ChildWorkflowStartError, @@ -858,8 +858,9 @@ impl BaseWorkflowContext { } }; let payload_converter = base_ctx.inner.data_converter.payload_converter(); - let ctx = - SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = + SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, payload_converter); match payload_converter.to_payloads(&ctx, &input) { Ok(payloads) => { let cancellation_token = opts @@ -952,8 +953,9 @@ impl BaseWorkflowContext { } }; let payload_converter = base_ctx.inner.data_converter.payload_converter(); - let ctx = - SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = + SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, payload_converter); match payload_converter.to_payloads(&ctx, &input) { Ok(payloads) => { let cancellation_token = opts @@ -1034,8 +1036,9 @@ impl BaseWorkflowContext { } }; let payload_converter = base_ctx.inner.data_converter.payload_converter(); - let ctx = - SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = + SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, payload_converter); let payloads = match payload_converter.to_payloads(&ctx, &input) { Ok(payloads) => payloads, Err(err) => { @@ -1186,8 +1189,9 @@ impl BaseWorkflowContext { } }; let payload_converter = base_ctx.data_converter().payload_converter(); - let ctx = - SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = + SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, payload_converter); let payloads = match payload_converter.to_payloads(&ctx, &input) { Ok(payloads) => payloads, Err(err) => { @@ -1419,7 +1423,7 @@ impl SyncWorkflowContext { Memo::from_raw( Some(self.base.inner.shared.borrow().memo.clone()), self.payload_converter().clone(), - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) } @@ -1535,7 +1539,9 @@ impl SyncWorkflowContext { Err(_) => return Err(outbound_type_error("continue-as-new input").into()), }; let pc = base_ctx.data_converter().payload_converter(); - let ctx = SerializationContext::new(&SerializationContextData::Workflow, pc); + let context_data = + SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, pc); let arguments = pc .to_payloads(&ctx, &*input) .map_err(WorkflowTermination::from)?; @@ -1759,8 +1765,8 @@ impl SyncWorkflowContext { K: Into, { let payload_converter = self.payload_converter(); - let context = - SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let context = SerializationContext::new(&context_data, payload_converter); let mut fields = HashMap::new(); let mut local_updates = Vec::new(); for (key, value) in updates { @@ -2653,60 +2659,72 @@ where fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = self.get_mut(); - let poll = match this { - ActivityFut::Errored { error, .. } => { - Poll::Ready(Err(*error.take().expect("polled after completion"))) - } - ActivityFut::Running { - inner, - data_converter, - .. - } => match Pin::new(inner).poll(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(resolution) => Poll::Ready({ - let status = resolution.status.ok_or_else(|| { - data_converter - .to_error( - &SerializationContextData::Workflow, - Failure { - message: "Activity completed without a status".to_string(), - ..Default::default() - }, - ActivityExecutionDecodeHint::new(false), - ) - .expect("synthetic activity failure should decode") - })?; - - match status { - activity_resolution::Status::Completed(success) => { - let payload = success.result.unwrap_or_default(); - let ctx = SerializationContext::new( - &SerializationContextData::Workflow, - data_converter.payload_converter(), - ); + let poll = + match this { + ActivityFut::Errored { error, .. } => { + Poll::Ready(Err(*error.take().expect("polled after completion"))) + } + ActivityFut::Running { + inner, + data_converter, + .. + } => match Pin::new(inner).poll(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(resolution) => Poll::Ready({ + let status = resolution.status.ok_or_else(|| { data_converter - .payload_converter() - .from_payload::(&ctx, payload) - .map_err(ActivityExecutionError::Serialization) - } - activity_resolution::Status::Failed(f) => Err(data_converter.to_error( - &SerializationContextData::Workflow, - f.failure.unwrap_or_default(), - ActivityExecutionDecodeHint::new(false), - )?), - activity_resolution::Status::Cancelled(c) => Err(data_converter.to_error( - &SerializationContextData::Workflow, - c.failure.unwrap_or_default(), - ActivityExecutionDecodeHint::new(true), - )?), - activity_resolution::Status::Backoff(_) => { - panic!("DoBackoff should be handled by LATimerBackoffFut") + .to_error( + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), + Failure { + message: "Activity completed without a status".to_string(), + ..Default::default() + }, + ActivityExecutionDecodeHint::new(false), + ) + .expect("synthetic activity failure should decode") + })?; + + match status { + activity_resolution::Status::Completed(success) => { + let payload = success.result.unwrap_or_default(); + let context_data = SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ); + let ctx = SerializationContext::new( + &context_data, + data_converter.payload_converter(), + ); + data_converter + .payload_converter() + .from_payload::(&ctx, payload) + .map_err(ActivityExecutionError::Serialization) + } + activity_resolution::Status::Failed(f) => Err(data_converter + .to_error( + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), + f.failure.unwrap_or_default(), + ActivityExecutionDecodeHint::new(false), + )?), + activity_resolution::Status::Cancelled(c) => Err(data_converter + .to_error( + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), + c.failure.unwrap_or_default(), + ActivityExecutionDecodeHint::new(true), + )?), + activity_resolution::Status::Backoff(_) => { + panic!("DoBackoff should be handled by LATimerBackoffFut") + } } - } - }), - }, - ActivityFut::Terminated => panic!("polled after termination"), - }; + }), + }, + ActivityFut::Terminated => panic!("polled after termination"), + }; if poll.is_ready() { *this = ActivityFut::Terminated; } @@ -2838,7 +2856,9 @@ where let status = result.status.ok_or_else(|| { data_converter .to_error( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), Failure { message: "Child workflow completed without a status" .to_string(), @@ -2851,8 +2871,11 @@ where match status { child_workflow_result::Status::Completed(success) => { let payloads = success.result.into_iter().collect(); + let context_data = SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ); let ctx = SerializationContext::new( - &SerializationContextData::Workflow, + &context_data, data_converter.payload_converter(), ); data_converter @@ -2860,14 +2883,20 @@ where .from_payloads::(&ctx, payloads) .map_err(ChildWorkflowExecutionError::Serialization) } - child_workflow_result::Status::Failed(f) => Err(data_converter.to_error( - &SerializationContextData::Workflow, - f.failure.unwrap_or_default(), - ChildWorkflowExecutionDecodeHint::default(), - )?), + child_workflow_result::Status::Failed(f) => { + Err(data_converter.to_error( + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), + f.failure.unwrap_or_default(), + ChildWorkflowExecutionDecodeHint::default(), + )?) + } child_workflow_result::Status::Cancelled(c) => Err(data_converter .to_error( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), c.failure.unwrap_or_default(), ChildWorkflowExecutionDecodeHint::default(), )?), @@ -2949,55 +2978,58 @@ where fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = self.get_mut(); - let poll = match this { - ChildWorkflowStartFut::Errored { error, .. } => { - Poll::Ready(Err(*error.take().expect("polled after completion"))) - } - ChildWorkflowStartFut::Running(inner) => match Pin::new(inner).poll(cx) { - Poll::Pending => Poll::Pending, - Poll::Ready(pending) => Poll::Ready(match pending.status { - ChildWorkflowStartStatus::Succeeded(s) => { - let ChildWfCommon { - workflow_id, - child_seq, - result_future, - base_ctx, - } = pending.common; - Ok(StartChildWorkflowOutput { - run_id: s.run_id, - result_future, - workflow_id, - child_seq, - base_ctx, - }) - } - ChildWorkflowStartStatus::Failed(f) => { - let mut result_future = pending.common.result_future; - result_future.unregister_cancellation(); - Err(ChildWorkflowStartError::StartFailed { - workflow_id: f.workflow_id, - workflow_type: f.workflow_type, - cause: StartChildWorkflowExecutionFailedCause::try_from(f.cause) - .unwrap_or(StartChildWorkflowExecutionFailedCause::Unspecified), - }) - } - ChildWorkflowStartStatus::Cancelled(c) => { - let ChildWfCommon { - mut result_future, - base_ctx, - .. - } = pending.common; - result_future.unregister_cancellation(); - Err(base_ctx.data_converter().to_error( - &SerializationContextData::Workflow, - c.failure.unwrap_or_default(), - ChildWorkflowStartDecodeHint::default(), - )?) - } - }), - }, - ChildWorkflowStartFut::Terminated => panic!("polled after termination"), - }; + let poll = + match this { + ChildWorkflowStartFut::Errored { error, .. } => { + Poll::Ready(Err(*error.take().expect("polled after completion"))) + } + ChildWorkflowStartFut::Running(inner) => match Pin::new(inner).poll(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(pending) => Poll::Ready(match pending.status { + ChildWorkflowStartStatus::Succeeded(s) => { + let ChildWfCommon { + workflow_id, + child_seq, + result_future, + base_ctx, + } = pending.common; + Ok(StartChildWorkflowOutput { + run_id: s.run_id, + result_future, + workflow_id, + child_seq, + base_ctx, + }) + } + ChildWorkflowStartStatus::Failed(f) => { + let mut result_future = pending.common.result_future; + result_future.unregister_cancellation(); + Err(ChildWorkflowStartError::StartFailed { + workflow_id: f.workflow_id, + workflow_type: f.workflow_type, + cause: StartChildWorkflowExecutionFailedCause::try_from(f.cause) + .unwrap_or(StartChildWorkflowExecutionFailedCause::Unspecified), + }) + } + ChildWorkflowStartStatus::Cancelled(c) => { + let ChildWfCommon { + mut result_future, + base_ctx, + .. + } = pending.common; + result_future.unregister_cancellation(); + Err(base_ctx.data_converter().to_error( + &SerializationContextData::Workflow( + WorkflowSerializationContext::new(), + ), + c.failure.unwrap_or_default(), + ChildWorkflowStartDecodeHint::default(), + )?) + } + }), + }, + ChildWorkflowStartFut::Terminated => panic!("polled after termination"), + }; if poll.is_ready() { *this = ChildWorkflowStartFut::Terminated; } @@ -3066,7 +3098,7 @@ where Poll::Pending => Poll::Pending, Poll::Ready(Ok(_)) => Poll::Ready(Ok(())), Poll::Ready(Err(failure)) => Poll::Ready(Err(data_converter.to_error( - &SerializationContextData::Workflow, + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), failure, WorkflowSignalDecodeHint::default(), )?)), @@ -4434,7 +4466,10 @@ mod tests { let payload_converter = PayloadConverter::default(); let removal_payload = payload_converter .to_payload( - &SerializationContext::new(&SerializationContextData::Workflow, &payload_converter), + &SerializationContext::new( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + &payload_converter, + ), &MemoValue::new(()), ) .unwrap(); diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index 03de9ff34..e9e544b22 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -5,7 +5,7 @@ use temporalio_common_wasm::{ ActivityCloseTimeouts, Priority, RetryPolicy, data_converters::{ GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, - SerializationContextData, + SerializationContextData, WorkflowSerializationContext, }, protos::{ coresdk::{ @@ -877,8 +877,8 @@ impl ContinueAsNewOptions { headers: HashMap, payload_converter: &PayloadConverter, ) -> Result { - let context = - SerializationContext::new(&SerializationContextData::Workflow, payload_converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let context = SerializationContext::new(&context_data, payload_converter); let memo = self .memo .map(|memo| { @@ -941,7 +941,8 @@ fn string_user_metadata(summary: Option, details: Option) -> Opt return None; } let converter = PayloadConverter::default(); - let context = SerializationContext::new(&SerializationContextData::Workflow, &converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let context = SerializationContext::new(&context_data, &converter); Some(UserMetadata { summary: summary.map(|value| { converter diff --git a/crates/workflow/src/workflow_context/view.rs b/crates/workflow/src/workflow_context/view.rs index 9d02b44ef..1932798b0 100644 --- a/crates/workflow/src/workflow_context/view.rs +++ b/crates/workflow/src/workflow_context/view.rs @@ -2,7 +2,7 @@ use std::time::{Duration, SystemTime}; use temporalio_common_wasm::{ Memo, Priority, RetryPolicy, WorkflowExecution, - data_converters::{PayloadConverter, SerializationContextData}, + data_converters::{PayloadConverter, SerializationContextData, WorkflowSerializationContext}, protos::coresdk::{ common::NamespacedWorkflowExecution, workflow_activation::InitializeWorkflow, }, @@ -144,7 +144,7 @@ impl WorkflowContextView { Memo::from_raw( self.raw.memo.clone(), self.payload_converter.clone(), - SerializationContextData::Workflow, + SerializationContextData::Workflow(WorkflowSerializationContext::new()), ) } diff --git a/crates/workflow/src/workflow_interceptors.rs b/crates/workflow/src/workflow_interceptors.rs index 1713b6319..5b0e0ace1 100644 --- a/crates/workflow/src/workflow_interceptors.rs +++ b/crates/workflow/src/workflow_interceptors.rs @@ -115,6 +115,7 @@ use temporalio_common_wasm::{ data_converters::{ GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, SerializationContextData, TemporalDeserializable, TemporalSerializable, + WorkflowSerializationContext, }, error::{ ActivityExecutionError, ChildWorkflowExecutionError, ChildWorkflowStartError, @@ -199,7 +200,8 @@ pub(crate) fn serialize_workflow_output( output: &dyn WorkflowOutputValue, converter: &PayloadConverter, ) -> Result { - let ctx = SerializationContext::new(&SerializationContextData::Workflow, converter); + let context_data = SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let ctx = SerializationContext::new(&context_data, converter); output.serialize_payload(&ctx) } From b804c0ba9c2d7dcb608d911647328481294cd284 Mon Sep 17 00:00:00 2001 From: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:33:17 -0700 Subject: [PATCH 46/87] chore(sdk): rename include_arguments_into_marker to include_arguments_in_marker (#1554) --- CHANGELOG.md | 2 +- .../sdk/core/workflow_commands/workflow_commands.proto | 2 +- crates/sdk-core/src/protosext/mod.rs | 4 ++-- .../workflow/machines/local_activity_state_machine.rs | 2 +- .../integ_tests/workflow_tests/local_activities.rs | 2 +- crates/workflow/src/workflow_context/options.rs | 10 +++++----- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf73dc35c..3fdfee279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ relevant information. ### Added * `DefaultFailureConverter::new(true)` moves failure messages and stack traces into encoded attributes so payload codecs can encrypt them. -* `LocalActivityOptions::include_arguments_into_marker` allows Rust workflows to opt in to +* `LocalActivityOptions::include_arguments_in_marker` allows Rust workflows to opt in to recording local activity arguments in Workflow history. * `WorkflowHandle::get_update_handle` creates a typed handle for an existing Workflow Update from its update ID, allowing callers to wait for the result independently of the original handle. diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto index 03e172216..f36c84cef 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_commands/workflow_commands.proto @@ -151,7 +151,7 @@ message ScheduleLocalActivity { // If set, the local activity arguments will be included in the resulting marker under the // `input` key. This is disabled by default to avoid increasing history size unless the lang // SDK explicitly chooses to expose it. - bool include_arguments_into_marker = 14; + bool include_arguments_in_marker = 14; } enum ActivityCancellationType { diff --git a/crates/sdk-core/src/protosext/mod.rs b/crates/sdk-core/src/protosext/mod.rs index 9f0b77348..506b953c6 100644 --- a/crates/sdk-core/src/protosext/mod.rs +++ b/crates/sdk-core/src/protosext/mod.rs @@ -322,7 +322,7 @@ pub(crate) struct ValidScheduleLA { pub(crate) retry_policy: ValidatedRetryPolicy, pub(crate) local_retry_threshold: Duration, pub(crate) cancellation_type: ActivityCancellationType, - pub(crate) include_arguments_into_marker: bool, + pub(crate) include_arguments_in_marker: bool, pub(crate) user_metadata: Option, pub(crate) event_group_markers: Vec, } @@ -433,7 +433,7 @@ impl ValidScheduleLA { retry_policy, local_retry_threshold, cancellation_type, - include_arguments_into_marker: v.include_arguments_into_marker, + include_arguments_in_marker: v.include_arguments_in_marker, user_metadata, event_group_markers, }) diff --git a/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs index 9b86f008a..f956b75f9 100644 --- a/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/local_activity_state_machine.rs @@ -726,7 +726,7 @@ impl WFMachinesAdapter for LocalActivityMachine { }, maybe_ok_result, ); - if self.shared_state.attrs.include_arguments_into_marker { + if self.shared_state.attrs.include_arguments_in_marker { details.insert( "input".to_string(), Payloads { diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs index 570fe33a8..6faaadf67 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/local_activities.rs @@ -3022,7 +3022,7 @@ async fn local_activity_marker_optionally_includes_arguments(#[case] include_arg activity_type: "test_act".to_string(), arguments, start_to_close_timeout: Some(prost_dur!(from_secs(30))), - include_arguments_into_marker: include_arguments, + include_arguments_in_marker: include_arguments, ..Default::default() } .into(), diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index e9e544b22..8dcf6f750 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -431,7 +431,7 @@ pub struct LocalActivityOptions { /// Enabling this makes the arguments visible in Workflow history and increases its size. /// Defaults to `false`. #[builder(default)] - pub include_arguments_into_marker: bool, + pub include_arguments_in_marker: bool, /// Cancellation token for this local activity. `None` inherits workflow cancellation. pub cancellation_token: Option, /// Indicates how long the caller is willing to wait for local activity completion. Limits how @@ -492,7 +492,7 @@ impl LocalActivityOptions { .timer_backoff_threshold .and_then(|duration| duration.try_into().ok()), cancellation_type: ProtoActivityCancellationType::from(self.cancel_type).into(), - include_arguments_into_marker: self.include_arguments_into_marker, + include_arguments_in_marker: self.include_arguments_in_marker, schedule_to_close_timeout: self .schedule_to_close_timeout .and_then(|duration| duration.try_into().ok()), @@ -1088,7 +1088,7 @@ mod tests { HashMap::new(), ); let enabled_command = LocalActivityOptions::builder() - .include_arguments_into_marker(true) + .include_arguments_in_marker(true) .build() .into_command(1, "test".to_string(), vec![], HashMap::new()); @@ -1102,8 +1102,8 @@ mod tests { else { panic!("expected ScheduleLocalActivity command"); }; - assert!(!default_command.include_arguments_into_marker); - assert!(enabled_command.include_arguments_into_marker); + assert!(!default_command.include_arguments_in_marker); + assert!(enabled_command.include_arguments_in_marker); } #[test] From a38b8b4fcd50eed1be8e1052cd2c93a6f68064d7 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Mon, 31 Aug 2026 20:24:06 -0400 Subject: [PATCH 47/87] chore(sdk): automate crate release (#1550) --- .github/workflows/create-release.yml | 88 ++++++++ crates/changelog-release-notes/Cargo.toml | 5 + .../src/bin/prepare-release.rs | 193 ++++++++++++++++++ crates/client/Cargo.toml | 6 +- crates/common-wasm/Cargo.toml | 2 +- crates/common/Cargo.toml | 4 +- crates/macros/Cargo.toml | 2 +- crates/sdk-core-c-bridge/Cargo.toml | 6 +- crates/sdk-core/Cargo.toml | 11 +- crates/sdk/Cargo.toml | 10 +- crates/workflow/Cargo.toml | 4 +- mise.toml | 1 + 12 files changed, 310 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/create-release.yml create mode 100644 crates/changelog-release-notes/src/bin/prepare-release.rs diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 000000000..bad159c01 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,88 @@ +name: Create Release + +on: + workflow_dispatch: + +concurrency: + group: publish-crates + cancel-in-progress: false + +jobs: + publish: + name: Publish crates + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: release + permissions: + contents: write + id-token: write + + steps: + - name: Require a release branch + shell: bash + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" && ! "$GITHUB_REF_NAME" =~ ^releases/[0-9]+\.[0-9]+\.x$ ]]; then + echo "Publishing must be run from main or a releases/..x branch; got $GITHUB_REF." + exit 1 + fi + + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + # Necessary as `cargo publish` builds crates as a check before publishing + - name: Set up repository + uses: ./.github/actions/setup + with: + mise-install-args: protoc + + - name: Read release metadata + id: release + shell: bash + run: | + metadata="$(cargo metadata --format-version 1 --no-deps)" + version="$(jq -er '[.packages[] | select(.name == "temporalio-sdk") | .version] | if length == 1 then .[0] else error("expected one temporalio-sdk package") end' <<<"$metadata")" + + if [[ "$GITHUB_REF_NAME" =~ ^releases/([0-9]+)\.([0-9]+)\.x$ ]]; then + release_line="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + if [[ "$version" != "$release_line".* ]]; then + echo "SDK version $version does not belong to release branch $GITHUB_REF_NAME." + exit 1 + fi + fi + + { + echo "version=$version" + echo "tag=v$version" + } >>"$GITHUB_OUTPUT" + + - name: Generate release notes + run: | + previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 HEAD)" + cargo run -p changelog-release-notes -- \ + --from "$previous_tag" \ + --to HEAD \ + --changelog rust \ + >"$RUNNER_TEMP/release-notes.md" + + - name: Authenticate to crates.io + id: auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1 + + - name: Publish crates + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: cargo publish --workspace + + - name: Create draft GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_TITLE: temporalio-sdk ${{ steps.release.outputs.tag }} + run: | + gh release create "$RELEASE_TAG" \ + --target "$GITHUB_SHA" \ + --draft \ + --title "$RELEASE_TITLE" \ + --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/crates/changelog-release-notes/Cargo.toml b/crates/changelog-release-notes/Cargo.toml index d5ebc3804..341081517 100644 --- a/crates/changelog-release-notes/Cargo.toml +++ b/crates/changelog-release-notes/Cargo.toml @@ -5,6 +5,11 @@ edition = "2024" license-file = { workspace = true } publish = false rust-version = "1.88.0" +default-run = "changelog-release-notes" + +[dependencies] +chrono = { version = "=0.4.45", default-features = false, features = ["clock"] } +semver = "=1.0.28" [lints] workspace = true diff --git a/crates/changelog-release-notes/src/bin/prepare-release.rs b/crates/changelog-release-notes/src/bin/prepare-release.rs new file mode 100644 index 000000000..0a32e07fa --- /dev/null +++ b/crates/changelog-release-notes/src/bin/prepare-release.rs @@ -0,0 +1,193 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, +}; + +use chrono::{NaiveDate, Utc}; +use semver::Version; + +const CORE_CRATE: &str = "temporalio-sdk-core"; +const BRIDGE_CRATE: &str = "temporalio-sdk-core-c-bridge"; +const RELEASE_TOOL_CRATE: &str = "changelog-release-notes"; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn cargo_set_version(root: &Path, args: &[String]) -> Result<(), String> { + let status = Command::new("cargo") + .args(args) + .current_dir(root) + .status() + .map_err(|err| format!("failed to run `cargo {}`: {err}", args.join(" ")))?; + if !status.success() { + return Err(format!("`cargo {}` failed with {status}", args.join(" "))); + } + Ok(()) +} + +fn turn_over_changelog( + changelog: &str, + version: &Version, + date: NaiveDate, +) -> Result { + let release_prefix = format!("## [{version}]"); + if changelog + .lines() + .any(|line| line.starts_with(&release_prefix)) + { + return Err(format!("CHANGELOG.md already contains {release_prefix}")); + } + + let header = "## Unreleased"; + let header_start = changelog + .match_indices(header) + .find_map(|(index, _)| { + let at_line_start = index == 0 || changelog.as_bytes().get(index - 1) == Some(&b'\n'); + let after = index + header.len(); + let at_line_end = matches!(changelog.as_bytes().get(after), None | Some(b'\n')); + (at_line_start && at_line_end).then_some(index) + }) + .ok_or("CHANGELOG.md is missing an `## Unreleased` section")?; + let body_start = header_start + header.len(); + let following = &changelog[body_start..]; + let next_heading = following + .find("\n## ") + .ok_or("CHANGELOG.md is missing a released-version section")?; + let body = following[..next_heading].trim(); + let previous_releases = &following[next_heading + 1..]; + + let mut output = String::from(&changelog[..header_start]); + output.push_str(header); + output.push_str("\n\n"); + output.push_str(&format!("## [{version}] - {date}")); + if !body.is_empty() { + output.push_str("\n\n"); + output.push_str(body); + } + output.push_str("\n\n"); + output.push_str(previous_releases.trim_start_matches('\n')); + Ok(output) +} + +fn parse_args(args: impl IntoIterator) -> Result<(Version, Version), String> { + let mut args = args.into_iter(); + let sdk_version = args.next().ok_or("expected ")?; + let core_version = args.next().ok_or("expected ")?; + if args.next().is_some() { + return Err("expected ".into()); + } + let sdk_version = + Version::parse(&sdk_version).map_err(|err| format!("invalid SDK version: {err}"))?; + let core_version = + Version::parse(&core_version).map_err(|err| format!("invalid Core version: {err}"))?; + if core_version.major != 0 { + return Err(format!( + "Core version must remain 0.x, found {core_version}" + )); + } + Ok((sdk_version, core_version)) +} + +fn main() -> Result<(), String> { + let (target_sdk, target_core) = parse_args(env::args().skip(1))?; + let root = workspace_root(); + + let changelog_path = root.join("CHANGELOG.md"); + let changelog = fs::read_to_string(&changelog_path) + .map_err(|err| format!("failed to read {}: {err}", changelog_path.display()))?; + let changelog = turn_over_changelog(&changelog, &target_sdk, Utc::now().date_naive())?; + + let sdk_update = vec![ + "set-version".into(), + "--workspace".into(), + target_sdk.to_string(), + "--exclude".into(), + CORE_CRATE.into(), + "--exclude".into(), + BRIDGE_CRATE.into(), + "--exclude".into(), + RELEASE_TOOL_CRATE.into(), + ]; + let core_update = vec![ + "set-version".into(), + "--package".into(), + CORE_CRATE.into(), + target_core.to_string(), + ]; + let mut sdk_dry_run = sdk_update.clone(); + sdk_dry_run.push("--dry-run".into()); + let mut core_dry_run = core_update.clone(); + core_dry_run.push("--dry-run".into()); + cargo_set_version(&root, &sdk_dry_run)?; + cargo_set_version(&root, &core_dry_run)?; + + cargo_set_version(&root, &sdk_update)?; + cargo_set_version(&root, &core_update)?; + fs::write(&changelog_path, changelog) + .map_err(|err| format!("failed to write {}: {err}", changelog_path.display()))?; + + println!("Prepared SDK {target_sdk} with Core {target_core}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn version(value: &str) -> Version { + Version::parse(value).unwrap() + } + + #[test] + fn rejects_core_one_x() { + assert!( + parse_args(["1.0.0".into(), "1.0.0".into()]) + .unwrap_err() + .contains("must remain 0.x") + ); + } + + #[test] + fn turns_over_a_populated_changelog() { + let input = "# Changelog\n\n## Unreleased\n\n### Added\n* Feature.\n\n## [0.7.0] - 2026-01-01\n\nOld.\n"; + assert_eq!( + turn_over_changelog( + input, + &version("1.0.0"), + NaiveDate::from_ymd_opt(2026, 8, 27).unwrap() + ) + .unwrap(), + "# Changelog\n\n## Unreleased\n\n## [1.0.0] - 2026-08-27\n\n### Added\n* Feature.\n\n## [0.7.0] - 2026-01-01\n\nOld.\n" + ); + } + + #[test] + fn turns_over_an_empty_changelog() { + let input = "# Changelog\n\n## Unreleased\n\n## [0.7.0] - 2026-01-01\n"; + assert_eq!( + turn_over_changelog( + input, + &version("1.0.0-rc.1"), + NaiveDate::from_ymd_opt(2026, 8, 27).unwrap() + ) + .unwrap(), + "# Changelog\n\n## Unreleased\n\n## [1.0.0-rc.1] - 2026-08-27\n\n## [0.7.0] - 2026-01-01\n" + ); + } + + #[test] + fn rejects_a_duplicate_changelog_release() { + let input = "# Changelog\n\n## Unreleased\n\n## [1.0.0] - 2026-01-01\n"; + assert!( + turn_over_changelog( + input, + &version("1.0.0"), + NaiveDate::from_ymd_opt(2026, 8, 27).unwrap() + ) + .unwrap_err() + .contains("already contains") + ); + } +} diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 6ae5d4c14..4ac943c52 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -61,7 +61,7 @@ serde_json = { workspace = true } [dependencies.temporalio-common] path = "../common" -version = "0.7" +version = "~0.7.0" default-features = false features = ["serde_serialize"] @@ -72,8 +72,8 @@ prost = "0.14" prost-types = { workspace = true } rstest = "0.26" tempfile = "3" -temporalio-macros = { path = "../macros", version = "0.7" } -temporalio-workflow = { path = "../workflow", version = "0.7" } +temporalio-macros = { path = "../macros", version = "~0.7.0" } +temporalio-workflow = { path = "../workflow", version = "~0.7.0" } tokio = { version = "1.47", default-features = false, features = [ "io-util", "macros", diff --git a/crates/common-wasm/Cargo.toml b/crates/common-wasm/Cargo.toml index 6104bc73a..5ebfff109 100644 --- a/crates/common-wasm/Cargo.toml +++ b/crates/common-wasm/Cargo.toml @@ -45,7 +45,7 @@ tracing-core = "0.1" url = "2.5" [dependencies.temporalio-protos] path = "../protos" -version = "0.7" +version = "~0.7.0" [lints] workspace = true diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index c8bea1bf9..26ab9e234 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -106,12 +106,12 @@ uuid = { version = "1.18", default-features = false, features = ["v4"] } [dependencies.temporalio-protos] path = "../protos" -version = "0.7" +version = "~0.7.0" features = ["grpc-clients"] [dependencies.temporalio-common-wasm] path = "../common-wasm" -version = "0.7" +version = "~0.7.0" [build-dependencies] prost = { workspace = true } diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 39b576029..2f47c9fce 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -20,7 +20,7 @@ quote = "1.0" [dev-dependencies] # This is to enable doctests for the macros -temporalio-common = { path = "../common" } +temporalio-common = { path = "../common", version = "~0.7.0" } derive_more = { workspace = true } [package.metadata.workspaces] diff --git a/crates/sdk-core-c-bridge/Cargo.toml b/crates/sdk-core-c-bridge/Cargo.toml index 7ebfed98b..76b5265a9 100644 --- a/crates/sdk-core-c-bridge/Cargo.toml +++ b/crates/sdk-core-c-bridge/Cargo.toml @@ -41,16 +41,16 @@ xz2 = { version = "0.1", optional = true } [dependencies.temporalio-client] path = "../client" -version = "0.7" +version = "~0.7.0" [dependencies.temporalio-sdk-core] path = "../sdk-core" -version = "0.7" +version = "=0.7.0" features = ["ephemeral-server", "otel"] [dependencies.temporalio-common] path = "../common" -version = "0.7" +version = "~0.7.0" features = ["core-based-sdk", "otel"] [dev-dependencies] diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index 48c8c2f07..f95d54663 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -131,19 +131,19 @@ zip = { version = "8.4", optional = true, default-features = false, features = [ # 1st party local deps [dependencies.temporalio-common] path = "../common" -version = "0.7" +version = "~0.7.0" default-features = false features = ["core-telemetry-bridge", "serde_serialize"] [dependencies.temporalio-client] path = "../client" -version = "0.7" +version = "~0.7.0" default-features = false features = ["core-based-sdk"] [dependencies.temporalio-macros] path = "../macros" -version = "0.7" +version = "~0.7.0" [dev-dependencies] assert_matches = "1.5" @@ -163,9 +163,10 @@ hyper-util = { version = "0.1", features = [ ] } rstest = "0.26" semver = "1.0" +# A registry version here creates a cycle when Cargo orders sdk-core before sdk for workspace publishing. temporalio-sdk = { path = "../sdk", features = ["testing", "wasm-workflows"] } -temporalio-common = { path = "../common", version = "0.7", default-features = false } -temporalio-workflow = { path = "../workflow" } +temporalio-common = { path = "../common", version = "~0.7.0", default-features = false } +temporalio-workflow = { path = "../workflow", version = "~0.7.0" } tokio = { version = "1.47", default-features = false, features = [ "rt", "rt-multi-thread", diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 22e5584cb..dc0b134f7 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -43,26 +43,26 @@ url = { version = "2.5", optional = true } [dependencies.temporalio-sdk-core] path = "../sdk-core" -version = "0.7" +version = "=0.7.0" default-features = false [dependencies.temporalio-workflow] path = "../workflow" -version = "0.7" +version = "~0.7.0" [dependencies.temporalio-common] path = "../common" -version = "0.7" +version = "~0.7.0" default-features = false [dependencies.temporalio-client] path = "../client" -version = "0.7" +version = "~0.7.0" default-features = false [dependencies.temporalio-macros] path = "../macros" -version = "0.7" +version = "~0.7.0" [dev-dependencies] futures = "0.3" diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index 89c572a17..39c9e2d37 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -33,11 +33,11 @@ wit-bindgen = { version = "0.57.1", default-features = false, features = ["macro [dependencies.temporalio-common-wasm] path = "../common-wasm" -version = "0.7" +version = "~0.7.0" [dependencies.temporalio-macros] path = "../macros" -version = "0.7" +version = "~0.7.0" [dev-dependencies] rstest = "0.26" diff --git a/mise.toml b/mise.toml index 65ed354b1..f2d785c56 100644 --- a/mise.toml +++ b/mise.toml @@ -3,3 +3,4 @@ protoc = "23.4" "github:temporalio/cli" = "1.8.0" "cargo:cargo-component" = "0.21.1" "cargo:cargo-msrv" = "0.19.3" +"cargo:cargo-edit" = { version = "0.13.9", locked = true } From 0ee46047f93f919f17803f5677cc49097c1f545d Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Tue, 1 Sep 2026 08:23:15 -0700 Subject: [PATCH 48/87] Fix flaky post-cancel abandoned child test (#1555) --- .../integ_tests/workflow_tests/child_workflows.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs index 262da5a44..379d6d324 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs @@ -198,7 +198,7 @@ async fn abandoned_child_bug_repro() { #[workflow] struct AbandonedChildResolvesPostCancelParent { - barr: Arc, + ready: Arc, } #[workflow_methods(factory_only)] @@ -217,8 +217,7 @@ impl AbandonedChildResolvesPostCancelParent { ) .await .expect("Child should start OK"); - let barr = ctx.state(|wf| wf.barr.clone()); - barr.wait().await; + ctx.state(|wf| wf.ready.notify_one()); ctx.cancelled().await; started.cancel("Die reason".to_string()); ctx.timer(Duration::from_secs(1)).await; @@ -242,12 +241,12 @@ impl AbandonedChildResolvesPostCancelChild { #[tokio::test] async fn abandoned_child_resolves_post_cancel() { let mut starter = CoreWfStarter::new("child-workflow-resolves-post-cancel"); - let barr = Arc::new(Barrier::new(2)); - let barr_clone = barr.clone(); + let ready = Arc::new(Notify::new()); + let ready_clone = ready.clone(); starter .sdk_config .register_workflow_with_factory(move || AbandonedChildResolvesPostCancelParent { - barr: barr_clone.clone(), + ready: ready_clone.clone(), }) .unwrap(); starter @@ -267,7 +266,7 @@ async fn abandoned_child_resolves_post_cancel() { .unwrap(); let client = starter.get_core_client().await; let canceller = async { - barr.wait().await; + ready.notified().await; handle .cancel(WorkflowCancelOptions::builder().reason("die").build()) .await @@ -277,6 +276,7 @@ async fn abandoned_child_resolves_post_cancel() { worker.run_until_done().await.unwrap(); }; tokio::join!(canceller, runner); + handle.get_result(Default::default()).await.unwrap(); // Verify no WFT failures on the child workflow. A failure here indicates // the child couldn't deserialize its input (e.g., sending a payload when none expected). From eea75f4124fc6434c90b8c0248c1872f509cf7b2 Mon Sep 17 00:00:00 2001 From: sdk-sentinel-bot Date: Tue, 1 Sep 2026 08:43:31 -0700 Subject: [PATCH 49/87] Stabilize Nexus async outcome deadlines (#1556) --- crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs index cbd5add25..76d527f58 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/nexus.rs @@ -340,10 +340,10 @@ async fn nexus_async( let core_worker = starter.get_core_worker().await; let endpoint = mk_nexus_endpoint(&mut starter).await; - let schedule_to_close_timeout = if outcome == Outcome::CancelAfterRecordedBeforeStarted { - None - } else { - Some(Duration::from_secs(5)) + let schedule_to_close_timeout = match outcome { + Outcome::CancelAfterRecordedBeforeStarted => None, + Outcome::Timeout => Some(Duration::from_secs(5)), + _ => Some(Duration::from_secs(60)), }; let submitter = worker.get_submitter_handle(); From 8ccec71dae6813879ff974e7ed45f727e151d575 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Tue, 1 Sep 2026 12:34:31 -0500 Subject: [PATCH 50/87] Add named deterministic workflow random streams (#1557) * Add named workflow random streams * Address named random stream review feedback * Clarify named stream seed constant * Express random stream key as bytes --- CHANGELOG.md | 4 + .../workflow_tests/continue_as_new.rs | 49 +++++ .../integ_tests/workflow_tests/determinism.rs | 10 +- .../integ_tests/workflow_tests/resets.rs | 17 ++ crates/workflow/Cargo.toml | 1 + crates/workflow/src/lib.rs | 2 +- crates/workflow/src/workflow_context.rs | 192 ++++++++++++++++++ crates/workflow/src/workflow_interceptors.rs | 11 + 8 files changed, 284 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdfee279..1e5afe3f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ relevant information. attributes so payload codecs can encrypt them. * `LocalActivityOptions::include_arguments_in_marker` allows Rust workflows to opt in to recording local activity arguments in Workflow history. +* `WorkflowContext::random_stream`, `SyncWorkflowContext::random_stream`, and + `WorkflowInterceptorContext::random_stream` provide deterministic, workflow-run-scoped + pseudo-random streams isolated by a stable caller-supplied name. Repeated lookup continues a + named stream without consuming the workflow's default randomness or any other named stream. * `WorkflowHandle::get_update_handle` creates a typed handle for an existing Workflow Update from its update ID, allowing callers to wait for the result independently of the original handle. * `WorkflowContext::all_handlers_finished` and `SyncWorkflowContext::all_handlers_finished` let diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/continue_as_new.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/continue_as_new.rs index 9d9fc315b..67ea80510 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/continue_as_new.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/continue_as_new.rs @@ -60,6 +60,55 @@ async fn continue_as_new_happy_path() { worker.run_until_done().await.unwrap(); } +#[workflow] +#[derive(Default)] +struct ContinueAsNewRandomWf; + +#[workflow_methods] +impl ContinueAsNewRandomWf { + #[run] + async fn run( + ctx: &mut WorkflowContext, + previous_value: Option, + ) -> WorkflowResult<(u64, u64)> { + let value = ctx.random_stream("continue-as-new-test").random::(); + if ctx.info().continued_from_run_id().is_none() { + ctx.continue_as_new(Some(value), ContinueAsNewOptions::default())?; + } + Ok(( + previous_value.expect("first run should pass its stream value"), + value, + )) + } +} + +#[tokio::test] +async fn continue_as_new_reseeds_named_random_streams() { + let wf_name = "continue_as_new_reseeds_named_random_streams"; + let mut starter = CoreWfStarter::new(wf_name); + starter + .sdk_config + .register_workflow::() + .unwrap(); + let mut worker = starter.worker().await; + + let task_queue = starter.get_task_queue().to_owned(); + let handle = worker + .submit_workflow( + ContinueAsNewRandomWf::run, + None, + WorkflowStartOptions::new(task_queue, wf_name).build(), + ) + .await + .unwrap(); + worker.run_until_done().await.unwrap(); + let (first_value, continued_value) = handle.get_result(Default::default()).await.unwrap(); + assert_ne!( + first_value, continued_value, + "continue-as-new should independently seed named streams" + ); +} + #[tokio::test] async fn continue_as_new_multiple_concurrent() { let wf_name = "continue_as_new_multiple_concurrent"; diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/determinism.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/determinism.rs index 760ee7868..bae6f1562 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/determinism.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/determinism.rs @@ -152,7 +152,15 @@ struct RandomReplayWf; impl RandomReplayWf { #[run] async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { - Ok(format!("{}:{}", ctx.random::(), ctx.uuid4())) + let orders = ctx.random_stream("example.com/orders"); + let first_order = orders.random::(); + let _ = ctx.random_stream("example.com/telemetry").random::(); + let second_order = ctx.random_stream("example.com/orders").random::(); + Ok(format!( + "{}:{}:{first_order}:{second_order}", + ctx.random::(), + ctx.uuid4() + )) } } diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/resets.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/resets.rs index d568ae84a..1e2cdfb84 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/resets.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/resets.rs @@ -116,6 +116,7 @@ async fn reset_workflow() { struct ResetRandomseedWf { did_fail: Arc, initial_random: Arc>, + initial_named_random: Arc>, reset_started: Arc, saw_updated_random: Arc, notify: Arc, @@ -127,10 +128,13 @@ struct ResetRandomseedWf { impl ResetRandomseedWf { #[run(name = "reset_randomseed")] async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + let named_random = ctx.random_stream("reset-test"); if ctx.state(|wf| !wf.reset_started.load(Ordering::Relaxed)) { let initial_random = ctx.random::(); + let initial_named_random = named_random.random::(); ctx.state(|wf| { let _ = wf.initial_random.set(initial_random); + let _ = wf.initial_named_random.set(initial_named_random); }); } ctx.timer(Duration::from_millis(100)).await; @@ -157,6 +161,16 @@ impl ResetRandomseedWf { initial_random, "random stream should be reseeded after reset" ); + let initial_named_random = ctx.state(|wf| { + *wf.initial_named_random + .get() + .expect("initial named random value should be recorded") + }); + assert_ne!( + named_random.random::(), + initial_named_random, + "named random stream should be reseeded after reset" + ); ctx.state(|wf| { wf.saw_updated_random.store(true, Ordering::Relaxed); }); @@ -194,10 +208,12 @@ async fn reset_randomseed() { let did_fail = Arc::new(AtomicBool::new(false)); let initial_random = Arc::new(OnceLock::new()); + let initial_named_random = Arc::new(OnceLock::new()); let reset_started = Arc::new(AtomicBool::new(false)); let saw_updated_random = Arc::new(AtomicBool::new(false)); let notify = Arc::new(Notify::new()); let notify_clone = notify.clone(); + let initial_named_random_for_wf = initial_named_random.clone(); let reset_started_for_wf = reset_started.clone(); let saw_updated_random_for_wf = saw_updated_random.clone(); starter @@ -205,6 +221,7 @@ async fn reset_randomseed() { .register_workflow_with_factory(move || ResetRandomseedWf { did_fail: did_fail.clone(), initial_random: initial_random.clone(), + initial_named_random: initial_named_random_for_wf.clone(), reset_started: reset_started_for_wf.clone(), saw_updated_random: saw_updated_random_for_wf.clone(), notify: notify_clone.clone(), diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index 39c9e2d37..f2bb9fda9 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -27,6 +27,7 @@ prost-types = { workspace = true } rand = { version = "0.10", default-features = false } rand_pcg = "0.10" serde = { version = "1.0", features = ["derive"] } +siphasher = "1.0" thiserror = "2" uuid = { version = "1.18", default-features = false } wit-bindgen = { version = "0.57.1", default-features = false, features = ["macros", "std", "realloc", "bitflags"] } diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 2949aebf3..bb49ecebd 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -45,7 +45,7 @@ pub use workflow_context::{ StartChildWorkflowExecutionFailedCause, StartChildWorkflowOutput, StartedChildWorkflow, StartedNexusOperation, SyncWorkflowContext, TimerOptions, VersioningIntent, WaitConditionOptions, WorkflowContext, WorkflowContextView, WorkflowIdReusePolicy, - WorkflowRandomValue, + WorkflowRandomStream, WorkflowRandomValue, }; #[doc(hidden)] pub use workflow_context::{PatchActivationCallback, PatchActivationCaller}; diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 152f00fd0..97722007e 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -45,10 +45,12 @@ use futures_util::{ }; use rand::SeedableRng; use rand_pcg::Pcg64Mcg; +use siphasher::sip::SipHasher13; use std::{ cell::{Cell, RefCell}, collections::{HashMap, HashSet}, future::{self, Future}, + hash::Hasher, marker::PhantomData, pin::Pin, rc::Rc, @@ -139,6 +141,57 @@ macro_rules! impl_random_value { impl_random_value!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, f32, f64); +/// A deterministic pseudo-random stream private to a stable caller-supplied name. +/// +/// Obtain a stream with [`WorkflowContext::random_stream`], +/// [`SyncWorkflowContext::random_stream`], or +/// [`crate::workflow_interceptors::WorkflowInterceptorContext::random_stream`]. Looking up the +/// same name again continues the same stream, while different names and the context's default +/// [`WorkflowContext::random`] stream do not consume one another. Clones of this value refer to the +/// same named stream. +/// +/// Draws advance workflow state without recording individual values in history, so replaying code +/// must draw from a given name in the same order. Adding or removing draws from one name does not +/// change any other name. +/// +/// Workflow reset replays the original sequence through the reset point. When Core supplies the +/// reset run's new randomness seed, all named streams start new sequences for work after that +/// point. Continue-as-new creates a new workflow run and independently seeds all streams. +/// +/// Random streams are deliberately unavailable from [`WorkflowContextView`], which is used by +/// read-only init, query, and update-validator handlers. +#[derive(Clone)] +pub struct WorkflowRandomStream { + base: BaseWorkflowContext, + name: String, +} + +impl WorkflowRandomStream { + /// Generates the next deterministic pseudo-random value from this named stream. + /// + /// This generator is not cryptographically secure. + pub fn random(&self) -> T + where + T: WorkflowRandomValue, + { + self.base.named_random(&self.name) + } + + /// Returns the stable name associated with this stream. + pub fn name(&self) -> &str { + &self.name + } +} + +fn named_random_seed(randomness_seed: u64, name: &str) -> u64 { + // The fixed second key provides domain separation and is part of replay compatibility. + let second_key = randomness_seed ^ u64::from_be_bytes(*b"temporal"); + let mut hasher = SipHasher13::new_with_keys(randomness_seed, second_key); + hasher.write(b"temporal-rust-workflow-random-stream\0"); + hasher.write(name.as_bytes()); + hasher.finish() +} + /// Non-generic base context containing all workflow execution infrastructure. /// /// This is used internally by futures and commands that don't need typed workflow state. @@ -235,6 +288,8 @@ impl BaseWorkflowContext { _ => None, }) { shared.random = Pcg64Mcg::seed_from_u64(seed); + shared.randomness_seed = seed; + shared.named_random.clear(); } } @@ -246,6 +301,26 @@ impl BaseWorkflowContext { ::sample(random) } + fn named_random(&self, name: &str) -> T + where + T: WorkflowRandomValue, + { + let mut shared = self.inner.shared.borrow_mut(); + let seed = shared.randomness_seed; + let random = shared + .named_random + .entry(name.to_owned()) + .or_insert_with(|| Pcg64Mcg::seed_from_u64(named_random_seed(seed, name))); + ::sample(random) + } + + pub(crate) fn random_stream(&self, name: impl Into) -> WorkflowRandomStream { + WorkflowRandomStream { + base: self.clone(), + name: name.into(), + } + } + fn uuid4(&self) -> String { Builder::from_random_bytes(self.random::().to_be_bytes()) .into_uuid() @@ -612,6 +687,8 @@ impl BaseWorkflowContext { run_id, shared: RefCell::new(WorkflowContextSharedData { random: Pcg64Mcg::seed_from_u64(init_workflow_job.randomness_seed), + randomness_seed: init_workflow_job.randomness_seed, + named_random: HashMap::new(), memo: init_workflow_job.memo.clone().unwrap_or_default(), search_attributes: init_workflow_job .search_attributes @@ -1445,6 +1522,25 @@ impl SyncWorkflowContext { self.base.uuid4() } + /// Returns the deterministic pseudo-random stream associated with `name`. + /// + /// Repeated lookup of the same name continues the prior stream. Different names are isolated + /// from one another and from [`Self::random`]. Keep the name stable across workflow replays. + /// + /// # Example + /// + /// ```no_run + /// # use temporalio_workflow::{SyncWorkflowContext, WorkflowRandomStream}; + /// # fn choose(ctx: &SyncWorkflowContext) { + /// let stream: WorkflowRandomStream = ctx.random_stream("example.com/orders/tiebreaker"); + /// let choice = stream.random::(); + /// # let _ = choice; + /// # } + /// ``` + pub fn random_stream(&self, name: impl Into) -> WorkflowRandomStream { + self.base.random_stream(name) + } + /// Returns true if the current workflow task is happening under replay pub fn is_replaying(&self) -> bool { self.base.inner.shared.borrow().activation.is_replaying @@ -1936,6 +2032,13 @@ impl WorkflowContext { self.sync.uuid4() } + /// Returns the deterministic pseudo-random stream associated with `name`. + /// + /// See [`SyncWorkflowContext::random_stream`]. + pub fn random_stream(&self, name: impl Into) -> WorkflowRandomStream { + self.sync.random_stream(name) + } + /// Returns true if the current workflow task is happening under replay pub fn is_replaying(&self) -> bool { self.sync.is_replaying() @@ -2296,6 +2399,8 @@ struct WorkflowContextSharedData { is_replaying_history_events: bool, search_attributes: ProtoSearchAttributes, random: Pcg64Mcg, + randomness_seed: u64, + named_random: HashMap, /// Current details string, surfaced via the workflow metadata query. current_details: String, } @@ -3916,6 +4021,93 @@ mod tests { assert_eq!(ctx.random::(), expected); } + #[test] + fn named_random_lookup_continues_the_same_stream() { + let ctx = test_context_with_seed(42); + let first_lookup = ctx.random_stream("orders"); + let first = first_lookup.random::(); + let second = ctx.random_stream("orders").random::(); + + let expected = test_context_with_seed(42).random_stream("orders"); + assert_eq!(first, expected.random::()); + assert_eq!(second, expected.random::()); + } + + #[test] + fn named_random_sequence_is_stable() { + let stream = test_context_with_seed(42).random_stream("example.com/orders"); + + // Changing seed derivation or the generator would break existing workflow replays. + assert_eq!(stream.random::(), 18_054_372_068_998_079_507); + } + + #[test] + fn named_random_streams_are_isolated() { + let ctx = test_context_with_seed(42); + let alpha = ctx.random_stream("alpha"); + let first_alpha = alpha.random::(); + let _ = ctx.random_stream("beta").random::(); + let second_alpha = alpha.random::(); + + let expected_ctx = test_context_with_seed(42); + let expected_alpha = expected_ctx.random_stream("alpha"); + assert_eq!(first_alpha, expected_alpha.random::()); + assert_eq!(second_alpha, expected_alpha.random::()); + assert_ne!( + test_context_with_seed(42) + .random_stream("alpha") + .random::(), + test_context_with_seed(42) + .random_stream("beta") + .random::() + ); + } + + #[test] + fn named_random_does_not_advance_default_randomness() { + let ctx = test_context_with_seed(42); + let first = ctx.random::(); + let _ = ctx.random_stream("plugin").random::(); + let second = ctx.random::(); + + let expected = test_context_with_seed(42); + assert_eq!(first, expected.random::()); + assert_eq!(second, expected.random::()); + } + + #[test] + fn interceptor_context_shares_named_random_stream_state() { + let ctx = test_context_with_seed(42); + let first = ctx.random_stream("plugin").random::(); + let interceptor_ctx = + crate::workflow_interceptors::WorkflowInterceptorContext::new(ctx.sync.base.clone()); + let second = interceptor_ctx.random_stream("plugin").random::(); + + let expected = test_context_with_seed(42).random_stream("plugin"); + assert_eq!(first, expected.random::()); + assert_eq!(second, expected.random::()); + } + + #[test] + fn named_random_streams_are_reseeded_by_activation() { + let ctx = test_context_with_seed(123); + let stream = ctx.random_stream("orders"); + let _ = stream.random::(); + let activation = CoreWorkflowActivation { + jobs: vec![WorkflowActivationJob { + variant: Some(ActivationVariant::UpdateRandomSeed(UpdateRandomSeed { + randomness_seed: 456, + })), + }], + ..Default::default() + }; + + ctx.sync.base.apply_activation_context(&activation, false); + + let expected = test_context_with_seed(456).random_stream("orders"); + assert_eq!(stream.random::(), expected.random::()); + } + struct MutatingRemainingOutboundInterceptor; impl WorkflowInterceptor for MutatingRemainingOutboundInterceptor { diff --git a/crates/workflow/src/workflow_interceptors.rs b/crates/workflow/src/workflow_interceptors.rs index 5b0e0ace1..d01aee424 100644 --- a/crates/workflow/src/workflow_interceptors.rs +++ b/crates/workflow/src/workflow_interceptors.rs @@ -86,6 +86,7 @@ use crate::{ ChildWorkflowOptions, ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, NexusOperationOptions, SignalWorkflowOptions, StartChildWorkflowOutput, StartedChildWorkflow, StartedNexusOperation, TimerOptions, WorkflowCancellationToken, WorkflowContextView, + WorkflowRandomStream, cancellation::WorkflowCancellationRegistration, runtime::{ entry::WorkflowError, @@ -337,6 +338,16 @@ impl WorkflowInterceptorContext { self.base.cancellation_token() } + /// Returns the deterministic pseudo-random stream associated with `name`. + /// + /// Named streams let interceptors consume replay-safe randomness without changing the + /// workflow's default random sequence or another interceptor's named sequence. Query and + /// update-validator interceptors receive [`SyncWorkflowInterceptorContext`], which does not + /// expose random streams because those handlers are read-only. + pub fn random_stream(&self, name: impl Into) -> WorkflowRandomStream { + self.base.random_stream(name) + } + /// Request to create a timer through the workflow outbound interceptor chain. pub fn timer>( &self, From 37d747ddaacc0b35d6bf98089a21c7a0e37bbbc5 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Tue, 1 Sep 2026 16:37:16 -0400 Subject: [PATCH 51/87] feat(sdk): gate experimental APIs behind feature (#1395) * feat(sdk): gate experimental APIs behind feature * mark everything experimental * self review * remove explicit feature badging --- .cargo/config.toml | 2 + .github/workflows/per-pr.yml | 7 +- CHANGELOG.md | 17 + README.md | 8 +- crates/client/Cargo.toml | 4 + crates/client/README.md | 5 + crates/client/src/errors.rs | 7 +- crates/client/src/lib.rs | 31 +- crates/client/src/options_structs.rs | 60 +- crates/client/src/plugins.rs | 2 +- crates/sdk-core-c-bridge/Cargo.toml | 1 + crates/sdk-core/Cargo.toml | 10 +- crates/sdk/Cargo.toml | 4 + crates/sdk/README.md | 29 +- crates/sdk/src/activities.rs | 1 + crates/sdk/src/error.rs | 2 + crates/sdk/src/interceptors.rs | 223 +-- crates/sdk/src/lib.rs | 253 +++- crates/sdk/src/plugins.rs | 2 +- crates/sdk/src/workflow_future.rs | 2 +- crates/sdk/src/workflow_registry.rs | 4 +- crates/sdk/src/workflow_replayer.rs | 31 +- crates/workflow/Cargo.toml | 6 + crates/workflow/src/component.rs | 2 +- crates/workflow/src/lib.rs | 22 +- crates/workflow/src/runtime/model.rs | 56 +- crates/workflow/src/runtime/model/nexus.rs | 49 + crates/workflow/src/workflow_context.rs | 1264 ++++++++--------- crates/workflow/src/workflow_context/nexus.rs | 127 ++ .../workflow/src/workflow_context/options.rs | 243 +--- .../options/continue_as_new_versioning.rs | 46 + .../src/workflow_context/options/nexus.rs | 145 ++ crates/workflow/src/workflow_interceptors.rs | 66 +- .../src/workflow_interceptors/nexus.rs | 50 + 34 files changed, 1585 insertions(+), 1196 deletions(-) create mode 100644 crates/workflow/src/runtime/model/nexus.rs create mode 100644 crates/workflow/src/workflow_context/nexus.rs create mode 100644 crates/workflow/src/workflow_context/options/continue_as_new_versioning.rs create mode 100644 crates/workflow/src/workflow_context/options/nexus.rs create mode 100644 crates/workflow/src/workflow_interceptors/nexus.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 12252757d..33f8e44c8 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -15,6 +15,8 @@ integ-test = [ "--features", "ephemeral-server", "--features", + "temporalio-sdk/experimental", + "--features", "temporalio-sdk-core/otel", "--package", "temporalio-sdk-core", diff --git a/.github/workflows/per-pr.yml b/.github/workflows/per-pr.yml index b66f3e12d..9767155d0 100644 --- a/.github/workflows/per-pr.yml +++ b/.github/workflows/per-pr.yml @@ -31,6 +31,7 @@ jobs: - run: cargo lint - run: cargo test-lint - run: cargo check + - run: cargo check --features experimental - run: git diff --exit-code test: @@ -67,12 +68,12 @@ jobs: with: script: | core.exportVariable('RUSTFLAGS', '-Csymbol-mangling-version=v0'); - - run: cargo test -- --include-ignored --nocapture + - run: cargo test --features experimental -- --include-ignored --nocapture - name: Find test executable for cgroup tests id: find-cgroup-test if: runner.os == 'Linux' && runner.arch == 'X64' run: | - test_executable=$(cargo build --tests --message-format json | jq -r 'select(.profile?.test == true and .target?.name == "temporalio_sdk_core" and .executable) | .executable') + test_executable=$(cargo build --tests --features experimental --message-format json | jq -r 'select(.profile?.test == true and .target?.name == "temporalio_sdk_core" and .executable) | .executable') cp $test_executable ./core-tests - name: Upload cgroup test executable uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -186,7 +187,7 @@ jobs: - uses: ./.github/actions/setup with: mise-install-args: protoc - - run: cargo test --features=test-utilities --test cloud_tests + - run: cargo test --features=test-utilities,temporalio-sdk/experimental --test cloud_tests - name: Generate Cloud test certificates run: | umask 077 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5afe3f0..a90ec5133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,23 @@ relevant information. * `WorkflowHistory::to_json` is now async, `WorkflowHistoryError` reports fetch and JSON conversion failures; and the eager `events`, `Clone`, and `From for History` APIs have been removed. Replay results expose their eagerly fetched events through `ReplayHistory`. +* Rust SDK APIs previously marked experimental now require the `experimental` Cargo feature. This + includes: + * Nexus operation caller and workflow interceptor APIs, including `NexusOperationOptions`, + `NexusOperationCancellationType`, `StartedNexusOperation`, the workflow-context start methods, + and the `WorkflowInterceptor::start_nexus_operation` hook and input/result types. + * Worker deployment versioning APIs: `ContinueAsNewVersioningBehavior`, + `ContinueAsNewOptions::initial_versioning_behavior`, + `WorkflowContext::target_worker_deployment_version_changed`, and + `SyncWorkflowContext::target_worker_deployment_version_changed`. + * Client, worker, and workflow replayer plugin APIs. + * Client payload warning thresholds (`PayloadLimitsOptions` and + `ConnectionOptions::payload_limits`) and `WorkerOptions::disable_payload_error_limit`. + * Patch activation callback types and the corresponding worker option. + * Worker lifecycle interception APIs (`WorkerInterceptor`, its input types and registration + methods, and `ReturnWorkflowExitValueInterceptor`). + * Event Group marker fields on activity, local activity, child workflow, timer, and external + signal options. * The following types are now non-exhaustive: `Priority`, `WorkerDeploymentVersion`, `WorkerCallbacks`, `WorkflowExecutionInfo`, `ActivityCloseTimeouts`, `ActivityExecutionDecodeHint`, child-workflow and signal decode hints, diff --git a/README.md b/README.md index 829853b1a..851ab4f72 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ Core SDK that can be used as a base for other Temporal SDKs. It is currently use # Documentation -Rust & Core SDK documentation can be generated with `cargo doc`, output will be placed in the -`target/doc` directory. +Rust & Core SDK documentation can be generated with `cargo doc --workspace --all-features`, output +will be placed in the `target/doc` directory. [Architecture](ARCHITECTURE.md) doc provides some high-level information about how Core SDK works and how language layers interact with it. @@ -59,7 +59,7 @@ All the following commands are enforced for each pull request: You can build and test the project using cargo: `cargo build` -`cargo test` +`cargo test --features experimental` Run integ tests with `cargo integ-test`. By default it will start an ephemeral server. You can also use an already-running server by passing `-s external`. @@ -79,7 +79,7 @@ cargo integ-test -s envconfig -- \ `TEMPORAL_CONFIG_FILE` and `TEMPORAL_PROFILE` can select a TOML profile instead. The harness does not start, configure, or clean up the target server or namespace in this mode. -Run load tests with `cargo test --test heavy_tests`. +Run load tests with `cargo test --features experimental --test heavy_tests`. NOTE: Integration tests should pass locally, if running on MacOS and you see integration tests consistently failing with an error that mentions `Too many open files`, this is likely due to `ulimit -n` being too low. You can raise diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 4ac943c52..259a5a667 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -11,8 +11,12 @@ keywords = ["temporal", "workflow"] categories = ["development-tools"] readme = "README.md" +[package.metadata.docs.rs] +features = ["experimental"] + [features] default = ["envconfig", "tls-ring"] +experimental = [] tls-ring = ["tonic/tls-ring"] tls-aws-lc = ["tonic/tls-aws-lc"] telemetry = ["dep:opentelemetry"] diff --git a/crates/client/README.md b/crates/client/README.md index 749455f5a..915adbdf1 100644 --- a/crates/client/README.md +++ b/crates/client/README.md @@ -162,6 +162,11 @@ while let Some(result) = stream.next().await { } ``` +## Experimental APIs + +APIs that are still under development require the `experimental` Cargo feature and may change or +be removed before stabilization. + ## Raw gRPC Access For operations not covered by the high-level API, access the underlying gRPC service clients diff --git a/crates/client/src/errors.rs b/crates/client/src/errors.rs index 336a6698c..e958511bc 100644 --- a/crates/client/src/errors.rs +++ b/crates/client/src/errors.rs @@ -1,6 +1,8 @@ //! Contains errors that can be returned by clients. -use crate::{PluginApplyError, WorkflowExecutionStatus, workflow_handle::WorkflowResultDetails}; +#[cfg(feature = "experimental")] +use crate::PluginApplyError; +use crate::{WorkflowExecutionStatus, workflow_handle::WorkflowResultDetails}; use http::uri::InvalidUri; use temporalio_common::{ data_converters::{DecodablePayloads, PayloadConversionError}, @@ -25,6 +27,7 @@ use tonic::Code; #[non_exhaustive] pub enum ClientConnectError { /// A plugin failed while configuring connection options. + #[cfg(feature = "experimental")] #[error(transparent)] Plugin(#[from] PluginApplyError), /// Invalid URI. Configuration error, fatal. @@ -57,6 +60,7 @@ pub enum ClientConnectError { impl From for ClientConnectError { fn from(value: ClientNewError) -> Self { match value { + #[cfg(feature = "experimental")] ClientNewError::Plugin(err) => Self::Plugin(err), } } @@ -425,6 +429,7 @@ impl AsyncActivityError { #[non_exhaustive] pub enum ClientNewError { /// A plugin failed while configuring client options. + #[cfg(feature = "experimental")] #[error(transparent)] Plugin(#[from] PluginApplyError), } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 81701a8bc..9e8e96c39 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] #![warn(missing_docs)] // error if there are missing docs //! This crate contains client implementations that can be used to contact the Temporal service. @@ -20,6 +21,7 @@ pub mod grpc; pub mod interceptors; mod metrics; mod options_structs; +#[cfg(feature = "experimental")] /// Experimental APIs for configuring clients with reusable plugins. pub mod plugins; /// Visible only for tests @@ -65,6 +67,7 @@ pub use interceptors::{ }; pub use metrics::{LONG_REQUEST_LATENCY_HISTOGRAM_NAME, REQUEST_LATENCY_HISTOGRAM_NAME}; pub use options_structs::*; +#[cfg(feature = "experimental")] pub use plugins::{ ClientPlugin, ErasedClientPlugin, PluginApplyError, PluginError, PluginTarget, WorkerPluginData, }; @@ -432,6 +435,14 @@ impl Connection { } else { None }; + #[cfg(feature = "experimental")] + let payloads_warn_size = options.payload_limits.payloads_warn_size; + #[cfg(not(feature = "experimental"))] + let payloads_warn_size = options_structs::DEFAULT_PAYLOADS_WARN_SIZE; + #[cfg(feature = "experimental")] + let memo_warn_size = options.payload_limits.memo_warn_size; + #[cfg(not(feature = "experimental"))] + let memo_warn_size = options_structs::DEFAULT_MEMO_WARN_SIZE; Ok(Self { inner: Arc::new(ConnectionInner { service: svc_client, @@ -445,12 +456,9 @@ impl Connection { _dns_task: dns_task, payloads_warn_size: resolve_warn_threshold( "payloads_warn_size", - options.payload_limits.payloads_warn_size, - ), - memo_warn_size: resolve_warn_threshold( - "memo_warn_size", - options.payload_limits.memo_warn_size, + payloads_warn_size, ), + memo_warn_size: resolve_warn_threshold("memo_warn_size", memo_warn_size), }), }) } @@ -1097,9 +1105,12 @@ impl Client { /// Connect to a Temporal service and create a namespace-bound client, applying registered /// plugins to connection and client options in registration order. pub async fn connect( - mut connection_options: ConnectionOptions, + connection_options: ConnectionOptions, client_options: ClientOptions, ) -> Result { + #[cfg(feature = "experimental")] + let mut connection_options = connection_options; + #[cfg(feature = "experimental")] plugins::apply_connection_plugins(&client_options, &mut connection_options)?; let connection = Connection::connect(connection_options).await?; Ok(Self::new(connection, client_options)?) @@ -1109,7 +1120,10 @@ impl Client { /// /// Registered client plugins are applied here. Connection plugin hooks only run when using /// [`Client::connect`]. - pub fn new(connection: Connection, mut options: ClientOptions) -> Result { + pub fn new(connection: Connection, options: ClientOptions) -> Result { + #[cfg(feature = "experimental")] + let mut options = options; + #[cfg(feature = "experimental")] plugins::apply_client_plugins(&mut options)?; Ok(Client { connection, @@ -1808,6 +1822,7 @@ fn build_start_workflow_request( options: WorkflowStartOptions, ) -> StartWorkflowExecutionRequest { let user_metadata = options.user_metadata(); + let request_eager_execution = options.enable_eager_workflow_start; StartWorkflowExecutionRequest { namespace: client.namespace(), input, @@ -1837,7 +1852,7 @@ fn build_start_workflow_request( .search_attributes .map(|attributes| attributes.into_proto()), cron_schedule: options.cron_schedule.unwrap_or_default(), - request_eager_execution: options.enable_eager_workflow_start, + request_eager_execution, retry_policy: options.retry_policy.map(Into::into), links: options.links, completion_callbacks: options.completion_callbacks, diff --git a/crates/client/src/options_structs.rs b/crates/client/src/options_structs.rs index 0dc66b915..29e03aa6c 100644 --- a/crates/client/src/options_structs.rs +++ b/crates/client/src/options_structs.rs @@ -1,7 +1,8 @@ use crate::{ - ClientInterceptor, ClientPlugin, ErasedClientPlugin, HttpConnectProxyOptions, RetryOptions, - RpcOptions, VERSION, callback_based, + ClientInterceptor, HttpConnectProxyOptions, RetryOptions, RpcOptions, VERSION, callback_based, }; +#[cfg(feature = "experimental")] +use crate::{ClientPlugin, ErasedClientPlugin}; use http::Uri; use std::{collections::HashMap, sync::Arc, time::Duration}; use temporalio_common::{ @@ -34,6 +35,9 @@ use tokio_rustls::rustls::client::ResolvesClientCert; use tokio_rustls::rustls::client::danger::ServerCertVerifier; use url::Url; +pub(crate) const DEFAULT_PAYLOADS_WARN_SIZE: u64 = 512 * 1024; +pub(crate) const DEFAULT_MEMO_WARN_SIZE: u64 = 2 * 1024; + /// Options for [crate::Connection::connect]. #[derive(bon::Builder, Clone, Debug)] #[non_exhaustive] @@ -106,6 +110,14 @@ pub struct ConnectionOptions { /// Payload size limit options for this connection. Defaults to the standard warning thresholds; /// disable an individual warning by setting its threshold to `0`. /// NOTE: Experimental + #[cfg(feature = "experimental")] + #[cfg_attr( + docsrs, + builder(setters( + some_fn(name = payload_limits_impl, vis = "pub(crate)"), + option_fn(name = maybe_payload_limits_impl, vis = "pub(crate)") + )) + )] #[builder(default)] pub payload_limits: PayloadLimitsOptions, @@ -128,6 +140,35 @@ pub struct ConnectionOptions { pub(crate) client_version: String, } +// Bon does not propagate `doc(cfg)` to generated setters, so these docs-only methods forward to +// renamed generated implementations. +#[cfg(all(feature = "experimental", docsrs))] +impl ConnectionOptionsBuilder { + /// Set the payload size limit options for this connection. + #[doc(cfg(feature = "experimental"))] + pub fn payload_limits( + self, + value: PayloadLimitsOptions, + ) -> ConnectionOptionsBuilder> + where + S::PayloadLimits: connection_options_builder::IsUnset, + { + self.payload_limits_impl(value) + } + + /// Set the payload size limit options for this connection from an optional value. + #[doc(cfg(feature = "experimental"))] + pub fn maybe_payload_limits( + self, + value: Option, + ) -> ConnectionOptionsBuilder> + where + S::PayloadLimits: connection_options_builder::IsUnset, + { + self.maybe_payload_limits_impl(value) + } +} + // Setters/getters for fields that should only be touched by SDK implementers. #[cfg(feature = "core-based-sdk")] impl ConnectionOptions { @@ -160,10 +201,12 @@ pub struct ClientOptions { #[builder(field)] #[debug(skip)] + #[cfg(feature = "experimental")] plugins: Vec, #[builder(field)] #[debug(skip)] + #[cfg(feature = "experimental")] client_plugins_applied: bool, /// The data converter used for serializing/deserializing payloads. @@ -175,6 +218,7 @@ pub struct ClientOptions { pub client_interceptors: Vec>, } +#[cfg(feature = "experimental")] impl ClientOptionsBuilder { /// Register a type-erased client plugin. /// @@ -211,14 +255,17 @@ impl ClientOptions { /// This is intended for SDK integrations that propagate worker plugin registrations. /// /// **Experimental:** This API may change or be removed. + #[cfg(feature = "experimental")] pub fn plugins(&self) -> &[ErasedClientPlugin] { &self.plugins } + #[cfg(feature = "experimental")] pub(crate) fn client_plugins_applied(&self) -> bool { self.client_plugins_applied } + #[cfg(feature = "experimental")] pub(crate) fn mark_client_plugins_applied(&mut self) { self.client_plugins_applied = true; } @@ -353,19 +400,21 @@ impl Default for DnsLoadBalancingOptions { /// Payload size limit options for a connection. /// NOTE: Experimental +#[cfg(feature = "experimental")] #[derive(Clone, Debug, PartialEq, bon::Builder)] #[non_exhaustive] pub struct PayloadLimitsOptions { /// Warning threshold (bytes) for the size of an outbound payload-bearing field; over-threshold /// fields are logged but still sent to server. Defaults to 512 KiB. Set to `0` to disable. - #[builder(default = 512 * 1024)] + #[builder(default = DEFAULT_PAYLOADS_WARN_SIZE)] pub payloads_warn_size: u64, /// Warning threshold (bytes) for outbound memo sizes; over-threshold memos are logged but still /// sent to server. Defaults to 2 KiB. Set to `0` to disable. - #[builder(default = 2 * 1024)] + #[builder(default = DEFAULT_MEMO_WARN_SIZE)] pub memo_warn_size: u64, } +#[cfg(feature = "experimental")] impl Default for PayloadLimitsOptions { fn default() -> Self { Self::builder().build() @@ -417,8 +466,7 @@ pub struct WorkflowStartOptions { /// Additional search attributes for the workflow. pub search_attributes: Option, - /// Optionally enable Eager Workflow Start, a latency optimization using local workers - /// NOTE: Experimental + /// Optionally enable Eager Workflow Start, a latency optimization using local workers. #[builder(default)] pub enable_eager_workflow_start: bool, diff --git a/crates/client/src/plugins.rs b/crates/client/src/plugins.rs index 09716f079..f5e7d2fcc 100644 --- a/crates/client/src/plugins.rs +++ b/crates/client/src/plugins.rs @@ -174,7 +174,7 @@ pub(crate) fn apply_client_plugins(options: &mut ClientOptions) -> Result<(), Pl Ok(()) } -#[cfg(test)] +#[cfg(all(test, feature = "experimental"))] mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/sdk-core-c-bridge/Cargo.toml b/crates/sdk-core-c-bridge/Cargo.toml index 76b5265a9..d579762c9 100644 --- a/crates/sdk-core-c-bridge/Cargo.toml +++ b/crates/sdk-core-c-bridge/Cargo.toml @@ -42,6 +42,7 @@ xz2 = { version = "0.1", optional = true } [dependencies.temporalio-client] path = "../client" version = "~0.7.0" +features = ["experimental"] [dependencies.temporalio-sdk-core] path = "../sdk-core" diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index f95d54663..af482e458 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -164,9 +164,15 @@ hyper-util = { version = "0.1", features = [ rstest = "0.26" semver = "1.0" # A registry version here creates a cycle when Cargo orders sdk-core before sdk for workspace publishing. -temporalio-sdk = { path = "../sdk", features = ["testing", "wasm-workflows"] } +temporalio-sdk = { path = "../sdk", features = [ + "experimental", + "testing", + "wasm-workflows", +] } temporalio-common = { path = "../common", version = "~0.7.0", default-features = false } -temporalio-workflow = { path = "../workflow", version = "~0.7.0" } +temporalio-workflow = { path = "../workflow", version = "~0.7.0", features = [ + "experimental", +] } tokio = { version = "1.47", default-features = false, features = [ "rt", "rt-multi-thread", diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index dc0b134f7..b858eca96 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -12,6 +12,9 @@ categories = ["development-tools"] readme = "README.md" autoexamples = false +[package.metadata.docs.rs] +features = ["experimental"] + [dependencies] async-trait = "0.1" anyhow = "1.0" @@ -71,6 +74,7 @@ rstest = "0.26" [features] default = ["envconfig", "prometheus"] envconfig = ["temporalio-sdk-core/envconfig"] +experimental = ["temporalio-client/experimental", "temporalio-workflow/experimental"] prometheus = ["temporalio-sdk-core/prometheus"] otel = ["temporalio-sdk-core/otel"] examples = ["serde/derive", "dep:serde_json", "envconfig"] diff --git a/crates/sdk/README.md b/crates/sdk/README.md index ef9bed34f..fa289bf41 100644 --- a/crates/sdk/README.md +++ b/crates/sdk/README.md @@ -108,11 +108,7 @@ async fn main() -> Result<(), Box> { ### Testing Enable the `testing` feature to run activities directly or start an isolated Temporal CLI dev -server for workflow tests: - -```toml -temporalio-sdk = { version = "0.6", features = ["testing"] } -``` +server for workflow tests. Activity test inputs and outputs are ordinary Rust values. Register an activity implementer when testing an instance activity: @@ -138,20 +134,15 @@ env.shutdown().await?; ## Crate Features The SDK enables a few convenience integrations by default. Users who want a smaller dependency -graph can disable defaults and opt back into the integrations they use: - -```toml -temporalio-sdk = { version = "0.3", default-features = false, features = ["envconfig"] } -``` - -- `envconfig` - enabled by default. Adds `ClientOptions::load_from_config` and related helpers for - loading connection settings from environment variables and `temporal.toml` files. -- `prometheus` - enabled by default. Adds the Prometheus metrics exporter in - `temporalio_common::telemetry` for serving SDK metrics from a HTTP endpoint. -- `otel` - optional. Adds the OpenTelemetry metrics exporter in `temporalio_common::telemetry` for - sending SDK metrics to an OpenTelemetry collector. -- `testing` - optional. Adds activity and workflow test environments, including local Temporal CLI - dev-server lifecycle management. +graph can disable defaults and opt back into the integrations they use. + +- `envconfig`: Support for loading connection settings from environment variables and `temporal.toml` files. | +- `prometheus`: The Prometheus metrics exporter for `temporalio_common::telemetry`. | +- `otel`: The OpenTelemetry metrics exporter for `temporalio_common::telemetry`. | +- `experimental`: Rust SDK, client, and Workflow APIs that are still under development and may change or be removed. | +- `testing`: The `testing` module, direct activity test support, and local Temporal CLI dev-server lifecycle management. | +- `dynamic-tls`: Dynamic mTLS client-certificate resolution for transparent certificate rotation. | +- `wasm-workflows`: Support WebAssembly workflow components through Wasmtime for workers and workflow replay. | ## Workflows in detail diff --git a/crates/sdk/src/activities.rs b/crates/sdk/src/activities.rs index fb86f2f53..2d71406c0 100644 --- a/crates/sdk/src/activities.rs +++ b/crates/sdk/src/activities.rs @@ -551,6 +551,7 @@ pub struct ActivityDefinitions { } impl ActivityDefinitions { + #[cfg(feature = "experimental")] pub(crate) fn extend(&mut self, other: &Self) { self.activities.extend(other.activities.clone()); } diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs index f50ee9f97..0feebea7e 100644 --- a/crates/sdk/src/error.rs +++ b/crates/sdk/src/error.rs @@ -1,6 +1,7 @@ //! Shared SDK error re-exports. pub use crate::workflow_registry::WorkflowRegistrationError; +#[cfg(feature = "experimental")] use temporalio_client::PluginApplyError; pub use temporalio_sdk_core::WorkerValidationError; @@ -11,6 +12,7 @@ pub use temporalio_sdk_core::WorkerValidationError; #[non_exhaustive] pub enum WorkerCreateError { /// A plugin failed while configuring worker options. + #[cfg(feature = "experimental")] #[error(transparent)] Plugin(#[from] PluginApplyError), /// Worker initialization failed after plugin configuration completed. diff --git a/crates/sdk/src/interceptors.rs b/crates/sdk/src/interceptors.rs index 221758309..809ae12c9 100644 --- a/crates/sdk/src/interceptors.rs +++ b/crates/sdk/src/interceptors.rs @@ -5,11 +5,9 @@ use crate::{ activities::{ActivityContext, ActivityError, ActivityInfo}, }; use futures_util::future::{BoxFuture, LocalBoxFuture}; -use std::{ - any::Any, - collections::HashMap, - sync::{Arc, OnceLock}, -}; +#[cfg(feature = "experimental")] +use std::sync::OnceLock; +use std::{any::Any, collections::HashMap, sync::Arc}; use temporalio_common::{ data_converters::{ GenericPayloadConverter, PayloadConversionError, SerializationContext, TemporalSerializable, @@ -46,49 +44,6 @@ mod activity_execution_value { } } -/// Implementors can intercept certain actions that happen within the Worker. -/// -/// Advanced usage only. -/// **Experimental:** This API may change or be removed. -#[async_trait::async_trait(?Send)] -pub trait WorkerInterceptor: Send + Sync { - /// Intercept the running of a worker. - fn run_worker<'a>( - &'a self, - input: RunWorkerInput<'a>, - next: Next<'a, RunWorkerInput<'a>, LocalBoxFuture<'a, Result<(), WorkerRunError>>>, - ) -> LocalBoxFuture<'a, Result<(), WorkerRunError>> { - next.run(input) - } - - /// Intercept the running of a worker created for workflow replay. - fn with_workflow_replay_worker<'a>( - &'a self, - input: WithWorkflowReplayWorkerInput<'a>, - next: Next< - 'a, - WithWorkflowReplayWorkerInput<'a>, - LocalBoxFuture<'a, Result<(), WorkerRunError>>, - >, - ) -> LocalBoxFuture<'a, Result<(), WorkerRunError>> { - next.run(input) - } - - /// Called every time a workflow activation completes (just before sending the completion to - /// core). - async fn on_workflow_activation_completion(&self, _completion: &WorkflowActivationCompletion) {} - /// Called after the worker has initiated shutdown and the workflow/activity polling loops - /// have exited, but just before waiting for the inner core worker shutdown - fn on_shutdown(&self, _sdk_worker: &Worker) {} - /// Called every time a workflow is about to be activated - async fn on_workflow_activation( - &self, - _activation: &WorkflowActivation, - ) -> Result<(), anyhow::Error> { - Ok(()) - } -} - /// Continuation for an interceptor operation. /// /// Interceptor implementations call [`Next::run`] to invoke the next step of the chain. @@ -96,76 +51,137 @@ pub struct Next<'a, I, O> { inner: Box O + Send + 'a>, } -/// Input to [`WorkerInterceptor::run_worker`]. -#[derive(Debug)] -#[non_exhaustive] -pub struct RunWorkerInput<'a> { - /// The worker being run. - pub worker: &'a mut Worker, -} +impl<'a, I, O> Next<'a, I, O> { + pub(crate) fn new(f: impl FnOnce(I) -> O + Send + 'a) -> Self { + Self { inner: Box::new(f) } + } -impl<'a> RunWorkerInput<'a> { - pub(crate) fn new(worker: &'a mut Worker) -> Self { - Self { worker } + /// Continue the call chain with the provided input. + pub fn run(self, input: I) -> O { + (self.inner)(input) } } -/// Input to [`WorkerInterceptor::with_workflow_replay_worker`]. -#[derive(Debug)] -#[non_exhaustive] -pub struct WithWorkflowReplayWorkerInput<'a> { - /// The worker created for this replay operation. - pub worker: &'a mut Worker, -} +#[cfg_attr(not(feature = "experimental"), allow(unreachable_pub))] +mod worker_lifecycle { + use super::*; -impl<'a> WithWorkflowReplayWorkerInput<'a> { - pub(crate) fn new(worker: &'a mut Worker) -> Self { - Self { worker } + /// Implementors can intercept certain actions that happen within the Worker. + /// + /// Advanced usage only. + /// **Experimental:** This API may change or be removed. + #[async_trait::async_trait(?Send)] + pub trait WorkerInterceptor: Send + Sync { + /// Intercept the running of a worker. + fn run_worker<'a>( + &'a self, + input: RunWorkerInput<'a>, + next: Next<'a, RunWorkerInput<'a>, LocalBoxFuture<'a, Result<(), WorkerRunError>>>, + ) -> LocalBoxFuture<'a, Result<(), WorkerRunError>> { + next.run(input) + } + + /// Intercept the running of a worker created for workflow replay. + fn with_workflow_replay_worker<'a>( + &'a self, + input: WithWorkflowReplayWorkerInput<'a>, + next: Next< + 'a, + WithWorkflowReplayWorkerInput<'a>, + LocalBoxFuture<'a, Result<(), WorkerRunError>>, + >, + ) -> LocalBoxFuture<'a, Result<(), WorkerRunError>> { + next.run(input) + } + + /// Called every time a workflow activation completes (just before sending the completion to + /// core). + async fn on_workflow_activation_completion( + &self, + _completion: &WorkflowActivationCompletion, + ) { + } + /// Called after the worker has initiated shutdown and the workflow/activity polling loops + /// have exited, but just before waiting for the inner core worker shutdown + fn on_shutdown(&self, _sdk_worker: &Worker) {} + /// Called every time a workflow is about to be activated + async fn on_workflow_activation( + &self, + _activation: &WorkflowActivation, + ) -> Result<(), anyhow::Error> { + Ok(()) + } } -} -pub(crate) fn call_run_worker<'a>( - interceptors: &'a [Arc], - input: RunWorkerInput<'a>, - terminal: Next<'a, RunWorkerInput<'a>, LocalBoxFuture<'a, Result<(), WorkerRunError>>>, -) -> LocalBoxFuture<'a, Result<(), WorkerRunError>> { - if let Some((interceptor, remaining)) = interceptors.split_first() { - let next = Next::new(move |input| call_run_worker(remaining, input, terminal)); - interceptor.run_worker(input, next) - } else { - terminal.run(input) + /// Input to [`WorkerInterceptor::run_worker`]. + #[derive(Debug)] + #[non_exhaustive] + pub struct RunWorkerInput<'a> { + /// The worker being run. + pub worker: &'a mut Worker, } -} -pub(crate) fn call_with_workflow_replay_worker<'a>( - interceptors: &'a [Arc], - input: WithWorkflowReplayWorkerInput<'a>, - terminal: Next< - 'a, - WithWorkflowReplayWorkerInput<'a>, - LocalBoxFuture<'a, Result<(), WorkerRunError>>, - >, -) -> LocalBoxFuture<'a, Result<(), WorkerRunError>> { - if let Some((interceptor, remaining)) = interceptors.split_first() { - let next = - Next::new(move |input| call_with_workflow_replay_worker(remaining, input, terminal)); - interceptor.with_workflow_replay_worker(input, next) - } else { - terminal.run(input) + impl<'a> RunWorkerInput<'a> { + pub(crate) fn new(worker: &'a mut Worker) -> Self { + Self { worker } + } } -} -impl<'a, I, O> Next<'a, I, O> { - pub(crate) fn new(f: impl FnOnce(I) -> O + Send + 'a) -> Self { - Self { inner: Box::new(f) } + /// Input to [`WorkerInterceptor::with_workflow_replay_worker`]. + #[derive(Debug)] + #[non_exhaustive] + pub struct WithWorkflowReplayWorkerInput<'a> { + /// The worker created for this replay operation. + pub worker: &'a mut Worker, } - /// Continue the call chain with the provided input. - pub fn run(self, input: I) -> O { - (self.inner)(input) + impl<'a> WithWorkflowReplayWorkerInput<'a> { + pub(crate) fn new(worker: &'a mut Worker) -> Self { + Self { worker } + } + } + + pub(crate) fn call_run_worker<'a>( + interceptors: &'a [Arc], + input: RunWorkerInput<'a>, + terminal: Next<'a, RunWorkerInput<'a>, LocalBoxFuture<'a, Result<(), WorkerRunError>>>, + ) -> LocalBoxFuture<'a, Result<(), WorkerRunError>> { + if let Some((interceptor, remaining)) = interceptors.split_first() { + let next = Next::new(move |input| call_run_worker(remaining, input, terminal)); + interceptor.run_worker(input, next) + } else { + terminal.run(input) + } + } + + pub(crate) fn call_with_workflow_replay_worker<'a>( + interceptors: &'a [Arc], + input: WithWorkflowReplayWorkerInput<'a>, + terminal: Next< + 'a, + WithWorkflowReplayWorkerInput<'a>, + LocalBoxFuture<'a, Result<(), WorkerRunError>>, + >, + ) -> LocalBoxFuture<'a, Result<(), WorkerRunError>> { + if let Some((interceptor, remaining)) = interceptors.split_first() { + let next = Next::new(move |input| { + call_with_workflow_replay_worker(remaining, input, terminal) + }); + interceptor.with_workflow_replay_worker(input, next) + } else { + terminal.run(input) + } } } +#[cfg(not(feature = "experimental"))] +pub(crate) use worker_lifecycle::{ + RunWorkerInput, WithWorkflowReplayWorkerInput, WorkerInterceptor, +}; +#[cfg(feature = "experimental")] +pub use worker_lifecycle::{RunWorkerInput, WithWorkflowReplayWorkerInput, WorkerInterceptor}; +pub(crate) use worker_lifecycle::{call_run_worker, call_with_workflow_replay_worker}; + /// Activity execution data passed to [`ActivityInboundInterceptor::execute_activity`]. #[non_exhaustive] pub struct ExecuteActivityInput { @@ -260,11 +276,13 @@ pub trait ActivityInboundInterceptor: Send + Sync + 'static { } /// An interceptor that allows you to fetch the exit value of the workflow if and when it is set +#[cfg(feature = "experimental")] #[derive(Default)] pub struct ReturnWorkflowExitValueInterceptor { result_value: Arc>, } +#[cfg(feature = "experimental")] impl ReturnWorkflowExitValueInterceptor { /// Can be used to fetch the workflow result if/when it is determined pub fn result_handle(&self) -> Arc> { @@ -273,6 +291,7 @@ impl ReturnWorkflowExitValueInterceptor { } #[async_trait::async_trait(?Send)] +#[cfg(feature = "experimental")] impl WorkerInterceptor for ReturnWorkflowExitValueInterceptor { async fn on_workflow_activation_completion(&self, c: &WorkflowActivationCompletion) { if let Some(v) = c.complete_workflow_execution_value() { diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index c3a97c046..dc32f47b4 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] #![warn(missing_docs)] // error if there are missing docs //! This crate defines a Public Preview Temporal Rust SDK. @@ -66,6 +67,7 @@ extern crate self as temporalio_sdk; pub mod activities; pub mod error; pub mod interceptors; +#[cfg(feature = "experimental")] /// Experimental APIs for configuring clients and workers with reusable plugins. pub mod plugins; pub mod runtime; @@ -81,6 +83,10 @@ pub mod workflow_replayer; mod workflow_wasm; pub mod workflows; +#[cfg(feature = "experimental")] +pub use crate::plugins::{ + ClientAndWorkerPlugin, SimplePlugin, SimplePluginBuilder, SimplePluginOption, WorkerPlugin, +}; pub use crate::{ error::{ ActivityExecutionError, ApplicationFailure, ChildWorkflowExecutionError, @@ -88,24 +94,24 @@ pub use crate::{ RetryState, TimeoutType, WorkerCreateError, WorkerRunError, WorkerValidationError, WorkflowRegistrationError, WorkflowSignalError, }, - plugins::{ - ClientAndWorkerPlugin, SimplePlugin, SimplePluginBuilder, SimplePluginOption, WorkerPlugin, - WorkflowDefinitions, - }, + workflow_registry::WorkflowDefinitions, }; pub use runtime::Runtime; pub use temporalio_client::Namespace; pub use temporalio_workflow::{ ActivityCancellationType, ActivityCloseTimeouts, ActivityOptions, BaseWorkflowContext, CancellableFuture, CancellableFutureWithReason, ChildWorkflowCancellationType, - ChildWorkflowOptions, ContinueAsNewOptions, ContinueAsNewVersioningBehavior, - ExternalWorkflowHandle, LocalActivityOptions, MemoValue, NexusOperationCancellationType, - NexusOperationOptions, ParentClosePolicy, PatchActivationCallback, SignalWorkflowOptions, - StartChildWorkflowExecutionFailedCause, StartChildWorkflowOutput, StartedChildWorkflow, - StartedNexusOperation, SyncWorkflowContext, TimerOptions, TimerResult, VersioningIntent, - WaitConditionOptions, WorkflowCancellationError, WorkflowCancellationToken, WorkflowContext, - WorkflowContextView, WorkflowIdReusePolicy, WorkflowRandomValue, WorkflowResult, - WorkflowTermination, + ChildWorkflowOptions, ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, + MemoValue, ParentClosePolicy, SignalWorkflowOptions, StartChildWorkflowExecutionFailedCause, + StartChildWorkflowOutput, StartedChildWorkflow, SyncWorkflowContext, TimerOptions, TimerResult, + VersioningIntent, WaitConditionOptions, WorkflowCancellationError, WorkflowCancellationToken, + WorkflowContext, WorkflowContextView, WorkflowIdReusePolicy, WorkflowRandomValue, + WorkflowResult, WorkflowTermination, +}; +#[cfg(feature = "experimental")] +pub use temporalio_workflow::{ + ContinueAsNewVersioningBehavior, NexusOperationCancellationType, NexusOperationOptions, + PatchActivationCallback, PatchActivationInput, StartedNexusOperation, }; #[cfg(feature = "wasm-workflows")] pub use workflow_wasm::WasmWorkflowComponent; @@ -132,6 +138,8 @@ use std::{ time::Duration, }; use temporalio_client::{Client, ClientOptions, NamespacedClient}; +#[cfg(feature = "experimental")] +use temporalio_common::protos::temporal::api::worker::v1::PluginInfo; use temporalio_common::{ ActivityDefinition, WorkflowDefinition, data_converters::{ @@ -150,13 +158,14 @@ use temporalio_common::{ }, temporal::api::{ common::v1::Payload, enums::v1::WorkflowTaskFailedCause, failure::v1::Failure, - worker::v1::PluginInfo, }, }, worker::{WorkerDeploymentOptions, WorkerTaskTypes, build_id_from_current_exe}, }; use temporalio_sdk_core::{PollError, init_worker}; -use temporalio_workflow::runtime::entry::WorkflowImplementation; +use temporalio_workflow::{ + InternalPatchActivationCallback, runtime::entry::WorkflowImplementation, +}; use tokio::sync::{ Notify, mpsc::{UnboundedSender, unbounded_channel}, @@ -200,9 +209,11 @@ pub struct WorkerOptions { workflow_interceptor_constructors: Vec, #[builder(field)] + #[cfg(feature = "experimental")] worker_plugins: Vec>, #[builder(field)] + #[cfg(feature = "experimental")] client_plugin_names: HashSet, #[cfg(feature = "wasm-workflows")] @@ -303,6 +314,14 @@ pub struct WorkerOptions { /// exceed the namespace error limits; oversized payloads are sent to server, which enforces the /// limit. Defaults to false. /// NOTE: Experimental + #[cfg(feature = "experimental")] + #[cfg_attr( + docsrs, + builder(setters( + some_fn(name = disable_payload_error_limit_impl, vis = "pub(crate)"), + option_fn(name = maybe_disable_payload_error_limit_impl, vis = "pub(crate)") + )) + )] #[builder(default = false)] pub disable_payload_error_limit: bool, /// Experimental callback that decides whether the first non-replay call to @@ -312,9 +331,70 @@ pub struct WorkerOptions { /// `true` records the patch marker; returning `false` leaves the patch inactive for the /// workflow run. For registered WASM workflow components, the callback remains on the worker /// host and is invoked through the workflow component's synchronous host interface. + #[cfg(feature = "experimental")] + #[cfg_attr( + docsrs, + builder(setters( + some_fn(name = patch_activation_callback_impl, vis = "pub(crate)"), + option_fn(name = maybe_patch_activation_callback_impl, vis = "pub(crate)") + )) + )] pub patch_activation_callback: Option, } +// Bon does not propagate `doc(cfg)` to generated setters, so these docs-only methods forward to +// renamed generated implementations. +#[cfg(all(feature = "experimental", docsrs))] +impl WorkerOptionsBuilder { + /// Set whether payloads over the namespace error limit are sent to the server. + #[doc(cfg(feature = "experimental"))] + pub fn disable_payload_error_limit( + self, + value: bool, + ) -> WorkerOptionsBuilder> + where + S::DisablePayloadErrorLimit: worker_options_builder::IsUnset, + { + self.disable_payload_error_limit_impl(value) + } + + /// Set the payload error limit override from an optional value. + #[doc(cfg(feature = "experimental"))] + pub fn maybe_disable_payload_error_limit( + self, + value: Option, + ) -> WorkerOptionsBuilder> + where + S::DisablePayloadErrorLimit: worker_options_builder::IsUnset, + { + self.maybe_disable_payload_error_limit_impl(value) + } + + /// Set the callback used to decide whether a patch should activate. + #[doc(cfg(feature = "experimental"))] + pub fn patch_activation_callback( + self, + value: PatchActivationCallback, + ) -> WorkerOptionsBuilder> + where + S::PatchActivationCallback: worker_options_builder::IsUnset, + { + self.patch_activation_callback_impl(value) + } + + /// Set the patch activation callback from an optional value. + #[doc(cfg(feature = "experimental"))] + pub fn maybe_patch_activation_callback( + self, + value: Option, + ) -> WorkerOptionsBuilder> + where + S::PatchActivationCallback: worker_options_builder::IsUnset, + { + self.maybe_patch_activation_callback_impl(value) + } +} + impl WorkerOptionsBuilder { pub(crate) fn with_workflows(mut self, workflows: WorkflowDefinitions) -> Self { self.workflows = workflows; @@ -337,6 +417,7 @@ impl WorkerOptionsBuilder { self } + #[cfg(feature = "experimental")] pub(crate) fn with_worker_plugins( mut self, worker_plugins: Vec>, @@ -357,12 +438,14 @@ impl WorkerOptionsBuilder { /// Register a worker plugin. /// /// **Experimental:** This API may change or be removed. + #[cfg(feature = "experimental")] pub fn worker_plugin(mut self, plugin: P) -> Self { self.worker_plugins.push(Arc::new(plugin)); self } /// Append a worker interceptor. Interceptors run in registration order. + #[cfg(feature = "experimental")] pub fn worker_interceptor(mut self, interceptor: I) -> Self { self.worker_interceptors.push(Arc::new(interceptor)); self @@ -475,6 +558,7 @@ fn def_build_id() -> WorkerDeploymentOptions { impl WorkerOptions { /// Append a worker interceptor. Interceptors run in registration order. + #[cfg(feature = "experimental")] pub fn worker_interceptor( &mut self, interceptor: I, @@ -588,6 +672,25 @@ impl WorkerOptions { if !workflows_registered && !activities_registered { return Err("At least one workflow or activity must be registered".to_owned()); } + #[cfg(feature = "experimental")] + let disable_payload_error_limit = self.disable_payload_error_limit; + #[cfg(not(feature = "experimental"))] + let disable_payload_error_limit = false; + #[cfg(feature = "experimental")] + let plugin_info = self + .client_plugin_names + .iter() + .map(|name| PluginInfo { + name: name.clone(), + version: String::new(), + }) + .chain(self.worker_plugins.iter().map(|registration| PluginInfo { + name: registration.name().to_owned(), + version: String::new(), + })) + .collect(); + #[cfg(not(feature = "experimental"))] + let plugin_info = HashSet::new(); WorkerConfig::builder() .namespace(namespace) @@ -626,20 +729,8 @@ impl WorkerOptions { )) .workflow_failure_errors(self.workflow_failure_errors.clone()) .workflow_types_to_failure_errors(self.workflow_types_to_failure_errors.clone()) - .plugins( - self.client_plugin_names - .iter() - .map(|name| PluginInfo { - name: name.clone(), - version: String::new(), - }) - .chain(self.worker_plugins.iter().map(|registration| PluginInfo { - name: registration.name().to_owned(), - version: String::new(), - })) - .collect(), - ) - .disable_payload_error_limit(self.disable_payload_error_limit) + .plugins(plugin_info) + .disable_payload_error_limit(disable_payload_error_limit) .build() } } @@ -677,7 +768,7 @@ struct WorkflowHalf { workflow_removed_from_map: Notify, detect_nondeterministic_futures: bool, #[debug(skip)] - patch_activation_callback: Option, + patch_activation_callback: Option, } #[derive(Debug)] struct WorkflowData { @@ -783,8 +874,11 @@ impl Worker { pub fn new( runtime: &Runtime, client: Client, - mut options: WorkerOptions, + options: WorkerOptions, ) -> Result { + #[cfg(feature = "experimental")] + let mut options = options; + #[cfg(feature = "experimental")] plugins::apply_worker_plugins(client.options(), &mut options)?; let wc = options .to_core_options(client.namespace(), client.identity()) @@ -816,8 +910,11 @@ impl Worker { pub fn new_from_core_options( worker: Arc, client_options: ClientOptions, - mut options: WorkerOptions, + options: WorkerOptions, ) -> Result { + #[cfg(feature = "experimental")] + let mut options = options; + #[cfg(feature = "experimental")] plugins::apply_worker_plugins(&client_options, &mut options)?; Self::new_from_core_options_prepared(worker, client_options, options) } @@ -846,7 +943,10 @@ impl Worker { workflow_interceptor_constructors, ); me.set_detect_nondeterministic_futures(options.detect_nondeterministic_futures); - me.workflow_half.patch_activation_callback = options.patch_activation_callback; + #[cfg(feature = "experimental")] + { + me.workflow_half.patch_activation_callback = options.patch_activation_callback; + } #[cfg(feature = "wasm-workflows")] me.workflow_half .workflow_definitions @@ -1748,30 +1848,6 @@ mod tests { .unwrap(); } - #[test] - fn simple_plugin_workflow_function_merges_definitions() { - let plugin = SimplePlugin::builder("simple") - .workflows(|existing: Option| { - assert!(existing.is_some()); - let mut workflows = WorkflowDefinitions::new(); - workflows.register_workflow::().unwrap(); - workflows - }) - .build(); - let client_options = ClientOptions::new("namespace").build(); - let mut worker_options = WorkerOptions::new("task_q") - .register_workflow::() - .unwrap() - .worker_plugin(plugin) - .build(); - - crate::plugins::apply_worker_plugins(&client_options, &mut worker_options).unwrap(); - - let workflows = format!("{:?}", worker_options.workflows()); - assert!(workflows.contains("MyWorkflow")); - assert!(workflows.contains("OtherWorkflow")); - } - #[rstest::rstest] #[case::workflow_only(true, false, Ok(WorkerTaskTypes::workflow_only()))] #[case::activity_only(false, true, Ok(WorkerTaskTypes::activity_only()))] @@ -1907,24 +1983,6 @@ mod tests { assert_eq!(config.client_identity_override, expected); } - #[rstest::rstest] - #[case::default_enforces_error_limit(None, false)] - #[case::opt_out_disables_error_limit(Some(true), true)] - #[case::explicit_enable_error_limit(Some(false), false)] - #[test] - fn disable_payload_error_limit_propagates( - #[case] override_value: Option, - #[case] expected: bool, - ) { - let config = WorkerOptions::new("task_q") - .register_activities(MyActivities {}) - .maybe_disable_payload_error_limit(override_value) - .build() - .to_core_options("ns".into(), String::new()) - .unwrap(); - assert_eq!(config.disable_payload_error_limit, expected); - } - #[test] fn max_eager_activity_reservations_per_workflow_task_propagates() { let config = WorkerOptions::new("task_q") @@ -1935,4 +1993,51 @@ mod tests { .unwrap(); assert_eq!(config.max_eager_activity_reservations_per_workflow_task, 7); } + + #[cfg(feature = "experimental")] + mod experimental_tests { + use super::*; + + #[test] + fn simple_plugin_workflow_function_merges_definitions() { + let plugin = SimplePlugin::builder("simple") + .workflows(|existing: Option| { + assert!(existing.is_some()); + let mut workflows = WorkflowDefinitions::new(); + workflows.register_workflow::().unwrap(); + workflows + }) + .build(); + let client_options = ClientOptions::new("namespace").build(); + let mut worker_options = WorkerOptions::new("task_q") + .register_workflow::() + .unwrap() + .worker_plugin(plugin) + .build(); + + crate::plugins::apply_worker_plugins(&client_options, &mut worker_options).unwrap(); + + let workflows = format!("{:?}", worker_options.workflows()); + assert!(workflows.contains("MyWorkflow")); + assert!(workflows.contains("OtherWorkflow")); + } + + #[rstest::rstest] + #[case::default_enforces_error_limit(None, false)] + #[case::opt_out_disables_error_limit(Some(true), true)] + #[case::explicit_enable_error_limit(Some(false), false)] + #[test] + fn disable_payload_error_limit_propagates( + #[case] override_value: Option, + #[case] expected: bool, + ) { + let config = WorkerOptions::new("task_q") + .register_activities(MyActivities {}) + .maybe_disable_payload_error_limit(override_value) + .build() + .to_core_options("ns".into(), String::new()) + .unwrap(); + assert_eq!(config.disable_payload_error_limit, expected); + } + } } diff --git a/crates/sdk/src/plugins.rs b/crates/sdk/src/plugins.rs index 7aa851a91..510973643 100644 --- a/crates/sdk/src/plugins.rs +++ b/crates/sdk/src/plugins.rs @@ -454,7 +454,7 @@ pub(crate) fn apply_workflow_replayer_plugins( Ok(()) } -#[cfg(test)] +#[cfg(all(test, feature = "experimental"))] mod tests { use super::*; use std::{ diff --git a/crates/sdk/src/workflow_future.rs b/crates/sdk/src/workflow_future.rs index d6920870d..c9b638344 100644 --- a/crates/sdk/src/workflow_future.rs +++ b/crates/sdk/src/workflow_future.rs @@ -34,7 +34,7 @@ use temporalio_common::{ }, }; use temporalio_workflow::{ - PatchActivationCallback, + InternalPatchActivationCallback as PatchActivationCallback, runtime::{ guest::WorkflowInstance, host::WorkflowHost, diff --git a/crates/sdk/src/workflow_registry.rs b/crates/sdk/src/workflow_registry.rs index e1b57d9af..f9f2568cf 100644 --- a/crates/sdk/src/workflow_registry.rs +++ b/crates/sdk/src/workflow_registry.rs @@ -12,7 +12,7 @@ use temporalio_common::{ }, }; use temporalio_workflow::{ - BaseWorkflowContext, PatchActivationCallback, + BaseWorkflowContext, InternalPatchActivationCallback as PatchActivationCallback, runtime::{ entry::WorkflowImplementation, guest::WorkflowInstance, @@ -75,6 +75,8 @@ pub struct WorkflowDefinitions { } impl WorkflowDefinitions { + // Only used by Plugins so feature flagged to avoid dead code. + #[cfg(feature = "experimental")] pub(crate) fn extend(&mut self, other: &Self) -> Result<(), WorkflowRegistrationError> { for workflow in other.workflows.values() { self.insert_workflow(workflow.definition.clone(), workflow.factory.clone())?; diff --git a/crates/sdk/src/workflow_replayer.rs b/crates/sdk/src/workflow_replayer.rs index 57732ca65..f2df36456 100644 --- a/crates/sdk/src/workflow_replayer.rs +++ b/crates/sdk/src/workflow_replayer.rs @@ -1,7 +1,8 @@ +#[cfg(feature = "experimental")] +use crate::plugins::WorkerPlugin; use crate::{ Worker, WorkerOptions, WorkerRunError, interceptors::{self, Next, WithWorkflowReplayWorkerInput, WorkerInterceptor}, - plugins::WorkerPlugin, runtime::WorkflowErrorType, workflow_interceptors::WorkflowInterceptorConstructor, workflow_registry::{WorkflowDefinitions, WorkflowRegistrationError}, @@ -12,9 +13,9 @@ use std::{ collections::{HashMap, HashSet}, sync::Arc, }; -use temporalio_client::{ - ClientOptions, PluginApplyError, WorkflowHistory, errors::WorkflowInteractionError, -}; +#[cfg(feature = "experimental")] +use temporalio_client::PluginApplyError; +use temporalio_client::{ClientOptions, WorkflowHistory, errors::WorkflowInteractionError}; use temporalio_common::{ WorkflowDefinition, data_converters::DataConverter, @@ -30,7 +31,7 @@ use temporalio_sdk_core::{ init_replay_worker, replay::{HistoryForReplay, ReplayWorkerInput}, }; -use temporalio_workflow::{PatchActivationCallback, runtime::entry::WorkflowImplementation}; +use temporalio_workflow::runtime::entry::WorkflowImplementation; #[cfg(feature = "wasm-workflows")] use crate::WasmWorkflowComponent; @@ -54,6 +55,7 @@ pub struct WorkflowReplayerOptions { pub(super) workflow_interceptor_constructors: Vec, #[builder(field)] + #[cfg(feature = "experimental")] pub(super) worker_plugins: Vec>, #[cfg(feature = "wasm-workflows")] @@ -83,21 +85,20 @@ pub struct WorkflowReplayerOptions { /// Whether to detect nondeterministic future usage in workflow code. #[builder(default = true)] pub detect_nondeterministic_futures: bool, - - /// Callback controlling first non-replay patch decisions. - pub patch_activation_callback: Option, } impl WorkflowReplayerOptionsBuilder { /// Register a worker plugin with this replayer. /// /// **Experimental:** This API may change or be removed. + #[cfg(feature = "experimental")] pub fn worker_plugin(mut self, plugin: P) -> Self { self.worker_plugins.push(Arc::new(plugin)); self } /// Append a worker interceptor used during replay. + #[cfg(feature = "experimental")] pub fn worker_interceptor(mut self, interceptor: I) -> Self { self.worker_interceptors.push(Arc::new(interceptor)); self @@ -162,6 +163,7 @@ impl WorkflowReplayerOptionsBuilder impl WorkflowReplayerOptions { /// Append a worker interceptor used during replay. + #[cfg(feature = "experimental")] pub fn worker_interceptor( &mut self, interceptor: I, @@ -317,6 +319,7 @@ pub enum WorkflowReplayError { #[non_exhaustive] pub enum WorkflowReplayWorkerError { /// A plugin failed while configuring replay options. + #[cfg(feature = "experimental")] #[error(transparent)] Plugin(#[from] PluginApplyError), /// No workflow definitions were registered after plugin configuration. @@ -346,7 +349,10 @@ pub struct WorkflowReplayer { impl WorkflowReplayer { /// Construct a replayer and apply its worker plugins. - pub fn new(mut options: WorkflowReplayerOptions) -> Result { + pub fn new(options: WorkflowReplayerOptions) -> Result { + #[cfg(feature = "experimental")] + let mut options = options; + #[cfg(feature = "experimental")] crate::plugins::apply_workflow_replayer_plugins(&mut options) .map_err(WorkflowReplayWorkerError::Plugin)?; if options.workflows.is_empty() { @@ -479,11 +485,12 @@ impl WorkflowReplayer { .with_workflow_interceptor_constructors( self.options.workflow_interceptor_constructors.clone(), ) - .with_worker_plugins(self.options.worker_plugins.clone()) .workflow_failure_errors(self.options.workflow_failure_errors.clone()) .workflow_types_to_failure_errors(self.options.workflow_types_to_failure_errors.clone()) - .detect_nondeterministic_futures(self.options.detect_nondeterministic_futures) - .maybe_patch_activation_callback(self.options.patch_activation_callback.clone()); + .detect_nondeterministic_futures(self.options.detect_nondeterministic_futures); + #[cfg(feature = "experimental")] + let worker_options = + worker_options.with_worker_plugins(self.options.worker_plugins.clone()); #[cfg(feature = "wasm-workflows")] let worker_options = worker_options .with_wasm_workflow_components(self.options.wasm_workflow_components.clone()); diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index f2bb9fda9..122357f9d 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -10,6 +10,12 @@ repository = "https://github.com/temporalio/sdk-core" keywords = ["temporal", "workflow"] categories = ["development-tools"] +[package.metadata.docs.rs] +features = ["experimental"] + +[features] +experimental = [] + [dependencies] anyhow = "1.0" bon = { workspace = true } diff --git a/crates/workflow/src/component.rs b/crates/workflow/src/component.rs index b7663945d..3619e96dd 100644 --- a/crates/workflow/src/component.rs +++ b/crates/workflow/src/component.rs @@ -2,7 +2,7 @@ //! //! Everything in this module is internal SDK/component glue. use crate::{ - BaseWorkflowContext, PatchActivationCallback, + BaseWorkflowContext, InternalPatchActivationCallback as PatchActivationCallback, runtime::{ entry::WorkflowImplementation, guest::WorkflowInstance as RuntimeWorkflowInstance, diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index bb49ecebd..285ecd70e 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] #![warn(missing_docs)] //! Temporal workflow authoring APIs and runtime glue. @@ -39,16 +40,21 @@ pub use temporalio_common_wasm::{ pub use workflow_context::{ ActivityCancellationType, ActivityOptions, BaseWorkflowContext, CancellableFuture, CancellableFutureWithReason, ChildWorkflowCancellationType, ChildWorkflowOptions, - ContinueAsNewOptions, ContinueAsNewVersioningBehavior, ExternalWorkflowHandle, - LocalActivityOptions, NamespacedWorkflowInfo, NexusOperationCancellationType, - NexusOperationOptions, ParentClosePolicy, SignalWorkflowOptions, - StartChildWorkflowExecutionFailedCause, StartChildWorkflowOutput, StartedChildWorkflow, - StartedNexusOperation, SyncWorkflowContext, TimerOptions, VersioningIntent, - WaitConditionOptions, WorkflowContext, WorkflowContextView, WorkflowIdReusePolicy, - WorkflowRandomStream, WorkflowRandomValue, + ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, NamespacedWorkflowInfo, + ParentClosePolicy, SignalWorkflowOptions, StartChildWorkflowExecutionFailedCause, + StartChildWorkflowOutput, StartedChildWorkflow, SyncWorkflowContext, TimerOptions, + VersioningIntent, WaitConditionOptions, WorkflowContext, WorkflowContextView, + WorkflowIdReusePolicy, WorkflowRandomStream, WorkflowRandomValue, +}; +#[cfg(feature = "experimental")] +pub use workflow_context::{ + ContinueAsNewVersioningBehavior, NexusOperationCancellationType, NexusOperationOptions, + PatchActivationCallback, PatchActivationInput, StartedNexusOperation, }; #[doc(hidden)] -pub use workflow_context::{PatchActivationCallback, PatchActivationCaller}; +pub use workflow_context::{ + PatchActivationCallback as InternalPatchActivationCallback, PatchActivationCaller, +}; pub use workflows::{join, join_all, select}; #[macro_export] diff --git a/crates/workflow/src/runtime/model.rs b/crates/workflow/src/runtime/model.rs index 960f6f92f..8ef984baa 100644 --- a/crates/workflow/src/runtime/model.rs +++ b/crates/workflow/src/runtime/model.rs @@ -1,11 +1,14 @@ //! Runtime protocol and execution model types shared by workflow code and native hosts. +#[cfg(feature = "experimental")] +mod nexus; +#[cfg(feature = "experimental")] +pub(crate) use nexus::NexusStartResult; + use crate::{ WorkflowCancellationError, runtime::types::ContinueAsNewRequest, - workflow_context::{ - ChildWfCommon, NexusUnblockData, PendingChildWorkflow, StartedNexusOperation, - }, + workflow_context::{ChildWfCommon, PendingChildWorkflow}, workflow_interceptors::WorkflowOutputValue, }; use temporalio_common_wasm::{ @@ -142,53 +145,6 @@ impl Unblockable for CancelExternalWfResult { } } -pub(crate) type NexusStartResult = Result; - -impl Unblockable for NexusStartResult { - type OtherDat = NexusUnblockData; - - fn unblock(ue: UnblockEvent, od: Self::OtherDat) -> Self { - let NexusUnblockData { - result_future, - schedule_seq, - base_ctx, - } = od; - match ue { - UnblockEvent::NexusOperationStart(_, result) => match *result { - resolve_nexus_operation_start::Status::OperationToken(op_token) => { - Ok(StartedNexusOperation { - operation_token: Some(op_token), - result_future, - schedule_seq, - base_ctx, - }) - } - resolve_nexus_operation_start::Status::StartedSync(_) => { - Ok(StartedNexusOperation { - operation_token: None, - result_future, - schedule_seq, - base_ctx, - }) - } - resolve_nexus_operation_start::Status::Failed(f) => Err(f), - }, - _ => panic!("Invalid unblock event for nexus operation"), - } - } -} - -impl Unblockable for NexusOperationResult { - type OtherDat = (); - - fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { - match ue { - UnblockEvent::NexusOperationComplete(_, result) => *result, - _ => panic!("Invalid unblock event for nexus operation complete"), - } - } -} - #[derive(Debug, Clone)] pub enum CancellableID { Timer(u32), diff --git a/crates/workflow/src/runtime/model/nexus.rs b/crates/workflow/src/runtime/model/nexus.rs new file mode 100644 index 000000000..31801f483 --- /dev/null +++ b/crates/workflow/src/runtime/model/nexus.rs @@ -0,0 +1,49 @@ +use super::*; +use crate::workflow_context::{NexusUnblockData, StartedNexusOperation}; + +pub(crate) type NexusStartResult = Result; + +impl Unblockable for NexusStartResult { + type OtherDat = NexusUnblockData; + + fn unblock(ue: UnblockEvent, od: Self::OtherDat) -> Self { + let NexusUnblockData { + result_future, + schedule_seq, + base_ctx, + } = od; + match ue { + UnblockEvent::NexusOperationStart(_, result) => match *result { + resolve_nexus_operation_start::Status::OperationToken(op_token) => { + Ok(StartedNexusOperation { + operation_token: Some(op_token), + result_future, + schedule_seq, + base_ctx, + }) + } + resolve_nexus_operation_start::Status::StartedSync(_) => { + Ok(StartedNexusOperation { + operation_token: None, + result_future, + schedule_seq, + base_ctx, + }) + } + resolve_nexus_operation_start::Status::Failed(f) => Err(f), + }, + _ => panic!("Invalid unblock event for nexus operation"), + } + } +} + +impl Unblockable for NexusOperationResult { + type OtherDat = (); + + fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { + match ue { + UnblockEvent::NexusOperationComplete(_, result) => *result, + _ => panic!("Invalid unblock event for nexus operation complete"), + } + } +} diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 97722007e..8f2c2a2e5 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -1,12 +1,20 @@ +#[cfg(feature = "experimental")] +mod nexus; mod options; mod view; +#[cfg(feature = "experimental")] +pub(crate) use nexus::NexusUnblockData; +#[cfg(feature = "experimental")] +pub use nexus::StartedNexusOperation; pub use options::{ ActivityCancellationType, ActivityOptions, ChildWorkflowCancellationType, ChildWorkflowOptions, - ContinueAsNewOptions, ContinueAsNewVersioningBehavior, LocalActivityOptions, - NexusOperationCancellationType, NexusOperationOptions, ParentClosePolicy, - SignalWorkflowOptions, TimerOptions, VersioningIntent, WaitConditionOptions, - WorkflowIdReusePolicy, + ContinueAsNewOptions, LocalActivityOptions, ParentClosePolicy, SignalWorkflowOptions, + TimerOptions, VersioningIntent, WaitConditionOptions, WorkflowIdReusePolicy, +}; +#[cfg(feature = "experimental")] +pub use options::{ + ContinueAsNewVersioningBehavior, NexusOperationCancellationType, NexusOperationOptions, }; pub use temporalio_common_wasm::protos::coresdk::child_workflow::StartChildWorkflowExecutionFailedCause; pub use view::{NamespacedWorkflowInfo, WorkflowContextView}; @@ -14,13 +22,13 @@ pub use view::{NamespacedWorkflowInfo, WorkflowContextView}; use crate::{ MemoValue, WorkflowCancellationError, WorkflowCancellationToken, runtime::{ - SdkGuardedFuture, SdkWakeGuard, + SdkWakeGuard, entry::WorkflowImplementation, host::WorkflowHost, mark_intercepted_future_activation, model::{ - CancelExternalWfResult, CancellableID, NexusStartResult, SignalExternalWfResult, - TimerResult, UnblockEvent, Unblockable, WorkflowTermination, + CancelExternalWfResult, CancellableID, SignalExternalWfResult, TimerResult, + UnblockEvent, Unblockable, WorkflowTermination, }, types::WorkflowInit, }, @@ -28,21 +36,16 @@ use crate::{ CancelExternalWorkflowInput, CancellableWorkflowOutboundFuture, ChildWorkflowOutboundResult, ContinueAsNewInput, ScheduleActivityInput, ScheduleLocalActivityInput, SignalWorkflowInput, SignalWorkflowResult, - SignalWorkflowTarget, StartChildWorkflowInput, StartChildWorkflowResult, - StartNexusOperationInput, StartTimerInput, WorkflowCancellationHandle, WorkflowInterceptor, - WorkflowInterceptorConstructor, WorkflowInterceptorContext, WorkflowNext, - WorkflowOutboundFuture, WorkflowOutboundValue, call_cancel_external_workflow, - call_continue_as_new, call_schedule_activity, call_schedule_local_activity, - call_signal_workflow, call_start_child_workflow, call_start_nexus_operation, + SignalWorkflowTarget, StartChildWorkflowInput, StartChildWorkflowResult, StartTimerInput, + WorkflowCancellationHandle, WorkflowInterceptor, WorkflowInterceptorConstructor, + WorkflowInterceptorContext, WorkflowNext, WorkflowOutboundFuture, WorkflowOutboundValue, + call_cancel_external_workflow, call_continue_as_new, call_schedule_activity, + call_schedule_local_activity, call_signal_workflow, call_start_child_workflow, call_start_timer, }, }; use futures_channel::oneshot; -use futures_util::{ - FutureExt, - future::{FusedFuture, Shared}, - task::Context, -}; +use futures_util::{FutureExt, future::FusedFuture, task::Context}; use rand::SeedableRng; use rand_pcg::Pcg64Mcg; use siphasher::sip::SipHasher13; @@ -78,7 +81,6 @@ use temporalio_common_wasm::{ activity_result::{ActivityResolution, Cancellation, activity_resolution}, child_workflow::{ChildWorkflowResult, child_workflow_result}, common::NamespacedWorkflowExecution, - nexus::NexusOperationResult, workflow_activation::{ InitializeWorkflow, WorkflowActivation as CoreWorkflowActivation, resolve_child_workflow_execution_start::Status as ChildWorkflowStartStatus, @@ -710,6 +712,7 @@ impl BaseWorkflowContext { next_child_workflow_sequence_number: 1, next_cancel_external_wf_sequence_number: 1, next_signal_external_wf_sequence_number: 1, + #[cfg(feature = "experimental")] next_nexus_op_sequence_number: 1, }), data_converter, @@ -1382,61 +1385,6 @@ impl BaseWorkflowContext { ); self.prepare_outbound_future(future) } - - pub(crate) fn start_nexus_operation( - &self, - opts: NexusOperationOptions, - ) -> impl CancellableFuture { - let input = StartNexusOperationInput::new(opts); - let base_ctx = self.clone(); - let next = WorkflowNext::new(move |input: StartNexusOperationInput| { - let mut opts = input.into_options(); - let cancellation_token = opts - .cancellation_token - .take() - .unwrap_or_else(|| base_ctx.cancellation_token()); - let seq = base_ctx.inner.seq_nums.borrow_mut().next_nexus_op_seq(); - let (result_future, unblocker) = - CancellableWFCommandFut::new(CancellableID::NexusOp(seq), base_ctx.clone()); - base_ctx - .inner - .runtime - .register_unblocker(PendingCommandId::NexusOpComplete(seq), unblocker); - base_ctx - .inner - .runtime - .host - .push_command(opts.into_command(seq)); - let result_future = CancellableWorkflowOutboundFuture::new( - result_future, - base_ctx.cancellation_handle(CancellableID::NexusOp(seq)), - ) - .with_cancellation_token(cancellation_token) - .shared(); - let (cmd, unblocker) = CancellableWFCommandFut::new_with_dat( - CancellableID::NexusOp(seq), - NexusUnblockData { - result_future: result_future.clone(), - schedule_seq: seq, - base_ctx: base_ctx.clone(), - }, - base_ctx.clone(), - ); - base_ctx - .inner - .runtime - .register_unblocker(PendingCommandId::NexusOpStart(seq), unblocker); - cancellable_outbound(cmd) - }); - let interceptors = self.inner.workflow_interceptors.clone(); - let future = call_start_nexus_operation( - interceptors, - WorkflowInterceptorContext::new(self.clone()), - input, - next, - ); - self.prepare_cancellable_outbound_future(future) - } } impl SyncWorkflowContext { @@ -1571,6 +1519,7 @@ impl SyncWorkflowContext { /// Returns true if the workflow's target worker deployment version changed. /// /// This experimental signal is intended for workers using worker deployment versioning. + #[cfg(feature = "experimental")] pub fn target_worker_deployment_version_changed(&self) -> bool { self.base .inner @@ -1917,14 +1866,6 @@ impl SyncWorkflowContext { self.base.inner.runtime.set_forced_wft_failure(with.into()); } - /// Start a nexus operation - pub fn start_nexus_operation( - &self, - opts: NexusOperationOptions, - ) -> impl CancellableFuture { - self.base.start_nexus_operation(opts) - } - /// Create a read-only view of this context. pub(crate) fn view(&self) -> WorkflowContextView { self.base.view() @@ -2079,6 +2020,7 @@ impl WorkflowContext { /// Returns true if the workflow's target worker deployment version changed. /// /// This experimental signal is intended for workers using worker deployment versioning. + #[cfg(feature = "experimental")] pub fn target_worker_deployment_version_changed(&self) -> bool { self.sync.target_worker_deployment_version_changed() } @@ -2256,14 +2198,6 @@ impl WorkflowContext { self.sync.force_task_fail(with) } - /// Start a nexus operation - pub fn start_nexus_operation( - &self, - opts: NexusOperationOptions, - ) -> impl CancellableFuture { - self.sync.start_nexus_operation(opts) - } - /// Access workflow state immutably via closure. /// /// The borrow is scoped to the closure and cannot escape, preventing @@ -2352,6 +2286,7 @@ struct WfCtxProtectedDat { next_child_workflow_sequence_number: u32, next_cancel_external_wf_sequence_number: u32, next_signal_external_wf_sequence_number: u32, + #[cfg(feature = "experimental")] next_nexus_op_sequence_number: u32, } @@ -2381,11 +2316,6 @@ impl WfCtxProtectedDat { self.next_signal_external_wf_sequence_number += 1; seq } - fn next_nexus_op_seq(&mut self) -> u32 { - let seq = self.next_nexus_op_sequence_number; - self.next_nexus_op_sequence_number += 1; - seq - } } #[derive(Clone, Debug)] @@ -2692,6 +2622,7 @@ impl Future for LATimerBackoffFut { .expect("duration converts ok"), cancellation_token: Some(self.cancellation_token.clone()), summary: None, + #[cfg(feature = "experimental")] event_group_markers: self.la_opts.event_group_markers.clone(), }); self.timer_fut = Some(Box::pin(timer_f)); @@ -3349,41 +3280,6 @@ impl ExternalWorkflowHandle { } } -#[derive(derive_more::Debug)] -#[debug("StartedNexusOperation{{ operation_token: {operation_token:?} }}")] -/// Handle to a started Nexus operation. -pub struct StartedNexusOperation { - /// The operation token, if the operation started asynchronously - pub operation_token: Option, - #[debug(skip)] - pub(crate) result_future: Shared>, - pub(crate) schedule_seq: u32, - #[debug(skip)] - pub(crate) base_ctx: BaseWorkflowContext, -} - -pub(crate) struct NexusUnblockData { - pub(crate) result_future: Shared>, - pub(crate) schedule_seq: u32, - pub(crate) base_ctx: BaseWorkflowContext, -} - -impl StartedNexusOperation { - /// Wait for the operation result. - pub async fn result(&self) -> NexusOperationResult { - // The result future is a `Shared`; poll it inside an `SdkWakeGuard` (via - // `SdkGuardedFuture`) so its internal waker machinery isn't mistaken for a non-SDK wake on - // replay (which would fail the workflow task with TMPRL1100). - SdkGuardedFuture(self.result_future.clone()).await - } - - /// Request cancellation of the operation. - pub fn cancel(&self) { - self.base_ctx - .cancel(CancellableID::NexusOp(self.schedule_seq)); - } -} - #[cfg(test)] mod tests { use super::*; @@ -3398,23 +3294,18 @@ mod tests { time::Duration, }; use temporalio_common_wasm::{ - RetryPolicy, data_converters::{TemporalDeserializable, TemporalSerializable}, error::OutgoingWorkflowError, protos::{ coresdk::{ AsJsonPayloadExt, FromJsonPayloadExt, common::VersioningIntent as ProtoVersioningIntent, - workflow_activation::{ - ResolveChildWorkflowExecutionStartSuccess, UpdateRandomSeed, - WorkflowActivationJob, resolve_nexus_operation_start, - }, + workflow_activation::{UpdateRandomSeed, WorkflowActivationJob}, workflow_commands::WorkflowCommand, }, temporal::api::{ - common::v1::{Payload, RetryPolicy as ProtoRetryPolicy}, + common::v1::Payload, enums::v1::ContinueAsNewVersioningBehavior as ProtoContinueAsNewVersioningBehavior, - sdk::v1::{EventGroupMarker, event_group_marker}, }, }, }; @@ -3487,17 +3378,6 @@ mod tests { } } - struct TestActivity; - - impl ActivityDefinition for TestActivity { - type Input = (); - type Output = (); - - fn name(&self) -> &str { - "test_activity" - } - } - fn test_context() -> WorkflowContext { test_context_with_seed(0) } @@ -3619,295 +3499,319 @@ mod tests { assert_eq!(timer.seq, 1); } - #[test] - fn custom_token_cancels_command_backed_operations() { - let host = Rc::new(RecordingHost::default()); - let init = WorkflowInit { - namespace: "default".to_string(), - task_queue: "task-queue".to_string(), - run_id: "run-id".to_string(), - initialize_workflow: InitializeWorkflow { - workflow_type: TestWorkflow.name().to_string(), - ..Default::default() + #[cfg(feature = "experimental")] + mod experimental_operation_tests { + use super::*; + use temporalio_common_wasm::protos::{ + coresdk::workflow_activation::{ + ResolveChildWorkflowExecutionStartSuccess, resolve_nexus_operation_start, }, + temporal::api::sdk::v1::{EventGroupMarker, event_group_marker}, }; - let base = BaseWorkflowContext::from_raw( - init, - DataConverter::default(), - host.clone(), - None, - Vec::new(), - ); - let token = WorkflowCancellationToken::new(); - let timer = base.timer(TimerOptions { - duration: Duration::from_secs(1), - cancellation_token: Some(token.clone()), - summary: None, - event_group_markers: vec![], - }); + struct TestActivity; - let mut activity_options = ActivityOptions::start_to_close_timeout(Duration::from_secs(1)); - activity_options.cancellation_token = Some(token.clone()); - let activity = base.execute_activity(TestActivity, (), activity_options); + impl ActivityDefinition for TestActivity { + type Input = (); + type Output = (); - let mut local_activity_options = LocalActivityOptions { - schedule_to_close_timeout: Some(Duration::from_secs(1)), - ..Default::default() - }; - local_activity_options.cancellation_token = Some(token.clone()); - let local_activity = base.execute_local_activity(TestActivity, (), local_activity_options); + fn name(&self) -> &str { + "test_activity" + } + } - let child_options = ChildWorkflowOptions { - cancellation_token: Some(token.clone()), - ..Default::default() - }; - let child = base.start_child_workflow(TestWorkflow::run, 1, child_options); + #[test] + fn custom_token_cancels_command_backed_operations() { + let host = Rc::new(RecordingHost::default()); + let init = WorkflowInit { + namespace: "default".to_string(), + task_queue: "task-queue".to_string(), + run_id: "run-id".to_string(), + initialize_workflow: InitializeWorkflow { + workflow_type: TestWorkflow.name().to_string(), + ..Default::default() + }, + }; + let base = BaseWorkflowContext::from_raw( + init, + DataConverter::default(), + host.clone(), + None, + Vec::new(), + ); + let token = WorkflowCancellationToken::new(); - let signal = base.external_workflow("external", None).signal( - TestWorkflow::test_signal, - "input".to_string(), - SignalWorkflowOptions::builder() - .cancellation_token(token.clone()) - .build(), - ); + let timer = base.timer(TimerOptions { + duration: Duration::from_secs(1), + cancellation_token: Some(token.clone()), + summary: None, + event_group_markers: vec![], + }); - let nexus_options = NexusOperationOptions::builder() - .endpoint("endpoint") - .service("service") - .operation("operation") - .cancellation_token(token.clone()) - .build(); - let nexus = base.start_nexus_operation(nexus_options); - - token.cancel_with_reason("group cancelled"); - timer.cancel(); - activity.cancel(); - local_activity.cancel(); - child.cancel_with_reason("explicit cancellation".to_string()); - signal.cancel(); - nexus.cancel(); + let mut activity_options = + ActivityOptions::start_to_close_timeout(Duration::from_secs(1)); + activity_options.cancellation_token = Some(token.clone()); + let activity = base.execute_activity(TestActivity, (), activity_options); - let commands = host.commands.borrow(); - assert_eq!( - commands - .iter() - .filter(|command| matches!( - &command.variant, - Some(workflow_command::Variant::CancelTimer(_)) - )) - .count(), - 1 - ); - assert_eq!( - commands - .iter() - .filter(|command| matches!( - &command.variant, - Some(workflow_command::Variant::RequestCancelActivity(_)) - )) - .count(), - 1 - ); - assert_eq!( - commands - .iter() - .filter(|command| matches!( - &command.variant, - Some(workflow_command::Variant::RequestCancelLocalActivity(_)) - )) - .count(), - 1 - ); - let child_cancellations = commands - .iter() - .filter_map(|command| match &command.variant { - Some(workflow_command::Variant::CancelChildWorkflowExecution(cancel)) => { - Some(cancel) - } - _ => None, - }) - .collect::>(); - assert_eq!(child_cancellations.len(), 1); - assert_eq!(child_cancellations[0].reason, "group cancelled"); - assert_eq!( - commands - .iter() - .filter(|command| matches!( - &command.variant, - Some(workflow_command::Variant::CancelSignalWorkflow(_)) - )) - .count(), - 1 - ); - assert_eq!( - commands - .iter() - .filter(|command| matches!( - &command.variant, - Some(workflow_command::Variant::RequestCancelNexusOperation(_)) - )) - .count(), - 1 - ); - } + let mut local_activity_options = LocalActivityOptions { + schedule_to_close_timeout: Some(Duration::from_secs(1)), + ..Default::default() + }; + local_activity_options.cancellation_token = Some(token.clone()); + let local_activity = + base.execute_local_activity(TestActivity, (), local_activity_options); - #[test] - fn child_and_nexus_tokens_remain_active_after_start() { - let host = Rc::new(RecordingHost::default()); - let init = WorkflowInit { - namespace: "default".to_string(), - task_queue: "task-queue".to_string(), - run_id: "run-id".to_string(), - initialize_workflow: InitializeWorkflow { - workflow_type: TestWorkflow.name().to_string(), + let child_options = ChildWorkflowOptions { + cancellation_token: Some(token.clone()), ..Default::default() - }, - }; - let base = BaseWorkflowContext::from_raw( - init, - DataConverter::default(), - host.clone(), - None, - Vec::new(), - ); + }; + let child = base.start_child_workflow(TestWorkflow::run, 1, child_options); - let child_token = WorkflowCancellationToken::new(); - let child_options = ChildWorkflowOptions { - cancellation_token: Some(child_token.clone()), - ..Default::default() - }; - let child = base.start_child_workflow(TestWorkflow::run, 1, child_options); - base.unblock(UnblockEvent::WorkflowStart( - 1, - Box::new(ChildWorkflowStartStatus::Succeeded( - ResolveChildWorkflowExecutionStartSuccess { - run_id: "child-run".to_string(), - }, - )), - )) - .unwrap(); - let started_child = child - .now_or_never() - .expect("child start should resolve") - .unwrap(); - child_token.cancel(); - started_child.cancel("explicit cancellation".to_string()); - - let nexus_token = WorkflowCancellationToken::new(); - let nexus_options = NexusOperationOptions::builder() - .endpoint("endpoint") - .service("service") - .operation("operation") - .cancellation_token(nexus_token.clone()) - .build(); - let nexus = base.start_nexus_operation(nexus_options); - base.unblock(UnblockEvent::NexusOperationStart( - 1, - Box::new(resolve_nexus_operation_start::Status::OperationToken( - "operation-token".to_string(), - )), - )) - .unwrap(); - let started_nexus = nexus - .now_or_never() - .expect("Nexus start should resolve") - .unwrap(); - nexus_token.cancel(); - started_nexus.cancel(); + let signal = base.external_workflow("external", None).signal( + TestWorkflow::test_signal, + "input".to_string(), + SignalWorkflowOptions::builder() + .cancellation_token(token.clone()) + .build(), + ); - let commands = host.commands.borrow(); - assert_eq!( - commands - .iter() - .filter(|command| matches!( - &command.variant, - Some(workflow_command::Variant::CancelChildWorkflowExecution(_)) - )) - .count(), - 1 - ); - assert_eq!( - commands + let nexus_options = NexusOperationOptions::builder() + .endpoint("endpoint") + .service("service") + .operation("operation") + .cancellation_token(token.clone()) + .build(); + let nexus = base.start_nexus_operation(nexus_options); + + token.cancel_with_reason("group cancelled"); + timer.cancel(); + activity.cancel(); + local_activity.cancel(); + child.cancel_with_reason("explicit cancellation".to_string()); + signal.cancel(); + nexus.cancel(); + + let commands = host.commands.borrow(); + assert_eq!( + commands + .iter() + .filter(|command| matches!( + &command.variant, + Some(workflow_command::Variant::CancelTimer(_)) + )) + .count(), + 1 + ); + assert_eq!( + commands + .iter() + .filter(|command| matches!( + &command.variant, + Some(workflow_command::Variant::RequestCancelActivity(_)) + )) + .count(), + 1 + ); + assert_eq!( + commands + .iter() + .filter(|command| matches!( + &command.variant, + Some(workflow_command::Variant::RequestCancelLocalActivity(_)) + )) + .count(), + 1 + ); + let child_cancellations = commands .iter() - .filter(|command| matches!( - &command.variant, - Some(workflow_command::Variant::RequestCancelNexusOperation(_)) - )) - .count(), - 1 - ); - } + .filter_map(|command| match &command.variant { + Some(workflow_command::Variant::CancelChildWorkflowExecution(cancel)) => { + Some(cancel) + } + _ => None, + }) + .collect::>(); + assert_eq!(child_cancellations.len(), 1); + assert_eq!(child_cancellations[0].reason, "group cancelled"); + assert_eq!( + commands + .iter() + .filter(|command| matches!( + &command.variant, + Some(workflow_command::Variant::CancelSignalWorkflow(_)) + )) + .count(), + 1 + ); + assert_eq!( + commands + .iter() + .filter(|command| matches!( + &command.variant, + Some(workflow_command::Variant::RequestCancelNexusOperation(_)) + )) + .count(), + 1 + ); + } - #[test] - fn local_activity_token_cancels_retry_backoff_timer() { - let host = Rc::new(RecordingHost::default()); - let init = WorkflowInit { - namespace: "default".to_string(), - task_queue: "task-queue".to_string(), - run_id: "run-id".to_string(), - initialize_workflow: InitializeWorkflow { - workflow_type: TestWorkflow.name().to_string(), + #[test] + fn child_and_nexus_tokens_remain_active_after_start() { + let host = Rc::new(RecordingHost::default()); + let init = WorkflowInit { + namespace: "default".to_string(), + task_queue: "task-queue".to_string(), + run_id: "run-id".to_string(), + initialize_workflow: InitializeWorkflow { + workflow_type: TestWorkflow.name().to_string(), + ..Default::default() + }, + }; + let base = BaseWorkflowContext::from_raw( + init, + DataConverter::default(), + host.clone(), + None, + Vec::new(), + ); + + let child_token = WorkflowCancellationToken::new(); + let child_options = ChildWorkflowOptions { + cancellation_token: Some(child_token.clone()), ..Default::default() - }, - }; - let base = BaseWorkflowContext::from_raw( - init, - DataConverter::default(), - host.clone(), - None, - Vec::new(), - ); - let token = WorkflowCancellationToken::new(); - let marker = EventGroupMarker { - variant: Some(event_group_marker::Variant::Label( - event_group_marker::Label { - id: "la-group".to_string(), - label: Some("la-group".as_json_payload().unwrap()), + }; + let child = base.start_child_workflow(TestWorkflow::run, 1, child_options); + base.unblock(UnblockEvent::WorkflowStart( + 1, + Box::new(ChildWorkflowStartStatus::Succeeded( + ResolveChildWorkflowExecutionStartSuccess { + run_id: "child-run".to_string(), + }, + )), + )) + .unwrap(); + let started_child = child + .now_or_never() + .expect("child start should resolve") + .unwrap(); + child_token.cancel(); + started_child.cancel("explicit cancellation".to_string()); + + let nexus_token = WorkflowCancellationToken::new(); + let nexus_options = NexusOperationOptions::builder() + .endpoint("endpoint") + .service("service") + .operation("operation") + .cancellation_token(nexus_token.clone()) + .build(); + let nexus = base.start_nexus_operation(nexus_options); + base.unblock(UnblockEvent::NexusOperationStart( + 1, + Box::new(resolve_nexus_operation_start::Status::OperationToken( + "operation-token".to_string(), + )), + )) + .unwrap(); + let started_nexus = nexus + .now_or_never() + .expect("Nexus start should resolve") + .unwrap(); + nexus_token.cancel(); + started_nexus.cancel(); + + let commands = host.commands.borrow(); + assert_eq!( + commands + .iter() + .filter(|command| matches!( + &command.variant, + Some(workflow_command::Variant::CancelChildWorkflowExecution(_)) + )) + .count(), + 1 + ); + assert_eq!( + commands + .iter() + .filter(|command| matches!( + &command.variant, + Some(workflow_command::Variant::RequestCancelNexusOperation(_)) + )) + .count(), + 1 + ); + } + + #[test] + fn local_activity_token_cancels_retry_backoff_timer() { + let host = Rc::new(RecordingHost::default()); + let init = WorkflowInit { + namespace: "default".to_string(), + task_queue: "task-queue".to_string(), + run_id: "run-id".to_string(), + initialize_workflow: InitializeWorkflow { + workflow_type: TestWorkflow.name().to_string(), + ..Default::default() }, - )), - }; - let mut options = LocalActivityOptions { - schedule_to_close_timeout: Some(Duration::from_secs(10)), - event_group_markers: vec![marker.clone()], - ..Default::default() - }; - options.cancellation_token = Some(token.clone()); - let activity = base.execute_local_activity(TestActivity, (), options); - futures_util::pin_mut!(activity); - base.unblock(UnblockEvent::Activity( - 1, - Box::new(ActivityResolution { - status: Some(activity_resolution::Status::Backoff( - temporalio_common_wasm::protos::coresdk::activity_result::DoBackoff { - attempt: 2, - backoff_duration: Some(Duration::from_secs(5).try_into().unwrap()), - original_schedule_time: None, + }; + let base = BaseWorkflowContext::from_raw( + init, + DataConverter::default(), + host.clone(), + None, + Vec::new(), + ); + let token = WorkflowCancellationToken::new(); + let marker = EventGroupMarker { + variant: Some(event_group_marker::Variant::Label( + event_group_marker::Label { + id: "la-group".to_string(), + label: Some("la-group".as_json_payload().unwrap()), }, )), - }), - )) - .unwrap(); + }; + let mut options = LocalActivityOptions { + schedule_to_close_timeout: Some(Duration::from_secs(10)), + event_group_markers: vec![marker.clone()], + ..Default::default() + }; + options.cancellation_token = Some(token.clone()); + let activity = base.execute_local_activity(TestActivity, (), options); + futures_util::pin_mut!(activity); + base.unblock(UnblockEvent::Activity( + 1, + Box::new(ActivityResolution { + status: Some(activity_resolution::Status::Backoff( + temporalio_common_wasm::protos::coresdk::activity_result::DoBackoff { + attempt: 2, + backoff_duration: Some(Duration::from_secs(5).try_into().unwrap()), + original_schedule_time: None, + }, + )), + }), + )) + .unwrap(); - assert!(activity.as_mut().now_or_never().is_none()); - token.cancel(); + assert!(activity.as_mut().now_or_never().is_none()); + token.cancel(); - let commands = host.commands.borrow(); - assert!(commands.iter().any(|command| matches!( - &command.variant, - Some(workflow_command::Variant::CancelTimer(_)) - ))); - - let start_timer = commands - .iter() - .find(|command| { - matches!( - &command.variant, - Some(workflow_command::Variant::StartTimer(_)) - ) - }) - .expect("backoff StartTimer is issued"); - assert_eq!(start_timer.event_group_markers, [marker]); + let commands = host.commands.borrow(); + assert!(commands.iter().any(|command| matches!( + &command.variant, + Some(workflow_command::Variant::CancelTimer(_)) + ))); + + let start_timer = commands + .iter() + .find(|command| { + matches!( + &command.variant, + Some(workflow_command::Variant::StartTimer(_)) + ) + }) + .expect("backoff StartTimer is issued"); + assert_eq!(start_timer.event_group_markers, [marker]); + } } #[test] @@ -4108,48 +4012,196 @@ mod tests { assert_eq!(stream.random::(), expected.random::()); } - struct MutatingRemainingOutboundInterceptor; + #[cfg(feature = "experimental")] + mod experimental_interceptor_tests { + use super::*; + use crate::workflow_interceptors::StartNexusOperationInput; - impl WorkflowInterceptor for MutatingRemainingOutboundInterceptor { - fn signal_workflow( - &self, - _ctx: WorkflowInterceptorContext, - mut input: SignalWorkflowInput, - next: WorkflowNext< - 'static, - SignalWorkflowInput, - CancellableWorkflowOutboundFuture, - >, - ) -> CancellableWorkflowOutboundFuture { - *input.signal_name_mut() = "mutated-signal".to_string(); - *input.input_mut::().unwrap() = "mutated-input".to_string(); - *input.target_mut() = SignalWorkflowTarget::External { - namespace: "mutated-namespace".to_string(), - workflow_id: "mutated-workflow".to_string(), - run_id: Some("mutated-run".to_string()), - }; - input - .headers_mut() - .insert("signal-header".to_string(), Payload::default()); - next.run(input) + struct MutatingRemainingOutboundInterceptor; + + impl WorkflowInterceptor for MutatingRemainingOutboundInterceptor { + fn signal_workflow( + &self, + _ctx: WorkflowInterceptorContext, + mut input: SignalWorkflowInput, + next: WorkflowNext< + 'static, + SignalWorkflowInput, + CancellableWorkflowOutboundFuture, + >, + ) -> CancellableWorkflowOutboundFuture { + *input.signal_name_mut() = "mutated-signal".to_string(); + *input.input_mut::().unwrap() = "mutated-input".to_string(); + *input.target_mut() = SignalWorkflowTarget::External { + namespace: "mutated-namespace".to_string(), + workflow_id: "mutated-workflow".to_string(), + run_id: Some("mutated-run".to_string()), + }; + input + .headers_mut() + .insert("signal-header".to_string(), Payload::default()); + next.run(input) + } + + fn cancel_external_workflow( + &self, + _ctx: WorkflowInterceptorContext, + mut input: CancelExternalWorkflowInput, + next: WorkflowNext< + 'static, + CancelExternalWorkflowInput, + WorkflowOutboundFuture, + >, + ) -> WorkflowOutboundFuture { + input.workflow_id = "mutated-cancel-workflow".to_string(); + input.run_id = Some("mutated-cancel-run".to_string()); + input.reason = Some("mutated-reason".to_string()); + next.run(input) + } + + fn continue_as_new( + &self, + _ctx: crate::workflow_interceptors::SyncWorkflowInterceptorContext, + mut input: ContinueAsNewInput, + next: WorkflowNext< + 'static, + ContinueAsNewInput, + crate::workflow_interceptors::ContinueAsNewResult, + >, + ) -> crate::workflow_interceptors::ContinueAsNewResult { + *input.input_mut::().unwrap() = 42; + input.options_mut().workflow_type = Some("mutated-workflow-type".to_string()); + input.headers_mut().insert( + "continue-header".to_string(), + Payload::from(b"continue-header-value".as_slice()), + ); + next.run(input) + } + + fn start_nexus_operation( + &self, + _ctx: WorkflowInterceptorContext, + mut input: StartNexusOperationInput, + next: WorkflowNext< + 'static, + StartNexusOperationInput, + CancellableWorkflowOutboundFuture< + crate::workflow_interceptors::StartNexusOperationResult, + >, + >, + ) -> CancellableWorkflowOutboundFuture< + crate::workflow_interceptors::StartNexusOperationResult, + > { + input.options_mut().endpoint = "mutated-endpoint".to_string(); + input.options_mut().service = "mutated-service".to_string(); + input.options_mut().operation = "mutated-operation".to_string(); + next.run(input) + } } - fn cancel_external_workflow( - &self, - _ctx: WorkflowInterceptorContext, - mut input: CancelExternalWorkflowInput, - next: WorkflowNext< - 'static, - CancelExternalWorkflowInput, - WorkflowOutboundFuture, - >, - ) -> WorkflowOutboundFuture { - input.workflow_id = "mutated-cancel-workflow".to_string(); - input.run_id = Some("mutated-cancel-run".to_string()); - input.reason = Some("mutated-reason".to_string()); - next.run(input) + #[test] + fn outbound_interceptors_mutate_signal_cancel_continue_as_new_and_nexus() { + let host = Rc::new(RecordingHost::default()); + let init = InitializeWorkflow { + workflow_type: TestWorkflow.name().to_string(), + ..Default::default() + }; + let init = WorkflowInit { + namespace: "default".to_string(), + task_queue: "task-queue".to_string(), + run_id: "run-id".to_string(), + initialize_workflow: init, + }; + let base = BaseWorkflowContext::from_raw( + init, + DataConverter::default(), + host.clone(), + None, + vec![WorkflowInterceptorConstructor::new(|_| { + MutatingRemainingOutboundInterceptor + })], + ); + let ctx = WorkflowContext::from_base(base, Rc::new(RefCell::new(TestWorkflow))); + + let signal = ctx + .external_workflow("original-workflow", Some("original-run".to_string())) + .signal( + TestWorkflow::test_signal, + "original-input".to_string(), + Default::default(), + ); + let cancel_target = + ctx.external_workflow("cancel-workflow", Some("cancel-run".to_string())); + let cancel = cancel_target.cancel(Some("original-reason".to_string())); + let termination = ctx + .continue_as_new(7, ContinueAsNewOptions::default()) + .expect_err("continue_as_new should terminate the workflow"); + let sync_ctx = ctx.sync_context(); + let nexus = sync_ctx.start_nexus_operation( + NexusOperationOptions::builder() + .endpoint("original-endpoint") + .service("original-service") + .operation("original-operation") + .build(), + ); + drop((signal, cancel, nexus)); + + let WorkflowTermination::ContinueAsNew(continue_as_new) = termination else { + panic!("expected continue-as-new termination") + }; + assert_eq!(continue_as_new.workflow_type, "mutated-workflow-type"); + assert_eq!( + continue_as_new.arguments, + vec![42u8.as_json_payload().unwrap()] + ); + assert!(continue_as_new.headers.contains_key("continue-header")); + + let commands = host.commands.borrow(); + assert_eq!(commands.len(), 3); + let Some(workflow_command::Variant::SignalExternalWorkflowExecution(signal)) = + &commands[0].variant + else { + panic!("expected signal command") + }; + assert_eq!(signal.signal_name, "mutated-signal"); + assert_eq!( + signal.args, + vec!["mutated-input".to_string().as_json_payload().unwrap()] + ); + assert!(signal.headers.contains_key("signal-header")); + let Some(signal_external_workflow_execution::Target::WorkflowExecution(target)) = + &signal.target + else { + panic!("expected external workflow signal target") + }; + assert_eq!(target.namespace, "mutated-namespace"); + assert_eq!(target.workflow_id, "mutated-workflow"); + assert_eq!(target.run_id, "mutated-run"); + + let Some(workflow_command::Variant::RequestCancelExternalWorkflowExecution(cancel)) = + &commands[1].variant + else { + panic!("expected external cancellation command") + }; + let target = cancel.workflow_execution.as_ref().unwrap(); + assert_eq!(target.workflow_id, "mutated-cancel-workflow"); + assert_eq!(target.run_id, "mutated-cancel-run"); + assert_eq!(cancel.reason, "mutated-reason"); + + let Some(workflow_command::Variant::ScheduleNexusOperation(nexus)) = + &commands[2].variant + else { + panic!("expected Nexus operation command") + }; + assert_eq!(nexus.endpoint, "mutated-endpoint"); + assert_eq!(nexus.service, "mutated-service"); + assert_eq!(nexus.operation, "mutated-operation"); } + } + struct HeaderAddingContinueAsNewInterceptor; + + impl WorkflowInterceptor for HeaderAddingContinueAsNewInterceptor { fn continue_as_new( &self, _ctx: crate::workflow_interceptors::SyncWorkflowInterceptorContext, @@ -4160,132 +4212,12 @@ mod tests { crate::workflow_interceptors::ContinueAsNewResult, >, ) -> crate::workflow_interceptors::ContinueAsNewResult { - *input.input_mut::().unwrap() = 42; - input.options_mut().workflow_type = Some("mutated-workflow-type".to_string()); input.headers_mut().insert( "continue-header".to_string(), Payload::from(b"continue-header-value".as_slice()), ); next.run(input) } - - fn start_nexus_operation( - &self, - _ctx: WorkflowInterceptorContext, - mut input: StartNexusOperationInput, - next: WorkflowNext< - 'static, - StartNexusOperationInput, - CancellableWorkflowOutboundFuture< - crate::workflow_interceptors::StartNexusOperationResult, - >, - >, - ) -> CancellableWorkflowOutboundFuture< - crate::workflow_interceptors::StartNexusOperationResult, - > { - input.options_mut().endpoint = "mutated-endpoint".to_string(); - input.options_mut().service = "mutated-service".to_string(); - input.options_mut().operation = "mutated-operation".to_string(); - next.run(input) - } - } - - #[test] - fn outbound_interceptors_mutate_signal_cancel_continue_as_new_and_nexus() { - let host = Rc::new(RecordingHost::default()); - let init = InitializeWorkflow { - workflow_type: TestWorkflow.name().to_string(), - ..Default::default() - }; - let init = WorkflowInit { - namespace: "default".to_string(), - task_queue: "task-queue".to_string(), - run_id: "run-id".to_string(), - initialize_workflow: init, - }; - let base = BaseWorkflowContext::from_raw( - init, - DataConverter::default(), - host.clone(), - None, - vec![WorkflowInterceptorConstructor::new(|_| { - MutatingRemainingOutboundInterceptor - })], - ); - let ctx = WorkflowContext::from_base(base, Rc::new(RefCell::new(TestWorkflow))); - - let signal = ctx - .external_workflow("original-workflow", Some("original-run".to_string())) - .signal( - TestWorkflow::test_signal, - "original-input".to_string(), - Default::default(), - ); - let cancel_target = - ctx.external_workflow("cancel-workflow", Some("cancel-run".to_string())); - let cancel = cancel_target.cancel(Some("original-reason".to_string())); - let termination = ctx - .continue_as_new(7, ContinueAsNewOptions::default()) - .expect_err("continue_as_new should terminate the workflow"); - let sync_ctx = ctx.sync_context(); - let nexus = sync_ctx.start_nexus_operation( - NexusOperationOptions::builder() - .endpoint("original-endpoint") - .service("original-service") - .operation("original-operation") - .build(), - ); - drop((signal, cancel, nexus)); - - let WorkflowTermination::ContinueAsNew(continue_as_new) = termination else { - panic!("expected continue-as-new termination") - }; - assert_eq!(continue_as_new.workflow_type, "mutated-workflow-type"); - assert_eq!( - continue_as_new.arguments, - vec![42u8.as_json_payload().unwrap()] - ); - assert!(continue_as_new.headers.contains_key("continue-header")); - - let commands = host.commands.borrow(); - assert_eq!(commands.len(), 3); - let Some(workflow_command::Variant::SignalExternalWorkflowExecution(signal)) = - &commands[0].variant - else { - panic!("expected signal command") - }; - assert_eq!(signal.signal_name, "mutated-signal"); - assert_eq!( - signal.args, - vec!["mutated-input".to_string().as_json_payload().unwrap()] - ); - assert!(signal.headers.contains_key("signal-header")); - let Some(signal_external_workflow_execution::Target::WorkflowExecution(target)) = - &signal.target - else { - panic!("expected external workflow signal target") - }; - assert_eq!(target.namespace, "mutated-namespace"); - assert_eq!(target.workflow_id, "mutated-workflow"); - assert_eq!(target.run_id, "mutated-run"); - - let Some(workflow_command::Variant::RequestCancelExternalWorkflowExecution(cancel)) = - &commands[1].variant - else { - panic!("expected external cancellation command") - }; - let target = cancel.workflow_execution.as_ref().unwrap(); - assert_eq!(target.workflow_id, "mutated-cancel-workflow"); - assert_eq!(target.run_id, "mutated-cancel-run"); - assert_eq!(cancel.reason, "mutated-reason"); - - let Some(workflow_command::Variant::ScheduleNexusOperation(nexus)) = &commands[2].variant - else { - panic!("expected Nexus operation command") - }; - assert_eq!(nexus.endpoint, "mutated-endpoint"); - assert_eq!(nexus.service, "mutated-service"); - assert_eq!(nexus.operation, "mutated-operation"); } #[test] @@ -4306,7 +4238,7 @@ mod tests { Rc::new(NoopHost), None, vec![WorkflowInterceptorConstructor::new(|_| { - MutatingRemainingOutboundInterceptor + HeaderAddingContinueAsNewInterceptor })], ); let ctx = WorkflowContext::from_base(base, Rc::new(RefCell::new(TestWorkflow))); @@ -4372,72 +4304,105 @@ mod tests { ); } - #[test] - fn sync_workflow_context_continue_as_new_applies_options() { - let ctx = test_context(); - let sync = ctx.sync_context(); - let mut memo = MemoValues::new(); - memo.insert("memo-key", "memo-value".to_string()); - let mut proto_search_attributes = ProtoSearchAttributes::default(); - proto_search_attributes.indexed_fields.insert( - "CustomKeywordField".to_string(), - Payload::from(b"value".as_slice()), - ); - let search_attributes = SearchAttributes::from_proto(&proto_search_attributes); - - let termination = sync - .continue_as_new( - 11, - ContinueAsNewOptions { - workflow_type: Some("next-workflow".to_string()), - task_queue: Some("next-task-queue".to_string()), - run_timeout: Some(Duration::from_secs(10)), - task_timeout: Some(Duration::from_secs(3)), - backoff_start_interval: Some(Duration::from_secs(4)), - memo: Some(memo.clone()), - search_attributes: Some(search_attributes.clone()), - retry_policy: Some(RetryPolicy::builder().maximum_attempts(5).build()), - versioning_intent: Some(ProtoVersioningIntent::Compatible.into()), - initial_versioning_behavior: Some( - ContinueAsNewVersioningBehavior::UseRampingVersion, - ), - }, - ) - .expect_err("continue_as_new should terminate the workflow"); - assert!( - matches!(termination, WorkflowTermination::ContinueAsNew(_)), - "expected continue-as-new termination, got {termination:?}" - ); - let WorkflowTermination::ContinueAsNew(cmd) = termination else { - unreachable!() + #[cfg(feature = "experimental")] + mod experimental_continue_as_new_tests { + use super::*; + use temporalio_common_wasm::{ + RetryPolicy, protos::temporal::api::common::v1::RetryPolicy as ProtoRetryPolicy, }; - assert_eq!( - *cmd, - crate::runtime::types::ContinueAsNewRequest { - workflow_type: "next-workflow".to_string(), - task_queue: "next-task-queue".to_string(), - arguments: vec![11u8.as_json_payload().unwrap()], - workflow_run_timeout: Some(Duration::from_secs(10).try_into().unwrap()), - workflow_task_timeout: Some(Duration::from_secs(3).try_into().unwrap()), - backoff_start_interval: Some(Duration::from_secs(4).try_into().unwrap()), - memo: HashMap::from([( - "memo-key".to_string(), - "memo-value".as_json_payload().unwrap(), - )]), - headers: HashMap::new(), - search_attributes: Some(proto_search_attributes), - retry_policy: Some(ProtoRetryPolicy { - initial_interval: Some(Duration::from_secs(1).try_into().unwrap()), - backoff_coefficient: 2.0, - maximum_attempts: 5, - ..Default::default() - }), - versioning_intent: ProtoVersioningIntent::Compatible.into(), - initial_versioning_behavior: ProtoContinueAsNewVersioningBehavior::UseRampingVersion - as i32, - } - ); + #[test] + fn sync_workflow_context_continue_as_new_applies_options() { + let ctx = test_context(); + let sync = ctx.sync_context(); + let mut memo = MemoValues::new(); + memo.insert("memo-key", "memo-value".to_string()); + let mut proto_search_attributes = ProtoSearchAttributes::default(); + proto_search_attributes.indexed_fields.insert( + "CustomKeywordField".to_string(), + Payload::from(b"value".as_slice()), + ); + let search_attributes = SearchAttributes::from_proto(&proto_search_attributes); + + let termination = sync + .continue_as_new( + 11, + ContinueAsNewOptions { + workflow_type: Some("next-workflow".to_string()), + task_queue: Some("next-task-queue".to_string()), + run_timeout: Some(Duration::from_secs(10)), + task_timeout: Some(Duration::from_secs(3)), + backoff_start_interval: Some(Duration::from_secs(4)), + memo: Some(memo.clone()), + search_attributes: Some(search_attributes.clone()), + retry_policy: Some(RetryPolicy::builder().maximum_attempts(5).build()), + versioning_intent: Some(ProtoVersioningIntent::Compatible.into()), + initial_versioning_behavior: Some( + ContinueAsNewVersioningBehavior::UseRampingVersion, + ), + }, + ) + .expect_err("continue_as_new should terminate the workflow"); + assert!( + matches!(termination, WorkflowTermination::ContinueAsNew(_)), + "expected continue-as-new termination, got {termination:?}" + ); + let WorkflowTermination::ContinueAsNew(cmd) = termination else { + unreachable!() + }; + + assert_eq!( + *cmd, + crate::runtime::types::ContinueAsNewRequest { + workflow_type: "next-workflow".to_string(), + task_queue: "next-task-queue".to_string(), + arguments: vec![11u8.as_json_payload().unwrap()], + workflow_run_timeout: Some(Duration::from_secs(10).try_into().unwrap()), + workflow_task_timeout: Some(Duration::from_secs(3).try_into().unwrap()), + backoff_start_interval: Some(Duration::from_secs(4).try_into().unwrap()), + memo: HashMap::from([( + "memo-key".to_string(), + "memo-value".as_json_payload().unwrap(), + )]), + headers: HashMap::new(), + search_attributes: Some(proto_search_attributes), + retry_policy: Some(ProtoRetryPolicy { + initial_interval: Some(Duration::from_secs(1).try_into().unwrap()), + backoff_coefficient: 2.0, + maximum_attempts: 5, + ..Default::default() + }), + versioning_intent: ProtoVersioningIntent::Compatible.into(), + initial_versioning_behavior: + ProtoContinueAsNewVersioningBehavior::UseRampingVersion as i32, + } + ); + } + + #[test] + fn workflow_context_continue_as_new_applies_auto_upgrade_versioning_behavior() { + let ctx = test_context(); + + let termination = ctx + .continue_as_new( + 13, + ContinueAsNewOptions { + initial_versioning_behavior: Some( + ContinueAsNewVersioningBehavior::AutoUpgrade, + ), + ..Default::default() + }, + ) + .expect_err("continue_as_new should terminate the workflow"); + let WorkflowTermination::ContinueAsNew(cmd) = termination else { + unreachable!() + }; + + assert_eq!( + cmd.initial_versioning_behavior, + ProtoContinueAsNewVersioningBehavior::AutoUpgrade as i32 + ); + } } #[test] @@ -4464,29 +4429,6 @@ mod tests { ); } - #[test] - fn workflow_context_continue_as_new_applies_auto_upgrade_versioning_behavior() { - let ctx = test_context(); - - let termination = ctx - .continue_as_new( - 13, - ContinueAsNewOptions { - initial_versioning_behavior: Some(ContinueAsNewVersioningBehavior::AutoUpgrade), - ..Default::default() - }, - ) - .expect_err("continue_as_new should terminate the workflow"); - let WorkflowTermination::ContinueAsNew(cmd) = termination else { - unreachable!() - }; - - assert_eq!( - cmd.initial_versioning_behavior, - ProtoContinueAsNewVersioningBehavior::AutoUpgrade as i32 - ); - } - #[test] fn continue_as_new_preserves_input_serialization_errors() { #[derive(Debug)] diff --git a/crates/workflow/src/workflow_context/nexus.rs b/crates/workflow/src/workflow_context/nexus.rs new file mode 100644 index 000000000..a0e382608 --- /dev/null +++ b/crates/workflow/src/workflow_context/nexus.rs @@ -0,0 +1,127 @@ +use super::*; +use crate::{ + runtime::{SdkGuardedFuture, model::NexusStartResult}, + workflow_interceptors::{StartNexusOperationInput, call_start_nexus_operation}, +}; +use futures_util::{FutureExt, future::Shared}; +use temporalio_common_wasm::protos::coresdk::nexus::NexusOperationResult; + +impl BaseWorkflowContext { + pub(crate) fn start_nexus_operation( + &self, + opts: NexusOperationOptions, + ) -> impl CancellableFuture { + let input = StartNexusOperationInput::new(opts); + let base_ctx = self.clone(); + let next = WorkflowNext::new(move |input: StartNexusOperationInput| { + let mut opts = input.into_options(); + let cancellation_token = opts + .cancellation_token + .take() + .unwrap_or_else(|| base_ctx.cancellation_token()); + let seq = base_ctx.inner.seq_nums.borrow_mut().next_nexus_op_seq(); + let (result_future, unblocker) = + CancellableWFCommandFut::new(CancellableID::NexusOp(seq), base_ctx.clone()); + base_ctx + .inner + .runtime + .register_unblocker(PendingCommandId::NexusOpComplete(seq), unblocker); + base_ctx + .inner + .runtime + .host + .push_command(opts.into_command(seq)); + let result_future = CancellableWorkflowOutboundFuture::new( + result_future, + base_ctx.cancellation_handle(CancellableID::NexusOp(seq)), + ) + .with_cancellation_token(cancellation_token) + .shared(); + let (cmd, unblocker) = CancellableWFCommandFut::new_with_dat( + CancellableID::NexusOp(seq), + NexusUnblockData { + result_future: result_future.clone(), + schedule_seq: seq, + base_ctx: base_ctx.clone(), + }, + base_ctx.clone(), + ); + base_ctx + .inner + .runtime + .register_unblocker(PendingCommandId::NexusOpStart(seq), unblocker); + cancellable_outbound(cmd) + }); + let interceptors = self.inner.workflow_interceptors.clone(); + let future = call_start_nexus_operation( + interceptors, + WorkflowInterceptorContext::new(self.clone()), + input, + next, + ); + self.prepare_cancellable_outbound_future(future) + } +} + +impl SyncWorkflowContext { + /// Start a Nexus operation. + pub fn start_nexus_operation( + &self, + opts: NexusOperationOptions, + ) -> impl CancellableFuture { + self.base.start_nexus_operation(opts) + } +} + +impl WorkflowContext { + /// Start a Nexus operation. + pub fn start_nexus_operation( + &self, + opts: NexusOperationOptions, + ) -> impl CancellableFuture { + self.sync.start_nexus_operation(opts) + } +} + +impl WfCtxProtectedDat { + fn next_nexus_op_seq(&mut self) -> u32 { + let seq = self.next_nexus_op_sequence_number; + self.next_nexus_op_sequence_number += 1; + seq + } +} + +#[derive(derive_more::Debug)] +#[debug("StartedNexusOperation{{ operation_token: {operation_token:?} }}")] +/// Handle to a started Nexus operation. +pub struct StartedNexusOperation { + /// The operation token, if the operation started asynchronously + pub operation_token: Option, + #[debug(skip)] + pub(crate) result_future: Shared>, + pub(crate) schedule_seq: u32, + #[debug(skip)] + pub(crate) base_ctx: BaseWorkflowContext, +} + +pub(crate) struct NexusUnblockData { + pub(crate) result_future: Shared>, + pub(crate) schedule_seq: u32, + pub(crate) base_ctx: BaseWorkflowContext, +} + +impl StartedNexusOperation { + /// Wait for the operation result. + pub async fn result(&self) -> NexusOperationResult { + // The result future is a `Shared`; poll it inside an `SdkWakeGuard` (via + // `SdkGuardedFuture`) so its internal waker machinery isn't mistaken for a non-SDK wake on + // replay (which would fail the workflow task with TMPRL1100). + SdkGuardedFuture(self.result_future.clone()).await + } + + /// Request cancellation of the operation. + pub fn cancel(&self) { + self.base_ctx + .cancel(CancellableID::NexusOp(self.schedule_seq)); + } +} diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index 8dcf6f750..5e6dd9c0c 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -1,6 +1,8 @@ use std::{collections::HashMap, time::Duration}; use crate::{MemoValues, WorkflowCancellationToken, runtime::types::ContinueAsNewRequest}; +#[cfg(feature = "experimental")] +use temporalio_common_wasm::protos::temporal::api::enums::v1::ContinueAsNewVersioningBehavior as ProtoContinueAsNewVersioningBehavior; use temporalio_common_wasm::{ ActivityCloseTimeouts, Priority, RetryPolicy, data_converters::{ @@ -14,27 +16,32 @@ use temporalio_common_wasm::{ ParentClosePolicy as ProtoParentClosePolicy, }, common::VersioningIntent as ProtoVersioningIntent, - nexus::NexusOperationCancellationType as ProtoNexusOperationCancellationType, workflow_commands::{ ActivityCancellationType as ProtoActivityCancellationType, ContinueAsNewWorkflowExecution, ScheduleActivity, ScheduleLocalActivity, - ScheduleNexusOperation, SignalExternalWorkflowExecution, - StartChildWorkflowExecution, StartTimer, WorkflowCommand, - signal_external_workflow_execution, workflow_command, + SignalExternalWorkflowExecution, StartChildWorkflowExecution, StartTimer, + WorkflowCommand, signal_external_workflow_execution, workflow_command, }, }, temporal::api::{ common::v1::Payload, - enums::v1::{ - ContinueAsNewVersioningBehavior as ProtoContinueAsNewVersioningBehavior, - WorkflowIdReusePolicy as ProtoWorkflowIdReusePolicy, - }, + enums::v1::WorkflowIdReusePolicy as ProtoWorkflowIdReusePolicy, sdk::v1::{EventGroupMarker, UserMetadata}, }, }, search_attributes::SearchAttributes, }; +#[cfg(feature = "experimental")] +mod continue_as_new_versioning; +#[cfg(feature = "experimental")] +mod nexus; + +#[cfg(feature = "experimental")] +pub use continue_as_new_versioning::ContinueAsNewVersioningBehavior; +#[cfg(feature = "experimental")] +pub use nexus::{NexusOperationCancellationType, NexusOperationOptions}; + /// Controls when activity cancellation is reported back to a workflow. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, @@ -232,52 +239,6 @@ impl From for VersioningIntent { } } -/// Controls when Nexus operation cancellation is reported to a workflow. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, -)] -#[non_exhaustive] -pub enum NexusOperationCancellationType { - /// Wait until cancellation has completed. - #[default] - WaitCancellationCompleted, - /// Do not request cancellation. - Abandon, - /// Request cancellation and report it immediately. - TryCancel, - /// Wait until the cancellation request is acknowledged. - WaitCancellationRequested, -} - -impl From for ProtoNexusOperationCancellationType { - fn from(value: NexusOperationCancellationType) -> Self { - match value { - NexusOperationCancellationType::WaitCancellationCompleted => { - Self::WaitCancellationCompleted - } - NexusOperationCancellationType::Abandon => Self::Abandon, - NexusOperationCancellationType::TryCancel => Self::TryCancel, - NexusOperationCancellationType::WaitCancellationRequested => { - Self::WaitCancellationRequested - } - } - } -} - -impl From for NexusOperationCancellationType { - fn from(value: ProtoNexusOperationCancellationType) -> Self { - match value { - ProtoNexusOperationCancellationType::WaitCancellationCompleted => { - Self::WaitCancellationCompleted - } - ProtoNexusOperationCancellationType::Abandon => Self::Abandon, - ProtoNexusOperationCancellationType::TryCancel => Self::TryCancel, - ProtoNexusOperationCancellationType::WaitCancellationRequested => { - Self::WaitCancellationRequested - } - } - } -} /// Options for scheduling an activity #[derive(Debug, bon::Builder, Clone)] #[non_exhaustive] @@ -327,6 +288,7 @@ pub struct ActivityOptions { /// /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists /// only for internal test purposes. This API *will* change. + #[cfg(feature = "experimental")] #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, @@ -366,6 +328,10 @@ impl ActivityOptions { args: Vec, headers: HashMap, ) -> WorkflowCommand { + #[cfg(feature = "experimental")] + let event_group_markers = self.event_group_markers; + #[cfg(not(feature = "experimental"))] + let event_group_markers = Vec::new(); command_with_metadata( workflow_command::Variant::ScheduleActivity(ScheduleActivity { seq, @@ -397,7 +363,7 @@ impl ActivityOptions { }), self.summary, None, - self.event_group_markers, + event_group_markers, ) } } @@ -455,6 +421,7 @@ pub struct LocalActivityOptions { /// /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists /// only for internal test purposes. This API *will* change. + #[cfg(feature = "experimental")] #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, @@ -478,6 +445,10 @@ impl LocalActivityOptions { // activity timeouts are normalized before the command is emitted. self.schedule_to_close_timeout .get_or_insert(Duration::from_secs(100)); + #[cfg(feature = "experimental")] + let event_group_markers = self.event_group_markers; + #[cfg(not(feature = "experimental"))] + let event_group_markers = Vec::new(); command_with_metadata( workflow_command::Variant::ScheduleLocalActivity(ScheduleLocalActivity { seq, @@ -505,7 +476,7 @@ impl LocalActivityOptions { }), self.summary, None, - self.event_group_markers, + event_group_markers, ) } } @@ -551,6 +522,7 @@ pub struct ChildWorkflowOptions { /// /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists /// only for internal test purposes. This API *will* change. + #[cfg(feature = "experimental")] #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, @@ -572,6 +544,10 @@ impl ChildWorkflowOptions { headers: HashMap, workflow_id: String, ) -> WorkflowCommand { + #[cfg(feature = "experimental")] + let event_group_markers = self.event_group_markers; + #[cfg(not(feature = "experimental"))] + let event_group_markers = Vec::new(); command_with_metadata( workflow_command::Variant::StartChildWorkflowExecution(StartChildWorkflowExecution { seq, @@ -606,7 +582,7 @@ impl ChildWorkflowOptions { }), self.static_summary, self.static_details, - self.event_group_markers, + event_group_markers, ) } } @@ -626,6 +602,7 @@ pub struct TimerOptions { /// /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists /// only for internal test purposes. This API *will* change. + #[cfg(feature = "experimental")] #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, @@ -648,6 +625,10 @@ impl From for TimerOptions { impl TimerOptions { pub(crate) fn into_command(self, seq: u32) -> WorkflowCommand { + #[cfg(feature = "experimental")] + let event_group_markers = self.event_group_markers; + #[cfg(not(feature = "experimental"))] + let event_group_markers = Vec::new(); command_with_metadata( workflow_command::Variant::StartTimer(StartTimer { seq, @@ -659,7 +640,7 @@ impl TimerOptions { }), self.summary, None, - self.event_group_markers, + event_group_markers, ) } } @@ -684,6 +665,7 @@ pub struct SignalWorkflowOptions { /// /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists /// only for internal test purposes. This API *will* change. + #[cfg(feature = "experimental")] #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, @@ -698,6 +680,10 @@ impl SignalWorkflowOptions { headers: HashMap, target: signal_external_workflow_execution::Target, ) -> WorkflowCommand { + #[cfg(feature = "experimental")] + let event_group_markers = self.event_group_markers; + #[cfg(not(feature = "experimental"))] + let event_group_markers = Vec::new(); command_with_metadata( workflow_command::Variant::SignalExternalWorkflowExecution( SignalExternalWorkflowExecution { @@ -710,131 +696,11 @@ impl SignalWorkflowOptions { ), self.summary, None, - self.event_group_markers, + event_group_markers, ) } } -/// Options for Nexus Operations -#[derive(Debug, Clone, bon::Builder)] -#[builder(on(String, into))] -#[non_exhaustive] -pub struct NexusOperationOptions { - /// Endpoint name, must exist in the endpoint registry or this command will fail. - pub endpoint: String, - /// Service name. - pub service: String, - /// Operation name. - pub operation: String, - /// Input for the operation. The server converts this into Nexus request content and the - /// appropriate content headers internally when sending the StartOperation request. On the - /// handler side, if it is also backed by Temporal, the content is transformed back to the - /// original Payload sent in this command. - pub input: Option, - /// Schedule-to-close timeout for this operation. - /// Indicates how long the caller is willing to wait for operation completion. - /// Calls are retried internally by the server. - pub schedule_to_close_timeout: Option, - /// Header to attach to the Nexus request. - /// Users are responsible for encrypting sensitive data in this header as it is stored in - /// workflow history and transmitted to external services as-is. This is useful for propagating - /// tracing information. Note these headers are not the same as Temporal headers on internal - /// activities and child workflows, these are transmitted to Nexus operations that may be - /// external and are not traditional payloads. - #[builder(default)] - pub nexus_header: HashMap, - /// Cancellation type for the operation - pub cancellation_type: Option, - /// Cancellation token for this operation. `None` inherits workflow cancellation. - pub cancellation_token: Option, - /// Schedule-to-start timeout for this operation. - /// Indicates how long the caller is willing to wait for the operation to be started (or completed if synchronous) - /// by the handler. If the operation is not started within this timeout, it will fail with - /// TIMEOUT_TYPE_SCHEDULE_TO_START. - /// If not set or zero, no schedule-to-start timeout is enforced. - pub schedule_to_start_timeout: Option, - /// Start-to-close timeout for this operation. - /// Indicates how long the caller is willing to wait for an asynchronous operation to complete after it has been - /// started. If the operation does not complete within this timeout after starting, it will fail with - /// TIMEOUT_TYPE_START_TO_CLOSE. - /// Only applies to asynchronous operations. Synchronous operations ignore this timeout. - /// If not set or zero, no start-to-close timeout is enforced. - pub start_to_close_timeout: Option, -} - -impl NexusOperationOptions { - pub(crate) fn into_command(self, seq: u32) -> WorkflowCommand { - workflow_command::Variant::ScheduleNexusOperation(ScheduleNexusOperation { - seq, - endpoint: self.endpoint, - service: self.service, - operation: self.operation, - input: self.input, - schedule_to_close_timeout: self - .schedule_to_close_timeout - .and_then(|duration| duration.try_into().ok()), - schedule_to_start_timeout: self - .schedule_to_start_timeout - .and_then(|duration| duration.try_into().ok()), - start_to_close_timeout: self - .start_to_close_timeout - .and_then(|duration| duration.try_into().ok()), - nexus_header: self.nexus_header, - cancellation_type: ProtoNexusOperationCancellationType::from( - self.cancellation_type - .unwrap_or(NexusOperationCancellationType::WaitCancellationCompleted), - ) - .into(), - }) - .into() - } -} - -/// Versioning behavior to use for the first workflow task of a new continue-as-new run. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -#[non_exhaustive] -pub enum ContinueAsNewVersioningBehavior { - /// No initial versioning behavior was specified. - #[default] - Unspecified, - /// Start the new run with AutoUpgrade behavior. - AutoUpgrade, - /// Start the new run on the task queue's ramping deployment version. - UseRampingVersion, -} - -impl From for ProtoContinueAsNewVersioningBehavior { - fn from(value: ContinueAsNewVersioningBehavior) -> Self { - match value { - ContinueAsNewVersioningBehavior::Unspecified => { - ProtoContinueAsNewVersioningBehavior::Unspecified - } - ContinueAsNewVersioningBehavior::AutoUpgrade => { - ProtoContinueAsNewVersioningBehavior::AutoUpgrade - } - ContinueAsNewVersioningBehavior::UseRampingVersion => { - ProtoContinueAsNewVersioningBehavior::UseRampingVersion - } - } - } -} - -impl From for ContinueAsNewVersioningBehavior { - fn from(value: ProtoContinueAsNewVersioningBehavior) -> Self { - match value { - ProtoContinueAsNewVersioningBehavior::Unspecified => { - ContinueAsNewVersioningBehavior::Unspecified - } - ProtoContinueAsNewVersioningBehavior::AutoUpgrade => { - ContinueAsNewVersioningBehavior::AutoUpgrade - } - ProtoContinueAsNewVersioningBehavior::UseRampingVersion => { - ContinueAsNewVersioningBehavior::UseRampingVersion - } - } - } -} - /// Options for continuing a workflow as a new execution. /// /// Unset fields inherit the current workflow's values where applicable. @@ -866,6 +732,7 @@ pub struct ContinueAsNewOptions { /// This experimental option is only meaningful for workers using worker deployment /// versioning. `AutoUpgrade` routes the new run to the current deployment version; /// `UseRampingVersion` routes it to the ramping deployment version when one is configured. + #[cfg(feature = "experimental")] pub initial_versioning_behavior: Option, } @@ -892,6 +759,14 @@ impl ContinueAsNewOptions { }) .transpose()? .unwrap_or_default(); + #[cfg(feature = "experimental")] + let initial_versioning_behavior = ProtoContinueAsNewVersioningBehavior::from( + self.initial_versioning_behavior + .unwrap_or(ContinueAsNewVersioningBehavior::Unspecified), + ) + .into(); + #[cfg(not(feature = "experimental"))] + let initial_versioning_behavior = Default::default(); Ok(ContinueAsNewWorkflowExecution { workflow_type: self.workflow_type.unwrap_or(workflow_type), task_queue: self.task_queue.unwrap_or_default(), @@ -914,11 +789,7 @@ impl ContinueAsNewOptions { .unwrap_or(VersioningIntent::Unspecified), ) .into(), - initial_versioning_behavior: ProtoContinueAsNewVersioningBehavior::from( - self.initial_versioning_behavior - .unwrap_or(ContinueAsNewVersioningBehavior::Unspecified), - ) - .into(), + initial_versioning_behavior, }) } } @@ -1000,10 +871,6 @@ mod tests { WorkflowIdReusePolicy::Unspecified ); assert_eq!(VersioningIntent::default(), VersioningIntent::Unspecified); - assert_eq!( - NexusOperationCancellationType::default(), - NexusOperationCancellationType::WaitCancellationCompleted - ); } #[test] diff --git a/crates/workflow/src/workflow_context/options/continue_as_new_versioning.rs b/crates/workflow/src/workflow_context/options/continue_as_new_versioning.rs new file mode 100644 index 000000000..e8fb32d3a --- /dev/null +++ b/crates/workflow/src/workflow_context/options/continue_as_new_versioning.rs @@ -0,0 +1,46 @@ +use temporalio_common_wasm::protos::temporal::api::enums::v1::ContinueAsNewVersioningBehavior as ProtoContinueAsNewVersioningBehavior; + +/// Versioning behavior to use for the first workflow task of a new continue-as-new run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[non_exhaustive] +pub enum ContinueAsNewVersioningBehavior { + /// No initial versioning behavior was specified. + #[default] + Unspecified, + /// Start the new run with AutoUpgrade behavior. + AutoUpgrade, + /// Start the new run on the task queue's ramping deployment version. + UseRampingVersion, +} + +impl From for ProtoContinueAsNewVersioningBehavior { + fn from(value: ContinueAsNewVersioningBehavior) -> Self { + match value { + ContinueAsNewVersioningBehavior::Unspecified => { + ProtoContinueAsNewVersioningBehavior::Unspecified + } + ContinueAsNewVersioningBehavior::AutoUpgrade => { + ProtoContinueAsNewVersioningBehavior::AutoUpgrade + } + ContinueAsNewVersioningBehavior::UseRampingVersion => { + ProtoContinueAsNewVersioningBehavior::UseRampingVersion + } + } + } +} + +impl From for ContinueAsNewVersioningBehavior { + fn from(value: ProtoContinueAsNewVersioningBehavior) -> Self { + match value { + ProtoContinueAsNewVersioningBehavior::Unspecified => { + ContinueAsNewVersioningBehavior::Unspecified + } + ProtoContinueAsNewVersioningBehavior::AutoUpgrade => { + ContinueAsNewVersioningBehavior::AutoUpgrade + } + ProtoContinueAsNewVersioningBehavior::UseRampingVersion => { + ContinueAsNewVersioningBehavior::UseRampingVersion + } + } + } +} diff --git a/crates/workflow/src/workflow_context/options/nexus.rs b/crates/workflow/src/workflow_context/options/nexus.rs new file mode 100644 index 000000000..1604b124f --- /dev/null +++ b/crates/workflow/src/workflow_context/options/nexus.rs @@ -0,0 +1,145 @@ +use std::{collections::HashMap, time::Duration}; + +use crate::WorkflowCancellationToken; +use temporalio_common_wasm::protos::{ + coresdk::{ + nexus::NexusOperationCancellationType as ProtoNexusOperationCancellationType, + workflow_commands::{ScheduleNexusOperation, WorkflowCommand, workflow_command}, + }, + temporal::api::common::v1::Payload, +}; + +/// Controls when Nexus operation cancellation is reported to a workflow. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, +)] +#[non_exhaustive] +pub enum NexusOperationCancellationType { + /// Wait until cancellation has completed. + #[default] + WaitCancellationCompleted, + /// Do not request cancellation. + Abandon, + /// Request cancellation and report it immediately. + TryCancel, + /// Wait until the cancellation request is acknowledged. + WaitCancellationRequested, +} + +impl From for ProtoNexusOperationCancellationType { + fn from(value: NexusOperationCancellationType) -> Self { + match value { + NexusOperationCancellationType::WaitCancellationCompleted => { + Self::WaitCancellationCompleted + } + NexusOperationCancellationType::Abandon => Self::Abandon, + NexusOperationCancellationType::TryCancel => Self::TryCancel, + NexusOperationCancellationType::WaitCancellationRequested => { + Self::WaitCancellationRequested + } + } + } +} + +impl From for NexusOperationCancellationType { + fn from(value: ProtoNexusOperationCancellationType) -> Self { + match value { + ProtoNexusOperationCancellationType::WaitCancellationCompleted => { + Self::WaitCancellationCompleted + } + ProtoNexusOperationCancellationType::Abandon => Self::Abandon, + ProtoNexusOperationCancellationType::TryCancel => Self::TryCancel, + ProtoNexusOperationCancellationType::WaitCancellationRequested => { + Self::WaitCancellationRequested + } + } + } +} + +/// Options for Nexus Operations +#[derive(Debug, Clone, bon::Builder)] +#[builder(on(String, into))] +#[non_exhaustive] +pub struct NexusOperationOptions { + /// Endpoint name, must exist in the endpoint registry or this command will fail. + pub endpoint: String, + /// Service name. + pub service: String, + /// Operation name. + pub operation: String, + /// Input for the operation. The server converts this into Nexus request content and the + /// appropriate content headers internally when sending the StartOperation request. On the + /// handler side, if it is also backed by Temporal, the content is transformed back to the + /// original Payload sent in this command. + pub input: Option, + /// Schedule-to-close timeout for this operation. + /// Indicates how long the caller is willing to wait for operation completion. + /// Calls are retried internally by the server. + pub schedule_to_close_timeout: Option, + /// Header to attach to the Nexus request. + /// Users are responsible for encrypting sensitive data in this header as it is stored in + /// workflow history and transmitted to external services as-is. This is useful for propagating + /// tracing information. Note these headers are not the same as Temporal headers on internal + /// activities and child workflows, these are transmitted to Nexus operations that may be + /// external and are not traditional payloads. + #[builder(default)] + pub nexus_header: HashMap, + /// Cancellation type for the operation + pub cancellation_type: Option, + /// Cancellation token for this operation. `None` inherits workflow cancellation. + pub cancellation_token: Option, + /// Schedule-to-start timeout for this operation. + /// Indicates how long the caller is willing to wait for the operation to be started (or completed if synchronous) + /// by the handler. If the operation is not started within this timeout, it will fail with + /// TIMEOUT_TYPE_SCHEDULE_TO_START. + /// If not set or zero, no schedule-to-start timeout is enforced. + pub schedule_to_start_timeout: Option, + /// Start-to-close timeout for this operation. + /// Indicates how long the caller is willing to wait for an asynchronous operation to complete after it has been + /// started. If the operation does not complete within this timeout after starting, it will fail with + /// TIMEOUT_TYPE_START_TO_CLOSE. + /// Only applies to asynchronous operations. Synchronous operations ignore this timeout. + /// If not set or zero, no start-to-close timeout is enforced. + pub start_to_close_timeout: Option, +} + +impl NexusOperationOptions { + pub(crate) fn into_command(self, seq: u32) -> WorkflowCommand { + workflow_command::Variant::ScheduleNexusOperation(ScheduleNexusOperation { + seq, + endpoint: self.endpoint, + service: self.service, + operation: self.operation, + input: self.input, + schedule_to_close_timeout: self + .schedule_to_close_timeout + .and_then(|duration| duration.try_into().ok()), + schedule_to_start_timeout: self + .schedule_to_start_timeout + .and_then(|duration| duration.try_into().ok()), + start_to_close_timeout: self + .start_to_close_timeout + .and_then(|duration| duration.try_into().ok()), + nexus_header: self.nexus_header, + cancellation_type: ProtoNexusOperationCancellationType::from( + self.cancellation_type + .unwrap_or(NexusOperationCancellationType::WaitCancellationCompleted), + ) + .into(), + }) + .into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_defaults_to_wait_for_completion() { + assert_eq!( + NexusOperationCancellationType::default(), + NexusOperationCancellationType::WaitCancellationCompleted + ); + } +} diff --git a/crates/workflow/src/workflow_interceptors.rs b/crates/workflow/src/workflow_interceptors.rs index d01aee424..ed53cc043 100644 --- a/crates/workflow/src/workflow_interceptors.rs +++ b/crates/workflow/src/workflow_interceptors.rs @@ -84,16 +84,12 @@ use crate::{ ActivityOptions, BaseWorkflowContext, CancellableFuture, CancellableFutureWithReason, ChildWorkflowOptions, ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, - NexusOperationOptions, SignalWorkflowOptions, StartChildWorkflowOutput, StartedChildWorkflow, - StartedNexusOperation, TimerOptions, WorkflowCancellationToken, WorkflowContextView, - WorkflowRandomStream, + SignalWorkflowOptions, StartChildWorkflowOutput, StartedChildWorkflow, TimerOptions, + WorkflowCancellationToken, WorkflowContextView, WorkflowRandomStream, cancellation::WorkflowCancellationRegistration, runtime::{ entry::WorkflowError, - model::{ - CancelExternalWfResult, NexusStartResult, TimerResult, WorkflowResult, - WorkflowTermination, - }, + model::{CancelExternalWfResult, TimerResult, WorkflowResult, WorkflowTermination}, }, }; use futures_util::{ @@ -122,10 +118,15 @@ use temporalio_common_wasm::{ ActivityExecutionError, ChildWorkflowExecutionError, ChildWorkflowStartError, WorkflowSignalError, }, - protos::temporal::api::{common::v1::Payload, failure::v1::Failure}, + protos::temporal::api::common::v1::Payload, search_attributes::SearchAttributes, }; +#[cfg(feature = "experimental")] +pub(crate) use nexus::call_start_nexus_operation; +#[cfg(feature = "experimental")] +pub use nexus::{StartNexusOperationInput, StartNexusOperationResult}; + mod workflow_output_value { use super::*; @@ -405,14 +406,6 @@ impl WorkflowInterceptorContext { ) -> ExternalWorkflowHandle { self.base.external_workflow(workflow_id, run_id) } - - /// Start a Nexus operation through the workflow outbound interceptor chain. - pub fn start_nexus_operation( - &self, - opts: NexusOperationOptions, - ) -> impl CancellableFuture { - self.base.start_nexus_operation(opts) - } } /// Workflow execution context available to sync-only inbound interceptors. @@ -1374,32 +1367,6 @@ impl ContinueAsNewInput { typed_outbound_input!(ContinueAsNewInput); -/// Input passed to [`WorkflowInterceptor::start_nexus_operation`]. -#[non_exhaustive] -pub struct StartNexusOperationInput { - options: NexusOperationOptions, -} - -impl StartNexusOperationInput { - pub(crate) fn new(options: NexusOperationOptions) -> Self { - Self { options } - } - - pub(crate) fn into_options(self) -> NexusOperationOptions { - self.options - } - - /// Nexus operation options. - pub fn options(&self) -> &NexusOperationOptions { - &self.options - } - - /// Mutably access Nexus operation options. - pub fn options_mut(&mut self) -> &mut NexusOperationOptions { - &mut self.options - } -} - /// Result of an intercepted activity call. pub type ScheduleActivityResult = Result, ActivityExecutionError>; @@ -1413,9 +1380,6 @@ pub type SignalWorkflowResult = Result<(), WorkflowSignalError>; /// Result of an intercepted child workflow start. pub type StartChildWorkflowResult = Result; -/// Result of an intercepted Nexus operation start. -pub type StartNexusOperationResult = Result; - /// Result of an intercepted continue-as-new call. pub type ContinueAsNewResult = Result; @@ -1616,6 +1580,7 @@ pub trait WorkflowInterceptor: 'static { } /// Called when the workflow starts a Nexus operation. + #[cfg(feature = "experimental")] fn start_nexus_operation( &self, _ctx: WorkflowInterceptorContext, @@ -1663,6 +1628,9 @@ macro_rules! outbound_chain { }; } +#[cfg(feature = "experimental")] +mod nexus; + outbound_chain!( call_start_timer, start_timer, @@ -1712,14 +1680,6 @@ outbound_chain!( ContinueAsNewInput, ContinueAsNewResult ); -outbound_chain!( - call_start_nexus_operation, - start_nexus_operation, - WorkflowInterceptorContext, - StartNexusOperationInput, - CancellableWorkflowOutboundFuture -); - type WorkflowInterceptorConstructorFn = dyn Fn(&WorkflowContextView) -> Arc + Send + Sync + 'static; diff --git a/crates/workflow/src/workflow_interceptors/nexus.rs b/crates/workflow/src/workflow_interceptors/nexus.rs new file mode 100644 index 000000000..9b058d430 --- /dev/null +++ b/crates/workflow/src/workflow_interceptors/nexus.rs @@ -0,0 +1,50 @@ +use super::*; +use crate::{NexusOperationOptions, StartedNexusOperation, runtime::model::NexusStartResult}; +use temporalio_common_wasm::protos::temporal::api::failure::v1::Failure; + +impl WorkflowInterceptorContext { + /// Start a Nexus operation through the workflow outbound interceptor chain. + pub fn start_nexus_operation( + &self, + opts: NexusOperationOptions, + ) -> impl CancellableFuture { + self.base.start_nexus_operation(opts) + } +} + +/// Input passed to [`WorkflowInterceptor::start_nexus_operation`]. +#[non_exhaustive] +pub struct StartNexusOperationInput { + options: NexusOperationOptions, +} + +impl StartNexusOperationInput { + pub(crate) fn new(options: NexusOperationOptions) -> Self { + Self { options } + } + + pub(crate) fn into_options(self) -> NexusOperationOptions { + self.options + } + + /// Nexus operation options. + pub fn options(&self) -> &NexusOperationOptions { + &self.options + } + + /// Mutably access Nexus operation options. + pub fn options_mut(&mut self) -> &mut NexusOperationOptions { + &mut self.options + } +} + +/// Result of an intercepted Nexus operation start. +pub type StartNexusOperationResult = Result; + +outbound_chain!( + call_start_nexus_operation, + start_nexus_operation, + WorkflowInterceptorContext, + StartNexusOperationInput, + CancellableWorkflowOutboundFuture +); From 85bf431fbcfab8d200c6096f9c2fdf5f43f996c0 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Tue, 1 Sep 2026 21:03:44 -0500 Subject: [PATCH 52/87] =?UTF-8?q?=F0=9F=92=A5=20Use=20context-sensitive=20?= =?UTF-8?q?randomness=20for=20workflow=20views=20(#1558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Expose workflow read-only execution state * Use context-sensitive workflow view randomness * 💥 Make WorkflowContextView single-threaded --- CHANGELOG.md | 5 + crates/sdk-core/tests/wasm_workflow_tests.rs | 12 +- crates/workflow/Cargo.toml | 3 + crates/workflow/src/runtime/instance.rs | 27 ++- crates/workflow/src/workflow_context.rs | 217 +++++++++++++++---- crates/workflow/src/workflow_context/view.rs | 33 ++- 6 files changed, 246 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a90ec5133..ae904b69e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ relevant information. ## Unreleased +### Breaking Changes +* `WorkflowContextView` and the containing `PatchActivationInput` are no longer `Send` or `Sync` + because workflow context views now share single-threaded workflow randomness with replay-sensitive + SDK integrations. + ### Added * `DefaultFailureConverter::new(true)` moves failure messages and stack traces into encoded attributes so payload codecs can encrypt them. diff --git a/crates/sdk-core/tests/wasm_workflow_tests.rs b/crates/sdk-core/tests/wasm_workflow_tests.rs index e597bf233..f946e5d16 100644 --- a/crates/sdk-core/tests/wasm_workflow_tests.rs +++ b/crates/sdk-core/tests/wasm_workflow_tests.rs @@ -95,7 +95,10 @@ async fn wasm_patch_activation_callback_can_decline() { let callback_input = input.clone(); let callback: PatchActivationCallback = Arc::new(move |value| { callback_calls.fetch_add(1, Ordering::Relaxed); - *callback_input.lock().unwrap() = Some(value); + *callback_input.lock().unwrap() = Some(( + value.workflow_info.workflow_type().to_string(), + value.patch_id, + )); false }); @@ -108,11 +111,8 @@ async fn wasm_patch_activation_callback_can_decline() { assert_eq!(marker_count, 0); let input = input.lock().unwrap(); let input = input.as_ref().unwrap(); - assert_eq!( - input.workflow_info.workflow_type(), - WASM_PATCH_ACTIVATION_WORKFLOW_TYPE - ); - assert_eq!(input.patch_id, WASM_PATCH_ID); + assert_eq!(input.0, WASM_PATCH_ACTIVATION_WORKFLOW_TYPE); + assert_eq!(input.1, WASM_PATCH_ID); } #[tokio::test] diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index 122357f9d..b1d325e1a 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -38,6 +38,9 @@ thiserror = "2" uuid = { version = "1.18", default-features = false } wit-bindgen = { version = "0.57.1", default-features = false, features = ["macros", "std", "realloc", "bitflags"] } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rand = { version = "0.10", default-features = false, features = ["thread_rng"] } + [dependencies.temporalio-common-wasm] path = "../common-wasm" version = "~0.7.0" diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index aa9e9d13f..e40c193ed 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -615,6 +615,7 @@ where let validation_input = ValidateUpdateInput::new(id.clone(), name.clone(), decoded_input, headers.clone()); let guard = self.base_ctx.track_handler(); + let _read_only = self.base_ctx.enter_read_only(); let validation_ctx = SyncWorkflowInterceptorContext::new(self.base_ctx.clone()); let workflow_ctx = self.ctx.clone(); let validation_next = WorkflowNext::new(move |input: ValidateUpdateInput| { @@ -721,6 +722,7 @@ where decoded_input, query.headers, ); + let _read_only = self.base_ctx.enter_read_only(); let interceptor_ctx = SyncWorkflowInterceptorContext::new(self.base_ctx.clone()); let workflow_ctx = self.ctx.clone(); let query_next = WorkflowNext::new(move |input: HandleQueryInput| { @@ -1199,7 +1201,7 @@ mod tests { use std::{ cell::Cell, rc::Rc, - sync::atomic::{AtomicUsize, Ordering}, + sync::atomic::{AtomicU64, AtomicUsize, Ordering}, task::Waker, }; use temporalio_common_wasm::{ @@ -1460,13 +1462,19 @@ mod tests { fn interceptor_constructors_run_before_workflow_input_decoding() { let constructor_calls = Arc::new(AtomicUsize::new(0)); let execute_calls = Arc::new(AtomicUsize::new(0)); + let constructor_random = Arc::new(AtomicU64::new(0)); let constructor_calls_ref = constructor_calls.clone(); let execute_calls_ref = execute_calls.clone(); + let constructor_random_ref = constructor_random.clone(); let constructor = WorkflowInterceptorConstructor::new(move |ctx| { assert_eq!(ctx.namespace(), "default"); assert_eq!(ctx.task_queue(), "task-queue"); assert_eq!(ctx.run_id(), "run-id"); assert_eq!(ctx.workflow_type(), DecodeFailureWorkflow::name()); + constructor_random_ref.store( + ctx.random_stream("plugin").random::(), + Ordering::Relaxed, + ); constructor_calls_ref.fetch_add(1, Ordering::Relaxed); CountingExecuteInterceptor { calls: execute_calls_ref.clone(), @@ -1478,9 +1486,20 @@ mod tests { run_id: "run-id".to_string(), initialize_workflow: InitializeWorkflow { workflow_type: DecodeFailureWorkflow::name().to_string(), + randomness_seed: 42, ..Default::default() }, }; + let expected_base_ctx = BaseWorkflowContext::from_raw( + init.clone(), + DataConverter::default(), + Rc::new(NoopHost), + None, + Vec::new(), + ); + let expected_random = expected_base_ctx.random_stream("plugin"); + let expected_constructor_random = expected_random.random::(); + let expected_next_random = expected_random.random::(); let base_ctx = BaseWorkflowContext::from_raw( init, DataConverter::default(), @@ -1488,6 +1507,7 @@ mod tests { None, vec![constructor], ); + let next_random = base_ctx.random_stream("plugin").random::(); let result = GuestWorkflowInstance::::instantiate( vec![Payload::default()], @@ -1498,5 +1518,10 @@ mod tests { assert!(result.is_err()); assert_eq!(constructor_calls.load(Ordering::Relaxed), 1); assert_eq!(execute_calls.load(Ordering::Relaxed), 0); + assert_eq!( + constructor_random.load(Ordering::Relaxed), + expected_constructor_random + ); + assert_eq!(next_random, expected_next_random); } } diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 8f2c2a2e5..1099d4231 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -143,7 +143,7 @@ macro_rules! impl_random_value { impl_random_value!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, f32, f64); -/// A deterministic pseudo-random stream private to a stable caller-supplied name. +/// A pseudo-random stream private to a stable caller-supplied name. /// /// Obtain a stream with [`WorkflowContext::random_stream`], /// [`SyncWorkflowContext::random_stream`], or @@ -159,24 +159,34 @@ impl_random_value!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, f32, f64); /// Workflow reset replays the original sequence through the reset point. When Core supplies the /// reset run's new randomness seed, all named streams start new sequences for work after that /// point. Continue-as-new creates a new workflow run and independently seeds all streams. -/// -/// Random streams are deliberately unavailable from [`WorkflowContextView`], which is used by -/// read-only init, query, and update-validator handlers. #[derive(Clone)] pub struct WorkflowRandomStream { - base: BaseWorkflowContext, + source: WorkflowRandomStreamSource, name: String, } +#[derive(Clone)] +enum WorkflowRandomStreamSource { + Workflow(Rc>), + System(Rc>), +} + impl WorkflowRandomStream { - /// Generates the next deterministic pseudo-random value from this named stream. + /// Generates the next pseudo-random value from this named stream. /// /// This generator is not cryptographically secure. pub fn random(&self) -> T where T: WorkflowRandomValue, { - self.base.named_random(&self.name) + match &self.source { + WorkflowRandomStreamSource::Workflow(random) => { + random.borrow_mut().named_random(&self.name) + } + WorkflowRandomStreamSource::System(random) => { + ::sample(&mut random.borrow_mut()) + } + } } /// Returns the stable name associated with this stream. @@ -185,6 +195,23 @@ impl WorkflowRandomStream { } } +fn system_random_stream_source() -> WorkflowRandomStreamSource { + #[cfg(not(target_arch = "wasm32"))] + let seed = rand::random(); + #[cfg(target_arch = "wasm32")] + let seed = { + // wasm32-unknown-unknown has no system entropy source by default, so RandomState uses the + // standard library's allocation-address fallback and varies its keys between constructions. + // This stream is only used when replay safety is not required; the important property here + // is that generating incidental identifiers does not consume workflow randomness. + let mut hasher = std::hash::BuildHasher::build_hasher(&std::hash::RandomState::new()); + std::hash::Hasher::write(&mut hasher, b"temporal-rust-system-random-stream"); + std::hash::Hasher::finish(&hasher) + }; + + WorkflowRandomStreamSource::System(Rc::new(RefCell::new(Pcg64Mcg::seed_from_u64(seed)))) +} + fn named_random_seed(randomness_seed: u64, name: &str) -> u64 { // The fixed second key provides domain separation and is part of replay compatibility. let second_key = randomness_seed ^ u64::from_be_bytes(*b"temporal"); @@ -194,6 +221,40 @@ fn named_random_seed(randomness_seed: u64, name: &str) -> u64 { hasher.finish() } +#[derive(Clone, Debug)] +pub(super) struct WorkflowRandomState { + random: Pcg64Mcg, + randomness_seed: u64, + named_random: HashMap, +} + +impl WorkflowRandomState { + fn new(randomness_seed: u64) -> Self { + Self { + random: Pcg64Mcg::seed_from_u64(randomness_seed), + randomness_seed, + named_random: HashMap::new(), + } + } + + fn random(&mut self) -> T { + ::sample(&mut self.random) + } + + fn named_random(&mut self, name: &str) -> T { + let random = self.named_random.entry(name.to_owned()).or_insert_with(|| { + Pcg64Mcg::seed_from_u64(named_random_seed(self.randomness_seed, name)) + }); + ::sample(random) + } + + fn reseed(&mut self, randomness_seed: u64) { + self.random = Pcg64Mcg::seed_from_u64(randomness_seed); + self.randomness_seed = randomness_seed; + self.named_random.clear(); + } +} + /// Non-generic base context containing all workflow execution infrastructure. /// /// This is used internally by futures and commands that don't need typed workflow state. @@ -241,6 +302,8 @@ impl PatchActivationCaller { run_id, init, payload_converter, + false, + None, ), } } @@ -282,16 +345,17 @@ impl BaseWorkflowContext { activation: &CoreWorkflowActivation, is_replaying_history_events: bool, ) { - let mut shared = self.inner.shared.borrow_mut(); - shared.activation = activation.clone(); - shared.is_replaying_history_events = is_replaying_history_events; - if let Some(seed) = activation.jobs.iter().find_map(|job| match &job.variant { - Some(ActivationVariant::UpdateRandomSeed(attrs)) => Some(attrs.randomness_seed), - _ => None, - }) { - shared.random = Pcg64Mcg::seed_from_u64(seed); - shared.randomness_seed = seed; - shared.named_random.clear(); + let new_seed = { + let mut shared = self.inner.shared.borrow_mut(); + shared.activation = activation.clone(); + shared.is_replaying_history_events = is_replaying_history_events; + activation.jobs.iter().find_map(|job| match &job.variant { + Some(ActivationVariant::UpdateRandomSeed(attrs)) => Some(attrs.randomness_seed), + _ => None, + }) + }; + if let Some(seed) = new_seed { + self.inner.random.borrow_mut().reseed(seed); } } @@ -299,26 +363,12 @@ impl BaseWorkflowContext { where T: WorkflowRandomValue, { - let random = &mut self.inner.shared.borrow_mut().random; - ::sample(random) - } - - fn named_random(&self, name: &str) -> T - where - T: WorkflowRandomValue, - { - let mut shared = self.inner.shared.borrow_mut(); - let seed = shared.randomness_seed; - let random = shared - .named_random - .entry(name.to_owned()) - .or_insert_with(|| Pcg64Mcg::seed_from_u64(named_random_seed(seed, name))); - ::sample(random) + self.inner.random.borrow_mut().random() } pub(crate) fn random_stream(&self, name: impl Into) -> WorkflowRandomStream { WorkflowRandomStream { - base: self.clone(), + source: WorkflowRandomStreamSource::Workflow(self.inner.random.clone()), name: name.into(), } } @@ -394,6 +444,18 @@ impl BaseWorkflowContext { self.inner.shared.borrow().is_replaying_history_events } + fn requires_replay_safety(&self) -> bool { + self.inner.requires_replay_safety.get() + } + + pub(crate) fn enter_read_only(&self) -> ReadOnlyGuard { + let previous = self.inner.requires_replay_safety.replace(false); + ReadOnlyGuard { + base: self.clone(), + previous, + } + } + /// Returns the payload converter used by the worker running this workflow. pub fn payload_converter(&self) -> &PayloadConverter { self.inner.data_converter.payload_converter() @@ -455,6 +517,8 @@ impl BaseWorkflowContext { self.inner.run_id.clone(), initial_information, self.inner.data_converter.payload_converter().clone(), + self.requires_replay_safety(), + Some(self.inner.random.clone()), ) } } @@ -558,11 +622,13 @@ struct WorkflowContextInner { cancellation_token: WorkflowCancellationToken, cancelled_operations: RefCell>, shared: RefCell, + random: Rc>, seq_nums: RefCell, data_converter: DataConverter, patch_activation_callback: Option, state_mutated: Cell, active_handlers: Cell, + requires_replay_safety: Cell, condition_wakers: RefCell>, current_waker: RefCell>, workflow_interceptors: Rc<[Arc]>, @@ -572,6 +638,17 @@ pub(crate) struct HandlerExecutionGuard { base: BaseWorkflowContext, } +pub(crate) struct ReadOnlyGuard { + base: BaseWorkflowContext, + previous: bool, +} + +impl Drop for ReadOnlyGuard { + fn drop(&mut self) { + self.base.inner.requires_replay_safety.set(self.previous); + } +} + impl Drop for HandlerExecutionGuard { fn drop(&mut self) { let active_handlers = self.base.inner.active_handlers.get(); @@ -669,12 +746,17 @@ impl BaseWorkflowContext { run_id, initialize_workflow, } = init; + let random = Rc::new(RefCell::new(WorkflowRandomState::new( + initialize_workflow.randomness_seed, + ))); let view = WorkflowContextView::new( namespace, task_queue, run_id, initialize_workflow, data_converter.payload_converter().clone(), + true, + Some(random.clone()), ); let workflow_interceptors = workflow_interceptor_constructors .into_iter() @@ -688,9 +770,6 @@ impl BaseWorkflowContext { task_queue, run_id, shared: RefCell::new(WorkflowContextSharedData { - random: Pcg64Mcg::seed_from_u64(init_workflow_job.randomness_seed), - randomness_seed: init_workflow_job.randomness_seed, - named_random: HashMap::new(), memo: init_workflow_job.memo.clone().unwrap_or_default(), search_attributes: init_workflow_job .search_attributes @@ -702,6 +781,7 @@ impl BaseWorkflowContext { current_details: Default::default(), notified_patches: Default::default(), }), + random, initial_information: init_workflow_job, runtime: WorkflowRuntimeState::new(host), cancellation_token: WorkflowCancellationToken::new(), @@ -719,6 +799,7 @@ impl BaseWorkflowContext { patch_activation_callback, state_mutated: Cell::new(false), active_handlers: Cell::new(0), + requires_replay_safety: Cell::new(true), condition_wakers: Default::default(), current_waker: RefCell::new(None), workflow_interceptors, @@ -1729,6 +1810,7 @@ impl SyncWorkflowContext { let res = if deprecated || replaying || notified { !replaying || notified } else if let Some(callback) = &self.base.inner.patch_activation_callback { + let _read_only = self.base.enter_read_only(); callback(PatchActivationInput { workflow_info: self.base.view(), patch_id: patch_id.to_string(), @@ -2328,9 +2410,6 @@ struct WorkflowContextSharedData { memo: ProtoMemo, is_replaying_history_events: bool, search_attributes: ProtoSearchAttributes, - random: Pcg64Mcg, - randomness_seed: u64, - named_random: HashMap, /// Current details string, surfaced via the workflow metadata query. current_details: String, } @@ -3821,8 +3900,16 @@ mod tests { let callback_calls = calls.clone(); let callback_input = input.clone(); let callback: PatchActivationCallback = Arc::new(move |value| { + assert!(matches!( + value.workflow_info.random_stream("plugin").source, + WorkflowRandomStreamSource::System(_) + )); callback_calls.fetch_add(1, AtomicOrdering::Relaxed); - *callback_input.lock().unwrap() = Some(value); + *callback_input.lock().unwrap() = Some(( + value.workflow_info.workflow_id().to_string(), + value.workflow_info.run_id().to_string(), + value.patch_id, + )); true }); let (_, ctx, commands) = patch_test_context(Some(callback)); @@ -3833,9 +3920,9 @@ mod tests { assert_eq!(commands.borrow().len(), 1); let input = input.lock().unwrap(); let input = input.as_ref().unwrap(); - assert_eq!(input.workflow_info.workflow_id(), "workflow-id"); - assert_eq!(input.workflow_info.run_id(), "run-id"); - assert_eq!(input.patch_id, "my-patch"); + assert_eq!(input.0, "workflow-id"); + assert_eq!(input.1, "run-id"); + assert_eq!(input.2, "my-patch"); } #[test] @@ -3992,6 +4079,50 @@ mod tests { assert_eq!(second, expected.random::()); } + #[test] + fn replay_safe_context_view_shares_workflow_randomness() { + let ctx = test_context_with_seed(42); + let first = ctx.sync.base.view().random_stream("plugin").random::(); + let second = ctx.random_stream("plugin").random::(); + + let expected = test_context_with_seed(42).random_stream("plugin"); + assert_eq!(first, expected.random::()); + assert_eq!(second, expected.random::()); + } + + #[test] + fn read_only_context_view_does_not_advance_workflow_randomness() { + let ctx = test_context_with_seed(42); + let expected = test_context_with_seed(42) + .random_stream("plugin") + .random::(); + + { + let _read_only = ctx.sync.base.enter_read_only(); + let _ = ctx.sync.base.view().random_stream("plugin").random::(); + } + + assert_eq!(ctx.random_stream("plugin").random::(), expected); + } + + #[test] + fn nested_read_only_scopes_restore_replay_safety() { + let ctx = test_context_with_seed(42); + assert!(ctx.sync.base.requires_replay_safety()); + + { + let _outer = ctx.sync.base.enter_read_only(); + assert!(!ctx.sync.base.requires_replay_safety()); + { + let _inner = ctx.sync.base.enter_read_only(); + assert!(!ctx.sync.base.requires_replay_safety()); + } + assert!(!ctx.sync.base.requires_replay_safety()); + } + + assert!(ctx.sync.base.requires_replay_safety()); + } + #[test] fn named_random_streams_are_reseeded_by_activation() { let ctx = test_context_with_seed(123); diff --git a/crates/workflow/src/workflow_context/view.rs b/crates/workflow/src/workflow_context/view.rs index 1932798b0..0956bbb17 100644 --- a/crates/workflow/src/workflow_context/view.rs +++ b/crates/workflow/src/workflow_context/view.rs @@ -1,4 +1,9 @@ -use std::time::{Duration, SystemTime}; +use super::{WorkflowRandomState, WorkflowRandomStream, WorkflowRandomStreamSource}; +use std::{ + cell::RefCell, + rc::Rc, + time::{Duration, SystemTime}, +}; use temporalio_common_wasm::{ Memo, Priority, RetryPolicy, WorkflowExecution, @@ -20,6 +25,8 @@ pub struct WorkflowContextView { task_queue: String, run_id: String, payload_converter: PayloadConverter, + requires_replay_safety: bool, + workflow_random: Option>>, } impl WorkflowContextView { @@ -30,6 +37,8 @@ impl WorkflowContextView { run_id: String, raw: InitializeWorkflow, payload_converter: PayloadConverter, + requires_replay_safety: bool, + workflow_random: Option>>, ) -> Self { Self { raw, @@ -37,6 +46,8 @@ impl WorkflowContextView { task_queue, run_id, payload_converter, + requires_replay_safety, + workflow_random, } } @@ -156,6 +167,26 @@ impl WorkflowContextView { .map(SearchAttributes::from_proto) } + #[allow( + dead_code, + reason = "used by SDK-provided interceptors built separately from this change" + )] + pub(crate) fn random_stream(&self, name: impl Into) -> WorkflowRandomStream { + let source = if self.requires_replay_safety { + WorkflowRandomStreamSource::Workflow( + self.workflow_random + .clone() + .expect("replay-safe context views must have workflow randomness"), + ) + } else { + super::system_random_stream_source() + }; + WorkflowRandomStream { + source, + name: name.into(), + } + } + /// Accesses the underlying workflow initialization protobuf. pub fn raw(&self) -> &InitializeWorkflow { &self.raw From 173afc0269592749e77cc773ff8c3153d307d429 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 2 Sep 2026 10:54:45 -0500 Subject: [PATCH 53/87] Make SdkWakeGuard thread-bound (#1562) --- CHANGELOG.md | 2 ++ crates/workflow/src/runtime/mod.rs | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae904b69e..58edff709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ relevant information. ## Unreleased ### Breaking Changes +* `SdkWakeGuard` is no longer `Send` or `Sync`, preventing the thread-local guard from being moved + to or referenced from another thread. * `WorkflowContextView` and the containing `PatchActivationInput` are no longer `Send` or `Sync` because workflow context views now share single-threaded workflow randomness with replay-sensitive SDK integrations. diff --git a/crates/workflow/src/runtime/mod.rs b/crates/workflow/src/runtime/mod.rs index 5e49236e6..018eea3d0 100644 --- a/crates/workflow/src/runtime/mod.rs +++ b/crates/workflow/src/runtime/mod.rs @@ -7,6 +7,7 @@ use crate::runtime::types::RoutinePendingState; use std::{ cell::{Cell, RefCell}, future::Future, + marker::PhantomData, pin::Pin, rc::Rc, task::{Context, Poll}, @@ -239,14 +240,16 @@ pub(crate) fn mark_intercepted_handler_ready() { /// Guard that marks the current scope as an SDK-initiated wake source. #[doc(hidden)] pub struct SdkWakeGuard { - _priv: (), + _not_send_or_sync: PhantomData>, } impl SdkWakeGuard { #[doc(hidden)] pub fn new() -> Self { SDK_WAKE_DEPTH.with(|c| c.set(c.get() + 1)); - Self { _priv: () } + Self { + _not_send_or_sync: PhantomData, + } } } From 98bf1639fd20654b2a79305f8ba069484852f71b Mon Sep 17 00:00:00 2001 From: sdk-sentinel-bot Date: Wed, 2 Sep 2026 09:25:45 -0700 Subject: [PATCH 54/87] Synchronize cgroup memory test refresh (#1559) --- crates/sdk-core/src/worker/tuner/resource_based.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/sdk-core/src/worker/tuner/resource_based.rs b/crates/sdk-core/src/worker/tuner/resource_based.rs index 4ea9401ee..e5fc9b6f6 100644 --- a/crates/sdk-core/src/worker/tuner/resource_based.rs +++ b/crates/sdk-core/src/worker/tuner/resource_based.rs @@ -778,7 +778,6 @@ mod tests { Arc, atomic::{AtomicU64, Ordering}, }, - thread::sleep, }; struct FakeMIS { @@ -1205,8 +1204,8 @@ mod tests { let to_allocate: usize = (half_total).saturating_sub(cur_used) as usize; let _buf = black_box(vec![1u8; to_allocate]); - // make sure we sleep enough to let real_sys_info need a refresh - sleep(Duration::from_millis(200)); + // Refresh synchronously so the assertion cannot race the background sampler. + sys_info.inner.refresh(); let percentage = sys_info.used_mem_percent(); let diff = (percentage - expected_percentage).abs(); From 2f7ef34598e4733f77eea84dd4645af49f4ba8ef Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 2 Sep 2026 12:28:13 -0400 Subject: [PATCH 55/87] chore(sdk): make publish idempotent (#1561) --- .github/workflows/create-release.yml | 18 +- crates/changelog-release-notes/Cargo.toml | 3 + .../src/bin/plan-release-publish.rs | 166 ++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 crates/changelog-release-notes/src/bin/plan-release-publish.rs diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index bad159c01..f154e592a 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -66,14 +66,30 @@ jobs: --changelog rust \ >"$RUNNER_TEMP/release-notes.md" + - name: Plan crate publication + id: publish-plan + run: cargo run --quiet -p changelog-release-notes --bin plan-release-publish >>"$GITHUB_OUTPUT" + - name: Authenticate to crates.io + if: steps.publish-plan.outputs.packages != '[]' id: auth uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1 - name: Publish crates + if: steps.publish-plan.outputs.packages != '[]' env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} - run: cargo publish --workspace + PUBLISH_PACKAGES: ${{ steps.publish-plan.outputs.packages }} + shell: bash + run: | + jq -e 'type == "array" and length > 0 and all(.[]; type == "string" and length > 0)' \ + <<<"$PUBLISH_PACKAGES" >/dev/null + mapfile -t packages < <(jq -r '.[]' <<<"$PUBLISH_PACKAGES") + publish_args=() + for package in "${packages[@]}"; do + publish_args+=(--package "$package") + done + cargo publish "${publish_args[@]}" - name: Create draft GitHub release env: diff --git a/crates/changelog-release-notes/Cargo.toml b/crates/changelog-release-notes/Cargo.toml index 341081517..1cfbc28b6 100644 --- a/crates/changelog-release-notes/Cargo.toml +++ b/crates/changelog-release-notes/Cargo.toml @@ -9,7 +9,10 @@ default-run = "changelog-release-notes" [dependencies] chrono = { version = "=0.4.45", default-features = false, features = ["clock"] } +reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } semver = "=1.0.28" +serde = { version = "1.0", features = ["derive"] } +serde_json = { workspace = true } [lints] workspace = true diff --git a/crates/changelog-release-notes/src/bin/plan-release-publish.rs b/crates/changelog-release-notes/src/bin/plan-release-publish.rs new file mode 100644 index 000000000..d22fd78bc --- /dev/null +++ b/crates/changelog-release-notes/src/bin/plan-release-publish.rs @@ -0,0 +1,166 @@ +use std::{process::Command, time::Duration}; + +use reqwest::{StatusCode, blocking::Client}; +use serde::Deserialize; + +const CRATES_IO_SPARSE_INDEX: &str = "https://index.crates.io"; +const USER_AGENT: &str = + "temporalio/sdk-rust release planner (https://github.com/temporalio/sdk-rust)"; + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct Metadata { + packages: Vec, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct Package { + name: String, + version: String, + publish: Option>, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct IndexEntry { + vers: String, +} + +fn workspace_metadata() -> Result { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--no-deps"]) + .output() + .map_err(|err| format!("failed to run cargo metadata: {err}"))?; + if !output.status.success() { + return Err(format!( + "cargo metadata failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + serde_json::from_slice(&output.stdout) + .map_err(|err| format!("failed to parse cargo metadata: {err}")) +} + +fn crates_io_packages(metadata: Metadata) -> impl Iterator { + metadata.packages.into_iter().filter(|package| { + package + .publish + .as_ref() + .is_none_or(|registries| registries.iter().any(|registry| registry == "crates-io")) + }) +} + +fn sparse_index_path(name: &str) -> String { + let name = name.to_ascii_lowercase(); + // See https://doc.rust-lang.org/cargo/reference/registry-index.html#index-files + match name.len() { + 1 => format!("1/{name}"), + 2 => format!("2/{name}"), + 3 => format!("3/{}/{name}", &name[..1]), + _ => format!("{}/{}/{name}", &name[..2], &name[2..4]), + } +} + +fn version_in_index(index: &str, version: &str) -> Result { + for line in index.lines() { + let entry: IndexEntry = serde_json::from_str(line) + .map_err(|err| format!("failed to parse sparse index entry: {err}"))?; + if entry.vers == version { + return Ok(true); + } + } + Ok(false) +} + +fn version_is_published(client: &Client, package: &Package) -> Result { + let url = format!( + "{CRATES_IO_SPARSE_INDEX}/{}", + sparse_index_path(&package.name) + ); + let response = client.get(&url).send().map_err(|err| { + format!( + "failed to check {}@{}: {err}", + package.name, package.version + ) + })?; + match response.status() { + StatusCode::OK => { + let index = response.text().map_err(|err| { + format!( + "failed to check {}@{}: failed to read sparse index entry: {err}", + package.name, package.version + ) + })?; + version_in_index(&index, &package.version).map_err(|err| { + format!( + "failed to check {}@{}: {err}", + package.name, package.version + ) + }) + } + StatusCode::NOT_FOUND => Ok(false), + status => Err(format!( + "failed to check {}@{}: sparse index returned unexpected HTTP status {status}", + package.name, package.version + )), + } +} + +fn main() -> Result<(), String> { + let client = Client::builder() + .user_agent(USER_AGENT) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|err| format!("failed to create crates.io client: {err}"))?; + + let mut unpublished = Vec::new(); + for package in crates_io_packages(workspace_metadata()?) { + if version_is_published(&client, &package)? { + eprintln!( + "{}@{} is already published; skipping.", + package.name, package.version + ); + } else { + unpublished.push(package.name); + } + } + println!( + "packages={}", + serde_json::to_string(&unpublished) + .map_err(|err| format!("failed to serialize publish plan: {err}"))? + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selects_packages_publishable_to_crates_io() { + let packages = crates_io_packages(workspace_metadata().expect("workspace metadata")) + .map(|package| package.name) + .collect::>(); + + assert!(packages.iter().any(|package| package == "temporalio-sdk")); + assert!( + !packages + .iter() + .any(|package| package == "temporalio-sdk-core-c-bridge") + ); + } + + #[test] + fn builds_sparse_index_paths() { + assert_eq!(sparse_index_path("temporalio-sdk"), "te/mp/temporalio-sdk"); + } + + #[test] + fn finds_versions_in_sparse_index_entries() { + let index = r#"{"vers":"0.7.0","yanked":true} +{"vers":"0.8.0","yanked":false}"#; + + assert_eq!(version_in_index(index, "0.7.0"), Ok(true)); + assert_eq!(version_in_index(index, "0.8.0"), Ok(true)); + assert_eq!(version_in_index(index, "0.9.0"), Ok(false)); + assert!(version_in_index("not json", "0.7.0").is_err()); + } +} From 207acc165c8091421a3eb41aef65b1ca53ae6aa1 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 2 Sep 2026 12:56:17 -0400 Subject: [PATCH 56/87] chore(sdk): prepare for 0.8.0 release (#1560) --- CHANGELOG.md | 2 ++ crates/client/Cargo.toml | 8 ++++---- crates/common-wasm/Cargo.toml | 4 ++-- crates/common/Cargo.toml | 6 +++--- crates/macros/Cargo.toml | 4 ++-- crates/protos/Cargo.toml | 2 +- crates/sdk-core-c-bridge/Cargo.toml | 6 +++--- crates/sdk-core/Cargo.toml | 12 ++++++------ crates/sdk/Cargo.toml | 12 ++++++------ crates/workflow/Cargo.toml | 6 +++--- 10 files changed, 32 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58edff709..58ab40ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ relevant information. ## Unreleased +## [0.8.0] - 2026-09-02 + ### Breaking Changes * `SdkWakeGuard` is no longer `Send` or `Sync`, preventing the thread-local guard from being moved to or referenced from another thread. diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 259a5a667..6dff22464 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-client" -version = "0.7.0" +version = "0.8.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -65,7 +65,7 @@ serde_json = { workspace = true } [dependencies.temporalio-common] path = "../common" -version = "~0.7.0" +version = "~0.8.0" default-features = false features = ["serde_serialize"] @@ -76,8 +76,8 @@ prost = "0.14" prost-types = { workspace = true } rstest = "0.26" tempfile = "3" -temporalio-macros = { path = "../macros", version = "~0.7.0" } -temporalio-workflow = { path = "../workflow", version = "~0.7.0" } +temporalio-macros = { path = "../macros", version = "~0.8.0" } +temporalio-workflow = { path = "../workflow", version = "~0.8.0" } tokio = { version = "1.47", default-features = false, features = [ "io-util", "macros", diff --git a/crates/common-wasm/Cargo.toml b/crates/common-wasm/Cargo.toml index 5ebfff109..f5f96dd32 100644 --- a/crates/common-wasm/Cargo.toml +++ b/crates/common-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-common-wasm" -version = "0.7.0" +version = "0.8.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -45,7 +45,7 @@ tracing-core = "0.1" url = "2.5" [dependencies.temporalio-protos] path = "../protos" -version = "~0.7.0" +version = "~0.8.0" [lints] workspace = true diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 26ab9e234..67cd24f9b 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-common" -version = "0.7.0" +version = "0.8.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -106,12 +106,12 @@ uuid = { version = "1.18", default-features = false, features = ["v4"] } [dependencies.temporalio-protos] path = "../protos" -version = "~0.7.0" +version = "~0.8.0" features = ["grpc-clients"] [dependencies.temporalio-common-wasm] path = "../common-wasm" -version = "~0.7.0" +version = "~0.8.0" [build-dependencies] prost = { workspace = true } diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 2f47c9fce..2e00daa5c 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-macros" -version = "0.7.0" +version = "0.8.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -20,7 +20,7 @@ quote = "1.0" [dev-dependencies] # This is to enable doctests for the macros -temporalio-common = { path = "../common", version = "~0.7.0" } +temporalio-common = { path = "../common", version = "~0.8.0" } derive_more = { workspace = true } [package.metadata.workspaces] diff --git a/crates/protos/Cargo.toml b/crates/protos/Cargo.toml index 7974f7ed1..e756990b4 100644 --- a/crates/protos/Cargo.toml +++ b/crates/protos/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-protos" -version = "0.7.0" +version = "0.8.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } diff --git a/crates/sdk-core-c-bridge/Cargo.toml b/crates/sdk-core-c-bridge/Cargo.toml index d579762c9..57e7f3d42 100644 --- a/crates/sdk-core-c-bridge/Cargo.toml +++ b/crates/sdk-core-c-bridge/Cargo.toml @@ -41,17 +41,17 @@ xz2 = { version = "0.1", optional = true } [dependencies.temporalio-client] path = "../client" -version = "~0.7.0" +version = "~0.8.0" features = ["experimental"] [dependencies.temporalio-sdk-core] path = "../sdk-core" -version = "=0.7.0" +version = "=0.8.0" features = ["ephemeral-server", "otel"] [dependencies.temporalio-common] path = "../common" -version = "~0.7.0" +version = "~0.8.0" features = ["core-based-sdk", "otel"] [dev-dependencies] diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index af482e458..2f128d5ec 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-sdk-core" -version = "0.7.0" +version = "0.8.0" authors = ["Temporal Technologies Inc. "] edition = "2024" license-file = { workspace = true } @@ -131,19 +131,19 @@ zip = { version = "8.4", optional = true, default-features = false, features = [ # 1st party local deps [dependencies.temporalio-common] path = "../common" -version = "~0.7.0" +version = "~0.8.0" default-features = false features = ["core-telemetry-bridge", "serde_serialize"] [dependencies.temporalio-client] path = "../client" -version = "~0.7.0" +version = "~0.8.0" default-features = false features = ["core-based-sdk"] [dependencies.temporalio-macros] path = "../macros" -version = "~0.7.0" +version = "~0.8.0" [dev-dependencies] assert_matches = "1.5" @@ -169,8 +169,8 @@ temporalio-sdk = { path = "../sdk", features = [ "testing", "wasm-workflows", ] } -temporalio-common = { path = "../common", version = "~0.7.0", default-features = false } -temporalio-workflow = { path = "../workflow", version = "~0.7.0", features = [ +temporalio-common = { path = "../common", version = "~0.8.0", default-features = false } +temporalio-workflow = { path = "../workflow", version = "~0.8.0", features = [ "experimental", ] } tokio = { version = "1.47", default-features = false, features = [ diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index b858eca96..9dc416f93 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-sdk" -version = "0.7.0" +version = "0.8.0" edition = "2024" authors = ["Spencer Judge "] license-file = { workspace = true } @@ -46,26 +46,26 @@ url = { version = "2.5", optional = true } [dependencies.temporalio-sdk-core] path = "../sdk-core" -version = "=0.7.0" +version = "=0.8.0" default-features = false [dependencies.temporalio-workflow] path = "../workflow" -version = "~0.7.0" +version = "~0.8.0" [dependencies.temporalio-common] path = "../common" -version = "~0.7.0" +version = "~0.8.0" default-features = false [dependencies.temporalio-client] path = "../client" -version = "~0.7.0" +version = "~0.8.0" default-features = false [dependencies.temporalio-macros] path = "../macros" -version = "~0.7.0" +version = "~0.8.0" [dev-dependencies] futures = "0.3" diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index b1d325e1a..63dce1d83 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-workflow" -version = "0.7.0" +version = "0.8.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -43,11 +43,11 @@ rand = { version = "0.10", default-features = false, features = ["thread_rng"] } [dependencies.temporalio-common-wasm] path = "../common-wasm" -version = "~0.7.0" +version = "~0.8.0" [dependencies.temporalio-macros] path = "../macros" -version = "~0.7.0" +version = "~0.8.0" [dev-dependencies] rstest = "0.26" From 9f1acdb99126163788a37f08d6b6fb797be2051e Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 2 Sep 2026 14:36:28 -0400 Subject: [PATCH 57/87] chore(sdk): resolve some doc(hidden) APIs (#1563) * chore(sdk): move http proxy tests to avoid doc hiding * chore(sdk): hide a few types that had no external usage * chore(sdk): make event group markers visible * chore(sdk): move retryable tests, make codes private * chore(sdk): remove nondetermistic future setter on worker * chore(sdk): remove payload_limit_violation_from public helper * chore(sdk): remove doc_hidden from Memo::from_raw * chore(sdk): limit macro rexports * chore(sdk): remove private ref from docs --- crates/client/Cargo.toml | 4 + crates/client/src/grpc.rs | 9 +- crates/client/src/lib.rs | 19 +- crates/client/src/proxy.rs | 284 +++++++++++++++++- crates/client/src/request_extensions.rs | 2 +- crates/client/src/retry.rs | 66 +++- crates/common-wasm/src/memo.rs | 1 - crates/macros/src/workflow_definitions.rs | 24 +- crates/sdk-core-c-bridge/src/client.rs | 4 +- crates/sdk-core/src/worker/activities.rs | 7 +- .../activities/activity_heartbeat_manager.rs | 3 +- crates/sdk-core/src/worker/client.rs | 6 + crates/sdk-core/src/worker/nexus.rs | 6 +- crates/sdk-core/src/worker/workflow/mod.rs | 7 +- crates/sdk-core/tests/common/http_proxy.rs | 134 --------- crates/sdk-core/tests/common/mod.rs | 1 - .../tests/integ_tests/client_tests.rs | 140 +-------- .../integ_tests/workflow_tests/timers.rs | 6 +- crates/sdk/src/lib.rs | 9 +- crates/workflow/src/lib.rs | 9 +- crates/workflow/src/runtime/model.rs | 6 +- .../workflow/src/workflow_context/options.rs | 25 +- 22 files changed, 412 insertions(+), 360 deletions(-) delete mode 100644 crates/sdk-core/tests/common/http_proxy.rs diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 6dff22464..8b1134e8e 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -71,6 +71,8 @@ features = ["serde_serialize"] [dev-dependencies] assert_matches = "1" +hyper = { version = "1.7.0", features = ["http1", "server"] } +hyper-util = { version = "0.1.16", features = ["http1", "server", "tokio"] } mockall = "0.14" prost = "0.14" prost-types = { workspace = true } @@ -86,6 +88,8 @@ tokio = { version = "1.47", default-features = false, features = [ "sync", "time", ] } +tokio-stream = { version = "0.1", default-features = false, features = ["net"] } +tonic = { workspace = true, default-features = false, features = ["router", "server"] } trybuild = { version = "1.0", features = ["diff"] } [lints] diff --git a/crates/client/src/grpc.rs b/crates/client/src/grpc.rs index 0ec015c02..1c3d5b0a6 100644 --- a/crates/client/src/grpc.rs +++ b/crates/client/src/grpc.rs @@ -193,7 +193,7 @@ fn req_cloner(cloneme: &Request) -> Request { /// `*_warn` are the connection's configured warn thresholds; per-call error limits ride a /// [`PayloadErrorLimits`] extension. On an error-level violation, returns a [`Status`] carrying -/// the [`PayloadLimitViolation`] as its source (extract via [crate::payload_limit_violation_from]). +/// the payload limit violation as its source. fn validate_request_payload_limits( req: &Request, blob_warn: usize, @@ -2055,8 +2055,11 @@ mod tests { req.extensions_mut() .insert(PayloadErrorLimits { blob: 10, memo: 10 }); let err = validate_request_payload_limits(&req, 1, 1).unwrap_err(); - let violation = - crate::payload_limit_violation_from(&err).expect("violation carried on status"); + let violation = std::error::Error::source(&err) + .and_then(|source| { + source.downcast_ref::() + }) + .expect("violation carried on status"); assert_eq!(violation.path, "input"); assert_eq!( violation.class, diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 9e8e96c39..2c4ca7977 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -24,9 +24,7 @@ mod options_structs; #[cfg(feature = "experimental")] /// Experimental APIs for configuring clients with reusable plugins. pub mod plugins; -/// Visible only for tests -#[doc(hidden)] -pub mod proxy; +mod proxy; mod replaceable; pub mod request_extensions; mod retry; @@ -39,15 +37,12 @@ pub mod worker; mod workflow_handle; mod workflow_status; -pub use crate::{ - proxy::HttpConnectProxyOptions, - request_extensions::PayloadErrorLimits, - retry::{CallType, RETRYABLE_ERROR_CODES}, -}; +pub use crate::{proxy::HttpConnectProxyOptions, request_extensions::PayloadErrorLimits}; pub use activity::*; pub use async_activity_handle::{ ActivityHeartbeatResponse, ActivityIdentifier, AsyncActivityHandle, }; +pub(crate) use retry::CallType; #[doc(hidden)] pub use retry::jittered; @@ -194,14 +189,6 @@ static TEMPORAL_NAMESPACE_HEADER_KEY: &str = "temporal-namespace"; /// Key used to communicate when a GRPC message is too large pub static MESSAGE_TOO_LARGE_KEY: &str = "message-too-large"; #[doc(hidden)] -/// Returns the violation, if `status` is the client proactively rejecting an outbound request for exceeding a -/// payload/memo error size limit. -pub fn payload_limit_violation_from( - status: &tonic::Status, -) -> Option<&temporalio_common::payload_limits::PayloadLimitViolation> { - std::error::Error::source(status).and_then(|src| src.downcast_ref()) -} -#[doc(hidden)] /// Key used to indicate a error was returned by the retryer because of the short-circuit predicate pub static ERROR_RETURNED_DUE_TO_SHORT_CIRCUIT: &str = "short-circuit"; diff --git a/crates/client/src/proxy.rs b/crates/client/src/proxy.rs index df1fd3eca..09ae6ae13 100644 --- a/crates/client/src/proxy.rs +++ b/crates/client/src/proxy.rs @@ -108,9 +108,7 @@ impl Service for OverrideAddrConnector { } } -/// Visible only for tests -#[doc(hidden)] -pub enum ProxyStream { +enum ProxyStream { Tcp(TcpStream), #[cfg(unix)] Unix(UnixStream), @@ -234,11 +232,214 @@ fn ensure_connect_authority_port(uri: tonic::transport::Uri) -> tonic::transport #[cfg(test)] mod tests { - use super::*; + use super::{HttpConnectProxyOptions, ProxyStream}; + use crate::{ + Client, ClientOptions, Connection as TemporalConnection, ConnectionOptions, RetryOptions, + grpc::WorkflowService, + }; + use base64::prelude::*; + use futures_util::{FutureExt, future::BoxFuture}; + use http::{Request, Response}; + use http_body_util::Empty; + use hyper::{ + body::{Bytes, Incoming}, + server::conn::http1, + service::service_fn, + }; + use hyper_util::rt::TokioIo; + use std::{ + convert::Infallible, + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll}, + }; + use temporalio_common::protos::temporal::api::workflowservice::v1::ListNamespacesRequest; + #[cfg(unix)] + use tokio::net::UnixListener; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, - net::TcpListener, + net::{TcpListener, TcpStream}, + sync::oneshot, }; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::{IntoRequest, body::Body, server::NamedService, transport::Server}; + use tower::Service; + use tracing::warn; + use url::Url; + + #[derive(Clone)] + struct FakeWorkflowService(F); + + impl Service> for FakeWorkflowService + where + F: FnMut(Request) -> BoxFuture<'static, Response>, + { + type Response = Response; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: Request) -> Self::Future { + let response = (self.0)(request); + async move { Ok(response.await) }.boxed() + } + } + + impl NamedService for FakeWorkflowService { + const NAME: &'static str = "temporal.api.workflowservice.v1.WorkflowService"; + } + + struct FakeServer { + addr: std::net::SocketAddr, + shutdown_tx: oneshot::Sender<()>, + } + + async fn fake_server(response_maker: F) -> FakeServer + where + F: FnMut(Request) -> BoxFuture<'static, Response> + + Clone + + Send + + Sync + + 'static, + { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let listener = TcpListener::bind("[::]:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + Server::builder() + .add_service(FakeWorkflowService(response_maker)) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + FakeServer { addr, shutdown_tx } + } + + struct HttpProxy { + proxy_hits: Arc, + shutdown_tx: oneshot::Sender<()>, + } + + impl HttpProxy { + fn spawn_tcp(listener: TcpListener) -> Self { + Self::spawn(ProxyListener::Tcp(listener)) + } + + #[cfg(unix)] + fn spawn_unix(listener: UnixListener) -> Self { + Self::spawn(ProxyListener::Unix(listener)) + } + + fn spawn(listener: ProxyListener) -> Self { + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let proxy_hits = Arc::new(AtomicUsize::new(0)); + let proxy_hits_for_task = proxy_hits.clone(); + tokio::spawn(async move { + loop { + let proxy_hits = proxy_hits_for_task.clone(); + tokio::select! { + _ = &mut shutdown_rx => break, + stream = listener.accept() => { + let stream = match stream { + Ok(stream) => stream, + Err(error) => { + warn!(%error, "Proxy accept failed"); + continue; + } + }; + tokio::spawn(async move { + if let Err(error) = http1::Builder::new() + .serve_connection( + TokioIo::new(stream), + service_fn(move |request| { + handle_connect(request, proxy_hits.clone()) + }), + ) + .with_upgrades() + .await + { + warn!(%error, "Proxy connection failed"); + } + }); + } + } + } + }); + Self { + proxy_hits, + shutdown_tx, + } + } + + fn hit_count(&self) -> usize { + self.proxy_hits.load(Ordering::SeqCst) + } + + fn shutdown(self) { + let _ = self.shutdown_tx.send(()); + } + } + + async fn handle_connect( + request: Request, + counter: Arc, + ) -> Result>, hyper::Error> { + if request.method() != hyper::Method::CONNECT { + return Ok(Response::builder() + .status(hyper::StatusCode::METHOD_NOT_ALLOWED) + .body(Empty::new()) + .unwrap()); + } + + counter.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + if let Some(addr) = request + .uri() + .authority() + .map(|authority| authority.as_str()) + && let Ok(mut server_stream) = TcpStream::connect(addr).await + && let Ok(upgraded) = hyper::upgrade::on(request).await + { + let mut upgraded = TokioIo::new(upgraded); + let _ = tokio::io::copy_bidirectional(&mut upgraded, &mut server_stream).await; + } + }); + + Ok(Response::builder() + .status(hyper::StatusCode::OK) + .body(Empty::new()) + .unwrap()) + } + + enum ProxyListener { + Tcp(TcpListener), + #[cfg(unix)] + Unix(UnixListener), + } + + impl ProxyListener { + async fn accept(&self) -> io::Result { + match self { + ProxyListener::Tcp(listener) => listener + .accept() + .await + .map(|(stream, _)| ProxyStream::Tcp(stream)), + #[cfg(unix)] + ProxyListener::Unix(listener) => listener + .accept() + .await + .map(|(stream, _)| ProxyStream::Unix(stream)), + } + } + } struct CapturedConnect { request_line: String, @@ -311,4 +512,77 @@ mod tests { format!("proxy-authorization: Basic {creds}") ); } + + #[tokio::test] + async fn connection_uses_http_connect_proxy() { + let call_count = Arc::new(AtomicUsize::new(0)); + let call_count_for_server = call_count.clone(); + let server = fake_server(move |_| { + call_count_for_server.fetch_add(1, Ordering::SeqCst); + async { Response::new(Body::empty()) }.boxed() + }) + .await; + + let tcp_proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let tcp_proxy_addr = tcp_proxy_listener.local_addr().unwrap(); + let tcp_proxy = HttpProxy::spawn_tcp(tcp_proxy_listener); + + let mut options = ConnectionOptions::new( + Url::parse(&format!("http://[::1]:{}", server.addr.port())).unwrap(), + ) + .retry_options(RetryOptions::no_retries()) + .skip_get_system_info(true) + .build(); + + let connection = TemporalConnection::connect(options.clone()).await.unwrap(); + let client_options = ClientOptions::new("my-namespace").build(); + let client = Client::new(connection, client_options).unwrap(); + let _ = WorkflowService::list_namespaces( + &mut client.clone(), + ListNamespacesRequest::default().into_request(), + ) + .await; + assert_eq!(call_count.load(Ordering::SeqCst), 1); + assert_eq!(tcp_proxy.hit_count(), 0); + + options.http_connect_proxy = + Some(HttpConnectProxyOptions::new(tcp_proxy_addr.to_string()).build()); + options.dns_load_balancing = None; + let connection = TemporalConnection::connect(options.clone()).await.unwrap(); + let client_options = ClientOptions::new("my-namespace").build(); + let proxied_client = Client::new(connection, client_options).unwrap(); + let _ = WorkflowService::list_namespaces( + &mut proxied_client.clone(), + ListNamespacesRequest::default().into_request(), + ) + .await; + assert_eq!(call_count.load(Ordering::SeqCst), 2); + assert_eq!(tcp_proxy.hit_count(), 1); + + #[cfg(unix)] + { + let socket_dir = tempfile::tempdir().unwrap(); + let socket_path = socket_dir.path().join("http-proxy.sock"); + let unix_proxy = HttpProxy::spawn_unix(UnixListener::bind(&socket_path).unwrap()); + + options.http_connect_proxy = Some( + HttpConnectProxyOptions::new(format!("unix:{}", socket_path.display())).build(), + ); + let connection = TemporalConnection::connect(options).await.unwrap(); + let client_options = ClientOptions::new("my-namespace").build(); + let proxied_client = Client::new(connection, client_options).unwrap(); + let _ = WorkflowService::list_namespaces( + &mut proxied_client.clone(), + ListNamespacesRequest::default().into_request(), + ) + .await; + assert_eq!(call_count.load(Ordering::SeqCst), 3); + assert_eq!(unix_proxy.hit_count(), 1); + + unix_proxy.shutdown(); + } + + let _ = server.shutdown_tx.send(()); + tcp_proxy.shutdown(); + } } diff --git a/crates/client/src/request_extensions.rs b/crates/client/src/request_extensions.rs index 6f6f614f2..3e4904648 100644 --- a/crates/client/src/request_extensions.rs +++ b/crates/client/src/request_extensions.rs @@ -7,7 +7,7 @@ use crate::RetryOptions; use std::time::Duration; /// A request extension that, when set, should make the retry behavior consider this call to be a -/// [CallType::TaskLongPoll](crate::CallType::TaskLongPoll) +/// worker task long poll. #[derive(Copy, Clone, Debug)] pub struct IsWorkerTaskLongPoll; diff --git a/crates/client/src/retry.rs b/crates/client/src/retry.rs index f655bf689..9ac2cdd81 100644 --- a/crates/client/src/retry.rs +++ b/crates/client/src/retry.rs @@ -14,8 +14,7 @@ use std::{ use tonic::Code; /// List of gRPC error codes that client will retry. -#[doc(hidden)] -pub const RETRYABLE_ERROR_CODES: [Code; 7] = [ +const RETRYABLE_ERROR_CODES: [Code; 7] = [ Code::DataLoss, Code::Internal, Code::Unknown, @@ -252,9 +251,8 @@ pub(crate) struct CallInfo { retry_short_circuit: Option, } -#[doc(hidden)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] -pub enum CallType { +pub(crate) enum CallType { Normal, // A long poll but won't always retry timeouts/cancels. EX: Get workflow history UserLongPoll, @@ -373,12 +371,25 @@ fn is_transport_cancelled(status: &tonic::Status) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::{ + Client, ClientOptions, Connection, ConnectionOptions, + callback_based::{CallbackBasedGrpcService, GrpcSuccessResponse}, + }; use assert_matches::assert_matches; - use std::time::Instant; + use prost::Message; + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Instant, + }; use temporalio_common::protos::temporal::api::workflowservice::v1::{ - PollActivityTaskQueueRequest, PollNexusTaskQueueRequest, PollWorkflowTaskQueueRequest, + CountWorkflowExecutionsResponse, PollActivityTaskQueueRequest, PollNexusTaskQueueRequest, + PollWorkflowTaskQueueRequest, }; use tonic::{IntoRequest, Status}; + use url::Url; /// Predefined retry configs with low durations to make unit tests faster const TEST_RETRY_CONFIG: RetryOptions = RetryOptions { @@ -394,6 +405,49 @@ mod tests { const POLL_ACTIVITY_METH_NAME: &str = "poll_activity_task_queue"; const POLL_NEXUS_METH_NAME: &str = "poll_nexus_task_queue"; + #[tokio::test] + async fn retryable_errors() { + // Resource exhausted has a separate retry policy and is covered below. + for code in RETRYABLE_ERROR_CODES + .iter() + .copied() + .filter(|code| code != &Code::ResourceExhausted) + { + let attempts = Arc::new(AtomicUsize::new(0)); + let callback_attempts = attempts.clone(); + let service_override = CallbackBasedGrpcService { + callback: Arc::new(move |request| { + assert_eq!(request.rpc, "CountWorkflowExecutions"); + let callback_attempts = callback_attempts.clone(); + Box::pin(async move { + if callback_attempts.fetch_add(1, Ordering::Relaxed) < 3 { + Err(Status::new(code, "retryable")) + } else { + Ok(GrpcSuccessResponse { + headers: Default::default(), + proto: CountWorkflowExecutionsResponse::default().encode_to_vec(), + }) + } + }) + }), + }; + let connection_options = + ConnectionOptions::new(Url::parse("http://localhost:7233").unwrap()) + .retry_options(TEST_RETRY_CONFIG) + .skip_get_system_info(true) + .service_override(service_override) + .dns_load_balancing(None) + .build(); + let connection = Connection::connect(connection_options).await.unwrap(); + let client = Client::new(connection, ClientOptions::new("ns").build()).unwrap(); + + let result = client.count_workflows("whatever", Default::default()).await; + + assert!(result.is_ok(), "{result:?}"); + assert_eq!(attempts.load(Ordering::Relaxed), 4); + } + } + #[tokio::test] async fn long_poll_non_retryable_errors() { for code in [ diff --git a/crates/common-wasm/src/memo.rs b/crates/common-wasm/src/memo.rs index 7ba28d589..e4eabb44b 100644 --- a/crates/common-wasm/src/memo.rs +++ b/crates/common-wasm/src/memo.rs @@ -19,7 +19,6 @@ pub struct Memo { impl Memo { /// Construct a memo with the payload converter and serialization context associated with its /// source. - #[doc(hidden)] pub fn from_raw( raw: Option, payload_converter: PayloadConverter, diff --git a/crates/macros/src/workflow_definitions.rs b/crates/macros/src/workflow_definitions.rs index 5fdbb64ce..0a5fd3d62 100644 --- a/crates/macros/src/workflow_definitions.rs +++ b/crates/macros/src/workflow_definitions.rs @@ -774,8 +774,8 @@ impl WorkflowMethodsDefinition { fn handle( mut ctx: ::temporalio_workflow::WorkflowContext, input: <#module_ident::#struct_ident as ::temporalio_workflow::common::SignalDefinition>::Input, - ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, ()> { - ::temporalio_workflow::__private::futures_util::FutureExt::boxed_local( + ) -> ::temporalio_workflow::__private::LocalBoxFuture<'static, ()> { + ::temporalio_workflow::__private::FutureExt::boxed_local( async move { #method_call.await } ) } @@ -907,14 +907,14 @@ impl WorkflowMethodsDefinition { }; let handle_body = if update.is_fallible { quote! { - ::temporalio_workflow::__private::futures_util::FutureExt::boxed_local( + ::temporalio_workflow::__private::FutureExt::boxed_local( async move { #method_call.await } ) } } else { quote! { - ::temporalio_workflow::__private::futures_util::FutureExt::boxed_local( - ::temporalio_workflow::__private::futures_util::FutureExt::map( + ::temporalio_workflow::__private::FutureExt::boxed_local( + ::temporalio_workflow::__private::FutureExt::map( async move { #method_call.await }, Ok, ) @@ -926,7 +926,7 @@ impl WorkflowMethodsDefinition { fn handle( mut ctx: ::temporalio_workflow::WorkflowContext, input: <#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Input, - ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<<#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Output, Box>> { + ) -> ::temporalio_workflow::__private::LocalBoxFuture<'static, Result<<#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Output, Box>> { #handle_body } @@ -1015,7 +1015,7 @@ impl WorkflowMethodsDefinition { }; let run_impl_body = quote! { - ::temporalio_workflow::__private::futures_util::FutureExt::boxed_local(async move { + ::temporalio_workflow::__private::FutureExt::boxed_local(async move { let result = #run_call; match result { Ok(value) => Ok( @@ -1106,7 +1106,7 @@ impl WorkflowMethodsDefinition { _ctx: ::temporalio_workflow::WorkflowContext, name: &str, _input: ::std::boxed::Box, - ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<(), ::temporalio_workflow::workflows::WorkflowError>> { + ) -> ::temporalio_workflow::__private::LocalBoxFuture<'static, Result<(), ::temporalio_workflow::workflows::WorkflowError>> { unreachable!("typed signal dispatch called for unknown signal handler '{name}'") } } @@ -1116,7 +1116,7 @@ impl WorkflowMethodsDefinition { ctx: ::temporalio_workflow::WorkflowContext, name: &str, input: ::std::boxed::Box, - ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<(), ::temporalio_workflow::workflows::WorkflowError>> { + ) -> ::temporalio_workflow::__private::LocalBoxFuture<'static, Result<(), ::temporalio_workflow::workflows::WorkflowError>> { match name { #(#dispatch_signal_arms)* _ => unreachable!("typed signal dispatch called for unknown signal handler '{name}'"), @@ -1238,7 +1238,7 @@ impl WorkflowMethodsDefinition { _ctx: ::temporalio_workflow::WorkflowContext, name: &str, _input: ::std::boxed::Box, - ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<::std::boxed::Box, ::temporalio_workflow::workflows::WorkflowError>> { + ) -> ::temporalio_workflow::__private::LocalBoxFuture<'static, Result<::std::boxed::Box, ::temporalio_workflow::workflows::WorkflowError>> { unreachable!("typed update dispatch called for unknown update handler '{name}'") } @@ -1257,7 +1257,7 @@ impl WorkflowMethodsDefinition { ctx: ::temporalio_workflow::WorkflowContext, name: &str, input: ::std::boxed::Box, - ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<::std::boxed::Box, ::temporalio_workflow::workflows::WorkflowError>> { + ) -> ::temporalio_workflow::__private::LocalBoxFuture<'static, Result<::std::boxed::Box, ::temporalio_workflow::workflows::WorkflowError>> { match name { #(#dispatch_update_arms)* _ => unreachable!("typed update dispatch called for unknown update handler '{name}'"), @@ -1408,7 +1408,7 @@ impl WorkflowMethodsDefinition { fn run( mut ctx: ::temporalio_workflow::WorkflowContext, input: ::std::option::Option<::Input>, - ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<::std::boxed::Box, ::temporalio_workflow::WorkflowTermination>> { + ) -> ::temporalio_workflow::__private::LocalBoxFuture<'static, Result<::std::boxed::Box, ::temporalio_workflow::WorkflowTermination>> { #run_impl_body } diff --git a/crates/sdk-core-c-bridge/src/client.rs b/crates/sdk-core-c-bridge/src/client.rs index 3379ac5ed..580874fd3 100644 --- a/crates/sdk-core-c-bridge/src/client.rs +++ b/crates/sdk-core-c-bridge/src/client.rs @@ -1544,9 +1544,9 @@ impl From<&ClientDnsLoadBalancingOptions> for temporalio_client::DnsLoadBalancin } } -impl From<&ClientHttpConnectProxyOptions> for temporalio_client::proxy::HttpConnectProxyOptions { +impl From<&ClientHttpConnectProxyOptions> for temporalio_client::HttpConnectProxyOptions { fn from(opts: &ClientHttpConnectProxyOptions) -> Self { - temporalio_client::proxy::HttpConnectProxyOptions::new(opts.target_host.to_string()) + temporalio_client::HttpConnectProxyOptions::new(opts.target_host.to_string()) .maybe_basic_auth(if opts.username.size != 0 && opts.password.size != 0 { Some((opts.username.to_string(), opts.password.to_string())) } else { diff --git a/crates/sdk-core/src/worker/activities.rs b/crates/sdk-core/src/worker/activities.rs index 7bb23792d..c41991103 100644 --- a/crates/sdk-core/src/worker/activities.rs +++ b/crates/sdk-core/src/worker/activities.rs @@ -19,7 +19,8 @@ use crate::{ }, worker::{ ActivitySlotKind, PollError, - activities::activity_heartbeat_manager::ActivityHeartbeatError, client::WorkerClient, + activities::activity_heartbeat_manager::ActivityHeartbeatError, + client::{WorkerClient, payload_limit_violation_from}, }, }; use activity_heartbeat_manager::ActivityHeartbeatManager; @@ -37,9 +38,7 @@ use std::{ }, time::{Duration, Instant, SystemTime}, }; -use temporalio_client::{ - PayloadErrorLimits, payload_limit_violation_from, worker::CancelActivityCallback, -}; +use temporalio_client::{PayloadErrorLimits, worker::CancelActivityCallback}; use temporalio_common::{ payload_limits::{PayloadLimitViolation, PayloadLimits, validate_known_payload_limits}, protos::{ diff --git a/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs b/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs index fdba8bd3e..8be4b8ba1 100644 --- a/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs +++ b/crates/sdk-core/src/worker/activities/activity_heartbeat_manager.rs @@ -3,7 +3,7 @@ use crate::{ abstractions::take_cell::TakeCell, worker::{ activities::{PendingActivityCancel, make_payloads_too_large_failure}, - client::WorkerClient, + client::{WorkerClient, payload_limit_violation_from}, }, }; use futures_util::StreamExt; @@ -12,7 +12,6 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use temporalio_client::payload_limit_violation_from; use temporalio_common::protos::{ coresdk::{ ActivityHeartbeat, IntoPayloadsExt, diff --git a/crates/sdk-core/src/worker/client.rs b/crates/sdk-core/src/worker/client.rs index a9977efb9..c81549134 100644 --- a/crates/sdk-core/src/worker/client.rs +++ b/crates/sdk-core/src/worker/client.rs @@ -57,6 +57,12 @@ use uuid::Uuid; type Result = std::result::Result; +pub(crate) fn payload_limit_violation_from( + status: &tonic::Status, +) -> Option<&temporalio_common::payload_limits::PayloadLimitViolation> { + std::error::Error::source(status).and_then(|source| source.downcast_ref()) +} + /// Maximum encoded size of a single completion page, kept below the ~4 MiB gRPC frame limit. This /// per-page cap is distinct from the server's namespace-wide limit on the recombined completion /// size. diff --git a/crates/sdk-core/src/worker/nexus.rs b/crates/sdk-core/src/worker/nexus.rs index b4b60aced..732b12384 100644 --- a/crates/sdk-core/src/worker/nexus.rs +++ b/crates/sdk-core/src/worker/nexus.rs @@ -2,7 +2,10 @@ use crate::{ abstractions::UsedMeteredSemPermit, pollers::{BoxedNexusPoller, NexusPollItem, new_nexus_task_poller}, telemetry::metrics::{self, FailureReason, MetricsContext}, - worker::{CompleteNexusError, NexusSlotKind, PollError, client::WorkerClient}, + worker::{ + CompleteNexusError, NexusSlotKind, PollError, + client::{WorkerClient, payload_limit_violation_from}, + }, }; use anyhow::anyhow; use futures_util::{ @@ -18,7 +21,6 @@ use std::{ }, time::{Duration, Instant, SystemTime}, }; -use temporalio_client::payload_limit_violation_from; use temporalio_common::{ payload_limits::PayloadLimitViolation, protos::{ diff --git a/crates/sdk-core/src/worker/workflow/mod.rs b/crates/sdk-core/src/worker/workflow/mod.rs index b0da392a3..9efe8f9a9 100644 --- a/crates/sdk-core/src/worker/workflow/mod.rs +++ b/crates/sdk-core/src/worker/workflow/mod.rs @@ -32,7 +32,10 @@ use crate::{ LocalActivityResolution, NamespaceCapabilities, PollError, PostActivateHookData, WorkflowSlotKind, activities::{ActivitiesFromWFTsHandle, LocalActivityManager}, - client::{LegacyQueryResult, REQUEST_TOO_LARGE_KEY, WorkerClient, WorkflowTaskCompletion}, + client::{ + LegacyQueryResult, REQUEST_TOO_LARGE_KEY, WorkerClient, WorkflowTaskCompletion, + payload_limit_violation_from, + }, workflow::{ history_update::HistoryPaginator, machines::MachineError, @@ -59,7 +62,7 @@ use std::{ thread, time::{Duration, Instant}, }; -use temporalio_client::{MESSAGE_TOO_LARGE_KEY, payload_limit_violation_from}; +use temporalio_client::MESSAGE_TOO_LARGE_KEY; use temporalio_common::{ payload_limits::PayloadLimitViolation, protos::{ diff --git a/crates/sdk-core/tests/common/http_proxy.rs b/crates/sdk-core/tests/common/http_proxy.rs deleted file mode 100644 index c39c3a6c5..000000000 --- a/crates/sdk-core/tests/common/http_proxy.rs +++ /dev/null @@ -1,134 +0,0 @@ -use bytes::Bytes; -use http_body_util::Empty; -use hyper::{ - Request, Response, StatusCode, body::Incoming, server::conn::http1, service::service_fn, -}; -use hyper_util::rt::TokioIo; -use std::{ - io, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, -}; -use temporalio_client::proxy::ProxyStream; -#[cfg(unix)] -use tokio::net::UnixListener; -use tokio::{ - net::{TcpListener, TcpStream}, - sync::oneshot, -}; - -pub(crate) struct HttpProxy { - proxy_hits: Arc, - shutdown_tx: oneshot::Sender<()>, -} -impl HttpProxy { - pub(crate) fn spawn_tcp(listener: TcpListener) -> Self { - Self::spawn(ProxyListener::Tcp(listener)) - } - - #[cfg(unix)] - pub(crate) fn spawn_unix(listener: UnixListener) -> Self { - Self::spawn(ProxyListener::Unix(listener)) - } - - fn spawn(listener: ProxyListener) -> Self { - let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); - let proxy_hits = Arc::new(AtomicUsize::new(0)); - let proxy_hits_cloned = proxy_hits.clone(); - tokio::spawn(async move { - loop { - let proxy_hits_cloned = proxy_hits_cloned.clone(); - tokio::select! { - _ = &mut shutdown_rx => break, - stream = listener.accept() => { - let stream = match stream { - Ok(stream) => stream, - Err(e) => { println!("Proxy accept error: {e}"); continue; } - }; - tokio::spawn(async move { - if let Err(e) = http1::Builder::new() - .serve_connection( - TokioIo::new(stream), - service_fn(move |req| handle_connect(req, proxy_hits_cloned.clone())), - ) - .with_upgrades() - .await - { - println!("Proxy conn error: {e}"); - } - }); - } - } - } - }); - Self { - proxy_hits, - shutdown_tx, - } - } - - pub(crate) fn hit_count(&self) -> usize { - self.proxy_hits.load(Ordering::SeqCst) - } - - /// Returns before shutdown occurs - pub(crate) fn shutdown(self) { - let _ = self.shutdown_tx.send(()); - } -} - -async fn handle_connect( - req: Request, - counter: Arc, -) -> Result>, hyper::Error> { - if req.method() == hyper::Method::CONNECT { - // Increment atomic counter - counter.fetch_add(1, Ordering::SeqCst); - - // Tell the client the tunnel is established - tokio::spawn(async move { - if let Some(addr) = req.uri().authority().map(|a| a.as_str()) { - match TcpStream::connect(addr).await { - Ok(mut server_stream) => match hyper::upgrade::on(req).await { - Ok(upgraded) => { - let mut upgraded = TokioIo::new(upgraded); - let _ = - tokio::io::copy_bidirectional(&mut upgraded, &mut server_stream) - .await; - } - Err(err) => println!("Upgrade failed: {err}"), - }, - Err(e) => println!("Failed to connect to {addr}: {e}"), - } - } - }); - - Ok(Response::builder() - .status(StatusCode::OK) - .body(Empty::new()) - .unwrap()) - } else { - Ok(Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .body(Empty::new()) - .unwrap()) - } -} - -enum ProxyListener { - Tcp(TcpListener), - #[cfg(unix)] - Unix(UnixListener), -} - -impl ProxyListener { - async fn accept(&self) -> io::Result { - match self { - ProxyListener::Tcp(tcp) => tcp.accept().await.map(|(s, _)| ProxyStream::Tcp(s)), - #[cfg(unix)] - ProxyListener::Unix(unix) => unix.accept().await.map(|(s, _)| ProxyStream::Unix(s)), - } - } -} diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index 274982ac9..93b52afb2 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -3,7 +3,6 @@ pub(crate) mod activity_functions; pub(crate) mod fake_grpc_server; -pub(crate) mod http_proxy; pub(crate) mod workflows; use anyhow::bail; diff --git a/crates/sdk-core/tests/integ_tests/client_tests.rs b/crates/sdk-core/tests/integ_tests/client_tests.rs index 9a4cd606a..00b1d7fdf 100644 --- a/crates/sdk-core/tests/integ_tests/client_tests.rs +++ b/crates/sdk-core/tests/integ_tests/client_tests.rs @@ -2,7 +2,6 @@ use crate::common::{ CoreWfStarter, NAMESPACE, fake_grpc_server::{FakeServer, GenericService, fake_server}, get_integ_server_options, - http_proxy::HttpProxy, }; use assert_matches::assert_matches; use futures_util::{FutureExt, stream}; @@ -22,19 +21,17 @@ use std::{ time::Duration, }; use temporalio_client::{ - Connection, GrpcCompression, RETRYABLE_ERROR_CODES, RetryOptions, UntypedWorkflow, - errors::ClientConnectError, grpc::WorkflowService, proxy::HttpConnectProxyOptions, + Connection, GrpcCompression, RetryOptions, UntypedWorkflow, errors::ClientConnectError, + grpc::WorkflowService, }; use temporalio_common::protos::temporal::api::{ cloud::cloudservice::v1::GetNamespaceRequest, workflowservice::v1::{ DescribeNamespaceRequest, GetSystemInfoResponse, GetWorkflowExecutionHistoryRequest, - ListNamespacesRequest, RespondActivityTaskCanceledResponse, SignalWorkflowExecutionRequest, - SignalWorkflowExecutionResponse, get_system_info_response, + ListNamespacesRequest, SignalWorkflowExecutionRequest, SignalWorkflowExecutionResponse, + get_system_info_response, }, }; -#[cfg(unix)] -use tokio::net::UnixListener; use tokio::{net::TcpListener, sync::oneshot}; use tonic::{ Code, IntoRequest, Request, Status, body::Body, codegen::http::Response, transport::Server, @@ -383,47 +380,6 @@ async fn non_retryable_errors() { } } -#[tokio::test] -async fn retryable_errors() { - // Take out retry exhausted since it gets a special policy which would make this take ages - for code in RETRYABLE_ERROR_CODES - .iter() - .copied() - .filter(|p| p != &Code::ResourceExhausted) - { - let count = Arc::new(AtomicUsize::new(0)); - let mut fs = fake_server(move |_| { - let prev = count.fetch_add(1, Ordering::Relaxed); - let r = if prev < 3 { - Status::new(code, "bla").into_http() - } else { - make_ok_response(RespondActivityTaskCanceledResponse::default()) - }; - async { r }.boxed() - }) - .await; - - let mut opts = get_integ_server_options(); - opts.target = format!("http://localhost:{}", fs.addr.port()) - .parse::() - .unwrap(); - opts.set_skip_get_system_info(true); - let connection = Connection::connect(opts).await.unwrap(); - let client_opts = temporalio_client::ClientOptions::new("ns").build(); - let client = temporalio_client::Client::new(connection, client_opts).unwrap(); - - let result = client.count_workflows("whatever", Default::default()).await; - - // Expecting successful response after retries - assert!(result.is_ok(), "{:?}", result); - let mut all_calls = vec![]; - fs.header_rx.recv_many(&mut all_calls, 9999).await; - // Should be 4 attempts - assert_eq!(all_calls.len(), 4); - fs.shutdown().await; - } -} - #[tokio::test] async fn namespace_header_attached_to_relevant_calls() { let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); @@ -535,94 +491,6 @@ async fn cloud_ops_test() { assert_eq!(res.into_inner().namespace.unwrap().namespace, namespace); } -#[tokio::test] -async fn http_proxy() { - // Create server - let call_count = Arc::new(AtomicUsize::new(0)); - let call_count_cloned = call_count.clone(); - let server = fake_server(move |_| { - call_count_cloned.fetch_add(1, Ordering::SeqCst); - async { Response::new(Body::empty()) }.boxed() - }) - .await; - - // Create HTTP TCP proxy - let tcp_proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let tcp_proxy_addr = tcp_proxy_listener.local_addr().unwrap(); - let tcp_proxy = HttpProxy::spawn_tcp(tcp_proxy_listener); - - // General client options - let mut opts = get_integ_server_options(); - opts.retry_options = RetryOptions::no_retries(); - opts.set_skip_get_system_info(true); - - // Connect client with no proxy and make call and confirm reached - opts.target = format!("http://[::1]:{}", server.addr.port()) - .parse() - .unwrap(); - let connection = Connection::connect(opts.clone()).await.unwrap(); - let client_opts = temporalio_client::ClientOptions::new("my-namespace").build(); - let client = temporalio_client::Client::new(connection, client_opts).unwrap(); - let _ = WorkflowService::list_namespaces( - &mut client.clone(), - ListNamespacesRequest::default().into_request(), - ) - .await; - assert!(call_count.load(Ordering::SeqCst) == 1); - assert!(tcp_proxy.hit_count() == 0); - - // Connect client to proxy and make call and confirm reached - opts.http_connect_proxy = - Some(HttpConnectProxyOptions::new(tcp_proxy_addr.to_string()).build()); - opts.dns_load_balancing = None; - let connection = Connection::connect(opts.clone()).await.unwrap(); - let client_opts = temporalio_client::ClientOptions::new("my-namespace").build(); - let proxied_client = temporalio_client::Client::new(connection, client_opts).unwrap(); - let _ = WorkflowService::list_namespaces( - &mut proxied_client.clone(), - ListNamespacesRequest::default().into_request(), - ) - .await; - assert!(call_count.load(Ordering::SeqCst) == 2); - assert!(tcp_proxy.hit_count() == 1); - - // Test Unix socket too only in Unix environments - #[cfg(unix)] - { - // Create temp socket path - let mut sock_path = std::env::temp_dir(); - sock_path.push(format!("http-proxy-test-{}.sock", std::process::id())); - // Remove if there just in case - let _ = std::fs::remove_file(&sock_path); - - // Create unix-socket-based proxy - let unix_proxy = HttpProxy::spawn_unix(UnixListener::bind(&sock_path).unwrap()); - - // Connect client to proxy and make call and confirm reached - opts.http_connect_proxy = Some( - HttpConnectProxyOptions::new(format!("unix:{}", sock_path.to_str().unwrap())).build(), - ); - opts.dns_load_balancing = None; - let connection = Connection::connect(opts.clone()).await.unwrap(); - let client_opts = temporalio_client::ClientOptions::new("my-namespace").build(); - let proxied_client = temporalio_client::Client::new(connection, client_opts).unwrap(); - let _ = WorkflowService::list_namespaces( - &mut proxied_client.clone(), - ListNamespacesRequest::default().into_request(), - ) - .await; - assert!(call_count.load(Ordering::SeqCst) == 3); - assert!(unix_proxy.hit_count() == 1); - - // Shutdown unix proxy - unix_proxy.shutdown(); - } - - // Shutdown server and proxy - server.shutdown().await; - tcp_proxy.shutdown(); -} - #[tokio::test] async fn update_get_result_retries_on_empty_outcome() { use temporalio_common::protos::temporal::api::{ diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/timers.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/timers.rs index 5ccdb1e39..adc5af694 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/timers.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/timers.rs @@ -375,10 +375,10 @@ async fn wait_condition_waker_in_futures_unordered() { let t = canned_histories::single_timer_wf_completes("1"); let mock_cfg = MockPollCfg::from_hist_builder(t); let mut worker = crate::common::build_fake_sdk_with_options(mock_cfg, |options| { + // FuturesUnordered uses internal wakers that forward wake calls outside the + // SdkWakeGuard scope. + options.detect_nondeterministic_futures = false; options.register_workflow::().unwrap(); }); - // FuturesUnordered uses internal wakers that forward wake calls outside the - // SdkWakeGuard scope. - worker.set_detect_nondeterministic_futures(false); worker.run().await.unwrap(); } diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index dc32f47b4..acb76eb91 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -942,7 +942,7 @@ impl Worker { activity_inbound_interceptors, workflow_interceptor_constructors, ); - me.set_detect_nondeterministic_futures(options.detect_nondeterministic_futures); + me.workflow_half.detect_nondeterministic_futures = options.detect_nondeterministic_futures; #[cfg(feature = "experimental")] { me.workflow_half.patch_activation_callback = options.patch_activation_callback; @@ -997,13 +997,6 @@ impl Worker { &self.common.task_queue } - #[doc(hidden)] - /// Set whether nondeterministic future detection is enabled for workflows on this worker. Users - /// should use [WorkerOptions] to set this. TODO: Only needs to exist due to test setup. - pub fn set_detect_nondeterministic_futures(&mut self, enabled: bool) { - self.workflow_half.detect_nondeterministic_futures = enabled; - } - /// Return a handle that can be used to initiate shutdown. This is useful because [Worker::run] /// takes self mutably, so you may want to obtain a handle for shutting down before running. pub fn shutdown_handle(&self) -> impl Fn() + use<> { diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 285ecd70e..828e316aa 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -12,7 +12,8 @@ pub use temporalio_macros::{ #[doc(hidden)] pub mod __private { - pub use futures_util; + // Rexports used by macros + pub use futures_util::{FutureExt, future::LocalBoxFuture, join, select_biased}; } mod cancellation; @@ -25,8 +26,6 @@ pub mod workflow_interceptors; pub mod workflows; pub use cancellation::{WorkflowCancellationError, WorkflowCancellationToken}; -#[doc(hidden)] -pub use runtime::model::{CancellableID, UnblockEvent}; pub use runtime::model::{TimerResult, WorkflowResult, WorkflowTermination}; #[doc(hidden)] pub use runtime::{SdkWakeGuard, is_sdk_wake}; @@ -61,7 +60,7 @@ pub use workflows::{join, join_all, select}; #[doc(hidden)] macro_rules! __temporal_select { ($($tokens:tt)*) => { - $crate::__private::futures_util::select_biased! { $($tokens)* } + $crate::__private::select_biased! { $($tokens)* } }; } @@ -69,7 +68,7 @@ macro_rules! __temporal_select { #[doc(hidden)] macro_rules! __temporal_join { ($($tokens:tt)*) => { - $crate::__private::futures_util::join!($($tokens)*) + $crate::__private::join!($($tokens)*) }; } diff --git a/crates/workflow/src/runtime/model.rs b/crates/workflow/src/runtime/model.rs index 8ef984baa..80fdb9a3c 100644 --- a/crates/workflow/src/runtime/model.rs +++ b/crates/workflow/src/runtime/model.rs @@ -32,8 +32,9 @@ use temporalio_common_wasm::{ }, }; +#[cfg_attr(not(feature = "experimental"), allow(dead_code))] #[derive(Debug)] -pub enum UnblockEvent { +pub(crate) enum UnblockEvent { Timer(u32, TimerResult), Activity(u32, Box), WorkflowStart(u32, Box), @@ -145,8 +146,9 @@ impl Unblockable for CancelExternalWfResult { } } +#[cfg_attr(not(feature = "experimental"), allow(dead_code))] #[derive(Debug, Clone)] -pub enum CancellableID { +pub(crate) enum CancellableID { Timer(u32), Activity(u32), LocalActivity(u32), diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index 5e6dd9c0c..d3f66679b 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -286,10 +286,9 @@ pub struct ActivityOptions { pub do_not_eagerly_execute: bool, /// Event group markers to attach to the resulting `ScheduleActivityTask` command. /// - /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists - /// only for internal test purposes. This API *will* change. + /// **Experimental:** Event Groups are not yet fully supported by the Rust SDK. This API may + /// change. #[cfg(feature = "experimental")] - #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, } @@ -419,10 +418,9 @@ pub struct LocalActivityOptions { pub summary: Option, /// Event group markers to attach to the resulting `RecordMarker` command. /// - /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists - /// only for internal test purposes. This API *will* change. + /// **Experimental:** Event Groups are not yet fully supported by the Rust SDK. This API may + /// change. #[cfg(feature = "experimental")] - #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, } @@ -520,10 +518,9 @@ pub struct ChildWorkflowOptions { pub priority: Option, /// Event group markers to attach to the resulting `StartChildWorkflowExecution` command. /// - /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists - /// only for internal test purposes. This API *will* change. + /// **Experimental:** Event Groups are not yet fully supported by the Rust SDK. This API may + /// change. #[cfg(feature = "experimental")] - #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, } @@ -600,10 +597,9 @@ pub struct TimerOptions { pub summary: Option, /// Event group markers to attach to the resulting `StartTimer` command. /// - /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists - /// only for internal test purposes. This API *will* change. + /// **Experimental:** Event Groups are not yet fully supported by the Rust SDK. This API may + /// change. #[cfg(feature = "experimental")] - #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, } @@ -663,10 +659,9 @@ pub struct SignalWorkflowOptions { pub summary: Option, /// Event group markers to attach to the resulting `SignalExternalWorkflowExecution` command. /// - /// **Unstable:** Event Groups are not yet implemented in the Rust SDK; this field exists - /// only for internal test purposes. This API *will* change. + /// **Experimental:** Event Groups are not yet fully supported by the Rust SDK. This API may + /// change. #[cfg(feature = "experimental")] - #[doc(hidden)] #[builder(default)] pub event_group_markers: Vec, } From f3bf104bf862bb044c5b8d8473223b3db7a9156d Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 2 Sep 2026 21:18:15 -0400 Subject: [PATCH 58/87] chore(sdk): limit macro exports (#1564) * chore(sdk): limit macro exports * chore(sdk): split out wasm integ test from example * self review * feat(sdk): expose proper cancel external error type (#1565) * feat(sdk): expose proper cancel external error type * fixup changelog * chore(sdk): make SdkWakeGuard private (#1566) --- CHANGELOG.md | 4 + .../src/data_converters/failure_converter.rs | 39 +++++- crates/common-wasm/src/error.rs | 55 +++++++++ crates/macros/src/workflow_definitions.rs | 8 +- .../fixtures/wasm_patch_activation/src/lib.rs | 38 +----- .../fixtures/wasm_task_failure/.gitignore | 1 + .../fixtures/wasm_task_failure/Cargo.toml | 16 +++ .../fixtures/wasm_task_failure/src/lib.rs | 102 ++++++++++++++++ .../workflow_tests/cancel_external.rs | 14 ++- .../workflow_tests/continue_as_new.rs | 17 +-- crates/sdk-core/tests/wasm_workflow_tests.rs | 7 +- crates/sdk/examples/wasm_workflows/src/lib.rs | 113 +----------------- crates/sdk/src/error.rs | 5 +- crates/sdk/src/lib.rs | 12 +- crates/sdk/src/workflow_executor.rs | 68 +++++------ crates/sdk/src/workflow_future.rs | 18 ++- crates/sdk/src/workflow_registry.rs | 11 +- crates/sdk/src/workflow_replayer.rs | 2 +- crates/sdk/src/workflow_wasm.rs | 45 +++---- crates/workflow/src/component.rs | 15 ++- crates/workflow/src/lib.rs | 73 +++++++---- crates/workflow/src/runtime/instance.rs | 15 +-- crates/workflow/src/runtime/mod.rs | 67 +++++++++-- crates/workflow/src/runtime/model.rs | 28 +++-- crates/workflow/src/runtime/types.rs | 69 ++++++++++- crates/workflow/src/workflow_context.rs | 47 +++++--- crates/workflow/src/workflow_interceptors.rs | 20 ++-- crates/workflow/src/workflows.rs | 9 +- 28 files changed, 569 insertions(+), 349 deletions(-) create mode 100644 crates/sdk-core/tests/fixtures/wasm_task_failure/.gitignore create mode 100644 crates/sdk-core/tests/fixtures/wasm_task_failure/Cargo.toml create mode 100644 crates/sdk-core/tests/fixtures/wasm_task_failure/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ab40ca2..6b1381eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ relevant information. ## Unreleased +### Added +* `CancelExternalWorkflowError` and `workflow_interceptors::CancelExternalWorkflowResult` + for use in interceptors. + ## [0.8.0] - 2026-09-02 ### Breaking Changes diff --git a/crates/common-wasm/src/data_converters/failure_converter.rs b/crates/common-wasm/src/data_converters/failure_converter.rs index f12b7c6d5..ca5deb3f4 100644 --- a/crates/common-wasm/src/data_converters/failure_converter.rs +++ b/crates/common-wasm/src/data_converters/failure_converter.rs @@ -15,12 +15,12 @@ use super::{ }; use crate::{ error::{ - ActivityExecutionError, ActivityFailureError, ApplicationFailure, CancelledError, - ChildWorkflowExecutionError, ChildWorkflowFailureError, ChildWorkflowStartError, - IncomingError, IncomingNexusHandlerError, IncomingNexusOperationExecutionError, - OutgoingActivityError, OutgoingError, OutgoingWorkflowError, ResetWorkflowError, - ServerError, TerminatedError, TimeoutError, WorkflowSignalError, - WorkflowSignalFailureError, + ActivityExecutionError, ActivityFailureError, ApplicationFailure, + CancelExternalWorkflowError, CancelledError, ChildWorkflowExecutionError, + ChildWorkflowFailureError, ChildWorkflowStartError, IncomingError, + IncomingNexusHandlerError, IncomingNexusOperationExecutionError, OutgoingActivityError, + OutgoingError, OutgoingWorkflowError, ResetWorkflowError, ServerError, TerminatedError, + TimeoutError, WorkflowSignalError, WorkflowSignalFailureError, }, protos::temporal::api::{ enums::v1::ApplicationErrorCategory as ProtoApplicationErrorCategory, @@ -247,6 +247,9 @@ impl FailureConverter for DefaultFailureConverter { OutgoingError::Workflow(OutgoingWorkflowError::WorkflowSignal(signal)) => { signal.encode_failure(payload_converter, context) } + OutgoingError::Workflow(OutgoingWorkflowError::CancelExternalWorkflow(cancel)) => { + cancel.encode_failure(payload_converter, context) + } }; let mut failure = encoded.unwrap_or_else(|converter_error| { Failure::application_failure( @@ -290,6 +293,7 @@ enum ClassifiedFailure<'a> { ChildWorkflowExecution(&'a ChildWorkflowExecutionError), ChildWorkflowStart(&'a ChildWorkflowStartError), WorkflowSignal(&'a WorkflowSignalError), + CancelExternalWorkflow(&'a CancelExternalWorkflowError), Generic(&'a (dyn std::error::Error + 'static)), } @@ -315,6 +319,8 @@ impl<'a> ClassifiedFailure<'a> { Self::ChildWorkflowStart(child) } else if let Some(child_signal) = err.downcast_ref::() { Self::WorkflowSignal(child_signal) + } else if let Some(cancel_external) = err.downcast_ref::() { + Self::CancelExternalWorkflow(cancel_external) } else { Self::Generic(err) } @@ -362,6 +368,14 @@ impl<'a> ClassifiedFailure<'a> { .unwrap_or_else(|converter_error| { encode_failed_error_conversion(signal, converter_error) }), + Self::CancelExternalWorkflow(cancel) => cancel + .encode_failure( + &PayloadConverter::default(), + &SerializationContextData::None, + ) + .unwrap_or_else(|converter_error| { + encode_failed_error_conversion(cancel, converter_error) + }), Self::Generic(err) => encode_generic_application_failure(err), } } @@ -469,6 +483,19 @@ impl EncodeFailure for WorkflowSignalError { } } +impl EncodeFailure for CancelExternalWorkflowError { + fn encode_failure( + &self, + _: &PayloadConverter, + _: &SerializationContextData, + ) -> Result { + Ok(match self { + Self::Failed(error) => error.failure().clone(), + Self::Serialization(err) => encode_generic_application_failure(err), + }) + } +} + fn encode_outgoing_activity_error( err: OutgoingActivityError, payload_converter: &PayloadConverter, diff --git a/crates/common-wasm/src/error.rs b/crates/common-wasm/src/error.rs index 282655280..98131de9c 100644 --- a/crates/common-wasm/src/error.rs +++ b/crates/common-wasm/src/error.rs @@ -449,6 +449,9 @@ pub enum OutgoingWorkflowError { /// A workflow failure sourced from signaling a workflow. #[error(transparent)] WorkflowSignal(#[from] Box), + /// A workflow failure sourced from requesting cancellation of an external workflow. + #[error(transparent)] + CancelExternalWorkflow(#[from] Box), } impl OutgoingWorkflowError { @@ -462,6 +465,7 @@ impl OutgoingWorkflowError { Self::ChildWorkflowExecution(err) => err.as_cancelled(), Self::ChildWorkflowStart(err) => err.as_cancelled(), Self::WorkflowSignal(err) => err.as_cancelled(), + Self::CancelExternalWorkflow(err) => err.as_cancelled(), } } } @@ -514,6 +518,15 @@ impl From for OutgoingWorkflowError { } } +impl From for OutgoingWorkflowError { + fn from(value: CancelExternalWorkflowError) -> Self { + match value { + CancelExternalWorkflowError::Serialization(err) => Self::PayloadConversion(err), + other => Self::CancelExternalWorkflow(Box::new(other)), + } + } +} + /// A normalized incoming Temporal failure decoded from a protobuf [`Failure`]. #[derive(Debug)] #[non_exhaustive] @@ -1155,6 +1168,48 @@ pub enum WorkflowSignalError { Serialization(#[from] PayloadConversionError), } +/// Error returned when requesting cancellation of an external workflow fails. +#[derive(Debug, thiserror::Error)] +pub enum CancelExternalWorkflowError { + /// The cancellation request failed. + #[error("External workflow cancellation request failed: {}", .0.failure().message)] + Failed(#[source] Box), + /// Failed to deserialize payloads attached to the cancellation failure. + #[error("External workflow cancellation failure conversion failed: {0}")] + Serialization(#[from] PayloadConversionError), +} + +impl CancelExternalWorkflowError { + /// Returns the retained top-level cancellation failure proto, if one exists. + pub fn failure(&self) -> Option<&Failure> { + match self { + Self::Failed(err) => Some(err.failure()), + Self::Serialization(_) => None, + } + } + + /// Returns the normalized cause of the cancellation failure, if any. + pub fn cause(&self) -> Option<&IncomingError> { + match self { + Self::Failed(err) => err.cause(), + Self::Serialization(_) => None, + } + } + + /// Returns the normalized cancellation failure itself, if one exists. + pub fn reason(&self) -> Option<&IncomingError> { + match self { + Self::Failed(err) => Some(err), + Self::Serialization(_) => None, + } + } + + /// If this error was caused by cancellation, returns the associated [`CancelledError`]. + pub fn as_cancelled(&self) -> Option<&CancelledError> { + self.reason()?.as_cancelled() + } +} + impl WorkflowSignalError { /// Returns the retained top-level workflow signal failure proto, if one exists. pub fn failure(&self) -> Option<&Failure> { diff --git a/crates/macros/src/workflow_definitions.rs b/crates/macros/src/workflow_definitions.rs index 0a5fd3d62..108f4e546 100644 --- a/crates/macros/src/workflow_definitions.rs +++ b/crates/macros/src/workflow_definitions.rs @@ -1180,7 +1180,7 @@ impl WorkflowMethodsDefinition { let handler_name = &info.handler_name; let has_validator = u.validator.is_some(); quote! { - ::temporalio_workflow::runtime::types::UpdateDefinitionDescriptor { + ::temporalio_workflow::__private::macros::UpdateDefinitionDescriptor { name: (#handler_name).to_string(), has_validator: #has_validator, } @@ -1377,7 +1377,7 @@ impl WorkflowMethodsDefinition { }; quote! { - impl ::temporalio_workflow::runtime::entry::WorkflowImplementation for #impl_type { + impl ::temporalio_workflow::__private::macros::WorkflowImplementation for #impl_type { type Run = #module_ident::#run_struct_ident; const HAS_INIT: bool = #has_init; @@ -1387,8 +1387,8 @@ impl WorkflowMethodsDefinition { <#impl_type>::name() } - fn definition() -> ::temporalio_workflow::runtime::types::WorkflowDefinitionDescriptor { - ::temporalio_workflow::runtime::types::WorkflowDefinitionDescriptor { + fn definition() -> ::temporalio_workflow::__private::macros::WorkflowDefinitionDescriptor { + ::temporalio_workflow::__private::macros::WorkflowDefinitionDescriptor { workflow_type: Self::name().to_string(), has_init: #has_init, init_takes_input: #init_has_input, diff --git a/crates/sdk-core/tests/fixtures/wasm_patch_activation/src/lib.rs b/crates/sdk-core/tests/fixtures/wasm_patch_activation/src/lib.rs index 7341b4dd8..695cb215f 100644 --- a/crates/sdk-core/tests/fixtures/wasm_patch_activation/src/lib.rs +++ b/crates/sdk-core/tests/fixtures/wasm_patch_activation/src/lib.rs @@ -1,12 +1,6 @@ use std::time::Duration; use temporalio_workflow::{ WorkflowContext, WorkflowResult, - component::{StaticWorkflowComponent, instantiate_component_workflow}, - runtime::{ - guest::WorkflowInstance, - host::WorkflowHost, - types::{WorkflowDefinitionDescriptor, WorkflowFailure, WorkflowInit}, - }, workflow, workflow_methods, }; @@ -24,34 +18,4 @@ impl PatchActivationWorkflow { } } -struct WasmPatchActivationWorkflowModule; - -impl StaticWorkflowComponent for WasmPatchActivationWorkflowModule { - fn list_workflows() -> Vec { - vec![ - ::definition(), - ] - } - - fn instantiate_workflow( - workflow_type: &str, - init: WorkflowInit, - host: std::rc::Rc, - ) -> Result, WorkflowFailure> { - match workflow_type { - name if name - == ::name() => - { - instantiate_component_workflow::(init, host) - } - _ => unreachable!("unexpected workflow type '{workflow_type}'"), - } - } -} - -type WasmPatchActivationWorkflowComponentExport = - temporalio_workflow::component::ExportedComponent; - -temporalio_workflow::__temporalio_export_workflow_component!( - WasmPatchActivationWorkflowComponentExport -); +temporalio_workflow::export_workflow_module!([PatchActivationWorkflow]); diff --git a/crates/sdk-core/tests/fixtures/wasm_task_failure/.gitignore b/crates/sdk-core/tests/fixtures/wasm_task_failure/.gitignore new file mode 100644 index 000000000..b83d22266 --- /dev/null +++ b/crates/sdk-core/tests/fixtures/wasm_task_failure/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/crates/sdk-core/tests/fixtures/wasm_task_failure/Cargo.toml b/crates/sdk-core/tests/fixtures/wasm_task_failure/Cargo.toml new file mode 100644 index 000000000..aed713fe1 --- /dev/null +++ b/crates/sdk-core/tests/fixtures/wasm_task_failure/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "temporal-wasm-task-failure-workflow" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +temporalio-workflow = { path = "../../../../workflow" } + +[lib] +crate-type = ["cdylib"] + +[package.metadata.component] +package = "temporal:task-failure-workflow" + +[workspace] diff --git a/crates/sdk-core/tests/fixtures/wasm_task_failure/src/lib.rs b/crates/sdk-core/tests/fixtures/wasm_task_failure/src/lib.rs new file mode 100644 index 000000000..c99dc09d3 --- /dev/null +++ b/crates/sdk-core/tests/fixtures/wasm_task_failure/src/lib.rs @@ -0,0 +1,102 @@ +use temporalio_workflow::{ + __private::{ + macros::{ExportedComponent, StaticWorkflowComponent}, + sdk::{ + ActivationJobResult, ActivationResult, MAIN_ROUTINE_ID, MainRoutineCompletion, + RoutineCompletion, RoutinePollResult, TaskFailure, WorkflowActivation, + WorkflowFailure, WorkflowHost, WorkflowInit, WorkflowInstance, + }, + }, + common::protos::temporal::api::{ + enums::v1::WorkflowTaskFailedCause, + failure::v1::{ApplicationFailureInfo, Failure, failure::FailureInfo}, + }, + workflows::WorkflowDefinitionDescriptor, +}; + +struct WasmTaskFailureWorkflow; + +impl WorkflowInstance for WasmTaskFailureWorkflow { + fn activate( + &mut self, + activation: WorkflowActivation, + _waker: &std::task::Waker, + ) -> Result { + Ok(ActivationResult { + job_results: activation + .jobs + .iter() + .map(|_| ActivationJobResult::None) + .collect(), + }) + } + + fn poll_routine( + &mut self, + routine_id: u64, + _waker: &std::task::Waker, + ) -> Result { + if routine_id != MAIN_ROUTINE_ID { + return Err(Box::new(Failure { + message: format!("unexpected routine id {routine_id}"), + ..Default::default() + })); + } + + Ok(RoutinePollResult { + completion: Some(RoutineCompletion::Main(MainRoutineCompletion::TaskFailed( + TaskFailure { + failure: Box::new(Failure { + message: "structured wasm workflow task failure".to_string(), + failure_info: Some(FailureInfo::ApplicationFailureInfo( + ApplicationFailureInfo { + r#type: "WasmTaskFailure".to_string(), + non_retryable: true, + ..Default::default() + }, + )), + ..Default::default() + }), + force_cause: Some(WorkflowTaskFailedCause::NonDeterministicError as u32), + }, + ))), + made_progress: true, + pending_state: None, + }) + } +} + +struct WasmTaskFailureWorkflowModule; + +impl StaticWorkflowComponent for WasmTaskFailureWorkflowModule { + fn list_workflows() -> Vec { + vec![WorkflowDefinitionDescriptor { + workflow_type: "WasmTaskFailureWorkflow".to_string(), + has_init: false, + init_takes_input: false, + signals: vec![], + queries: vec![], + updates: vec![], + }] + } + + fn instantiate_workflow( + workflow_type: &str, + _init: WorkflowInit, + _host: std::rc::Rc, + ) -> Result, WorkflowFailure> { + match workflow_type { + "WasmTaskFailureWorkflow" => Ok(Box::new(WasmTaskFailureWorkflow)), + _ => Err(Box::new(Failure { + message: format!("No workflow named '{workflow_type}' exported by this component"), + ..Default::default() + })), + } + } +} + +type WasmTaskFailureWorkflowComponentExport = ExportedComponent; + +temporalio_workflow::__temporalio_export_workflow_component!( + WasmTaskFailureWorkflowComponentExport +); diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_external.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_external.rs index 0c70d31ea..324f1d425 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_external.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_external.rs @@ -5,7 +5,9 @@ use temporalio_common::protos::{ temporal::api::enums::v1::{CommandType, EventType}, }; use temporalio_macros::{workflow, workflow_methods}; -use temporalio_sdk::{ApplicationFailure, WorkflowContext, WorkflowResult}; +use temporalio_sdk::{ + ApplicationFailure, CancelExternalWorkflowError, WorkflowContext, WorkflowResult, +}; use temporalio_sdk_core::{ replay::{DEFAULT_WORKFLOW_TYPE, TestHistoryBuilder}, test_help::MockPollCfg, @@ -103,10 +105,12 @@ impl CancelSenderCanned { async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { let handle = ctx.external_workflow("fake_wid", Some("fake_rid".into())); let res = handle.cancel(None).await; - if res.is_err() { - Err(ApplicationFailure::new("Cancel fail!").into()) - } else { - Ok(()) + match res { + Err(CancelExternalWorkflowError::Failed(_)) => { + Err(ApplicationFailure::new("Cancel fail!").into()) + } + Err(error) => panic!("unexpected external cancellation error: {error}"), + Ok(()) => Ok(()), } } } diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/continue_as_new.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/continue_as_new.rs index 67ea80510..5a11ddd64 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/continue_as_new.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/continue_as_new.rs @@ -12,13 +12,14 @@ use temporalio_common::{ search_attributes::{SearchAttributeKey, SearchAttributes}, }; use temporalio_macros::{workflow, workflow_methods}; -use temporalio_sdk::{ContinueAsNewOptions, WorkflowContext, WorkflowResult, WorkflowTermination}; +use temporalio_sdk::{ + ContinueAsNewOptions, ContinueAsNewVersioningBehavior, WorkflowContext, WorkflowResult, +}; use temporalio_sdk_core::{ TunerHolder, replay::{DEFAULT_WORKFLOW_TYPE, canned_histories}, test_help::MockPollCfg, }; -use temporalio_workflow::runtime::types::ContinueAsNewRequest; const SA_TXT: SearchAttributeKey = SearchAttributeKey::text(SEARCH_ATTR_TXT); @@ -145,11 +146,13 @@ impl WfWithTimer { #[run(name = DEFAULT_WORKFLOW_TYPE)] async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { ctx.timer(Duration::from_millis(500)).await; - Err(WorkflowTermination::continue_as_new(ContinueAsNewRequest { - arguments: vec![[1].into()], - initial_versioning_behavior: ProtoContinueAsNewVersioningBehavior::AutoUpgrade.into(), - ..Default::default() - })) + ctx.continue_as_new( + (), + ContinueAsNewOptions::builder() + .initial_versioning_behavior(ContinueAsNewVersioningBehavior::AutoUpgrade) + .build(), + )?; + Ok(()) } } diff --git a/crates/sdk-core/tests/wasm_workflow_tests.rs b/crates/sdk-core/tests/wasm_workflow_tests.rs index f946e5d16..e6babde46 100644 --- a/crates/sdk-core/tests/wasm_workflow_tests.rs +++ b/crates/sdk-core/tests/wasm_workflow_tests.rs @@ -205,7 +205,7 @@ async fn wasm_patch_activation_callback_panic_fails_workflow_task() { #[tokio::test] async fn wasm_task_failure_preserves_wit_failure_details() { - let component_path = build_wasm_hello_component().await; + let component_path = build_wasm_task_failure_component().await; let component = WasmWorkflowComponent::from_file(WASM_COMPONENT_ID, component_path) .expect("sample WASM component should be loadable"); @@ -389,6 +389,11 @@ async fn build_wasm_patch_activation_component() -> PathBuf { build_wasm_component(fixture_dir, "temporal_wasm_patch_activation_workflow.wasm").await } +async fn build_wasm_task_failure_component() -> PathBuf { + let fixture_dir = repository_root().join("crates/sdk-core/tests/fixtures/wasm_task_failure"); + build_wasm_component(fixture_dir, "temporal_wasm_task_failure_workflow.wasm").await +} + fn repository_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .ancestors() diff --git a/crates/sdk/examples/wasm_workflows/src/lib.rs b/crates/sdk/examples/wasm_workflows/src/lib.rs index 966519a07..c7dd0e607 100644 --- a/crates/sdk/examples/wasm_workflows/src/lib.rs +++ b/crates/sdk/examples/wasm_workflows/src/lib.rs @@ -1,21 +1,4 @@ -use temporalio_workflow::{ - WorkflowContext, WorkflowResult, - common::protos::temporal::api::{ - enums::v1::WorkflowTaskFailedCause, - failure::v1::{ApplicationFailureInfo, Failure, failure::FailureInfo}, - }, - component::{StaticWorkflowComponent, instantiate_component_workflow}, - runtime::{ - guest::WorkflowInstance, - host::WorkflowHost, - types::{ - ActivationJobResult, ActivationResult, MAIN_ROUTINE_ID, MainRoutineCompletion, - RoutineCompletion, RoutinePollResult, TaskFailure, WorkflowDefinitionDescriptor, - WorkflowFailure, WorkflowInit, - }, - }, - workflow, workflow_methods, -}; +use temporalio_workflow::{WorkflowContext, WorkflowResult, workflow, workflow_methods}; #[workflow] #[derive(Default)] @@ -29,96 +12,4 @@ impl HelloWorkflow { } } -struct WasmTaskFailureWorkflow; - -impl WorkflowInstance for WasmTaskFailureWorkflow { - fn activate( - &mut self, - activation: temporalio_workflow::runtime::types::WorkflowActivation, - _waker: &std::task::Waker, - ) -> Result { - Ok(ActivationResult { - job_results: activation - .jobs - .iter() - .map(|_| ActivationJobResult::None) - .collect(), - }) - } - - fn poll_routine( - &mut self, - routine_id: u64, - _waker: &std::task::Waker, - ) -> Result { - if routine_id != MAIN_ROUTINE_ID { - return Err(Box::new(Failure { - message: format!("unexpected routine id {routine_id}"), - ..Default::default() - })); - } - - Ok(RoutinePollResult { - completion: Some(RoutineCompletion::Main(MainRoutineCompletion::TaskFailed( - TaskFailure { - failure: Box::new(Failure { - message: "structured wasm workflow task failure".to_string(), - failure_info: Some(FailureInfo::ApplicationFailureInfo( - ApplicationFailureInfo { - r#type: "WasmTaskFailure".to_string(), - non_retryable: true, - ..Default::default() - }, - )), - ..Default::default() - }), - force_cause: Some(WorkflowTaskFailedCause::NonDeterministicError as u32), - }, - ))), - made_progress: true, - pending_state: None, - }) - } -} - -struct WasmTestWorkflowModule; - -impl StaticWorkflowComponent for WasmTestWorkflowModule { - fn list_workflows() -> Vec { - vec![ - ::definition(), - WorkflowDefinitionDescriptor { - workflow_type: "WasmTaskFailureWorkflow".to_string(), - has_init: false, - init_takes_input: false, - signals: vec![], - queries: vec![], - updates: vec![], - }, - ] - } - - fn instantiate_workflow( - workflow_type: &str, - init: WorkflowInit, - host: std::rc::Rc, - ) -> Result, WorkflowFailure> { - match workflow_type { - name if name - == ::name() => - { - instantiate_component_workflow::(init, host) - } - "WasmTaskFailureWorkflow" => Ok(Box::new(WasmTaskFailureWorkflow)), - _ => Err(Box::new(Failure { - message: format!("No workflow named '{workflow_type}' exported by this component"), - ..Default::default() - })), - } - } -} - -type WasmTestWorkflowComponentExport = - temporalio_workflow::component::ExportedComponent; - -temporalio_workflow::__temporalio_export_workflow_component!(WasmTestWorkflowComponentExport); +temporalio_workflow::export_workflow_module!([HelloWorkflow]); diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs index 0feebea7e..ecaa9b74e 100644 --- a/crates/sdk/src/error.rs +++ b/crates/sdk/src/error.rs @@ -40,6 +40,7 @@ pub enum WorkerRunError { pub use temporalio_common::error::{ ActivityExecutionError, ApplicationErrorCategory, ApplicationFailure, - ChildWorkflowExecutionError, ChildWorkflowStartError, OutgoingActivityError, OutgoingError, - OutgoingWorkflowError, RetryState, TimeoutType, WorkflowSignalError, + CancelExternalWorkflowError, ChildWorkflowExecutionError, ChildWorkflowStartError, + OutgoingActivityError, OutgoingError, OutgoingWorkflowError, RetryState, TimeoutType, + WorkflowSignalError, }; diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index acb76eb91..d99b5bcd1 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -89,10 +89,10 @@ pub use crate::plugins::{ }; pub use crate::{ error::{ - ActivityExecutionError, ApplicationFailure, ChildWorkflowExecutionError, - ChildWorkflowStartError, OutgoingActivityError, OutgoingError, OutgoingWorkflowError, - RetryState, TimeoutType, WorkerCreateError, WorkerRunError, WorkerValidationError, - WorkflowRegistrationError, WorkflowSignalError, + ActivityExecutionError, ApplicationFailure, CancelExternalWorkflowError, + ChildWorkflowExecutionError, ChildWorkflowStartError, OutgoingActivityError, OutgoingError, + OutgoingWorkflowError, RetryState, TimeoutType, WorkerCreateError, WorkerRunError, + WorkerValidationError, WorkflowRegistrationError, WorkflowSignalError, }, workflow_registry::WorkflowDefinitions, }; @@ -163,9 +163,7 @@ use temporalio_common::{ worker::{WorkerDeploymentOptions, WorkerTaskTypes, build_id_from_current_exe}, }; use temporalio_sdk_core::{PollError, init_worker}; -use temporalio_workflow::{ - InternalPatchActivationCallback, runtime::entry::WorkflowImplementation, -}; +use temporalio_workflow::{InternalPatchActivationCallback, workflows::WorkflowImplementation}; use tokio::sync::{ Notify, mpsc::{UnboundedSender, unbounded_channel}, diff --git a/crates/sdk/src/workflow_executor.rs b/crates/sdk/src/workflow_executor.rs index b23565f09..1151deab5 100644 --- a/crates/sdk/src/workflow_executor.rs +++ b/crates/sdk/src/workflow_executor.rs @@ -10,7 +10,7 @@ use std::{ }, task::{Context, Poll, Wake, Waker}, }; -use temporalio_workflow::runtime::is_sdk_wake; +use temporalio_workflow::__private::sdk::is_sdk_wake; /// Persists across polls to accumulate non-SDK wake detection. Each poll creates a lightweight /// waker via [`WakeTracker::new_per_poll_waker`] that shares the detection flag but has the @@ -255,7 +255,7 @@ impl WorkflowExecutor { #[cfg(test)] mod tests { use super::*; - use temporalio_workflow::runtime::SdkWakeGuard; + use temporalio_workflow::WorkflowCancellationToken; use tokio::sync::oneshot; #[tokio::test] @@ -344,34 +344,7 @@ mod tests { } #[test] - fn sdk_wake_guard_nesting() { - assert!(!is_sdk_wake()); - - let guard1 = SdkWakeGuard::new(); - assert!(is_sdk_wake()); - - { - let _guard2 = SdkWakeGuard::new(); - assert!(is_sdk_wake()); - } - assert!(is_sdk_wake()); - - drop(guard1); - assert!(!is_sdk_wake()); - } - - #[test] - fn sdk_wake_guard_panic_safety() { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _guard = SdkWakeGuard::new(); - panic!("test panic"); - })); - assert!(result.is_err()); - assert!(!is_sdk_wake()); - } - - #[test] - fn wake_tracker_detects_non_sdk_wake() { + fn wake_tracker_distinguishes_sdk_wakes() { let tracker = WakeTracker::new(); let noop = Waker::noop(); let waker = tracker.new_per_poll_waker(noop); @@ -379,23 +352,42 @@ mod tests { waker.wake_by_ref(); assert!(tracker.take_non_sdk_wake()); - let _guard = SdkWakeGuard::new(); - waker.wake_by_ref(); + // Create an SDK owned wake + let cancellation = WorkflowCancellationToken::new(); + let mut cancelled = std::pin::pin!(cancellation.cancelled()); + let mut cx = Context::from_waker(&waker); + assert!(cancelled.as_mut().poll(&mut cx).is_pending()); + + cancellation.cancel(); + assert!(!tracker.take_non_sdk_wake()); } + struct CrossThreadWake(Waker); + + impl Wake for CrossThreadWake { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + let waker = self.0.clone(); + std::thread::spawn(move || waker.wake()).join().unwrap(); + } + } #[test] fn wake_tracker_cross_thread_detection() { let tracker = WakeTracker::new(); let noop = Waker::noop(); - let waker = tracker.new_per_poll_waker(noop); + let tracked_waker = tracker.new_per_poll_waker(noop); + let cross_thread_waker = Waker::from(Arc::new(CrossThreadWake(tracked_waker))); - let _guard = SdkWakeGuard::new(); + let cancellation = WorkflowCancellationToken::new(); + let mut cancelled = std::pin::pin!(cancellation.cancelled()); + let mut cx = Context::from_waker(&cross_thread_waker); + assert!(cancelled.as_mut().poll(&mut cx).is_pending()); - let handle = std::thread::spawn(move || { - waker.wake_by_ref(); - }); - handle.join().unwrap(); + cancellation.cancel(); assert!(tracker.take_non_sdk_wake()); } diff --git a/crates/sdk/src/workflow_future.rs b/crates/sdk/src/workflow_future.rs index c9b638344..f18604ce0 100644 --- a/crates/sdk/src/workflow_future.rs +++ b/crates/sdk/src/workflow_future.rs @@ -34,17 +34,13 @@ use temporalio_common::{ }, }; use temporalio_workflow::{ - InternalPatchActivationCallback as PatchActivationCallback, - runtime::{ - guest::WorkflowInstance, - host::WorkflowHost, - model::{WorkflowResult, WorkflowTermination}, - types::{ - ActivationJobResult, ActivationResult, MainRoutineCompletion, RoutineCompletion, - RoutineId, RoutineKind, RoutinePendingState, RoutinePollResult, TerminalOutcome, - UpdateRoutineCompletion, WorkflowActivation, - }, + __private::sdk::{ + ActivationJobResult, ActivationResult, MainRoutineCompletion, RoutineCompletion, RoutineId, + RoutineKind, RoutinePendingState, RoutinePollResult, TerminalOutcome, + UpdateRoutineCompletion, WorkflowActivation, WorkflowHost, WorkflowInstance, }, + InternalPatchActivationCallback as PatchActivationCallback, WorkflowResult, + WorkflowTermination, }; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; @@ -527,7 +523,7 @@ impl WorkflowFuture { self.active_routines = still_active; let main_poll_result = match self - .poll_guest_routine(temporalio_workflow::runtime::types::MAIN_ROUTINE_ID, cx) + .poll_guest_routine(temporalio_workflow::__private::sdk::MAIN_ROUTINE_ID, cx) { Ok(result) => result, Err(e) => { diff --git a/crates/sdk/src/workflow_registry.rs b/crates/sdk/src/workflow_registry.rs index f9f2568cf..4b4b36406 100644 --- a/crates/sdk/src/workflow_registry.rs +++ b/crates/sdk/src/workflow_registry.rs @@ -12,15 +12,10 @@ use temporalio_common::{ }, }; use temporalio_workflow::{ + __private::sdk::{GuestWorkflowInstance, WorkflowHost, WorkflowInit, WorkflowInstance}, BaseWorkflowContext, InternalPatchActivationCallback as PatchActivationCallback, - runtime::{ - entry::WorkflowImplementation, - guest::WorkflowInstance, - host::WorkflowHost, - instance::{GuestWorkflowInstance, instantiate_workflow}, - types::{WorkflowDefinitionDescriptor, WorkflowInit}, - }, workflow_interceptors::WorkflowInterceptorConstructor, + workflows::{WorkflowDefinitionDescriptor, WorkflowImplementation}, }; /// Host-owned execution inputs used to instantiate a single workflow run. @@ -100,7 +95,7 @@ impl WorkflowDefinitions { { let factory = Arc::new(move |input| { let (payloads, payload_converter, base_ctx) = workflow_input_parts(input); - instantiate_workflow::(payloads, payload_converter, base_ctx) + GuestWorkflowInstance::::instantiate(payloads, payload_converter, base_ctx) .context("Failed to instantiate native workflow") }); self.insert_workflow(W::definition(), factory)?; diff --git a/crates/sdk/src/workflow_replayer.rs b/crates/sdk/src/workflow_replayer.rs index f2df36456..615737489 100644 --- a/crates/sdk/src/workflow_replayer.rs +++ b/crates/sdk/src/workflow_replayer.rs @@ -31,7 +31,7 @@ use temporalio_sdk_core::{ init_replay_worker, replay::{HistoryForReplay, ReplayWorkerInput}, }; -use temporalio_workflow::runtime::entry::WorkflowImplementation; +use temporalio_workflow::workflows::WorkflowImplementation; #[cfg(feature = "wasm-workflows")] use crate::WasmWorkflowComponent; diff --git a/crates/sdk/src/workflow_wasm.rs b/crates/sdk/src/workflow_wasm.rs index fd6032bde..94b6d30b8 100644 --- a/crates/sdk/src/workflow_wasm.rs +++ b/crates/sdk/src/workflow_wasm.rs @@ -6,17 +6,14 @@ use temporalio_common::protos::{ coresdk::workflow_commands::WorkflowCommand, temporal::api::failure::v1::Failure, }; use temporalio_workflow::{ - PatchActivationCaller, - runtime::{ - guest::WorkflowInstance, - host::WorkflowHost, - types::{ - ActivationJobResult, ActivationResult, MainRoutineCompletion, QueryResponse, - RoutineCompletion, RoutinePendingState, RoutinePollResult, StartedRoutine, TaskFailure, - TerminalOutcome, UpdateRoutineCompletion, UpdateRoutineKind, WorkflowActivation, - WorkflowDefinitionDescriptor, WorkflowFailure, - }, + __private::sdk::{ + ActivationJobResult, ActivationResult, MainRoutineCompletion, QueryResponse, + RoutineCompletion, RoutineKind, RoutinePendingState, RoutinePollResult, StartedRoutine, + TaskFailure, TerminalOutcome, UpdateRoutineCompletion, UpdateRoutineKind, + WorkflowActivation, WorkflowFailure, WorkflowHost, WorkflowInstance, }, + PatchActivationCaller, + workflows::{UpdateDefinitionDescriptor, WorkflowDefinitionDescriptor}, }; use wasmtime::{ Config, Engine, Store, @@ -175,11 +172,9 @@ impl CompiledWasmWorkflowModule { updates: def .updates .into_iter() - .map(|u| { - temporalio_workflow::runtime::types::UpdateDefinitionDescriptor { - name: u.name, - has_validator: u.has_validator, - } + .map(|u| UpdateDefinitionDescriptor { + name: u.name, + has_validator: u.has_validator, }) .collect(), }) @@ -278,20 +273,14 @@ impl WorkflowInstance for WasmWorkflowInstance { ActivationJobResult::StartedRoutine(StartedRoutine { routine_id: routine.routine_id, kind: match routine.kind { - wit_types::RoutineKind::Main => { - temporalio_workflow::runtime::types::RoutineKind::Main - } - wit_types::RoutineKind::Signal(name) => { - temporalio_workflow::runtime::types::RoutineKind::Signal(name) - } + wit_types::RoutineKind::Main => RoutineKind::Main, + wit_types::RoutineKind::Signal(name) => RoutineKind::Signal(name), wit_types::RoutineKind::Update(update) => { - temporalio_workflow::runtime::types::RoutineKind::Update( - UpdateRoutineKind { - name: update.name, - update_id: update.update_id, - protocol_instance_id: update.protocol_instance_id, - }, - ) + RoutineKind::Update(UpdateRoutineKind { + name: update.name, + update_id: update.update_id, + protocol_instance_id: update.protocol_instance_id, + }) } }, }) diff --git a/crates/workflow/src/component.rs b/crates/workflow/src/component.rs index 3619e96dd..8e6026e98 100644 --- a/crates/workflow/src/component.rs +++ b/crates/workflow/src/component.rs @@ -7,7 +7,7 @@ use crate::{ entry::WorkflowImplementation, guest::WorkflowInstance as RuntimeWorkflowInstance, host::WorkflowHost, - instance::instantiate_workflow, + instance::GuestWorkflowInstance, types::{ ActivationJobResult, MainRoutineCompletion, RoutineCompletion, RoutinePendingState, TerminalOutcome, UpdateRoutineCompletion, WorkflowDefinitionDescriptor, @@ -24,6 +24,10 @@ use temporalio_common_wasm::{ protos::{coresdk::workflow_commands::WorkflowCommand, temporal::api::failure::v1::Failure}, }; +/// Generated component-model bindings named by the workflow export macro. +/// +/// This module must remain public because the export macro expands in the workflow author's crate. +#[doc(hidden)] pub mod bindings { wit_bindgen::generate!({ path: "wit", @@ -46,8 +50,11 @@ use self::bindings::{ temporal::workflow_runtime::{types as wit_types, workflow_host as wit_host}, }; +/// Connects the static workflow set emitted by `export_workflow_module!` to the component adapter. pub trait StaticWorkflowComponent { + /// Describes every workflow implementation exported by the component. fn list_workflows() -> Vec; + /// Instantiates the workflow selected by the host from the component's static workflow set. fn instantiate_workflow( workflow_type: &str, init: WorkflowInit, @@ -55,6 +62,7 @@ pub trait StaticWorkflowComponent { ) -> Result, WorkflowFailure>; } +/// Adapts a [`StaticWorkflowComponent`] to the guest interface generated from the workflow WIT. pub struct ExportedComponent(PhantomData); impl wit_guest::Guest for ExportedComponent { @@ -83,6 +91,7 @@ impl wit_guest::Guest for ExportedComponent { } } +/// Adapts one runtime workflow instance to the resource interface generated from the workflow WIT. pub struct ExportedWorkflowInstance(RefCell>); impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { @@ -212,6 +221,7 @@ impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { } } +/// Instantiates a generated workflow implementation for a component without interceptors. pub fn instantiate_component_workflow( init: WorkflowInit, host: Rc, @@ -222,6 +232,7 @@ where instantiate_component_workflow_with_interceptor_constructors::(init, host, Vec::new()) } +/// Instantiates a generated workflow implementation with component-local interceptor constructors. pub fn instantiate_component_workflow_with_interceptor_constructors( init: WorkflowInit, host: Rc, @@ -242,7 +253,7 @@ where Some(patch_activation_callback), interceptor_constructors, ); - instantiate_workflow::(args, payload_converter, base_ctx).map_err(|err| { + GuestWorkflowInstance::::instantiate(args, payload_converter, base_ctx).map_err(|err| { Box::new(Failure { message: format!("Workflow input deserialization failed: {err}"), ..Default::default() diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 828e316aa..13317787b 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -12,28 +12,59 @@ pub use temporalio_macros::{ #[doc(hidden)] pub mod __private { - // Rexports used by macros pub use futures_util::{FutureExt, future::LocalBoxFuture, join, select_biased}; + + pub mod macros { + pub use crate::{ + component::{ + __wit_export, ExportedComponent, StaticWorkflowComponent, bindings, + instantiate_component_workflow, + instantiate_component_workflow_with_interceptor_constructors, + }, + runtime::{ + entry::WorkflowImplementation, + guest::WorkflowInstance, + host::WorkflowHost, + types::{ + UpdateDefinitionDescriptor, WorkflowDefinitionDescriptor, WorkflowFailure, + WorkflowInit, + }, + }, + }; + } + + pub mod sdk { + pub use crate::runtime::{ + entry::WorkflowImplementation, + guest::WorkflowInstance, + host::WorkflowHost, + instance::GuestWorkflowInstance, + is_sdk_wake, + types::{ + ActivationJobResult, ActivationResult, MAIN_ROUTINE_ID, MainRoutineCompletion, + QueryResponse, RoutineCompletion, RoutineId, RoutineKind, RoutinePendingState, + RoutinePollResult, StartedRoutine, TaskFailure, TerminalOutcome, + UpdateRoutineCompletion, UpdateRoutineKind, WorkflowActivation, WorkflowFailure, + WorkflowInit, + }, + }; + } } mod cancellation; -#[doc(hidden)] -pub mod component; -#[doc(hidden)] -pub mod runtime; +mod component; +mod runtime; mod workflow_context; pub mod workflow_interceptors; pub mod workflows; pub use cancellation::{WorkflowCancellationError, WorkflowCancellationToken}; pub use runtime::model::{TimerResult, WorkflowResult, WorkflowTermination}; -#[doc(hidden)] -pub use runtime::{SdkWakeGuard, is_sdk_wake}; pub use temporalio_common_wasm::{ ActivityCloseTimeouts, Memo, MemoValue, MemoValues, RetryPolicy, error::{ - ActivityExecutionError, ChildWorkflowExecutionError, ChildWorkflowStartError, RetryState, - TimeoutType, WorkflowSignalError, + ActivityExecutionError, CancelExternalWorkflowError, ChildWorkflowExecutionError, + ChildWorkflowStartError, RetryState, TimeoutType, WorkflowSignalError, }, }; pub use workflow_context::{ @@ -76,8 +107,8 @@ macro_rules! __temporal_join { #[doc(hidden)] macro_rules! __temporalio_export_workflow_component { ($export_type:ident) => { - $crate::component::__wit_export!( - $export_type with_types_in $crate::component::bindings + $crate::__private::macros::__wit_export!( + $export_type with_types_in $crate::__private::macros::bindings ); }; } @@ -111,24 +142,24 @@ macro_rules! export_workflow_module { ] } - impl ::temporalio_workflow::component::StaticWorkflowComponent for __TemporalWorkflowModule { + impl $crate::__private::macros::StaticWorkflowComponent for __TemporalWorkflowModule { fn list_workflows( - ) -> ::std::vec::Vec<::temporalio_workflow::runtime::types::WorkflowDefinitionDescriptor> { - ::std::vec![$(<$workflow as ::temporalio_workflow::runtime::entry::WorkflowImplementation>::definition()),*] + ) -> ::std::vec::Vec<$crate::__private::macros::WorkflowDefinitionDescriptor> { + ::std::vec![$(<$workflow as $crate::__private::macros::WorkflowImplementation>::definition()),*] } fn instantiate_workflow( workflow_type: &str, - init: ::temporalio_workflow::runtime::types::WorkflowInit, - host: ::std::rc::Rc, + init: $crate::__private::macros::WorkflowInit, + host: ::std::rc::Rc, ) -> ::std::result::Result< - ::std::boxed::Box, - ::temporalio_workflow::runtime::types::WorkflowFailure, + ::std::boxed::Box, + $crate::__private::macros::WorkflowFailure, > { match workflow_type { $( - name if name == <$workflow as ::temporalio_workflow::runtime::entry::WorkflowImplementation>::name() => { - ::temporalio_workflow::component::instantiate_component_workflow_with_interceptor_constructors::<$workflow>( + name if name == <$workflow as $crate::__private::macros::WorkflowImplementation>::name() => { + $crate::__private::macros::instantiate_component_workflow_with_interceptor_constructors::<$workflow>( init, host, __temporal_workflow_interceptor_constructors(), @@ -149,7 +180,7 @@ macro_rules! export_workflow_module { } type __TemporalWorkflowComponentExport = - ::temporalio_workflow::component::ExportedComponent<__TemporalWorkflowModule>; + $crate::__private::macros::ExportedComponent<__TemporalWorkflowModule>; ::temporalio_workflow::__temporalio_export_workflow_component!( __TemporalWorkflowComponentExport diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index e40c193ed..b355825b8 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -59,6 +59,7 @@ use temporalio_common_wasm::{ }, }; +/// Owns the deterministic execution state for one native workflow instance. pub struct GuestWorkflowInstance { base_ctx: BaseWorkflowContext, ctx: WorkflowContext, @@ -360,6 +361,8 @@ impl GuestWorkflowInstance where ::Input: Send, { + /// Deserializes workflow input, runs initialization interceptors, and creates an executable + /// workflow instance. pub fn instantiate( payloads: Vec, converter: PayloadConverter, @@ -407,6 +410,7 @@ where ))) } + /// Creates an executable instance around an already initialized workflow value. pub fn new_with_workflow( workflow: W, base_ctx: BaseWorkflowContext, @@ -1143,17 +1147,6 @@ where } } -pub fn instantiate_workflow( - payloads: Vec, - converter: PayloadConverter, - base_ctx: BaseWorkflowContext, -) -> Result, PayloadConversionError> -where - ::Input: Send, -{ - GuestWorkflowInstance::::instantiate(payloads, converter, base_ctx) -} - /// Attempts to turn caught panics into something printable fn panic_formatter(panic: Box) -> Box { _panic_formatter::<&str>(panic) diff --git a/crates/workflow/src/runtime/mod.rs b/crates/workflow/src/runtime/mod.rs index 018eea3d0..4e29e99a1 100644 --- a/crates/workflow/src/runtime/mod.rs +++ b/crates/workflow/src/runtime/mod.rs @@ -13,12 +13,12 @@ use std::{ task::{Context, Poll}, }; -pub mod entry; -pub mod guest; -pub mod host; -pub mod instance; -pub mod model; -pub mod types; +pub(crate) mod entry; +pub(crate) mod guest; +pub(crate) mod host; +pub(crate) mod instance; +pub(crate) mod model; +pub(crate) mod types; thread_local! { static SDK_WAKE_DEPTH: Cell = const { Cell::new(0) }; @@ -238,14 +238,13 @@ pub(crate) fn mark_intercepted_handler_ready() { } /// Guard that marks the current scope as an SDK-initiated wake source. -#[doc(hidden)] -pub struct SdkWakeGuard { +pub(crate) struct SdkWakeGuard { _not_send_or_sync: PhantomData>, } impl SdkWakeGuard { - #[doc(hidden)] - pub fn new() -> Self { + /// Enters an SDK wake scope until the returned guard is dropped. + pub(crate) fn new() -> Self { SDK_WAKE_DEPTH.with(|c| c.set(c.get() + 1)); Self { _not_send_or_sync: PhantomData, @@ -253,13 +252,19 @@ impl SdkWakeGuard { } } +impl Default for SdkWakeGuard { + fn default() -> Self { + Self::new() + } +} + impl Drop for SdkWakeGuard { fn drop(&mut self) { SDK_WAKE_DEPTH.with(|c| c.set(c.get() - 1)); } } -#[doc(hidden)] +/// Reports whether the current thread is inside an SDK-initiated wake scope. pub fn is_sdk_wake() -> bool { SDK_WAKE_DEPTH.with(|c| c.get() > 0) } @@ -276,3 +281,43 @@ impl Future for SdkGuardedFuture { Pin::new(&mut self.0).poll(cx) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sdk_wake_guard_nesting() { + assert!(!is_sdk_wake()); + + { + let _guard1 = SdkWakeGuard::new(); + assert!(is_sdk_wake()); + { + let _guard2 = SdkWakeGuard::new(); + assert!(is_sdk_wake()); + } + assert!(is_sdk_wake()); + } + assert!(!is_sdk_wake()); + } + + #[test] + fn sdk_wake_guard_panic_safety() { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = SdkWakeGuard::new(); + panic!("test panic"); + })); + assert!(result.is_err()); + assert!(!is_sdk_wake()); + } + + #[test] + fn sdk_wake_guard_is_thread_local() { + let _guard = SdkWakeGuard::new(); + assert!(is_sdk_wake()); + + let child_is_sdk_wake = std::thread::spawn(is_sdk_wake).join().unwrap(); + assert!(!child_is_sdk_wake); + } +} diff --git a/crates/workflow/src/runtime/model.rs b/crates/workflow/src/runtime/model.rs index 80fdb9a3c..60f14222e 100644 --- a/crates/workflow/src/runtime/model.rs +++ b/crates/workflow/src/runtime/model.rs @@ -15,8 +15,8 @@ use temporalio_common_wasm::{ WorkflowDefinition, data_converters::{PayloadConversionError, TemporalSerializable}, error::{ - ActivityExecutionError, ApplicationFailure, ChildWorkflowExecutionError, - ChildWorkflowStartError, WorkflowSignalError, + ActivityExecutionError, ApplicationFailure, CancelExternalWorkflowError, + ChildWorkflowExecutionError, ChildWorkflowStartError, WorkflowSignalError, }, protos::{ coresdk::{ @@ -56,15 +56,15 @@ pub enum TimerResult { /// Successful result of sending a signal to an external workflow #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SignalExternalOk; +pub(crate) struct SignalExternalOk; /// Result of awaiting on sending a signal to an external workflow -pub type SignalExternalWfResult = Result; +pub(crate) type SignalExternalWfResult = Result; -/// Successful result of sending a cancel request to an external workflow +/// Distinguishes external cancellation resolutions from other command results. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CancelExternalOk; -/// Result of awaiting on sending a cancel request to an external workflow -pub type CancelExternalWfResult = Result; +pub(crate) struct CancelExternalOk; +/// Internal result delivered when an external cancellation command resolves. +pub(crate) type CancelExternalWfResult = Result; pub(crate) trait Unblockable { type OtherDat; @@ -179,16 +179,20 @@ pub type WorkflowResult = Result; /// Wrap an error in an [`ApplicationFailure`] to explicitly fail the Workflow Execution. #[derive(derive_more::Debug, thiserror::Error)] pub enum WorkflowTermination { + /// The Workflow Execution was cancelled, optionally with user-supplied details. #[error("Workflow cancelled")] Cancelled { /// Optional cancellation details. #[debug(skip)] details: Option>, }, + /// The workflow was evicted and must stop without producing a completion command. #[error("Workflow evicted from cache")] Evicted, + /// The workflow requested a new run with the supplied command attributes. #[error("Continue as new")] ContinueAsNew(Box), + /// The Workflow Execution failed with an error already converted for outbound handling. #[error("Workflow failed: {0}")] Failed(#[source] temporalio_common_wasm::error::OutgoingWorkflowError), } @@ -210,6 +214,7 @@ impl WorkflowTermination { } } + /// Constructs a termination that asks the worker to continue the workflow as a new run. pub fn continue_as_new(can: ContinueAsNewRequest) -> Self { Self::ContinueAsNew(Box::new(can)) } @@ -256,6 +261,12 @@ impl From for WorkflowTermination { } } +impl From for WorkflowTermination { + fn from(value: CancelExternalWorkflowError) -> Self { + Self::Failed(value.into()) + } +} + impl From for WorkflowTermination { fn from(value: ChildWorkflowStartError) -> Self { Self::Failed(value.into()) @@ -278,6 +289,7 @@ mod tests { #[case::child_start(ChildWorkflowStartError::Serialization(conversion_error()))] #[case::child_execution(ChildWorkflowExecutionError::Serialization(conversion_error()))] #[case::signal(WorkflowSignalError::Serialization(conversion_error()))] + #[case::cancel_external(CancelExternalWorkflowError::Serialization(conversion_error()))] fn conversion_error_is_preserved_in_workflow_termination>( #[case] error: T, ) { diff --git a/crates/workflow/src/runtime/types.rs b/crates/workflow/src/runtime/types.rs index 5944a5667..7fe00b366 100644 --- a/crates/workflow/src/runtime/types.rs +++ b/crates/workflow/src/runtime/types.rs @@ -13,112 +13,174 @@ use temporalio_common_wasm::protos::{ }, }; +/// Host-provided state required to construct one workflow execution. #[derive(Clone, Debug, PartialEq)] pub struct WorkflowInit { + /// Namespace used when workflow code constructs namespaced commands. pub namespace: String, + /// Task queue exposed through workflow information. pub task_queue: String, + /// Run ID used to seed deterministic workflow state. pub run_id: String, + /// Initialization activation job containing workflow metadata and input. pub initialize_workflow: InitializeWorkflow, } +/// Static metadata a host needs before choosing and instantiating a workflow implementation. #[derive(Clone, Debug, PartialEq, Eq)] pub struct WorkflowDefinitionDescriptor { + /// Workflow type registered with the worker. pub workflow_type: String, + /// Whether initialization must invoke a user-defined `#[init]` method. pub has_init: bool, + /// Whether workflow input is consumed by `#[init]` instead of `#[run]`. pub init_takes_input: bool, + /// Signal names accepted by the workflow implementation. pub signals: Vec, + /// Query names accepted by the workflow implementation. pub queries: Vec, + /// Update definitions accepted by the workflow implementation. pub updates: Vec, } +/// Static metadata needed to route an update before constructing its handler future. #[derive(Clone, Debug, PartialEq, Eq)] pub struct UpdateDefinitionDescriptor { + /// Update name registered by the workflow implementation. pub name: String, + /// Whether the update has a validator that must run before its handler. pub has_validator: bool, } +/// Encoded query result returned directly while applying an activation. #[derive(Clone, Debug, PartialEq)] pub struct QueryResponse { + /// Successful payload or failure produced by the query handler. pub result: Result, } +/// Identifier assigned by the workflow runtime to a pollable routine. pub type RoutineId = u64; +/// Reserved routine identifier for the workflow's main run method. pub const MAIN_ROUTINE_ID: RoutineId = 0; +/// Activation representation shared by native and component workflow backends. pub type WorkflowActivation = CoreWorkflowActivation; +/// Identifies which workflow handler owns a runtime routine. #[derive(Clone, Debug, PartialEq)] pub enum RoutineKind { + /// The workflow's main run method. Main, + /// A signal handler, identified by signal name. Signal(String), + /// An update handler and its protocol routing metadata. Update(UpdateRoutineKind), } +/// Routing metadata required to complete an update routine through the update protocol. #[derive(Clone, Debug, PartialEq, Eq)] pub struct UpdateRoutineKind { + /// Registered update name. pub name: String, + /// User-visible update ID. pub update_id: String, + /// Protocol instance receiving the update response. pub protocol_instance_id: String, } +/// Describes a handler routine created while applying an activation. #[derive(Clone, Debug, PartialEq)] pub struct StartedRoutine { + /// Runtime-assigned identifier used for subsequent polls. pub routine_id: RoutineId, + /// Handler category and routing metadata for the new routine. pub kind: RoutineKind, } +/// Result produced synchronously while applying one activation job. #[derive(Clone, Debug, PartialEq)] pub enum ActivationJobResult { + /// The job produced no host-visible result. None, + /// The job started a routine that the host must poll. StartedRoutine(StartedRoutine), + /// A query completed without creating a persistent routine. QueryResponse(Box), + /// An update validator rejected the update before its handler started. UpdateRejected(WorkflowFailure), } +/// Results produced while applying all jobs in one activation. #[derive(Clone, Debug, PartialEq)] pub struct ActivationResult { + /// One result for each activation job, preserving activation order. pub job_results: Vec, } -pub type ContinueAsNewRequest = ContinueAsNewWorkflowExecution; +/// Command attributes used when a workflow continues as a new run. +pub(crate) type ContinueAsNewRequest = ContinueAsNewWorkflowExecution; +/// Workflow Task failure requested by the main workflow routine. #[derive(Clone, Debug, PartialEq)] pub struct TaskFailure { + /// Failure returned to Core for the current Workflow Task. pub failure: WorkflowFailure, + /// Optional server failure cause override used for failures such as nondeterminism. pub force_cause: Option, } +/// Terminal command requested when the main workflow routine finishes. #[derive(Clone, Debug, PartialEq)] pub enum TerminalOutcome { + /// Complete the Workflow Execution with the encoded result. Completed(Payload), + /// Fail the Workflow Execution with the encoded failure. Failed(WorkflowFailure), + /// Cancel the Workflow Execution with optional encoded details. Cancelled(Option), + /// Continue the Workflow Execution as a new run. ContinueAsNew(Box), } +/// Completion state returned when polling the main workflow routine. #[derive(Clone, Debug, PartialEq)] pub enum MainRoutineCompletion { + /// The main routine is intentionally blocked until a later activation. Blocked, + /// The current Workflow Task must fail without terminating the Workflow Execution. TaskFailed(TaskFailure), + /// The Workflow Execution reached a terminal or continue-as-new outcome. Terminal(Box), } +/// Completion state returned when polling an update handler routine. #[derive(Clone, Debug, PartialEq)] pub enum UpdateRoutineCompletion { + /// The update handler completed successfully. Completed { + /// Protocol instance receiving the successful response. protocol_instance_id: String, + /// Encoded update result. result: Payload, }, + /// The update handler failed after being accepted. Rejected { + /// Protocol instance receiving the failure response. protocol_instance_id: String, + /// Encoded handler failure. failure: WorkflowFailure, }, } +/// Completion state for any pollable workflow routine. #[derive(Clone, Debug, PartialEq)] pub enum RoutineCompletion { + /// Completion from the main workflow routine. Main(MainRoutineCompletion), + /// Completion from a signal handler. Signal(Result<(), WorkflowFailure>), + /// Completion from an update handler. Update(UpdateRoutineCompletion), } @@ -140,11 +202,16 @@ pub enum RoutinePendingState { InterceptorWithActivation, } +/// Outcome of polling one workflow routine. #[derive(Clone, Debug, PartialEq)] pub struct RoutinePollResult { + /// Completion emitted when the routine finished during this poll. pub completion: Option, + /// Whether polling advanced runtime state even if the routine remains pending. pub made_progress: bool, + /// Why an intercepted routine remains pending, when interceptor tracking applies. pub pending_state: Option, } +/// Failure representation shared across native and component workflow runtime boundaries. pub type WorkflowFailure = Box; diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 1099d4231..449bdb93b 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -33,15 +33,15 @@ use crate::{ types::WorkflowInit, }, workflow_interceptors::{ - CancelExternalWorkflowInput, CancellableWorkflowOutboundFuture, - ChildWorkflowOutboundResult, ContinueAsNewInput, ScheduleActivityInput, - ScheduleLocalActivityInput, SignalWorkflowInput, SignalWorkflowResult, - SignalWorkflowTarget, StartChildWorkflowInput, StartChildWorkflowResult, StartTimerInput, - WorkflowCancellationHandle, WorkflowInterceptor, WorkflowInterceptorConstructor, - WorkflowInterceptorContext, WorkflowNext, WorkflowOutboundFuture, WorkflowOutboundValue, - call_cancel_external_workflow, call_continue_as_new, call_schedule_activity, - call_schedule_local_activity, call_signal_workflow, call_start_child_workflow, - call_start_timer, + CancelExternalWorkflowInput, CancelExternalWorkflowResult, + CancellableWorkflowOutboundFuture, ChildWorkflowOutboundResult, ContinueAsNewInput, + ScheduleActivityInput, ScheduleLocalActivityInput, SignalWorkflowInput, + SignalWorkflowResult, SignalWorkflowTarget, StartChildWorkflowInput, + StartChildWorkflowResult, StartTimerInput, WorkflowCancellationHandle, WorkflowInterceptor, + WorkflowInterceptorConstructor, WorkflowInterceptorContext, WorkflowNext, + WorkflowOutboundFuture, WorkflowOutboundValue, call_cancel_external_workflow, + call_continue_as_new, call_schedule_activity, call_schedule_local_activity, + call_signal_workflow, call_start_child_workflow, call_start_timer, }, }; use futures_channel::oneshot; @@ -68,13 +68,13 @@ use temporalio_common_wasm::{ ActivityDefinition, Memo, SignalDefinition, WorkflowDefinition, data_converters::{ ActivityExecutionDecodeHint, ChildWorkflowExecutionDecodeHint, - ChildWorkflowStartDecodeHint, DataConverter, GenericPayloadConverter, + ChildWorkflowStartDecodeHint, DataConverter, GenericPayloadConverter, NoopDecodeHint, PayloadConversionError, PayloadConverter, SerializationContext, SerializationContextData, TemporalDeserializable, WorkflowSerializationContext, WorkflowSignalDecodeHint, }, error::{ - ActivityExecutionError, ChildWorkflowExecutionError, ChildWorkflowStartError, - WorkflowSignalError, + ActivityExecutionError, CancelExternalWorkflowError, ChildWorkflowExecutionError, + ChildWorkflowStartError, WorkflowSignalError, }, protos::{ coresdk::{ @@ -1428,7 +1428,7 @@ impl BaseWorkflowContext { fn cancel_external_workflow( &self, input: CancelExternalWorkflowInput, - ) -> WorkflowOutboundFuture { + ) -> WorkflowOutboundFuture { let base_ctx = self.clone(); let next = WorkflowNext::new(move |input: CancelExternalWorkflowInput| { let seq = base_ctx @@ -1436,7 +1436,7 @@ impl BaseWorkflowContext { .seq_nums .borrow_mut() .next_cancel_external_wf_seq(); - let (cmd, unblocker) = WFCommandFut::new(); + let (cmd, unblocker) = WFCommandFut::::new(); base_ctx .inner .runtime @@ -1455,7 +1455,18 @@ impl BaseWorkflowContext { ) .into(), ); - WorkflowOutboundFuture::new(cmd) + let data_converter = base_ctx.data_converter().clone(); + WorkflowOutboundFuture::new(async move { + match cmd.await { + Ok(_) => Ok(()), + Err(failure) => { + let context = + SerializationContextData::Workflow(WorkflowSerializationContext::new()); + let error = data_converter.to_error(&context, failure, NoopDecodeHint)?; + Err(CancelExternalWorkflowError::Failed(Box::new(error))) + } + } + }) }); let interceptors = self.inner.workflow_interceptors.clone(); let future = call_cancel_external_workflow( @@ -3349,7 +3360,7 @@ impl ExternalWorkflowHandle { pub fn cancel( &self, reason: Option, - ) -> impl FusedFuture { + ) -> impl FusedFuture { self.base_ctx .cancel_external_workflow(CancelExternalWorkflowInput { workflow_id: self.workflow_id.clone(), @@ -4181,9 +4192,9 @@ mod tests { next: WorkflowNext< 'static, CancelExternalWorkflowInput, - WorkflowOutboundFuture, + WorkflowOutboundFuture, >, - ) -> WorkflowOutboundFuture { + ) -> WorkflowOutboundFuture { input.workflow_id = "mutated-cancel-workflow".to_string(); input.run_id = Some("mutated-cancel-run".to_string()); input.reason = Some("mutated-reason".to_string()); diff --git a/crates/workflow/src/workflow_interceptors.rs b/crates/workflow/src/workflow_interceptors.rs index ed53cc043..97d216895 100644 --- a/crates/workflow/src/workflow_interceptors.rs +++ b/crates/workflow/src/workflow_interceptors.rs @@ -82,14 +82,15 @@ //! ``` use crate::{ - ActivityOptions, BaseWorkflowContext, CancellableFuture, CancellableFutureWithReason, - ChildWorkflowOptions, ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, - SignalWorkflowOptions, StartChildWorkflowOutput, StartedChildWorkflow, TimerOptions, - WorkflowCancellationToken, WorkflowContextView, WorkflowRandomStream, + ActivityOptions, BaseWorkflowContext, CancelExternalWorkflowError, CancellableFuture, + CancellableFutureWithReason, ChildWorkflowOptions, ContinueAsNewOptions, + ExternalWorkflowHandle, LocalActivityOptions, SignalWorkflowOptions, StartChildWorkflowOutput, + StartedChildWorkflow, TimerOptions, WorkflowCancellationToken, WorkflowContextView, + WorkflowRandomStream, cancellation::WorkflowCancellationRegistration, runtime::{ entry::WorkflowError, - model::{CancelExternalWfResult, TimerResult, WorkflowResult, WorkflowTermination}, + model::{TimerResult, WorkflowResult, WorkflowTermination}, }, }; use futures_util::{ @@ -1377,6 +1378,9 @@ pub type ChildWorkflowOutboundResult = /// Result of an intercepted signal call. pub type SignalWorkflowResult = Result<(), WorkflowSignalError>; +/// Result of requesting cancellation of an external workflow. +pub type CancelExternalWorkflowResult = Result<(), CancelExternalWorkflowError>; + /// Result of an intercepted child workflow start. pub type StartChildWorkflowResult = Result; @@ -1563,9 +1567,9 @@ pub trait WorkflowInterceptor: 'static { next: WorkflowNext< 'static, CancelExternalWorkflowInput, - WorkflowOutboundFuture, + WorkflowOutboundFuture, >, - ) -> WorkflowOutboundFuture { + ) -> WorkflowOutboundFuture { next.run(input) } @@ -1671,7 +1675,7 @@ outbound_chain!( cancel_external_workflow, WorkflowInterceptorContext, CancelExternalWorkflowInput, - WorkflowOutboundFuture + WorkflowOutboundFuture ); outbound_chain!( call_continue_as_new, diff --git a/crates/workflow/src/workflows.rs b/crates/workflow/src/workflows.rs index 3abec6efc..caea861df 100644 --- a/crates/workflow/src/workflows.rs +++ b/crates/workflow/src/workflows.rs @@ -92,9 +92,12 @@ pub use crate::__temporal_join as join; use crate::runtime::SdkGuardedFuture; use futures_util::FutureExt; -pub use crate::runtime::entry::{ - ExecutableAsyncSignal, ExecutableAsyncUpdate, ExecutableQuery, ExecutableSyncSignal, - ExecutableSyncUpdate, WorkflowError, WorkflowImplementation, serialize_result, +pub use crate::runtime::{ + entry::{ + ExecutableAsyncSignal, ExecutableAsyncUpdate, ExecutableQuery, ExecutableSyncSignal, + ExecutableSyncUpdate, WorkflowError, WorkflowImplementation, serialize_result, + }, + types::{UpdateDefinitionDescriptor, WorkflowDefinitionDescriptor}, }; /// Deterministic `join_all` for use in Temporal workflows. From 5db7e2efd5e33b868542dba8a5190360b6554dcd Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 3 Sep 2026 08:54:47 -0700 Subject: [PATCH 59/87] docs: update API WIT documentation (#1573) --- .../nexus/deps/nexus-temporal-types/model.wit | 2 ++ .../api_upstream/nexus/workflow-service.wit | 20 ++++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit index 9909d34c6..f7d2ebd64 100644 --- a/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit +++ b/crates/protos/protos/api_upstream/nexus/deps/nexus-temporal-types/model.wit @@ -177,8 +177,10 @@ interface model { /// typescript-import="@temporalio/common" type workflow-id-conflict-policy = placeholder; + /// @nexus.doc "Static metadata for a workflow execution." /// @nexus.proto "temporal.api.sdk.v1.UserMetadata" typescript-import="@temporalio/proto" /// @nexus.flatten-in-api + /// @nexus.experimental record user-metadata { /// @nexus.doc "Single-line fixed summary for the workflow execution that may appear in UI and CLI. This can be in single-line Temporal Markdown format." /// @nexus.proto-field "summary" diff --git a/crates/protos/protos/api_upstream/nexus/workflow-service.wit b/crates/protos/protos/api_upstream/nexus/workflow-service.wit index 190aae88c..c4a7bf5e9 100644 --- a/crates/protos/protos/api_upstream/nexus/workflow-service.wit +++ b/crates/protos/protos/api_upstream/nexus/workflow-service.wit @@ -37,9 +37,10 @@ interface workflow-service { /// python="Workflow type name or callable identifying the workflow to start." /// typescript="Workflow type name or workflow function identifying the workflow to start." /// dotnet="Workflow type name or workflow expression identifying the workflow to start." + /// go="Workflow function identifying the workflow to start." /// @nexus.proto-field "workflow_type" workflow: workflow-function, - /// @nexus.doc "Unique identifier for the workflow execution." + /// @nexus.doc "Unique identifier for the workflow execution. Must be nonempty." /// @nexus.proto-field "workflow_id" id: string, /// @nexus.doc "Task queue to run the workflow on." @@ -50,14 +51,17 @@ interface workflow-service { /// dotnet="Signal name or signal expression to send with the start request." /// @nexus.proto-field "signal_name" signal: signal-function, - /// @nexus.doc "Total workflow execution timeout, including retries and continue-as-new." + /// @nexus.doc "Total workflow execution timeout, including retries and continue-as-new. Defaults to unlimited." /// @nexus.proto-field "workflow_execution_timeout" + /// @nexus.name go="WorkflowExecutionTimeout" execution-timeout: option, - /// @nexus.doc "Timeout of a single workflow run." + /// @nexus.doc "Timeout of a single workflow run. Defaults to the workflow execution timeout." /// @nexus.proto-field "workflow_run_timeout" + /// @nexus.name go="WorkflowRunTimeout" run-timeout: option, - /// @nexus.doc "Timeout of a single workflow task." + /// @nexus.doc "Timeout of a single workflow task. Defaults to 10 seconds." /// @nexus.proto-field "workflow_task_timeout" + /// @nexus.name go="WorkflowTaskTimeout" task-timeout: option, /// @nexus.omit identity: placeholder, @@ -67,7 +71,7 @@ interface workflow-service { /// @nexus.proto-field "workflow_id_reuse_policy" /// @nexus.default "allow-duplicate" id-reuse-policy: workflow-id-reuse-policy, - /// @nexus.doc "Behavior when a workflow is currently running with the same ID. Set to use-existing for idempotent deduplication on workflow ID. Cannot be set if id-reuse-policy is terminate-if-running." + /// @nexus.doc "Behavior when a workflow is currently running with the same ID. Set to use-existing for idempotent deduplication on workflow ID. Cannot be set if id-reuse-policy is terminate-if-running. Defaults to use-existing." /// @nexus.proto-field "workflow_id_conflict_policy" id-conflict-policy: option, /// @nexus.doc "Retry policy for the workflow." @@ -85,12 +89,15 @@ interface workflow-service { /// @nexus.doc "Amount of time to wait before starting the workflow. This does not work with cron-schedule." /// @nexus.proto-field "workflow_start_delay" start-delay: option, + /// @nexus.doc "Static metadata for the workflow execution." user-metadata: option, /// @nexus.source python="workflow_namespace()" typescript="workflowNamespace()" go="workflow.GetInfo(ctx).Namespace" dotnet="TemporalWorkflowContext.WorkflowNamespace()" + /// @nexus.doc "Namespace of the workflow execution." namespace: string, /// @nexus.omit control: placeholder, /// @nexus.api-omit + /// @nexus.doc "Headers for the request." /// @nexus.proto-field "header" headers: option
, /// @nexus.omit @@ -99,10 +106,13 @@ interface workflow-service { time-skipping-config: placeholder, } + /// @nexus.doc "Result of signaling a workflow and starting it if needed." /// @nexus.experimental /// @nexus.proto "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse" typescript-import="@temporalio/proto" record signal-with-start-workflow-response { + /// @nexus.doc "Run ID of the started workflow." run-id: option, + /// @nexus.doc "Whether the workflow was started." started: option, /// @nexus.omit signal-link: placeholder, From a94ca631fca1f625dd4da4bf1390294df1d7d458 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Thu, 3 Sep 2026 12:38:14 -0400 Subject: [PATCH 60/87] chore(sdk): use correct repo in cargo toml (#1567) --- crates/common-wasm/Cargo.toml | 2 +- crates/protos/Cargo.toml | 2 +- crates/workflow/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/common-wasm/Cargo.toml b/crates/common-wasm/Cargo.toml index f5f96dd32..3a57ba39e 100644 --- a/crates/common-wasm/Cargo.toml +++ b/crates/common-wasm/Cargo.toml @@ -6,7 +6,7 @@ authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } description = "WASM-safe shared functionality for the Temporal Rust workflow surface" homepage = "https://temporal.io/" -repository = "https://github.com/temporalio/sdk-core" +repository = "https://github.com/temporalio/sdk-rust" keywords = ["temporal", "workflow"] categories = ["development-tools"] diff --git a/crates/protos/Cargo.toml b/crates/protos/Cargo.toml index e756990b4..ebbae9820 100644 --- a/crates/protos/Cargo.toml +++ b/crates/protos/Cargo.toml @@ -6,7 +6,7 @@ authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } description = "Compiled protobuf definitions for the Temporal Rust SDK" homepage = "https://temporal.io/" -repository = "https://github.com/temporalio/sdk-core" +repository = "https://github.com/temporalio/sdk-rust" keywords = ["temporal", "protobuf"] categories = ["development-tools"] exclude = ["protos/*/.github/*"] diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index 63dce1d83..8ec424661 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -6,7 +6,7 @@ authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } description = "Temporal Rust workflow authoring surface" homepage = "https://temporal.io/" -repository = "https://github.com/temporalio/sdk-core" +repository = "https://github.com/temporalio/sdk-rust" keywords = ["temporal", "workflow"] categories = ["development-tools"] From 139d2e8fa7556999423b0b73d839a6d92e2c3e97 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Thu, 3 Sep 2026 12:46:46 -0400 Subject: [PATCH 61/87] chore(sdk): mark remaining error enums as non exhaustive (#1568) --- CHANGELOG.md | 4 ++++ crates/common-wasm/src/activity_definition.rs | 1 + crates/common-wasm/src/data_converters.rs | 1 + crates/common-wasm/src/error.rs | 4 ++++ .../sdk-core/tests/integ_tests/workflow_tests/activities.rs | 1 + crates/sdk/src/activities.rs | 6 ++++++ 6 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1381eb1..610cbdc3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ relevant information. ### Added * `CancelExternalWorkflowError` and `workflow_interceptors::CancelExternalWorkflowResult` for use in interceptors. +### Breaking Changes +* `ActivityError`, `PayloadConversionError`, `ActivityExecutionError`, + `ChildWorkflowStartError`, `ChildWorkflowExecutionError`, and `WorkflowSignalError` are now + non-exhaustive. Add wildcard branches when matching these enums. ## [0.8.0] - 2026-09-02 diff --git a/crates/common-wasm/src/activity_definition.rs b/crates/common-wasm/src/activity_definition.rs index 04e6d5e39..6fcfa80ee 100644 --- a/crates/common-wasm/src/activity_definition.rs +++ b/crates/common-wasm/src/activity_definition.rs @@ -44,6 +44,7 @@ impl ActivityDefinition for UntypedActivity { /// Returned as errors from activity functions. #[derive(Debug)] +#[non_exhaustive] pub enum ActivityError { /// Return this error to attach application-failure metadata to an activity failure. Application(Box), diff --git a/crates/common-wasm/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs index 9fc5b3c1c..d4562e133 100644 --- a/crates/common-wasm/src/data_converters.rs +++ b/crates/common-wasm/src/data_converters.rs @@ -256,6 +256,7 @@ impl Default for PayloadConverter { /// Errors that can occur during payload conversion. #[derive(Debug)] +#[non_exhaustive] pub enum PayloadConversionError { /// The payload's encoding does not match what the converter expects. WrongEncoding, diff --git a/crates/common-wasm/src/error.rs b/crates/common-wasm/src/error.rs index 98131de9c..f067e0da9 100644 --- a/crates/common-wasm/src/error.rs +++ b/crates/common-wasm/src/error.rs @@ -990,6 +990,7 @@ incoming_failure_wrapper!( /// Error type for activity execution outcomes. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum ActivityExecutionError { /// The activity failed with the given failure details. #[error("Activity failed: {}", .0.failure().message)] @@ -1051,6 +1052,7 @@ impl ActivityExecutionError { /// Error returned when starting a child workflow fails. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum ChildWorkflowStartError { /// The child workflow start was cancelled before the normal execution wrapper path existed. #[error("Child workflow start cancelled: {}", .0.failure().message)] @@ -1104,6 +1106,7 @@ impl ChildWorkflowStartError { /// Error returned when a child workflow execution fails. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum ChildWorkflowExecutionError { /// The child workflow failed. #[error("Child workflow failed: {}", .0.failure().message)] @@ -1159,6 +1162,7 @@ impl ChildWorkflowExecutionError { /// Error returned when signaling a workflow fails. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum WorkflowSignalError { /// The signal delivery failed. #[error("Child workflow signal failed: {}", .0.failure().message)] diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs index 0f8d5c9de..92a91b687 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/activities.rs @@ -208,6 +208,7 @@ fn activity_execution_result_status(result: &ExecuteActivityResult) -> &'static Err(ActivityError::Application(_)) => "failed", Err(ActivityError::Cancelled { .. }) => "cancelled", Err(ActivityError::WillCompleteAsync) => "will_complete_async", + Err(_) => "unknown", } } diff --git a/crates/sdk/src/activities.rs b/crates/sdk/src/activities.rs index 2d71406c0..f2164b270 100644 --- a/crates/sdk/src/activities.rs +++ b/crates/sdk/src/activities.rs @@ -672,6 +672,12 @@ pub(crate) fn activity_error_to_core_result( OutgoingError::Activity(OutgoingActivityError::Cancelled { details }), )), ActivityError::WillCompleteAsync => ActivityExecutionResult::will_complete_async(), + other => ActivityExecutionResult::fail(dc.to_failure( + &SerializationContextData::Activity(ActivitySerializationContext::new()), + OutgoingError::Activity(OutgoingActivityError::Application(Box::new( + ApplicationFailure::new(anyhow::anyhow!("Unsupported activity error: {other:?}")), + ))), + )), } } From 078f7fe5810fa42db3be6deedeb20c9cf48cd223 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Thu, 3 Sep 2026 14:02:05 -0400 Subject: [PATCH 62/87] feat(sdk): add not found variant for signal/cancel external wf (#1569) * feat(sdk): add NotFound variant to signal/cancel external wf * chore(sdk): wrap cancel external wf error --- CHANGELOG.md | 4 + crates/common-wasm/src/data_converters.rs | 5 +- .../src/data_converters/failure_converter.rs | 117 ++++++++++++++++-- crates/common-wasm/src/error.rs | 69 +++++++++-- .../workflow_activation.proto | 9 +- crates/sdk-core/CHANGELOG.md | 2 + .../machines/cancel_external_state_machine.rs | 2 + .../machines/signal_external_state_machine.rs | 3 + .../workflow_tests/cancel_external.rs | 37 +++++- .../integ_tests/workflow_tests/signals.rs | 4 +- crates/workflow/src/runtime/instance.rs | 21 +++- crates/workflow/src/runtime/model.rs | 26 +++- crates/workflow/src/workflow_context.rs | 28 +++-- 13 files changed, 282 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 610cbdc3b..aa91454f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ relevant information. * `ActivityError`, `PayloadConversionError`, `ActivityExecutionError`, `ChildWorkflowStartError`, `ChildWorkflowExecutionError`, and `WorkflowSignalError` are now non-exhaustive. Add wildcard branches when matching these enums. + for use in interceptors, with `WorkflowCancelFailureError` exposing the decoded cancellation + failure. +* `WorkflowSignalError::NotFound` and `CancelExternalWorkflowError::NotFound` let workflows + distinguish a missing signal or cancellation target from other delivery failures. ## [0.8.0] - 2026-09-02 diff --git a/crates/common-wasm/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs index d4562e133..3529e6c75 100644 --- a/crates/common-wasm/src/data_converters.rs +++ b/crates/common-wasm/src/data_converters.rs @@ -5,8 +5,9 @@ mod failure_converter; mod well_known; pub use failure_converter::{ - ActivityExecutionDecodeHint, ChildWorkflowExecutionDecodeHint, ChildWorkflowStartDecodeHint, - CommonAttributes, DefaultFailureConverter, FailureConverter, FailureDecodeHint, NoopDecodeHint, + ActivityExecutionDecodeHint, CancelExternalWorkflowDecodeHint, + ChildWorkflowExecutionDecodeHint, ChildWorkflowStartDecodeHint, CommonAttributes, + DefaultFailureConverter, FailureConverter, FailureDecodeHint, NoopDecodeHint, WorkflowSignalDecodeHint, }; use well_known::{BINARY_NULL_ENCODING_VAL, WellKnownType, binary_null_payload}; diff --git a/crates/common-wasm/src/data_converters/failure_converter.rs b/crates/common-wasm/src/data_converters/failure_converter.rs index ca5deb3f4..c6615e40c 100644 --- a/crates/common-wasm/src/data_converters/failure_converter.rs +++ b/crates/common-wasm/src/data_converters/failure_converter.rs @@ -20,10 +20,13 @@ use crate::{ ChildWorkflowFailureError, ChildWorkflowStartError, IncomingError, IncomingNexusHandlerError, IncomingNexusOperationExecutionError, OutgoingActivityError, OutgoingError, OutgoingWorkflowError, ResetWorkflowError, ServerError, TerminatedError, - TimeoutError, WorkflowSignalError, WorkflowSignalFailureError, + TimeoutError, WorkflowCancelFailureError, WorkflowSignalError, WorkflowSignalFailureError, }, protos::temporal::api::{ - enums::v1::ApplicationErrorCategory as ProtoApplicationErrorCategory, + enums::v1::{ + ApplicationErrorCategory as ProtoApplicationErrorCategory, + CancelExternalWorkflowExecutionFailedCause, SignalExternalWorkflowExecutionFailedCause, + }, failure::v1::{ ActivityFailureInfo, ApplicationFailureInfo, CanceledFailureInfo, ChildWorkflowExecutionFailureInfo, Failure, failure::FailureInfo, @@ -204,16 +207,60 @@ impl FailureDecodeHint for ChildWorkflowExecutionDecodeHint { /// Decode hint for workflow signal failures. #[derive(Debug, Clone, Copy, Default)] #[non_exhaustive] -pub struct WorkflowSignalDecodeHint; +pub struct WorkflowSignalDecodeHint { + cause: SignalExternalWorkflowExecutionFailedCause, +} + +impl WorkflowSignalDecodeHint { + /// Creates a decode hint with the server-reported signal failure cause. + pub fn new(cause: SignalExternalWorkflowExecutionFailedCause) -> Self { + Self { cause } + } +} impl FailureDecodeHint for WorkflowSignalDecodeHint { type Output = WorkflowSignalError; fn adapt(self, normalized: IncomingError) -> Self::Output { let failure = normalized.failure().clone(); - WorkflowSignalError::Failed(Box::new(WorkflowSignalFailureError::new( - failure, normalized, - ))) + let error = Box::new(WorkflowSignalFailureError::new(failure, normalized)); + if self.cause + == SignalExternalWorkflowExecutionFailedCause::ExternalWorkflowExecutionNotFound + { + WorkflowSignalError::NotFound(error) + } else { + WorkflowSignalError::Failed(error) + } + } +} + +/// Decode hint for external-workflow cancellation failures. +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] +pub struct CancelExternalWorkflowDecodeHint { + cause: CancelExternalWorkflowExecutionFailedCause, +} + +impl CancelExternalWorkflowDecodeHint { + /// Creates a decode hint with the server-reported cancellation failure cause. + pub fn new(cause: CancelExternalWorkflowExecutionFailedCause) -> Self { + Self { cause } + } +} + +impl FailureDecodeHint for CancelExternalWorkflowDecodeHint { + type Output = CancelExternalWorkflowError; + + fn adapt(self, normalized: IncomingError) -> Self::Output { + let failure = normalized.failure().clone(); + let error = Box::new(WorkflowCancelFailureError::new(failure, normalized)); + if self.cause + == CancelExternalWorkflowExecutionFailedCause::ExternalWorkflowExecutionNotFound + { + CancelExternalWorkflowError::NotFound(error) + } else { + CancelExternalWorkflowError::Failed(error) + } } } @@ -477,7 +524,7 @@ impl EncodeFailure for WorkflowSignalError { _: &SerializationContextData, ) -> Result { Ok(match self { - Self::Failed(failure) => failure.failure().clone(), + Self::NotFound(failure) | Self::Failed(failure) => failure.failure().clone(), Self::Serialization(err) => encode_generic_application_failure(err), }) } @@ -490,7 +537,7 @@ impl EncodeFailure for CancelExternalWorkflowError { _: &SerializationContextData, ) -> Result { Ok(match self { - Self::Failed(error) => error.failure().clone(), + Self::NotFound(error) | Self::Failed(error) => error.failure().clone(), Self::Serialization(err) => encode_generic_application_failure(err), }) } @@ -1606,6 +1653,58 @@ mod tests { assert!(decoded_failure.cause().is_none()); } + #[test] + fn workflow_signal_decode_hint_recognizes_not_found() { + let failure = Failure { + message: "workflow not found".to_owned(), + ..Default::default() + }; + let decoded = data_converter() + .to_error( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + failure.clone(), + WorkflowSignalDecodeHint::new( + SignalExternalWorkflowExecutionFailedCause::ExternalWorkflowExecutionNotFound, + ), + ) + .unwrap(); + + let WorkflowSignalError::NotFound(decoded_failure) = decoded else { + panic!("expected not-found workflow signal error"); + }; + assert_eq!(decoded_failure.failure(), &failure); + } + + #[test] + fn cancel_external_workflow_decode_hint_recognizes_not_found() { + let failure = Failure { + message: "workflow not found".to_owned(), + cause: Some(Box::new(Failure { + message: "timed out".to_owned(), + failure_info: Some(FailureInfo::TimeoutFailureInfo( + TimeoutFailureInfo::default(), + )), + ..Default::default() + })), + ..Default::default() + }; + let decoded = data_converter() + .to_error( + &SerializationContextData::Workflow(WorkflowSerializationContext::new()), + failure.clone(), + CancelExternalWorkflowDecodeHint::new( + CancelExternalWorkflowExecutionFailedCause::ExternalWorkflowExecutionNotFound, + ), + ) + .unwrap(); + + let CancelExternalWorkflowError::NotFound(decoded_failure) = decoded else { + panic!("expected not-found external-workflow cancellation error"); + }; + assert_eq!(decoded_failure.failure(), &failure); + assert!(std::error::Error::source(&*decoded_failure).is_some()); + } + #[test] fn child_workflow_signal_decode_hint_preserves_failure_proto() { let failure = Failure { @@ -1623,7 +1722,7 @@ mod tests { .to_error( &SerializationContextData::Workflow(WorkflowSerializationContext::new()), failure.clone(), - WorkflowSignalDecodeHint, + WorkflowSignalDecodeHint::default(), ) .unwrap(); diff --git a/crates/common-wasm/src/error.rs b/crates/common-wasm/src/error.rs index f067e0da9..707ce7182 100644 --- a/crates/common-wasm/src/error.rs +++ b/crates/common-wasm/src/error.rs @@ -1164,6 +1164,9 @@ impl ChildWorkflowExecutionError { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum WorkflowSignalError { + /// The target workflow was not found. + #[error("Workflow not found: {}", .0.failure().message)] + NotFound(#[source] Box), /// The signal delivery failed. #[error("Child workflow signal failed: {}", .0.failure().message)] Failed(#[source] Box), @@ -1175,9 +1178,12 @@ pub enum WorkflowSignalError { /// Error returned when requesting cancellation of an external workflow fails. #[derive(Debug, thiserror::Error)] pub enum CancelExternalWorkflowError { + /// The target workflow was not found. + #[error("Workflow not found: {}", .0.failure().message)] + NotFound(#[source] Box), /// The cancellation request failed. #[error("External workflow cancellation request failed: {}", .0.failure().message)] - Failed(#[source] Box), + Failed(#[source] Box), /// Failed to deserialize payloads attached to the cancellation failure. #[error("External workflow cancellation failure conversion failed: {0}")] Serialization(#[from] PayloadConversionError), @@ -1187,7 +1193,7 @@ impl CancelExternalWorkflowError { /// Returns the retained top-level cancellation failure proto, if one exists. pub fn failure(&self) -> Option<&Failure> { match self { - Self::Failed(err) => Some(err.failure()), + Self::NotFound(err) | Self::Failed(err) => Some(err.failure()), Self::Serialization(_) => None, } } @@ -1195,7 +1201,7 @@ impl CancelExternalWorkflowError { /// Returns the normalized cause of the cancellation failure, if any. pub fn cause(&self) -> Option<&IncomingError> { match self { - Self::Failed(err) => err.cause(), + Self::NotFound(err) | Self::Failed(err) => err.cause(), Self::Serialization(_) => None, } } @@ -1203,7 +1209,7 @@ impl CancelExternalWorkflowError { /// Returns the normalized cancellation failure itself, if one exists. pub fn reason(&self) -> Option<&IncomingError> { match self { - Self::Failed(err) => Some(err), + Self::NotFound(err) | Self::Failed(err) => Some(err.error()), Self::Serialization(_) => None, } } @@ -1218,7 +1224,9 @@ impl WorkflowSignalError { /// Returns the retained top-level workflow signal failure proto, if one exists. pub fn failure(&self) -> Option<&Failure> { match self { - WorkflowSignalError::Failed(err) => Some(err.failure()), + WorkflowSignalError::NotFound(err) | WorkflowSignalError::Failed(err) => { + Some(err.failure()) + } WorkflowSignalError::Serialization(_) => None, } } @@ -1226,7 +1234,7 @@ impl WorkflowSignalError { /// Returns the normalized cause of the workflow signal failure, if any. pub fn cause(&self) -> Option<&IncomingError> { match self { - WorkflowSignalError::Failed(err) => err.cause(), + WorkflowSignalError::NotFound(err) | WorkflowSignalError::Failed(err) => err.cause(), WorkflowSignalError::Serialization(_) => None, } } @@ -1234,7 +1242,9 @@ impl WorkflowSignalError { /// Returns the underlying failure reason for wrapper-shaped signal failures. pub fn reason(&self) -> Option<&IncomingError> { match self { - WorkflowSignalError::Failed(err) => Some(err.error()), + WorkflowSignalError::NotFound(err) | WorkflowSignalError::Failed(err) => { + Some(err.error()) + } WorkflowSignalError::Serialization(_) => None, } } @@ -1291,6 +1301,51 @@ impl std::error::Error for WorkflowSignalFailureError { } } +/// A normalized external workflow cancellation failure wrapper. +#[derive(Debug)] +pub struct WorkflowCancelFailureError { + failure: Failure, + error: Box, +} + +impl WorkflowCancelFailureError { + /// Creates an external workflow cancellation failure wrapper. + pub(crate) fn new(failure: Failure, error: IncomingError) -> Self { + Self { + failure, + error: Box::new(error), + } + } + + /// Returns the retained top-level proto failure. + pub fn failure(&self) -> &Failure { + &self.failure + } + + /// Returns the normalized direct cause of the external workflow cancellation failure, if any. + pub fn cause(&self) -> Option<&IncomingError> { + self.error.cause() + } + + /// Returns the direct decoded incoming error represented by the top-level proto failure. + pub fn error(&self) -> &IncomingError { + &self.error + } +} + +impl std::fmt::Display for WorkflowCancelFailureError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.failure.fmt(f) + } +} + +impl std::error::Error for WorkflowCancelFailureError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.cause() + .map(|cause| cause as &(dyn std::error::Error + 'static)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto index 097994ac6..7287ab1c1 100644 --- a/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto +++ b/crates/protos/protos/local/temporal/sdk/core/workflow_activation/workflow_activation.proto @@ -13,6 +13,7 @@ import "google/protobuf/empty.proto"; import "temporal/api/failure/v1/message.proto"; import "temporal/api/update/v1/message.proto"; import "temporal/api/common/v1/message.proto"; +import "temporal/api/enums/v1/failed_cause.proto"; import "temporal/api/enums/v1/workflow.proto"; import "temporal/sdk/core/activity_result/activity_result.proto"; import "temporal/sdk/core/child_workflow/child_workflow.proto"; @@ -321,15 +322,19 @@ message ResolveSignalExternalWorkflow { // If populated, this signal either failed to be sent or was cancelled depending on failure // type / info. temporal.api.failure.v1.Failure failure = 2; + // The server-reported cause when the signal failed. Unspecified when the signal succeeded or + // was cancelled before being sent. + temporal.api.enums.v1.SignalExternalWorkflowExecutionFailedCause cause = 3; } message ResolveRequestCancelExternalWorkflow { // Sequence number as provided by lang in the corresponding // RequestCancelExternalWorkflowExecution command uint32 seq = 1; - // If populated, this signal either failed to be sent or was cancelled depending on failure - // type / info. + // If populated, the cancellation request failed. temporal.api.failure.v1.Failure failure = 2; + // The server-reported cause when the cancellation request failed. + temporal.api.enums.v1.CancelExternalWorkflowExecutionFailedCause cause = 3; } // Lang is requested to invoke an update handler on the workflow. Lang should invoke the update diff --git a/crates/sdk-core/CHANGELOG.md b/crates/sdk-core/CHANGELOG.md index 23b23e67c..4a4828195 100644 --- a/crates/sdk-core/CHANGELOG.md +++ b/crates/sdk-core/CHANGELOG.md @@ -34,6 +34,8 @@ relevant information. ## Unreleased ### Added +* External workflow signal and cancellation resolution activations now include the typed server + failure cause alongside the existing failure. * Language SDKs can opt in to recording local activity arguments in the local activity marker's `input` detail. * Core console logs can now be emitted as newline-delimited JSON when an SDK selects the JSON log diff --git a/crates/sdk-core/src/worker/workflow/machines/cancel_external_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/cancel_external_state_machine.rs index 392135176..f1986cced 100644 --- a/crates/sdk-core/src/worker/workflow/machines/cancel_external_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/cancel_external_state_machine.rs @@ -180,6 +180,7 @@ impl WFMachinesAdapter for CancelExternalMachine { ResolveRequestCancelExternalWorkflow { seq: self.shared_state.seq, failure: None, + cause: CancelExternalWorkflowExecutionFailedCause::Unspecified as i32, } .into(), ] @@ -203,6 +204,7 @@ impl WFMachinesAdapter for CancelExternalMachine { )), ..Default::default() }), + cause: f as i32, } .into(), ] diff --git a/crates/sdk-core/src/worker/workflow/machines/signal_external_state_machine.rs b/crates/sdk-core/src/worker/workflow/machines/signal_external_state_machine.rs index 8b8710cd2..1a93ca2a6 100644 --- a/crates/sdk-core/src/worker/workflow/machines/signal_external_state_machine.rs +++ b/crates/sdk-core/src/worker/workflow/machines/signal_external_state_machine.rs @@ -220,6 +220,7 @@ impl WFMachinesAdapter for SignalExternalMachine { ResolveSignalExternalWorkflow { seq: self.shared_state.seq, failure: None, + cause: SignalExternalWorkflowExecutionFailedCause::Unspecified as i32, } .into(), ] @@ -248,6 +249,7 @@ impl WFMachinesAdapter for SignalExternalMachine { )), ..Default::default() }), + cause: f as i32, } .into(), ] @@ -278,6 +280,7 @@ impl SignalExternalMachine { )), ..Default::default() }), + cause: SignalExternalWorkflowExecutionFailedCause::Unspecified as i32, } .into(), ]; diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_external.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_external.rs index 324f1d425..883f94fa1 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_external.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/cancel_external.rs @@ -22,10 +22,15 @@ impl CancelSender { #[run] async fn run( ctx: &mut WorkflowContext, - (run_id, workflow_id): (String, String), + (run_id, workflow_id, expect_not_found): (String, String, bool), ) -> WorkflowResult<()> { let handle = ctx.external_workflow(workflow_id, Some(run_id)); - handle.cancel(Some("cancel-reason".into())).await.unwrap(); + let result = handle.cancel(Some("cancel-reason".into())).await; + if expect_not_found { + assert_matches!(result, Err(CancelExternalWorkflowError::NotFound(_))); + } else { + result.unwrap(); + } Ok(()) } } @@ -70,7 +75,7 @@ async fn sends_cancel_to_other_wf() { let sender_handle = worker .submit_workflow( CancelSender::run, - (receiver_run_id.to_owned(), receiver_wfid.to_owned()), + (receiver_run_id.to_owned(), receiver_wfid.to_owned(), false), WorkflowStartOptions::new(task_queue, "sends-cancel-sender").build(), ) .await @@ -95,6 +100,32 @@ async fn sends_cancel_to_other_wf() { ); } +#[tokio::test] +async fn cancel_missing_external_wf_returns_not_found() { + let wf_name = "cancel_missing_external_wf_returns_not_found"; + let mut starter = CoreWfStarter::new(wf_name); + starter + .sdk_config + .register_workflow::() + .unwrap(); + let mut worker = starter.worker().await; + + let task_queue = starter.get_task_queue().to_owned(); + let handle = worker + .submit_workflow( + CancelSender::run, + (uuid::Uuid::new_v4().to_string(), wf_name.to_owned(), true), + WorkflowStartOptions::new(task_queue, wf_name).build(), + ) + .await + .unwrap(); + worker.run_until_done().await.unwrap(); + handle + .get_result(Default::default()) + .await + .expect("workflow should observe a not-found cancellation error"); +} + #[workflow] #[derive(Default)] struct CancelSenderCanned; diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/signals.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/signals.rs index 4c65c6dd0..5f961f577 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/signals.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/signals.rs @@ -24,7 +24,7 @@ use temporalio_sdk_core::replay::{DEFAULT_WORKFLOW_TYPE, TestHistoryBuilder}; use temporalio_macros::{workflow, workflow_methods}; use temporalio_sdk::{ ApplicationFailure, CancellableFuture, ChildWorkflowOptions, SignalWorkflowOptions, - SyncWorkflowContext, WorkflowContext, WorkflowResult, + SyncWorkflowContext, WorkflowContext, WorkflowResult, WorkflowSignalError, workflow_interceptors::{ HandleSignalInput, HandleSignalResult, WorkflowInterceptor, WorkflowInterceptorConstructor, WorkflowInterceptorContext, WorkflowInterceptorFuture, WorkflowNext, @@ -56,7 +56,7 @@ impl SignalSender { ) .await; if expect_failure { - assert!(sigres.is_err()); + assert_matches!(sigres, Err(WorkflowSignalError::NotFound(_))); } else { sigres.unwrap(); } diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index b355825b8..7c6b3a033 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -6,7 +6,10 @@ use crate::{ InterceptedFuturePollGuard, InterceptedFuturePollKind, InterceptedFutureStatus, entry::{WorkflowError, WorkflowImplementation}, guest::WorkflowInstance, - model::{TimerResult, UnblockEvent, WorkflowTermination}, + model::{ + CancelExternalWfFailure, SignalExternalWfFailure, TimerResult, UnblockEvent, + WorkflowTermination, + }, types::{ ActivationJobResult, ActivationResult, MAIN_ROUTINE_ID, MainRoutineCompletion, QueryResponse, RoutineCompletion, RoutineId, RoutineKind, RoutinePendingState, @@ -759,10 +762,22 @@ where UnblockEvent::WorkflowComplete(event.seq, Box::new(expect_resolution(event.result))) } ActivationVariant::ResolveSignalExternalWorkflow(event) => { - UnblockEvent::SignalExternal(event.seq, event.failure) + let cause = event.cause(); + UnblockEvent::SignalExternal( + event.seq, + event + .failure + .map(|failure| SignalExternalWfFailure { failure, cause }), + ) } ActivationVariant::ResolveRequestCancelExternalWorkflow(event) => { - UnblockEvent::CancelExternal(event.seq, event.failure) + let cause = event.cause(); + UnblockEvent::CancelExternal( + event.seq, + event + .failure + .map(|failure| CancelExternalWfFailure { failure, cause }), + ) } ActivationVariant::ResolveNexusOperationStart(event) => { UnblockEvent::NexusOperationStart( diff --git a/crates/workflow/src/runtime/model.rs b/crates/workflow/src/runtime/model.rs index 60f14222e..55bd8d4b4 100644 --- a/crates/workflow/src/runtime/model.rs +++ b/crates/workflow/src/runtime/model.rs @@ -28,7 +28,13 @@ use temporalio_common_wasm::{ resolve_nexus_operation_start, }, }, - temporal::api::failure::v1::Failure, + temporal::api::{ + enums::v1::{ + CancelExternalWorkflowExecutionFailedCause, + SignalExternalWorkflowExecutionFailedCause, + }, + failure::v1::Failure, + }, }, }; @@ -39,8 +45,8 @@ pub(crate) enum UnblockEvent { Activity(u32, Box), WorkflowStart(u32, Box), WorkflowComplete(u32, Box), - SignalExternal(u32, Option), - CancelExternal(u32, Option), + SignalExternal(u32, Option), + CancelExternal(u32, Option), NexusOperationStart(u32, Box), NexusOperationComplete(u32, Box), } @@ -57,14 +63,24 @@ pub enum TimerResult { /// Successful result of sending a signal to an external workflow #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct SignalExternalOk; +#[derive(Debug)] +pub(crate) struct SignalExternalWfFailure { + pub(crate) failure: Failure, + pub(crate) cause: SignalExternalWorkflowExecutionFailedCause, +} /// Result of awaiting on sending a signal to an external workflow -pub(crate) type SignalExternalWfResult = Result; +pub(crate) type SignalExternalWfResult = Result; /// Distinguishes external cancellation resolutions from other command results. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct CancelExternalOk; +#[derive(Debug)] +pub(crate) struct CancelExternalWfFailure { + pub(crate) failure: Failure, + pub(crate) cause: CancelExternalWorkflowExecutionFailedCause, +} /// Internal result delivered when an external cancellation command resolves. -pub(crate) type CancelExternalWfResult = Result; +pub(crate) type CancelExternalWfResult = Result; pub(crate) trait Unblockable { type OtherDat; diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 449bdb93b..d5db8df60 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -67,14 +67,15 @@ use std::{ use temporalio_common_wasm::{ ActivityDefinition, Memo, SignalDefinition, WorkflowDefinition, data_converters::{ - ActivityExecutionDecodeHint, ChildWorkflowExecutionDecodeHint, - ChildWorkflowStartDecodeHint, DataConverter, GenericPayloadConverter, NoopDecodeHint, - PayloadConversionError, PayloadConverter, SerializationContext, SerializationContextData, - TemporalDeserializable, WorkflowSerializationContext, WorkflowSignalDecodeHint, + ActivityExecutionDecodeHint, CancelExternalWorkflowDecodeHint, + ChildWorkflowExecutionDecodeHint, ChildWorkflowStartDecodeHint, DataConverter, + GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, + SerializationContextData, TemporalDeserializable, WorkflowSerializationContext, + WorkflowSignalDecodeHint, }, error::{ - ActivityExecutionError, CancelExternalWorkflowError, ChildWorkflowExecutionError, - ChildWorkflowStartError, WorkflowSignalError, + ActivityExecutionError, ChildWorkflowExecutionError, ChildWorkflowStartError, + WorkflowSignalError, }, protos::{ coresdk::{ @@ -1459,11 +1460,14 @@ impl BaseWorkflowContext { WorkflowOutboundFuture::new(async move { match cmd.await { Ok(_) => Ok(()), - Err(failure) => { + Err(error) => { let context = SerializationContextData::Workflow(WorkflowSerializationContext::new()); - let error = data_converter.to_error(&context, failure, NoopDecodeHint)?; - Err(CancelExternalWorkflowError::Failed(Box::new(error))) + Err(data_converter.to_error( + &context, + error.failure, + CancelExternalWorkflowDecodeHint::new(error.cause), + )?) } } }) @@ -3223,10 +3227,10 @@ where } => match Pin::new(inner).poll(cx) { Poll::Pending => Poll::Pending, Poll::Ready(Ok(_)) => Poll::Ready(Ok(())), - Poll::Ready(Err(failure)) => Poll::Ready(Err(data_converter.to_error( + Poll::Ready(Err(error)) => Poll::Ready(Err(data_converter.to_error( &SerializationContextData::Workflow(WorkflowSerializationContext::new()), - failure, - WorkflowSignalDecodeHint::default(), + error.failure, + WorkflowSignalDecodeHint::new(error.cause), )?)), }, SignalChildFut::Terminated => panic!("polled after termination"), From 8cab93da185581540ca381b1c4a99cf11061ca33 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Thu, 3 Sep 2026 14:02:06 -0400 Subject: [PATCH 63/87] chore(sdk): mark pub test utilities as experimental (#1570) * chore(sdk): mark testing utils as experimental * chore(sdk): just remove raw core worker access --- crates/sdk-core/tests/common/mod.rs | 22 ++++---- .../tests/integ_tests/plugin_tests.rs | 53 +------------------ .../tests/integ_tests/worker_tests.rs | 1 + crates/sdk/src/lib.rs | 7 +-- crates/sdk/src/workflow_replayer.rs | 2 +- 5 files changed, 18 insertions(+), 67 deletions(-) diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index 93b52afb2..07e1db7a3 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -511,9 +511,10 @@ impl CoreWfStarter { let interceptor_router = TestWorkerInterceptorRouter::default(); let mut sdk_config = self.sdk_config.clone(); sdk_config.worker_interceptor(interceptor_router.clone()); - let sdk = Worker::new_from_core_options(worker, client.options().clone(), sdk_config) - .expect("SDK worker should initialize from core worker and options"); - let mut w = TestWorker::new_with_interceptor_router(sdk, interceptor_router); + let sdk = + Worker::new_from_core_options(worker.clone(), client.options().clone(), sdk_config) + .expect("SDK worker should initialize from core worker and options"); + let mut w = TestWorker::new_with_interceptor_router(sdk, worker, interceptor_router); w.client = Some(client); w @@ -690,6 +691,7 @@ impl CoreWfStarter { /// Provides conveniences for running integ tests with the SDK (against real server or mocks) pub(crate) struct TestWorker { inner: Worker, + core_worker: Arc, interceptor_router: Option, client: Option, pub started_workflows: Arc>>, @@ -699,9 +701,10 @@ pub(crate) struct TestWorker { } impl TestWorker { /// Create a new test worker - pub(crate) fn new(sdk: Worker) -> Self { + pub(crate) fn new(sdk: Worker, core_worker: Arc) -> Self { Self { inner: sdk, + core_worker, interceptor_router: None, client: None, started_workflows: Arc::new(Mutex::new(vec![])), @@ -711,11 +714,12 @@ impl TestWorker { fn new_with_interceptor_router( sdk: Worker, + core_worker: Arc, interceptor_router: TestWorkerInterceptorRouter, ) -> Self { Self { interceptor_router: Some(interceptor_router), - ..Self::new(sdk) + ..Self::new(sdk, core_worker) } } @@ -855,7 +859,7 @@ impl TestWorker { } pub(crate) fn core_worker(&self) -> Arc { - self.inner.core_worker() + self.core_worker.clone() } } @@ -1211,7 +1215,7 @@ pub(crate) fn mock_sdk_cfg_with_options( poll_cfg.using_rust_sdk = true; let mut mock = build_mock_pollers(poll_cfg); mock.worker_cfg(mutator); - let core = mock_worker(mock); + let core = Arc::new(mock_worker(mock)); let interceptor_router = TestWorkerInterceptorRouter::default(); let client_options = ClientOptions::new(core.get_config().namespace.clone()) .data_converter(DataConverter::default()) @@ -1220,9 +1224,9 @@ pub(crate) fn mock_sdk_cfg_with_options( .worker_interceptor(interceptor_router.clone()) .build(); options_mutator(&mut worker_options); - let sdk = Worker::new_from_core_options(Arc::new(core), client_options, worker_options) + let sdk = Worker::new_from_core_options(core.clone(), client_options, worker_options) .expect("mock worker options are valid"); - TestWorker::new_with_interceptor_router(sdk, interceptor_router) + TestWorker::new_with_interceptor_router(sdk, core, interceptor_router) } #[derive(Default)] diff --git a/crates/sdk-core/tests/integ_tests/plugin_tests.rs b/crates/sdk-core/tests/integ_tests/plugin_tests.rs index b3d98bd0e..850198aed 100644 --- a/crates/sdk-core/tests/integ_tests/plugin_tests.rs +++ b/crates/sdk-core/tests/integ_tests/plugin_tests.rs @@ -114,13 +114,11 @@ async fn plugins_configure_client_and_worker() { .register_workflow::() .unwrap() .build(); - let worker = Worker::new(&runtime, client, worker_options).unwrap(); + let _worker = Worker::new(&runtime, client, worker_options).unwrap(); assert_eq!(connection_calls.load(Relaxed), 1); assert_eq!(client_calls.load(Relaxed), 1); assert_eq!(worker_calls.load(Relaxed), 1); - assert_eq!(worker.core_worker().get_config().max_cached_workflows, 0); - assert_eq!(worker.core_worker().get_config().plugins.len(), 1); } struct CountingPayloadCodec { @@ -364,52 +362,3 @@ impl WorkerPlugin for FailingWorkerPlugin { Err(PluginError::new("worker failure")) } } - -struct ClientOnlyMetadataPlugin; - -impl ClientPlugin for ClientOnlyMetadataPlugin { - fn name(&self) -> &str { - "client-only-plugin" - } -} - -struct WorkerOnlyMetadataPlugin; - -impl WorkerPlugin for WorkerOnlyMetadataPlugin { - fn name(&self) -> &str { - "worker-only-plugin" - } -} - -#[tokio::test] -async fn worker_metadata_includes_client_and_worker_plugin_names() { - let runtime = new_sdk_runtime(); - let client = Client::connect( - get_integ_server_options(), - ClientOptions::new(integ_namespace()) - .client_plugin(ClientOnlyMetadataPlugin) - .build(), - ) - .await - .unwrap(); - let worker = Worker::new( - &runtime, - client, - WorkerOptions::new(format!("plugin-metadata-{}", Uuid::new_v4())) - .register_workflow::() - .unwrap() - .worker_plugin(WorkerOnlyMetadataPlugin) - .build(), - ) - .unwrap(); - let core_worker = worker.core_worker(); - let mut names = core_worker - .get_config() - .plugins - .iter() - .map(|plugin| plugin.name.clone()) - .collect::>(); - names.sort_unstable(); - - assert_eq!(names, ["client-only-plugin", "worker-only-plugin"]); -} diff --git a/crates/sdk-core/tests/integ_tests/worker_tests.rs b/crates/sdk-core/tests/integ_tests/worker_tests.rs index a93406243..e0c73b025 100644 --- a/crates/sdk-core/tests/integ_tests/worker_tests.rs +++ b/crates/sdk-core/tests/integ_tests/worker_tests.rs @@ -840,6 +840,7 @@ async fn activity_tasks_from_completion_reserve_slots() { let mut worker = crate::common::TestWorker::new( temporalio_sdk::Worker::new_from_core_options(core.clone(), client_options, worker_options) .unwrap(), + core.clone(), ); // First poll for activities twice, occupying both slots diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index d99b5bcd1..1cd21734f 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -887,6 +887,7 @@ impl Worker { } // TODO [rust-sdk-branch]: Eliminate this constructor in favor of passing in fake connection + #[cfg(feature = "experimental")] #[doc(hidden)] pub fn new_from_core(worker: Arc, data_converter: DataConverter) -> Self { let client_options = ClientOptions::new(worker.get_config().namespace.clone()) @@ -904,6 +905,7 @@ impl Worker { } // TODO [rust-sdk-branch]: Eliminate this constructor in favor of passing in fake connection + #[cfg(feature = "experimental")] #[doc(hidden)] pub fn new_from_core_options( worker: Arc, @@ -1307,11 +1309,6 @@ impl Worker { self.common.worker.worker_instance_key() } - #[doc(hidden)] - pub fn core_worker(&self) -> Arc { - self.common.worker.clone() - } - fn split_apart(&mut self) -> (&mut CommonWorker, &mut WorkflowHalf, &mut ActivityHalf) { ( &mut self.common, diff --git a/crates/sdk/src/workflow_replayer.rs b/crates/sdk/src/workflow_replayer.rs index 615737489..f75fe8647 100644 --- a/crates/sdk/src/workflow_replayer.rs +++ b/crates/sdk/src/workflow_replayer.rs @@ -461,7 +461,7 @@ impl WorkflowReplayer { ) .await { - let core_worker = worker.core_worker(); + let core_worker = worker.common.worker.clone(); core_worker.initiate_shutdown(); core_worker.shutdown().await; return Err(WorkflowReplayWorkerError::Run(source).into()); From c8551672bb5c7bf1fb211ef1caf10b2c11483bfc Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Thu, 3 Sep 2026 14:02:06 -0400 Subject: [PATCH 64/87] chore(core): remove doc hidden from runtime opts (#1572) --- crates/sdk-core/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/sdk-core/src/lib.rs b/crates/sdk-core/src/lib.rs index c825b6fca..e7608a603 100644 --- a/crates/sdk-core/src/lib.rs +++ b/crates/sdk-core/src/lib.rs @@ -194,14 +194,12 @@ pub struct RuntimeOptions { #[builder(default)] disable_environment_info: bool, /// Runtime information supplied by language SDK bridges. - #[doc(hidden)] #[builder(skip = vec![environment::native_runtime()])] runtimes: Vec, } impl RuntimeOptions { /// Supplies runtime information from a language SDK bridge. - #[doc(hidden)] pub fn with_runtimes(mut self, runtimes: Vec) -> Self { self.runtimes = runtimes; self From e7693a2c187896c5c14ee7885fde999be039e953 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 3 Sep 2026 18:01:56 -0500 Subject: [PATCH 65/87] Add workflow-scoped context storage (#1580) --- CHANGELOG.md | 6 + .../workflow_tests/interceptors.rs | 168 ++++++++- crates/sdk/Cargo.toml | 5 + crates/sdk/examples/workflow_context.rs | 83 +++++ crates/sdk/src/lib.rs | 4 +- crates/workflow/src/lib.rs | 5 +- crates/workflow/src/workflow_context.rs | 320 +++++++++++++++++- crates/workflow/src/workflow_context/view.rs | 22 +- crates/workflow/src/workflow_interceptors.rs | 47 ++- 9 files changed, 645 insertions(+), 15 deletions(-) create mode 100644 crates/sdk/examples/workflow_context.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index aa91454f4..d914e2c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,12 @@ relevant information. ### Added * `CancelExternalWorkflowError` and `workflow_interceptors::CancelExternalWorkflowResult` for use in interceptors. +* `WorkflowContextKey` and context-value scopes provide replay-safe, workflow-run-owned context + storage for application code and workflow interceptors. Values survive async suspension while + remaining isolated between concurrent branches and signal/update handlers. Read-only workflow + views can observe values established by synchronous inbound interceptors, and outbound + interceptors can use values to propagate metadata to activities, child workflows, signals, + Nexus operations, and continue-as-new runs. ### Breaking Changes * `ActivityError`, `PayloadConversionError`, `ActivityExecutionError`, `ChildWorkflowStartError`, `ChildWorkflowExecutionError`, and `WorkflowSignalError` are now diff --git a/crates/sdk-core/tests/integ_tests/workflow_tests/interceptors.rs b/crates/sdk-core/tests/integ_tests/workflow_tests/interceptors.rs index 43989a6e2..1550300f4 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/interceptors.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/interceptors.rs @@ -20,7 +20,8 @@ use temporalio_common::protos::temporal::api::{ use temporalio_macros::{workflow, workflow_methods}; use temporalio_sdk::{ ActivityOptions, ChildWorkflowOptions, LocalActivityOptions, NexusOperationOptions, - SyncWorkflowContext, TimerResult, WorkflowContext, WorkflowContextView, WorkflowResult, + SyncWorkflowContext, TimerResult, WorkflowContext, WorkflowContextKey, WorkflowContextView, + WorkflowResult, workflow_interceptors::{ CancellableWorkflowOutboundFuture, ExecuteWorkflowInput, ExecuteWorkflowResult, HandleQueryInput, HandleQueryResult, HandleSignalInput, HandleSignalResult, @@ -69,9 +70,13 @@ impl InboundInterceptorWorkflow { #[update_validator(set_update)] fn validate_set_update( &self, - _ctx: &WorkflowContextView, + ctx: &WorkflowContextView, input: &str, ) -> Result<(), Box> { + assert_eq!( + ctx.context_value::().as_deref(), + Some(&"validator") + ); assert!(input.ends_with("-validated")); if input.starts_with("reject") { Err("update rejected by validator".into()) @@ -88,7 +93,11 @@ impl InboundInterceptorWorkflow { } #[query] - fn get_status(&self, _ctx: &WorkflowContextView, input: String) -> String { + fn get_status(&self, ctx: &WorkflowContextView, input: String) -> String { + assert_eq!( + ctx.context_value::().as_deref(), + Some(&"query") + ); assert_eq!(input, "query-mutated"); "query-original-output".to_string() } @@ -190,7 +199,10 @@ impl WorkflowInterceptor for MutatingWorkflowInterceptor { *input = "query-mutated".to_string(); } - let result = next.run(input)?; + assert!(ctx.context_value::().is_none()); + let result = + ctx.with_context_value::("query", || next.run(input))?; + assert!(ctx.context_value::().is_none()); assert_eq!( result.downcast_ref::().map(String::as_str), Some("query-original-output") @@ -200,7 +212,7 @@ impl WorkflowInterceptor for MutatingWorkflowInterceptor { fn validate_update( &self, - _ctx: SyncWorkflowInterceptorContext, + ctx: SyncWorkflowInterceptorContext, mut input: ValidateUpdateInput, next: WorkflowNext<'_, ValidateUpdateInput, ValidateUpdateResult>, ) -> ValidateUpdateResult { @@ -213,7 +225,11 @@ impl WorkflowInterceptor for MutatingWorkflowInterceptor { if let Some(input) = input.input_mut::() { input.push_str("-validated"); } - next.run(input) + assert!(ctx.context_value::().is_none()); + let result = + ctx.with_context_value::("validator", || next.run(input)); + assert!(ctx.context_value::().is_none()); + result } } @@ -490,6 +506,146 @@ async fn all_handlers_finished_waits_for_handler_chain( handle.fetch_history_and_replay(&mut worker).await.unwrap(); } +struct CurrentContextLabel; + +impl WorkflowContextKey for CurrentContextLabel { + type Value = &'static str; +} + +#[workflow] +#[derive(Default)] +struct WorkflowContextPropagationWorkflow { + finish: bool, +} + +#[workflow_methods] +impl WorkflowContextPropagationWorkflow { + #[run] + async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { + let run_ctx = ctx.clone(); + ctx.with_context_value::("run", async move { + let left_ctx = run_ctx.clone(); + let left = run_ctx.with_context_value::("left", async move { + left_ctx.timer(Duration::from_millis(1)).await; + assert_eq!( + left_ctx.context_value::().as_deref(), + Some(&"left") + ); + }); + let right_ctx = run_ctx.clone(); + let right = run_ctx.with_context_value::("right", async move { + right_ctx.timer(Duration::from_millis(1)).await; + assert_eq!( + right_ctx.context_value::().as_deref(), + Some(&"right") + ); + }); + temporalio_sdk::workflows::join!(left, right); + + assert_eq!( + run_ctx.context_value::().as_deref(), + Some(&"run") + ); + run_ctx.timer(Duration::from_millis(1)).await; + run_ctx.wait_condition(|state| state.finish).await + }) + .await?; + assert!(ctx.context_value::().is_none()); + Ok(()) + } + + #[signal] + async fn finish(ctx: &mut WorkflowContext) { + let signal_ctx = ctx.clone(); + ctx.with_context_value::("signal", async move { + signal_ctx.timer(Duration::from_millis(1)).await; + assert_eq!( + signal_ctx.context_value::().as_deref(), + Some(&"signal") + ); + signal_ctx.state_mut(|state| state.finish = true); + }) + .await; + assert!(ctx.context_value::().is_none()); + } +} + +struct ObserveWorkflowContextInterceptor { + observed: Arc>>, +} + +impl WorkflowInterceptor for ObserveWorkflowContextInterceptor { + fn start_timer( + &self, + ctx: WorkflowInterceptorContext, + input: StartTimerInput, + next: WorkflowNext< + 'static, + StartTimerInput, + CancellableWorkflowOutboundFuture, + >, + ) -> CancellableWorkflowOutboundFuture { + self.observed.lock().unwrap().push( + *ctx.context_value::() + .expect("timer must have workflow context"), + ); + next.run(input) + } +} + +#[tokio::test] +async fn workflow_context_is_branch_and_handler_local_during_replay() { + let mut starter = + CoreWfStarter::new("workflow_context_is_branch_and_handler_local_during_replay"); + let observed = Arc::new(Mutex::new(Vec::new())); + let interceptor_observed = observed.clone(); + starter + .sdk_config + .register_workflow::() + .unwrap() + .register_workflow_interceptors(vec![WorkflowInterceptorConstructor::new(move |_| { + ObserveWorkflowContextInterceptor { + observed: interceptor_observed.clone(), + } + })]); + let mut worker = starter.worker().await; + + let handle = worker + .submit_workflow( + WorkflowContextPropagationWorkflow::run, + (), + WorkflowStartOptions::new( + starter.get_task_queue().to_owned(), + starter.get_wf_id().to_owned(), + ) + .build(), + ) + .await + .unwrap(); + let driver = async { + handle + .signal( + WorkflowContextPropagationWorkflow::finish, + (), + WorkflowSignalOptions::default(), + ) + .await + .unwrap(); + handle.get_result(Default::default()).await.unwrap(); + }; + let (_, worker_result) = join!(driver, worker.run_until_done()); + worker_result.unwrap(); + let mut live_observed = observed.lock().unwrap().clone(); + live_observed.sort_unstable(); + assert_eq!(live_observed, ["left", "right", "run", "signal"]); + + observed.lock().unwrap().clear(); + handle.fetch_history_and_replay(&mut worker).await.unwrap(); + let mut replay_observed = observed.lock().unwrap().clone(); + replay_observed.sort_unstable(); + assert_eq!(replay_observed, ["left", "right", "run", "signal"]); +} + #[tokio::test] async fn rejected_update_does_not_leave_a_handler_in_progress() { let mut starter = CoreWfStarter::new("rejected_update_does_not_leave_a_handler_in_progress"); diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 9dc416f93..ef74d0f2f 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -117,6 +117,11 @@ name = "activity-interceptor-starter" path = "examples/activity_interceptor/starter.rs" required-features = ["examples"] +[[example]] +name = "workflow-context" +path = "examples/workflow_context.rs" +required-features = ["examples"] + [[example]] name = "timer-examples-worker" path = "examples/timer_examples/worker.rs" diff --git a/crates/sdk/examples/workflow_context.rs b/crates/sdk/examples/workflow_context.rs new file mode 100644 index 000000000..75c2aae51 --- /dev/null +++ b/crates/sdk/examples/workflow_context.rs @@ -0,0 +1,83 @@ +//! Establish application context in workflow code and observe it in an outbound interceptor. + +use std::{sync::Arc, time::Duration}; +use temporalio_common::protos::temporal::api::common::v1::Payload; +use temporalio_macros::{activities, workflow, workflow_methods}; +use temporalio_sdk::{ + ActivityOptions, WorkflowContext, WorkflowContextKey, WorkflowResult, + activities::{ActivityContext, ActivityError}, + workflow_interceptors::{ + CancellableWorkflowOutboundFuture, ScheduleActivityInput, ScheduleActivityResult, + WorkflowInterceptor, WorkflowInterceptorContext, WorkflowNext, + }, +}; + +struct CurrentSpan; + +impl WorkflowContextKey for CurrentSpan { + type Value = String; +} + +#[workflow] +#[derive(Default)] +struct ContextWorkflow; + +#[workflow_methods] +impl ContextWorkflow { + #[run] + async fn run(ctx: &mut WorkflowContext, name: String) -> WorkflowResult { + let scoped_ctx = ctx.clone(); + ctx.with_context_value::(format!("greet-{name}"), async move { + scoped_ctx + .execute_activity( + GreetingActivities::greet, + name, + ActivityOptions::start_to_close_timeout(Duration::from_secs(10)), + ) + .await + .map_err(Into::into) + }) + .await + } +} + +struct GreetingActivities; + +#[activities] +impl GreetingActivities { + #[activity] + async fn greet(_ctx: ActivityContext, name: String) -> Result { + Ok(format!("Hello, {name}!")) + } +} + +struct ContextHeaderInterceptor; + +impl WorkflowInterceptor for ContextHeaderInterceptor { + fn schedule_activity( + &self, + ctx: WorkflowInterceptorContext, + mut input: ScheduleActivityInput, + next: WorkflowNext< + 'static, + ScheduleActivityInput, + CancellableWorkflowOutboundFuture, + >, + ) -> CancellableWorkflowOutboundFuture { + if let Some(span) = ctx.context_value::() { + input.headers_mut().insert( + "example-span".to_owned(), + Payload { + metadata: [("encoding".to_owned(), b"binary/plain".to_vec())].into(), + data: span.as_bytes().to_vec(), + ..Default::default() + }, + ); + } + next.run(input) + } +} + +fn main() { + let _interceptor: Arc = Arc::new(ContextHeaderInterceptor); +} diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 1cd21734f..846a9c141 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -105,8 +105,8 @@ pub use temporalio_workflow::{ MemoValue, ParentClosePolicy, SignalWorkflowOptions, StartChildWorkflowExecutionFailedCause, StartChildWorkflowOutput, StartedChildWorkflow, SyncWorkflowContext, TimerOptions, TimerResult, VersioningIntent, WaitConditionOptions, WorkflowCancellationError, WorkflowCancellationToken, - WorkflowContext, WorkflowContextView, WorkflowIdReusePolicy, WorkflowRandomValue, - WorkflowResult, WorkflowTermination, + WorkflowContext, WorkflowContextFuture, WorkflowContextKey, WorkflowContextView, + WorkflowIdReusePolicy, WorkflowRandomValue, WorkflowResult, WorkflowTermination, }; #[cfg(feature = "experimental")] pub use temporalio_workflow::{ diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 13317787b..4a59b5555 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -73,8 +73,9 @@ pub use workflow_context::{ ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, NamespacedWorkflowInfo, ParentClosePolicy, SignalWorkflowOptions, StartChildWorkflowExecutionFailedCause, StartChildWorkflowOutput, StartedChildWorkflow, SyncWorkflowContext, TimerOptions, - VersioningIntent, WaitConditionOptions, WorkflowContext, WorkflowContextView, - WorkflowIdReusePolicy, WorkflowRandomStream, WorkflowRandomValue, + VersioningIntent, WaitConditionOptions, WorkflowContext, WorkflowContextFuture, + WorkflowContextKey, WorkflowContextView, WorkflowIdReusePolicy, WorkflowRandomStream, + WorkflowRandomValue, }; #[cfg(feature = "experimental")] pub use workflow_context::{ diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index d5db8df60..235f1d4e0 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -50,8 +50,10 @@ use rand::SeedableRng; use rand_pcg::Pcg64Mcg; use siphasher::sip::SipHasher13; use std::{ + any::{Any, TypeId}, cell::{Cell, RefCell}, collections::{HashMap, HashSet}, + fmt, future::{self, Future}, hash::Hasher, marker::PhantomData, @@ -264,6 +266,106 @@ pub struct BaseWorkflowContext { inner: Rc, } +/// A typed key for values stored in the current workflow execution context. +/// +/// Implement this trait on a dedicated marker type shared by the workflow and its interceptors. +/// The marker type itself is the key, so different markers can store the same value type without +/// colliding. +/// +/// # Scope and propagation +/// +/// A scope inherits the values active when it is created. A nested scope shadows only its selected +/// key. Plain child futures polled inside a scope see that scope, while separately scoped +/// concurrent futures retain their own snapshots. Independently scheduled signal and update +/// handlers start without another routine's values; an inbound interceptor or the handler itself +/// can establish a handler-local scope. +/// +/// Values remain installed only while scoped workflow code is being polled. The SDK restores the +/// prior snapshot on suspension, completion, cancellation by dropping the future, and panic. This +/// prevents a value from leaking to another routine sharing the workflow's single-threaded +/// executor, or to another workflow execution. Cache eviction drops all values; replay recreates +/// them by executing the same deterministic scope calls. +/// +/// Storage is in-memory and local to one workflow run. Cross-boundary propagation is explicit: +/// outbound interceptors read values and write headers for activities, local activities, child +/// workflows, signals, Nexus operations, or continue-as-new, and inbound interceptors decode those +/// headers and establish a new scope. +/// +/// ``` +/// use temporalio_workflow::WorkflowContextKey; +/// +/// struct RequestId; +/// +/// impl WorkflowContextKey for RequestId { +/// type Value = String; +/// } +/// ``` +pub trait WorkflowContextKey: 'static { + /// Value stored under this key. + type Value: 'static; +} + +type WorkflowContextValues = Rc>>; + +#[derive(Clone, Default)] +pub(super) struct WorkflowContextValueStore { + current: Rc>, +} + +impl fmt::Debug for WorkflowContextValueStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WorkflowContextValueStore") + .finish_non_exhaustive() + } +} + +impl WorkflowContextValueStore { + pub(super) fn context_value(&self) -> Option> { + self.current + .borrow() + .get(&TypeId::of::()) + .cloned() + .and_then(|value| value.downcast().ok()) + } +} + +/// A future that installs workflow context values while polling its inner future. +/// +/// Create this with [`WorkflowContext::with_context_value`] or +/// [`WorkflowInterceptorContext::with_context_value`](crate::workflow_interceptors::WorkflowInterceptorContext::with_context_value). +/// Values survive suspension and are isolated from concurrently polled workflow futures. +#[must_use = "futures do nothing unless polled"] +pub struct WorkflowContextFuture { + base: BaseWorkflowContext, + values: WorkflowContextValues, + inner: Pin>, +} + +impl Future for WorkflowContextFuture { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + let _guard = this.base.install_context_values(this.values.clone()); + this.inner.as_mut().poll(cx) + } +} + +struct WorkflowContextRestoreGuard { + base: BaseWorkflowContext, + previous: Option, +} + +impl Drop for WorkflowContextRestoreGuard { + fn drop(&mut self) { + self.base + .inner + .context_values + .current + .replace(self.previous.take().expect("context is restored once")); + } +} + /// Input provided to a worker's patch activation callback. #[derive(Clone, Debug)] #[non_exhaustive] @@ -521,6 +623,7 @@ impl BaseWorkflowContext { self.requires_replay_safety(), Some(self.inner.random.clone()), ) + .with_context_values(self.inner.context_values.clone()) } } @@ -632,6 +735,7 @@ struct WorkflowContextInner { requires_replay_safety: Cell, condition_wakers: RefCell>, current_waker: RefCell>, + context_values: WorkflowContextValueStore, workflow_interceptors: Rc<[Arc]>, } @@ -750,6 +854,7 @@ impl BaseWorkflowContext { let random = Rc::new(RefCell::new(WorkflowRandomState::new( initialize_workflow.randomness_seed, ))); + let context_values = WorkflowContextValueStore::default(); let view = WorkflowContextView::new( namespace, task_queue, @@ -758,7 +863,8 @@ impl BaseWorkflowContext { data_converter.payload_converter().clone(), true, Some(random.clone()), - ); + ) + .with_context_values(context_values.clone()); let workflow_interceptors = workflow_interceptor_constructors .into_iter() .map(|constructor| constructor.construct(&view)) @@ -803,11 +909,52 @@ impl BaseWorkflowContext { requires_replay_safety: Cell::new(true), condition_wakers: Default::default(), current_waker: RefCell::new(None), + context_values, workflow_interceptors, }), } } + pub(crate) fn context_value(&self) -> Option> { + self.inner.context_values.context_value::() + } + + fn context_values_with(&self, value: K::Value) -> WorkflowContextValues { + let mut values = self.inner.context_values.current.borrow().as_ref().clone(); + values.insert(TypeId::of::(), Rc::new(value)); + Rc::new(values) + } + + pub(crate) fn with_context_value( + &self, + value: K::Value, + future: F, + ) -> WorkflowContextFuture { + WorkflowContextFuture { + base: self.clone(), + values: self.context_values_with::(value), + inner: Box::pin(future), + } + } + + pub(crate) fn with_context_value_sync( + &self, + value: K::Value, + f: impl FnOnce() -> R, + ) -> R { + let values = self.context_values_with::(value); + let _guard = self.install_context_values(values); + f() + } + + fn install_context_values(&self, values: WorkflowContextValues) -> WorkflowContextRestoreGuard { + let previous = self.inner.context_values.current.replace(values); + WorkflowContextRestoreGuard { + base: self.clone(), + previous: Some(previous), + } + } + pub(crate) fn workflow_interceptors(&self) -> Rc<[Arc]> { self.inner.workflow_interceptors.clone() } @@ -1484,6 +1631,26 @@ impl BaseWorkflowContext { } impl SyncWorkflowContext { + /// Return the value associated with key type `K` in the current workflow context scope. + /// + /// The returned [`Rc`] makes lookup inexpensive without requiring stored values to implement + /// [`Clone`]. Values exist only in memory for this workflow run and are rebuilt during replay. + pub fn context_value(&self) -> Option> { + self.base.context_value::() + } + + /// Run synchronous workflow code with `value` installed for key type `K`. + /// + /// Nested calls inherit other current values and shadow the same key. The previous context is + /// restored when `f` returns or unwinds. + pub fn with_context_value_sync( + &self, + value: K::Value, + f: impl FnOnce() -> R, + ) -> R { + self.base.with_context_value_sync::(value, f) + } + /// Return the workflow's unique identifier pub fn workflow_id(&self) -> &str { &self.base.inner.initial_information.workflow_id @@ -2006,6 +2173,37 @@ impl WorkflowContext { // --- Delegated methods from SyncWorkflowContext --- + /// Return the value associated with key type `K` in the current workflow context scope. + pub fn context_value(&self) -> Option> { + self.sync.context_value::() + } + + /// Poll `future` with `value` installed for key type `K`. + /// + /// The scope captures the context active when this method is called. Nested scopes inherit + /// other values and shadow the same key. Context is restored after every poll, including when + /// the future completes or panics, so concurrent workflow branches and handlers cannot observe + /// one another's scoped values. + /// + /// Context values are runtime-only. They are not recorded in history or automatically placed + /// in command headers; outbound interceptors can read them and propagate selected values. + pub fn with_context_value( + &self, + value: K::Value, + future: F, + ) -> WorkflowContextFuture { + self.sync.base.with_context_value::(value, future) + } + + /// Run synchronous workflow code with `value` installed for key type `K`. + pub fn with_context_value_sync( + &self, + value: K::Value, + f: impl FnOnce() -> R, + ) -> R { + self.sync.with_context_value_sync::(value, f) + } + /// Return the workflow's unique identifier pub fn workflow_id(&self) -> &str { self.sync.workflow_id() @@ -4922,4 +5120,124 @@ mod tests { assert_eq!(info.raw(), &expected); assert_eq!(info.into_raw(), expected); } + + #[test] + fn async_context_values_survive_suspension_and_isolate_concurrent_branches() { + struct Label; + + impl WorkflowContextKey for Label { + type Value = &'static str; + } + + let ctx = test_context(); + let first_poll = Rc::new(Cell::new(true)); + let second_poll = Rc::new(Cell::new(true)); + let first_ctx = ctx.clone(); + let first_poll_in_future = first_poll.clone(); + let first = ctx.with_context_value::( + "first", + future::poll_fn(move |_| { + assert_eq!( + first_ctx.context_value::