From c13ab88820d1c76633b317ab360b8ef140254794 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Fri, 17 Apr 2026 11:34:23 -0700 Subject: [PATCH 1/9] Split workflow crate into new crate Remove proto dupe Take more things out of workflow crate Attempting to add interfaces for WASM/Native workflow execution Move more things behind runtime interfaces Keep job sequencing logic on host Consolidate shared data Various cleanup Fix two small regressions Fix rebase issues --- Cargo.toml | 2 + crates/common-wasm/Cargo.toml | 61 + crates/common-wasm/build.rs | 180 + .../src/activity_definition.rs | 0 .../src/data_converters.rs | 0 crates/common-wasm/src/lib.rs | 34 + .../{common => common-wasm}/src/priority.rs | 0 .../src/protos/constants.rs | 0 crates/common-wasm/src/protos/mod.rs | 2978 ++++++++++++++++ .../src/protos/task_token.rs | 0 .../src/protos/utilities.rs | 0 crates/common-wasm/src/worker.rs | 61 + .../src/workflow_definition.rs | 0 crates/common/Cargo.toml | 8 +- crates/common/build.rs | 206 +- crates/common/src/lib.rs | 14 +- crates/common/src/protos/mod.rs | 3015 +---------------- crates/common/src/worker.rs | 64 +- crates/macros/src/activities_definitions.rs | 41 +- crates/macros/src/activity_definitions.rs | 211 ++ crates/macros/src/lib.rs | 12 + crates/macros/src/workflow_definitions.rs | 127 +- crates/sdk-core/Cargo.toml | 1 + crates/sdk-core/tests/common/mod.rs | 10 +- .../workflow_tests/continue_as_new.rs | 64 +- .../integ_tests/workflow_tests/resets.rs | 19 +- crates/sdk/Cargo.toml | 4 + crates/sdk/src/lib.rs | 392 +-- crates/sdk/src/workflow_executor.rs | 84 +- crates/sdk/src/workflow_future.rs | 1426 ++++---- crates/sdk/src/workflow_registry.rs | 112 + crates/sdk/src/workflows.rs | 732 +--- crates/workflow/Cargo.toml | 37 + crates/workflow/src/lib.rs | 41 + crates/workflow/src/runtime/entry.rs | 310 ++ crates/workflow/src/runtime/guest.rs | 40 + crates/workflow/src/runtime/host.rs | 50 + crates/workflow/src/runtime/instance.rs | 575 ++++ crates/workflow/src/runtime/mod.rs | 63 + crates/workflow/src/runtime/model.rs | 262 ++ crates/workflow/src/runtime/types.rs | 470 +++ .../{sdk => workflow}/src/workflow_context.rs | 674 ++-- .../src/workflow_context/options.rs | 417 +-- crates/workflow/src/workflows.rs | 141 + crates/workflow/wit/README.md | 43 + crates/workflow/wit/guest.wit | 18 + crates/workflow/wit/host.wit | 31 + crates/workflow/wit/types.wit | 480 +++ crates/workflow/wit/world.wit | 6 + 49 files changed, 7948 insertions(+), 5568 deletions(-) create mode 100644 crates/common-wasm/Cargo.toml create mode 100644 crates/common-wasm/build.rs rename crates/{common => common-wasm}/src/activity_definition.rs (100%) rename crates/{common => common-wasm}/src/data_converters.rs (100%) create mode 100644 crates/common-wasm/src/lib.rs rename crates/{common => common-wasm}/src/priority.rs (100%) rename crates/{common => common-wasm}/src/protos/constants.rs (100%) create mode 100644 crates/common-wasm/src/protos/mod.rs rename crates/{common => common-wasm}/src/protos/task_token.rs (100%) rename crates/{common => common-wasm}/src/protos/utilities.rs (100%) create mode 100644 crates/common-wasm/src/worker.rs rename crates/{common => common-wasm}/src/workflow_definition.rs (100%) create mode 100644 crates/macros/src/activity_definitions.rs create mode 100644 crates/sdk/src/workflow_registry.rs create mode 100644 crates/workflow/Cargo.toml create mode 100644 crates/workflow/src/lib.rs create mode 100644 crates/workflow/src/runtime/entry.rs create mode 100644 crates/workflow/src/runtime/guest.rs create mode 100644 crates/workflow/src/runtime/host.rs create mode 100644 crates/workflow/src/runtime/instance.rs create mode 100644 crates/workflow/src/runtime/mod.rs create mode 100644 crates/workflow/src/runtime/model.rs create mode 100644 crates/workflow/src/runtime/types.rs rename crates/{sdk => workflow}/src/workflow_context.rs (81%) rename crates/{sdk => workflow}/src/workflow_context/options.rs (58%) create mode 100644 crates/workflow/src/workflows.rs create mode 100644 crates/workflow/wit/README.md create mode 100644 crates/workflow/wit/guest.wit create mode 100644 crates/workflow/wit/host.wit create mode 100644 crates/workflow/wit/types.wit create mode 100644 crates/workflow/wit/world.wit diff --git a/Cargo.toml b/Cargo.toml index 87b79ccad..26484d534 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,10 @@ members = [ "crates/sdk-core", "crates/client", "crates/common", + "crates/common-wasm", "crates/macros", "crates/sdk", + "crates/workflow", "crates/sdk-core-c-bridge", ] resolver = "2" diff --git a/crates/common-wasm/Cargo.toml b/crates/common-wasm/Cargo.toml new file mode 100644 index 000000000..5e0944623 --- /dev/null +++ b/crates/common-wasm/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "temporalio-common-wasm" +version = "0.2.0" +edition = "2024" +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" +keywords = ["temporal", "workflow"] +categories = ["development-tools"] +exclude = ["protos/*/.github/*"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +serde_serialize = [] + +[dependencies] +anyhow = "1.0" +async-trait = "0.1" +base64 = "0.22" +bon = { workspace = true } +crc32fast = "1" +derive_more = { workspace = true } +erased-serde = "0.4" +futures = { version = "0.3", default-features = false, features = ["alloc"] } +parking_lot = { version = "0.12" } +prost = { workspace = true } +prost-wkt = "0.7" +prost-types = { workspace = true } +serde = { version = "1.0", features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tonic = { workspace = true, default-features = false, features = ["transport", "codegen"] } +tonic-prost = { workspace = true } +tracing = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = [ + "parking_lot", + "env-filter", + "registry", + "ansi", +] } +tracing-core = "0.1" +url = "2.5" +uuid = { version = "1.18", default-features = false, features = ["v4"] } +pbjson = { workspace = true } + +[build-dependencies] +prost = { workspace = true } +prost-types = "0.14" +tonic-prost-build = { workspace = true } +pbjson-build = { workspace = true } + +[lints] +workspace = true + +[dev-dependencies] +futures-util = { version = "0.3", default-features = false } +tempfile = "3.21" +tokio = { version = "1.47", features = ["macros", "rt"] } diff --git a/crates/common-wasm/build.rs b/crates/common-wasm/build.rs new file mode 100644 index 000000000..cf8ccff81 --- /dev/null +++ b/crates/common-wasm/build.rs @@ -0,0 +1,180 @@ +use std::{env, path::PathBuf}; +use tonic_prost_build::Config; + +static ALWAYS_SERDE: &str = "#[cfg_attr(not(feature = \"serde_serialize\"), \ + derive(::serde::Serialize, ::serde::Deserialize))]"; + +static SERDE_ATTR: &str = + "#[cfg_attr(feature = \"serde_serialize\", derive(::serde::Serialize, ::serde::Deserialize))]"; + +/// Package prefixes that get conditional serde derive via type_attribute. +/// Packages under `temporal.api` are listed individually to exclude the pbjson packages +/// (temporal.api.common, temporal.api.enums, temporal.api.failure), which get their +/// Serialize/Deserialize impls from generated .serde.rs files. +/// +/// If you add a new proto package, add its prefix here unless it is also +/// added to the pbjson_build list below. +const SERDE_DERIVE_PREFIXES: &[&str] = &[ + ".coresdk", + ".grpc", + ".temporal.api.activity", + ".temporal.api.batch", + ".temporal.api.cloud", + ".temporal.api.command", + ".temporal.api.deployment", + ".temporal.api.filter", + ".temporal.api.history", + ".temporal.api.namespace", + ".temporal.api.nexus", + ".temporal.api.operatorservice", + ".temporal.api.protocol", + ".temporal.api.query", + ".temporal.api.replication", + ".temporal.api.rules", + ".temporal.api.schedule", + ".temporal.api.sdk", + ".temporal.api.taskqueue", + ".temporal.api.testservice", + ".temporal.api.update", + ".temporal.api.version", + ".temporal.api.worker", + ".temporal.api.workflow", + ".temporal.api.workflowservice", +]; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=../common/protos"); + let out = PathBuf::from(env::var("OUT_DIR").unwrap()); + let descriptor_file = out.join("descriptors.bin"); + let mut builder = tonic_prost_build::configure() + // We don't actually want to build the grpc definitions - we don't need them (for now). + // Just build the message structs. + .build_server(false) + .build_client(true) + // Make conversions easier for some types + .type_attribute( + "temporal.api.history.v1.HistoryEvent.attributes", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "temporal.api.history.v1.History", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "temporal.api.command.v1.Command.attributes", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "temporal.api.common.v1.WorkflowType", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "temporal.api.common.v1.Header", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "temporal.api.common.v1.Memo", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "temporal.api.enums.v1.SignalExternalWorkflowExecutionFailedCause", + "#[derive(::derive_more::Display)]", + ) + .type_attribute( + "temporal.api.enums.v1.CancelExternalWorkflowExecutionFailedCause", + "#[derive(::derive_more::Display)]", + ) + .type_attribute( + "coresdk.workflow_commands.WorkflowCommand.variant", + "#[derive(::derive_more::From, ::derive_more::Display)]", + ) + .type_attribute( + "coresdk.workflow_commands.QueryResult.variant", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "coresdk.workflow_activation.workflow_activation_job", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "coresdk.workflow_activation.WorkflowActivationJob.variant", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "coresdk.workflow_completion.WorkflowActivationCompletion.status", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "coresdk.activity_result.ActivityExecutionResult.status", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "coresdk.activity_result.ActivityResolution.status", + "#[derive(::derive_more::From)]", + ) + .type_attribute( + "coresdk.activity_task.ActivityCancelReason", + "#[derive(::derive_more::Display)]", + ) + .type_attribute("coresdk.Task.variant", "#[derive(::derive_more::From)]") + // All external data is useful to be able to JSON serialize, so it can render in web UI + .type_attribute(".coresdk.external_data", ALWAYS_SERDE); + + for prefix in SERDE_DERIVE_PREFIXES { + builder = builder.type_attribute(*prefix, SERDE_ATTR); + } + + builder + .field_attribute( + "coresdk.external_data.LocalActivityMarkerData.complete_time", + "#[serde(with = \"opt_timestamp\")]", + ) + .field_attribute( + "coresdk.external_data.LocalActivityMarkerData.original_schedule_time", + "#[serde(with = \"opt_timestamp\")]", + ) + .field_attribute( + "coresdk.external_data.LocalActivityMarkerData.backoff", + "#[serde(with = \"opt_duration\")]", + ) + .file_descriptor_set_path(&descriptor_file) + .skip_debug(["temporal.api.common.v1.Payload"]) + .compile_with_config( + { + let mut c = Config::new(); + c.enable_type_names(); + c + }, + &[ + "../common/protos/local/temporal/sdk/core/core_interface.proto", + "../common/protos/api_upstream/temporal/api/sdk/v1/workflow_metadata.proto", + "../common/protos/api_upstream/temporal/api/workflowservice/v1/service.proto", + "../common/protos/api_upstream/temporal/api/operatorservice/v1/service.proto", + "../common/protos/api_upstream/temporal/api/errordetails/v1/message.proto", + "../common/protos/api_cloud_upstream/temporal/api/cloud/cloudservice/v1/service.proto", + "../common/protos/testsrv_upstream/temporal/api/testservice/v1/service.proto", + "../common/protos/grpc/health/v1/health.proto", + "../common/protos/google/rpc/status.proto", + ], + &[ + "../common/protos/api_upstream", + "../common/protos/api_cloud_upstream", + "../common/protos/local", + "../common/protos/testsrv_upstream", + "../common/protos/grpc", + "../common/protos", + ], + )?; + + // TODO [rust-sdk-branch]: support normal JSON and proto JSON serialization + let descriptors = std::fs::read(&descriptor_file)?; + pbjson_build::Builder::new() + .register_descriptors(&descriptors)? + .build(&[ + ".temporal.api.failure", + ".temporal.api.common", + ".temporal.api.enums", + ])?; + + Ok(()) +} diff --git a/crates/common/src/activity_definition.rs b/crates/common-wasm/src/activity_definition.rs similarity index 100% rename from crates/common/src/activity_definition.rs rename to crates/common-wasm/src/activity_definition.rs diff --git a/crates/common/src/data_converters.rs b/crates/common-wasm/src/data_converters.rs similarity index 100% rename from crates/common/src/data_converters.rs rename to crates/common-wasm/src/data_converters.rs diff --git a/crates/common-wasm/src/lib.rs b/crates/common-wasm/src/lib.rs new file mode 100644 index 000000000..9c0e64f44 --- /dev/null +++ b/crates/common-wasm/src/lib.rs @@ -0,0 +1,34 @@ +#![warn(missing_docs)] // error if there are missing docs + +//! This crate contains the shared definitions and serialization/proto surface needed by the +//! workflow authoring APIs, including WASM-targeted builds. + +#[allow(unused_imports)] // Not used by all flag combinations, which is fine. +#[macro_use] +extern crate tracing; + +mod activity_definition; +pub mod data_converters; +mod priority; +pub mod protos; +pub mod worker; +mod workflow_definition; + +pub use activity_definition::ActivityDefinition; +pub use priority::Priority; +pub use worker::WorkerDeploymentVersion; +pub use workflow_definition::{ + HasWorkflowDefinition, QueryDefinition, SignalDefinition, UntypedWorkflow, UpdateDefinition, + WorkflowDefinition, +}; + +#[allow(unused_macros)] +macro_rules! dbg_panic { + ($($arg:tt)*) => { + use tracing::error; + error!($($arg)*); + debug_assert!(false, $($arg)*); + }; +} +#[allow(unused_imports)] +pub(crate) use dbg_panic; diff --git a/crates/common/src/priority.rs b/crates/common-wasm/src/priority.rs similarity index 100% rename from crates/common/src/priority.rs rename to crates/common-wasm/src/priority.rs diff --git a/crates/common/src/protos/constants.rs b/crates/common-wasm/src/protos/constants.rs similarity index 100% rename from crates/common/src/protos/constants.rs rename to crates/common-wasm/src/protos/constants.rs diff --git a/crates/common-wasm/src/protos/mod.rs b/crates/common-wasm/src/protos/mod.rs new file mode 100644 index 000000000..ddc00a30c --- /dev/null +++ b/crates/common-wasm/src/protos/mod.rs @@ -0,0 +1,2978 @@ +//! Contains the protobuf definitions used as arguments to and return values from interactions with +//! the Temporal Core SDK. Language SDK authors can generate structs using the proto definitions +//! that will match the generated structs in this module. + +pub mod constants; +/// Utility functions for working with protobuf types. +pub mod utilities; + +mod task_token; + +use std::time::Duration; + +pub use task_token::TaskToken; + +/// Payload metadata key that identifies the encoding format. +pub static ENCODING_PAYLOAD_KEY: &str = "encoding"; +/// The metadata value for JSON-encoded payloads. +pub static JSON_ENCODING_VAL: &str = "json/plain"; +/// The details key used in patched marker payloads. +pub static PATCHED_MARKER_DETAILS_KEY: &str = "patch-data"; +/// The search attribute key used when registering change versions +pub static VERSION_SEARCH_ATTR_KEY: &str = "TemporalChangeVersion"; + +macro_rules! include_proto_with_serde { + ($pkg:tt) => { + tonic::include_proto!($pkg); + + include!(concat!(env!("OUT_DIR"), concat!("/", $pkg, ".serde.rs"))); + }; +} + +#[allow( + clippy::large_enum_variant, + clippy::derive_partial_eq_without_eq, + clippy::reserve_after_initialization +)] +// I'd prefer not to do this, but there are some generated things that just don't need it. +#[allow(missing_docs)] +pub mod coresdk { + //! Contains all protobufs relating to communication between core and lang-specific SDKs + + tonic::include_proto!("coresdk"); + + use crate::protos::{ + ENCODING_PAYLOAD_KEY, JSON_ENCODING_VAL, + temporal::api::{ + common::v1::{Payload, Payloads, RetryPolicy, WorkflowExecution}, + enums::v1::{ + ApplicationErrorCategory, TimeoutType, VersioningBehavior, WorkflowTaskFailedCause, + }, + failure::v1::{ + ActivityFailureInfo, ApplicationFailureInfo, Failure, TimeoutFailureInfo, + failure::FailureInfo, + }, + workflowservice::v1::PollActivityTaskQueueResponse, + }, + }; + use activity_task::ActivityTask; + use serde::{Deserialize, Serialize}; + use std::{ + collections::HashMap, + convert::TryFrom, + fmt::{Display, Formatter}, + iter::FromIterator, + }; + use workflow_activation::{WorkflowActivationJob, workflow_activation_job}; + use workflow_commands::{WorkflowCommand, workflow_command, workflow_command::Variant}; + use workflow_completion::{WorkflowActivationCompletion, workflow_activation_completion}; + + #[allow(clippy::module_inception)] + pub mod activity_task { + use crate::protos::{coresdk::ActivityTaskCompletion, task_token::format_task_token}; + use std::fmt::{Display, Formatter}; + tonic::include_proto!("coresdk.activity_task"); + + impl ActivityTask { + pub fn cancel_from_ids( + task_token: Vec, + reason: ActivityCancelReason, + details: ActivityCancellationDetails, + ) -> Self { + Self { + task_token, + variant: Some(activity_task::Variant::Cancel(Cancel { + reason: reason as i32, + details: Some(details), + })), + } + } + + // Checks if both the primary reason or details have a timeout cancellation. + pub fn is_timeout(&self) -> bool { + match &self.variant { + Some(activity_task::Variant::Cancel(Cancel { reason, details })) => { + *reason == ActivityCancelReason::TimedOut as i32 + || details.as_ref().is_some_and(|d| d.is_timed_out) + } + _ => false, + } + } + + pub fn primary_reason_to_cancellation_details( + reason: ActivityCancelReason, + ) -> ActivityCancellationDetails { + ActivityCancellationDetails { + is_not_found: reason == ActivityCancelReason::NotFound, + is_cancelled: reason == ActivityCancelReason::Cancelled, + is_paused: reason == ActivityCancelReason::Paused, + is_timed_out: reason == ActivityCancelReason::TimedOut, + is_worker_shutdown: reason == ActivityCancelReason::WorkerShutdown, + is_reset: reason == ActivityCancelReason::Reset, + } + } + } + + impl Display for ActivityTaskCompletion { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ActivityTaskCompletion(token: {}", + format_task_token(&self.task_token), + )?; + if let Some(r) = self.result.as_ref().and_then(|r| r.status.as_ref()) { + write!(f, ", {r}")?; + } else { + write!(f, ", missing result")?; + } + write!(f, ")") + } + } + } + #[allow(clippy::module_inception)] + pub mod activity_result { + tonic::include_proto!("coresdk.activity_result"); + use super::super::temporal::api::{ + common::v1::Payload, + failure::v1::{CanceledFailureInfo, Failure as APIFailure, failure}, + }; + use crate::protos::{ + coresdk::activity_result::activity_resolution::Status, + temporal::api::enums::v1::TimeoutType, + }; + use activity_execution_result as aer; + use anyhow::anyhow; + use std::fmt::{Display, Formatter}; + + impl ActivityExecutionResult { + pub const fn ok(result: Payload) -> Self { + Self { + status: Some(aer::Status::Completed(Success { + result: Some(result), + })), + } + } + + pub fn fail(fail: APIFailure) -> Self { + Self { + status: Some(aer::Status::Failed(Failure { + failure: Some(fail), + })), + } + } + + pub fn cancel_from_details(payload: Option) -> Self { + Self { + status: Some(aer::Status::Cancelled(Cancellation::from_details(payload))), + } + } + + pub const fn will_complete_async() -> Self { + Self { + status: Some(aer::Status::WillCompleteAsync(WillCompleteAsync {})), + } + } + + pub fn is_cancelled(&self) -> bool { + matches!(self.status, Some(aer::Status::Cancelled(_))) + } + } + + impl Display for aer::Status { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "ActivityExecutionResult(")?; + match self { + aer::Status::Completed(v) => { + write!(f, "{v})") + } + aer::Status::Failed(v) => { + write!(f, "{v})") + } + aer::Status::Cancelled(v) => { + write!(f, "{v})") + } + aer::Status::WillCompleteAsync(_) => { + write!(f, "Will complete async)") + } + } + } + } + + impl Display for Success { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Success(")?; + if let Some(ref v) = self.result { + write!(f, "{v}")?; + } + write!(f, ")") + } + } + + impl Display for Failure { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Failure(")?; + if let Some(ref v) = self.failure { + write!(f, "{v}")?; + } + write!(f, ")") + } + } + + impl Display for Cancellation { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Cancellation(")?; + if let Some(ref v) = self.failure { + write!(f, "{v}")?; + } + write!(f, ")") + } + } + + impl From> for ActivityExecutionResult { + fn from(r: Result) -> Self { + Self { + status: match r { + Ok(p) => Some(aer::Status::Completed(Success { result: Some(p) })), + Err(f) => Some(aer::Status::Failed(Failure { failure: Some(f) })), + }, + } + } + } + + impl ActivityResolution { + /// Extract an activity's payload if it completed successfully, or return an error for all + /// other outcomes. + pub fn success_payload_or_error(self) -> Result, anyhow::Error> { + let Some(status) = self.status else { + return Err(anyhow!("Activity completed without a status")); + }; + + match status { + activity_resolution::Status::Completed(success) => Ok(success.result), + e => Err(anyhow!("Activity was not successful: {e:?}")), + } + } + + pub fn unwrap_ok_payload(self) -> Payload { + self.success_payload_or_error().unwrap().unwrap() + } + + pub fn completed_ok(&self) -> bool { + matches!(self.status, Some(activity_resolution::Status::Completed(_))) + } + + pub fn failed(&self) -> bool { + matches!(self.status, Some(activity_resolution::Status::Failed(_))) + } + + pub fn timed_out(&self) -> Option { + match self.status { + Some(activity_resolution::Status::Failed(Failure { + failure: Some(ref f), + })) => f.is_timeout(), + _ => None, + } + } + + pub fn cancelled(&self) -> bool { + matches!(self.status, Some(activity_resolution::Status::Cancelled(_))) + } + + /// If this resolution is any kind of failure, return the inner failure details. Panics + /// if the activity succeeded, is in backoff, or this resolution is malformed. + pub fn unwrap_failure(self) -> APIFailure { + match self.status.unwrap() { + Status::Failed(f) => f.failure.unwrap(), + Status::Cancelled(c) => c.failure.unwrap(), + _ => panic!("Actvity did not fail"), + } + } + } + + impl Cancellation { + /// Create a cancellation result from some payload. This is to be used when telling Core + /// that an activity completed as cancelled. + pub fn from_details(details: Option) -> Self { + Cancellation { + failure: Some(APIFailure { + message: "Activity cancelled".to_string(), + failure_info: Some(failure::FailureInfo::CanceledFailureInfo( + CanceledFailureInfo { + details: details.map(Into::into), + }, + )), + ..Default::default() + }), + } + } + } + } + + pub mod common { + tonic::include_proto!("coresdk.common"); + use super::external_data::LocalActivityMarkerData; + use crate::protos::{ + PATCHED_MARKER_DETAILS_KEY, + coresdk::{ + AsJsonPayloadExt, FromJsonPayloadExt, IntoPayloadsExt, + external_data::PatchedMarkerData, + }, + temporal::api::common::v1::{Payload, Payloads}, + }; + use std::collections::HashMap; + + pub fn build_has_change_marker_details( + patch_id: impl Into, + deprecated: bool, + ) -> anyhow::Result> { + let mut hm = HashMap::new(); + let encoded = PatchedMarkerData { + id: patch_id.into(), + deprecated, + } + .as_json_payload()?; + hm.insert(PATCHED_MARKER_DETAILS_KEY.to_string(), encoded.into()); + Ok(hm) + } + + pub fn decode_change_marker_details( + details: &HashMap, + ) -> Option<(String, bool)> { + // We used to write change markers with plain bytes, so try to decode if they are + // json first, then fall back to that. + if let Some(cd) = details.get(PATCHED_MARKER_DETAILS_KEY) { + let decoded = PatchedMarkerData::from_json_payload(cd.payloads.first()?).ok()?; + return Some((decoded.id, decoded.deprecated)); + } + + let id_entry = details.get("patch_id")?.payloads.first()?; + let deprecated_entry = details.get("deprecated")?.payloads.first()?; + let name = std::str::from_utf8(&id_entry.data).ok()?; + let deprecated = *deprecated_entry.data.first()? != 0; + Some((name.to_string(), deprecated)) + } + + pub fn build_local_activity_marker_details( + metadata: LocalActivityMarkerData, + result: Option, + ) -> HashMap { + let mut hm = HashMap::new(); + // It would be more efficient for this to be proto binary, but then it shows up as + // meaningless in the Temporal UI... + if let Some(jsonified) = metadata.as_json_payload().into_payloads() { + hm.insert("data".to_string(), jsonified); + } + if let Some(res) = result { + hm.insert("result".to_string(), res.into()); + } + hm + } + + /// Given a marker detail map, returns just the local activity info, but not the payload. + /// This is fairly inexpensive. Deserializing the whole payload may not be. + pub fn extract_local_activity_marker_data( + details: &HashMap, + ) -> Option { + details + .get("data") + .and_then(|p| p.payloads.first()) + .and_then(|p| std::str::from_utf8(&p.data).ok()) + .and_then(|s| serde_json::from_str(s).ok()) + } + + /// Given a marker detail map, returns the local activity info and the result payload + /// if they are found and the marker data is well-formed. This removes the data from the + /// map. + pub fn extract_local_activity_marker_details( + details: &mut HashMap, + ) -> (Option, Option) { + let data = extract_local_activity_marker_data(details); + let result = details.remove("result").and_then(|mut p| p.payloads.pop()); + (data, result) + } + } + + pub mod external_data { + use prost_types::{Duration, Timestamp}; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + tonic::include_proto!("coresdk.external_data"); + + // Buncha hullaballoo because prost types aren't serde compat. + // See https://github.com/tokio-rs/prost/issues/75 which hilariously Chad opened ages ago + + #[derive(Serialize, Deserialize)] + #[serde(remote = "Timestamp")] + struct TimestampDef { + seconds: i64, + nanos: i32, + } + mod opt_timestamp { + use super::*; + + pub(super) fn serialize( + value: &Option, + serializer: S, + ) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + struct Helper<'a>(#[serde(with = "TimestampDef")] &'a Timestamp); + + value.as_ref().map(Helper).serialize(serializer) + } + + pub(super) fn deserialize<'de, D>( + deserializer: D, + ) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper(#[serde(with = "TimestampDef")] Timestamp); + + let helper = Option::deserialize(deserializer)?; + Ok(helper.map(|Helper(external)| external)) + } + } + + // Luckily Duration is also stored the exact same way + #[derive(Serialize, Deserialize)] + #[serde(remote = "Duration")] + struct DurationDef { + seconds: i64, + nanos: i32, + } + mod opt_duration { + use super::*; + + pub(super) fn serialize( + value: &Option, + serializer: S, + ) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + struct Helper<'a>(#[serde(with = "DurationDef")] &'a Duration); + + value.as_ref().map(Helper).serialize(serializer) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper(#[serde(with = "DurationDef")] Duration); + + let helper = Option::deserialize(deserializer)?; + Ok(helper.map(|Helper(external)| external)) + } + } + } + + pub mod workflow_activation { + use crate::protos::{ + coresdk::{ + FromPayloadsExt, + activity_result::{ActivityResolution, activity_resolution}, + common::NamespacedWorkflowExecution, + fix_retry_policy, + workflow_activation::remove_from_cache::EvictionReason, + }, + temporal::api::{ + enums::v1::WorkflowTaskFailedCause, + history::v1::{ + WorkflowExecutionCancelRequestedEventAttributes, + WorkflowExecutionSignaledEventAttributes, + WorkflowExecutionStartedEventAttributes, + }, + query::v1::WorkflowQuery, + }, + }; + use prost_types::Timestamp; + use std::fmt::{Display, Formatter}; + + tonic::include_proto!("coresdk.workflow_activation"); + + pub fn create_evict_activation( + run_id: String, + message: String, + reason: EvictionReason, + ) -> WorkflowActivation { + WorkflowActivation { + timestamp: None, + run_id, + is_replaying: false, + history_length: 0, + jobs: vec![WorkflowActivationJob::from( + workflow_activation_job::Variant::RemoveFromCache(RemoveFromCache { + message, + reason: reason as i32, + }), + )], + available_internal_flags: vec![], + history_size_bytes: 0, + continue_as_new_suggested: false, + deployment_version_for_current_task: None, + last_sdk_version: String::new(), + suggest_continue_as_new_reasons: vec![], + target_worker_deployment_version_changed: false, + } + } + + pub fn query_to_job(id: String, q: WorkflowQuery) -> QueryWorkflow { + QueryWorkflow { + query_id: id, + query_type: q.query_type, + arguments: Vec::from_payloads(q.query_args), + headers: q.header.map(|h| h.into()).unwrap_or_default(), + } + } + + impl WorkflowActivation { + /// Returns true if the only job in the activation is eviction + pub fn is_only_eviction(&self) -> bool { + matches!( + self.jobs.as_slice(), + [WorkflowActivationJob { + variant: Some(workflow_activation_job::Variant::RemoveFromCache(_)) + }] + ) + } + + /// Returns eviction reason if this activation is an eviction + pub fn eviction_reason(&self) -> Option { + self.jobs.iter().find_map(|j| { + if let Some(workflow_activation_job::Variant::RemoveFromCache(ref rj)) = + j.variant + { + EvictionReason::try_from(rj.reason).ok() + } else { + None + } + }) + } + } + + impl workflow_activation_job::Variant { + pub fn is_local_activity_resolution(&self) -> bool { + matches!(self, workflow_activation_job::Variant::ResolveActivity(ra) if ra.is_local) + } + } + + impl Display for EvictionReason { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } + } + + impl From for WorkflowTaskFailedCause { + fn from(value: EvictionReason) -> Self { + match value { + EvictionReason::Nondeterminism => { + WorkflowTaskFailedCause::NonDeterministicError + } + _ => WorkflowTaskFailedCause::Unspecified, + } + } + } + + impl Display for WorkflowActivation { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "WorkflowActivation(")?; + write!(f, "run_id: {}, ", self.run_id)?; + write!(f, "is_replaying: {}, ", self.is_replaying)?; + write!( + f, + "jobs: {})", + self.jobs + .iter() + .map(ToString::to_string) + .collect::>() + .as_slice() + .join(", ") + ) + } + } + + impl Display for WorkflowActivationJob { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match &self.variant { + None => write!(f, "empty"), + Some(v) => write!(f, "{v}"), + } + } + } + + impl Display for workflow_activation_job::Variant { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + workflow_activation_job::Variant::InitializeWorkflow(_) => { + write!(f, "InitializeWorkflow") + } + workflow_activation_job::Variant::FireTimer(t) => { + write!(f, "FireTimer({})", t.seq) + } + workflow_activation_job::Variant::UpdateRandomSeed(_) => { + write!(f, "UpdateRandomSeed") + } + workflow_activation_job::Variant::QueryWorkflow(_) => { + write!(f, "QueryWorkflow") + } + workflow_activation_job::Variant::CancelWorkflow(_) => { + write!(f, "CancelWorkflow") + } + workflow_activation_job::Variant::SignalWorkflow(_) => { + write!(f, "SignalWorkflow") + } + workflow_activation_job::Variant::ResolveActivity(r) => { + write!( + f, + "ResolveActivity({}, {})", + r.seq, + r.result + .as_ref() + .unwrap_or(&ActivityResolution { status: None }) + ) + } + workflow_activation_job::Variant::NotifyHasPatch(_) => { + write!(f, "NotifyHasPatch") + } + workflow_activation_job::Variant::ResolveChildWorkflowExecutionStart(_) => { + write!(f, "ResolveChildWorkflowExecutionStart") + } + workflow_activation_job::Variant::ResolveChildWorkflowExecution(_) => { + write!(f, "ResolveChildWorkflowExecution") + } + workflow_activation_job::Variant::ResolveSignalExternalWorkflow(_) => { + write!(f, "ResolveSignalExternalWorkflow") + } + workflow_activation_job::Variant::RemoveFromCache(_) => { + write!(f, "RemoveFromCache") + } + workflow_activation_job::Variant::ResolveRequestCancelExternalWorkflow(_) => { + write!(f, "ResolveRequestCancelExternalWorkflow") + } + workflow_activation_job::Variant::DoUpdate(u) => { + write!(f, "DoUpdate({})", u.id) + } + workflow_activation_job::Variant::ResolveNexusOperationStart(_) => { + write!(f, "ResolveNexusOperationStart") + } + workflow_activation_job::Variant::ResolveNexusOperation(_) => { + write!(f, "ResolveNexusOperation") + } + } + } + } + + impl Display for ActivityResolution { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self.status { + None => { + write!(f, "None") + } + Some(activity_resolution::Status::Failed(_)) => { + write!(f, "Failed") + } + Some(activity_resolution::Status::Completed(_)) => { + write!(f, "Completed") + } + Some(activity_resolution::Status::Cancelled(_)) => { + write!(f, "Cancelled") + } + Some(activity_resolution::Status::Backoff(_)) => { + write!(f, "Backoff") + } + } + } + } + + impl Display for QueryWorkflow { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "QueryWorkflow(id: {}, type: {})", + self.query_id, self.query_type + ) + } + } + + impl From for SignalWorkflow { + fn from(a: WorkflowExecutionSignaledEventAttributes) -> 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(), + } + } + } + + impl From for CancelWorkflow { + fn from(a: WorkflowExecutionCancelRequestedEventAttributes) -> Self { + Self { reason: a.cause } + } + } + + /// Create a [InitializeWorkflow] job from corresponding event attributes + pub fn start_workflow_from_attribs( + attrs: WorkflowExecutionStartedEventAttributes, + workflow_id: String, + randomness_seed: u64, + start_time: Timestamp, + ) -> InitializeWorkflow { + InitializeWorkflow { + workflow_type: attrs.workflow_type.map(|wt| wt.name).unwrap_or_default(), + workflow_id, + arguments: Vec::from_payloads(attrs.input), + randomness_seed, + headers: attrs.header.unwrap_or_default().fields, + identity: attrs.identity, + parent_workflow_info: attrs.parent_workflow_execution.map(|pe| { + NamespacedWorkflowExecution { + namespace: attrs.parent_workflow_namespace, + run_id: pe.run_id, + workflow_id: pe.workflow_id, + } + }), + workflow_execution_timeout: attrs.workflow_execution_timeout, + workflow_run_timeout: attrs.workflow_run_timeout, + workflow_task_timeout: attrs.workflow_task_timeout, + continued_from_execution_run_id: attrs.continued_execution_run_id, + continued_initiator: attrs.initiator, + continued_failure: attrs.continued_failure, + last_completion_result: attrs.last_completion_result, + first_execution_run_id: attrs.first_execution_run_id, + retry_policy: attrs.retry_policy.map(fix_retry_policy), + attempt: attrs.attempt, + cron_schedule: attrs.cron_schedule, + workflow_execution_expiration_time: attrs.workflow_execution_expiration_time, + cron_schedule_to_schedule_interval: attrs.first_workflow_task_backoff, + memo: attrs.memo, + search_attributes: attrs.search_attributes, + start_time: Some(start_time), + root_workflow: attrs.root_workflow_execution, + priority: attrs.priority, + } + } + } + + pub mod workflow_completion { + use crate::protos::temporal::api::{enums::v1::WorkflowTaskFailedCause, failure}; + tonic::include_proto!("coresdk.workflow_completion"); + + impl workflow_activation_completion::Status { + pub const fn is_success(&self) -> bool { + match &self { + Self::Successful(_) => true, + Self::Failed(_) => false, + } + } + } + + impl From for Failure { + fn from(f: failure::v1::Failure) -> Self { + Failure { + failure: Some(f), + force_cause: WorkflowTaskFailedCause::Unspecified as i32, + } + } + } + } + + pub mod child_workflow { + tonic::include_proto!("coresdk.child_workflow"); + } + + pub mod nexus { + use crate::protos::temporal::api::workflowservice::v1::PollNexusTaskQueueResponse; + use std::fmt::{Display, Formatter}; + + tonic::include_proto!("coresdk.nexus"); + + impl NexusTask { + /// Unwrap the inner server-delivered nexus task if that's what this is, else panic. + pub fn unwrap_task(self) -> PollNexusTaskQueueResponse { + if let Some(nexus_task::Variant::Task(t)) = self.variant { + return t; + } + panic!("Nexus task did not contain a server task"); + } + + /// Get the task token + pub fn task_token(&self) -> &[u8] { + match &self.variant { + Some(nexus_task::Variant::Task(t)) => t.task_token.as_slice(), + Some(nexus_task::Variant::CancelTask(c)) => c.task_token.as_slice(), + None => panic!("Nexus task did not contain a task token"), + } + } + } + + impl Display for nexus_task_completion::Status { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "NexusTaskCompletion(")?; + match self { + nexus_task_completion::Status::Completed(c) => { + write!(f, "{c}") + } + nexus_task_completion::Status::AckCancel(_) => { + write!(f, "AckCancel") + } + #[allow(deprecated)] + nexus_task_completion::Status::Error(error) => { + write!(f, "Error({error:?})") + } + nexus_task_completion::Status::Failure(failure) => { + write!(f, "{failure}") + } + }?; + write!(f, ")") + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum NexusOperationErrorState { + Failed, + Canceled, + } + + impl Display for NexusOperationErrorState { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Failed => write!(f, "failed"), + Self::Canceled => write!(f, "canceled"), + } + } + } + } + + pub mod workflow_commands { + tonic::include_proto!("coresdk.workflow_commands"); + + use crate::protos::temporal::api::{common::v1::Payloads, enums::v1::QueryResultType}; + use std::fmt::{Display, Formatter}; + + impl Display for WorkflowCommand { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match &self.variant { + None => write!(f, "Empty"), + Some(v) => write!(f, "{v}"), + } + } + } + + impl Display for StartTimer { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "StartTimer({})", self.seq) + } + } + + impl Display for ScheduleActivity { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "ScheduleActivity({}, {})", self.seq, self.activity_type) + } + } + + impl Display for ScheduleLocalActivity { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ScheduleLocalActivity({}, {})", + self.seq, self.activity_type + ) + } + } + + impl Display for QueryResult { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "RespondToQuery({})", self.query_id) + } + } + + impl Display for RequestCancelActivity { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "RequestCancelActivity({})", self.seq) + } + } + + impl Display for RequestCancelLocalActivity { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "RequestCancelLocalActivity({})", self.seq) + } + } + + impl Display for CancelTimer { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "CancelTimer({})", self.seq) + } + } + + impl Display for CompleteWorkflowExecution { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "CompleteWorkflowExecution") + } + } + + impl Display for FailWorkflowExecution { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "FailWorkflowExecution") + } + } + + impl Display for ContinueAsNewWorkflowExecution { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "ContinueAsNewWorkflowExecution") + } + } + + impl Display for CancelWorkflowExecution { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "CancelWorkflowExecution") + } + } + + impl Display for SetPatchMarker { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "SetPatchMarker({})", self.patch_id) + } + } + + impl Display for StartChildWorkflowExecution { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "StartChildWorkflowExecution({}, {})", + self.seq, self.workflow_type + ) + } + } + + impl Display for RequestCancelExternalWorkflowExecution { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "RequestCancelExternalWorkflowExecution({})", self.seq) + } + } + + impl Display for UpsertWorkflowSearchAttributes { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let keys: Vec<_> = self + .search_attributes + .as_ref() + .map(|sa| sa.indexed_fields.keys().collect()) + .unwrap_or_default(); + write!(f, "UpsertWorkflowSearchAttributes({:?})", keys) + } + } + + impl Display for SignalExternalWorkflowExecution { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "SignalExternalWorkflowExecution({})", self.seq) + } + } + + impl Display for CancelSignalWorkflow { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "CancelSignalWorkflow({})", self.seq) + } + } + + impl Display for CancelChildWorkflowExecution { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "CancelChildWorkflowExecution({})", + self.child_workflow_seq + ) + } + } + + impl Display for ModifyWorkflowProperties { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ModifyWorkflowProperties(upserted memo keys: {:?})", + self.upserted_memo.as_ref().map(|m| m.fields.keys()) + ) + } + } + + impl Display for UpdateResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "UpdateResponse(protocol_instance_id: {}, response: {:?})", + self.protocol_instance_id, self.response + ) + } + } + + impl Display for ScheduleNexusOperation { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "ScheduleNexusOperation({})", self.seq) + } + } + + impl Display for RequestCancelNexusOperation { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "RequestCancelNexusOperation({})", self.seq) + } + } + + impl QueryResult { + /// Helper to construct the Temporal API query result types. + pub fn into_components(self) -> (String, QueryResultType, Option, String) { + match self { + QueryResult { + variant: Some(query_result::Variant::Succeeded(qs)), + query_id, + } => ( + query_id, + QueryResultType::Answered, + qs.response.map(Into::into), + "".to_string(), + ), + QueryResult { + variant: Some(query_result::Variant::Failed(err)), + query_id, + } => (query_id, QueryResultType::Failed, None, err.message), + QueryResult { + variant: None, + query_id, + } => ( + query_id, + QueryResultType::Failed, + None, + "Query response was empty".to_string(), + ), + } + } + } + } + + pub type HistoryEventId = i64; + + impl From for WorkflowActivationJob { + fn from(a: workflow_activation_job::Variant) -> Self { + Self { variant: Some(a) } + } + } + + impl From> for workflow_completion::Success { + fn from(v: Vec) -> Self { + Self { + commands: v, + used_internal_flags: vec![], + versioning_behavior: VersioningBehavior::Unspecified.into(), + } + } + } + + impl From for WorkflowCommand { + fn from(v: workflow_command::Variant) -> Self { + Self { + variant: Some(v), + user_metadata: None, + } + } + } + + impl workflow_completion::Success { + pub fn from_variants(cmds: Vec) -> Self { + let cmds: Vec<_> = cmds.into_iter().map(|c| c.into()).collect(); + cmds.into() + } + } + + impl WorkflowActivationCompletion { + /// Create a successful activation with no commands in it + pub fn empty(run_id: impl Into) -> Self { + let success = workflow_completion::Success::from_variants(vec![]); + Self { + run_id: run_id.into(), + status: Some(workflow_activation_completion::Status::Successful(success)), + } + } + + /// Create a successful activation from a list of command variants + pub fn from_cmds(run_id: impl Into, cmds: Vec) -> Self { + let success = workflow_completion::Success::from_variants(cmds); + Self { + run_id: run_id.into(), + status: Some(workflow_activation_completion::Status::Successful(success)), + } + } + + /// Create a successful activation from just one command variant + pub fn from_cmd(run_id: impl Into, cmd: workflow_command::Variant) -> Self { + let success = workflow_completion::Success::from_variants(vec![cmd]); + Self { + run_id: run_id.into(), + status: Some(workflow_activation_completion::Status::Successful(success)), + } + } + + pub fn fail( + run_id: impl Into, + failure: Failure, + cause: Option, + ) -> Self { + Self { + run_id: run_id.into(), + status: Some(workflow_activation_completion::Status::Failed( + workflow_completion::Failure { + failure: Some(failure), + force_cause: cause.unwrap_or(WorkflowTaskFailedCause::Unspecified) as i32, + }, + )), + } + } + + /// Returns true if the activation has either a fail, continue, cancel, or complete workflow + /// execution command in it. + pub fn has_execution_ending(&self) -> bool { + self.has_complete_workflow_execution() + || self.has_fail_execution() + || self.has_continue_as_new() + || self.has_cancel_workflow_execution() + } + + /// Returns true if the activation contains a fail workflow execution command + pub fn has_fail_execution(&self) -> bool { + if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { + return s.commands.iter().any(|wfc| { + matches!( + wfc, + WorkflowCommand { + variant: Some(workflow_command::Variant::FailWorkflowExecution(_)), + .. + } + ) + }); + } + false + } + + /// Returns true if the activation contains a cancel workflow execution command + pub fn has_cancel_workflow_execution(&self) -> bool { + if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { + return s.commands.iter().any(|wfc| { + matches!( + wfc, + WorkflowCommand { + variant: Some(workflow_command::Variant::CancelWorkflowExecution(_)), + .. + } + ) + }); + } + false + } + + /// Returns true if the activation contains a continue as new workflow execution command + pub fn has_continue_as_new(&self) -> bool { + if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { + return s.commands.iter().any(|wfc| { + matches!( + wfc, + WorkflowCommand { + variant: Some( + workflow_command::Variant::ContinueAsNewWorkflowExecution(_) + ), + .. + } + ) + }); + } + false + } + + /// Returns true if the activation contains a complete workflow execution command + pub fn has_complete_workflow_execution(&self) -> bool { + self.complete_workflow_execution_value().is_some() + } + + /// Returns the completed execution result value, if any + pub fn complete_workflow_execution_value(&self) -> Option<&Payload> { + if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { + s.commands.iter().find_map(|wfc| match wfc { + WorkflowCommand { + variant: Some(workflow_command::Variant::CompleteWorkflowExecution(v)), + .. + } => v.result.as_ref(), + _ => None, + }) + } else { + None + } + } + + /// Returns true if the activation completion is a success with no commands + pub fn is_empty(&self) -> bool { + if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { + return s.commands.is_empty(); + } + false + } + + pub fn add_internal_flags(&mut self, patch: u32) { + if let Some(workflow_activation_completion::Status::Successful(s)) = &mut self.status { + s.used_internal_flags.push(patch); + } + } + } + + /// Makes converting outgoing lang commands into [WorkflowActivationCompletion]s easier + pub trait IntoCompletion { + /// The conversion function + fn into_completion(self, run_id: String) -> WorkflowActivationCompletion; + } + + impl IntoCompletion for workflow_command::Variant { + fn into_completion(self, run_id: String) -> WorkflowActivationCompletion { + WorkflowActivationCompletion::from_cmd(run_id, self) + } + } + + impl IntoCompletion for I + where + I: IntoIterator, + V: Into, + { + fn into_completion(self, run_id: String) -> WorkflowActivationCompletion { + let success = self.into_iter().map(Into::into).collect::>().into(); + WorkflowActivationCompletion { + run_id, + status: Some(workflow_activation_completion::Status::Successful(success)), + } + } + } + + impl Display for WorkflowActivationCompletion { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "WorkflowActivationCompletion(run_id: {}, status: ", + &self.run_id + )?; + match &self.status { + None => write!(f, "empty")?, + Some(s) => write!(f, "{s}")?, + }; + write!(f, ")") + } + } + + impl Display for workflow_activation_completion::Status { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + workflow_activation_completion::Status::Successful( + workflow_completion::Success { commands, .. }, + ) => { + write!(f, "Success(")?; + let mut written = 0; + for c in commands { + write!(f, "{c} ")?; + written += 1; + if written >= 10 && written < commands.len() { + write!(f, "... {} more", commands.len() - written)?; + break; + } + } + write!(f, ")") + } + workflow_activation_completion::Status::Failed(_) => { + write!(f, "Failed") + } + } + } + } + + impl ActivityTask { + pub fn start_from_poll_resp(r: PollActivityTaskQueueResponse) -> Self { + let (workflow_id, run_id) = r + .workflow_execution + .map(|we| (we.workflow_id, we.run_id)) + .unwrap_or_default(); + Self { + task_token: r.task_token, + variant: Some(activity_task::activity_task::Variant::Start( + activity_task::Start { + workflow_namespace: r.workflow_namespace, + workflow_type: r.workflow_type.map_or_else(|| "".to_string(), |wt| wt.name), + workflow_execution: Some(WorkflowExecution { + workflow_id, + run_id, + }), + activity_id: r.activity_id, + activity_type: r.activity_type.map_or_else(|| "".to_string(), |at| at.name), + header_fields: r.header.map(Into::into).unwrap_or_default(), + input: Vec::from_payloads(r.input), + heartbeat_details: Vec::from_payloads(r.heartbeat_details), + scheduled_time: r.scheduled_time, + current_attempt_scheduled_time: r.current_attempt_scheduled_time, + started_time: r.started_time, + attempt: r.attempt as u32, + schedule_to_close_timeout: r.schedule_to_close_timeout, + start_to_close_timeout: r.start_to_close_timeout, + heartbeat_timeout: r.heartbeat_timeout, + retry_policy: r.retry_policy.map(fix_retry_policy), + priority: r.priority, + is_local: false, + run_id: r.activity_run_id, + }, + )), + } + } + } + + impl Failure { + pub fn is_timeout(&self) -> Option { + match &self.failure_info { + Some(FailureInfo::TimeoutFailureInfo(ti)) => Some(ti.timeout_type()), + _ => { + if let Some(c) = &self.cause { + c.is_timeout() + } else { + None + } + } + } + } + + pub fn application_failure(message: String, non_retryable: bool) -> Self { + Self { + message, + failure_info: Some(FailureInfo::ApplicationFailureInfo( + ApplicationFailureInfo { + non_retryable, + ..Default::default() + }, + )), + ..Default::default() + } + } + + pub fn application_failure_from_error(ae: anyhow::Error, non_retryable: bool) -> Self { + Self { + failure_info: Some(FailureInfo::ApplicationFailureInfo( + ApplicationFailureInfo { + non_retryable, + ..Default::default() + }, + )), + ..ae.chain() + .rfold(None, |cause, e| { + Some(Self { + message: e.to_string(), + cause: cause.map(Box::new), + ..Default::default() + }) + }) + .unwrap_or_default() + } + } + + pub fn timeout(timeout_type: TimeoutType) -> Self { + Self { + message: "Activity timed out".to_string(), + cause: Some(Box::new(Failure { + message: "Activity timed out".to_string(), + failure_info: Some(FailureInfo::TimeoutFailureInfo(TimeoutFailureInfo { + timeout_type: timeout_type.into(), + ..Default::default() + })), + ..Default::default() + })), + failure_info: Some(FailureInfo::ActivityFailureInfo( + ActivityFailureInfo::default(), + )), + ..Default::default() + } + } + + /// Extracts an ApplicationFailureInfo from a Failure instance if it exists + pub fn maybe_application_failure(&self) -> Option<&ApplicationFailureInfo> { + if let Failure { + failure_info: Some(FailureInfo::ApplicationFailureInfo(f)), + .. + } = self + { + Some(f) + } else { + None + } + } + + // Checks if a failure is an ApplicationFailure with Benign category. + pub fn is_benign_application_failure(&self) -> bool { + self.maybe_application_failure() + .is_some_and(|app_info| app_info.category() == ApplicationErrorCategory::Benign) + } + } + + impl Display for Failure { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Failure({}, ", self.message)?; + match self.failure_info.as_ref() { + None => write!(f, "missing info")?, + Some(FailureInfo::TimeoutFailureInfo(v)) => { + write!(f, "Timeout: {:?}", v.timeout_type())?; + } + Some(FailureInfo::ApplicationFailureInfo(v)) => { + write!(f, "Application Failure: {}", v.r#type)?; + } + Some(FailureInfo::CanceledFailureInfo(_)) => { + write!(f, "Cancelled")?; + } + Some(FailureInfo::TerminatedFailureInfo(_)) => { + write!(f, "Terminated")?; + } + Some(FailureInfo::ServerFailureInfo(_)) => { + write!(f, "Server Failure")?; + } + Some(FailureInfo::ResetWorkflowFailureInfo(_)) => { + write!(f, "Reset Workflow")?; + } + Some(FailureInfo::ActivityFailureInfo(v)) => { + write!( + f, + "Activity Failure: scheduled_event_id: {}", + v.scheduled_event_id + )?; + } + Some(FailureInfo::ChildWorkflowExecutionFailureInfo(v)) => { + write!( + f, + "Child Workflow: started_event_id: {}", + v.started_event_id + )?; + } + Some(FailureInfo::NexusOperationExecutionFailureInfo(v)) => { + write!( + f, + "Nexus Operation Failure: scheduled_event_id: {}", + v.scheduled_event_id + )?; + } + Some(FailureInfo::NexusHandlerFailureInfo(v)) => { + write!(f, "Nexus Handler Failure: {}", v.r#type)?; + } + } + write!(f, ")") + } + } + + impl From<&str> for Failure { + fn from(v: &str) -> Self { + Failure::application_failure(v.to_string(), false) + } + } + + impl From for Failure { + fn from(v: String) -> Self { + Failure::application_failure(v, false) + } + } + + impl From for Failure { + fn from(ae: anyhow::Error) -> Self { + Failure::application_failure_from_error(ae, false) + } + } + + pub trait FromPayloadsExt { + fn from_payloads(p: Option) -> Self; + } + impl FromPayloadsExt for T + where + T: FromIterator, + { + fn from_payloads(p: Option) -> Self { + match p { + None => std::iter::empty().collect(), + Some(p) => p.payloads.into_iter().collect(), + } + } + } + + pub trait IntoPayloadsExt { + fn into_payloads(self) -> Option; + } + impl IntoPayloadsExt for T + where + T: IntoIterator, + { + fn into_payloads(self) -> Option { + let mut iterd = self.into_iter().peekable(); + if iterd.peek().is_none() { + None + } else { + Some(Payloads { + payloads: iterd.collect(), + }) + } + } + } + + impl From for Payloads { + fn from(p: Payload) -> Self { + Self { payloads: vec![p] } + } + } + + impl From for Payloads + where + T: AsRef<[u8]>, + { + fn from(v: T) -> Self { + Self { + payloads: vec![v.into()], + } + } + } + + #[derive(thiserror::Error, Debug)] + pub enum PayloadDeserializeErr { + /// This deserializer does not handle this type of payload. Allows composing multiple + /// deserializers. + #[error("This deserializer does not understand this payload")] + DeserializerDoesNotHandle, + #[error("Error during deserialization: {0}")] + DeserializeErr(#[from] anyhow::Error), + } + + // TODO: Once the prototype SDK is un-prototyped this serialization will need to be compat with + // other SDKs (given they might execute an activity). + pub trait AsJsonPayloadExt { + fn as_json_payload(&self) -> anyhow::Result; + } + impl AsJsonPayloadExt for T + where + T: Serialize, + { + fn as_json_payload(&self) -> anyhow::Result { + let as_json = serde_json::to_string(self)?; + let mut metadata = HashMap::new(); + metadata.insert( + ENCODING_PAYLOAD_KEY.to_string(), + JSON_ENCODING_VAL.as_bytes().to_vec(), + ); + Ok(Payload { + metadata, + data: as_json.into_bytes(), + external_payloads: Default::default(), + }) + } + } + + pub trait FromJsonPayloadExt: Sized { + fn from_json_payload(payload: &Payload) -> Result; + } + impl FromJsonPayloadExt for T + where + T: for<'de> Deserialize<'de>, + { + fn from_json_payload(payload: &Payload) -> Result { + if !payload.is_json_payload() { + return Err(PayloadDeserializeErr::DeserializerDoesNotHandle); + } + let payload_str = std::str::from_utf8(&payload.data).map_err(anyhow::Error::from)?; + Ok(serde_json::from_str(payload_str).map_err(anyhow::Error::from)?) + } + } + + /// Errors when converting from a [Payloads] api proto to our internal [Payload] + #[derive(derive_more::Display, Debug)] + pub enum PayloadsToPayloadError { + MoreThanOnePayload, + NoPayload, + } + impl TryFrom for Payload { + type Error = PayloadsToPayloadError; + + fn try_from(mut v: Payloads) -> Result { + match v.payloads.pop() { + None => Err(PayloadsToPayloadError::NoPayload), + Some(p) => { + if v.payloads.is_empty() { + Ok(p) + } else { + Err(PayloadsToPayloadError::MoreThanOnePayload) + } + } + } + } + } + + /// If initial_interval is missing, fills it with zero value to prevent crashes + /// (lang assumes that RetryPolicy always has initial_interval set). + fn fix_retry_policy(mut retry_policy: RetryPolicy) -> RetryPolicy { + if retry_policy.initial_interval.is_none() { + retry_policy.initial_interval = Default::default(); + } + retry_policy + } +} + +// No need to lint these +#[allow( + clippy::all, + missing_docs, + rustdoc::broken_intra_doc_links, + rustdoc::bare_urls +)] +// This is disgusting, but unclear to me how to avoid it. TODO: Discuss w/ prost maintainer +pub mod temporal { + pub mod api { + pub mod activity { + pub mod v1 { + tonic::include_proto!("temporal.api.activity.v1"); + } + } + pub mod batch { + pub mod v1 { + tonic::include_proto!("temporal.api.batch.v1"); + } + } + pub mod command { + pub mod v1 { + tonic::include_proto!("temporal.api.command.v1"); + + use crate::protos::{ + coresdk::{IntoPayloadsExt, workflow_commands}, + temporal::api::{ + common::v1::{ActivityType, WorkflowType}, + enums::v1::CommandType, + }, + }; + use command::Attributes; + use std::fmt::{Display, Formatter}; + + impl From for Command { + fn from(c: command::Attributes) -> Self { + match c { + a @ Attributes::StartTimerCommandAttributes(_) => Self { + command_type: CommandType::StartTimer as i32, + attributes: Some(a), + user_metadata: Default::default(), + }, + a @ Attributes::CancelTimerCommandAttributes(_) => Self { + command_type: CommandType::CancelTimer as i32, + attributes: Some(a), + user_metadata: Default::default(), + }, + a @ Attributes::CompleteWorkflowExecutionCommandAttributes(_) => Self { + command_type: CommandType::CompleteWorkflowExecution as i32, + attributes: Some(a), + user_metadata: Default::default(), + }, + a @ Attributes::FailWorkflowExecutionCommandAttributes(_) => Self { + command_type: CommandType::FailWorkflowExecution as i32, + attributes: Some(a), + user_metadata: Default::default(), + }, + a @ Attributes::ScheduleActivityTaskCommandAttributes(_) => Self { + command_type: CommandType::ScheduleActivityTask as i32, + attributes: Some(a), + user_metadata: Default::default(), + }, + a @ Attributes::RequestCancelActivityTaskCommandAttributes(_) => Self { + command_type: CommandType::RequestCancelActivityTask as i32, + attributes: Some(a), + user_metadata: Default::default(), + }, + a @ Attributes::ContinueAsNewWorkflowExecutionCommandAttributes(_) => { + Self { + command_type: CommandType::ContinueAsNewWorkflowExecution + as i32, + attributes: Some(a), + user_metadata: Default::default(), + } + } + a @ Attributes::CancelWorkflowExecutionCommandAttributes(_) => Self { + command_type: CommandType::CancelWorkflowExecution as i32, + attributes: Some(a), + user_metadata: Default::default(), + }, + a @ Attributes::RecordMarkerCommandAttributes(_) => Self { + command_type: CommandType::RecordMarker as i32, + attributes: Some(a), + user_metadata: Default::default(), + }, + a @ Attributes::ProtocolMessageCommandAttributes(_) => Self { + command_type: CommandType::ProtocolMessage as i32, + attributes: Some(a), + user_metadata: Default::default(), + }, + a @ Attributes::RequestCancelNexusOperationCommandAttributes(_) => { + Self { + command_type: CommandType::RequestCancelNexusOperation as i32, + attributes: Some(a), + user_metadata: Default::default(), + } + } + _ => unimplemented!(), + } + } + } + + impl Display for Command { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let ct = CommandType::try_from(self.command_type) + .unwrap_or(CommandType::Unspecified); + write!(f, "{:?}", ct) + } + } + + pub trait CommandAttributesExt { + fn as_type(&self) -> CommandType; + } + + impl CommandAttributesExt for command::Attributes { + fn as_type(&self) -> CommandType { + match self { + Attributes::ScheduleActivityTaskCommandAttributes(_) => { + CommandType::ScheduleActivityTask + } + Attributes::StartTimerCommandAttributes(_) => CommandType::StartTimer, + Attributes::CompleteWorkflowExecutionCommandAttributes(_) => { + CommandType::CompleteWorkflowExecution + } + Attributes::FailWorkflowExecutionCommandAttributes(_) => { + CommandType::FailWorkflowExecution + } + Attributes::RequestCancelActivityTaskCommandAttributes(_) => { + CommandType::RequestCancelActivityTask + } + Attributes::CancelTimerCommandAttributes(_) => CommandType::CancelTimer, + Attributes::CancelWorkflowExecutionCommandAttributes(_) => { + CommandType::CancelWorkflowExecution + } + Attributes::RequestCancelExternalWorkflowExecutionCommandAttributes( + _, + ) => CommandType::RequestCancelExternalWorkflowExecution, + Attributes::RecordMarkerCommandAttributes(_) => { + CommandType::RecordMarker + } + Attributes::ContinueAsNewWorkflowExecutionCommandAttributes(_) => { + CommandType::ContinueAsNewWorkflowExecution + } + Attributes::StartChildWorkflowExecutionCommandAttributes(_) => { + CommandType::StartChildWorkflowExecution + } + Attributes::SignalExternalWorkflowExecutionCommandAttributes(_) => { + CommandType::SignalExternalWorkflowExecution + } + Attributes::UpsertWorkflowSearchAttributesCommandAttributes(_) => { + CommandType::UpsertWorkflowSearchAttributes + } + Attributes::ProtocolMessageCommandAttributes(_) => { + CommandType::ProtocolMessage + } + Attributes::ModifyWorkflowPropertiesCommandAttributes(_) => { + CommandType::ModifyWorkflowProperties + } + Attributes::ScheduleNexusOperationCommandAttributes(_) => { + CommandType::ScheduleNexusOperation + } + Attributes::RequestCancelNexusOperationCommandAttributes(_) => { + CommandType::RequestCancelNexusOperation + } + } + } + } + + impl Display for command::Attributes { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.as_type()) + } + } + + impl From for command::Attributes { + fn from(s: workflow_commands::StartTimer) -> Self { + Self::StartTimerCommandAttributes(StartTimerCommandAttributes { + timer_id: s.seq.to_string(), + start_to_fire_timeout: s.start_to_fire_timeout, + }) + } + } + + impl From for command::Attributes { + fn from(s: workflow_commands::UpsertWorkflowSearchAttributes) -> Self { + Self::UpsertWorkflowSearchAttributesCommandAttributes( + UpsertWorkflowSearchAttributesCommandAttributes { + search_attributes: s.search_attributes, + }, + ) + } + } + + impl From for command::Attributes { + fn from(s: workflow_commands::ModifyWorkflowProperties) -> Self { + Self::ModifyWorkflowPropertiesCommandAttributes( + ModifyWorkflowPropertiesCommandAttributes { + upserted_memo: s.upserted_memo.map(Into::into), + }, + ) + } + } + + impl From for command::Attributes { + fn from(s: workflow_commands::CancelTimer) -> Self { + Self::CancelTimerCommandAttributes(CancelTimerCommandAttributes { + timer_id: s.seq.to_string(), + }) + } + } + + pub fn schedule_activity_cmd_to_api( + s: workflow_commands::ScheduleActivity, + use_workflow_build_id: bool, + ) -> command::Attributes { + command::Attributes::ScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes { + activity_id: s.activity_id, + activity_type: Some(ActivityType { + name: s.activity_type, + }), + task_queue: Some(s.task_queue.into()), + header: Some(s.headers.into()), + input: s.arguments.into_payloads(), + schedule_to_close_timeout: s.schedule_to_close_timeout, + schedule_to_start_timeout: s.schedule_to_start_timeout, + start_to_close_timeout: s.start_to_close_timeout, + heartbeat_timeout: s.heartbeat_timeout, + retry_policy: s.retry_policy.map(Into::into), + request_eager_execution: !s.do_not_eagerly_execute, + use_workflow_build_id, + priority: s.priority, + }, + ) + } + + #[allow(deprecated)] + pub fn start_child_workflow_cmd_to_api( + s: workflow_commands::StartChildWorkflowExecution, + inherit_build_id: bool, + ) -> command::Attributes { + command::Attributes::StartChildWorkflowExecutionCommandAttributes( + StartChildWorkflowExecutionCommandAttributes { + workflow_id: s.workflow_id, + workflow_type: Some(WorkflowType { + name: s.workflow_type, + }), + control: "".into(), + namespace: s.namespace, + task_queue: Some(s.task_queue.into()), + header: Some(s.headers.into()), + memo: Some(s.memo.into()), + search_attributes: s.search_attributes, + input: s.input.into_payloads(), + workflow_id_reuse_policy: s.workflow_id_reuse_policy, + workflow_execution_timeout: s.workflow_execution_timeout, + workflow_run_timeout: s.workflow_run_timeout, + workflow_task_timeout: s.workflow_task_timeout, + retry_policy: s.retry_policy.map(Into::into), + cron_schedule: s.cron_schedule.clone(), + parent_close_policy: s.parent_close_policy, + inherit_build_id, + priority: s.priority, + }, + ) + } + + impl From for command::Attributes { + fn from(c: workflow_commands::CompleteWorkflowExecution) -> Self { + Self::CompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes { + result: c.result.map(Into::into), + }, + ) + } + } + + impl From for command::Attributes { + fn from(c: workflow_commands::FailWorkflowExecution) -> Self { + Self::FailWorkflowExecutionCommandAttributes( + FailWorkflowExecutionCommandAttributes { + failure: c.failure.map(Into::into), + }, + ) + } + } + + #[allow(deprecated)] + pub fn continue_as_new_cmd_to_api( + c: workflow_commands::ContinueAsNewWorkflowExecution, + inherit_build_id: bool, + ) -> command::Attributes { + command::Attributes::ContinueAsNewWorkflowExecutionCommandAttributes( + ContinueAsNewWorkflowExecutionCommandAttributes { + workflow_type: Some(c.workflow_type.into()), + task_queue: Some(c.task_queue.into()), + input: c.arguments.into_payloads(), + workflow_run_timeout: c.workflow_run_timeout, + workflow_task_timeout: c.workflow_task_timeout, + memo: if c.memo.is_empty() { + None + } else { + Some(c.memo.into()) + }, + header: if c.headers.is_empty() { + None + } else { + Some(c.headers.into()) + }, + retry_policy: c.retry_policy, + search_attributes: c.search_attributes, + inherit_build_id, + initial_versioning_behavior: c.initial_versioning_behavior, + ..Default::default() + }, + ) + } + + impl From for command::Attributes { + fn from(_c: workflow_commands::CancelWorkflowExecution) -> Self { + Self::CancelWorkflowExecutionCommandAttributes( + CancelWorkflowExecutionCommandAttributes { details: None }, + ) + } + } + + impl From for command::Attributes { + fn from(c: workflow_commands::ScheduleNexusOperation) -> Self { + Self::ScheduleNexusOperationCommandAttributes( + ScheduleNexusOperationCommandAttributes { + endpoint: c.endpoint, + service: c.service, + operation: c.operation, + input: c.input, + schedule_to_close_timeout: c.schedule_to_close_timeout, + schedule_to_start_timeout: c.schedule_to_start_timeout, + start_to_close_timeout: c.start_to_close_timeout, + nexus_header: c.nexus_header, + }, + ) + } + } + } + } + #[allow(rustdoc::invalid_html_tags)] + pub mod cloud { + pub mod account { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.account.v1"); + } + } + pub mod auditlog { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.auditlog.v1"); + } + } + pub mod billing { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.billing.v1"); + } + } + pub mod cloudservice { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.cloudservice.v1"); + } + } + pub mod connectivityrule { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.connectivityrule.v1"); + } + } + pub mod identity { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.identity.v1"); + } + } + pub mod namespace { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.namespace.v1"); + } + } + pub mod nexus { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.nexus.v1"); + } + } + pub mod operation { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.operation.v1"); + } + } + pub mod region { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.region.v1"); + } + } + pub mod resource { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.resource.v1"); + } + } + pub mod sink { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.sink.v1"); + } + } + pub mod usage { + pub mod v1 { + tonic::include_proto!("temporal.api.cloud.usage.v1"); + } + } + } + pub mod common { + pub mod v1 { + use crate::protos::{ENCODING_PAYLOAD_KEY, JSON_ENCODING_VAL}; + use base64::{Engine, prelude::BASE64_STANDARD}; + use std::{ + collections::HashMap, + fmt::{Display, Formatter}, + }; + include_proto_with_serde!("temporal.api.common.v1"); + + impl From for Payload + where + T: AsRef<[u8]>, + { + fn from(v: T) -> Self { + // TODO: Set better encodings, whole data converter deal. Setting anything + // for now at least makes it show up in the web UI. + let mut metadata = HashMap::new(); + metadata.insert(ENCODING_PAYLOAD_KEY.to_string(), b"binary/plain".to_vec()); + Self { + metadata, + data: v.as_ref().to_vec(), + external_payloads: Default::default(), + } + } + } + + impl Payload { + // Is its own function b/c asref causes implementation conflicts + pub fn as_slice(&self) -> &[u8] { + self.data.as_slice() + } + + pub fn is_json_payload(&self) -> bool { + self.metadata + .get(ENCODING_PAYLOAD_KEY) + .map(|v| v.as_slice() == JSON_ENCODING_VAL.as_bytes()) + .unwrap_or_default() + } + } + + impl std::fmt::Debug for Payload { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if std::env::var("TEMPORAL_PRINT_FULL_PAYLOADS").is_err() + && self.data.len() > 64 + { + let mut windows = self.data.as_slice().windows(32); + write!( + f, + "[{}..{}]", + BASE64_STANDARD.encode(windows.next().unwrap_or_default()), + BASE64_STANDARD.encode(windows.next_back().unwrap_or_default()) + ) + } else { + write!(f, "[{}]", BASE64_STANDARD.encode(&self.data)) + } + } + } + + impl Display for Payload { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } + } + + impl Display for Header { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Header(")?; + for kv in &self.fields { + write!(f, "{}: ", kv.0)?; + write!(f, "{}, ", kv.1)?; + } + write!(f, ")") + } + } + + impl From
for HashMap { + fn from(h: Header) -> Self { + h.fields.into_iter().map(|(k, v)| (k, v.into())).collect() + } + } + + impl From for HashMap { + fn from(h: Memo) -> Self { + h.fields.into_iter().map(|(k, v)| (k, v.into())).collect() + } + } + + impl From for HashMap { + fn from(h: SearchAttributes) -> Self { + h.indexed_fields + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect() + } + } + + impl From> for SearchAttributes { + fn from(h: HashMap) -> Self { + Self { + indexed_fields: h.into_iter().map(|(k, v)| (k, v.into())).collect(), + } + } + } + + impl From for ActivityType { + fn from(name: String) -> Self { + Self { name } + } + } + + impl From<&str> for ActivityType { + fn from(name: &str) -> Self { + Self { + name: name.to_string(), + } + } + } + + impl From for String { + fn from(at: ActivityType) -> Self { + at.name + } + } + + impl From<&str> for WorkflowType { + fn from(v: &str) -> Self { + Self { + name: v.to_string(), + } + } + } + } + } + pub mod deployment { + pub mod v1 { + tonic::include_proto!("temporal.api.deployment.v1"); + } + } + pub mod enums { + pub mod v1 { + include_proto_with_serde!("temporal.api.enums.v1"); + } + } + pub mod errordetails { + pub mod v1 { + tonic::include_proto!("temporal.api.errordetails.v1"); + } + } + pub mod failure { + pub mod v1 { + include_proto_with_serde!("temporal.api.failure.v1"); + } + } + pub mod filter { + pub mod v1 { + tonic::include_proto!("temporal.api.filter.v1"); + } + } + pub mod history { + pub mod v1 { + use crate::protos::temporal::api::{ + enums::v1::EventType, history::v1::history_event::Attributes, + }; + use anyhow::bail; + use std::fmt::{Display, Formatter}; + + tonic::include_proto!("temporal.api.history.v1"); + + impl History { + pub fn extract_run_id_from_start(&self) -> Result<&str, anyhow::Error> { + extract_original_run_id_from_events(&self.events) + } + + /// Returns the event id of the final event in the history. Will return 0 if + /// there are no events. + pub fn last_event_id(&self) -> i64 { + self.events.last().map(|e| e.event_id).unwrap_or_default() + } + } + + pub fn extract_original_run_id_from_events( + events: &[HistoryEvent], + ) -> Result<&str, anyhow::Error> { + if let Some(Attributes::WorkflowExecutionStartedEventAttributes(wes)) = + events.get(0).and_then(|x| x.attributes.as_ref()) + { + Ok(&wes.original_execution_run_id) + } else { + bail!("First event is not WorkflowExecutionStarted?!?") + } + } + + impl HistoryEvent { + /// Returns true if this is an event created to mirror a command + pub fn is_command_event(&self) -> bool { + EventType::try_from(self.event_type).map_or(false, |et| match et { + EventType::ActivityTaskScheduled + | EventType::ActivityTaskCancelRequested + | EventType::MarkerRecorded + | EventType::RequestCancelExternalWorkflowExecutionInitiated + | EventType::SignalExternalWorkflowExecutionInitiated + | EventType::StartChildWorkflowExecutionInitiated + | EventType::TimerCanceled + | EventType::TimerStarted + | EventType::UpsertWorkflowSearchAttributes + | EventType::WorkflowPropertiesModified + | EventType::NexusOperationScheduled + | EventType::NexusOperationCancelRequested + | EventType::WorkflowExecutionCanceled + | EventType::WorkflowExecutionCompleted + | EventType::WorkflowExecutionContinuedAsNew + | EventType::WorkflowExecutionFailed + | EventType::WorkflowExecutionUpdateAccepted + | EventType::WorkflowExecutionUpdateRejected + | EventType::WorkflowExecutionUpdateCompleted => true, + _ => false, + }) + } + + /// Returns the command's initiating event id, if present. This is the id of the + /// event which "started" the command. Usually, the "scheduled" event for the + /// command. + pub fn get_initial_command_event_id(&self) -> Option { + self.attributes.as_ref().and_then(|a| { + // Fun! Not really any way to make this better w/o incompatibly changing + // protos. + match a { + Attributes::ActivityTaskStartedEventAttributes(a) => + Some(a.scheduled_event_id), + Attributes::ActivityTaskCompletedEventAttributes(a) => + Some(a.scheduled_event_id), + Attributes::ActivityTaskFailedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::ActivityTaskTimedOutEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::ActivityTaskCancelRequestedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::ActivityTaskCanceledEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::TimerFiredEventAttributes(a) => Some(a.started_event_id), + Attributes::TimerCanceledEventAttributes(a) => Some(a.started_event_id), + Attributes::RequestCancelExternalWorkflowExecutionFailedEventAttributes(a) => Some(a.initiated_event_id), + Attributes::ExternalWorkflowExecutionCancelRequestedEventAttributes(a) => Some(a.initiated_event_id), + Attributes::StartChildWorkflowExecutionFailedEventAttributes(a) => Some(a.initiated_event_id), + Attributes::ChildWorkflowExecutionStartedEventAttributes(a) => Some(a.initiated_event_id), + Attributes::ChildWorkflowExecutionCompletedEventAttributes(a) => Some(a.initiated_event_id), + Attributes::ChildWorkflowExecutionFailedEventAttributes(a) => Some(a.initiated_event_id), + Attributes::ChildWorkflowExecutionCanceledEventAttributes(a) => Some(a.initiated_event_id), + Attributes::ChildWorkflowExecutionTimedOutEventAttributes(a) => Some(a.initiated_event_id), + Attributes::ChildWorkflowExecutionTerminatedEventAttributes(a) => Some(a.initiated_event_id), + Attributes::SignalExternalWorkflowExecutionFailedEventAttributes(a) => Some(a.initiated_event_id), + Attributes::ExternalWorkflowExecutionSignaledEventAttributes(a) => Some(a.initiated_event_id), + Attributes::WorkflowTaskStartedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::WorkflowTaskCompletedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::WorkflowTaskTimedOutEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::WorkflowTaskFailedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::NexusOperationStartedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::NexusOperationCompletedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::NexusOperationFailedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::NexusOperationTimedOutEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::NexusOperationCanceledEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::NexusOperationCancelRequestedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::NexusOperationCancelRequestCompletedEventAttributes(a) => Some(a.scheduled_event_id), + Attributes::NexusOperationCancelRequestFailedEventAttributes(a) => Some(a.scheduled_event_id), + _ => None + } + }) + } + + /// Return the event's associated protocol instance, if one exists. + pub fn get_protocol_instance_id(&self) -> Option<&str> { + self.attributes.as_ref().and_then(|a| match a { + Attributes::WorkflowExecutionUpdateAcceptedEventAttributes(a) => { + Some(a.protocol_instance_id.as_str()) + } + _ => None, + }) + } + + /// Returns true if the event is one which would end a workflow + pub fn is_final_wf_execution_event(&self) -> bool { + match self.event_type() { + EventType::WorkflowExecutionCompleted => true, + EventType::WorkflowExecutionCanceled => true, + EventType::WorkflowExecutionFailed => true, + EventType::WorkflowExecutionTimedOut => true, + EventType::WorkflowExecutionContinuedAsNew => true, + EventType::WorkflowExecutionTerminated => true, + _ => false, + } + } + + pub fn is_wft_closed_event(&self) -> bool { + match self.event_type() { + EventType::WorkflowTaskCompleted => true, + EventType::WorkflowTaskFailed => true, + EventType::WorkflowTaskTimedOut => true, + _ => false, + } + } + + pub fn is_ignorable(&self) -> bool { + if !self.worker_may_ignore { + return false; + } + // Never add a catch-all case to this match statement. We need to explicitly + // mark any new event types as ignorable or not. + if let Some(a) = self.attributes.as_ref() { + match a { + Attributes::WorkflowExecutionStartedEventAttributes(_) => false, + Attributes::WorkflowExecutionCompletedEventAttributes(_) => false, + Attributes::WorkflowExecutionFailedEventAttributes(_) => false, + Attributes::WorkflowExecutionTimedOutEventAttributes(_) => false, + Attributes::WorkflowTaskScheduledEventAttributes(_) => false, + Attributes::WorkflowTaskStartedEventAttributes(_) => false, + Attributes::WorkflowTaskCompletedEventAttributes(_) => false, + Attributes::WorkflowTaskTimedOutEventAttributes(_) => false, + Attributes::WorkflowTaskFailedEventAttributes(_) => false, + Attributes::ActivityTaskScheduledEventAttributes(_) => false, + Attributes::ActivityTaskStartedEventAttributes(_) => false, + Attributes::ActivityTaskCompletedEventAttributes(_) => false, + Attributes::ActivityTaskFailedEventAttributes(_) => false, + Attributes::ActivityTaskTimedOutEventAttributes(_) => false, + Attributes::TimerStartedEventAttributes(_) => false, + Attributes::TimerFiredEventAttributes(_) => false, + Attributes::ActivityTaskCancelRequestedEventAttributes(_) => false, + Attributes::ActivityTaskCanceledEventAttributes(_) => false, + Attributes::TimerCanceledEventAttributes(_) => false, + Attributes::MarkerRecordedEventAttributes(_) => false, + Attributes::WorkflowExecutionSignaledEventAttributes(_) => false, + Attributes::WorkflowExecutionTerminatedEventAttributes(_) => false, + Attributes::WorkflowExecutionCancelRequestedEventAttributes(_) => false, + Attributes::WorkflowExecutionCanceledEventAttributes(_) => false, + Attributes::RequestCancelExternalWorkflowExecutionInitiatedEventAttributes(_) => false, + Attributes::RequestCancelExternalWorkflowExecutionFailedEventAttributes(_) => false, + Attributes::ExternalWorkflowExecutionCancelRequestedEventAttributes(_) => false, + Attributes::WorkflowExecutionContinuedAsNewEventAttributes(_) => false, + Attributes::StartChildWorkflowExecutionInitiatedEventAttributes(_) => false, + Attributes::StartChildWorkflowExecutionFailedEventAttributes(_) => false, + Attributes::ChildWorkflowExecutionStartedEventAttributes(_) => false, + Attributes::ChildWorkflowExecutionCompletedEventAttributes(_) => false, + Attributes::ChildWorkflowExecutionFailedEventAttributes(_) => false, + Attributes::ChildWorkflowExecutionCanceledEventAttributes(_) => false, + Attributes::ChildWorkflowExecutionTimedOutEventAttributes(_) => false, + Attributes::ChildWorkflowExecutionTerminatedEventAttributes(_) => false, + Attributes::SignalExternalWorkflowExecutionInitiatedEventAttributes(_) => false, + Attributes::SignalExternalWorkflowExecutionFailedEventAttributes(_) => false, + Attributes::ExternalWorkflowExecutionSignaledEventAttributes(_) => false, + Attributes::UpsertWorkflowSearchAttributesEventAttributes(_) => false, + Attributes::WorkflowExecutionUpdateAcceptedEventAttributes(_) => false, + Attributes::WorkflowExecutionUpdateRejectedEventAttributes(_) => false, + Attributes::WorkflowExecutionUpdateCompletedEventAttributes(_) => false, + Attributes::WorkflowPropertiesModifiedExternallyEventAttributes(_) => false, + Attributes::ActivityPropertiesModifiedExternallyEventAttributes(_) => false, + Attributes::WorkflowPropertiesModifiedEventAttributes(_) => false, + Attributes::WorkflowExecutionUpdateAdmittedEventAttributes(_) => false, + Attributes::NexusOperationScheduledEventAttributes(_) => false, + Attributes::NexusOperationStartedEventAttributes(_) => false, + Attributes::NexusOperationCompletedEventAttributes(_) => false, + Attributes::NexusOperationFailedEventAttributes(_) => false, + Attributes::NexusOperationCanceledEventAttributes(_) => false, + Attributes::NexusOperationTimedOutEventAttributes(_) => false, + Attributes::NexusOperationCancelRequestedEventAttributes(_) => false, + // !! Ignorable !! + Attributes::WorkflowExecutionOptionsUpdatedEventAttributes(_) => true, + Attributes::NexusOperationCancelRequestCompletedEventAttributes(_) => false, + Attributes::NexusOperationCancelRequestFailedEventAttributes(_) => false, + // !! Ignorable !! + Attributes::WorkflowExecutionPausedEventAttributes(_) => true, + // !! Ignorable !! + Attributes::WorkflowExecutionUnpausedEventAttributes(_) => true, + } + } else { + false + } + } + } + + impl Display for HistoryEvent { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "HistoryEvent(id: {}, {:?})", + self.event_id, + EventType::try_from(self.event_type).unwrap_or_default() + ) + } + } + + impl Attributes { + pub fn event_type(&self) -> EventType { + // I just absolutely _love_ this + match self { + Attributes::WorkflowExecutionStartedEventAttributes(_) => { EventType::WorkflowExecutionStarted } + Attributes::WorkflowExecutionCompletedEventAttributes(_) => { EventType::WorkflowExecutionCompleted } + Attributes::WorkflowExecutionFailedEventAttributes(_) => { EventType::WorkflowExecutionFailed } + Attributes::WorkflowExecutionTimedOutEventAttributes(_) => { EventType::WorkflowExecutionTimedOut } + Attributes::WorkflowTaskScheduledEventAttributes(_) => { EventType::WorkflowTaskScheduled } + Attributes::WorkflowTaskStartedEventAttributes(_) => { EventType::WorkflowTaskStarted } + Attributes::WorkflowTaskCompletedEventAttributes(_) => { EventType::WorkflowTaskCompleted } + Attributes::WorkflowTaskTimedOutEventAttributes(_) => { EventType::WorkflowTaskTimedOut } + Attributes::WorkflowTaskFailedEventAttributes(_) => { EventType::WorkflowTaskFailed } + Attributes::ActivityTaskScheduledEventAttributes(_) => { EventType::ActivityTaskScheduled } + Attributes::ActivityTaskStartedEventAttributes(_) => { EventType::ActivityTaskStarted } + Attributes::ActivityTaskCompletedEventAttributes(_) => { EventType::ActivityTaskCompleted } + Attributes::ActivityTaskFailedEventAttributes(_) => { EventType::ActivityTaskFailed } + Attributes::ActivityTaskTimedOutEventAttributes(_) => { EventType::ActivityTaskTimedOut } + Attributes::TimerStartedEventAttributes(_) => { EventType::TimerStarted } + Attributes::TimerFiredEventAttributes(_) => { EventType::TimerFired } + Attributes::ActivityTaskCancelRequestedEventAttributes(_) => { EventType::ActivityTaskCancelRequested } + Attributes::ActivityTaskCanceledEventAttributes(_) => { EventType::ActivityTaskCanceled } + Attributes::TimerCanceledEventAttributes(_) => { EventType::TimerCanceled } + Attributes::MarkerRecordedEventAttributes(_) => { EventType::MarkerRecorded } + Attributes::WorkflowExecutionSignaledEventAttributes(_) => { EventType::WorkflowExecutionSignaled } + Attributes::WorkflowExecutionTerminatedEventAttributes(_) => { EventType::WorkflowExecutionTerminated } + Attributes::WorkflowExecutionCancelRequestedEventAttributes(_) => { EventType::WorkflowExecutionCancelRequested } + Attributes::WorkflowExecutionCanceledEventAttributes(_) => { EventType::WorkflowExecutionCanceled } + Attributes::RequestCancelExternalWorkflowExecutionInitiatedEventAttributes(_) => { EventType::RequestCancelExternalWorkflowExecutionInitiated } + Attributes::RequestCancelExternalWorkflowExecutionFailedEventAttributes(_) => { EventType::RequestCancelExternalWorkflowExecutionFailed } + Attributes::ExternalWorkflowExecutionCancelRequestedEventAttributes(_) => { EventType::ExternalWorkflowExecutionCancelRequested } + Attributes::WorkflowExecutionContinuedAsNewEventAttributes(_) => { EventType::WorkflowExecutionContinuedAsNew } + Attributes::StartChildWorkflowExecutionInitiatedEventAttributes(_) => { EventType::StartChildWorkflowExecutionInitiated } + Attributes::StartChildWorkflowExecutionFailedEventAttributes(_) => { EventType::StartChildWorkflowExecutionFailed } + Attributes::ChildWorkflowExecutionStartedEventAttributes(_) => { EventType::ChildWorkflowExecutionStarted } + Attributes::ChildWorkflowExecutionCompletedEventAttributes(_) => { EventType::ChildWorkflowExecutionCompleted } + Attributes::ChildWorkflowExecutionFailedEventAttributes(_) => { EventType::ChildWorkflowExecutionFailed } + Attributes::ChildWorkflowExecutionCanceledEventAttributes(_) => { EventType::ChildWorkflowExecutionCanceled } + Attributes::ChildWorkflowExecutionTimedOutEventAttributes(_) => { EventType::ChildWorkflowExecutionTimedOut } + Attributes::ChildWorkflowExecutionTerminatedEventAttributes(_) => { EventType::ChildWorkflowExecutionTerminated } + Attributes::SignalExternalWorkflowExecutionInitiatedEventAttributes(_) => { EventType::SignalExternalWorkflowExecutionInitiated } + Attributes::SignalExternalWorkflowExecutionFailedEventAttributes(_) => { EventType::SignalExternalWorkflowExecutionFailed } + Attributes::ExternalWorkflowExecutionSignaledEventAttributes(_) => { EventType::ExternalWorkflowExecutionSignaled } + Attributes::UpsertWorkflowSearchAttributesEventAttributes(_) => { EventType::UpsertWorkflowSearchAttributes } + Attributes::WorkflowExecutionUpdateAdmittedEventAttributes(_) => { EventType::WorkflowExecutionUpdateAdmitted } + Attributes::WorkflowExecutionUpdateRejectedEventAttributes(_) => { EventType::WorkflowExecutionUpdateRejected } + Attributes::WorkflowExecutionUpdateAcceptedEventAttributes(_) => { EventType::WorkflowExecutionUpdateAccepted } + Attributes::WorkflowExecutionUpdateCompletedEventAttributes(_) => { EventType::WorkflowExecutionUpdateCompleted } + Attributes::WorkflowPropertiesModifiedExternallyEventAttributes(_) => { EventType::WorkflowPropertiesModifiedExternally } + Attributes::ActivityPropertiesModifiedExternallyEventAttributes(_) => { EventType::ActivityPropertiesModifiedExternally } + Attributes::WorkflowPropertiesModifiedEventAttributes(_) => { EventType::WorkflowPropertiesModified } + Attributes::NexusOperationScheduledEventAttributes(_) => { EventType::NexusOperationScheduled } + Attributes::NexusOperationStartedEventAttributes(_) => { EventType::NexusOperationStarted } + Attributes::NexusOperationCompletedEventAttributes(_) => { EventType::NexusOperationCompleted } + Attributes::NexusOperationFailedEventAttributes(_) => { EventType::NexusOperationFailed } + Attributes::NexusOperationCanceledEventAttributes(_) => { EventType::NexusOperationCanceled } + Attributes::NexusOperationTimedOutEventAttributes(_) => { EventType::NexusOperationTimedOut } + Attributes::NexusOperationCancelRequestedEventAttributes(_) => { EventType::NexusOperationCancelRequested } + Attributes::WorkflowExecutionOptionsUpdatedEventAttributes(_) => { EventType::WorkflowExecutionOptionsUpdated } + Attributes::NexusOperationCancelRequestCompletedEventAttributes(_) => { EventType::NexusOperationCancelRequestCompleted } + Attributes::NexusOperationCancelRequestFailedEventAttributes(_) => { EventType::NexusOperationCancelRequestFailed } + Attributes::WorkflowExecutionPausedEventAttributes(_) => { EventType::WorkflowExecutionPaused } + Attributes::WorkflowExecutionUnpausedEventAttributes(_) => { EventType::WorkflowExecutionUnpaused } + } + } + } + } + } + pub mod namespace { + pub mod v1 { + tonic::include_proto!("temporal.api.namespace.v1"); + } + } + pub mod operatorservice { + pub mod v1 { + tonic::include_proto!("temporal.api.operatorservice.v1"); + } + } + pub mod protocol { + pub mod v1 { + use std::fmt::{Display, Formatter}; + tonic::include_proto!("temporal.api.protocol.v1"); + + impl Display for Message { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "ProtocolMessage({})", self.id) + } + } + } + } + pub mod query { + pub mod v1 { + tonic::include_proto!("temporal.api.query.v1"); + } + } + pub mod replication { + pub mod v1 { + tonic::include_proto!("temporal.api.replication.v1"); + } + } + pub mod rules { + pub mod v1 { + tonic::include_proto!("temporal.api.rules.v1"); + } + } + pub mod schedule { + #[allow(rustdoc::invalid_html_tags)] + pub mod v1 { + tonic::include_proto!("temporal.api.schedule.v1"); + } + } + pub mod sdk { + pub mod v1 { + tonic::include_proto!("temporal.api.sdk.v1"); + } + } + pub mod taskqueue { + pub mod v1 { + use crate::protos::temporal::api::enums::v1::TaskQueueKind; + tonic::include_proto!("temporal.api.taskqueue.v1"); + + impl From for TaskQueue { + fn from(name: String) -> Self { + Self { + name, + kind: TaskQueueKind::Normal as i32, + normal_name: "".to_string(), + } + } + } + } + } + pub mod testservice { + pub mod v1 { + tonic::include_proto!("temporal.api.testservice.v1"); + } + } + pub mod update { + pub mod v1 { + use crate::protos::temporal::api::update::v1::outcome::Value; + tonic::include_proto!("temporal.api.update.v1"); + + impl Outcome { + pub fn is_success(&self) -> bool { + match self.value { + Some(Value::Success(_)) => true, + _ => false, + } + } + } + } + } + pub mod version { + pub mod v1 { + tonic::include_proto!("temporal.api.version.v1"); + } + } + pub mod worker { + pub mod v1 { + tonic::include_proto!("temporal.api.worker.v1"); + } + } + pub mod workflow { + pub mod v1 { + tonic::include_proto!("temporal.api.workflow.v1"); + } + } + pub mod nexus { + pub mod v1 { + use crate::protos::{ + camel_case_to_screaming_snake, + temporal::api::{ + common::{ + self, + v1::link::{WorkflowEvent, workflow_event}, + }, + enums::v1::EventType, + failure, + }, + }; + use anyhow::{anyhow, bail}; + use prost::Name; + use std::{ + collections::HashMap, + fmt::{Display, Formatter}, + }; + use tonic::transport::Uri; + + tonic::include_proto!("temporal.api.nexus.v1"); + + impl Display for Response { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "NexusResponse(",)?; + match &self.variant { + None => {} + Some(v) => { + write!(f, "{v}")?; + } + } + write!(f, ")") + } + } + + impl Display for response::Variant { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + response::Variant::StartOperation(_) => { + write!(f, "StartOperation") + } + response::Variant::CancelOperation(_) => { + write!(f, "CancelOperation") + } + } + } + } + + impl Display for HandlerError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "HandlerError") + } + } + + pub enum NexusTaskFailure { + Legacy(HandlerError), + Temporal(failure::v1::Failure), + } + + static SCHEME_PREFIX: &str = "temporal://"; + + /// Attempt to parse a nexus lint into a workflow event link + pub fn workflow_event_link_from_nexus( + l: &Link, + ) -> Result { + if !l.url.starts_with(SCHEME_PREFIX) { + bail!("Invalid scheme for nexus link: {:?}", l.url); + } + // We strip the scheme/authority portion because of + // https://github.com/hyperium/http/issues/696 + let no_authority_url = l.url.strip_prefix(SCHEME_PREFIX).unwrap(); + let uri = Uri::try_from(no_authority_url)?; + let parts = uri.into_parts(); + let path = parts.path_and_query.ok_or_else(|| { + anyhow!("Failed to parse nexus link, invalid path: {:?}", l) + })?; + let path_parts = path.path().split('/').collect::>(); + if path_parts.get(1) != Some(&"namespaces") { + bail!("Invalid path for nexus link: {:?}", l); + } + let namespace = path_parts.get(2).ok_or_else(|| { + anyhow!("Failed to parse nexus link, no namespace: {:?}", l) + })?; + if path_parts.get(3) != Some(&"workflows") { + bail!("Invalid path for nexus link, no workflows segment: {:?}", l); + } + let workflow_id = path_parts.get(4).ok_or_else(|| { + anyhow!("Failed to parse nexus link, no workflow id: {:?}", l) + })?; + let run_id = path_parts + .get(5) + .ok_or_else(|| anyhow!("Failed to parse nexus link, no run id: {:?}", l))?; + if path_parts.get(6) != Some(&"history") { + bail!("Invalid path for nexus link, no history segment: {:?}", l); + } + let reference = if let Some(query) = path.query() { + let mut eventref = workflow_event::EventReference::default(); + let query_parts = query.split('&').collect::>(); + for qp in query_parts { + let mut kv = qp.split('='); + let key = kv.next().ok_or_else(|| { + anyhow!("Failed to parse nexus link query parameter: {:?}", l) + })?; + let val = kv.next().ok_or_else(|| { + anyhow!("Failed to parse nexus link query parameter: {:?}", l) + })?; + match key { + "eventID" => { + eventref.event_id = val.parse().map_err(|_| { + anyhow!("Failed to parse nexus link event id: {:?}", l) + })?; + } + "eventType" => { + eventref.event_type = EventType::from_str_name(val) + .unwrap_or_else(|| { + EventType::from_str_name( + &("EVENT_TYPE_".to_string() + + &camel_case_to_screaming_snake(val)), + ) + .unwrap_or_default() + }) + .into() + } + _ => continue, + } + } + Some(workflow_event::Reference::EventRef(eventref)) + } else { + None + }; + + Ok(common::v1::Link { + variant: Some(common::v1::link::Variant::WorkflowEvent(WorkflowEvent { + namespace: namespace.to_string(), + workflow_id: workflow_id.to_string(), + run_id: run_id.to_string(), + reference, + })), + }) + } + + impl TryFrom for Failure { + type Error = serde_json::Error; + + fn try_from(mut f: failure::v1::Failure) -> Result { + // 1. Remove message from failure + let message = std::mem::take(&mut f.message); + + // 2. Serialize Failure as JSON + let details = serde_json::to_vec(&f)?; + + // 3. Package Temporal Failure as Nexus Failure + Ok(Failure { + message, + stack_trace: f.stack_trace, + metadata: HashMap::from([( + "type".to_string(), + failure::v1::Failure::full_name().into(), + )]), + details, + cause: None, + }) + } + } + } + } + pub mod workflowservice { + pub mod v1 { + use std::{ + convert::TryInto, + fmt::{Display, Formatter}, + time::{Duration, SystemTime}, + }; + + tonic::include_proto!("temporal.api.workflowservice.v1"); + + macro_rules! sched_to_start_impl { + ($sched_field:ident) => { + /// Return the duration of the task schedule time (current attempt) to its + /// start time if both are set and time went forward. + pub fn sched_to_start(&self) -> Option { + if let Some((sch, st)) = + self.$sched_field.clone().zip(self.started_time.clone()) + { + if let Some(value) = elapsed_between_prost_times(sch, st) { + return value; + } + } + None + } + }; + } + + fn elapsed_between_prost_times( + from: prost_types::Timestamp, + to: prost_types::Timestamp, + ) -> Option> { + let from: Result = from.try_into(); + let to: Result = to.try_into(); + if let (Ok(from), Ok(to)) = (from, to) { + return Some(to.duration_since(from).ok()); + } + None + } + + impl PollWorkflowTaskQueueResponse { + sched_to_start_impl!(scheduled_time); + } + + impl Display for PollWorkflowTaskQueueResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let last_event = self + .history + .as_ref() + .and_then(|h| h.events.last().map(|he| he.event_id)) + .unwrap_or(0); + write!( + f, + "PollWFTQResp(run_id: {}, attempt: {}, last_event: {})", + self.workflow_execution + .as_ref() + .map_or("", |we| we.run_id.as_str()), + self.attempt, + last_event + ) + } + } + + /// Can be used while debugging to avoid filling up a whole screen with poll resps + pub struct CompactHist<'a>(pub &'a PollWorkflowTaskQueueResponse); + impl Display for CompactHist<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "PollWorkflowTaskQueueResponse (prev_started: {}, started: {})", + self.0.previous_started_event_id, self.0.started_event_id + )?; + if let Some(h) = self.0.history.as_ref() { + for event in &h.events { + writeln!(f, "{}", event)?; + } + } + writeln!(f, "query: {:#?}", self.0.query)?; + writeln!(f, "queries: {:#?}", self.0.queries) + } + } + + impl PollActivityTaskQueueResponse { + sched_to_start_impl!(current_attempt_scheduled_time); + } + + impl PollNexusTaskQueueResponse { + pub fn sched_to_start(&self) -> Option { + if let Some((sch, st)) = self + .request + .as_ref() + .and_then(|r| r.scheduled_time) + .clone() + .zip(SystemTime::now().try_into().ok()) + { + if let Some(value) = elapsed_between_prost_times(sch, st) { + return value; + } + } + None + } + } + + impl QueryWorkflowResponse { + /// Unwrap a successful response as vec of payloads + pub fn unwrap(self) -> Vec { + self.query_result.unwrap().payloads + } + } + } + } + } +} + +#[allow( + clippy::all, + missing_docs, + rustdoc::broken_intra_doc_links, + rustdoc::bare_urls +)] +pub mod google { + pub mod rpc { + tonic::include_proto!("google.rpc"); + } +} + +#[allow( + clippy::all, + missing_docs, + rustdoc::broken_intra_doc_links, + rustdoc::bare_urls +)] +pub mod grpc { + pub mod health { + pub mod v1 { + tonic::include_proto!("grpc.health.v1"); + } + } +} + +/// Case conversion, used for json -> proto enum string conversion +pub fn camel_case_to_screaming_snake(val: &str) -> String { + let mut out = String::new(); + let mut last_was_upper = true; + for c in val.chars() { + if c.is_uppercase() { + if !last_was_upper { + out.push('_'); + } + out.push(c.to_ascii_uppercase()); + last_was_upper = true; + } else { + out.push(c.to_ascii_uppercase()); + last_was_upper = false; + } + } + out +} + +/// Convert a protobuf [`prost_types::Timestamp`] to a [`std::time::SystemTime`]. +pub fn proto_ts_to_system_time(ts: &prost_types::Timestamp) -> Option { + std::time::SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(ts.seconds as u64) + Duration::from_nanos(ts.nanos as u64)) +} + +#[cfg(test)] +mod tests { + use crate::protos::{ + coresdk::{activity_task, activity_task::ActivityTask}, + temporal::api::{failure::v1::Failure, workflowservice::v1::PollActivityTaskQueueResponse}, + }; + use anyhow::anyhow; + + #[test] + fn start_from_poll_resp_standalone_activity_populates_run_id() { + let resp = PollActivityTaskQueueResponse { + task_token: vec![1, 2, 3], + activity_run_id: "test-run-id-123".to_string(), + activity_id: "my-activity".to_string(), + ..Default::default() + }; + let task = ActivityTask::start_from_poll_resp(resp); + let start = match task.variant { + Some(activity_task::activity_task::Variant::Start(s)) => s, + _ => panic!("expected Start variant"), + }; + assert_eq!(start.run_id, "test-run-id-123"); + assert!(!start.is_local); + } + + #[test] + fn start_from_poll_resp_workflow_activity_has_empty_run_id() { + use crate::protos::temporal::api::common::v1::WorkflowExecution; + let resp = PollActivityTaskQueueResponse { + task_token: vec![4, 5, 6], + activity_id: "my-workflow-activity".to_string(), + workflow_execution: Some(WorkflowExecution { + workflow_id: "wf-123".to_string(), + run_id: "wf-run-456".to_string(), + }), + // activity_run_id intentionally absent — this is a workflow-scheduled activity + ..Default::default() + }; + let task = ActivityTask::start_from_poll_resp(resp); + let start = match task.variant { + Some(activity_task::activity_task::Variant::Start(s)) => s, + _ => panic!("expected Start variant"), + }; + assert!(start.run_id.is_empty()); + // workflow_execution is preserved and distinct from run_id + assert_eq!(start.workflow_execution.unwrap().run_id, "wf-run-456"); + } + + #[test] + fn anyhow_to_failure_conversion() { + let no_causes: Failure = anyhow!("no causes").into(); + assert_eq!(no_causes.cause, None); + assert_eq!(no_causes.message, "no causes"); + let orig = anyhow!("fail 1"); + let mid = orig.context("fail 2"); + let top = mid.context("fail 3"); + let as_fail: Failure = top.into(); + assert_eq!(as_fail.message, "fail 3"); + assert_eq!(as_fail.cause.as_ref().unwrap().message, "fail 2"); + assert_eq!(as_fail.cause.unwrap().cause.unwrap().message, "fail 1"); + } +} diff --git a/crates/common/src/protos/task_token.rs b/crates/common-wasm/src/protos/task_token.rs similarity index 100% rename from crates/common/src/protos/task_token.rs rename to crates/common-wasm/src/protos/task_token.rs diff --git a/crates/common/src/protos/utilities.rs b/crates/common-wasm/src/protos/utilities.rs similarity index 100% rename from crates/common/src/protos/utilities.rs rename to crates/common-wasm/src/protos/utilities.rs diff --git a/crates/common-wasm/src/worker.rs b/crates/common-wasm/src/worker.rs new file mode 100644 index 000000000..bc133e1d4 --- /dev/null +++ b/crates/common-wasm/src/worker.rs @@ -0,0 +1,61 @@ +//! WASM-safe worker-related shared types exposed through workflow APIs. + +use crate::protos::{coresdk, temporal}; +use std::str::FromStr; + +/// Identifies a specific version of a worker deployment. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct WorkerDeploymentVersion { + /// Name of the deployment + pub deployment_name: String, + /// Build ID for the worker. + pub build_id: String, +} + +impl WorkerDeploymentVersion { + /// Returns true if both the deployment name and build ID are empty. + pub fn is_empty(&self) -> bool { + self.deployment_name.is_empty() && self.build_id.is_empty() + } +} + +impl FromStr for WorkerDeploymentVersion { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.split_once('.') { + Some((name, build_id)) => Ok(WorkerDeploymentVersion { + deployment_name: name.to_owned(), + build_id: build_id.to_owned(), + }), + _ => Err(()), + } + } +} + +impl From for coresdk::common::WorkerDeploymentVersion { + fn from(v: WorkerDeploymentVersion) -> coresdk::common::WorkerDeploymentVersion { + coresdk::common::WorkerDeploymentVersion { + deployment_name: v.deployment_name, + build_id: v.build_id, + } + } +} + +impl From for WorkerDeploymentVersion { + fn from(v: coresdk::common::WorkerDeploymentVersion) -> WorkerDeploymentVersion { + WorkerDeploymentVersion { + deployment_name: v.deployment_name, + build_id: v.build_id, + } + } +} + +impl From for WorkerDeploymentVersion { + fn from(v: temporal::api::deployment::v1::WorkerDeploymentVersion) -> Self { + Self { + deployment_name: v.deployment_name, + build_id: v.build_id, + } + } +} diff --git a/crates/common/src/workflow_definition.rs b/crates/common-wasm/src/workflow_definition.rs similarity index 100% rename from crates/common/src/workflow_definition.rs rename to crates/common-wasm/src/workflow_definition.rs diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 887afee13..7bae0d4bd 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -23,7 +23,7 @@ prometheus = [ "dep:tokio", ] envconfig = ["dep:toml", "dep:dirs"] -serde_serialize = [] +serde_serialize = ["temporalio-common-wasm/serde_serialize"] core-telemetry-bridge = ["dep:ringbuf", "dep:futures-channel"] core-based-sdk = ["core-telemetry-bridge", "prometheus", "envconfig"] @@ -96,11 +96,13 @@ url = "2.5" uuid = { version = "1.18", default-features = false, features = ["v4"] } pbjson = { workspace = true } +[dependencies.temporalio-common-wasm] +path = "../common-wasm" +version = "0.2" + [build-dependencies] prost = { workspace = true } prost-types = "0.14" -tonic-prost-build = { workspace = true } -pbjson-build = { workspace = true } [lints] workspace = true diff --git a/crates/common/build.rs b/crates/common/build.rs index 5c2ec3f04..76008f7f8 100644 --- a/crates/common/build.rs +++ b/crates/common/build.rs @@ -9,187 +9,53 @@ use std::{ fs::File, io::{Read, Write}, path::{Path, PathBuf}, + process::Command, }; -use tonic_prost_build::Config; - -static ALWAYS_SERDE: &str = "#[cfg_attr(not(feature = \"serde_serialize\"), \ - derive(::serde::Serialize, ::serde::Deserialize))]"; - -static SERDE_ATTR: &str = - "#[cfg_attr(feature = \"serde_serialize\", derive(::serde::Serialize, ::serde::Deserialize))]"; - -/// Package prefixes that get conditional serde derive via type_attribute. -/// Packages under `temporal.api` are listed individually to exclude the pbjson packages -/// (temporal.api.common, temporal.api.enums, temporal.api.failure), which get their -/// Serialize/Deserialize impls from generated .serde.rs files. -/// -/// If you add a new proto package, add its prefix here unless it is also -/// added to the pbjson_build list below. -const SERDE_DERIVE_PREFIXES: &[&str] = &[ - ".coresdk", - ".grpc", - ".temporal.api.activity", - ".temporal.api.batch", - ".temporal.api.callback", - ".temporal.api.cloud", - ".temporal.api.command", - ".temporal.api.compute", - ".temporal.api.deployment", - ".temporal.api.filter", - ".temporal.api.history", - ".temporal.api.namespace", - ".temporal.api.nexus", - ".temporal.api.operatorservice", - ".temporal.api.protocol", - ".temporal.api.query", - ".temporal.api.replication", - ".temporal.api.rules", - ".temporal.api.schedule", - ".temporal.api.sdk", - ".temporal.api.taskqueue", - ".temporal.api.testservice", - ".temporal.api.update", - ".temporal.api.version", - ".temporal.api.worker", - ".temporal.api.workflow", - ".temporal.api.workflowservice", + +const ROOT_PROTOS: &[&str] = &[ + "./protos/local/temporal/sdk/core/core_interface.proto", + "./protos/api_upstream/temporal/api/sdk/v1/workflow_metadata.proto", + "./protos/api_upstream/temporal/api/workflowservice/v1/service.proto", + "./protos/api_upstream/temporal/api/operatorservice/v1/service.proto", + "./protos/api_upstream/temporal/api/errordetails/v1/message.proto", + "./protos/api_cloud_upstream/temporal/api/cloud/cloudservice/v1/service.proto", + "./protos/testsrv_upstream/temporal/api/testservice/v1/service.proto", + "./protos/grpc/health/v1/health.proto", + "./protos/google/rpc/status.proto", +]; + +const INCLUDE_DIRS: &[&str] = &[ + "./protos/api_upstream", + "./protos/api_cloud_upstream", + "./protos/local", + "./protos/testsrv_upstream", + "./protos/grpc", + "./protos", ]; fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=./protos"); let out = PathBuf::from(env::var("OUT_DIR").unwrap()); let descriptor_file = out.join("descriptors.bin"); - let mut builder = tonic_prost_build::configure() - // We don't actually want to build the grpc definitions - we don't need them (for now). - // Just build the message structs. - .build_server(false) - .build_client(true) - // Make conversions easier for some types - .type_attribute( - "temporal.api.history.v1.HistoryEvent.attributes", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "temporal.api.history.v1.History", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "temporal.api.command.v1.Command.attributes", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "temporal.api.common.v1.WorkflowType", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "temporal.api.common.v1.Header", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "temporal.api.common.v1.Memo", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "temporal.api.enums.v1.SignalExternalWorkflowExecutionFailedCause", - "#[derive(::derive_more::Display)]", - ) - .type_attribute( - "temporal.api.enums.v1.CancelExternalWorkflowExecutionFailedCause", - "#[derive(::derive_more::Display)]", - ) - .type_attribute( - "coresdk.workflow_commands.WorkflowCommand.variant", - "#[derive(::derive_more::From, ::derive_more::Display)]", - ) - .type_attribute( - "coresdk.workflow_commands.QueryResult.variant", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "coresdk.workflow_activation.workflow_activation_job", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "coresdk.workflow_activation.WorkflowActivationJob.variant", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "coresdk.workflow_completion.WorkflowActivationCompletion.status", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "coresdk.activity_result.ActivityExecutionResult.status", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "coresdk.activity_result.ActivityResolution.status", - "#[derive(::derive_more::From)]", - ) - .type_attribute( - "coresdk.activity_task.ActivityCancelReason", - "#[derive(::derive_more::Display)]", - ) - .type_attribute("coresdk.Task.variant", "#[derive(::derive_more::From)]") - // All external data is useful to be able to JSON serialize, so it can render in web UI - .type_attribute(".coresdk.external_data", ALWAYS_SERDE); - for prefix in SERDE_DERIVE_PREFIXES { - builder = builder.type_attribute(*prefix, SERDE_ATTR); + let mut protoc = Command::new("protoc"); + protoc.arg(format!( + "--descriptor_set_out={}", + descriptor_file.display() + )); + protoc.arg("--include_imports"); + for include_dir in INCLUDE_DIRS { + protoc.arg("-I").arg(include_dir); + } + for proto in ROOT_PROTOS { + protoc.arg(proto); + } + let output = protoc.output()?; + if !output.status.success() { + return Err(format!("protoc failed: {}", String::from_utf8_lossy(&output.stderr)).into()); } - - builder - .field_attribute( - "coresdk.external_data.LocalActivityMarkerData.complete_time", - "#[serde(with = \"opt_timestamp\")]", - ) - .field_attribute( - "coresdk.external_data.LocalActivityMarkerData.original_schedule_time", - "#[serde(with = \"opt_timestamp\")]", - ) - .field_attribute( - "coresdk.external_data.LocalActivityMarkerData.backoff", - "#[serde(with = \"opt_duration\")]", - ) - .file_descriptor_set_path(&descriptor_file) - .skip_debug(["temporal.api.common.v1.Payload"]) - .compile_with_config( - { - let mut c = Config::new(); - c.enable_type_names(); - c - }, - &[ - "./protos/local/temporal/sdk/core/core_interface.proto", - "./protos/api_upstream/temporal/api/sdk/v1/workflow_metadata.proto", - "./protos/api_upstream/temporal/api/workflowservice/v1/service.proto", - "./protos/api_upstream/temporal/api/operatorservice/v1/service.proto", - "./protos/api_upstream/temporal/api/errordetails/v1/message.proto", - "./protos/api_cloud_upstream/temporal/api/cloud/cloudservice/v1/service.proto", - "./protos/testsrv_upstream/temporal/api/testservice/v1/service.proto", - "./protos/grpc/health/v1/health.proto", - "./protos/google/rpc/status.proto", - ], - &[ - "./protos/api_upstream", - "./protos/api_cloud_upstream", - "./protos/local", - "./protos/testsrv_upstream", - "./protos/grpc", - "./protos", - ], - )?; generate_payload_visitor(&out, &descriptor_file)?; - // TODO [rust-sdk-branch]: support normal JSON and proto JSON serialization - let descriptors = std::fs::read(&descriptor_file)?; - pbjson_build::Builder::new() - .register_descriptors(&descriptors)? - .build(&[ - ".temporal.api.failure", - ".temporal.api.common", - ".temporal.api.enums", - ])?; - Ok(()) } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ffdc299fd..8d0b341a6 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -7,25 +7,19 @@ #[macro_use] extern crate tracing; -mod activity_definition; -pub mod data_converters; #[cfg(feature = "envconfig")] pub mod envconfig; pub mod error; #[doc(hidden)] pub mod fsm_trait; pub mod payload_visitor; -mod priority; pub mod protos; pub mod telemetry; pub mod worker; -mod workflow_definition; - -pub use activity_definition::ActivityDefinition; -pub use priority::Priority; -pub use workflow_definition::{ - HasWorkflowDefinition, QueryDefinition, SignalDefinition, UntypedWorkflow, UpdateDefinition, - WorkflowDefinition, +pub use temporalio_common_wasm::{ + ActivityDefinition, HasWorkflowDefinition, Priority, QueryDefinition, SignalDefinition, + UntypedWorkflow, UpdateDefinition, WorkerDeploymentVersion, WorkflowDefinition, + data_converters, }; macro_rules! dbg_panic { diff --git a/crates/common/src/protos/mod.rs b/crates/common/src/protos/mod.rs index 6e39904f8..40915429b 100644 --- a/crates/common/src/protos/mod.rs +++ b/crates/common/src/protos/mod.rs @@ -2,2999 +2,22 @@ //! the Temporal Core SDK. Language SDK authors can generate structs using the proto definitions //! that will match the generated structs in this module. -pub mod constants; -/// Utility functions for working with protobuf types. -pub mod utilities; - -mod task_token; - -use std::time::Duration; - -pub use task_token::TaskToken; - -/// Payload metadata key that identifies the encoding format. -pub static ENCODING_PAYLOAD_KEY: &str = "encoding"; -/// The metadata value for JSON-encoded payloads. -pub static JSON_ENCODING_VAL: &str = "json/plain"; -/// The details key used in patched marker payloads. -pub static PATCHED_MARKER_DETAILS_KEY: &str = "patch-data"; -/// The search attribute key used when registering change versions -pub static VERSION_SEARCH_ATTR_KEY: &str = "TemporalChangeVersion"; - -macro_rules! include_proto_with_serde { - ($pkg:tt) => { - tonic::include_proto!($pkg); - - include!(concat!(env!("OUT_DIR"), concat!("/", $pkg, ".serde.rs"))); - }; -} - -#[allow( - clippy::large_enum_variant, - clippy::derive_partial_eq_without_eq, - clippy::reserve_after_initialization -)] -// I'd prefer not to do this, but there are some generated things that just don't need it. -#[allow(missing_docs)] -pub mod coresdk { - //! Contains all protobufs relating to communication between core and lang-specific SDKs - - tonic::include_proto!("coresdk"); - - use crate::protos::{ - ENCODING_PAYLOAD_KEY, JSON_ENCODING_VAL, - temporal::api::{ - common::v1::{Payload, Payloads, RetryPolicy, WorkflowExecution}, - enums::v1::{ - ApplicationErrorCategory, TimeoutType, VersioningBehavior, WorkflowTaskFailedCause, - }, - failure::v1::{ - ActivityFailureInfo, ApplicationFailureInfo, Failure, TimeoutFailureInfo, - failure::FailureInfo, - }, - workflowservice::v1::PollActivityTaskQueueResponse, - }, - }; - use activity_task::ActivityTask; - use serde::{Deserialize, Serialize}; - use std::{ - collections::HashMap, - convert::TryFrom, - fmt::{Display, Formatter}, - iter::FromIterator, - }; - use workflow_activation::{WorkflowActivationJob, workflow_activation_job}; - use workflow_commands::{WorkflowCommand, workflow_command, workflow_command::Variant}; - use workflow_completion::{WorkflowActivationCompletion, workflow_activation_completion}; - - #[allow(clippy::module_inception)] - pub mod activity_task { - use crate::protos::{coresdk::ActivityTaskCompletion, task_token::format_task_token}; - use std::fmt::{Display, Formatter}; - tonic::include_proto!("coresdk.activity_task"); - - impl ActivityTask { - pub fn cancel_from_ids( - task_token: Vec, - reason: ActivityCancelReason, - details: ActivityCancellationDetails, - ) -> Self { - Self { - task_token, - variant: Some(activity_task::Variant::Cancel(Cancel { - reason: reason as i32, - details: Some(details), - })), - } - } - - // Checks if both the primary reason or details have a timeout cancellation. - pub fn is_timeout(&self) -> bool { - match &self.variant { - Some(activity_task::Variant::Cancel(Cancel { reason, details })) => { - *reason == ActivityCancelReason::TimedOut as i32 - || details.as_ref().is_some_and(|d| d.is_timed_out) - } - _ => false, - } - } - - pub fn primary_reason_to_cancellation_details( - reason: ActivityCancelReason, - ) -> ActivityCancellationDetails { - ActivityCancellationDetails { - is_not_found: reason == ActivityCancelReason::NotFound, - is_cancelled: reason == ActivityCancelReason::Cancelled, - is_paused: reason == ActivityCancelReason::Paused, - is_timed_out: reason == ActivityCancelReason::TimedOut, - is_worker_shutdown: reason == ActivityCancelReason::WorkerShutdown, - is_reset: reason == ActivityCancelReason::Reset, - } - } - } - - impl Display for ActivityTaskCompletion { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "ActivityTaskCompletion(token: {}", - format_task_token(&self.task_token), - )?; - if let Some(r) = self.result.as_ref().and_then(|r| r.status.as_ref()) { - write!(f, ", {r}")?; - } else { - write!(f, ", missing result")?; - } - write!(f, ")") - } - } - } - #[allow(clippy::module_inception)] - pub mod activity_result { - tonic::include_proto!("coresdk.activity_result"); - use super::super::temporal::api::{ - common::v1::Payload, - failure::v1::{CanceledFailureInfo, Failure as APIFailure, failure}, - }; - use crate::protos::{ - coresdk::activity_result::activity_resolution::Status, - temporal::api::enums::v1::TimeoutType, - }; - use activity_execution_result as aer; - use anyhow::anyhow; - use std::fmt::{Display, Formatter}; - - impl ActivityExecutionResult { - pub const fn ok(result: Payload) -> Self { - Self { - status: Some(aer::Status::Completed(Success { - result: Some(result), - })), - } - } - - pub fn fail(fail: APIFailure) -> Self { - Self { - status: Some(aer::Status::Failed(Failure { - failure: Some(fail), - })), - } - } - - pub fn cancel_from_details(payload: Option) -> Self { - Self { - status: Some(aer::Status::Cancelled(Cancellation::from_details(payload))), - } - } - - pub fn cancel(fail: APIFailure) -> Self { - Self { - status: Some(aer::Status::Cancelled(Cancellation { - failure: Some(fail), - })), - } - } - - pub const fn will_complete_async() -> Self { - Self { - status: Some(aer::Status::WillCompleteAsync(WillCompleteAsync {})), - } - } - - pub fn is_cancelled(&self) -> bool { - matches!(self.status, Some(aer::Status::Cancelled(_))) - } - } - - impl Display for aer::Status { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "ActivityExecutionResult(")?; - match self { - aer::Status::Completed(v) => { - write!(f, "{v})") - } - aer::Status::Failed(v) => { - write!(f, "{v})") - } - aer::Status::Cancelled(v) => { - write!(f, "{v})") - } - aer::Status::WillCompleteAsync(_) => { - write!(f, "Will complete async)") - } - } - } - } - - impl Display for Success { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "Success(")?; - if let Some(ref v) = self.result { - write!(f, "{v}")?; - } - write!(f, ")") - } - } - - impl Display for Failure { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "Failure(")?; - if let Some(ref v) = self.failure { - write!(f, "{v}")?; - } - write!(f, ")") - } - } - - impl Display for Cancellation { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "Cancellation(")?; - if let Some(ref v) = self.failure { - write!(f, "{v}")?; - } - write!(f, ")") - } - } - - impl From> for ActivityExecutionResult { - fn from(r: Result) -> Self { - Self { - status: match r { - Ok(p) => Some(aer::Status::Completed(Success { result: Some(p) })), - Err(f) => Some(aer::Status::Failed(Failure { failure: Some(f) })), - }, - } - } - } - - impl ActivityResolution { - /// Extract an activity's payload if it completed successfully, or return an error for all - /// other outcomes. - pub fn success_payload_or_error(self) -> Result, anyhow::Error> { - let Some(status) = self.status else { - return Err(anyhow!("Activity completed without a status")); - }; - - match status { - activity_resolution::Status::Completed(success) => Ok(success.result), - e => Err(anyhow!("Activity was not successful: {e:?}")), - } - } - - pub fn unwrap_ok_payload(self) -> Payload { - self.success_payload_or_error().unwrap().unwrap() - } - - pub fn completed_ok(&self) -> bool { - matches!(self.status, Some(activity_resolution::Status::Completed(_))) - } - - pub fn failed(&self) -> bool { - matches!(self.status, Some(activity_resolution::Status::Failed(_))) - } - - pub fn timed_out(&self) -> Option { - match self.status { - Some(activity_resolution::Status::Failed(Failure { - failure: Some(ref f), - })) => f.is_timeout(), - _ => None, - } - } - - pub fn cancelled(&self) -> bool { - matches!(self.status, Some(activity_resolution::Status::Cancelled(_))) - } - - /// If this resolution is any kind of failure, return the inner failure details. Panics - /// if the activity succeeded, is in backoff, or this resolution is malformed. - pub fn unwrap_failure(self) -> APIFailure { - match self.status.unwrap() { - Status::Failed(f) => f.failure.unwrap(), - Status::Cancelled(c) => c.failure.unwrap(), - _ => panic!("Actvity did not fail"), - } - } - } - - impl Cancellation { - /// Create a cancellation result from some payload. This is to be used when telling Core - /// that an activity completed as cancelled. - pub fn from_details(details: Option) -> Self { - Cancellation { - failure: Some(APIFailure { - message: "Activity cancelled".to_string(), - failure_info: Some(failure::FailureInfo::CanceledFailureInfo( - CanceledFailureInfo { - details: details.map(Into::into), - identity: Default::default(), - }, - )), - ..Default::default() - }), - } - } - } - } - - pub mod common { - tonic::include_proto!("coresdk.common"); - use super::external_data::LocalActivityMarkerData; - use crate::protos::{ - PATCHED_MARKER_DETAILS_KEY, - coresdk::{ - AsJsonPayloadExt, FromJsonPayloadExt, IntoPayloadsExt, - external_data::PatchedMarkerData, - }, - temporal::api::common::v1::{Payload, Payloads}, - }; - use std::collections::HashMap; - - pub fn build_has_change_marker_details( - patch_id: impl Into, - deprecated: bool, - ) -> anyhow::Result> { - let mut hm = HashMap::new(); - let encoded = PatchedMarkerData { - id: patch_id.into(), - deprecated, - } - .as_json_payload()?; - hm.insert(PATCHED_MARKER_DETAILS_KEY.to_string(), encoded.into()); - Ok(hm) - } - - pub fn decode_change_marker_details( - details: &HashMap, - ) -> Option<(String, bool)> { - // We used to write change markers with plain bytes, so try to decode if they are - // json first, then fall back to that. - if let Some(cd) = details.get(PATCHED_MARKER_DETAILS_KEY) { - let decoded = PatchedMarkerData::from_json_payload(cd.payloads.first()?).ok()?; - return Some((decoded.id, decoded.deprecated)); - } - - let id_entry = details.get("patch_id")?.payloads.first()?; - let deprecated_entry = details.get("deprecated")?.payloads.first()?; - let name = std::str::from_utf8(&id_entry.data).ok()?; - let deprecated = *deprecated_entry.data.first()? != 0; - Some((name.to_string(), deprecated)) - } - - pub fn build_local_activity_marker_details( - metadata: LocalActivityMarkerData, - result: Option, - ) -> HashMap { - let mut hm = HashMap::new(); - // It would be more efficient for this to be proto binary, but then it shows up as - // meaningless in the Temporal UI... - if let Some(jsonified) = metadata.as_json_payload().into_payloads() { - hm.insert("data".to_string(), jsonified); - } - if let Some(res) = result { - hm.insert("result".to_string(), res.into()); - } - hm - } - - /// Given a marker detail map, returns just the local activity info, but not the payload. - /// This is fairly inexpensive. Deserializing the whole payload may not be. - pub fn extract_local_activity_marker_data( - details: &HashMap, - ) -> Option { - details - .get("data") - .and_then(|p| p.payloads.first()) - .and_then(|p| std::str::from_utf8(&p.data).ok()) - .and_then(|s| serde_json::from_str(s).ok()) - } - - /// Given a marker detail map, returns the local activity info and the result payload - /// if they are found and the marker data is well-formed. This removes the data from the - /// map. - pub fn extract_local_activity_marker_details( - details: &mut HashMap, - ) -> (Option, Option) { - let data = extract_local_activity_marker_data(details); - let result = details.remove("result").and_then(|mut p| p.payloads.pop()); - (data, result) - } - } - - pub mod external_data { - use prost_types::{Duration, Timestamp}; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - tonic::include_proto!("coresdk.external_data"); - - // Buncha hullaballoo because prost types aren't serde compat. - // See https://github.com/tokio-rs/prost/issues/75 which hilariously Chad opened ages ago - - #[derive(Serialize, Deserialize)] - #[serde(remote = "Timestamp")] - struct TimestampDef { - seconds: i64, - nanos: i32, - } - mod opt_timestamp { - use super::*; - - pub(super) fn serialize( - value: &Option, - serializer: S, - ) -> Result - where - S: Serializer, - { - #[derive(Serialize)] - struct Helper<'a>(#[serde(with = "TimestampDef")] &'a Timestamp); - - value.as_ref().map(Helper).serialize(serializer) - } - - pub(super) fn deserialize<'de, D>( - deserializer: D, - ) -> Result, D::Error> - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - struct Helper(#[serde(with = "TimestampDef")] Timestamp); - - let helper = Option::deserialize(deserializer)?; - Ok(helper.map(|Helper(external)| external)) - } - } - - // Luckily Duration is also stored the exact same way - #[derive(Serialize, Deserialize)] - #[serde(remote = "Duration")] - struct DurationDef { - seconds: i64, - nanos: i32, - } - mod opt_duration { - use super::*; - - pub(super) fn serialize( - value: &Option, - serializer: S, - ) -> Result - where - S: Serializer, - { - #[derive(Serialize)] - struct Helper<'a>(#[serde(with = "DurationDef")] &'a Duration); - - value.as_ref().map(Helper).serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - struct Helper(#[serde(with = "DurationDef")] Duration); - - let helper = Option::deserialize(deserializer)?; - Ok(helper.map(|Helper(external)| external)) - } - } - } - - pub mod workflow_activation { - use crate::protos::{ - coresdk::{ - FromPayloadsExt, - activity_result::{ActivityResolution, activity_resolution}, - common::NamespacedWorkflowExecution, - fix_retry_policy, - workflow_activation::remove_from_cache::EvictionReason, - }, - temporal::api::{ - enums::v1::WorkflowTaskFailedCause, - history::v1::{ - WorkflowExecutionCancelRequestedEventAttributes, - WorkflowExecutionSignaledEventAttributes, - WorkflowExecutionStartedEventAttributes, - }, - query::v1::WorkflowQuery, - }, - }; - use prost_types::Timestamp; - use std::fmt::{Display, Formatter}; - - tonic::include_proto!("coresdk.workflow_activation"); - - pub fn create_evict_activation( - run_id: String, - message: String, - reason: EvictionReason, - ) -> WorkflowActivation { - WorkflowActivation { - timestamp: None, - run_id, - is_replaying: false, - history_length: 0, - jobs: vec![WorkflowActivationJob::from( - workflow_activation_job::Variant::RemoveFromCache(RemoveFromCache { - message, - reason: reason as i32, - }), - )], - available_internal_flags: vec![], - history_size_bytes: 0, - continue_as_new_suggested: false, - deployment_version_for_current_task: None, - last_sdk_version: String::new(), - suggest_continue_as_new_reasons: vec![], - target_worker_deployment_version_changed: false, - } - } - - pub fn query_to_job(id: String, q: WorkflowQuery) -> QueryWorkflow { - QueryWorkflow { - query_id: id, - query_type: q.query_type, - arguments: Vec::from_payloads(q.query_args), - headers: q.header.map(|h| h.into()).unwrap_or_default(), - } - } - - impl WorkflowActivation { - /// Returns true if the only job in the activation is eviction - pub fn is_only_eviction(&self) -> bool { - matches!( - self.jobs.as_slice(), - [WorkflowActivationJob { - variant: Some(workflow_activation_job::Variant::RemoveFromCache(_)) - }] - ) - } - - /// Returns eviction reason if this activation is an eviction - pub fn eviction_reason(&self) -> Option { - self.jobs.iter().find_map(|j| { - if let Some(workflow_activation_job::Variant::RemoveFromCache(ref rj)) = - j.variant - { - EvictionReason::try_from(rj.reason).ok() - } else { - None - } - }) - } - } - - impl workflow_activation_job::Variant { - pub fn is_local_activity_resolution(&self) -> bool { - matches!(self, workflow_activation_job::Variant::ResolveActivity(ra) if ra.is_local) - } - } - - impl Display for EvictionReason { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{self:?}") - } - } - - impl From for WorkflowTaskFailedCause { - fn from(value: EvictionReason) -> Self { - match value { - EvictionReason::Nondeterminism => { - WorkflowTaskFailedCause::NonDeterministicError - } - _ => WorkflowTaskFailedCause::Unspecified, - } - } - } - - impl Display for WorkflowActivation { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "WorkflowActivation(")?; - write!(f, "run_id: {}, ", self.run_id)?; - write!(f, "is_replaying: {}, ", self.is_replaying)?; - write!( - f, - "jobs: {})", - self.jobs - .iter() - .map(ToString::to_string) - .collect::>() - .as_slice() - .join(", ") - ) - } - } - - impl Display for WorkflowActivationJob { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match &self.variant { - None => write!(f, "empty"), - Some(v) => write!(f, "{v}"), - } - } - } - - impl Display for workflow_activation_job::Variant { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - workflow_activation_job::Variant::InitializeWorkflow(_) => { - write!(f, "InitializeWorkflow") - } - workflow_activation_job::Variant::FireTimer(t) => { - write!(f, "FireTimer({})", t.seq) - } - workflow_activation_job::Variant::UpdateRandomSeed(_) => { - write!(f, "UpdateRandomSeed") - } - workflow_activation_job::Variant::QueryWorkflow(_) => { - write!(f, "QueryWorkflow") - } - workflow_activation_job::Variant::CancelWorkflow(_) => { - write!(f, "CancelWorkflow") - } - workflow_activation_job::Variant::SignalWorkflow(_) => { - write!(f, "SignalWorkflow") - } - workflow_activation_job::Variant::ResolveActivity(r) => { - write!( - f, - "ResolveActivity({}, {})", - r.seq, - r.result - .as_ref() - .unwrap_or(&ActivityResolution { status: None }) - ) - } - workflow_activation_job::Variant::NotifyHasPatch(_) => { - write!(f, "NotifyHasPatch") - } - workflow_activation_job::Variant::ResolveChildWorkflowExecutionStart(_) => { - write!(f, "ResolveChildWorkflowExecutionStart") - } - workflow_activation_job::Variant::ResolveChildWorkflowExecution(_) => { - write!(f, "ResolveChildWorkflowExecution") - } - workflow_activation_job::Variant::ResolveSignalExternalWorkflow(_) => { - write!(f, "ResolveSignalExternalWorkflow") - } - workflow_activation_job::Variant::RemoveFromCache(_) => { - write!(f, "RemoveFromCache") - } - workflow_activation_job::Variant::ResolveRequestCancelExternalWorkflow(_) => { - write!(f, "ResolveRequestCancelExternalWorkflow") - } - workflow_activation_job::Variant::DoUpdate(u) => { - write!(f, "DoUpdate({})", u.id) - } - workflow_activation_job::Variant::ResolveNexusOperationStart(_) => { - write!(f, "ResolveNexusOperationStart") - } - workflow_activation_job::Variant::ResolveNexusOperation(_) => { - write!(f, "ResolveNexusOperation") - } - } - } - } - - impl Display for ActivityResolution { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self.status { - None => { - write!(f, "None") - } - Some(activity_resolution::Status::Failed(_)) => { - write!(f, "Failed") - } - Some(activity_resolution::Status::Completed(_)) => { - write!(f, "Completed") - } - Some(activity_resolution::Status::Cancelled(_)) => { - write!(f, "Cancelled") - } - Some(activity_resolution::Status::Backoff(_)) => { - write!(f, "Backoff") - } - } - } - } - - impl Display for QueryWorkflow { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "QueryWorkflow(id: {}, type: {})", - self.query_id, self.query_type - ) - } - } - - impl From for SignalWorkflow { - fn from(a: WorkflowExecutionSignaledEventAttributes) -> 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(), - } - } - } - - impl From for CancelWorkflow { - fn from(a: WorkflowExecutionCancelRequestedEventAttributes) -> Self { - Self { reason: a.cause } - } - } - - /// Create a [InitializeWorkflow] job from corresponding event attributes - pub fn start_workflow_from_attribs( - attrs: WorkflowExecutionStartedEventAttributes, - workflow_id: String, - randomness_seed: u64, - start_time: Timestamp, - ) -> InitializeWorkflow { - InitializeWorkflow { - workflow_type: attrs.workflow_type.map(|wt| wt.name).unwrap_or_default(), - workflow_id, - arguments: Vec::from_payloads(attrs.input), - randomness_seed, - headers: attrs.header.unwrap_or_default().fields, - identity: attrs.identity, - parent_workflow_info: attrs.parent_workflow_execution.map(|pe| { - NamespacedWorkflowExecution { - namespace: attrs.parent_workflow_namespace, - run_id: pe.run_id, - workflow_id: pe.workflow_id, - } - }), - workflow_execution_timeout: attrs.workflow_execution_timeout, - workflow_run_timeout: attrs.workflow_run_timeout, - workflow_task_timeout: attrs.workflow_task_timeout, - continued_from_execution_run_id: attrs.continued_execution_run_id, - continued_initiator: attrs.initiator, - continued_failure: attrs.continued_failure, - last_completion_result: attrs.last_completion_result, - first_execution_run_id: attrs.first_execution_run_id, - retry_policy: attrs.retry_policy.map(fix_retry_policy), - attempt: attrs.attempt, - cron_schedule: attrs.cron_schedule, - workflow_execution_expiration_time: attrs.workflow_execution_expiration_time, - cron_schedule_to_schedule_interval: attrs.first_workflow_task_backoff, - memo: attrs.memo, - search_attributes: attrs.search_attributes, - start_time: Some(start_time), - root_workflow: attrs.root_workflow_execution, - priority: attrs.priority, - } - } - } - - pub mod workflow_completion { - use crate::protos::temporal::api::{enums::v1::WorkflowTaskFailedCause, failure}; - tonic::include_proto!("coresdk.workflow_completion"); - - impl workflow_activation_completion::Status { - pub const fn is_success(&self) -> bool { - match &self { - Self::Successful(_) => true, - Self::Failed(_) => false, - } - } - } - - impl From for Failure { - fn from(f: failure::v1::Failure) -> Self { - Failure { - failure: Some(f), - force_cause: WorkflowTaskFailedCause::Unspecified as i32, - } - } - } - } - - pub mod child_workflow { - tonic::include_proto!("coresdk.child_workflow"); - } - - pub mod nexus { - use crate::protos::temporal::api::workflowservice::v1::PollNexusTaskQueueResponse; - use std::fmt::{Display, Formatter}; - - tonic::include_proto!("coresdk.nexus"); - - impl NexusTask { - /// Unwrap the inner server-delivered nexus task if that's what this is, else panic. - pub fn unwrap_task(self) -> PollNexusTaskQueueResponse { - if let Some(nexus_task::Variant::Task(t)) = self.variant { - return t; - } - panic!("Nexus task did not contain a server task"); - } - - /// Get the task token - pub fn task_token(&self) -> &[u8] { - match &self.variant { - Some(nexus_task::Variant::Task(t)) => t.task_token.as_slice(), - Some(nexus_task::Variant::CancelTask(c)) => c.task_token.as_slice(), - None => panic!("Nexus task did not contain a task token"), - } - } - } - - impl Display for nexus_task_completion::Status { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "NexusTaskCompletion(")?; - match self { - nexus_task_completion::Status::Completed(c) => { - write!(f, "{c}") - } - nexus_task_completion::Status::AckCancel(_) => { - write!(f, "AckCancel") - } - #[allow(deprecated)] - nexus_task_completion::Status::Error(error) => { - write!(f, "Error({error:?})") - } - nexus_task_completion::Status::Failure(failure) => { - write!(f, "{failure}") - } - }?; - write!(f, ")") - } - } - - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum NexusOperationErrorState { - Failed, - Canceled, - } - - impl Display for NexusOperationErrorState { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::Failed => write!(f, "failed"), - Self::Canceled => write!(f, "canceled"), - } - } - } - } - - pub mod workflow_commands { - tonic::include_proto!("coresdk.workflow_commands"); - - use crate::protos::temporal::api::{common::v1::Payloads, enums::v1::QueryResultType}; - use std::fmt::{Display, Formatter}; - - impl Display for WorkflowCommand { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match &self.variant { - None => write!(f, "Empty"), - Some(v) => write!(f, "{v}"), - } - } - } - - impl Display for StartTimer { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "StartTimer({})", self.seq) - } - } - - impl Display for ScheduleActivity { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "ScheduleActivity({}, {})", self.seq, self.activity_type) - } - } - - impl Display for ScheduleLocalActivity { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "ScheduleLocalActivity({}, {})", - self.seq, self.activity_type - ) - } - } - - impl Display for QueryResult { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "RespondToQuery({})", self.query_id) - } - } - - impl Display for RequestCancelActivity { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "RequestCancelActivity({})", self.seq) - } - } - - impl Display for RequestCancelLocalActivity { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "RequestCancelLocalActivity({})", self.seq) - } - } - - impl Display for CancelTimer { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "CancelTimer({})", self.seq) - } - } - - impl Display for CompleteWorkflowExecution { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "CompleteWorkflowExecution") - } - } - - impl Display for FailWorkflowExecution { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "FailWorkflowExecution") - } - } - - impl Display for ContinueAsNewWorkflowExecution { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "ContinueAsNewWorkflowExecution") - } - } - - impl Display for CancelWorkflowExecution { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "CancelWorkflowExecution") - } - } - - impl Display for SetPatchMarker { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "SetPatchMarker({})", self.patch_id) - } - } - - impl Display for StartChildWorkflowExecution { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "StartChildWorkflowExecution({}, {})", - self.seq, self.workflow_type - ) - } - } - - impl Display for RequestCancelExternalWorkflowExecution { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "RequestCancelExternalWorkflowExecution({})", self.seq) - } - } - - impl Display for UpsertWorkflowSearchAttributes { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let keys: Vec<_> = self - .search_attributes - .as_ref() - .map(|sa| sa.indexed_fields.keys().collect()) - .unwrap_or_default(); - write!(f, "UpsertWorkflowSearchAttributes({:?})", keys) - } - } - - impl Display for SignalExternalWorkflowExecution { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "SignalExternalWorkflowExecution({})", self.seq) - } - } - - impl Display for CancelSignalWorkflow { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "CancelSignalWorkflow({})", self.seq) - } - } - - impl Display for CancelChildWorkflowExecution { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "CancelChildWorkflowExecution({})", - self.child_workflow_seq - ) - } - } - - impl Display for ModifyWorkflowProperties { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "ModifyWorkflowProperties(upserted memo keys: {:?})", - self.upserted_memo.as_ref().map(|m| m.fields.keys()) - ) - } - } - - impl Display for UpdateResponse { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "UpdateResponse(protocol_instance_id: {}, response: {:?})", - self.protocol_instance_id, self.response - ) - } - } - - impl Display for ScheduleNexusOperation { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "ScheduleNexusOperation({})", self.seq) - } - } - - impl Display for RequestCancelNexusOperation { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "RequestCancelNexusOperation({})", self.seq) - } - } - - impl QueryResult { - /// Helper to construct the Temporal API query result types. - pub fn into_components(self) -> (String, QueryResultType, Option, String) { - match self { - QueryResult { - variant: Some(query_result::Variant::Succeeded(qs)), - query_id, - } => ( - query_id, - QueryResultType::Answered, - qs.response.map(Into::into), - "".to_string(), - ), - QueryResult { - variant: Some(query_result::Variant::Failed(err)), - query_id, - } => (query_id, QueryResultType::Failed, None, err.message), - QueryResult { - variant: None, - query_id, - } => ( - query_id, - QueryResultType::Failed, - None, - "Query response was empty".to_string(), - ), - } - } - } - } - - pub type HistoryEventId = i64; - - impl From for WorkflowActivationJob { - fn from(a: workflow_activation_job::Variant) -> Self { - Self { variant: Some(a) } - } - } - - impl From> for workflow_completion::Success { - fn from(v: Vec) -> Self { - Self { - commands: v, - used_internal_flags: vec![], - versioning_behavior: VersioningBehavior::Unspecified.into(), - } - } - } - - impl From for WorkflowCommand { - fn from(v: workflow_command::Variant) -> Self { - Self { - variant: Some(v), - user_metadata: None, - } - } - } - - impl workflow_completion::Success { - pub fn from_variants(cmds: Vec) -> Self { - let cmds: Vec<_> = cmds.into_iter().map(|c| c.into()).collect(); - cmds.into() - } - } - - impl WorkflowActivationCompletion { - /// Create a successful activation with no commands in it - pub fn empty(run_id: impl Into) -> Self { - let success = workflow_completion::Success::from_variants(vec![]); - Self { - run_id: run_id.into(), - status: Some(workflow_activation_completion::Status::Successful(success)), - } - } - - /// Create a successful activation from a list of command variants - pub fn from_cmds(run_id: impl Into, cmds: Vec) -> Self { - let success = workflow_completion::Success::from_variants(cmds); - Self { - run_id: run_id.into(), - status: Some(workflow_activation_completion::Status::Successful(success)), - } - } - - /// Create a successful activation from just one command variant - pub fn from_cmd(run_id: impl Into, cmd: workflow_command::Variant) -> Self { - let success = workflow_completion::Success::from_variants(vec![cmd]); - Self { - run_id: run_id.into(), - status: Some(workflow_activation_completion::Status::Successful(success)), - } - } - - pub fn fail( - run_id: impl Into, - failure: Failure, - cause: Option, - ) -> Self { - Self { - run_id: run_id.into(), - status: Some(workflow_activation_completion::Status::Failed( - workflow_completion::Failure { - failure: Some(failure), - force_cause: cause.unwrap_or(WorkflowTaskFailedCause::Unspecified) as i32, - }, - )), - } - } - - /// Returns true if the activation has either a fail, continue, cancel, or complete workflow - /// execution command in it. - pub fn has_execution_ending(&self) -> bool { - self.has_complete_workflow_execution() - || self.has_fail_execution() - || self.has_continue_as_new() - || self.has_cancel_workflow_execution() - } - - /// Returns true if the activation contains a fail workflow execution command - pub fn has_fail_execution(&self) -> bool { - if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { - return s.commands.iter().any(|wfc| { - matches!( - wfc, - WorkflowCommand { - variant: Some(workflow_command::Variant::FailWorkflowExecution(_)), - .. - } - ) - }); - } - false - } - - /// Returns true if the activation contains a cancel workflow execution command - pub fn has_cancel_workflow_execution(&self) -> bool { - if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { - return s.commands.iter().any(|wfc| { - matches!( - wfc, - WorkflowCommand { - variant: Some(workflow_command::Variant::CancelWorkflowExecution(_)), - .. - } - ) - }); - } - false - } - - /// Returns true if the activation contains a continue as new workflow execution command - pub fn has_continue_as_new(&self) -> bool { - if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { - return s.commands.iter().any(|wfc| { - matches!( - wfc, - WorkflowCommand { - variant: Some( - workflow_command::Variant::ContinueAsNewWorkflowExecution(_) - ), - .. - } - ) - }); - } - false - } - - /// Returns true if the activation contains a complete workflow execution command - pub fn has_complete_workflow_execution(&self) -> bool { - self.complete_workflow_execution_value().is_some() - } - - /// Returns the completed execution result value, if any - pub fn complete_workflow_execution_value(&self) -> Option<&Payload> { - if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { - s.commands.iter().find_map(|wfc| match wfc { - WorkflowCommand { - variant: Some(workflow_command::Variant::CompleteWorkflowExecution(v)), - .. - } => v.result.as_ref(), - _ => None, - }) - } else { - None - } - } - - /// Returns true if the activation completion is a success with no commands - pub fn is_empty(&self) -> bool { - if let Some(workflow_activation_completion::Status::Successful(s)) = &self.status { - return s.commands.is_empty(); - } - false - } - - pub fn add_internal_flags(&mut self, patch: u32) { - if let Some(workflow_activation_completion::Status::Successful(s)) = &mut self.status { - s.used_internal_flags.push(patch); - } - } - } - - /// Makes converting outgoing lang commands into [WorkflowActivationCompletion]s easier - pub trait IntoCompletion { - /// The conversion function - fn into_completion(self, run_id: String) -> WorkflowActivationCompletion; - } - - impl IntoCompletion for workflow_command::Variant { - fn into_completion(self, run_id: String) -> WorkflowActivationCompletion { - WorkflowActivationCompletion::from_cmd(run_id, self) - } - } - - impl IntoCompletion for I - where - I: IntoIterator, - V: Into, - { - fn into_completion(self, run_id: String) -> WorkflowActivationCompletion { - let success = self.into_iter().map(Into::into).collect::>().into(); - WorkflowActivationCompletion { - run_id, - status: Some(workflow_activation_completion::Status::Successful(success)), - } - } - } - - impl Display for WorkflowActivationCompletion { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "WorkflowActivationCompletion(run_id: {}, status: ", - &self.run_id - )?; - match &self.status { - None => write!(f, "empty")?, - Some(s) => write!(f, "{s}")?, - }; - write!(f, ")") - } - } - - impl Display for workflow_activation_completion::Status { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - workflow_activation_completion::Status::Successful( - workflow_completion::Success { commands, .. }, - ) => { - write!(f, "Success(")?; - let mut written = 0; - for c in commands { - write!(f, "{c} ")?; - written += 1; - if written >= 10 && written < commands.len() { - write!(f, "... {} more", commands.len() - written)?; - break; - } - } - write!(f, ")") - } - workflow_activation_completion::Status::Failed(_) => { - write!(f, "Failed") - } - } - } - } - - impl ActivityTask { - pub fn start_from_poll_resp(r: PollActivityTaskQueueResponse) -> Self { - let (workflow_id, run_id) = r - .workflow_execution - .map(|we| (we.workflow_id, we.run_id)) - .unwrap_or_default(); - Self { - task_token: r.task_token, - variant: Some(activity_task::activity_task::Variant::Start( - activity_task::Start { - workflow_namespace: r.workflow_namespace, - workflow_type: r.workflow_type.map_or_else(|| "".to_string(), |wt| wt.name), - workflow_execution: Some(WorkflowExecution { - workflow_id, - run_id, - }), - activity_id: r.activity_id, - activity_type: r.activity_type.map_or_else(|| "".to_string(), |at| at.name), - header_fields: r.header.map(Into::into).unwrap_or_default(), - input: Vec::from_payloads(r.input), - heartbeat_details: Vec::from_payloads(r.heartbeat_details), - scheduled_time: r.scheduled_time, - current_attempt_scheduled_time: r.current_attempt_scheduled_time, - started_time: r.started_time, - attempt: r.attempt as u32, - schedule_to_close_timeout: r.schedule_to_close_timeout, - start_to_close_timeout: r.start_to_close_timeout, - heartbeat_timeout: r.heartbeat_timeout, - retry_policy: r.retry_policy.map(fix_retry_policy), - priority: r.priority, - is_local: false, - run_id: r.activity_run_id, - }, - )), - } - } - } - - impl Failure { - pub fn is_timeout(&self) -> Option { - match &self.failure_info { - Some(FailureInfo::TimeoutFailureInfo(ti)) => Some(ti.timeout_type()), - _ => { - if let Some(c) = &self.cause { - c.is_timeout() - } else { - None - } - } - } - } - - pub fn application_failure(message: String, non_retryable: bool) -> Self { - Self { - message, - failure_info: Some(FailureInfo::ApplicationFailureInfo( - ApplicationFailureInfo { - non_retryable, - ..Default::default() - }, - )), - ..Default::default() - } - } - - pub fn application_failure_from_error(ae: anyhow::Error, non_retryable: bool) -> Self { - Self { - failure_info: Some(FailureInfo::ApplicationFailureInfo( - ApplicationFailureInfo { - non_retryable, - ..Default::default() - }, - )), - ..ae.chain() - .rfold(None, |cause, e| { - Some(Self { - message: e.to_string(), - cause: cause.map(Box::new), - ..Default::default() - }) - }) - .unwrap_or_default() - } - } - - pub fn timeout(timeout_type: TimeoutType) -> Self { - Self { - message: "Activity timed out".to_string(), - cause: Some(Box::new(Failure { - message: "Activity timed out".to_string(), - failure_info: Some(FailureInfo::TimeoutFailureInfo(TimeoutFailureInfo { - timeout_type: timeout_type.into(), - ..Default::default() - })), - ..Default::default() - })), - failure_info: Some(FailureInfo::ActivityFailureInfo( - ActivityFailureInfo::default(), - )), - ..Default::default() - } - } - - /// Extracts an ApplicationFailureInfo from a Failure instance if it exists - pub fn maybe_application_failure(&self) -> Option<&ApplicationFailureInfo> { - if let Failure { - failure_info: Some(FailureInfo::ApplicationFailureInfo(f)), - .. - } = self - { - Some(f) - } else { - None - } - } - - // Checks if a failure is an ApplicationFailure with Benign category. - pub fn is_benign_application_failure(&self) -> bool { - self.maybe_application_failure() - .is_some_and(|app_info| app_info.category() == ApplicationErrorCategory::Benign) - } - } - - impl Display for Failure { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "Failure({}, ", self.message)?; - match self.failure_info.as_ref() { - None => write!(f, "missing info")?, - Some(FailureInfo::TimeoutFailureInfo(v)) => { - write!(f, "Timeout: {:?}", v.timeout_type())?; - } - Some(FailureInfo::ApplicationFailureInfo(v)) => { - write!(f, "Application Failure: {}", v.r#type)?; - } - Some(FailureInfo::CanceledFailureInfo(_)) => { - write!(f, "Cancelled")?; - } - Some(FailureInfo::TerminatedFailureInfo(_)) => { - write!(f, "Terminated")?; - } - Some(FailureInfo::ServerFailureInfo(_)) => { - write!(f, "Server Failure")?; - } - Some(FailureInfo::ResetWorkflowFailureInfo(_)) => { - write!(f, "Reset Workflow")?; - } - Some(FailureInfo::ActivityFailureInfo(v)) => { - write!( - f, - "Activity Failure: scheduled_event_id: {}", - v.scheduled_event_id - )?; - } - Some(FailureInfo::ChildWorkflowExecutionFailureInfo(v)) => { - write!( - f, - "Child Workflow: started_event_id: {}", - v.started_event_id - )?; - } - Some(FailureInfo::NexusOperationExecutionFailureInfo(v)) => { - write!( - f, - "Nexus Operation Failure: scheduled_event_id: {}", - v.scheduled_event_id - )?; - } - Some(FailureInfo::NexusHandlerFailureInfo(v)) => { - write!(f, "Nexus Handler Failure: {}", v.r#type)?; - } - } - write!(f, ")") - } - } - - impl From<&str> for Failure { - fn from(v: &str) -> Self { - Failure::application_failure(v.to_string(), false) - } - } - - impl From for Failure { - fn from(v: String) -> Self { - Failure::application_failure(v, false) - } - } - - impl From for Failure { - fn from(ae: anyhow::Error) -> Self { - Failure::application_failure_from_error(ae, false) - } - } - - pub trait FromPayloadsExt { - fn from_payloads(p: Option) -> Self; - } - impl FromPayloadsExt for T - where - T: FromIterator, - { - fn from_payloads(p: Option) -> Self { - match p { - None => std::iter::empty().collect(), - Some(p) => p.payloads.into_iter().collect(), - } - } - } - - pub trait IntoPayloadsExt { - fn into_payloads(self) -> Option; - } - impl IntoPayloadsExt for T - where - T: IntoIterator, - { - fn into_payloads(self) -> Option { - let mut iterd = self.into_iter().peekable(); - if iterd.peek().is_none() { - None - } else { - Some(Payloads { - payloads: iterd.collect(), - }) - } - } - } - - impl From for Payloads { - fn from(p: Payload) -> Self { - Self { payloads: vec![p] } - } - } - - impl From for Payloads - where - T: AsRef<[u8]>, - { - fn from(v: T) -> Self { - Self { - payloads: vec![v.into()], - } - } - } - - #[derive(thiserror::Error, Debug)] - pub enum PayloadDeserializeErr { - /// This deserializer does not handle this type of payload. Allows composing multiple - /// deserializers. - #[error("This deserializer does not understand this payload")] - DeserializerDoesNotHandle, - #[error("Error during deserialization: {0}")] - DeserializeErr(#[from] anyhow::Error), - } - - // TODO: Once the prototype SDK is un-prototyped this serialization will need to be compat with - // other SDKs (given they might execute an activity). - pub trait AsJsonPayloadExt { - fn as_json_payload(&self) -> anyhow::Result; - } - impl AsJsonPayloadExt for T - where - T: Serialize, - { - fn as_json_payload(&self) -> anyhow::Result { - let as_json = serde_json::to_string(self)?; - let mut metadata = HashMap::new(); - metadata.insert( - ENCODING_PAYLOAD_KEY.to_string(), - JSON_ENCODING_VAL.as_bytes().to_vec(), - ); - Ok(Payload { - metadata, - data: as_json.into_bytes(), - external_payloads: Default::default(), - }) - } - } - - pub trait FromJsonPayloadExt: Sized { - fn from_json_payload(payload: &Payload) -> Result; - } - impl FromJsonPayloadExt for T - where - T: for<'de> Deserialize<'de>, - { - fn from_json_payload(payload: &Payload) -> Result { - if !payload.is_json_payload() { - return Err(PayloadDeserializeErr::DeserializerDoesNotHandle); - } - let payload_str = std::str::from_utf8(&payload.data).map_err(anyhow::Error::from)?; - Ok(serde_json::from_str(payload_str).map_err(anyhow::Error::from)?) - } - } - - /// Errors when converting from a [Payloads] api proto to our internal [Payload] - #[derive(derive_more::Display, Debug)] - pub enum PayloadsToPayloadError { - MoreThanOnePayload, - NoPayload, - } - impl TryFrom for Payload { - type Error = PayloadsToPayloadError; - - fn try_from(mut v: Payloads) -> Result { - match v.payloads.pop() { - None => Err(PayloadsToPayloadError::NoPayload), - Some(p) => { - if v.payloads.is_empty() { - Ok(p) - } else { - Err(PayloadsToPayloadError::MoreThanOnePayload) - } - } - } - } - } - - /// If initial_interval is missing, fills it with zero value to prevent crashes - /// (lang assumes that RetryPolicy always has initial_interval set). - fn fix_retry_policy(mut retry_policy: RetryPolicy) -> RetryPolicy { - if retry_policy.initial_interval.is_none() { - retry_policy.initial_interval = Default::default(); - } - retry_policy - } -} - -// No need to lint these -#[allow( - clippy::all, - missing_docs, - rustdoc::broken_intra_doc_links, - rustdoc::bare_urls -)] -// This is disgusting, but unclear to me how to avoid it. TODO: Discuss w/ prost maintainer -pub mod temporal { - pub mod api { - pub mod activity { - pub mod v1 { - tonic::include_proto!("temporal.api.activity.v1"); - } - } - pub mod batch { - pub mod v1 { - tonic::include_proto!("temporal.api.batch.v1"); - } - } - pub mod callback { - pub mod v1 { - tonic::include_proto!("temporal.api.callback.v1"); - } - } - pub mod command { - pub mod v1 { - tonic::include_proto!("temporal.api.command.v1"); - - use crate::protos::{ - coresdk::{IntoPayloadsExt, workflow_commands}, - temporal::api::{ - common::v1::{ActivityType, WorkflowType}, - enums::v1::CommandType, - }, - }; - use command::Attributes; - use std::fmt::{Display, Formatter}; - - impl From for Command { - fn from(c: command::Attributes) -> Self { - match c { - a @ Attributes::StartTimerCommandAttributes(_) => Self { - command_type: CommandType::StartTimer as i32, - attributes: Some(a), - user_metadata: Default::default(), - }, - a @ Attributes::CancelTimerCommandAttributes(_) => Self { - command_type: CommandType::CancelTimer as i32, - attributes: Some(a), - user_metadata: Default::default(), - }, - a @ Attributes::CompleteWorkflowExecutionCommandAttributes(_) => Self { - command_type: CommandType::CompleteWorkflowExecution as i32, - attributes: Some(a), - user_metadata: Default::default(), - }, - a @ Attributes::FailWorkflowExecutionCommandAttributes(_) => Self { - command_type: CommandType::FailWorkflowExecution as i32, - attributes: Some(a), - user_metadata: Default::default(), - }, - a @ Attributes::ScheduleActivityTaskCommandAttributes(_) => Self { - command_type: CommandType::ScheduleActivityTask as i32, - attributes: Some(a), - user_metadata: Default::default(), - }, - a @ Attributes::RequestCancelActivityTaskCommandAttributes(_) => Self { - command_type: CommandType::RequestCancelActivityTask as i32, - attributes: Some(a), - user_metadata: Default::default(), - }, - a @ Attributes::ContinueAsNewWorkflowExecutionCommandAttributes(_) => { - Self { - command_type: CommandType::ContinueAsNewWorkflowExecution - as i32, - attributes: Some(a), - user_metadata: Default::default(), - } - } - a @ Attributes::CancelWorkflowExecutionCommandAttributes(_) => Self { - command_type: CommandType::CancelWorkflowExecution as i32, - attributes: Some(a), - user_metadata: Default::default(), - }, - a @ Attributes::RecordMarkerCommandAttributes(_) => Self { - command_type: CommandType::RecordMarker as i32, - attributes: Some(a), - user_metadata: Default::default(), - }, - a @ Attributes::ProtocolMessageCommandAttributes(_) => Self { - command_type: CommandType::ProtocolMessage as i32, - attributes: Some(a), - user_metadata: Default::default(), - }, - a @ Attributes::RequestCancelNexusOperationCommandAttributes(_) => { - Self { - command_type: CommandType::RequestCancelNexusOperation as i32, - attributes: Some(a), - user_metadata: Default::default(), - } - } - _ => unimplemented!(), - } - } - } - - impl Display for Command { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let ct = CommandType::try_from(self.command_type) - .unwrap_or(CommandType::Unspecified); - write!(f, "{:?}", ct) - } - } - - pub trait CommandAttributesExt { - fn as_type(&self) -> CommandType; - } - - impl CommandAttributesExt for command::Attributes { - fn as_type(&self) -> CommandType { - match self { - Attributes::ScheduleActivityTaskCommandAttributes(_) => { - CommandType::ScheduleActivityTask - } - Attributes::StartTimerCommandAttributes(_) => CommandType::StartTimer, - Attributes::CompleteWorkflowExecutionCommandAttributes(_) => { - CommandType::CompleteWorkflowExecution - } - Attributes::FailWorkflowExecutionCommandAttributes(_) => { - CommandType::FailWorkflowExecution - } - Attributes::RequestCancelActivityTaskCommandAttributes(_) => { - CommandType::RequestCancelActivityTask - } - Attributes::CancelTimerCommandAttributes(_) => CommandType::CancelTimer, - Attributes::CancelWorkflowExecutionCommandAttributes(_) => { - CommandType::CancelWorkflowExecution - } - Attributes::RequestCancelExternalWorkflowExecutionCommandAttributes( - _, - ) => CommandType::RequestCancelExternalWorkflowExecution, - Attributes::RecordMarkerCommandAttributes(_) => { - CommandType::RecordMarker - } - Attributes::ContinueAsNewWorkflowExecutionCommandAttributes(_) => { - CommandType::ContinueAsNewWorkflowExecution - } - Attributes::StartChildWorkflowExecutionCommandAttributes(_) => { - CommandType::StartChildWorkflowExecution - } - Attributes::SignalExternalWorkflowExecutionCommandAttributes(_) => { - CommandType::SignalExternalWorkflowExecution - } - Attributes::UpsertWorkflowSearchAttributesCommandAttributes(_) => { - CommandType::UpsertWorkflowSearchAttributes - } - Attributes::ProtocolMessageCommandAttributes(_) => { - CommandType::ProtocolMessage - } - Attributes::ModifyWorkflowPropertiesCommandAttributes(_) => { - CommandType::ModifyWorkflowProperties - } - Attributes::ScheduleNexusOperationCommandAttributes(_) => { - CommandType::ScheduleNexusOperation - } - Attributes::RequestCancelNexusOperationCommandAttributes(_) => { - CommandType::RequestCancelNexusOperation - } - } - } - } - - impl Display for command::Attributes { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self.as_type()) - } - } - - impl From for command::Attributes { - fn from(s: workflow_commands::StartTimer) -> Self { - Self::StartTimerCommandAttributes(StartTimerCommandAttributes { - timer_id: s.seq.to_string(), - start_to_fire_timeout: s.start_to_fire_timeout, - }) - } - } - - impl From for command::Attributes { - fn from(s: workflow_commands::UpsertWorkflowSearchAttributes) -> Self { - Self::UpsertWorkflowSearchAttributesCommandAttributes( - UpsertWorkflowSearchAttributesCommandAttributes { - search_attributes: s.search_attributes, - }, - ) - } - } - - impl From for command::Attributes { - fn from(s: workflow_commands::ModifyWorkflowProperties) -> Self { - Self::ModifyWorkflowPropertiesCommandAttributes( - ModifyWorkflowPropertiesCommandAttributes { - upserted_memo: s.upserted_memo.map(Into::into), - }, - ) - } - } - - impl From for command::Attributes { - fn from(s: workflow_commands::CancelTimer) -> Self { - Self::CancelTimerCommandAttributes(CancelTimerCommandAttributes { - timer_id: s.seq.to_string(), - }) - } - } - - pub fn schedule_activity_cmd_to_api( - s: workflow_commands::ScheduleActivity, - use_workflow_build_id: bool, - ) -> command::Attributes { - command::Attributes::ScheduleActivityTaskCommandAttributes( - ScheduleActivityTaskCommandAttributes { - activity_id: s.activity_id, - activity_type: Some(ActivityType { - name: s.activity_type, - }), - task_queue: Some(s.task_queue.into()), - header: Some(s.headers.into()), - input: s.arguments.into_payloads(), - schedule_to_close_timeout: s.schedule_to_close_timeout, - schedule_to_start_timeout: s.schedule_to_start_timeout, - start_to_close_timeout: s.start_to_close_timeout, - heartbeat_timeout: s.heartbeat_timeout, - retry_policy: s.retry_policy.map(Into::into), - request_eager_execution: !s.do_not_eagerly_execute, - use_workflow_build_id, - priority: s.priority, - }, - ) - } - - #[allow(deprecated)] - pub fn start_child_workflow_cmd_to_api( - s: workflow_commands::StartChildWorkflowExecution, - inherit_build_id: bool, - ) -> command::Attributes { - command::Attributes::StartChildWorkflowExecutionCommandAttributes( - StartChildWorkflowExecutionCommandAttributes { - workflow_id: s.workflow_id, - workflow_type: Some(WorkflowType { - name: s.workflow_type, - }), - control: "".into(), - namespace: s.namespace, - task_queue: Some(s.task_queue.into()), - header: Some(s.headers.into()), - memo: Some(s.memo.into()), - search_attributes: s.search_attributes, - input: s.input.into_payloads(), - workflow_id_reuse_policy: s.workflow_id_reuse_policy, - workflow_execution_timeout: s.workflow_execution_timeout, - workflow_run_timeout: s.workflow_run_timeout, - workflow_task_timeout: s.workflow_task_timeout, - retry_policy: s.retry_policy.map(Into::into), - cron_schedule: s.cron_schedule.clone(), - parent_close_policy: s.parent_close_policy, - inherit_build_id, - priority: s.priority, - }, - ) - } - - impl From for command::Attributes { - fn from(c: workflow_commands::CompleteWorkflowExecution) -> Self { - Self::CompleteWorkflowExecutionCommandAttributes( - CompleteWorkflowExecutionCommandAttributes { - result: c.result.map(Into::into), - }, - ) - } - } - - impl From for command::Attributes { - fn from(c: workflow_commands::FailWorkflowExecution) -> Self { - Self::FailWorkflowExecutionCommandAttributes( - FailWorkflowExecutionCommandAttributes { - failure: c.failure.map(Into::into), - }, - ) - } - } - - #[allow(deprecated)] - pub fn continue_as_new_cmd_to_api( - c: workflow_commands::ContinueAsNewWorkflowExecution, - inherit_build_id: bool, - ) -> command::Attributes { - command::Attributes::ContinueAsNewWorkflowExecutionCommandAttributes( - ContinueAsNewWorkflowExecutionCommandAttributes { - workflow_type: Some(c.workflow_type.into()), - task_queue: Some(c.task_queue.into()), - input: c.arguments.into_payloads(), - workflow_run_timeout: c.workflow_run_timeout, - workflow_task_timeout: c.workflow_task_timeout, - memo: if c.memo.is_empty() { - None - } else { - Some(c.memo.into()) - }, - header: if c.headers.is_empty() { - None - } else { - Some(c.headers.into()) - }, - retry_policy: c.retry_policy, - search_attributes: c.search_attributes, - inherit_build_id, - initial_versioning_behavior: c.initial_versioning_behavior, - ..Default::default() - }, - ) - } - - impl From for command::Attributes { - fn from(_c: workflow_commands::CancelWorkflowExecution) -> Self { - Self::CancelWorkflowExecutionCommandAttributes( - CancelWorkflowExecutionCommandAttributes { details: None }, - ) - } - } - - impl From for command::Attributes { - fn from(c: workflow_commands::ScheduleNexusOperation) -> Self { - Self::ScheduleNexusOperationCommandAttributes( - ScheduleNexusOperationCommandAttributes { - endpoint: c.endpoint, - service: c.service, - operation: c.operation, - input: c.input, - schedule_to_close_timeout: c.schedule_to_close_timeout, - schedule_to_start_timeout: c.schedule_to_start_timeout, - start_to_close_timeout: c.start_to_close_timeout, - nexus_header: c.nexus_header, - }, - ) - } - } - } - } - #[allow(rustdoc::invalid_html_tags)] - pub mod cloud { - pub mod account { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.account.v1"); - } - } - pub mod auditlog { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.auditlog.v1"); - } - } - pub mod billing { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.billing.v1"); - } - } - pub mod cloudservice { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.cloudservice.v1"); - } - } - pub mod connectivityrule { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.connectivityrule.v1"); - } - } - pub mod identity { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.identity.v1"); - } - } - pub mod namespace { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.namespace.v1"); - } - } - pub mod nexus { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.nexus.v1"); - } - } - pub mod operation { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.operation.v1"); - } - } - pub mod region { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.region.v1"); - } - } - pub mod resource { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.resource.v1"); - } - } - pub mod sink { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.sink.v1"); - } - } - pub mod usage { - pub mod v1 { - tonic::include_proto!("temporal.api.cloud.usage.v1"); - } - } - } - pub mod common { - pub mod v1 { - use crate::protos::{ENCODING_PAYLOAD_KEY, JSON_ENCODING_VAL}; - use base64::{Engine, prelude::BASE64_STANDARD}; - use std::{ - collections::HashMap, - fmt::{Display, Formatter}, - }; - include_proto_with_serde!("temporal.api.common.v1"); - - impl From for Payload - where - T: AsRef<[u8]>, - { - fn from(v: T) -> Self { - // TODO: Set better encodings, whole data converter deal. Setting anything - // for now at least makes it show up in the web UI. - let mut metadata = HashMap::new(); - metadata.insert(ENCODING_PAYLOAD_KEY.to_string(), b"binary/plain".to_vec()); - Self { - metadata, - data: v.as_ref().to_vec(), - external_payloads: Default::default(), - } - } - } - - impl Payload { - // Is its own function b/c asref causes implementation conflicts - pub fn as_slice(&self) -> &[u8] { - self.data.as_slice() - } - - pub fn is_json_payload(&self) -> bool { - self.metadata - .get(ENCODING_PAYLOAD_KEY) - .map(|v| v.as_slice() == JSON_ENCODING_VAL.as_bytes()) - .unwrap_or_default() - } - } - - impl std::fmt::Debug for Payload { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - if std::env::var("TEMPORAL_PRINT_FULL_PAYLOADS").is_err() - && self.data.len() > 64 - { - let mut windows = self.data.as_slice().windows(32); - write!( - f, - "[{}..{}]", - BASE64_STANDARD.encode(windows.next().unwrap_or_default()), - BASE64_STANDARD.encode(windows.next_back().unwrap_or_default()) - ) - } else { - write!(f, "[{}]", BASE64_STANDARD.encode(&self.data)) - } - } - } - - impl Display for Payload { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) - } - } - - impl Display for Header { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "Header(")?; - for kv in &self.fields { - write!(f, "{}: ", kv.0)?; - write!(f, "{}, ", kv.1)?; - } - write!(f, ")") - } - } - - impl From
for HashMap { - fn from(h: Header) -> Self { - h.fields.into_iter().map(|(k, v)| (k, v.into())).collect() - } - } - - impl From for HashMap { - fn from(h: Memo) -> Self { - h.fields.into_iter().map(|(k, v)| (k, v.into())).collect() - } - } - - impl From for HashMap { - fn from(h: SearchAttributes) -> Self { - h.indexed_fields - .into_iter() - .map(|(k, v)| (k, v.into())) - .collect() - } - } - - impl From> for SearchAttributes { - fn from(h: HashMap) -> Self { - Self { - indexed_fields: h.into_iter().map(|(k, v)| (k, v.into())).collect(), - } - } - } - - impl From for ActivityType { - fn from(name: String) -> Self { - Self { name } - } - } - - impl From<&str> for ActivityType { - fn from(name: &str) -> Self { - Self { - name: name.to_string(), - } - } - } - - impl From for String { - fn from(at: ActivityType) -> Self { - at.name - } - } - - impl From<&str> for WorkflowType { - fn from(v: &str) -> Self { - Self { - name: v.to_string(), - } - } - } - } - } - pub mod compute { - pub mod v1 { - tonic::include_proto!("temporal.api.compute.v1"); - } - } - pub mod deployment { - pub mod v1 { - tonic::include_proto!("temporal.api.deployment.v1"); - } - } - pub mod enums { - pub mod v1 { - include_proto_with_serde!("temporal.api.enums.v1"); - } - } - pub mod errordetails { - pub mod v1 { - tonic::include_proto!("temporal.api.errordetails.v1"); - } - } - pub mod failure { - pub mod v1 { - include_proto_with_serde!("temporal.api.failure.v1"); - } - } - pub mod filter { - pub mod v1 { - tonic::include_proto!("temporal.api.filter.v1"); - } - } - pub mod history { - pub mod v1 { - use crate::protos::temporal::api::{ - enums::v1::EventType, history::v1::history_event::Attributes, - }; - use anyhow::bail; - use std::fmt::{Display, Formatter}; - - tonic::include_proto!("temporal.api.history.v1"); - - impl History { - pub fn extract_run_id_from_start(&self) -> Result<&str, anyhow::Error> { - extract_original_run_id_from_events(&self.events) - } - - /// Returns the event id of the final event in the history. Will return 0 if - /// there are no events. - pub fn last_event_id(&self) -> i64 { - self.events.last().map(|e| e.event_id).unwrap_or_default() - } - } - - pub fn extract_original_run_id_from_events( - events: &[HistoryEvent], - ) -> Result<&str, anyhow::Error> { - if let Some(Attributes::WorkflowExecutionStartedEventAttributes(wes)) = - events.get(0).and_then(|x| x.attributes.as_ref()) - { - Ok(&wes.original_execution_run_id) - } else { - bail!("First event is not WorkflowExecutionStarted?!?") - } - } - - impl HistoryEvent { - /// Returns true if this is an event created to mirror a command - pub fn is_command_event(&self) -> bool { - EventType::try_from(self.event_type).map_or(false, |et| match et { - EventType::ActivityTaskScheduled - | EventType::ActivityTaskCancelRequested - | EventType::MarkerRecorded - | EventType::RequestCancelExternalWorkflowExecutionInitiated - | EventType::SignalExternalWorkflowExecutionInitiated - | EventType::StartChildWorkflowExecutionInitiated - | EventType::TimerCanceled - | EventType::TimerStarted - | EventType::UpsertWorkflowSearchAttributes - | EventType::WorkflowPropertiesModified - | EventType::NexusOperationScheduled - | EventType::NexusOperationCancelRequested - | EventType::WorkflowExecutionCanceled - | EventType::WorkflowExecutionCompleted - | EventType::WorkflowExecutionContinuedAsNew - | EventType::WorkflowExecutionFailed - | EventType::WorkflowExecutionUpdateAccepted - | EventType::WorkflowExecutionUpdateRejected - | EventType::WorkflowExecutionUpdateCompleted => true, - _ => false, - }) - } - - /// Returns the command's initiating event id, if present. This is the id of the - /// event which "started" the command. Usually, the "scheduled" event for the - /// command. - pub fn get_initial_command_event_id(&self) -> Option { - self.attributes.as_ref().and_then(|a| { - // Fun! Not really any way to make this better w/o incompatibly changing - // protos. - match a { - Attributes::ActivityTaskStartedEventAttributes(a) => - Some(a.scheduled_event_id), - Attributes::ActivityTaskCompletedEventAttributes(a) => - Some(a.scheduled_event_id), - Attributes::ActivityTaskFailedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::ActivityTaskTimedOutEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::ActivityTaskCancelRequestedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::ActivityTaskCanceledEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::TimerFiredEventAttributes(a) => Some(a.started_event_id), - Attributes::TimerCanceledEventAttributes(a) => Some(a.started_event_id), - Attributes::RequestCancelExternalWorkflowExecutionFailedEventAttributes(a) => Some(a.initiated_event_id), - Attributes::ExternalWorkflowExecutionCancelRequestedEventAttributes(a) => Some(a.initiated_event_id), - Attributes::StartChildWorkflowExecutionFailedEventAttributes(a) => Some(a.initiated_event_id), - Attributes::ChildWorkflowExecutionStartedEventAttributes(a) => Some(a.initiated_event_id), - Attributes::ChildWorkflowExecutionCompletedEventAttributes(a) => Some(a.initiated_event_id), - Attributes::ChildWorkflowExecutionFailedEventAttributes(a) => Some(a.initiated_event_id), - Attributes::ChildWorkflowExecutionCanceledEventAttributes(a) => Some(a.initiated_event_id), - Attributes::ChildWorkflowExecutionTimedOutEventAttributes(a) => Some(a.initiated_event_id), - Attributes::ChildWorkflowExecutionTerminatedEventAttributes(a) => Some(a.initiated_event_id), - Attributes::SignalExternalWorkflowExecutionFailedEventAttributes(a) => Some(a.initiated_event_id), - Attributes::ExternalWorkflowExecutionSignaledEventAttributes(a) => Some(a.initiated_event_id), - Attributes::WorkflowTaskStartedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::WorkflowTaskCompletedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::WorkflowTaskTimedOutEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::WorkflowTaskFailedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::NexusOperationStartedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::NexusOperationCompletedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::NexusOperationFailedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::NexusOperationTimedOutEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::NexusOperationCanceledEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::NexusOperationCancelRequestedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::NexusOperationCancelRequestCompletedEventAttributes(a) => Some(a.scheduled_event_id), - Attributes::NexusOperationCancelRequestFailedEventAttributes(a) => Some(a.scheduled_event_id), - _ => None - } - }) - } - - /// Return the event's associated protocol instance, if one exists. - pub fn get_protocol_instance_id(&self) -> Option<&str> { - self.attributes.as_ref().and_then(|a| match a { - Attributes::WorkflowExecutionUpdateAcceptedEventAttributes(a) => { - Some(a.protocol_instance_id.as_str()) - } - _ => None, - }) - } - - /// Returns true if the event is one which would end a workflow - pub fn is_final_wf_execution_event(&self) -> bool { - match self.event_type() { - EventType::WorkflowExecutionCompleted => true, - EventType::WorkflowExecutionCanceled => true, - EventType::WorkflowExecutionFailed => true, - EventType::WorkflowExecutionTimedOut => true, - EventType::WorkflowExecutionContinuedAsNew => true, - EventType::WorkflowExecutionTerminated => true, - _ => false, - } - } - - pub fn is_wft_closed_event(&self) -> bool { - match self.event_type() { - EventType::WorkflowTaskCompleted => true, - EventType::WorkflowTaskFailed => true, - EventType::WorkflowTaskTimedOut => true, - _ => false, - } - } - - pub fn is_ignorable(&self) -> bool { - if !self.worker_may_ignore { - return false; - } - // Never add a catch-all case to this match statement. We need to explicitly - // mark any new event types as ignorable or not. - if let Some(a) = self.attributes.as_ref() { - match a { - Attributes::WorkflowExecutionStartedEventAttributes(_) => false, - Attributes::WorkflowExecutionCompletedEventAttributes(_) => false, - Attributes::WorkflowExecutionFailedEventAttributes(_) => false, - Attributes::WorkflowExecutionTimedOutEventAttributes(_) => false, - Attributes::WorkflowTaskScheduledEventAttributes(_) => false, - Attributes::WorkflowTaskStartedEventAttributes(_) => false, - Attributes::WorkflowTaskCompletedEventAttributes(_) => false, - Attributes::WorkflowTaskTimedOutEventAttributes(_) => false, - Attributes::WorkflowTaskFailedEventAttributes(_) => false, - Attributes::ActivityTaskScheduledEventAttributes(_) => false, - Attributes::ActivityTaskStartedEventAttributes(_) => false, - Attributes::ActivityTaskCompletedEventAttributes(_) => false, - Attributes::ActivityTaskFailedEventAttributes(_) => false, - Attributes::ActivityTaskTimedOutEventAttributes(_) => false, - Attributes::TimerStartedEventAttributes(_) => false, - Attributes::TimerFiredEventAttributes(_) => false, - Attributes::ActivityTaskCancelRequestedEventAttributes(_) => false, - Attributes::ActivityTaskCanceledEventAttributes(_) => false, - Attributes::TimerCanceledEventAttributes(_) => false, - Attributes::MarkerRecordedEventAttributes(_) => false, - Attributes::WorkflowExecutionSignaledEventAttributes(_) => false, - Attributes::WorkflowExecutionTerminatedEventAttributes(_) => false, - Attributes::WorkflowExecutionCancelRequestedEventAttributes(_) => false, - Attributes::WorkflowExecutionCanceledEventAttributes(_) => false, - Attributes::RequestCancelExternalWorkflowExecutionInitiatedEventAttributes(_) => false, - Attributes::RequestCancelExternalWorkflowExecutionFailedEventAttributes(_) => false, - Attributes::ExternalWorkflowExecutionCancelRequestedEventAttributes(_) => false, - Attributes::WorkflowExecutionContinuedAsNewEventAttributes(_) => false, - Attributes::StartChildWorkflowExecutionInitiatedEventAttributes(_) => false, - Attributes::StartChildWorkflowExecutionFailedEventAttributes(_) => false, - Attributes::ChildWorkflowExecutionStartedEventAttributes(_) => false, - Attributes::ChildWorkflowExecutionCompletedEventAttributes(_) => false, - Attributes::ChildWorkflowExecutionFailedEventAttributes(_) => false, - Attributes::ChildWorkflowExecutionCanceledEventAttributes(_) => false, - Attributes::ChildWorkflowExecutionTimedOutEventAttributes(_) => false, - Attributes::ChildWorkflowExecutionTerminatedEventAttributes(_) => false, - Attributes::SignalExternalWorkflowExecutionInitiatedEventAttributes(_) => false, - Attributes::SignalExternalWorkflowExecutionFailedEventAttributes(_) => false, - Attributes::ExternalWorkflowExecutionSignaledEventAttributes(_) => false, - Attributes::UpsertWorkflowSearchAttributesEventAttributes(_) => false, - Attributes::WorkflowExecutionUpdateAcceptedEventAttributes(_) => false, - Attributes::WorkflowExecutionUpdateRejectedEventAttributes(_) => false, - Attributes::WorkflowExecutionUpdateCompletedEventAttributes(_) => false, - Attributes::WorkflowPropertiesModifiedExternallyEventAttributes(_) => false, - Attributes::ActivityPropertiesModifiedExternallyEventAttributes(_) => false, - Attributes::WorkflowPropertiesModifiedEventAttributes(_) => false, - Attributes::WorkflowExecutionUpdateAdmittedEventAttributes(_) => false, - Attributes::NexusOperationScheduledEventAttributes(_) => false, - Attributes::NexusOperationStartedEventAttributes(_) => false, - Attributes::NexusOperationCompletedEventAttributes(_) => false, - Attributes::NexusOperationFailedEventAttributes(_) => false, - Attributes::NexusOperationCanceledEventAttributes(_) => false, - Attributes::NexusOperationTimedOutEventAttributes(_) => false, - Attributes::NexusOperationCancelRequestedEventAttributes(_) => false, - // !! Ignorable !! - Attributes::WorkflowExecutionOptionsUpdatedEventAttributes(_) => true, - Attributes::NexusOperationCancelRequestCompletedEventAttributes(_) => false, - Attributes::NexusOperationCancelRequestFailedEventAttributes(_) => false, - // !! Ignorable !! - Attributes::WorkflowExecutionPausedEventAttributes(_) => true, - // !! Ignorable !! - Attributes::WorkflowExecutionUnpausedEventAttributes(_) => true, - // !! Ignorable !! - Attributes::WorkflowExecutionTimeSkippingTransitionedEventAttributes(_) => true, - } - } else { - false - } - } - } - - impl Display for HistoryEvent { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "HistoryEvent(id: {}, {:?})", - self.event_id, - EventType::try_from(self.event_type).unwrap_or_default() - ) - } - } - - impl Attributes { - pub fn event_type(&self) -> EventType { - // I just absolutely _love_ this - match self { - Attributes::WorkflowExecutionStartedEventAttributes(_) => { EventType::WorkflowExecutionStarted } - Attributes::WorkflowExecutionCompletedEventAttributes(_) => { EventType::WorkflowExecutionCompleted } - Attributes::WorkflowExecutionFailedEventAttributes(_) => { EventType::WorkflowExecutionFailed } - Attributes::WorkflowExecutionTimedOutEventAttributes(_) => { EventType::WorkflowExecutionTimedOut } - Attributes::WorkflowTaskScheduledEventAttributes(_) => { EventType::WorkflowTaskScheduled } - Attributes::WorkflowTaskStartedEventAttributes(_) => { EventType::WorkflowTaskStarted } - Attributes::WorkflowTaskCompletedEventAttributes(_) => { EventType::WorkflowTaskCompleted } - Attributes::WorkflowTaskTimedOutEventAttributes(_) => { EventType::WorkflowTaskTimedOut } - Attributes::WorkflowTaskFailedEventAttributes(_) => { EventType::WorkflowTaskFailed } - Attributes::ActivityTaskScheduledEventAttributes(_) => { EventType::ActivityTaskScheduled } - Attributes::ActivityTaskStartedEventAttributes(_) => { EventType::ActivityTaskStarted } - Attributes::ActivityTaskCompletedEventAttributes(_) => { EventType::ActivityTaskCompleted } - Attributes::ActivityTaskFailedEventAttributes(_) => { EventType::ActivityTaskFailed } - Attributes::ActivityTaskTimedOutEventAttributes(_) => { EventType::ActivityTaskTimedOut } - Attributes::TimerStartedEventAttributes(_) => { EventType::TimerStarted } - Attributes::TimerFiredEventAttributes(_) => { EventType::TimerFired } - Attributes::ActivityTaskCancelRequestedEventAttributes(_) => { EventType::ActivityTaskCancelRequested } - Attributes::ActivityTaskCanceledEventAttributes(_) => { EventType::ActivityTaskCanceled } - Attributes::TimerCanceledEventAttributes(_) => { EventType::TimerCanceled } - Attributes::MarkerRecordedEventAttributes(_) => { EventType::MarkerRecorded } - Attributes::WorkflowExecutionSignaledEventAttributes(_) => { EventType::WorkflowExecutionSignaled } - Attributes::WorkflowExecutionTerminatedEventAttributes(_) => { EventType::WorkflowExecutionTerminated } - Attributes::WorkflowExecutionCancelRequestedEventAttributes(_) => { EventType::WorkflowExecutionCancelRequested } - Attributes::WorkflowExecutionCanceledEventAttributes(_) => { EventType::WorkflowExecutionCanceled } - Attributes::RequestCancelExternalWorkflowExecutionInitiatedEventAttributes(_) => { EventType::RequestCancelExternalWorkflowExecutionInitiated } - Attributes::RequestCancelExternalWorkflowExecutionFailedEventAttributes(_) => { EventType::RequestCancelExternalWorkflowExecutionFailed } - Attributes::ExternalWorkflowExecutionCancelRequestedEventAttributes(_) => { EventType::ExternalWorkflowExecutionCancelRequested } - Attributes::WorkflowExecutionContinuedAsNewEventAttributes(_) => { EventType::WorkflowExecutionContinuedAsNew } - Attributes::StartChildWorkflowExecutionInitiatedEventAttributes(_) => { EventType::StartChildWorkflowExecutionInitiated } - Attributes::StartChildWorkflowExecutionFailedEventAttributes(_) => { EventType::StartChildWorkflowExecutionFailed } - Attributes::ChildWorkflowExecutionStartedEventAttributes(_) => { EventType::ChildWorkflowExecutionStarted } - Attributes::ChildWorkflowExecutionCompletedEventAttributes(_) => { EventType::ChildWorkflowExecutionCompleted } - Attributes::ChildWorkflowExecutionFailedEventAttributes(_) => { EventType::ChildWorkflowExecutionFailed } - Attributes::ChildWorkflowExecutionCanceledEventAttributes(_) => { EventType::ChildWorkflowExecutionCanceled } - Attributes::ChildWorkflowExecutionTimedOutEventAttributes(_) => { EventType::ChildWorkflowExecutionTimedOut } - Attributes::ChildWorkflowExecutionTerminatedEventAttributes(_) => { EventType::ChildWorkflowExecutionTerminated } - Attributes::SignalExternalWorkflowExecutionInitiatedEventAttributes(_) => { EventType::SignalExternalWorkflowExecutionInitiated } - Attributes::SignalExternalWorkflowExecutionFailedEventAttributes(_) => { EventType::SignalExternalWorkflowExecutionFailed } - Attributes::ExternalWorkflowExecutionSignaledEventAttributes(_) => { EventType::ExternalWorkflowExecutionSignaled } - Attributes::UpsertWorkflowSearchAttributesEventAttributes(_) => { EventType::UpsertWorkflowSearchAttributes } - Attributes::WorkflowExecutionUpdateAdmittedEventAttributes(_) => { EventType::WorkflowExecutionUpdateAdmitted } - Attributes::WorkflowExecutionUpdateRejectedEventAttributes(_) => { EventType::WorkflowExecutionUpdateRejected } - Attributes::WorkflowExecutionUpdateAcceptedEventAttributes(_) => { EventType::WorkflowExecutionUpdateAccepted } - Attributes::WorkflowExecutionUpdateCompletedEventAttributes(_) => { EventType::WorkflowExecutionUpdateCompleted } - Attributes::WorkflowPropertiesModifiedExternallyEventAttributes(_) => { EventType::WorkflowPropertiesModifiedExternally } - Attributes::ActivityPropertiesModifiedExternallyEventAttributes(_) => { EventType::ActivityPropertiesModifiedExternally } - Attributes::WorkflowPropertiesModifiedEventAttributes(_) => { EventType::WorkflowPropertiesModified } - Attributes::NexusOperationScheduledEventAttributes(_) => { EventType::NexusOperationScheduled } - Attributes::NexusOperationStartedEventAttributes(_) => { EventType::NexusOperationStarted } - Attributes::NexusOperationCompletedEventAttributes(_) => { EventType::NexusOperationCompleted } - Attributes::NexusOperationFailedEventAttributes(_) => { EventType::NexusOperationFailed } - Attributes::NexusOperationCanceledEventAttributes(_) => { EventType::NexusOperationCanceled } - Attributes::NexusOperationTimedOutEventAttributes(_) => { EventType::NexusOperationTimedOut } - Attributes::NexusOperationCancelRequestedEventAttributes(_) => { EventType::NexusOperationCancelRequested } - Attributes::WorkflowExecutionOptionsUpdatedEventAttributes(_) => { EventType::WorkflowExecutionOptionsUpdated } - Attributes::NexusOperationCancelRequestCompletedEventAttributes(_) => { EventType::NexusOperationCancelRequestCompleted } - Attributes::NexusOperationCancelRequestFailedEventAttributes(_) => { EventType::NexusOperationCancelRequestFailed } - Attributes::WorkflowExecutionPausedEventAttributes(_) => { EventType::WorkflowExecutionPaused } - Attributes::WorkflowExecutionUnpausedEventAttributes(_) => { EventType::WorkflowExecutionUnpaused } - Attributes::WorkflowExecutionTimeSkippingTransitionedEventAttributes(_) => { EventType::WorkflowExecutionTimeSkippingTransitioned } - } - } - } - } - } - pub mod namespace { - pub mod v1 { - tonic::include_proto!("temporal.api.namespace.v1"); - } - } - pub mod operatorservice { - pub mod v1 { - tonic::include_proto!("temporal.api.operatorservice.v1"); - } - } - pub mod protocol { - pub mod v1 { - use std::fmt::{Display, Formatter}; - tonic::include_proto!("temporal.api.protocol.v1"); - - impl Display for Message { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "ProtocolMessage({})", self.id) - } - } - } - } - pub mod query { - pub mod v1 { - tonic::include_proto!("temporal.api.query.v1"); - } - } - pub mod replication { - pub mod v1 { - tonic::include_proto!("temporal.api.replication.v1"); - } - } - pub mod rules { - pub mod v1 { - tonic::include_proto!("temporal.api.rules.v1"); - } - } - pub mod schedule { - #[allow(rustdoc::invalid_html_tags)] - pub mod v1 { - tonic::include_proto!("temporal.api.schedule.v1"); - } - } - pub mod sdk { - pub mod v1 { - tonic::include_proto!("temporal.api.sdk.v1"); - } - } - pub mod taskqueue { - pub mod v1 { - use crate::protos::temporal::api::enums::v1::TaskQueueKind; - tonic::include_proto!("temporal.api.taskqueue.v1"); - - impl From for TaskQueue { - fn from(name: String) -> Self { - Self { - name, - kind: TaskQueueKind::Normal as i32, - normal_name: "".to_string(), - } - } - } - } - } - pub mod testservice { - pub mod v1 { - tonic::include_proto!("temporal.api.testservice.v1"); - } - } - pub mod update { - pub mod v1 { - use crate::protos::temporal::api::update::v1::outcome::Value; - tonic::include_proto!("temporal.api.update.v1"); - - impl Outcome { - pub fn is_success(&self) -> bool { - match self.value { - Some(Value::Success(_)) => true, - _ => false, - } - } - } - } - } - pub mod version { - pub mod v1 { - tonic::include_proto!("temporal.api.version.v1"); - } - } - pub mod worker { - pub mod v1 { - tonic::include_proto!("temporal.api.worker.v1"); - } - } - pub mod workflow { - pub mod v1 { - tonic::include_proto!("temporal.api.workflow.v1"); - } - } - pub mod nexus { - pub mod v1 { - use crate::protos::{ - camel_case_to_screaming_snake, - temporal::api::{ - common::{ - self, - v1::link::{WorkflowEvent, workflow_event}, - }, - enums::v1::EventType, - failure, - }, - }; - use anyhow::{anyhow, bail}; - use prost::Name; - use std::{ - collections::HashMap, - fmt::{Display, Formatter}, - }; - use tonic::transport::Uri; - - tonic::include_proto!("temporal.api.nexus.v1"); - - impl Display for Response { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "NexusResponse(",)?; - match &self.variant { - None => {} - Some(v) => { - write!(f, "{v}")?; - } - } - write!(f, ")") - } - } - - impl Display for response::Variant { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - response::Variant::StartOperation(_) => { - write!(f, "StartOperation") - } - response::Variant::CancelOperation(_) => { - write!(f, "CancelOperation") - } - } - } - } - - impl Display for HandlerError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "HandlerError") - } - } - - pub enum NexusTaskFailure { - Legacy(HandlerError), - Temporal(failure::v1::Failure), - } - - static SCHEME_PREFIX: &str = "temporal://"; - - /// Attempt to parse a nexus lint into a workflow event link - pub fn workflow_event_link_from_nexus( - l: &Link, - ) -> Result { - if !l.url.starts_with(SCHEME_PREFIX) { - bail!("Invalid scheme for nexus link: {:?}", l.url); - } - // We strip the scheme/authority portion because of - // https://github.com/hyperium/http/issues/696 - let no_authority_url = l.url.strip_prefix(SCHEME_PREFIX).unwrap(); - let uri = Uri::try_from(no_authority_url)?; - let parts = uri.into_parts(); - let path = parts.path_and_query.ok_or_else(|| { - anyhow!("Failed to parse nexus link, invalid path: {:?}", l) - })?; - let path_parts = path.path().split('/').collect::>(); - if path_parts.get(1) != Some(&"namespaces") { - bail!("Invalid path for nexus link: {:?}", l); - } - let namespace = path_parts.get(2).ok_or_else(|| { - anyhow!("Failed to parse nexus link, no namespace: {:?}", l) - })?; - if path_parts.get(3) != Some(&"workflows") { - bail!("Invalid path for nexus link, no workflows segment: {:?}", l); - } - let workflow_id = path_parts.get(4).ok_or_else(|| { - anyhow!("Failed to parse nexus link, no workflow id: {:?}", l) - })?; - let run_id = path_parts - .get(5) - .ok_or_else(|| anyhow!("Failed to parse nexus link, no run id: {:?}", l))?; - if path_parts.get(6) != Some(&"history") { - bail!("Invalid path for nexus link, no history segment: {:?}", l); - } - let reference = if let Some(query) = path.query() { - let mut eventref = workflow_event::EventReference::default(); - let query_parts = query.split('&').collect::>(); - for qp in query_parts { - let mut kv = qp.split('='); - let key = kv.next().ok_or_else(|| { - anyhow!("Failed to parse nexus link query parameter: {:?}", l) - })?; - let val = kv.next().ok_or_else(|| { - anyhow!("Failed to parse nexus link query parameter: {:?}", l) - })?; - match key { - "eventID" => { - eventref.event_id = val.parse().map_err(|_| { - anyhow!("Failed to parse nexus link event id: {:?}", l) - })?; - } - "eventType" => { - eventref.event_type = EventType::from_str_name(val) - .unwrap_or_else(|| { - EventType::from_str_name( - &("EVENT_TYPE_".to_string() - + &camel_case_to_screaming_snake(val)), - ) - .unwrap_or_default() - }) - .into() - } - _ => continue, - } - } - Some(workflow_event::Reference::EventRef(eventref)) - } else { - None - }; - - Ok(common::v1::Link { - variant: Some(common::v1::link::Variant::WorkflowEvent(WorkflowEvent { - namespace: namespace.to_string(), - workflow_id: workflow_id.to_string(), - run_id: run_id.to_string(), - reference, - })), - }) - } - - impl TryFrom for Failure { - type Error = serde_json::Error; - - fn try_from(mut f: failure::v1::Failure) -> Result { - // 1. Remove message from failure - let message = std::mem::take(&mut f.message); - - // 2. Serialize Failure as JSON - let details = serde_json::to_vec(&f)?; - - // 3. Package Temporal Failure as Nexus Failure - Ok(Failure { - message, - stack_trace: f.stack_trace, - metadata: HashMap::from([( - "type".to_string(), - failure::v1::Failure::full_name().into(), - )]), - details, - cause: None, - }) - } - } - } - } - pub mod workflowservice { - pub mod v1 { - use std::{ - convert::TryInto, - fmt::{Display, Formatter}, - time::{Duration, SystemTime}, - }; - - tonic::include_proto!("temporal.api.workflowservice.v1"); - - macro_rules! sched_to_start_impl { - ($sched_field:ident) => { - /// Return the duration of the task schedule time (current attempt) to its - /// start time if both are set and time went forward. - pub fn sched_to_start(&self) -> Option { - if let Some((sch, st)) = - self.$sched_field.clone().zip(self.started_time.clone()) - { - if let Some(value) = elapsed_between_prost_times(sch, st) { - return value; - } - } - None - } - }; - } - - fn elapsed_between_prost_times( - from: prost_types::Timestamp, - to: prost_types::Timestamp, - ) -> Option> { - let from: Result = from.try_into(); - let to: Result = to.try_into(); - if let (Ok(from), Ok(to)) = (from, to) { - return Some(to.duration_since(from).ok()); - } - None - } - - impl PollWorkflowTaskQueueResponse { - sched_to_start_impl!(scheduled_time); - } - - impl Display for PollWorkflowTaskQueueResponse { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let last_event = self - .history - .as_ref() - .and_then(|h| h.events.last().map(|he| he.event_id)) - .unwrap_or(0); - write!( - f, - "PollWFTQResp(run_id: {}, attempt: {}, last_event: {})", - self.workflow_execution - .as_ref() - .map_or("", |we| we.run_id.as_str()), - self.attempt, - last_event - ) - } - } - - /// Can be used while debugging to avoid filling up a whole screen with poll resps - pub struct CompactHist<'a>(pub &'a PollWorkflowTaskQueueResponse); - impl Display for CompactHist<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - writeln!( - f, - "PollWorkflowTaskQueueResponse (prev_started: {}, started: {})", - self.0.previous_started_event_id, self.0.started_event_id - )?; - if let Some(h) = self.0.history.as_ref() { - for event in &h.events { - writeln!(f, "{}", event)?; - } - } - writeln!(f, "query: {:#?}", self.0.query)?; - writeln!(f, "queries: {:#?}", self.0.queries) - } - } - - impl PollActivityTaskQueueResponse { - sched_to_start_impl!(current_attempt_scheduled_time); - } - - impl PollNexusTaskQueueResponse { - pub fn sched_to_start(&self) -> Option { - if let Some((sch, st)) = self - .request - .as_ref() - .and_then(|r| r.scheduled_time) - .clone() - .zip(SystemTime::now().try_into().ok()) - { - if let Some(value) = elapsed_between_prost_times(sch, st) { - return value; - } - } - None - } - } - - impl QueryWorkflowResponse { - /// Unwrap a successful response as vec of payloads - pub fn unwrap(self) -> Vec { - self.query_result.unwrap().payloads - } - } - } - } - } -} - -#[allow( - clippy::all, - missing_docs, - rustdoc::broken_intra_doc_links, - rustdoc::bare_urls -)] -pub mod google { - pub mod rpc { - tonic::include_proto!("google.rpc"); - } -} - -#[allow( - clippy::all, - missing_docs, - rustdoc::broken_intra_doc_links, - rustdoc::bare_urls -)] -pub mod grpc { - pub mod health { - pub mod v1 { - tonic::include_proto!("grpc.health.v1"); - } - } -} - -/// Case conversion, used for json -> proto enum string conversion -pub fn camel_case_to_screaming_snake(val: &str) -> String { - let mut out = String::new(); - let mut last_was_upper = true; - for c in val.chars() { - if c.is_uppercase() { - if !last_was_upper { - out.push('_'); - } - out.push(c.to_ascii_uppercase()); - last_was_upper = true; - } else { - out.push(c.to_ascii_uppercase()); - last_was_upper = false; - } - } - out -} - -/// Convert a protobuf [`prost_types::Timestamp`] to a [`std::time::SystemTime`]. -pub fn proto_ts_to_system_time(ts: &prost_types::Timestamp) -> Option { - std::time::SystemTime::UNIX_EPOCH - .checked_add(Duration::from_secs(ts.seconds as u64) + Duration::from_nanos(ts.nanos as u64)) -} - -#[cfg(test)] -mod tests { - use crate::protos::{ - coresdk::{activity_task, activity_task::ActivityTask}, - temporal::api::{failure::v1::Failure, workflowservice::v1::PollActivityTaskQueueResponse}, - }; - use anyhow::anyhow; - - #[test] - fn start_from_poll_resp_standalone_activity_populates_run_id() { - let resp = PollActivityTaskQueueResponse { - task_token: vec![1, 2, 3], - activity_run_id: "test-run-id-123".to_string(), - activity_id: "my-activity".to_string(), - ..Default::default() - }; - let task = ActivityTask::start_from_poll_resp(resp); - let start = match task.variant { - Some(activity_task::activity_task::Variant::Start(s)) => s, - _ => panic!("expected Start variant"), - }; - assert_eq!(start.run_id, "test-run-id-123"); - assert!(!start.is_local); - } - - #[test] - fn start_from_poll_resp_workflow_activity_has_empty_run_id() { - use crate::protos::temporal::api::common::v1::WorkflowExecution; - let resp = PollActivityTaskQueueResponse { - task_token: vec![4, 5, 6], - activity_id: "my-workflow-activity".to_string(), - workflow_execution: Some(WorkflowExecution { - workflow_id: "wf-123".to_string(), - run_id: "wf-run-456".to_string(), - }), - // activity_run_id intentionally absent — this is a workflow-scheduled activity - ..Default::default() - }; - let task = ActivityTask::start_from_poll_resp(resp); - let start = match task.variant { - Some(activity_task::activity_task::Variant::Start(s)) => s, - _ => panic!("expected Start variant"), - }; - assert!(start.run_id.is_empty()); - // workflow_execution is preserved and distinct from run_id - assert_eq!(start.workflow_execution.unwrap().run_id, "wf-run-456"); - } - - #[test] - fn anyhow_to_failure_conversion() { - let no_causes: Failure = anyhow!("no causes").into(); - assert_eq!(no_causes.cause, None); - assert_eq!(no_causes.message, "no causes"); - let orig = anyhow!("fail 1"); - let mid = orig.context("fail 2"); - let top = mid.context("fail 3"); - let as_fail: Failure = top.into(); - assert_eq!(as_fail.message, "fail 3"); - assert_eq!(as_fail.cause.as_ref().unwrap().message, "fail 2"); - assert_eq!(as_fail.cause.unwrap().cause.unwrap().message, "fail 1"); - } -} +pub use temporalio_common_wasm::protos::*; + +#[cfg(feature = "test-utilities")] +/// Pre-built test histories for common workflow patterns. +pub mod canned_histories; +#[cfg(feature = "history_builders")] +mod history_builder; +#[cfg(feature = "history_builders")] +mod history_info; +#[cfg(feature = "test-utilities")] +pub mod test_utils; + +#[cfg(feature = "history_builders")] +pub use history_builder::{ + DEFAULT_ACTIVITY_TYPE, DEFAULT_WORKFLOW_TYPE, TestHistoryBuilder, default_act_sched, + default_wes_attribs, +}; +#[cfg(feature = "history_builders")] +pub use history_info::HistoryInfo; diff --git a/crates/common/src/worker.rs b/crates/common/src/worker.rs index 3599f988a..e5d541b29 100644 --- a/crates/common/src/worker.rs +++ b/crates/common/src/worker.rs @@ -1,16 +1,13 @@ //! Contains types that are needed by both the client and the sdk when configuring / interacting //! with workers. -use crate::protos::{ - coresdk, temporal, - temporal::api::enums::v1::{TaskQueueType, VersioningBehavior}, -}; +use crate::protos::temporal::api::enums::v1::{TaskQueueType, VersioningBehavior}; use std::{ fs::File, io::{self, BufReader, Read}, - str::FromStr, sync::OnceLock, }; +pub use temporalio_common_wasm::worker::WorkerDeploymentVersion; /// Specifies which task types a worker will poll for. /// @@ -127,63 +124,6 @@ impl WorkerDeploymentOptions { } } -/// Identifies a specific version of a worker deployment. -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub struct WorkerDeploymentVersion { - /// Name of the deployment - pub deployment_name: String, - /// Build ID for the worker. - pub build_id: String, -} - -impl WorkerDeploymentVersion { - /// Returns true if both the deployment name and build ID are empty. - pub fn is_empty(&self) -> bool { - self.deployment_name.is_empty() && self.build_id.is_empty() - } -} - -impl FromStr for WorkerDeploymentVersion { - type Err = (); - - fn from_str(s: &str) -> Result { - match s.split_once('.') { - Some((name, build_id)) => Ok(WorkerDeploymentVersion { - deployment_name: name.to_owned(), - build_id: build_id.to_owned(), - }), - _ => Err(()), - } - } -} - -impl From for coresdk::common::WorkerDeploymentVersion { - fn from(v: WorkerDeploymentVersion) -> coresdk::common::WorkerDeploymentVersion { - coresdk::common::WorkerDeploymentVersion { - deployment_name: v.deployment_name, - build_id: v.build_id, - } - } -} - -impl From for WorkerDeploymentVersion { - fn from(v: coresdk::common::WorkerDeploymentVersion) -> WorkerDeploymentVersion { - WorkerDeploymentVersion { - deployment_name: v.deployment_name, - build_id: v.build_id, - } - } -} - -impl From for WorkerDeploymentVersion { - fn from(v: temporal::api::deployment::v1::WorkerDeploymentVersion) -> Self { - Self { - deployment_name: v.deployment_name, - build_id: v.build_id, - } - } -} - static CACHED_BUILD_ID: OnceLock = OnceLock::new(); /// Build ID derived from hashing the on-disk bytes of the current executable. diff --git a/crates/macros/src/activities_definitions.rs b/crates/macros/src/activities_definitions.rs index 2d4477300..37399ba73 100644 --- a/crates/macros/src/activities_definitions.rs +++ b/crates/macros/src/activities_definitions.rs @@ -19,6 +19,7 @@ pub(crate) struct ActivitiesDefinition { #[derive(Default)] struct ActivityAttributes { name_override: Option, + definition_path: Option, } struct ActivityMethod { @@ -102,6 +103,11 @@ fn extract_activity_attributes(attrs: &[Attribute]) -> syn::Result::name() } + } else if let Some(ref name_expr) = activity.attributes.name_override { quote! { #name_expr } } else { let default_name = format!("{}::{}", impl_type_name, activity.method.sig.ident); quote! { #default_name } }; + let definition_assertions = activity + .attributes + .definition_path + .as_ref() + .map(|definition_path| { + quote! { + const _: () = { + trait __TemporalSame {} + impl __TemporalSame for T {} + fn __assert_same_input() + where + A: __TemporalSame, + {} + fn __assert_same_output() + where + A: __TemporalSame, + {} + let _ = __assert_same_input::< + <#module_ident::#struct_ident as ::temporalio_common::ActivityDefinition>::Input, + <#definition_path as ::temporalio_common::ActivityDefinition>::Input, + >; + let _ = __assert_same_output::< + <#module_ident::#struct_ident as ::temporalio_common::ActivityDefinition>::Output, + <#definition_path as ::temporalio_common::ActivityDefinition>::Output, + >; + }; + } + }) + .unwrap_or_default(); let receiver_pattern = if activity.is_static { quote! { _receiver } @@ -545,6 +582,8 @@ impl ActivitiesDefinition { #execute_body } } + + #definition_assertions } } diff --git a/crates/macros/src/activity_definitions.rs b/crates/macros/src/activity_definitions.rs new file mode 100644 index 000000000..0cf786936 --- /dev/null +++ b/crates/macros/src/activity_definitions.rs @@ -0,0 +1,211 @@ +use crate::macro_utils::{ident_to_snake_case, method_name_to_pascal_case}; +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote, quote_spanned}; +use syn::{ + Attribute, FnArg, ItemTrait, ReturnType, TraitItem, Type, + parse::{Parse, ParseStream}, + spanned::Spanned, +}; + +pub(crate) struct ActivityDefinitionsTrait { + trait_item: ItemTrait, + activities: Vec, +} + +#[derive(Default)] +struct ActivityAttributes { + name_override: Option, +} + +struct ActivityDefMethod { + method: syn::TraitItemFn, + attributes: ActivityAttributes, + input_types: Vec, + output_type: Option, +} + +impl Parse for ActivityDefinitionsTrait { + fn parse(input: ParseStream) -> syn::Result { + let trait_item: ItemTrait = input.parse()?; + let mut activities = Vec::new(); + for item in &trait_item.items { + if let TraitItem::Fn(method) = item { + activities.push(parse_activity_def_method(method)?); + } + } + Ok(Self { + trait_item, + activities, + }) + } +} + +fn parse_activity_def_method(method: &syn::TraitItemFn) -> syn::Result { + if method.sig.asyncness.is_some() { + return Err(syn::Error::new_spanned( + &method.sig, + "Activity definitions must not be async; declare only the input/output contract", + )); + } + if matches!(method.sig.inputs.first(), Some(FnArg::Receiver(_))) { + return Err(syn::Error::new_spanned( + &method.sig, + "Activity definitions must not take self; declare only the input/output contract", + )); + } + Ok(ActivityDefMethod { + method: method.clone(), + attributes: extract_activity_attributes(&method.attrs)?, + input_types: method + .sig + .inputs + .iter() + .filter_map(|arg| match arg { + FnArg::Typed(p) => Some((*p.ty).clone()), + FnArg::Receiver(_) => None, + }) + .collect(), + output_type: extract_output_type(&method.sig), + }) +} + +fn extract_activity_attributes(attrs: &[Attribute]) -> syn::Result { + let mut activity_attributes = ActivityAttributes::default(); + + for attr in attrs { + if attr.path().is_ident("activity") && attr.meta.require_list().is_ok() { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("name") { + let value = meta.value()?; + let expr: syn::Expr = value.parse()?; + activity_attributes.name_override = Some(expr); + Ok(()) + } else { + Err(meta.error("unsupported activity attribute")) + } + })?; + } + } + + Ok(activity_attributes) +} + +fn extract_output_type(sig: &syn::Signature) -> Option { + match &sig.output { + ReturnType::Type(_, ty) => Some((**ty).clone()), + ReturnType::Default => None, + } +} + +fn multi_args_input_type(types: &[Type]) -> TokenStream2 { + match types.len() { + 0 => quote! { () }, + 1 => { + let t = &types[0]; + quote! { #t } + } + n => { + let multi_args = format_ident!("MultiArgs{}", n); + let types = types.iter(); + quote! { ::temporalio_workflow::common::data_converters::#multi_args<#(#types),*> } + } + } +} + +impl ActivityDefinitionsTrait { + pub(crate) fn codegen(&self) -> TokenStream { + let trait_ident = &self.trait_item.ident; + let module_ident = format_ident!("{}", ident_to_snake_case(&trait_ident.to_string())); + let module_vis = &self.trait_item.vis; + + let mut cleaned_trait = self.trait_item.clone(); + for item in &mut cleaned_trait.items { + if let TraitItem::Fn(method) = item { + method + .attrs + .retain(|attr| !attr.path().is_ident("activity")); + } + } + + let marker_structs: Vec<_> = self + .activities + .iter() + .map(|activity| { + let struct_ident = + format_ident!("{}", method_name_to_pascal_case(&activity.method.sig.ident)); + quote! { pub struct #struct_ident; } + }) + .collect(); + + let marker_consts: Vec<_> = self + .activities + .iter() + .map(|activity| { + let method_ident = &activity.method.sig.ident; + let struct_ident = + format_ident!("{}", method_name_to_pascal_case(&activity.method.sig.ident)); + let span = activity.method.span(); + quote_spanned! { span=> + #[allow(non_upper_case_globals, dead_code)] + pub const #method_ident: #struct_ident = #struct_ident; + } + }) + .collect(); + + let activity_impls: Vec<_> = self + .activities + .iter() + .map(|activity| { + let method_ident = &activity.method.sig.ident; + let struct_ident = + format_ident!("{}", method_name_to_pascal_case(&activity.method.sig.ident)); + let input_type = multi_args_input_type(&activity.input_types); + let output_type = activity + .output_type + .as_ref() + .map(|t| quote! { #t }) + .unwrap_or(quote! { () }); + let activity_name = if let Some(ref name_expr) = activity.attributes.name_override { + quote! { #name_expr } + } else { + let default_name = format!("{}::{}", trait_ident, method_ident); + quote! { #default_name } + }; + + quote! { + impl #module_ident::#struct_ident { + /// Returns the activity name (delegates to ActivityDefinition::name()). + pub fn name(&self) -> &'static str { + ::name() + } + } + + impl ::temporalio_workflow::common::ActivityDefinition for #module_ident::#struct_ident { + type Input = #input_type; + type Output = #output_type; + + fn name() -> &'static str + where + Self: Sized, + { + #activity_name + } + } + } + }) + .collect(); + + quote! { + #cleaned_trait + + #module_vis mod #module_ident { + #(#marker_structs)* + #(#marker_consts)* + } + + #(#activity_impls)* + } + .into() + } +} diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index b7d2f3f5a..d9eeb1916 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -2,6 +2,7 @@ use proc_macro::TokenStream; use syn::parse_macro_input; mod activities_definitions; +mod activity_definitions; mod fsm_impl; mod macro_utils; mod workflow_definitions; @@ -24,6 +25,17 @@ pub fn activity(_attr: TokenStream, item: TokenStream) -> TokenStream { item } +/// Defines activity markers and shared contracts without providing native implementations. +/// +/// This macro is intended for workflow crates that need typed activity declarations which can be +/// implemented elsewhere by a native worker crate. +#[proc_macro_attribute] +pub fn activity_definitions(_attr: TokenStream, item: TokenStream) -> TokenStream { + let def: activity_definitions::ActivityDefinitionsTrait = + parse_macro_input!(item as activity_definitions::ActivityDefinitionsTrait); + def.codegen() +} + /// Marks a struct as a workflow definition. /// /// This attribute can optionally specify a custom workflow name: diff --git a/crates/macros/src/workflow_definitions.rs b/crates/macros/src/workflow_definitions.rs index b37a47b08..9166fba8c 100644 --- a/crates/macros/src/workflow_definitions.rs +++ b/crates/macros/src/workflow_definitions.rs @@ -660,7 +660,7 @@ impl WorkflowMethodsDefinition { const_definitions.push(generate_const_definition(&run_method.method, &module_ident)); trait_impls.push(quote! { - impl ::temporalio_common::WorkflowDefinition for #module_ident::#struct_ident { + impl ::temporalio_workflow::common::WorkflowDefinition for #module_ident::#struct_ident { type Input = #input_type; type Output = #output_type; @@ -669,11 +669,11 @@ impl WorkflowMethodsDefinition { } } - impl ::temporalio_common::HasWorkflowDefinition for #module_ident::#struct_ident { + impl ::temporalio_workflow::common::HasWorkflowDefinition for #module_ident::#struct_ident { type Run = Self; } - impl ::temporalio_common::WorkflowDefinition for #impl_type { + impl ::temporalio_workflow::common::WorkflowDefinition for #impl_type { type Input = #input_type; type Output = #output_type; @@ -682,7 +682,7 @@ impl WorkflowMethodsDefinition { } } - impl ::temporalio_common::HasWorkflowDefinition for #impl_type { + impl ::temporalio_workflow::common::HasWorkflowDefinition for #impl_type { type Run = #module_ident::#struct_ident; } }); @@ -713,7 +713,6 @@ impl WorkflowMethodsDefinition { let workflow_impl = self.generate_workflow_implementation(impl_type, &module_ident, factory_only); - let implementer_impl = self.generate_workflow_implementer(impl_type); let const_impl = quote! { impl #impl_type { @@ -738,8 +737,6 @@ impl WorkflowMethodsDefinition { #(#trait_impls)* #workflow_impl - - #implementer_impl }; output.into() @@ -763,7 +760,7 @@ impl WorkflowMethodsDefinition { let run_struct_ident = self.run_struct_ident(); let definition_impl = quote! { - impl ::temporalio_common::SignalDefinition for #module_ident::#struct_ident { + impl ::temporalio_workflow::common::SignalDefinition for #module_ident::#struct_ident { type Workflow = #module_ident::#run_struct_ident; type Input = #input_type; @@ -778,10 +775,10 @@ impl WorkflowMethodsDefinition { let handle_body = generate_async_handler_body(impl_type, &info.prefixed_method, has_input); quote! { - impl ::temporalio_sdk::workflows::ExecutableAsyncSignal<#module_ident::#struct_ident> for #impl_type { + impl ::temporalio_workflow::workflows::ExecutableAsyncSignal<#module_ident::#struct_ident> for #impl_type { fn handle( - mut ctx: ::temporalio_sdk::WorkflowContext, - input: <#module_ident::#struct_ident as ::temporalio_common::SignalDefinition>::Input, + mut ctx: ::temporalio_workflow::WorkflowContext, + input: <#module_ident::#struct_ident as ::temporalio_workflow::common::SignalDefinition>::Input, ) -> ::futures_util::future::LocalBoxFuture<'static, ()> { use ::futures_util::FutureExt; #handle_body @@ -791,11 +788,11 @@ impl WorkflowMethodsDefinition { } else { let method_call = generate_method_call(&info.prefixed_method, has_input); quote! { - impl ::temporalio_sdk::workflows::ExecutableSyncSignal<#module_ident::#struct_ident> for #impl_type { + impl ::temporalio_workflow::workflows::ExecutableSyncSignal<#module_ident::#struct_ident> for #impl_type { fn handle( &mut self, - ctx: &mut ::temporalio_sdk::SyncWorkflowContext, - input: <#module_ident::#struct_ident as ::temporalio_common::SignalDefinition>::Input, + ctx: &mut ::temporalio_workflow::SyncWorkflowContext, + input: <#module_ident::#struct_ident as ::temporalio_workflow::common::SignalDefinition>::Input, ) { #method_call } @@ -839,7 +836,7 @@ impl WorkflowMethodsDefinition { let run_struct_ident = self.run_struct_ident(); let trait_impl = quote! { - impl ::temporalio_common::QueryDefinition for #module_ident::#struct_ident { + impl ::temporalio_workflow::common::QueryDefinition for #module_ident::#struct_ident { type Workflow = #module_ident::#run_struct_ident; type Input = #input_type; type Output = #output_type; @@ -849,12 +846,12 @@ impl WorkflowMethodsDefinition { } } - impl ::temporalio_sdk::workflows::ExecutableQuery<#module_ident::#struct_ident> for #impl_type { + impl ::temporalio_workflow::workflows::ExecutableQuery<#module_ident::#struct_ident> for #impl_type { fn handle( &self, - ctx: &::temporalio_sdk::WorkflowContextView, - input: <#module_ident::#struct_ident as ::temporalio_common::QueryDefinition>::Input, - ) -> Result<<#module_ident::#struct_ident as ::temporalio_common::QueryDefinition>::Output, Box> { + ctx: &::temporalio_workflow::WorkflowContextView, + input: <#module_ident::#struct_ident as ::temporalio_workflow::common::QueryDefinition>::Input, + ) -> Result<<#module_ident::#struct_ident as ::temporalio_workflow::common::QueryDefinition>::Output, Box> { #body } } @@ -882,7 +879,7 @@ impl WorkflowMethodsDefinition { let run_struct_ident = self.run_struct_ident(); let definition_impl = quote! { - impl ::temporalio_common::UpdateDefinition for #module_ident::#struct_ident { + impl ::temporalio_workflow::common::UpdateDefinition for #module_ident::#struct_ident { type Workflow = #module_ident::#run_struct_ident; type Input = #input_type; type Output = #output_type; @@ -897,7 +894,7 @@ impl WorkflowMethodsDefinition { let validate_impl = if let Some(ref validator_name) = update.validator { let prefixed_validator = format_ident!("__{}", validator_name); quote! { - fn validate(&self, ctx: &::temporalio_sdk::WorkflowContextView, input: &<#module_ident::#struct_ident as ::temporalio_common::UpdateDefinition>::Input) -> Result<(), Box> { + fn validate(&self, ctx: &::temporalio_workflow::WorkflowContextView, input: &<#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Input) -> Result<(), Box> { self.#prefixed_validator(ctx, input) } } @@ -913,11 +910,11 @@ impl WorkflowMethodsDefinition { update.is_fallible, ); quote! { - impl ::temporalio_sdk::workflows::ExecutableAsyncUpdate<#module_ident::#struct_ident> for #impl_type { + impl ::temporalio_workflow::workflows::ExecutableAsyncUpdate<#module_ident::#struct_ident> for #impl_type { fn handle( - mut ctx: ::temporalio_sdk::WorkflowContext, - input: <#module_ident::#struct_ident as ::temporalio_common::UpdateDefinition>::Input, - ) -> ::futures_util::future::LocalBoxFuture<'static, Result<<#module_ident::#struct_ident as ::temporalio_common::UpdateDefinition>::Output, Box>> { + mut ctx: ::temporalio_workflow::WorkflowContext, + input: <#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Input, + ) -> ::futures_util::future::LocalBoxFuture<'static, Result<<#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Output, Box>> { use ::futures_util::FutureExt; #handle_body } @@ -933,12 +930,12 @@ impl WorkflowMethodsDefinition { quote! { Ok(#method_call) } }; quote! { - impl ::temporalio_sdk::workflows::ExecutableSyncUpdate<#module_ident::#struct_ident> for #impl_type { + impl ::temporalio_workflow::workflows::ExecutableSyncUpdate<#module_ident::#struct_ident> for #impl_type { fn handle( &mut self, - ctx: &mut ::temporalio_sdk::SyncWorkflowContext, - input: <#module_ident::#struct_ident as ::temporalio_common::UpdateDefinition>::Input, - ) -> Result<<#module_ident::#struct_ident as ::temporalio_common::UpdateDefinition>::Output, Box> { + ctx: &mut ::temporalio_workflow::SyncWorkflowContext, + input: <#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Input, + ) -> Result<<#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Output, Box> { #body } @@ -1011,8 +1008,8 @@ impl WorkflowMethodsDefinition { async move { let result = #run_call; match result { - Ok(value) => ::temporalio_sdk::workflows::serialize_result(value, &ctx.payload_converter()) - .map_err(|e| ::temporalio_sdk::WorkflowTermination::from(::anyhow::Error::new(e))), + Ok(value) => ::temporalio_workflow::workflows::serialize_result(value, &ctx.payload_converter()) + .map_err(|e| ::temporalio_workflow::WorkflowTermination::from(::anyhow::Error::new(e))), Err(e) => Err(e), } }.boxed_local() @@ -1034,11 +1031,11 @@ impl WorkflowMethodsDefinition { if s.is_async { quote! { - #handler_name => Some(>::dispatch(ctx.clone(), payloads, converter)), + #handler_name => Some(>::dispatch(ctx.clone(), payloads, converter)), } } else { quote! { - #handler_name => Some(>::dispatch(ctx, payloads, converter)), + #handler_name => Some(>::dispatch(ctx, payloads, converter)), } } }) @@ -1050,11 +1047,11 @@ impl WorkflowMethodsDefinition { } else { quote! { fn dispatch_signal( - ctx: ::temporalio_sdk::WorkflowContext, + ctx: ::temporalio_workflow::WorkflowContext, name: &str, - payloads: ::temporalio_common::protos::temporal::api::common::v1::Payloads, - converter: &::temporalio_common::data_converters::PayloadConverter, - ) -> Option<::futures_util::future::LocalBoxFuture<'static, Result<(), ::temporalio_sdk::workflows::WorkflowError>>> { + payloads: ::temporalio_workflow::common::protos::temporal::api::common::v1::Payloads, + converter: &::temporalio_workflow::common::data_converters::PayloadConverter, + ) -> Option<::futures_util::future::LocalBoxFuture<'static, Result<(), ::temporalio_workflow::workflows::WorkflowError>>> { match name { #(#dispatch_signal_arms)* _ => None, @@ -1079,11 +1076,11 @@ impl WorkflowMethodsDefinition { if u.is_async { quote! { - #handler_name => Some(>::dispatch(ctx.clone(), payloads, converter)), + #handler_name => Some(>::dispatch(ctx.clone(), payloads, converter)), } } else { quote! { - #handler_name => Some(>::dispatch(ctx, payloads, converter)), + #handler_name => Some(>::dispatch(ctx, payloads, converter)), } } }) @@ -1104,9 +1101,9 @@ impl WorkflowMethodsDefinition { let handler_name = &info.handler_name; let validate_trait = if u.is_async { - quote! { ::temporalio_sdk::workflows::ExecutableAsyncUpdate<#module_ident::#struct_ident> } + quote! { ::temporalio_workflow::workflows::ExecutableAsyncUpdate<#module_ident::#struct_ident> } } else { - quote! { ::temporalio_sdk::workflows::ExecutableSyncUpdate<#module_ident::#struct_ident> } + quote! { ::temporalio_workflow::workflows::ExecutableSyncUpdate<#module_ident::#struct_ident> } }; quote! { @@ -1121,11 +1118,11 @@ impl WorkflowMethodsDefinition { } else { quote! { fn dispatch_update( - ctx: ::temporalio_sdk::WorkflowContext, + ctx: ::temporalio_workflow::WorkflowContext, name: &str, - payloads: ::temporalio_common::protos::temporal::api::common::v1::Payloads, - converter: &::temporalio_common::data_converters::PayloadConverter, - ) -> Option<::futures_util::future::LocalBoxFuture<'static, Result<::temporalio_common::protos::temporal::api::common::v1::Payload, ::temporalio_sdk::workflows::WorkflowError>>> { + payloads: ::temporalio_workflow::common::protos::temporal::api::common::v1::Payloads, + converter: &::temporalio_workflow::common::data_converters::PayloadConverter, + ) -> Option<::futures_util::future::LocalBoxFuture<'static, Result<::temporalio_workflow::common::protos::temporal::api::common::v1::Payload, ::temporalio_workflow::workflows::WorkflowError>>> { match name { #(#dispatch_update_arms)* _ => None, @@ -1134,11 +1131,11 @@ impl WorkflowMethodsDefinition { fn validate_update( &self, - ctx: ::temporalio_sdk::WorkflowContextView, + ctx: ::temporalio_workflow::WorkflowContextView, name: &str, - payloads: &::temporalio_common::protos::temporal::api::common::v1::Payloads, - converter: &::temporalio_common::data_converters::PayloadConverter, - ) -> Option> { + payloads: &::temporalio_workflow::common::protos::temporal::api::common::v1::Payloads, + converter: &::temporalio_workflow::common::data_converters::PayloadConverter, + ) -> Option> { match name { #(#validate_update_arms)* @@ -1163,7 +1160,7 @@ impl WorkflowMethodsDefinition { let handler_name = &info.handler_name; quote! { - #handler_name => Some(>::dispatch(self, &ctx, payloads, converter)), + #handler_name => Some(>::dispatch(self, &ctx, payloads, converter)), } }) .collect(); @@ -1175,11 +1172,11 @@ impl WorkflowMethodsDefinition { quote! { fn dispatch_query( &self, - ctx: ::temporalio_sdk::WorkflowContextView, + ctx: ::temporalio_workflow::WorkflowContextView, name: &str, - payloads: &::temporalio_common::protos::temporal::api::common::v1::Payloads, - converter: &::temporalio_common::data_converters::PayloadConverter, - ) -> Option> { + payloads: &::temporalio_workflow::common::protos::temporal::api::common::v1::Payloads, + converter: &::temporalio_workflow::common::data_converters::PayloadConverter, + ) -> Option> { match name { #(#dispatch_query_arms)* _ => None, @@ -1189,7 +1186,7 @@ impl WorkflowMethodsDefinition { }; quote! { - impl ::temporalio_sdk::workflows::WorkflowImplementation for #impl_type { + impl ::temporalio_workflow::runtime::entry::WorkflowImplementation for #impl_type { type Run = #module_ident::#run_struct_ident; const HAS_INIT: bool = #has_init; @@ -1200,16 +1197,16 @@ impl WorkflowMethodsDefinition { } fn init( - ctx: ::temporalio_sdk::WorkflowContextView, - input: ::std::option::Option<::Input>, + ctx: ::temporalio_workflow::WorkflowContextView, + input: ::std::option::Option<::Input>, ) -> Self { #init_body } fn run( - mut ctx: ::temporalio_sdk::WorkflowContext, - input: ::std::option::Option<::Input>, - ) -> ::futures_util::future::LocalBoxFuture<'static, Result<::temporalio_common::protos::temporal::api::common::v1::Payload, ::temporalio_sdk::WorkflowTermination>> { + mut ctx: ::temporalio_workflow::WorkflowContext, + input: ::std::option::Option<::Input>, + ) -> ::futures_util::future::LocalBoxFuture<'static, Result<::temporalio_workflow::common::protos::temporal::api::common::v1::Payload, ::temporalio_workflow::WorkflowTermination>> { #run_impl_body } @@ -1219,14 +1216,4 @@ impl WorkflowMethodsDefinition { } } } - - fn generate_workflow_implementer(&self, impl_type: &Type) -> TokenStream2 { - quote! { - impl ::temporalio_sdk::workflows::WorkflowImplementer for #impl_type { - fn register_all(defs: &mut ::temporalio_sdk::workflows::WorkflowDefinitions) { - defs.register_workflow_run::(); - } - } - } - } } diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index f74a9bd2a..1b0e62a7c 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -153,6 +153,7 @@ rstest = "0.26" semver = "1.0" temporalio-sdk = { path = "../sdk" } temporalio-common = { path = "../common", version = "0.4", default-features = false } +temporalio-workflow = { path = "../workflow" } tokio = { version = "1.47", default-features = false, features = [ "rt", "rt-multi-thread", diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index 7afe1c0e8..ec17fd98b 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -60,7 +60,7 @@ use temporalio_sdk::{ FailOnNondeterminismInterceptor, InterceptorWithNext, ReturnWorkflowExitValueInterceptor, WorkerInterceptor, }, - workflows::{WorkflowImplementation, WorkflowImplementer}, + workflows::WorkflowImplementation, }; #[cfg(any(feature = "test-utilities", test))] pub(crate) use temporalio_sdk_core::test_help::NAMESPACE; @@ -537,8 +537,12 @@ impl TestWorker { } #[allow(unused)] - pub(crate) fn register_workflow(&mut self) -> &mut Self { - self.inner.register_workflow::(); + pub(crate) fn register_workflow(&mut self) -> &mut Self + where + W: WorkflowImplementation, + ::Input: Send, + { + self.inner.register_workflow::(); self } 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 f03e019e8..dadada351 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 @@ -1,11 +1,12 @@ -use crate::common::{CoreWfStarter, build_fake_sdk}; -use std::{sync::Arc, time::Duration}; +use crate::common::{CoreWfStarter, SEARCH_ATTR_TXT, build_fake_sdk}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use temporalio_client::WorkflowStartOptions; use temporalio_common::{ protos::{ - coresdk::workflow_commands::ContinueAsNewWorkflowExecution, + coresdk::AsJsonPayloadExt, temporal::api::{ command::v1::command::Attributes, + common::v1::SearchAttributes, enums::v1::{CommandType, ContinueAsNewVersioningBehavior}, history::v1::history_event, }, @@ -19,6 +20,7 @@ use temporalio_sdk_core::{ replay::{DEFAULT_WORKFLOW_TYPE, canned_histories}, test_help::MockPollCfg, }; +use temporalio_workflow::runtime::types::ContinueAsNewRequest; #[workflow] #[derive(Default)] @@ -90,13 +92,11 @@ 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( - ContinueAsNewWorkflowExecution { - arguments: vec![[1].into()], - initial_versioning_behavior: ContinueAsNewVersioningBehavior::AutoUpgrade.into(), - ..Default::default() - }, - )) + Err(WorkflowTermination::continue_as_new(ContinueAsNewRequest { + args: vec![[1].into()], + initial_versioning_behavior: Some(ContinueAsNewVersioningBehavior::AutoUpgrade), + ..Default::default() + })) } } @@ -164,3 +164,47 @@ async fn continue_as_new_suggested_flag_exposed() { worker.register_workflow::(); worker.run().await.unwrap(); } + +#[workflow] +#[derive(Default)] +struct ClearSearchAttrsOnContinueAsNewWf; + +#[workflow_methods] +impl ClearSearchAttrsOnContinueAsNewWf { + #[run(name = "clear_search_attrs_on_continue_as_new")] + async fn run(ctx: &mut WorkflowContext, first_run: bool) -> WorkflowResult<()> { + if first_run { + let mut opts = ContinueAsNewOptions::default(); + opts.search_attributes = Some(SearchAttributes::default()); + ctx.continue_as_new(&false, opts)?; + } + + assert!(ctx.search_attributes().indexed_fields.is_empty()); + Ok(()) + } +} + +#[tokio::test] +async fn clear_search_attributes_on_continue_as_new() { + let wf_name = "clear_search_attrs_on_continue_as_new"; + let mut starter = CoreWfStarter::new(wf_name); + starter.sdk_config.task_types = WorkerTaskTypes::workflow_only(); + let mut worker = starter.worker().await; + worker.register_workflow::(); + + let task_queue = starter.get_task_queue().to_owned(); + worker + .submit_workflow( + ClearSearchAttrsOnContinueAsNewWf::run, + true, + WorkflowStartOptions::new(task_queue, wf_name.to_string()) + .search_attributes(HashMap::from([( + SEARCH_ATTR_TXT.to_string(), + "hello".as_json_payload().unwrap(), + )])) + .build(), + ) + .await + .unwrap(); + worker.run_until_done().await.unwrap(); +} 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 b594a0b8d..498cd4623 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/resets.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/resets.rs @@ -115,6 +115,7 @@ async fn reset_workflow() { struct ResetRandomseedWf { did_fail: Arc, rand_seed: Arc, + saw_updated_seed: Arc, notify: Arc, post_fail_received: bool, post_reset_received: bool, @@ -147,6 +148,9 @@ impl ResetRandomseedWf { if ctx.state(|wf| wf.rand_seed.load(Ordering::Relaxed)) == ctx.random_seed() { ctx.timer(Duration::from_millis(100)).await; } else { + ctx.state(|wf| { + wf.saw_updated_seed.store(true, Ordering::Relaxed); + }); ctx.start_local_activity( StdActivities::echo, "hi!".to_string(), @@ -187,11 +191,14 @@ async fn reset_randomseed() { let did_fail = Arc::new(AtomicBool::new(false)); let rand_seed = Arc::new(AtomicU64::new(0)); + let saw_updated_seed = Arc::new(AtomicBool::new(false)); let notify = Arc::new(Notify::new()); let notify_clone = notify.clone(); + let saw_updated_seed_for_wf = saw_updated_seed.clone(); worker.register_workflow_with_factory(move || ResetRandomseedWf { did_fail: did_fail.clone(), rand_seed: rand_seed.clone(), + saw_updated_seed: saw_updated_seed_for_wf.clone(), notify: notify_clone.clone(), post_fail_received: false, post_reset_received: false, @@ -241,8 +248,9 @@ async fn reset_randomseed() { // Unblock the workflow by sending the signal. Run ID will have changed after reset so // we re-obtain the handle. - client - .get_workflow_handle::(wf_name.to_owned()) + let reset_handle = + client.get_workflow_handle::(wf_name.to_owned()); + reset_handle .signal( ResetRandomseedWf::post_reset, (), @@ -251,9 +259,14 @@ async fn reset_randomseed() { .await .unwrap(); - // Wait for the now-reset workflow to finish + // Wait for the original run to terminate and the reset run to finish. let result = handle.get_result(Default::default()).await; assert_matches!(result, Err(WorkflowGetResultError::Terminated { .. })); + reset_handle.get_result(Default::default()).await.unwrap(); + assert!( + saw_updated_seed.load(Ordering::Relaxed), + "workflow should observe the server-supplied updated random seed after reset" + ); starter.shutdown().await; }; let run_fut = worker.run_until_done(); diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 0e9aee107..3a7bb3572 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -43,6 +43,10 @@ path = "../sdk-core" version = "0.4" default-features = false +[dependencies.temporalio-workflow] +path = "../workflow" +version = "0.2" + [dependencies.temporalio-common] path = "../common" version = "0.4" diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index f979771bf..e5198222b 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -74,41 +74,19 @@ extern crate self as temporalio_sdk; pub mod activities; pub mod error; pub mod interceptors; -mod workflow_context; mod workflow_executor; mod workflow_future; +mod workflow_registry; pub mod workflows; -#[macro_export] -#[doc(hidden)] -macro_rules! __temporal_select { - ($($tokens:tt)*) => { - ::futures_util::select_biased! { $($tokens)* } - }; -} - -#[macro_export] -#[doc(hidden)] -macro_rules! __temporal_join { - ($($tokens:tt)*) => { - ::futures_util::join!($($tokens)*) - }; -} - -use workflow_future::WorkflowFunction; - -pub use error::{ - ActivityExecutionError, ApplicationFailure, ChildWorkflowExecutionError, - ChildWorkflowSignalError, ChildWorkflowStartError, OutgoingActivityError, OutgoingError, - OutgoingWorkflowError, -}; pub use temporalio_client::Namespace; -pub use workflow_context::{ +pub use temporalio_workflow::{ ActivityCloseTimeouts, ActivityOptions, BaseWorkflowContext, CancellableFuture, ChildWorkflowOptions, ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, NexusOperationOptions, ParentWorkflowInfo, RootWorkflowInfo, Signal, SignalData, StartChildWorkflowExecutionFailedCause, StartedChildWorkflow, SyncWorkflowContext, - TimerOptions, WorkflowContext, WorkflowContextView, + TimerOptions, TimerResult, WorkflowContext, WorkflowContextView, WorkflowResult, + WorkflowTermination, }; use crate::{ @@ -117,11 +95,9 @@ use crate::{ ExecutableActivity, }, interceptors::WorkerInterceptor, - workflow_context::{ - ChildWfCommon, NexusUnblockData, PendingChildWorkflow, StartedNexusOperation, - }, - workflow_executor::WorkflowExecutor, - workflows::{WorkflowDefinitions, WorkflowImplementation, WorkflowImplementer}, + workflow_executor::{TaskHandle, WorkflowExecutor}, + workflow_future::WorkflowFunction, + workflow_registry::WorkflowDefinitions, }; use anyhow::{Context, anyhow, bail}; use futures_util::{FutureExt, StreamExt, TryFutureExt, TryStreamExt}; @@ -131,7 +107,6 @@ use std::{ collections::{HashMap, HashSet}, fmt::{Debug, Display, Formatter}, future::Future, - marker::PhantomData, panic::AssertUnwindSafe, sync::Arc, time::Duration, @@ -145,18 +120,9 @@ use temporalio_common::{ TaskToken, coresdk::{ ActivityTaskCompletion, AsJsonPayloadExt, - activity_result::{ActivityExecutionResult, ActivityResolution}, + activity_result::ActivityExecutionResult, activity_task::{ActivityTask, activity_task}, - child_workflow::ChildWorkflowResult, - nexus::NexusOperationResult, - workflow_activation::{ - WorkflowActivation, - resolve_child_workflow_execution_start::Status as ChildWorkflowStartStatus, - resolve_nexus_operation_start, workflow_activation_job::Variant, - }, - workflow_commands::{ - ContinueAsNewWorkflowExecution, WorkflowCommand, workflow_command, - }, + workflow_activation::{WorkflowActivation, workflow_activation_job::Variant}, workflow_completion::WorkflowActivationCompletion, }, temporal::api::{ @@ -169,10 +135,10 @@ use temporalio_sdk_core::{ CoreRuntime, PollError, PollerBehavior, TunerBuilder, Worker as CoreWorker, WorkerConfig, WorkerTuner, WorkerVersioningStrategy, WorkflowErrorType, init_worker, }; +use temporalio_workflow::runtime::entry::WorkflowImplementation; use tokio::sync::{ Notify, mpsc::{UnboundedSender, unbounded_channel}, - oneshot, }; use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_util::sync::CancellationToken; @@ -301,8 +267,12 @@ impl WorkerOptionsBuilder { } /// Registers all workflows on a workflow implementer. - pub fn register_workflow(mut self) -> Self { - self.workflows.register_workflow::(); + pub fn register_workflow(mut self) -> Self + where + W: WorkflowImplementation, + ::Input: Send, + { + self.workflows.register_workflow::(); self } @@ -361,8 +331,12 @@ impl WorkerOptions { } /// Registers all workflows on a workflow implementer. - pub fn register_workflow(&mut self) -> &mut Self { - self.workflows.register_workflow::(); + pub fn register_workflow(&mut self) -> &mut Self + where + W: WorkflowImplementation, + ::Input: Send, + { + self.workflows.register_workflow::(); self } @@ -568,10 +542,14 @@ impl Worker { } /// Registers all workflows on a workflow implementer. - pub fn register_workflow(&mut self) -> &mut Self { + pub fn register_workflow(&mut self) -> &mut Self + where + W: WorkflowImplementation, + ::Input: Send, + { self.workflow_half .workflow_definitions - .register_workflow::(); + .register_workflow::(); self } @@ -595,9 +573,8 @@ impl Worker { pub async fn run(&mut self) -> Result<(), anyhow::Error> { let shutdown_token = CancellationToken::new(); let (common, wf_half, act_half) = self.split_apart(); - let (wf_future_tx, wf_future_rx) = unbounded_channel::< - WorkflowFutureHandle>>, - >(); + let (wf_future_tx, wf_future_rx) = + unbounded_channel::>>>(); let (completions_tx, completions_rx) = unbounded_channel(); // Workflows run in a LocalSet because they use Rc for state management. @@ -793,10 +770,8 @@ impl WorkflowHalf { mut activation: WorkflowActivation, completions_tx: &UnboundedSender, executor: &WorkflowExecutor, - ) -> Result< - Option>>>, - anyhow::Error, - > { + ) -> Result>>>, anyhow::Error> + { let mut res = None; let run_id = activation.run_id.clone(); @@ -1021,290 +996,6 @@ impl ActivityHalf { } } -#[derive(Debug)] -enum UnblockEvent { - Timer(u32, TimerResult), - Activity(u32, Box), - WorkflowStart(u32, Box), - WorkflowComplete(u32, Box), - SignalExternal(u32, Option), - CancelExternal(u32, Option), - NexusOperationStart(u32, Box), - NexusOperationComplete(u32, Box), -} - -/// Result of awaiting on a timer -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum TimerResult { - /// The timer was cancelled - Cancelled, - /// The timer elapsed and fired - Fired, -} - -/// Successful result of sending a signal to an external workflow -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SignalExternalOk; -/// Result of awaiting on sending a signal to an external workflow -pub type SignalExternalWfResult = Result; - -/// Successful result of sending a cancel request to an external workflow -#[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; - -trait Unblockable { - type OtherDat; - - fn unblock(ue: UnblockEvent, od: Self::OtherDat) -> Self; -} - -impl Unblockable for TimerResult { - type OtherDat = (); - fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { - match ue { - UnblockEvent::Timer(_, result) => result, - _ => panic!("Invalid unblock event for timer"), - } - } -} - -impl Unblockable for ActivityResolution { - type OtherDat = (); - fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { - match ue { - UnblockEvent::Activity(_, result) => *result, - _ => panic!("Invalid unblock event for activity"), - } - } -} - -impl Unblockable for PendingChildWorkflow { - type OtherDat = ChildWfCommon; - fn unblock(ue: UnblockEvent, od: Self::OtherDat) -> Self { - match ue { - UnblockEvent::WorkflowStart(_, result) => Self { - status: *result, - common: od, - _phantom: PhantomData, - }, - _ => panic!("Invalid unblock event for child workflow start"), - } - } -} - -impl Unblockable for ChildWorkflowResult { - type OtherDat = (); - fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { - match ue { - UnblockEvent::WorkflowComplete(_, result) => *result, - _ => panic!("Invalid unblock event for child workflow complete"), - } - } -} - -impl Unblockable for SignalExternalWfResult { - type OtherDat = (); - fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { - match ue { - UnblockEvent::SignalExternal(_, maybefail) => { - maybefail.map_or(Ok(SignalExternalOk), Err) - } - _ => panic!("Invalid unblock event for signal external workflow result"), - } - } -} - -impl Unblockable for CancelExternalWfResult { - type OtherDat = (); - fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { - match ue { - UnblockEvent::CancelExternal(_, maybefail) => { - maybefail.map_or(Ok(CancelExternalOk), Err) - } - _ => panic!("Invalid unblock event for signal external workflow result"), - } - } -} - -type NexusStartResult = Result; -impl Unblockable for NexusStartResult { - type OtherDat = NexusUnblockData; - fn unblock(ue: UnblockEvent, od: Self::OtherDat) -> Self { - match ue { - UnblockEvent::NexusOperationStart(_, result) => match *result { - resolve_nexus_operation_start::Status::OperationToken(op_token) => { - Ok(StartedNexusOperation { - operation_token: Some(op_token), - unblock_dat: od, - }) - } - resolve_nexus_operation_start::Status::StartedSync(_) => { - Ok(StartedNexusOperation { - operation_token: None, - unblock_dat: od, - }) - } - 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"), - } - } -} - -/// Identifier for cancellable operations -#[derive(Debug, Clone)] -pub(crate) enum CancellableID { - Timer(u32), - Activity(u32), - LocalActivity(u32), - ChildWorkflow { - seqnum: u32, - reason: String, - }, - SignalExternalWorkflow(u32), - /// A nexus operation (waiting for start) - NexusOp(u32), -} - -/// Cancellation IDs that support a reason. -pub(crate) trait SupportsCancelReason { - /// Returns a new version of this ID with the provided cancellation reason. - fn with_reason(self, reason: String) -> CancellableID; -} -#[derive(Debug, Clone)] -pub(crate) enum CancellableIDWithReason { - ChildWorkflow { seqnum: u32 }, -} -impl SupportsCancelReason for CancellableIDWithReason { - fn with_reason(self, reason: String) -> CancellableID { - match self { - CancellableIDWithReason::ChildWorkflow { seqnum } => { - CancellableID::ChildWorkflow { seqnum, reason } - } - } - } -} -impl From for CancellableID { - fn from(v: CancellableIDWithReason) -> Self { - v.with_reason("".to_string()) - } -} - -#[derive(derive_more::From)] -#[allow(clippy::large_enum_variant)] -enum RustWfCmd { - #[from(ignore)] - Cancel(CancellableID), - ForceWFTFailure(anyhow::Error), - NewCmd(CommandCreateRequest), - NewNonblockingCmd(workflow_command::Variant), - SubscribeChildWorkflowCompletion(CommandSubscribeChildWorkflowCompletion), - SubscribeNexusOperationCompletion { - seq: u32, - unblocker: oneshot::Sender, - }, -} - -struct CommandCreateRequest { - cmd: WorkflowCommand, - unblocker: oneshot::Sender, -} - -struct CommandSubscribeChildWorkflowCompletion { - seq: u32, - unblocker: oneshot::Sender, -} - -/// The result of running a workflow. -/// -/// Successful completion returns `Ok(T)` where `T` is the workflow's return type. -/// Non-error terminations (cancel, eviction, continue-as-new) return `Err(WorkflowTermination)`. -pub type WorkflowResult = Result; - -/// Represents ways a workflow can terminate without producing a normal result. -/// -/// This is used as the error type in [`WorkflowResult`] for non-error termination conditions -/// like cancellation, eviction, continue-as-new, or actual failures. -#[derive(Debug, thiserror::Error)] -pub enum WorkflowTermination { - /// The workflow was cancelled. - #[error("Workflow cancelled")] - Cancelled, - - /// The workflow was evicted from the cache. - #[error("Workflow evicted from cache")] - Evicted, - - /// The workflow should continue as a new execution. - #[error("Continue as new")] - ContinueAsNew(Box), - - /// The workflow failed with an error. - #[error("Workflow failed: {0}")] - Failed(#[source] OutgoingWorkflowError), -} - -impl WorkflowTermination { - /// Construct a [WorkflowTermination::ContinueAsNew] - pub fn continue_as_new(can: ContinueAsNewWorkflowExecution) -> Self { - Self::ContinueAsNew(Box::new(can)) - } - - /// Construct a [WorkflowTermination::Failed] variant from an application failure. - pub fn failed_application(err: ApplicationFailure) -> Self { - Self::Failed(err.into()) - } -} - -impl From for WorkflowTermination { - fn from(err: anyhow::Error) -> Self { - Self::Failed(err.into()) - } -} - -impl From for WorkflowTermination { - fn from(err: temporalio_common::data_converters::PayloadConversionError) -> Self { - Self::Failed(err.into()) - } -} - -impl From for WorkflowTermination { - fn from(value: ActivityExecutionError) -> Self { - Self::Failed(value.into()) - } -} - -impl From for WorkflowTermination { - fn from(value: ChildWorkflowExecutionError) -> Self { - Self::Failed(value.into()) - } -} - -impl From for WorkflowTermination { - fn from(value: ChildWorkflowStartError) -> Self { - Self::Failed(value.into()) - } -} - -impl From for WorkflowTermination { - fn from(value: ChildWorkflowSignalError) -> Self { - Self::Failed(value.into()) - } -} - /// Activity functions may return these values when exiting #[derive(Debug)] pub enum ActExitValue { @@ -1358,12 +1049,19 @@ impl PrintablePanicType for EndPrintingAttempts { } #[cfg(test)] +#[allow(dead_code, unreachable_pub)] mod tests { use super::*; - use temporalio_macros::{activities, workflow, workflow_methods}; + use temporalio_macros::{activities, activity_definitions, workflow, workflow_methods}; struct MyActivities {} + #[activity_definitions] + trait SharedActivities { + #[activity(name = "shared-greet")] + fn greet(name: String) -> String; + } + #[activities] impl MyActivities { #[activity] @@ -1371,6 +1069,11 @@ mod tests { Ok(()) } + #[activity(definition = shared_activities::Greet)] + async fn greet(_ctx: ActivityContext, name: String) -> Result { + Ok(name) + } + #[activity] async fn takes_self( self: Arc, @@ -1396,6 +1099,11 @@ mod tests { (), ActivityOptions::start_to_close_timeout(Duration::from_secs(5)), ); + wf_ctx.start_activity( + shared_activities::greet, + "Hi".to_owned(), + ActivityOptions::start_to_close_timeout(Duration::from_secs(5)), + ); wf_ctx.start_activity( MyActivities::takes_self, "Hi".to_owned(), diff --git a/crates/sdk/src/workflow_executor.rs b/crates/sdk/src/workflow_executor.rs index 77ac6f1da..48ef5ffa2 100644 --- a/crates/sdk/src/workflow_executor.rs +++ b/crates/sdk/src/workflow_executor.rs @@ -10,41 +10,11 @@ use std::{ }, task::{Context, Poll, Wake, Waker}, }; - -thread_local! { - static SDK_WAKE_DEPTH: Cell = const { Cell::new(0) }; -} - -/// Guard that marks the current scope as an SDK-initiated wake source. -/// -/// When the tracking waker's `wake()` is called while this guard is active -/// (depth > 0), the wake is recognized as coming from SDK internals and is not -/// flagged as nondeterministic. Nesting is safe via a depth counter, and the -/// `Drop` impl ensures cleanup. -pub(crate) struct SdkWakeGuard { - _priv: (), // prevent construction outside this module -} - -impl SdkWakeGuard { - pub(crate) fn new() -> Self { - SDK_WAKE_DEPTH.with(|c| c.set(c.get() + 1)); - Self { _priv: () } - } -} - -impl Drop for SdkWakeGuard { - fn drop(&mut self) { - SDK_WAKE_DEPTH.with(|c| c.set(c.get() - 1)); - } -} - -fn is_sdk_wake() -> bool { - SDK_WAKE_DEPTH.with(|c| c.get() > 0) -} +use temporalio_workflow::runtime::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 -/// current parent waker baked in (no mutex needed). +/// current parent waker baked in, which avoids sharing mutable waker state across polls. pub(crate) struct WakeTracker { non_sdk_wake_detected: Arc, } @@ -56,8 +26,8 @@ impl WakeTracker { } } - /// Create a waker for this poll that forwards to `parent_waker` and sets the shared - /// detection flag on non-SDK wakes. + /// Create a waker for this poll that forwards to `parent_waker` and sets the shared detection + /// flag on non-SDK wakes. pub(crate) fn new_per_poll_waker(&self, parent_waker: &Waker) -> Waker { Waker::from(Arc::new(PerPollWakeTracker { non_sdk_wake_detected: self.non_sdk_wake_detected.clone(), @@ -88,24 +58,10 @@ impl Wake for PerPollWakeTracker { } } -/// A future wrapper that activates [`SdkWakeGuard`] during poll. Use this around futures whose -/// internal waker machinery (e.g., `FuturesOrdered` inside `join_all`) would otherwise trigger -/// false positives in nondeterminism detection. -pub(crate) struct SdkGuardedFuture(pub(crate) F); - -impl Future for SdkGuardedFuture { - type Output = F::Output; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let _guard = SdkWakeGuard::new(); - Pin::new(&mut self.0).poll(cx) - } -} - struct ExecutorShared { ready_queue: parking_lot::Mutex>, - /// Waker to notify when tasks are enqueued. Set by `shutdown` so it can - /// park instead of busy-polling when tasks are waiting on external events. + /// Waker to notify when tasks are enqueued. Set by `shutdown` so it can park instead of + /// busy-polling when tasks are waiting on external events. waker: parking_lot::Mutex>, } @@ -189,6 +145,12 @@ pub(crate) struct WorkflowExecutor { shared: Arc, } +impl Default for WorkflowExecutor { + fn default() -> Self { + Self::new() + } +} + impl WorkflowExecutor { pub(crate) fn new() -> Self { Self { @@ -201,7 +163,8 @@ impl WorkflowExecutor { } } - /// Spawn a future onto this executor, returning a `!Send` join handle. + /// Spawn a future onto this executor and return a handle that resolves once the executor drains + /// that task to completion. pub(crate) fn spawn(&self, future: F) -> TaskHandle where F: Future + 'static, @@ -246,7 +209,8 @@ impl WorkflowExecutor { self.tasks.borrow().is_empty() } - /// Drain remaining tasks until all complete. + /// Keep draining until no tasks remain because workflow shutdown must flush spawned handlers + /// before the activation can be considered quiescent. pub(crate) async fn shutdown(&self) { std::future::poll_fn(|cx| { *self.shared.waker.lock() = Some(cx.waker().clone()); @@ -260,7 +224,8 @@ impl WorkflowExecutor { .await } - /// Drain the ready queue, polling all ready tasks once. + /// Poll each ready task once so wake-ups that happen during polling are re-queued instead of + /// recursively draining the executor on the same stack. pub(crate) fn process_tasks(&self) { loop { let task_id = self.shared.ready_queue.lock().pop_front(); @@ -281,6 +246,7 @@ impl WorkflowExecutor { #[cfg(test)] mod tests { use super::*; + use temporalio_workflow::runtime::SdkWakeGuard; use tokio::sync::oneshot; #[tokio::test] @@ -369,17 +335,16 @@ mod tests { fn sdk_wake_guard_nesting() { assert!(!is_sdk_wake()); - let _g1 = SdkWakeGuard::new(); + let guard1 = SdkWakeGuard::new(); assert!(is_sdk_wake()); { - let _g2 = SdkWakeGuard::new(); + let _guard2 = SdkWakeGuard::new(); assert!(is_sdk_wake()); } - // g2 dropped, but g1 still active assert!(is_sdk_wake()); - drop(_g1); + drop(guard1); assert!(!is_sdk_wake()); } @@ -390,7 +355,6 @@ mod tests { panic!("test panic"); })); assert!(result.is_err()); - // Guard was cleaned up by Drop despite the panic assert!(!is_sdk_wake()); } @@ -400,11 +364,9 @@ mod tests { let noop = Waker::noop(); let waker = tracker.new_per_poll_waker(noop); - // Wake without SDK guard -- should be detected waker.wake_by_ref(); assert!(tracker.take_non_sdk_wake()); - // Wake with SDK guard -- should not be detected let _guard = SdkWakeGuard::new(); waker.wake_by_ref(); assert!(!tracker.take_non_sdk_wake()); @@ -416,10 +378,8 @@ mod tests { let noop = Waker::noop(); let waker = tracker.new_per_poll_waker(noop); - // Set SDK guard on THIS thread let _guard = SdkWakeGuard::new(); - // Wake from another thread -- thread-local not set there let handle = std::thread::spawn(move || { waker.wake_by_ref(); }); diff --git a/crates/sdk/src/workflow_future.rs b/crates/sdk/src/workflow_future.rs index 9d2f46392..f8cf88cb9 100644 --- a/crates/sdk/src/workflow_future.rs +++ b/crates/sdk/src/workflow_future.rs @@ -1,18 +1,11 @@ -use crate::{ - BaseWorkflowContext, CancellableID, OutgoingError, OutgoingWorkflowError, RustWfCmd, - TimerResult, UnblockEvent, WorkflowResult, WorkflowTermination, panic_formatter, - workflow_executor::{SdkWakeGuard, WakeTracker}, - workflows::{DispatchData, DynWorkflowExecution, WorkflowExecutionFactory}, -}; use anyhow::{Context as AnyhowContext, Error, anyhow, bail}; -use futures_util::{FutureExt, future::LocalBoxFuture}; use std::{ - collections::HashMap, + cell::RefCell, future::Future, panic, panic::AssertUnwindSafe, pin::Pin, - sync::mpsc::Receiver, + rc::Rc, task::{Context, Poll}, }; use temporalio_common::{ @@ -25,30 +18,52 @@ use temporalio_common::{ workflow_activation::{ FireTimer, InitializeWorkflow, NotifyHasPatch, ResolveActivity, ResolveChildWorkflowExecution, ResolveChildWorkflowExecutionStart, - WorkflowActivation, WorkflowActivationJob, workflow_activation_job::Variant, + WorkflowActivation as CoreWorkflowActivation, + WorkflowActivationJob as CoreWorkflowActivationJob, + workflow_activation_job::Variant, }, workflow_commands::{ CancelChildWorkflowExecution, CancelSignalWorkflow, CancelTimer, - CancelWorkflowExecution, CompleteWorkflowExecution, FailWorkflowExecution, - QueryResult, QuerySuccess, RequestCancelActivity, RequestCancelLocalActivity, - RequestCancelNexusOperation, ScheduleActivity, ScheduleLocalActivity, StartTimer, - UpdateResponse, WorkflowCommand, query_result, update_response, workflow_command, + CancelWorkflowExecution, CompleteWorkflowExecution, ContinueAsNewWorkflowExecution, + FailWorkflowExecution, ModifyWorkflowProperties, QueryResult, QuerySuccess, + RequestCancelActivity, RequestCancelExternalWorkflowExecution, + RequestCancelLocalActivity, RequestCancelNexusOperation, ScheduleActivity, + ScheduleLocalActivity, ScheduleNexusOperation, SetPatchMarker, + SignalExternalWorkflowExecution, StartChildWorkflowExecution, StartTimer, + UpdateResponse, UpsertWorkflowSearchAttributes, WorkflowCommand, query_result, + update_response, workflow_command, }, workflow_completion::{ self, WorkflowActivationCompletion, workflow_activation_completion, }, }, temporal::api::{ - common::v1::{Payload, Payloads}, - enums::v1::{VersioningBehavior, WorkflowTaskFailedCause}, - failure::v1::Failure, + common::v1::{Memo, Payload, SearchAttributes}, + enums::v1::{SuggestContinueAsNewReason, VersioningBehavior, WorkflowTaskFailedCause}, + sdk::v1::UserMetadata, }, utilities::TryIntoOrNone, }, }; -use tokio::sync::{ - mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}, - oneshot, watch, +use temporalio_workflow::runtime::{ + BaseWorkflowContext, + guest::WorkflowInstance, + host::WorkflowHost, + model::{WorkflowResult, WorkflowTermination}, + types::{ + ActivationContext, ActivationJobResult, ActivationResult, CancelChildWorkflowRequest, + IntoNamedPayloads, IntoPayloadMap, MainRoutineCompletion, NamedPayload, + RequestCancelExternalWorkflowRequest, RequestCancelNexusOperationRequest, + RoutineCompletion, RoutineId, RoutineKind, RoutinePollResult, ScheduleActivityRequest, + ScheduleLocalActivityRequest, ScheduleNexusOperationRequest, SignalExternalWorkflowRequest, + SignalWorkflowTarget, StartChildWorkflowRequest, StartTimerRequest, TerminalOutcome, + UpdateRoutineCompletion, WorkflowActivation, WorkflowActivationJob, WorkflowResolution, + }, +}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; + +use crate::{ + panic_formatter, workflow_executor::WakeTracker, workflow_registry::WorkflowExecutionFactory, }; pub(crate) struct WorkflowFunction { @@ -56,6 +71,7 @@ pub(crate) struct WorkflowFunction { } impl WorkflowFunction { + /// Create a workflow driver from a registered workflow factory. pub(crate) fn from_invocation(factory: WorkflowExecutionFactory) -> Self { WorkflowFunction { factory } } @@ -75,11 +91,10 @@ impl WorkflowFunction { ) -> Result< ( impl Future> + use<>, - UnboundedSender, + UnboundedSender, ), anyhow::Error, > { - let (cancel_tx, cancel_rx) = watch::channel(None); let span = info_span!( "RunWorkflow", "otel.name" = format!("RunWorkflow:{}", &init_workflow_job.workflow_type), @@ -88,13 +103,14 @@ impl WorkflowFunction { let payload_converter = data_converter.payload_converter().clone(); let input = init_workflow_job.arguments.clone(); - let (base_ctx, cmd_receiver) = BaseWorkflowContext::new( + let host = Rc::new(NativeWorkflowHost::default()); + let base_ctx = BaseWorkflowContext::new( namespace, task_queue, run_id, init_workflow_job, - cancel_rx, data_converter.clone(), + host.clone(), ); // Create the workflow execution using the factory @@ -110,97 +126,349 @@ impl WorkflowFunction { let (tx, incoming_activations) = unbounded_channel(); Ok(( WorkflowFuture { - base_ctx, execution, + host, span, - incoming_commands: cmd_receiver, outgoing_completions, incoming_activations, - command_status: Default::default(), - cancel_sender: cancel_tx, - data_converter, - update_futures: Default::default(), - signal_futures: Default::default(), wake_tracking, + active_routines: Vec::new(), }, tx, )) } } -struct WFCommandFutInfo { - unblocker: oneshot::Sender, +#[derive(Default)] +struct NativeWorkflowHost { + activation_cmds: RefCell>, +} + +impl NativeWorkflowHost { + fn push_command( + &self, + variant: workflow_command::Variant, + user_metadata: Option, + ) { + self.activation_cmds.borrow_mut().push(WorkflowCommand { + variant: Some(variant), + user_metadata, + }); + } + + fn take_commands(&self) -> Vec { + std::mem::take(&mut *self.activation_cmds.borrow_mut()) + } +} + +impl WorkflowHost for NativeWorkflowHost { + fn set_current_details(&self, _details: String) {} + + fn start_timer(&self, req: StartTimerRequest) { + self.push_command( + workflow_command::Variant::StartTimer(StartTimer { + seq: req.seq, + start_to_fire_timeout: Some( + req.timeout + .try_into() + .expect("workflow timer timeout must fit into protobuf duration"), + ), + }), + string_user_metadata(req.summary, None), + ); + } + + fn cancel_timer(&self, seq: u32) { + self.push_command( + workflow_command::Variant::CancelTimer(CancelTimer { seq }), + None, + ); + } + + fn schedule_activity(&self, req: ScheduleActivityRequest) { + self.push_command( + workflow_command::Variant::ScheduleActivity(ScheduleActivity { + seq: req.seq, + activity_type: req.activity_type, + activity_id: req.activity_id.unwrap_or_else(|| req.seq.to_string()), + task_queue: req.task_queue.unwrap_or_default(), + schedule_to_start_timeout: req + .schedule_to_start_timeout + .and_then(|v| v.try_into().ok()), + start_to_close_timeout: req.start_to_close_timeout.and_then(|v| v.try_into().ok()), + schedule_to_close_timeout: req + .schedule_to_close_timeout + .and_then(|v| v.try_into().ok()), + heartbeat_timeout: req.heartbeat_timeout.and_then(|v| v.try_into().ok()), + cancellation_type: req.cancellation_type.into(), + arguments: req.args, + retry_policy: req.retry_policy, + priority: req.priority.map(Into::into), + do_not_eagerly_execute: req.do_not_eagerly_execute, + ..Default::default() + }), + string_user_metadata(req.summary, None), + ); + } + + fn cancel_activity(&self, seq: u32) { + self.push_command( + workflow_command::Variant::RequestCancelActivity(RequestCancelActivity { seq }), + None, + ); + } + + fn schedule_local_activity(&self, req: ScheduleLocalActivityRequest) { + self.push_command( + workflow_command::Variant::ScheduleLocalActivity(ScheduleLocalActivity { + seq: req.seq, + activity_type: req.activity_type, + activity_id: req.activity_id.unwrap_or_else(|| req.seq.to_string()), + arguments: req.args, + retry_policy: Some(req.retry_policy), + attempt: req.attempt.unwrap_or(1), + original_schedule_time: req.original_schedule_time.map(Into::into), + local_retry_threshold: req.timer_backoff_threshold.and_then(|v| v.try_into().ok()), + cancellation_type: req.cancellation_type.into(), + schedule_to_close_timeout: req + .schedule_to_close_timeout + .and_then(|v| v.try_into().ok()), + schedule_to_start_timeout: req + .schedule_to_start_timeout + .and_then(|v| v.try_into().ok()), + start_to_close_timeout: req.start_to_close_timeout.and_then(|v| v.try_into().ok()), + ..Default::default() + }), + string_user_metadata(req.summary, None), + ); + } + + fn cancel_local_activity(&self, seq: u32) { + self.push_command( + workflow_command::Variant::RequestCancelLocalActivity(RequestCancelLocalActivity { + seq, + }), + None, + ); + } + + fn start_child_workflow(&self, req: StartChildWorkflowRequest) { + self.push_command( + workflow_command::Variant::StartChildWorkflowExecution(StartChildWorkflowExecution { + seq: req.seq, + workflow_type: req.workflow_type, + workflow_id: req.workflow_id, + task_queue: req.task_queue.unwrap_or_default(), + input: req.args, + cancellation_type: req.cancellation_type.into(), + workflow_id_reuse_policy: req.id_reuse_policy.into(), + workflow_execution_timeout: req.execution_timeout.and_then(|v| v.try_into().ok()), + workflow_run_timeout: req.run_timeout.and_then(|v| v.try_into().ok()), + workflow_task_timeout: req.task_timeout.and_then(|v| v.try_into().ok()), + search_attributes: (!req.search_attributes.is_empty()).then_some( + SearchAttributes { + indexed_fields: req.search_attributes.into_payload_map(), + }, + ), + cron_schedule: req.cron_schedule.unwrap_or_default(), + parent_close_policy: req.parent_close_policy.into(), + priority: req.priority.map(Into::into), + ..Default::default() + }), + string_user_metadata(req.static_summary, req.static_details), + ); + } + + fn cancel_child_workflow(&self, req: CancelChildWorkflowRequest) { + self.push_command( + workflow_command::Variant::CancelChildWorkflowExecution(CancelChildWorkflowExecution { + child_workflow_seq: req.seq, + reason: req.reason.unwrap_or_default(), + }), + None, + ); + } + + fn request_cancel_external_workflow(&self, req: RequestCancelExternalWorkflowRequest) { + let workflow_execution = + temporalio_common::protos::coresdk::common::NamespacedWorkflowExecution { + namespace: req.namespace.unwrap_or_default(), + workflow_id: req.workflow_id, + run_id: req.run_id.unwrap_or_default(), + }; + self.push_command( + workflow_command::Variant::RequestCancelExternalWorkflowExecution( + RequestCancelExternalWorkflowExecution { + seq: req.seq, + workflow_execution: Some(workflow_execution), + reason: req.reason.unwrap_or_default(), + }, + ), + None, + ); + } + + fn signal_external_workflow(&self, req: SignalExternalWorkflowRequest) { + let target = match req.target { + SignalWorkflowTarget::WorkflowExecution(target) => { + temporalio_common::protos::coresdk::workflow_commands::signal_external_workflow_execution::Target::WorkflowExecution( + temporalio_common::protos::coresdk::common::NamespacedWorkflowExecution { + namespace: target.namespace, + workflow_id: target.workflow_id, + run_id: target.run_id.unwrap_or_default(), + }, + ) + } + SignalWorkflowTarget::ChildWorkflowId(child_workflow_id) => { + temporalio_common::protos::coresdk::workflow_commands::signal_external_workflow_execution::Target::ChildWorkflowId( + child_workflow_id, + ) + } + }; + self.push_command( + workflow_command::Variant::SignalExternalWorkflowExecution( + SignalExternalWorkflowExecution { + seq: req.seq, + signal_name: req.signal.name, + args: req.signal.args, + target: Some(target), + headers: req.signal.headers.into_payload_map(), + }, + ), + None, + ); + } + + fn cancel_signal_external_workflow(&self, seq: u32) { + self.push_command( + workflow_command::Variant::CancelSignalWorkflow(CancelSignalWorkflow { seq }), + None, + ); + } + + fn schedule_nexus_operation(&self, req: ScheduleNexusOperationRequest) { + self.push_command( + workflow_command::Variant::ScheduleNexusOperation(ScheduleNexusOperation { + seq: req.seq, + endpoint: req.endpoint, + service: req.service, + operation: req.operation, + input: req.input, + schedule_to_close_timeout: req + .schedule_to_close_timeout + .and_then(|v| v.try_into().ok()), + schedule_to_start_timeout: req + .schedule_to_start_timeout + .and_then(|v| v.try_into().ok()), + start_to_close_timeout: req.start_to_close_timeout.and_then(|v| v.try_into().ok()), + nexus_header: req + .headers + .into_iter() + .map(|header| (header.key, header.value)) + .collect(), + cancellation_type: req.cancellation_type.into(), + }), + None, + ); + } + + fn cancel_nexus_operation(&self, req: RequestCancelNexusOperationRequest) { + self.push_command( + workflow_command::Variant::RequestCancelNexusOperation(RequestCancelNexusOperation { + seq: req.seq, + }), + None, + ); + } + + fn upsert_search_attributes(&self, entries: Vec) { + self.push_command( + workflow_command::Variant::UpsertWorkflowSearchAttributes( + UpsertWorkflowSearchAttributes { + search_attributes: Some(SearchAttributes { + indexed_fields: entries.into_payload_map(), + }), + }, + ), + None, + ); + } + + fn upsert_memo(&self, entries: Vec) { + self.push_command( + workflow_command::Variant::ModifyWorkflowProperties(ModifyWorkflowProperties { + upserted_memo: Some(Memo { + fields: entries.into_payload_map(), + }), + }), + None, + ); + } + + fn set_patch_marker(&self, patch_id: String, deprecated: bool) { + self.push_command( + workflow_command::Variant::SetPatchMarker(SetPatchMarker { + patch_id, + deprecated, + }), + None, + ); + } + + fn continue_as_new(&self, req: temporalio_workflow::runtime::types::ContinueAsNewRequest) { + self.push_command( + workflow_command::Variant::ContinueAsNewWorkflowExecution( + ContinueAsNewWorkflowExecution { + workflow_type: req.workflow_type.unwrap_or_default(), + task_queue: req.task_queue.unwrap_or_default(), + arguments: req.args, + workflow_run_timeout: req.run_timeout.and_then(|v| v.try_into().ok()), + workflow_task_timeout: req.task_timeout.and_then(|v| v.try_into().ok()), + memo: req.memo.into_payload_map(), + headers: req.headers.into_payload_map(), + search_attributes: req.search_attributes.map(|entries| SearchAttributes { + indexed_fields: entries.into_payload_map(), + }), + retry_policy: req.retry_policy, + versioning_intent: req.versioning_intent.unwrap_or_default().into(), + initial_versioning_behavior: req + .initial_versioning_behavior + .unwrap_or_default() + .into(), + }, + ), + None, + ); + } } pub(crate) struct WorkflowFuture { /// The workflow execution instance - execution: Box, + execution: Box, + /// Native host adapter collecting commands emitted by guest workflow code. + host: Rc, /// The tracing span for this workflow span: tracing::Span, - /// Commands produced inside user's wf code - incoming_commands: Receiver, /// Once blocked or the workflow has finished or errored out, the result is sent here outgoing_completions: UnboundedSender, /// Activations from core - incoming_activations: UnboundedReceiver, - /// Commands by ID -> blocked status - command_status: HashMap, - /// Use to notify workflow code of cancellation - cancel_sender: watch::Sender>, - /// Base workflow context for sending commands - base_ctx: BaseWorkflowContext, - /// Data converter for workflow failure conversion and payload serialization. - data_converter: DataConverter, - /// Stores in-progress update futures - update_futures: Vec<( - String, - LocalBoxFuture<'static, Result>, - )>, - /// Stores in-progress signal futures - signal_futures: Vec>>, + incoming_activations: UnboundedReceiver, /// Nondeterminism detection tracker. When present, a tracking waker is used /// for polling user workflow code, and any non-SDK wake is flagged. wake_tracking: Option, + /// Signal and update routines that are still live across activations. + active_routines: Vec, } -impl WorkflowFuture { - fn workflow_message_to_failure(&self, message: String) -> Failure { - self.workflow_error_to_failure(anyhow!(message).into()) - } - - fn workflow_error_to_failure(&self, error: crate::workflows::WorkflowError) -> Failure { - let outgoing = match error { - crate::workflows::WorkflowError::PayloadConversion(err) => { - OutgoingWorkflowError::from(err) - } - crate::workflows::WorkflowError::Execution(err) => err.into(), - }; - self.data_converter.to_failure( - &SerializationContextData::Workflow, - OutgoingError::Workflow(outgoing), - ) - } - - fn unblock(&mut self, event: UnblockEvent) -> Result<(), Error> { - let cmd_id = match event { - UnblockEvent::Timer(seq, _) => CommandID::Timer(seq), - UnblockEvent::Activity(seq, _) => CommandID::Activity(seq), - UnblockEvent::WorkflowStart(seq, _) => CommandID::ChildWorkflowStart(seq), - UnblockEvent::WorkflowComplete(seq, _) => CommandID::ChildWorkflowComplete(seq), - UnblockEvent::SignalExternal(seq, _) => CommandID::SignalExternal(seq), - UnblockEvent::CancelExternal(seq, _) => CommandID::CancelExternal(seq), - UnblockEvent::NexusOperationStart(seq, _) => CommandID::NexusOpStart(seq), - UnblockEvent::NexusOperationComplete(seq, _) => CommandID::NexusOpComplete(seq), - }; - let unblocker = self.command_status.remove(&cmd_id); - let _guard = SdkWakeGuard::new(); - let _ = unblocker - .ok_or_else(|| anyhow!("Command {cmd_id:?} not found to unblock!"))? - .unblocker - .send(event); - Ok(()) - } +#[derive(Debug)] +enum ActivationJobContext { + Passive, + Signal, + Update { protocol_instance_id: String }, + Query { query_id: String }, +} +impl WorkflowFuture { fn fail_wft(&self, run_id: String, fail: Error, cause: Option) { warn!("Workflow task failed for {}: {}", run_id, fail); self.outgoing_completions @@ -227,256 +495,290 @@ impl WorkflowFuture { .expect("Completion channel intact"); } - /// Handle a particular workflow activation job. - /// - /// Returns `Ok(should_poll_wf)` where `should_poll_wf` indicates whether the workflow future - /// should be polled after this job. Query jobs return false since an activation containing - /// _only_ queries must not advance the workflow. - /// - /// Returns an error in the event that the workflow task should be failed. - /// - /// Panics if internal assumptions are violated - fn handle_job( - &mut self, - variant: Option, - outgoing_cmds: &mut Vec, - ) -> Result { - if let Some(v) = variant { - match v { + fn translate_activation( + &self, + activation: CoreWorkflowActivation, + ) -> Result<(WorkflowActivation, Vec, bool), Error> { + let context = activation_context_from_activation(&activation); + let mut should_poll_routines = false; + let mut jobs = Vec::with_capacity(activation.jobs.len()); + let mut job_contexts = Vec::with_capacity(activation.jobs.len()); + macro_rules! push_job { + ($job:expr, $context:expr $(,)?) => {{ + jobs.push($job); + job_contexts.push($context); + }}; + } + macro_rules! push_polled_job { + ($job:expr, $context:expr $(,)?) => {{ + should_poll_routines = true; + push_job!($job, $context); + }}; + } + macro_rules! push_resolution { + ($resolution:expr $(,)?) => { + push_polled_job!( + WorkflowActivationJob::Resolution($resolution), + ActivationJobContext::Passive + ) + }; + } + for CoreWorkflowActivationJob { variant } in activation.jobs { + match variant.context("Empty activation job variant")? { Variant::InitializeWorkflow(_) => { - // Don't do anything in here. Init workflow is looked at earlier, before - // jobs are handled, and may have information taken out of it to avoid clones. + should_poll_routines = true; } Variant::FireTimer(FireTimer { seq }) => { - self.unblock(UnblockEvent::Timer(seq, TimerResult::Fired))? + push_resolution!(WorkflowResolution::TimerFired( + temporalio_workflow::runtime::types::TimerFiredEvent { seq }, + )); } Variant::ResolveActivity(ResolveActivity { seq, result, .. }) => { - self.unblock(UnblockEvent::Activity( - seq, - Box::new(result.context("Activity must have result")?), - ))?; + push_resolution!(WorkflowResolution::Activity( + temporalio_workflow::runtime::types::ActivityResolutionEvent { + seq, + result: result.context("Activity must have result")?, + }, + )); } Variant::ResolveChildWorkflowExecutionStart( ResolveChildWorkflowExecutionStart { seq, status }, - ) => self.unblock(UnblockEvent::WorkflowStart( - seq, - Box::new(status.context("Workflow start must have status")?), - ))?, + ) => { + push_resolution!(WorkflowResolution::ChildWorkflowStart( + temporalio_workflow::runtime::types::ChildWorkflowStartResolutionEvent { + seq, + status: status.context("Workflow start must have status")?, + }, + )); + } Variant::ResolveChildWorkflowExecution(ResolveChildWorkflowExecution { seq, result, - }) => self.unblock(UnblockEvent::WorkflowComplete( - seq, - Box::new(result.context("Child Workflow execution must have a result")?), - ))?, - Variant::UpdateRandomSeed(rs) => { - self.base_ctx.shared_mut().random_seed = rs.randomness_seed; + }) => { + push_resolution!(WorkflowResolution::ChildWorkflow( + temporalio_workflow::runtime::types::ChildWorkflowResolutionEvent { + seq, + result: result + .context("Child Workflow execution must have a result")?, + }, + )); + } + Variant::UpdateRandomSeed(_) => { + should_poll_routines = true; } Variant::QueryWorkflow(q) => { debug!(query_type = %q.query_type, "Query received"); - let query_type = q.query_type; - let query_id = q.query_id; - let data = DispatchData { - payloads: Payloads { - payloads: q.arguments, - }, - headers: q.headers, - converter: self.data_converter.payload_converter(), - }; - - let dispatch_result = if query_type == "__temporal_workflow_metadata" { - // Mirror the proto JSON shape of temporal.api.sdk.v1.WorkflowMetadata. - // TODO [rust-sdk-branch]: support normal JSON and proto JSON serialization, and this will no longer be necessary. - #[derive(serde::Serialize)] - struct WorkflowMetadataJson { - #[serde( - rename = "currentDetails", - skip_serializing_if = "String::is_empty" - )] - current_details: String, - } - let converter = PayloadConverter::default(); - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; - let payload = converter.to_payload( - &ctx, - &WorkflowMetadataJson { - current_details: self.base_ctx.current_details(), + push_job!( + WorkflowActivationJob::Query( + temporalio_workflow::runtime::types::QueryInvocation { + name: q.query_type, + args: q.arguments, + headers: q.headers.into_named_payloads(), }, - ); - Some(Ok(payload?)) - } else { - match panic::catch_unwind(AssertUnwindSafe(|| { - self.execution.dispatch_query(&query_type, data) - })) { - Ok(r) => r, - Err(e) => Some(Err(anyhow!( - "Panic in query handler: {}", - panic_formatter(e) - ) - .into())), - } - }; - - let response = match dispatch_result { - Some(Ok(payload)) => query_result::Variant::Succeeded(QuerySuccess { - response: Some(payload), - }), - // TODO [rust-sdk-branch]: Return list of known queries in error - None => query_result::Variant::Failed(self.workflow_message_to_failure( - format!("No query handler for '{}'", query_type), - )), - Some(Err(e)) => { - query_result::Variant::Failed(self.workflow_error_to_failure(e)) - } - }; - - outgoing_cmds.push( - workflow_command::Variant::RespondToQuery(QueryResult { - query_id, - variant: Some(response), - }) - .into(), + ), + ActivationJobContext::Query { + query_id: q.query_id, + }, ); - return Ok(false); } Variant::CancelWorkflow(c) => { - // TODO: Cancel pending futures, etc - let _guard = SdkWakeGuard::new(); - self.cancel_sender - .send(Some(c.reason)) - .expect("Cancel rx not dropped"); + push_polled_job!( + WorkflowActivationJob::Cancel { reason: c.reason }, + ActivationJobContext::Passive, + ); } Variant::SignalWorkflow(sig) => { debug!(signal_name = %sig.signal_name, "Signal received"); - let data = DispatchData { - payloads: Payloads { - payloads: sig.input, - }, - headers: sig.headers, - converter: self.data_converter.payload_converter(), - }; - - let dispatch_result = match panic::catch_unwind(AssertUnwindSafe(|| { - self.execution.dispatch_signal(&sig.signal_name, data) - })) { - Ok(r) => r, - Err(e) => { - bail!("Panic in signal handler: {}", panic_formatter(e)); - } - }; - - if let Some(fut) = dispatch_result { - self.signal_futures.push(fut); - } - // TODO [rust-sdk-branch]: Buffer signals w/ no handler + push_polled_job!( + WorkflowActivationJob::Signal( + temporalio_workflow::runtime::types::SignalInvocation { + name: sig.signal_name, + args: sig.input, + headers: sig.headers.into_named_payloads(), + }, + ), + ActivationJobContext::Signal, + ); } Variant::NotifyHasPatch(NotifyHasPatch { patch_id }) => { - self.base_ctx.shared_mut().changes.insert(patch_id, true); + push_polled_job!( + WorkflowActivationJob::NotifyPatch { patch_id }, + ActivationJobContext::Passive, + ); } Variant::ResolveSignalExternalWorkflow(attrs) => { - self.unblock(UnblockEvent::SignalExternal(attrs.seq, attrs.failure))?; + push_resolution!(WorkflowResolution::ExternalSignal( + temporalio_workflow::runtime::types::ExternalSignalResolutionEvent { + seq: attrs.seq, + failure: attrs.failure, + }, + )); } Variant::ResolveRequestCancelExternalWorkflow(attrs) => { - self.unblock(UnblockEvent::CancelExternal(attrs.seq, attrs.failure))?; + push_resolution!(WorkflowResolution::ExternalCancel( + temporalio_workflow::runtime::types::ExternalCancelResolutionEvent { + seq: attrs.seq, + failure: attrs.failure, + }, + )); } Variant::DoUpdate(u) => { let protocol_instance_id = u.protocol_instance_id; - let name = u.name; - let data = DispatchData { - payloads: Payloads { payloads: u.input }, - headers: u.headers, - converter: self.data_converter.payload_converter(), - }; + push_polled_job!( + WorkflowActivationJob::Update( + temporalio_workflow::runtime::types::UpdateInvocation { + update_id: u.id, + protocol_instance_id: protocol_instance_id.clone(), + name: u.name, + args: u.input, + headers: u.headers.into_named_payloads(), + run_validator: u.run_validator, + }, + ), + ActivationJobContext::Update { + protocol_instance_id, + }, + ); + } + Variant::ResolveNexusOperationStart(attrs) => { + push_resolution!(WorkflowResolution::NexusStart( + temporalio_workflow::runtime::types::NexusStartResolutionEvent { + seq: attrs.seq, + status: attrs + .status + .context("Nexus operation start must have status")?, + }, + )); + } + Variant::ResolveNexusOperation(attrs) => { + push_resolution!(WorkflowResolution::Nexus( + temporalio_workflow::runtime::types::NexusResolutionEvent { + seq: attrs.seq, + result: attrs.result.context("Nexus operation must have result")?, + }, + )); + } + Variant::RemoveFromCache(_) => { + unreachable!("Cache removal should happen higher up"); + } + } + } - let trait_val_result = if u.run_validator { - match panic::catch_unwind(AssertUnwindSafe(|| { - self.execution.validate_update(&name, &data) - })) { - Ok(r) => r, - Err(e) => { - bail!("Panic in update validator {}", panic_formatter(e)); - } - } - } else { - Some(Ok(())) - }; + Ok(( + WorkflowActivation { context, jobs }, + job_contexts, + should_poll_routines, + )) + } - let mut not_found = false; - match trait_val_result { - Some(Ok(())) => { - if let Some(fut) = self.execution.start_update(&name, data) { - outgoing_cmds.push( - update_response( - protocol_instance_id.clone(), - update_response::Response::Accepted(()), - ) - .into(), - ); - self.update_futures - .push((protocol_instance_id.clone(), fut)); - } else { - not_found = true; - } - } - Some(Err(e)) => { - outgoing_cmds.push( - update_response( - protocol_instance_id.clone(), - update_response::Response::Rejected( - self.workflow_error_to_failure(e), - ), - ) - .into(), + fn process_activation_results( + &mut self, + job_contexts: Vec, + activation_result: ActivationResult, + outgoing_cmds: &mut Vec, + ) -> Result<(), Error> { + if job_contexts.len() != activation_result.job_results.len() { + bail!( + "Activation result count {} did not match job count {}", + activation_result.job_results.len(), + job_contexts.len() + ); + } + + for (job_context, job_result) in job_contexts.into_iter().zip(activation_result.job_results) + { + match (job_context, job_result) { + (ActivationJobContext::Passive, ActivationJobResult::None) + | (ActivationJobContext::Signal, ActivationJobResult::None) => {} + ( + ActivationJobContext::Signal, + ActivationJobResult::StartedRoutine(started_routine), + ) => match started_routine.kind { + RoutineKind::Signal { .. } => { + self.active_routines.push(started_routine.routine_id) + } + other => bail!("Signal job started unexpected routine kind {other:?}"), + }, + ( + ActivationJobContext::Update { + protocol_instance_id, + }, + ActivationJobResult::StartedRoutine(started_routine), + ) => match started_routine.kind { + RoutineKind::Update { + protocol_instance_id: started_id, + .. + } => { + if started_id != protocol_instance_id { + bail!( + "Update routine protocol instance id {} did not match {}", + started_id, + protocol_instance_id ); } - None => { - not_found = true; - } - } - if not_found { outgoing_cmds.push( update_response( protocol_instance_id, - update_response::Response::Rejected( - format!( - "No update handler registered for update name {}", - name - ) - .into(), - ), + update_response::Response::Accepted(()), ) .into(), ); + self.active_routines.push(started_routine.routine_id); } + other => bail!("Update job started unexpected routine kind {other:?}"), + }, + ( + ActivationJobContext::Update { + protocol_instance_id, + }, + ActivationJobResult::UpdateRejected(failure), + ) => { + outgoing_cmds.push( + update_response( + protocol_instance_id, + update_response::Response::Rejected(*failure), + ) + .into(), + ); } - Variant::ResolveNexusOperationStart(attrs) => { - self.unblock(UnblockEvent::NexusOperationStart( - attrs.seq, - Box::new( - attrs - .status - .context("Nexus operation start must have status")?, - ), - ))? - } - Variant::ResolveNexusOperation(attrs) => { - self.unblock(UnblockEvent::NexusOperationComplete( - attrs.seq, - Box::new(attrs.result.context("Nexus operation must have result")?), - ))? - } - Variant::RemoveFromCache(_) => { - unreachable!("Cache removal should happen higher up"); + ( + ActivationJobContext::Query { query_id }, + ActivationJobResult::QueryResponse(query_response), + ) => outgoing_cmds.push(query_response_command(query_id, *query_response)), + (job_context, job_result) => { + bail!("Unexpected activation result {job_result:?} for job {job_context:?}"); } } - } else { - bail!("Empty activation job variant"); } - Ok(true) + Ok(()) + } + + fn poll_guest_routine( + &mut self, + routine_id: RoutineId, + cx: &Context<'_>, + ) -> Result { + let span = self.span.clone(); + let _guard = span.enter(); + if let Some(ref tracker) = self.wake_tracking { + let waker = tracker.new_per_poll_waker(cx.waker()); + match panic::catch_unwind(AssertUnwindSafe(|| { + self.execution.poll_routine(routine_id, &waker) + })) { + Ok(Ok(result)) => Ok(result), + Ok(Err(err)) => Err(anyhow!(err.message)), + Err(e) => bail!("Workflow function panicked: {}", panic_formatter(e)), + } + } else { + match panic::catch_unwind(AssertUnwindSafe(|| { + self.execution.poll_routine(routine_id, cx.waker()) + })) { + Ok(Ok(result)) => Ok(result), + Ok(Err(err)) => Err(anyhow!(err.message)), + Err(e) => bail!("Workflow function panicked: {}", panic_formatter(e)), + } + } } } @@ -485,9 +787,6 @@ impl Future for WorkflowFuture { fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 'activations: loop { - // WF must always receive an activation first before responding with commands. - // Use the executor waker (cx) for the activation channel -- these wakes are - // always legitimate and should not be tracked. let activation = match self.incoming_activations.poll_recv(cx) { Poll::Ready(a) => match a { Some(act) => act, @@ -502,22 +801,11 @@ impl Future for WorkflowFuture { }; let is_only_eviction = activation.is_only_eviction(); - let run_id = activation.run_id; - { - let mut wlock = self.base_ctx.shared_mut(); - wlock.is_replaying = activation.is_replaying; - wlock.wf_time = activation.timestamp.try_into_or_none(); - wlock.history_length = activation.history_length; - wlock.continue_as_new_suggested = activation.continue_as_new_suggested; - wlock.current_deployment_version = activation - .deployment_version_for_current_task - .map(Into::into); - } + let run_id = activation.run_id.clone(); let mut activation_cmds = vec![]; if is_only_eviction { - // No need to do anything with the workflow code in this case self.outgoing_completions .send(WorkflowActivationCompletion::from_cmds(run_id, vec![])) .expect("Completion channel intact"); @@ -538,310 +826,164 @@ impl Future for WorkflowFuture { or std::thread. Use SDK-provided alternatives \ (ctx.timer(), ctx.state_mut() + ctx.wait_condition(), etc.) instead." ), - Some(WorkflowTaskFailedCause::NonDeterministicError) + Some(WorkflowTaskFailedCause::NonDeterministicError), ); continue 'activations; } - let mut should_poll_wf = false; - for WorkflowActivationJob { variant } in activation.jobs { - match self.handle_job(variant, &mut activation_cmds) { - Ok(should_poll) => should_poll_wf |= should_poll, + let (guest_activation, job_contexts, should_poll_routines) = + match self.translate_activation(activation) { + Ok(translated) => translated, Err(e) => { self.fail_wft(run_id, e, None); continue 'activations; } - } - } - - // Poll sub-futures using the tracked context if available, - // otherwise use the executor context. - let repoll = if let Some(ref tracker) = self.wake_tracking { - let waker = tracker.new_per_poll_waker(cx.waker()); - let mut tcx = Context::from_waker(&waker); - self.poll_sub_futures(&mut tcx, should_poll_wf, &run_id, &mut activation_cmds)? - } else { - self.poll_sub_futures(cx, should_poll_wf, &run_id, &mut activation_cmds)? - }; - if repoll { - continue 'activations; - } - - // TODO: deadlock detector - // Check if there's nothing to unblock and workflow has not completed. - // This is different from the assertion that was here before that checked that WF did - // not produce any commands which is completely viable in the case WF is waiting on - // multiple completions. + }; - self.send_completion(run_id, activation_cmds); - - // We don't actually return here, since we could be queried after finishing executing, - // and it allows us to rely on evictions for death and cache management - } - } -} - -// Separate impl block down here just to keep it close to the future poll implementation which -// it is specific to. -impl WorkflowFuture { - /// Runs the inner re-poll loop for signal, update, and workflow futures. - /// Returns `Ok(true)` if the activation loop should continue (restart from - /// the top), `Ok(false)` if the activation is fully processed. - fn poll_sub_futures( - &mut self, - cx: &mut Context<'_>, - should_poll_wf: bool, - run_id: &str, - activation_cmds: &mut Vec, - ) -> Result { - self.base_ctx.take_state_mutated(); - loop { - // Poll signals - let signal_result: Result, _> = std::mem::take(&mut self.signal_futures) - .into_iter() - .filter_map(|mut sig_fut| match sig_fut.poll_unpin(cx) { - Poll::Ready(Ok(())) => None, - Poll::Ready(Err(e)) => Some(Err(e)), - Poll::Pending => Some(Ok(sig_fut)), - }) - .collect(); - match signal_result { - Ok(remaining) => self.signal_futures = remaining, + let activation_result = match panic::catch_unwind(AssertUnwindSafe(|| { + self.execution.activate(guest_activation) + })) { + Ok(Ok(result)) => result, + Ok(Err(err)) => { + self.fail_wft(run_id.clone(), anyhow!(err.message), None); + continue 'activations; + } Err(e) => { self.fail_wft( - run_id.to_owned(), - anyhow!("Signal handler error: {}", e), + run_id.clone(), + anyhow!("Workflow function panicked: {}", panic_formatter(e)), None, ); - return Ok(true); + continue 'activations; } - } - - // Poll updates - self.update_futures = std::mem::take(&mut self.update_futures) - .into_iter() - .filter_map( - |(instance_id, mut update_fut)| match update_fut.poll_unpin(cx) { - Poll::Ready(v) => { - self.base_ctx.send( - update_response( - instance_id, - match v { - Ok(v) => update_response::Response::Completed(v), - Err(e) => update_response::Response::Rejected( - self.workflow_error_to_failure(e), - ), - }, - ) - .into(), - ); - None - } - Poll::Pending => Some((instance_id, update_fut)), - }, - ) - .collect(); - - if should_poll_wf && self.poll_wf_future(cx, run_id, activation_cmds)? { - return Ok(true); - } - - if !self.base_ctx.take_state_mutated() { - break; - } - } - Ok(false) - } + }; - /// Returns true if the workflow future polling loop should be continued - fn poll_wf_future( - &mut self, - cx: &mut Context, - run_id: &str, - activation_cmds: &mut Vec, - ) -> Result { - // SAFETY: AssertUnwindSafe is safe here because: - // 1. Workflows run in a single-threaded LocalSet - no data races possible - // 2. Workflow state uses closure-scoped borrows (RefCell guards released on unwind) - // 3. Core sends eviction after WFT failure, discarding all workflow state - let mut res = { - let _guard = self.span.enter(); - match panic::catch_unwind(AssertUnwindSafe(|| self.execution.poll_run(cx))) { - Ok(r) => r, - Err(e) => { - let errmsg = format!("Workflow function panicked: {}", panic_formatter(e)); - warn!("{}", errmsg); - self.outgoing_completions - .send(WorkflowActivationCompletion::fail( - run_id, - self.data_converter.to_failure( - &SerializationContextData::Workflow, - OutgoingError::Workflow(OutgoingWorkflowError::Application( - Box::new(crate::ApplicationFailure::non_retryable(anyhow!( - "{errmsg}" - ))), - )), - ), - None, - )) - .expect("Completion channel intact"); - // Loop back up because we're about to get evicted - return Ok(true); - } + if let Err(e) = self.process_activation_results( + job_contexts, + activation_result, + &mut activation_cmds, + ) { + self.fail_wft(run_id.clone(), e, None); + continue 'activations; } - }; - while let Ok(cmd) = self.incoming_commands.try_recv() { - match cmd { - RustWfCmd::Cancel(cancellable_id) => { - let cmd_variant = match cancellable_id { - CancellableID::Timer(seq) => { - self.unblock(UnblockEvent::Timer(seq, TimerResult::Cancelled))?; - // Re-poll wf future since a timer is now unblocked - res = self.execution.poll_run(cx); - workflow_command::Variant::CancelTimer(CancelTimer { seq }) - } - CancellableID::Activity(seq) => { - workflow_command::Variant::RequestCancelActivity( - RequestCancelActivity { seq }, - ) - } - CancellableID::LocalActivity(seq) => { - workflow_command::Variant::RequestCancelLocalActivity( - RequestCancelLocalActivity { seq }, - ) - } - CancellableID::ChildWorkflow { seqnum, reason } => { - workflow_command::Variant::CancelChildWorkflowExecution( - CancelChildWorkflowExecution { - child_workflow_seq: seqnum, - reason, - }, - ) - } - CancellableID::SignalExternalWorkflow(seq) => { - workflow_command::Variant::CancelSignalWorkflow(CancelSignalWorkflow { - seq, - }) - } - CancellableID::NexusOp(seq) => { - workflow_command::Variant::RequestCancelNexusOperation( - RequestCancelNexusOperation { seq }, - ) + if should_poll_routines { + loop { + let mut pass_made_progress = false; + let mut should_stop_polling = false; + let mut still_active = Vec::with_capacity(self.active_routines.len()); + for routine_id in std::mem::take(&mut self.active_routines) { + let poll_result = match self.poll_guest_routine(routine_id, cx) { + Ok(result) => result, + Err(e) => { + self.fail_wft(run_id.clone(), e, None); + continue 'activations; + } + }; + pass_made_progress |= poll_result.made_progress; + match poll_result.completion { + None => still_active.push(routine_id), + Some(result) => match *result { + RoutineCompletion::Signal(Ok(())) => {} + RoutineCompletion::Signal(Err(failure)) => { + self.fail_wft(run_id.clone(), anyhow!(failure.message), None); + continue 'activations; + } + RoutineCompletion::Update(UpdateRoutineCompletion::Completed { + protocol_instance_id, + result, + }) => activation_cmds.push( + update_response( + protocol_instance_id, + update_response::Response::Completed(result), + ) + .into(), + ), + RoutineCompletion::Update(UpdateRoutineCompletion::Rejected { + protocol_instance_id, + failure, + }) => activation_cmds.push( + update_response( + protocol_instance_id, + update_response::Response::Rejected(*failure), + ) + .into(), + ), + RoutineCompletion::Main(_) => { + self.fail_wft( + run_id.clone(), + anyhow!("non-main routine returned a main completion"), + None, + ); + continue 'activations; + } + }, } - }; - activation_cmds.push(cmd_variant.into()); - } + } + self.active_routines = still_active; - RustWfCmd::NewCmd(cmd) => { - let command_id = match cmd.cmd.variant.as_ref().expect("command variant is set") - { - workflow_command::Variant::StartTimer(StartTimer { seq, .. }) => { - CommandID::Timer(*seq) - } - workflow_command::Variant::ScheduleActivity(ScheduleActivity { - seq, - .. - }) - | workflow_command::Variant::ScheduleLocalActivity( - ScheduleLocalActivity { seq, .. }, - ) => CommandID::Activity(*seq), - workflow_command::Variant::SetPatchMarker(_) => { - panic!("Set patch marker should be a nonblocking command") - } - workflow_command::Variant::StartChildWorkflowExecution(req) => { - let seq = req.seq; - CommandID::ChildWorkflowStart(seq) - } - workflow_command::Variant::SignalExternalWorkflowExecution(req) => { - CommandID::SignalExternal(req.seq) - } - workflow_command::Variant::RequestCancelExternalWorkflowExecution(req) => { - CommandID::CancelExternal(req.seq) - } - workflow_command::Variant::ScheduleNexusOperation(req) => { - CommandID::NexusOpStart(req.seq) + let main_poll_result = match self.poll_guest_routine( + temporalio_workflow::runtime::types::MAIN_ROUTINE_ID, + cx, + ) { + Ok(result) => result, + Err(e) => { + self.fail_wft(run_id.clone(), e, None); + continue 'activations; } - _ => unimplemented!("Command type not implemented"), }; - activation_cmds.push(cmd.cmd); + pass_made_progress |= main_poll_result.made_progress; - self.command_status.insert( - command_id, - WFCommandFutInfo { - unblocker: cmd.unblocker, - }, - ); - } - RustWfCmd::NewNonblockingCmd(cmd) => activation_cmds.push(cmd.into()), - RustWfCmd::SubscribeChildWorkflowCompletion(sub) => { - self.command_status.insert( - CommandID::ChildWorkflowComplete(sub.seq), - WFCommandFutInfo { - unblocker: sub.unblocker, + match main_poll_result.completion { + None => { + self.fail_wft( + run_id.clone(), + anyhow!("main routine returned no completion"), + None, + ); + continue 'activations; + } + Some(result) => match *result { + RoutineCompletion::Main(MainRoutineCompletion::Blocked) => {} + RoutineCompletion::Main(MainRoutineCompletion::TaskFailed( + task_failure, + )) => { + self.fail_wft( + run_id.clone(), + anyhow!(task_failure.failure.message), + None, + ); + continue 'activations; + } + RoutineCompletion::Main(MainRoutineCompletion::Terminal(outcome)) => { + emit_terminal_outcome(&self.host, *outcome); + should_stop_polling = true; + } + other => { + self.fail_wft( + run_id.clone(), + anyhow!( + "main routine returned unexpected completion {other:?}" + ), + None, + ); + continue 'activations; + } }, - ); - } - RustWfCmd::ForceWFTFailure(err) => { - self.fail_wft(run_id.to_string(), err, None); - return Ok(true); - } - RustWfCmd::SubscribeNexusOperationCompletion { seq, unblocker } => { - self.command_status.insert( - CommandID::NexusOpComplete(seq), - WFCommandFutInfo { unblocker }, - ); + } + + if should_stop_polling || !pass_made_progress { + break; + } } } - } - if let Poll::Ready(res) = res { - let cmd = match res { - Ok(result) => workflow_command::Variant::CompleteWorkflowExecution( - CompleteWorkflowExecution { - result: Some(result), - }, - ), - Err(termination) => match termination { - WorkflowTermination::ContinueAsNew(cmd) => { - workflow_command::Variant::ContinueAsNewWorkflowExecution(*cmd) - } - WorkflowTermination::Cancelled => { - workflow_command::Variant::CancelWorkflowExecution( - CancelWorkflowExecution {}, - ) - } - WorkflowTermination::Evicted => { - panic!("Don't explicitly return WorkflowTermination::Evicted") - } - WorkflowTermination::Failed(e) => { - workflow_command::Variant::FailWorkflowExecution(FailWorkflowExecution { - failure: Some(self.data_converter.to_failure( - &SerializationContextData::Workflow, - OutgoingError::Workflow(e), - )), - }) - } - }, - }; - activation_cmds.push(cmd.into()) + activation_cmds.extend(self.host.take_commands()); + self.send_completion(run_id, activation_cmds); } - Ok(false) } } -#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] -enum CommandID { - Timer(u32), - Activity(u32), - ChildWorkflowStart(u32), - ChildWorkflowComplete(u32), - SignalExternal(u32), - CancelExternal(u32), - NexusOpStart(u32), - NexusOpComplete(u32), -} - fn update_response( instance_id: String, resp: update_response::Response, @@ -852,3 +994,123 @@ fn update_response( } .into() } + +fn query_response_command( + query_id: String, + response: temporalio_workflow::runtime::types::QueryResponse, +) -> WorkflowCommand { + workflow_command::Variant::RespondToQuery(QueryResult { + query_id, + variant: Some(match response.result { + Ok(payload) => query_result::Variant::Succeeded(QuerySuccess { + response: Some(payload), + }), + Err(failure) => query_result::Variant::Failed(failure), + }), + }) + .into() +} + +fn string_user_metadata(summary: Option, details: Option) -> Option { + if summary.is_none() && details.is_none() { + return None; + } + let converter = PayloadConverter::default(); + let context = SerializationContext { + data: &SerializationContextData::Workflow, + converter: &converter, + }; + Some(UserMetadata { + summary: summary.map(|value| { + converter + .to_payload(&context, &value) + .expect("String-to-JSON payload serialization is infallible") + }), + details: details.map(|value| { + converter + .to_payload(&context, &value) + .expect("String-to-JSON payload serialization is infallible") + }), + }) +} + +fn emit_terminal_outcome(host: &NativeWorkflowHost, outcome: TerminalOutcome) { + match outcome { + TerminalOutcome::Completed(result) => { + host.push_command( + workflow_command::Variant::CompleteWorkflowExecution(CompleteWorkflowExecution { + result: Some(result), + }), + None, + ); + } + TerminalOutcome::Failed(failure) => { + host.push_command( + workflow_command::Variant::FailWorkflowExecution(FailWorkflowExecution { + failure: Some(*failure), + }), + None, + ); + } + TerminalOutcome::Cancelled => { + host.push_command( + workflow_command::Variant::CancelWorkflowExecution(CancelWorkflowExecution {}), + None, + ); + } + TerminalOutcome::ContinueAsNew(req) => host.continue_as_new(req), + } +} + +fn activation_context_from_activation(activation: &CoreWorkflowActivation) -> ActivationContext { + let updated_randomness_seed = activation.jobs.iter().find_map(|job| match &job.variant { + Some(Variant::UpdateRandomSeed(attrs)) => Some(attrs.randomness_seed), + _ => None, + }); + ActivationContext { + workflow_time: activation.timestamp.try_into_or_none(), + is_replaying: activation.is_replaying, + history_length: activation.history_length, + history_size_bytes: activation.history_size_bytes, + continue_as_new_suggested: activation.continue_as_new_suggested, + current_deployment_version: activation + .deployment_version_for_current_task + .clone() + .map(Into::into), + last_sdk_version: (!activation.last_sdk_version.is_empty()) + .then_some(activation.last_sdk_version.clone()), + available_internal_flags: activation.available_internal_flags.clone(), + updated_randomness_seed, + target_worker_deployment_version_changed: activation + .target_worker_deployment_version_changed, + suggest_continue_as_new_reasons: activation + .suggest_continue_as_new_reasons + .iter() + .filter_map(|v| SuggestContinueAsNewReason::try_from(*v).ok()) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use temporalio_common::protos::coresdk::workflow_activation::{ + UpdateRandomSeed, WorkflowActivationJob, + }; + + #[test] + fn activation_context_preserves_updated_random_seed() { + let activation = CoreWorkflowActivation { + jobs: vec![WorkflowActivationJob { + variant: Some(Variant::UpdateRandomSeed(UpdateRandomSeed { + randomness_seed: 1234, + })), + }], + ..Default::default() + }; + + let context = activation_context_from_activation(&activation); + + assert_eq!(context.updated_randomness_seed, Some(1234)); + } +} diff --git a/crates/sdk/src/workflow_registry.rs b/crates/sdk/src/workflow_registry.rs new file mode 100644 index 000000000..1a1f03916 --- /dev/null +++ b/crates/sdk/src/workflow_registry.rs @@ -0,0 +1,112 @@ +use std::{collections::HashMap, fmt::Debug, sync::Arc}; + +use temporalio_common::{ + WorkflowDefinition, + data_converters::{ + GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, + SerializationContextData, + }, + protos::temporal::api::common::v1::Payload, +}; +use temporalio_workflow::runtime::{ + BaseWorkflowContext, + entry::WorkflowImplementation, + guest::WorkflowInstance, + instance::{GuestWorkflowInstance, instantiate_workflow}, +}; + +/// Creates workflow execution instances from activation input payloads and context. +pub(crate) type WorkflowExecutionFactory = Arc< + dyn Fn( + Vec, + PayloadConverter, + BaseWorkflowContext, + ) -> Result, PayloadConversionError> + + Send + + Sync, +>; + +/// Contains workflow registrations in a form ready for execution by workers. +#[derive(Default, Clone)] +pub struct WorkflowDefinitions { + workflows: HashMap<&'static str, WorkflowExecutionFactory>, +} + +impl WorkflowDefinitions { + /// Creates a new empty `WorkflowDefinitions`. + pub fn new() -> Self { + Self::default() + } + + /// Register a workflow implementation. + pub fn register_workflow(&mut self) -> &mut Self + where + ::Input: Send, + { + let workflow_name = W::name(); + let factory: WorkflowExecutionFactory = + Arc::new(move |payloads, converter: PayloadConverter, base_ctx| { + instantiate_workflow::(payloads, converter, base_ctx) + }); + self.workflows.insert(workflow_name, factory); + self + } + + /// Register a workflow with a custom factory for instance creation. + pub fn register_workflow_run_with_factory(&mut self, user_factory: F) -> &mut Self + where + W: WorkflowImplementation, + ::Input: Send, + F: Fn() -> W + Send + Sync + 'static, + { + assert!( + !W::HAS_INIT, + "Workflows registered with a factory must not define an #[init] method. \ + The factory replaces init for instance creation." + ); + + let workflow_name = W::name(); + let user_factory = Arc::new(user_factory); + let factory: WorkflowExecutionFactory = + Arc::new(move |payloads, converter: PayloadConverter, base_ctx| { + let ser_ctx = SerializationContext { + data: &SerializationContextData::Workflow, + converter: &converter, + }; + let input: ::Input = + converter.from_payloads(&ser_ctx, payloads)?; + + let workflow = user_factory(); + Ok(Box::new(GuestWorkflowInstance::::new_with_workflow( + workflow, + base_ctx, + Some(input), + )) as Box) + }); + + self.workflows.insert(workflow_name, factory); + self + } + + /// Check if any workflows are registered. + pub fn is_empty(&self) -> bool { + self.workflows.is_empty() + } + + pub(crate) fn get_workflow(&self, workflow_type: &str) -> Option { + self.workflows.get(workflow_type).cloned() + } + + /// Returns an iterator over registered workflow type names. + pub fn workflow_types(&self) -> impl Iterator + '_ { + self.workflows.keys().copied() + } +} + +impl Debug for WorkflowDefinitions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkflowDefinitions") + .field("workflows", &self.workflows.keys().collect::>()) + .finish() + } +} diff --git a/crates/sdk/src/workflows.rs b/crates/sdk/src/workflows.rs index ce3fdd9f6..aab9d7594 100644 --- a/crates/sdk/src/workflows.rs +++ b/crates/sdk/src/workflows.rs @@ -1,731 +1,5 @@ -//! Functionality related to defining and interacting with workflows -//! -//! This module contains traits and types for implementing workflows using the -//! `#[workflow]` and `#[workflow_methods]` macros. -//! -//! Example usage: -//! ``` -//! 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 method uses ctx.state() for reading -//! #[run] -//! pub async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { -//! let counter = ctx.state(|s| s.counter); -//! Ok(format!("Done with counter: {}", counter)) -//! } -//! -//! // Sync signals use &mut self for direct mutations -//! #[signal] -//! pub fn increment(&mut self, ctx: &mut SyncWorkflowContext, amount: u32) { -//! self.counter += amount; -//! } -//! -//! // Queries use &self with read-only context -//! #[query] -//! pub fn get_counter(&self, ctx: &WorkflowContextView) -> u32 { -//! self.counter -//! } -//! } -//! ``` +//! Workflow authoring APIs and native workflow registration helpers. -/// Deterministic `select!` for use in Temporal workflows. -/// -/// Polls branches in declaration order (top to bottom), ensuring deterministic -/// behavior across workflow replays. Delegates to [`futures_util::select_biased!`]. -/// -/// All workflow futures (timers, activities, child workflows, etc.) implement -/// `FusedFuture`, so they can be stored in variables and passed to `select!` -/// without needing `.fuse()`. -/// -/// # Example -/// -/// ```ignore -/// use temporalio_sdk::workflows::select; -/// use temporalio_sdk::WorkflowContext; -/// use std::time::Duration; -/// -/// # async fn hidden(ctx: &mut WorkflowContext<()>) { -/// select! { -/// _ = ctx.timer(Duration::from_secs(60)) => { /* timer fired */ } -/// reason = ctx.cancelled() => { /* cancelled */ } -/// }; -/// # } -/// ``` -#[doc(inline)] -pub use crate::__temporal_select as select; +pub use temporalio_workflow::workflows::*; -/// Deterministic `join!` for use in Temporal workflows. -/// -/// Polls all futures concurrently to completion in declaration order, -/// ensuring deterministic behavior across workflow replays. Delegates -/// to [`futures_util::join!`]. -/// -/// # Example -/// -/// ```ignore -/// use temporalio_sdk::workflows::join; -/// -/// # async fn hidden() { -/// let future_a = async { 1 }; -/// let future_b = async { 2 }; -/// let (a, b) = join!(future_a, future_b); -/// # } -/// ``` -#[doc(inline)] -pub use crate::__temporal_join as join; - -use crate::{ - BaseWorkflowContext, SyncWorkflowContext, WorkflowContext, WorkflowContextView, - WorkflowTermination, workflow_executor::SdkGuardedFuture, -}; -use futures_util::future::{Fuse, FutureExt, LocalBoxFuture}; -use std::{ - cell::RefCell, - collections::HashMap, - fmt::Debug, - pin::Pin, - rc::Rc, - sync::Arc, - task::{Context as TaskContext, Poll}, -}; -use temporalio_common::{ - QueryDefinition, SignalDefinition, UpdateDefinition, WorkflowDefinition, - data_converters::{ - GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, - SerializationContextData, TemporalDeserializable, TemporalSerializable, - }, - protos::temporal::api::{ - common::v1::{Payload, Payloads}, - failure::v1::Failure, - }, -}; - -/// Error type for workflow operations -#[derive(Debug, thiserror::Error)] -pub enum WorkflowError { - /// Error during payload conversion - #[error("Payload conversion error: {0}")] - PayloadConversion(#[from] PayloadConversionError), - - /// Workflow execution error - #[error("Workflow execution error: {0}")] - Execution(#[from] anyhow::Error), -} - -impl From for Failure { - fn from(err: WorkflowError) -> Self { - Failure { - message: err.to_string(), - ..Default::default() - } - } -} - -/// Trait implemented by workflow structs to enable execution by the worker. -/// -/// This trait is typically generated by the `#[workflow_methods]` macro and should not -/// be implemented manually in most cases. -#[doc(hidden)] -pub trait WorkflowImplementation: Sized + 'static { - /// The marker struct for the run method that implements `WorkflowDefinition` - type Run: WorkflowDefinition; - - /// Whether this workflow has a user-defined `#[init]` method. - /// Set to `true` by the macro when `#[init]` is present, `false` otherwise. - const HAS_INIT: bool; - - /// Whether the init method accepts the workflow input. - /// If true, input goes to init. If false, input goes to run. - const INIT_TAKES_INPUT: bool; - - /// Returns the workflow type name. - fn name() -> &'static str; - - /// Initialize the workflow instance. - /// - /// This is called when a new workflow execution starts. If `INIT_TAKES_INPUT` is true, - /// `input` will be `Some`. Otherwise it's `None`. - fn init( - ctx: WorkflowContextView, - input: Option<::Input>, - ) -> Self; - - /// Execute the workflow's main run function. - /// - /// If `INIT_TAKES_INPUT` is false, `input` will be `Some`. Otherwise it's `None`. - fn run( - ctx: WorkflowContext, - input: Option<::Input>, - ) -> LocalBoxFuture<'static, Result>; - - /// Dispatch an update request by name. Returns `None` if no handler for that name. - fn dispatch_update( - _ctx: WorkflowContext, - _name: &str, - _payloads: Payloads, - _converter: &PayloadConverter, - ) -> Option>> { - None - } - - /// Validate an update request by name. - /// - /// Returns `None` if no handler for that name, `Some(Ok(()))` if valid, - /// `Some(Err(...))` if validation failed. - fn validate_update( - &self, - _ctx: WorkflowContextView, - _name: &str, - _payloads: &Payloads, - _converter: &PayloadConverter, - ) -> Option> { - None - } - - /// Dispatch a signal by name. - /// - /// Returns `None` if no handler for that name. For sync signals, the mutation happens - /// immediately and returns a completed future. For async signals, returns a future - /// that must be polled to completion. - fn dispatch_signal( - _ctx: WorkflowContext, - _name: &str, - _payloads: Payloads, - _converter: &PayloadConverter, - ) -> Option>> { - None - } - - /// Dispatch a query by name. - /// - /// Returns `None` if no handler for that name, `Some(Ok(payload))` on success, - /// `Some(Err(...))` on failure. Queries are synchronous and read-only. - fn dispatch_query( - &self, - _ctx: WorkflowContextView, - _name: &str, - _payloads: &Payloads, - _converter: &PayloadConverter, - ) -> Option> { - None - } -} - -// NOTE: In the below traits, the dispatch functions take context by ownership while the handle -// methods take them by ref when sync and by ownership when async. They must be owned by async -// handlers since the returned futures must be 'static. - -/// Trait for executing synchronous signal handlers on a workflow. -#[doc(hidden)] -pub trait ExecutableSyncSignal: WorkflowImplementation { - /// Handle an incoming signal with the given input. - fn handle(&mut self, ctx: &mut SyncWorkflowContext, input: S::Input); - - /// Dispatch the signal with payload deserialization. - fn dispatch( - ctx: WorkflowContext, - payloads: Payloads, - converter: &PayloadConverter, - ) -> LocalBoxFuture<'static, Result<(), WorkflowError>> { - match deserialize_input::(payloads.payloads, converter) { - Ok(input) => { - let mut sync_ctx = ctx.sync_context(); - ctx.state_mut(|wf| Self::handle(wf, &mut sync_ctx, input)); - std::future::ready(Ok(())).boxed_local() - } - Err(e) => std::future::ready(Err(e)).boxed_local(), - } - } -} - -/// Trait for executing asynchronous signal handlers on a workflow. -#[doc(hidden)] -pub trait ExecutableAsyncSignal: WorkflowImplementation { - /// Handle an incoming signal with the given input. - fn handle(ctx: WorkflowContext, input: S::Input) -> LocalBoxFuture<'static, ()>; - - /// Dispatch the signal with payload deserialization. - fn dispatch( - ctx: WorkflowContext, - payloads: Payloads, - converter: &PayloadConverter, - ) -> LocalBoxFuture<'static, Result<(), WorkflowError>> { - match deserialize_input::(payloads.payloads, converter) { - Ok(input) => Self::handle(ctx, input).map(|()| Ok(())).boxed_local(), - Err(e) => std::future::ready(Err(e)).boxed_local(), - } - } -} - -/// Trait for executing query handlers on a workflow. -/// -/// Queries are read-only operations that do not mutate workflow state. -/// They must be synchronous. -#[doc(hidden)] -pub trait ExecutableQuery: WorkflowImplementation { - /// Handle a query with the given input and return the result. - /// - /// Queries take `&self` (immutable) and cannot modify workflow state. - /// Returning an error will cause the query to fail with that error message. - fn handle( - &self, - ctx: &WorkflowContextView, - input: Q::Input, - ) -> Result>; - - /// Dispatch the query with payload deserialization and output serialization. - fn dispatch( - &self, - ctx: &WorkflowContextView, - payloads: &Payloads, - converter: &PayloadConverter, - ) -> Result { - let input = deserialize_input::(payloads.payloads.clone(), converter)?; - let output = self.handle(ctx, input).map_err(wrap_handler_error)?; - serialize_output(&output, converter) - } -} - -/// Trait for executing synchronous update handlers on a workflow. -#[doc(hidden)] -pub trait ExecutableSyncUpdate: WorkflowImplementation { - /// Handle an update with the given input and return the result. - /// Returning an error will cause the update to fail with that error message. - fn handle( - &mut self, - ctx: &mut SyncWorkflowContext, - input: U::Input, - ) -> Result>; - - /// Validate an update before it is applied. - fn validate( - &self, - _ctx: &WorkflowContextView, - _input: &U::Input, - ) -> Result<(), Box> { - Ok(()) - } - - /// Dispatch the update with payload deserialization and output serialization. - fn dispatch( - ctx: WorkflowContext, - payloads: Payloads, - converter: &PayloadConverter, - ) -> LocalBoxFuture<'static, Result> { - let input = match deserialize_input::(payloads.payloads, converter) { - Ok(v) => v, - Err(e) => return std::future::ready(Err(e)).boxed_local(), - }; - let converter = converter.clone(); - let mut sync_ctx = ctx.sync_context(); - let result = ctx.state_mut(|wf| Self::handle(wf, &mut sync_ctx, input)); - match result { - Ok(output) => match serialize_output(&output, &converter) { - Ok(payload) => std::future::ready(Ok(payload)).boxed_local(), - Err(e) => std::future::ready(Err(e)).boxed_local(), - }, - Err(e) => std::future::ready(Err(wrap_handler_error(e))).boxed_local(), - } - } - - /// Dispatch validation with payload deserialization. - fn dispatch_validate( - &self, - ctx: &WorkflowContextView, - payloads: &Payloads, - converter: &PayloadConverter, - ) -> Result<(), WorkflowError> { - let input = deserialize_input::(payloads.payloads.clone(), converter)?; - self.validate(ctx, &input).map_err(wrap_handler_error) - } -} - -/// Trait for executing asynchronous update handlers on a workflow. -#[doc(hidden)] -pub trait ExecutableAsyncUpdate: WorkflowImplementation { - /// Handle an update with the given input and return the result. - /// Returning an error will cause the update to fail with that error message. - fn handle( - ctx: WorkflowContext, - input: U::Input, - ) -> LocalBoxFuture<'static, Result>>; - - /// Validate an update before it is applied. - fn validate( - &self, - _ctx: &WorkflowContextView, - _input: &U::Input, - ) -> Result<(), Box> { - Ok(()) - } - - /// Dispatch the update with payload deserialization and output serialization. - fn dispatch( - ctx: WorkflowContext, - payloads: Payloads, - converter: &PayloadConverter, - ) -> LocalBoxFuture<'static, Result> { - let input = match deserialize_input::(payloads.payloads, converter) { - Ok(v) => v, - Err(e) => return std::future::ready(Err(e)).boxed_local(), - }; - let converter = converter.clone(); - async move { - let output = Self::handle(ctx, input).await.map_err(wrap_handler_error)?; - serialize_output(&output, &converter) - } - .boxed_local() - } - - /// Dispatch validation with payload deserialization. - fn dispatch_validate( - &self, - ctx: &WorkflowContextView, - payloads: &Payloads, - converter: &PayloadConverter, - ) -> Result<(), WorkflowError> { - let input = deserialize_input::(payloads.payloads.clone(), converter)?; - self.validate(ctx, &input).map_err(wrap_handler_error) - } -} - -/// Data passed to handler dispatch methods (signals, updates, queries). -pub(crate) struct DispatchData<'a> { - pub(crate) payloads: Payloads, - pub(crate) headers: HashMap, - pub(crate) converter: &'a PayloadConverter, -} - -/// Trait implemented by workflow types to enable registration with workers. -/// -/// This trait is automatically generated by the `#[workflow_methods]` macro. -#[doc(hidden)] -pub trait WorkflowImplementer: WorkflowImplementation { - /// Register this workflow and all its handlers with the given definitions container. - fn register_all(defs: &mut WorkflowDefinitions); -} - -/// Type-erased trait for workflow execution instances. -pub(crate) trait DynWorkflowExecution { - /// Poll the run future. - fn poll_run(&mut self, cx: &mut TaskContext<'_>) -> Poll>; - - /// Validate an update request. Returns `None` if no handler. - fn validate_update(&self, name: &str, data: &DispatchData) - -> Option>; - - /// Start an update handler. Returns `None` if no handler for that name. - fn start_update( - &mut self, - name: &str, - data: DispatchData, - ) -> Option>>; - - /// Dispatch a signal by name. Returns `None` if no handler. - fn dispatch_signal( - &mut self, - name: &str, - data: DispatchData, - ) -> Option>>; - - /// Dispatch a query by name. Returns `None` if no handler. - fn dispatch_query( - &self, - name: &str, - data: DispatchData, - ) -> Option>; -} - -/// Manages a workflow execution, holding the context and run future. -pub(crate) struct WorkflowExecution { - ctx: WorkflowContext, - run_future: Fuse>>, -} - -impl WorkflowExecution -where - ::Input: Send, -{ - /// Create a new workflow execution using the workflow's `init` method. - pub(crate) fn new( - base_ctx: BaseWorkflowContext, - init_input: Option<::Input>, - run_input: Option<::Input>, - ) -> Self { - let view = base_ctx.view(); - let workflow = W::init(view, init_input); - Self::new_with_workflow(workflow, base_ctx, run_input) - } - - /// Create a new workflow execution from an already-created workflow instance. - pub(crate) fn new_with_workflow( - workflow: W, - base_ctx: BaseWorkflowContext, - run_input: Option<::Input>, - ) -> Self { - let workflow = Rc::new(RefCell::new(workflow)); - let ctx = WorkflowContext::from_base(base_ctx, workflow); - let run_future = W::run(ctx.clone(), run_input).fuse(); - - Self { ctx, run_future } - } -} - -impl DynWorkflowExecution for WorkflowExecution { - fn poll_run(&mut self, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.run_future).poll(cx) - } - - fn validate_update( - &self, - name: &str, - data: &DispatchData, - ) -> Option> { - let view = self.ctx.view(); - self.ctx - .state(|wf| wf.validate_update(view, name, &data.payloads, data.converter)) - } - - fn start_update( - &mut self, - name: &str, - data: DispatchData, - ) -> Option>> { - let ctx = self.ctx.with_headers(data.headers); - W::dispatch_update(ctx, name, data.payloads, data.converter) - } - - fn dispatch_signal( - &mut self, - name: &str, - data: DispatchData, - ) -> Option>> { - let ctx = self.ctx.with_headers(data.headers); - W::dispatch_signal(ctx, name, data.payloads, data.converter) - } - - fn dispatch_query( - &self, - name: &str, - data: DispatchData, - ) -> Option> { - let view = self.ctx.view(); - self.ctx - .state(|wf| wf.dispatch_query(view, name, &data.payloads, data.converter)) - } -} - -/// Type alias for workflow execution factory functions. -/// -/// Creates a new `WorkflowExecution` instance from the input payloads and context. -pub(crate) type WorkflowExecutionFactory = Arc< - dyn Fn( - Vec, - PayloadConverter, - BaseWorkflowContext, - ) -> Result, PayloadConversionError> - + Send - + Sync, ->; - -/// Contains workflow registrations in a form ready for execution by workers. -#[derive(Default, Clone)] -pub struct WorkflowDefinitions { - /// Maps workflow type name to execution factories - workflows: HashMap<&'static str, WorkflowExecutionFactory>, -} - -impl WorkflowDefinitions { - /// Creates a new empty `WorkflowDefinitions`. - pub fn new() -> Self { - Self::default() - } - - /// Register a workflow implementation. - pub fn register_workflow(&mut self) -> &mut Self { - W::register_all(self); - self - } - - /// Register a specific workflow's run method. - #[doc(hidden)] - pub fn register_workflow_run(&mut self) -> &mut Self - where - ::Input: Send, - { - let workflow_name = W::name(); - let factory: WorkflowExecutionFactory = - Arc::new(move |payloads, converter: PayloadConverter, base_ctx| { - let ser_ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; - let input = converter.from_payloads(&ser_ctx, payloads)?; - let (init_input, run_input) = if W::INIT_TAKES_INPUT { - (Some(input), None) - } else { - (None, Some(input)) - }; - Ok( - Box::new(WorkflowExecution::::new(base_ctx, init_input, run_input)) - as Box, - ) - }); - self.workflows.insert(workflow_name, factory); - self - } - - /// Register a workflow with a custom factory for instance creation. - pub fn register_workflow_run_with_factory(&mut self, user_factory: F) -> &mut Self - where - W: WorkflowImplementation, - ::Input: Send, - F: Fn() -> W + Send + Sync + 'static, - { - assert!( - !W::HAS_INIT, - "Workflows registered with a factory must not define an #[init] method. \ - The factory replaces init for instance creation." - ); - - let workflow_name = W::name(); - let user_factory = Arc::new(user_factory); - let factory: WorkflowExecutionFactory = - Arc::new(move |payloads, converter: PayloadConverter, base_ctx| { - let ser_ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; - let input: ::Input = - converter.from_payloads(&ser_ctx, payloads)?; - - // User factory creates the instance - input always goes to run() - let workflow = user_factory(); - Ok(Box::new(WorkflowExecution::::new_with_workflow( - workflow, - base_ctx, - Some(input), - )) as Box) - }); - - self.workflows.insert(workflow_name, factory); - self - } - - /// Check if any workflows are registered. - pub fn is_empty(&self) -> bool { - self.workflows.is_empty() - } - - /// Get the workflow execution factory for a given workflow type. - pub(crate) fn get_workflow(&self, workflow_type: &str) -> Option { - self.workflows.get(workflow_type).cloned() - } - - /// Returns an iterator over registered workflow type names. - pub fn workflow_types(&self) -> impl Iterator + '_ { - self.workflows.keys().copied() - } -} - -impl Debug for WorkflowDefinitions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("WorkflowDefinitions") - .field("workflows", &self.workflows.keys().collect::>()) - .finish() - } -} - -/// Deserialize handler input from payloads. -pub fn deserialize_input( - payloads: Vec, - converter: &PayloadConverter, -) -> Result { - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter, - }; - converter.from_payloads(&ctx, payloads).map_err(Into::into) -} - -/// Serialize handler output to a payload. -pub fn serialize_output( - output: &O, - converter: &PayloadConverter, -) -> Result { - let ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter, - }; - converter.to_payload(&ctx, output).map_err(Into::into) -} - -/// Wrap a handler error into WorkflowError. -pub fn wrap_handler_error(e: Box) -> WorkflowError { - WorkflowError::Execution(anyhow::anyhow!(e)) -} - -/// Serialize a workflow result value to a payload. -pub fn serialize_result( - result: T, - converter: &PayloadConverter, -) -> Result { - serialize_output(&result, converter) -} - -/// Deterministic `join_all` for use in Temporal workflows. -/// -/// Polls a collection of futures concurrently to completion in declaration order, -/// returning a `Vec` of their results. -/// -/// # Example -/// -/// ```ignore -/// use temporalio_sdk::workflows::join_all; -/// use temporalio_sdk::WorkflowContext; -/// use std::time::Duration; -/// -/// # async fn hidden(ctx: &mut WorkflowContext<()>) { -/// let timers = vec![ -/// ctx.timer(Duration::from_secs(1)), -/// ctx.timer(Duration::from_secs(2)), -/// ]; -/// let results = join_all(timers).await; -/// # } -/// ``` -pub fn join_all(iter: I) -> JoinAll -where - I: IntoIterator, - I::Item: std::future::Future, -{ - JoinAll(SdkGuardedFuture(futures_util::future::join_all(iter))) -} - -/// Future returned by [`join_all`]. -pub struct JoinAll(SdkGuardedFuture>); - -impl std::future::Future for JoinAll { - type Output = Vec; - - fn poll( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - self.0.poll_unpin(cx) - } -} +pub use crate::workflow_registry::WorkflowDefinitions; diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml new file mode 100644 index 000000000..cc7c95795 --- /dev/null +++ b/crates/workflow/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "temporalio-workflow" +version = "0.2.0" +edition = "2024" +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" +keywords = ["temporal", "workflow"] +categories = ["development-tools"] + +[dependencies] +anyhow = "1.0" +bon = { workspace = true } +derive_more = { workspace = true } +futures-util = { version = "0.3", default-features = false, features = [ + "async-await-macro", + "alloc", + "std", +] } +parking_lot = { version = "0.12" } +prost-types = { workspace = true } +serde = { version = "1.0", features = ["derive"] } +thiserror = "2" +tokio = { version = "1.47", default-features = false, features = ["sync"] } +tracing = "0.1" + +[dependencies.temporalio-common-wasm] +path = "../common-wasm" +version = "0.2" + +[lints] +workspace = true + +[dev-dependencies] +temporalio-macros = { path = "../macros" } diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs new file mode 100644 index 000000000..a9ad0c7bb --- /dev/null +++ b/crates/workflow/src/lib.rs @@ -0,0 +1,41 @@ +#![warn(missing_docs)] + +//! Temporal workflow authoring APIs and runtime glue. + +extern crate self as temporalio_workflow; + +pub use temporalio_common_wasm as common; + +pub mod runtime; +mod workflow_context; +pub mod workflows; + +#[macro_export] +#[doc(hidden)] +macro_rules! __temporal_select { + ($($tokens:tt)*) => { + ::futures_util::select_biased! { $($tokens)* } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __temporal_join { + ($($tokens:tt)*) => { + ::futures_util::join!($($tokens)*) + }; +} + +#[doc(hidden)] +pub use runtime::model::{CancellableID, CancellableIDWithReason, UnblockEvent}; +pub use runtime::model::{TimerResult, WorkflowResult, WorkflowTermination}; +#[doc(hidden)] +pub use runtime::{SdkWakeGuard, is_sdk_wake}; +pub use workflow_context::{ + ActivityCloseTimeouts, ActivityExecutionError, ActivityOptions, BaseWorkflowContext, + CancellableFuture, ChildWorkflowExecutionError, ChildWorkflowOptions, ChildWorkflowSignalError, + ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, NexusOperationOptions, + ParentWorkflowInfo, RootWorkflowInfo, Signal, SignalData, + StartChildWorkflowExecutionFailedCause, StartedChildWorkflow, SyncWorkflowContext, + TimerOptions, WorkflowContext, WorkflowContextView, +}; diff --git a/crates/workflow/src/runtime/entry.rs b/crates/workflow/src/runtime/entry.rs new file mode 100644 index 000000000..ff76a3985 --- /dev/null +++ b/crates/workflow/src/runtime/entry.rs @@ -0,0 +1,310 @@ +//! Runtime entry traits implemented by workflow definitions and message handlers. + +use crate::{ + SyncWorkflowContext, WorkflowContext, WorkflowContextView, runtime::model::WorkflowTermination, +}; +use futures_util::future::{FutureExt, LocalBoxFuture}; +use temporalio_common_wasm::{ + QueryDefinition, SignalDefinition, UpdateDefinition, WorkflowDefinition, + data_converters::{ + GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, + SerializationContextData, TemporalDeserializable, TemporalSerializable, + }, + protos::temporal::api::{ + common::v1::{Payload, Payloads}, + failure::v1::Failure, + }, +}; + +/// Error type for workflow operations +#[derive(Debug, thiserror::Error)] +pub enum WorkflowError { + /// Error during payload conversion + #[error("Payload conversion error: {0}")] + PayloadConversion(#[from] PayloadConversionError), + + /// Workflow execution error + #[error("Workflow execution error: {0}")] + Execution(#[from] anyhow::Error), +} + +impl From for Failure { + fn from(err: WorkflowError) -> Self { + Failure { + message: err.to_string(), + ..Default::default() + } + } +} + +/// Trait implemented by workflow structs to enable execution by the worker. +/// +/// This trait is typically generated by the `#[workflow_methods]` macro and should not +/// be implemented manually in most cases. +pub trait WorkflowImplementation: Sized + 'static { + /// The marker struct for the run method that implements `WorkflowDefinition` + type Run: WorkflowDefinition; + + /// Whether this workflow has a user-defined `#[init]` method. + /// Set to `true` by the macro when `#[init]` is present, `false` otherwise. + const HAS_INIT: bool; + + /// Whether the init method accepts the workflow input. + /// If true, input goes to init. If false, input goes to run. + const INIT_TAKES_INPUT: bool; + + /// Returns the workflow type name. + fn name() -> &'static str; + + /// Initialize the workflow instance. + fn init( + ctx: WorkflowContextView, + input: Option<::Input>, + ) -> Self; + + /// Execute the workflow's main run function. + fn run( + ctx: WorkflowContext, + input: Option<::Input>, + ) -> LocalBoxFuture<'static, Result>; + + /// Dispatch an update request by name. Returns `None` if no handler for that name. + fn dispatch_update( + _ctx: WorkflowContext, + _name: &str, + _payloads: Payloads, + _converter: &PayloadConverter, + ) -> Option>> { + None + } + + /// Validate an update request by name. + fn validate_update( + &self, + _ctx: WorkflowContextView, + _name: &str, + _payloads: &Payloads, + _converter: &PayloadConverter, + ) -> Option> { + None + } + + /// Dispatch a signal by name. + fn dispatch_signal( + _ctx: WorkflowContext, + _name: &str, + _payloads: Payloads, + _converter: &PayloadConverter, + ) -> Option>> { + None + } + + /// Dispatch a query by name. + fn dispatch_query( + &self, + _ctx: WorkflowContextView, + _name: &str, + _payloads: &Payloads, + _converter: &PayloadConverter, + ) -> Option> { + None + } +} + +/// Trait for executing synchronous signal handlers on a workflow. +pub trait ExecutableSyncSignal: WorkflowImplementation { + /// Handle an incoming signal with the given input. + fn handle(&mut self, ctx: &mut SyncWorkflowContext, input: S::Input); + + /// Dispatch the signal with payload deserialization. + fn dispatch( + ctx: WorkflowContext, + payloads: Payloads, + converter: &PayloadConverter, + ) -> LocalBoxFuture<'static, Result<(), WorkflowError>> { + match deserialize_input::(payloads.payloads, converter) { + Ok(input) => { + let mut sync_ctx = ctx.sync_context(); + ctx.state_mut(|wf| Self::handle(wf, &mut sync_ctx, input)); + std::future::ready(Ok(())).boxed_local() + } + Err(e) => std::future::ready(Err(e)).boxed_local(), + } + } +} + +/// Trait for executing asynchronous signal handlers on a workflow. +pub trait ExecutableAsyncSignal: WorkflowImplementation { + /// Handle an incoming signal with the given input. + fn handle(ctx: WorkflowContext, input: S::Input) -> LocalBoxFuture<'static, ()>; + + /// Dispatch the signal with payload deserialization. + fn dispatch( + ctx: WorkflowContext, + payloads: Payloads, + converter: &PayloadConverter, + ) -> LocalBoxFuture<'static, Result<(), WorkflowError>> { + match deserialize_input::(payloads.payloads, converter) { + Ok(input) => Self::handle(ctx, input).map(|()| Ok(())).boxed_local(), + Err(e) => std::future::ready(Err(e)).boxed_local(), + } + } +} + +/// Trait for executing query handlers on a workflow. +pub trait ExecutableQuery: WorkflowImplementation { + /// Handle a query with the given input and return the result. + fn handle( + &self, + ctx: &WorkflowContextView, + input: Q::Input, + ) -> Result>; + + /// Dispatch the query with payload deserialization and output serialization. + fn dispatch( + &self, + ctx: &WorkflowContextView, + payloads: &Payloads, + converter: &PayloadConverter, + ) -> Result { + let input = deserialize_input::(payloads.payloads.clone(), converter)?; + let output = self.handle(ctx, input).map_err(wrap_handler_error)?; + serialize_output(&output, converter) + } +} + +/// Trait for executing synchronous update handlers on a workflow. +pub trait ExecutableSyncUpdate: WorkflowImplementation { + /// Handle an update with the given input and return the result. + fn handle( + &mut self, + ctx: &mut SyncWorkflowContext, + input: U::Input, + ) -> Result>; + + /// Validate an update before it is applied. + fn validate( + &self, + _ctx: &WorkflowContextView, + _input: &U::Input, + ) -> Result<(), Box> { + Ok(()) + } + + /// Dispatch the update with payload deserialization and output serialization. + fn dispatch( + ctx: WorkflowContext, + payloads: Payloads, + converter: &PayloadConverter, + ) -> LocalBoxFuture<'static, Result> { + let input = match deserialize_input::(payloads.payloads, converter) { + Ok(v) => v, + Err(e) => return std::future::ready(Err(e)).boxed_local(), + }; + let converter = converter.clone(); + let mut sync_ctx = ctx.sync_context(); + let result = ctx.state_mut(|wf| Self::handle(wf, &mut sync_ctx, input)); + match result { + Ok(output) => match serialize_output(&output, &converter) { + Ok(payload) => std::future::ready(Ok(payload)).boxed_local(), + Err(e) => std::future::ready(Err(e)).boxed_local(), + }, + Err(e) => std::future::ready(Err(wrap_handler_error(e))).boxed_local(), + } + } + + /// Dispatch validation with payload deserialization. + fn dispatch_validate( + &self, + ctx: &WorkflowContextView, + payloads: &Payloads, + converter: &PayloadConverter, + ) -> Result<(), WorkflowError> { + let input = deserialize_input::(payloads.payloads.clone(), converter)?; + self.validate(ctx, &input).map_err(wrap_handler_error) + } +} + +/// Trait for executing asynchronous update handlers on a workflow. +pub trait ExecutableAsyncUpdate: WorkflowImplementation { + /// Handle an update with the given input and return the result. + fn handle( + ctx: WorkflowContext, + input: U::Input, + ) -> LocalBoxFuture<'static, Result>>; + + /// Validate an update before it is applied. + fn validate( + &self, + _ctx: &WorkflowContextView, + _input: &U::Input, + ) -> Result<(), Box> { + Ok(()) + } + + /// Dispatch the update with payload deserialization and output serialization. + fn dispatch( + ctx: WorkflowContext, + payloads: Payloads, + converter: &PayloadConverter, + ) -> LocalBoxFuture<'static, Result> { + let input = match deserialize_input::(payloads.payloads, converter) { + Ok(v) => v, + Err(e) => return std::future::ready(Err(e)).boxed_local(), + }; + let converter = converter.clone(); + async move { + let output = Self::handle(ctx, input).await.map_err(wrap_handler_error)?; + serialize_output(&output, &converter) + } + .boxed_local() + } + + /// Dispatch validation with payload deserialization. + fn dispatch_validate( + &self, + ctx: &WorkflowContextView, + payloads: &Payloads, + converter: &PayloadConverter, + ) -> Result<(), WorkflowError> { + let input = deserialize_input::(payloads.payloads.clone(), converter)?; + self.validate(ctx, &input).map_err(wrap_handler_error) + } +} + +/// Deserialize handler input from payloads. +pub fn deserialize_input( + payloads: Vec, + converter: &PayloadConverter, +) -> Result { + let ctx = SerializationContext { + data: &SerializationContextData::Workflow, + converter, + }; + converter.from_payloads(&ctx, payloads).map_err(Into::into) +} + +/// Serialize handler output to a payload. +pub fn serialize_output( + output: &O, + converter: &PayloadConverter, +) -> Result { + let ctx = SerializationContext { + data: &SerializationContextData::Workflow, + converter, + }; + converter.to_payload(&ctx, output).map_err(Into::into) +} + +/// Wrap a handler error into WorkflowError. +pub fn wrap_handler_error(e: Box) -> WorkflowError { + WorkflowError::Execution(anyhow::anyhow!(e)) +} + +/// Serialize a workflow result value to a payload. +pub fn serialize_result( + result: T, + converter: &PayloadConverter, +) -> Result { + serialize_output(&result, converter) +} diff --git a/crates/workflow/src/runtime/guest.rs b/crates/workflow/src/runtime/guest.rs new file mode 100644 index 000000000..9b0a8c01e --- /dev/null +++ b/crates/workflow/src/runtime/guest.rs @@ -0,0 +1,40 @@ +//! High-level guest-side runtime traits mirroring the checked-in WIT interface. + +#![allow(missing_docs)] + +use crate::runtime::types::{ + ActivationResult, RoutineId, RoutinePollResult, WorkflowActivation, + WorkflowDefinitionDescriptor, WorkflowFailure, WorkflowInit, +}; +use std::task::Waker; +use temporalio_common_wasm::protos::temporal::api::common::v1::Payload; + +/// Runtime-facing workflow module interface for native and WASM backends. +pub trait WorkflowModule { + /// List workflow definitions exported by this module. + fn list_workflows(&self) -> Vec; + + /// Instantiate a workflow run by workflow type. + fn instantiate_workflow( + &self, + workflow_type: &str, + init: WorkflowInit, + args: Vec, + ) -> Result, WorkflowFailure>; +} + +/// Runtime-facing single-workflow execution interface for native and WASM backends. +pub trait WorkflowInstance { + /// Apply one ordered workflow activation without polling any routines. + fn activate( + &mut self, + activation: WorkflowActivation, + ) -> Result; + /// Poll exactly one guest routine, using the provided waker for native integrations that need + /// wake tracking. + fn poll_routine( + &mut self, + routine_id: RoutineId, + waker: &Waker, + ) -> Result; +} diff --git a/crates/workflow/src/runtime/host.rs b/crates/workflow/src/runtime/host.rs new file mode 100644 index 000000000..d17b43fd5 --- /dev/null +++ b/crates/workflow/src/runtime/host.rs @@ -0,0 +1,50 @@ +//! Host-side command sink trait mirroring the checked-in WIT interface. + +#![allow(missing_docs)] + +use crate::runtime::types::{ + CancelChildWorkflowRequest, ContinueAsNewRequest, NamedPayload, + RequestCancelExternalWorkflowRequest, RequestCancelNexusOperationRequest, + ScheduleActivityRequest, ScheduleLocalActivityRequest, ScheduleNexusOperationRequest, + SignalExternalWorkflowRequest, StartChildWorkflowRequest, StartTimerRequest, +}; + +/// Runtime-facing workflow host interface for native and WASM backends. +pub trait WorkflowHost { + /// Update the details string surfaced through the workflow metadata query. + fn set_current_details(&self, details: String); + /// Start a timer. + fn start_timer(&self, req: StartTimerRequest); + /// Cancel a timer by sequence number. + fn cancel_timer(&self, seq: u32); + /// Schedule an activity. + fn schedule_activity(&self, req: ScheduleActivityRequest); + /// Cancel a scheduled activity. + fn cancel_activity(&self, seq: u32); + /// Schedule a local activity. + fn schedule_local_activity(&self, req: ScheduleLocalActivityRequest); + /// Cancel a scheduled local activity. + fn cancel_local_activity(&self, seq: u32); + /// Start a child workflow. + fn start_child_workflow(&self, req: StartChildWorkflowRequest); + /// Cancel a child workflow. + fn cancel_child_workflow(&self, req: CancelChildWorkflowRequest); + /// Request cancellation of an external workflow. + fn request_cancel_external_workflow(&self, req: RequestCancelExternalWorkflowRequest); + /// Send a signal to an external workflow. + fn signal_external_workflow(&self, req: SignalExternalWorkflowRequest); + /// Cancel an in-flight signal-external request. + fn cancel_signal_external_workflow(&self, seq: u32); + /// Schedule a nexus operation. + fn schedule_nexus_operation(&self, req: ScheduleNexusOperationRequest); + /// Cancel a nexus operation. + fn cancel_nexus_operation(&self, req: RequestCancelNexusOperationRequest); + /// Upsert search attributes on the running workflow. + fn upsert_search_attributes(&self, entries: Vec); + /// Upsert memo entries on the running workflow. + fn upsert_memo(&self, entries: Vec); + /// Record a patch marker. + fn set_patch_marker(&self, patch_id: String, deprecated: bool); + /// Continue the workflow as new. + fn continue_as_new(&self, req: ContinueAsNewRequest); +} diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs new file mode 100644 index 000000000..e9f90131e --- /dev/null +++ b/crates/workflow/src/runtime/instance.rs @@ -0,0 +1,575 @@ +//! Guest-side workflow execution implementation used by native and future WASM hosts. + +#![allow(missing_docs)] + +use crate::{ + WorkflowContext, + runtime::{ + BaseWorkflowContext, + entry::{WorkflowError, WorkflowImplementation}, + guest::WorkflowInstance, + model::{TimerResult, UnblockEvent, WorkflowResult, WorkflowTermination}, + types::{ + ActivationJobResult, ActivationResult, IntoPayloadMap, MAIN_ROUTINE_ID, + MainRoutineCompletion, NamedPayload, QueryInvocation, QueryResponse, RoutineCompletion, + RoutineId, RoutineKind, RoutinePollResult, SignalInvocation, StartedRoutine, + UpdateInvocation, UpdateRoutineCompletion, WorkflowActivation, WorkflowActivationJob, + WorkflowFailure, WorkflowResolution, + }, + }, +}; +use futures_util::{ + FutureExt, + future::{Fuse, LocalBoxFuture}, +}; +use std::{ + cell::RefCell, + collections::HashMap, + rc::Rc, + task::{Context, Poll, Waker}, +}; +use temporalio_common_wasm::{ + WorkflowDefinition, + data_converters::{ + GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, + SerializationContextData, + }, + protos::temporal::api::{ + common::v1::{Payload, Payloads}, + failure::v1::Failure, + }, +}; + +pub struct GuestWorkflowInstance { + base_ctx: BaseWorkflowContext, + ctx: WorkflowContext, + run_future: Fuse>>, + next_routine_id: RoutineId, + routines: HashMap, +} + +enum GuestRoutine { + Signal { + future: LocalBoxFuture<'static, Result<(), WorkflowError>>, + }, + Update { + protocol_instance_id: String, + future: LocalBoxFuture<'static, Result>, + }, +} + +enum RoutinePollState { + Ready { + result: T, + made_progress: bool, + }, + ForcedFailure { + failure: WorkflowFailure, + made_progress: bool, + }, + Stalled { + made_progress: bool, + }, +} + +struct DispatchData<'a> { + payloads: Payloads, + headers: HashMap, + converter: &'a PayloadConverter, +} + +impl<'a> DispatchData<'a> { + fn from_named_payloads( + payloads: Vec, + headers: Vec, + converter: &'a PayloadConverter, + ) -> Self { + Self { + payloads: Payloads { payloads }, + headers: headers.into_payload_map(), + converter, + } + } +} + +impl GuestWorkflowInstance +where + ::Input: Send, +{ + pub fn instantiate( + payloads: Vec, + converter: PayloadConverter, + base_ctx: BaseWorkflowContext, + ) -> Result, PayloadConversionError> { + let ser_ctx = SerializationContext { + data: &SerializationContextData::Workflow, + converter: &converter, + }; + let input = converter.from_payloads(&ser_ctx, payloads)?; + let (init_input, run_input) = if W::INIT_TAKES_INPUT { + (Some(input), None) + } else { + (None, Some(input)) + }; + Ok(Box::new(Self::new(base_ctx, init_input, run_input))) + } + + pub fn new( + base_ctx: BaseWorkflowContext, + init_input: Option<::Input>, + run_input: Option<::Input>, + ) -> Self { + let view = base_ctx.view(); + let workflow = W::init(view, init_input); + Self::new_with_workflow(workflow, base_ctx, run_input) + } + + pub fn new_with_workflow( + workflow: W, + base_ctx: BaseWorkflowContext, + run_input: Option<::Input>, + ) -> Self { + let workflow = Rc::new(RefCell::new(workflow)); + let ctx = WorkflowContext::from_base(base_ctx.clone(), workflow); + let run_future = W::run(ctx.clone(), run_input).fuse(); + Self { + base_ctx, + ctx, + run_future, + next_routine_id: MAIN_ROUTINE_ID + 1, + routines: HashMap::new(), + } + } + + fn query_metadata(&self) -> QueryResponse { + #[derive(serde::Serialize)] + struct WorkflowMetadataJson { + #[serde(rename = "currentDetails", skip_serializing_if = "String::is_empty")] + current_details: String, + } + + let converter = PayloadConverter::default(); + let ctx = SerializationContext { + data: &SerializationContextData::Workflow, + converter: &converter, + }; + QueryResponse { + result: converter + .to_payload( + &ctx, + &WorkflowMetadataJson { + current_details: self.base_ctx.current_details(), + }, + ) + .map_err(|err| Failure { + message: err.to_string(), + ..Default::default() + }), + } + } + + fn rejection_for_missing_update_handler(name: String) -> ActivationJobResult { + ActivationJobResult::UpdateRejected(Box::new(Failure { + message: format!("No update handler registered for update name {name}"), + ..Default::default() + })) + } + + fn next_routine_id(&mut self) -> RoutineId { + let id = self.next_routine_id; + self.next_routine_id += 1; + id + } + + fn start_signal_routine(&mut self, signal: SignalInvocation) -> ActivationJobResult { + let name = signal.name; + let data = DispatchData::from_named_payloads( + signal.args, + signal.headers, + self.ctx.payload_converter(), + ); + let ctx = self.ctx.with_headers(data.headers); + if let Some(future) = W::dispatch_signal(ctx, &name, data.payloads, data.converter) { + let routine_id = self.next_routine_id(); + self.routines + .insert(routine_id, GuestRoutine::Signal { future }); + ActivationJobResult::StartedRoutine(StartedRoutine { + routine_id, + kind: RoutineKind::Signal { name }, + }) + } else { + ActivationJobResult::None + } + } + + fn start_update_routine(&mut self, update: UpdateInvocation) -> ActivationJobResult { + let protocol_instance_id = update.protocol_instance_id.clone(); + let name = update.name.clone(); + if update.run_validator { + let data = DispatchData::from_named_payloads( + update.args.clone(), + update.headers.clone(), + self.ctx.payload_converter(), + ); + let view = self.ctx.view(); + let validation = self + .ctx + .state(|wf| wf.validate_update(view, &update.name, &data.payloads, data.converter)); + match validation { + Some(Ok(())) => {} + Some(Err(e)) => { + return ActivationJobResult::UpdateRejected(Box::new(e.into())); + } + None => return Self::rejection_for_missing_update_handler(name), + } + } + + let data = DispatchData::from_named_payloads( + update.args, + update.headers, + self.ctx.payload_converter(), + ); + let ctx = self.ctx.with_headers(data.headers); + if let Some(future) = W::dispatch_update(ctx, &name, data.payloads, data.converter) { + let routine_id = self.next_routine_id(); + self.routines.insert( + routine_id, + GuestRoutine::Update { + protocol_instance_id: protocol_instance_id.clone(), + future, + }, + ); + ActivationJobResult::StartedRoutine(StartedRoutine { + routine_id, + kind: RoutineKind::Update { + name, + update_id: update.update_id, + protocol_instance_id, + }, + }) + } else { + Self::rejection_for_missing_update_handler(name) + } + } + + fn query(&self, query: QueryInvocation) -> QueryResponse { + if query.name == "__temporal_workflow_metadata" { + return self.query_metadata(); + } + + let data = DispatchData::from_named_payloads( + query.args, + query.headers, + self.ctx.payload_converter(), + ); + let view = self.ctx.view(); + QueryResponse { + result: match self + .ctx + .state(|wf| wf.dispatch_query(view, &query.name, &data.payloads, data.converter)) + { + Some(Ok(payload)) => Ok(payload), + None => Err(Failure { + message: format!("No query handler for '{}'", query.name), + ..Default::default() + }), + Some(Err(e)) => Err(e.into()), + }, + } + } + + fn apply_resolution(&mut self, resolution: WorkflowResolution) { + let event = match resolution { + WorkflowResolution::TimerFired(event) => { + UnblockEvent::Timer(event.seq, TimerResult::Fired) + } + WorkflowResolution::Activity(event) => { + UnblockEvent::Activity(event.seq, Box::new(event.result)) + } + WorkflowResolution::ChildWorkflowStart(event) => { + UnblockEvent::WorkflowStart(event.seq, Box::new(event.status)) + } + WorkflowResolution::ChildWorkflow(event) => { + UnblockEvent::WorkflowComplete(event.seq, Box::new(event.result)) + } + WorkflowResolution::ExternalSignal(event) => { + UnblockEvent::SignalExternal(event.seq, event.failure) + } + WorkflowResolution::ExternalCancel(event) => { + UnblockEvent::CancelExternal(event.seq, event.failure) + } + WorkflowResolution::NexusStart(event) => { + UnblockEvent::NexusOperationStart(event.seq, Box::new(event.status)) + } + WorkflowResolution::Nexus(event) => { + UnblockEvent::NexusOperationComplete(event.seq, Box::new(event.result)) + } + }; + self.base_ctx + .unblock(event) + .expect("resolution must have a registered unblocker"); + } + + fn terminal_outcome_from_result( + result: WorkflowResult, + ) -> crate::runtime::types::TerminalOutcome { + match result { + Ok(result) => crate::runtime::types::TerminalOutcome::Completed(result), + Err(WorkflowTermination::ContinueAsNew(req)) => { + crate::runtime::types::TerminalOutcome::ContinueAsNew(*req) + } + Err(WorkflowTermination::Cancelled) => { + crate::runtime::types::TerminalOutcome::Cancelled + } + Err(WorkflowTermination::Evicted) => { + panic!("workflow instances must not explicitly return eviction") + } + Err(WorkflowTermination::Failed(err)) => { + crate::runtime::types::TerminalOutcome::Failed(Box::new(Failure { + message: format!("Workflow execution error: {err}"), + ..Default::default() + })) + } + } + } + + fn forced_failure(base_ctx: &BaseWorkflowContext) -> Option { + base_ctx.take_forced_wft_failure().map(|err| { + Box::new(Failure { + message: err.to_string(), + ..Default::default() + }) + }) + } + + fn poll_routine_loop( + base_ctx: &BaseWorkflowContext, + cx: &mut Context<'_>, + future: &mut F, + ) -> RoutinePollState { + base_ctx.take_state_mutated(); + base_ctx.take_runtime_progress(); + let mut made_progress = false; + + loop { + if let Some(failure) = Self::forced_failure(base_ctx) { + return RoutinePollState::ForcedFailure { + failure, + made_progress, + }; + } + + match future.poll_unpin(cx) { + Poll::Ready(result) => { + let state_mutated = base_ctx.take_state_mutated(); + let runtime_progress = base_ctx.take_runtime_progress(); + made_progress |= state_mutated || runtime_progress; + return RoutinePollState::Ready { + result, + made_progress, + }; + } + Poll::Pending => { + let state_mutated = base_ctx.take_state_mutated(); + let runtime_progress = base_ctx.take_runtime_progress(); + made_progress |= state_mutated || runtime_progress; + if !(state_mutated || runtime_progress) { + return RoutinePollState::Stalled { made_progress }; + } + } + } + } + } + + fn poll_main_routine( + &mut self, + cx: &mut Context<'_>, + ) -> Result { + Ok( + match Self::poll_routine_loop(&self.base_ctx, cx, &mut self.run_future) { + RoutinePollState::Ready { + result, + made_progress, + } => RoutinePollResult { + completion: Some(Box::new(RoutineCompletion::Main( + MainRoutineCompletion::Terminal(Box::new( + Self::terminal_outcome_from_result(result), + )), + ))), + made_progress, + }, + RoutinePollState::ForcedFailure { + failure, + made_progress, + } => RoutinePollResult { + completion: Some(Box::new(RoutineCompletion::Main( + MainRoutineCompletion::TaskFailed(crate::runtime::types::TaskFailure { + failure, + force_cause: None, + }), + ))), + made_progress, + }, + RoutinePollState::Stalled { made_progress } => RoutinePollResult { + completion: Some(Box::new(RoutineCompletion::Main( + MainRoutineCompletion::Blocked, + ))), + made_progress, + }, + }, + ) + } + + fn poll_signal_routine( + &mut self, + routine_id: RoutineId, + mut future: LocalBoxFuture<'static, Result<(), WorkflowError>>, + cx: &mut Context<'_>, + ) -> Result { + match Self::poll_routine_loop(&self.base_ctx, cx, &mut future) { + RoutinePollState::Ready { + result, + made_progress, + } => { + let result = result.map_err(|err| { + Box::new(Failure { + message: format!("Signal handler error: {err}"), + ..Default::default() + }) + }); + Ok(RoutinePollResult { + completion: Some(Box::new(RoutineCompletion::Signal(result))), + made_progress, + }) + } + RoutinePollState::ForcedFailure { failure, .. } => Err(failure), + RoutinePollState::Stalled { made_progress } => { + self.routines + .insert(routine_id, GuestRoutine::Signal { future }); + Ok(RoutinePollResult { + completion: None, + made_progress, + }) + } + } + } + + fn poll_update_routine( + &mut self, + routine_id: RoutineId, + protocol_instance_id: String, + mut future: LocalBoxFuture<'static, Result>, + cx: &mut Context<'_>, + ) -> Result { + match Self::poll_routine_loop(&self.base_ctx, cx, &mut future) { + RoutinePollState::Ready { + result, + made_progress, + } => { + let completion = match result { + Ok(result) => UpdateRoutineCompletion::Completed { + protocol_instance_id, + result, + }, + Err(err) => UpdateRoutineCompletion::Rejected { + protocol_instance_id, + failure: Box::new(err.into()), + }, + }; + Ok(RoutinePollResult { + completion: Some(Box::new(RoutineCompletion::Update(completion))), + made_progress, + }) + } + RoutinePollState::ForcedFailure { failure, .. } => Err(failure), + RoutinePollState::Stalled { made_progress } => { + self.routines.insert( + routine_id, + GuestRoutine::Update { + protocol_instance_id, + future, + }, + ); + Ok(RoutinePollResult { + completion: None, + made_progress, + }) + } + } + } +} + +impl WorkflowInstance for GuestWorkflowInstance +where + ::Input: Send, +{ + fn activate( + &mut self, + activation: WorkflowActivation, + ) -> Result { + self.base_ctx.apply_activation_context(&activation.context); + let mut job_results = Vec::with_capacity(activation.jobs.len()); + for job in activation.jobs { + let result = match job { + WorkflowActivationJob::NotifyPatch { patch_id } => { + self.base_ctx.record_patch(patch_id, true); + ActivationJobResult::None + } + WorkflowActivationJob::Cancel { reason } => { + self.base_ctx.notify_cancel(reason); + ActivationJobResult::None + } + WorkflowActivationJob::Signal(signal) => self.start_signal_routine(signal), + WorkflowActivationJob::Update(update) => self.start_update_routine(update), + WorkflowActivationJob::Query(query) => { + ActivationJobResult::QueryResponse(Box::new(self.query(query))) + } + WorkflowActivationJob::Resolution(resolution) => { + self.apply_resolution(resolution); + ActivationJobResult::None + } + }; + job_results.push(result); + } + Ok(ActivationResult { job_results }) + } + + fn poll_routine( + &mut self, + routine_id: RoutineId, + waker: &Waker, + ) -> Result { + let mut cx = Context::from_waker(waker); + if routine_id == MAIN_ROUTINE_ID { + return self.poll_main_routine(&mut cx); + } + + let routine = self.routines.remove(&routine_id).ok_or_else(|| { + Box::new(Failure { + message: format!("No routine registered for id {routine_id}"), + ..Default::default() + }) + })?; + + match routine { + GuestRoutine::Signal { future } => { + self.poll_signal_routine(routine_id, future, &mut cx) + } + GuestRoutine::Update { + protocol_instance_id, + future, + } => self.poll_update_routine(routine_id, protocol_instance_id, future, &mut cx), + } + } +} + +pub fn instantiate_workflow( + payloads: Vec, + converter: PayloadConverter, + base_ctx: BaseWorkflowContext, +) -> Result, PayloadConversionError> +where + ::Input: Send, +{ + GuestWorkflowInstance::::instantiate(payloads, converter, base_ctx) +} diff --git a/crates/workflow/src/runtime/mod.rs b/crates/workflow/src/runtime/mod.rs new file mode 100644 index 000000000..bb7d6c0c4 --- /dev/null +++ b/crates/workflow/src/runtime/mod.rs @@ -0,0 +1,63 @@ +//! Unstable runtime-facing APIs for workflow hosts and future WASM integrations. +//! +//! These modules collect the parts of the workflow crate that are intended for SDK/runtime glue +//! rather than normal workflow authors. The long-term target for this namespace is the WIT surface +//! checked in under `crates/workflow/wit/`. + +use std::{ + cell::Cell, + future::Future, + pin::Pin, + task::{Context, Poll}, +}; + +pub mod entry; +pub mod guest; +pub mod host; +pub mod instance; +pub mod model; +pub mod types; + +pub use crate::workflow_context::{BaseWorkflowContext, WorkflowContextView}; + +thread_local! { + static SDK_WAKE_DEPTH: Cell = const { Cell::new(0) }; +} + +/// Guard that marks the current scope as an SDK-initiated wake source. +#[doc(hidden)] +pub struct SdkWakeGuard { + _priv: (), +} + +impl SdkWakeGuard { + #[doc(hidden)] + pub fn new() -> Self { + SDK_WAKE_DEPTH.with(|c| c.set(c.get() + 1)); + Self { _priv: () } + } +} + +impl Drop for SdkWakeGuard { + fn drop(&mut self) { + SDK_WAKE_DEPTH.with(|c| c.set(c.get() - 1)); + } +} + +#[doc(hidden)] +pub fn is_sdk_wake() -> bool { + SDK_WAKE_DEPTH.with(|c| c.get() > 0) +} + +/// A future wrapper that activates [`SdkWakeGuard`] during poll. Use this around futures whose +/// internal waker machinery would otherwise trigger false positives in nondeterminism detection. +pub(crate) struct SdkGuardedFuture(pub(crate) F); + +impl Future for SdkGuardedFuture { + type Output = F::Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let _guard = SdkWakeGuard::new(); + Pin::new(&mut self.0).poll(cx) + } +} diff --git a/crates/workflow/src/runtime/model.rs b/crates/workflow/src/runtime/model.rs new file mode 100644 index 000000000..968a8dcc9 --- /dev/null +++ b/crates/workflow/src/runtime/model.rs @@ -0,0 +1,262 @@ +//! Runtime protocol and execution model types shared by workflow code and native hosts. + +#![allow(missing_docs)] + +use crate::{ + runtime::types::ContinueAsNewRequest, + workflow_context::{ + ActivityExecutionError, ChildWfCommon, ChildWorkflowExecutionError, + ChildWorkflowSignalError, NexusUnblockData, PendingChildWorkflow, StartedNexusOperation, + }, +}; +use temporalio_common_wasm::{ + WorkflowDefinition, + protos::{ + coresdk::{ + activity_result::ActivityResolution, + child_workflow::ChildWorkflowResult, + nexus::NexusOperationResult, + workflow_activation::{ + resolve_child_workflow_execution_start::Status as ChildWorkflowStartStatus, + resolve_nexus_operation_start, + }, + }, + temporal::api::failure::v1::Failure, + }, +}; + +#[derive(Debug)] +pub enum UnblockEvent { + Timer(u32, TimerResult), + Activity(u32, Box), + WorkflowStart(u32, Box), + WorkflowComplete(u32, Box), + SignalExternal(u32, Option), + CancelExternal(u32, Option), + NexusOperationStart(u32, Box), + NexusOperationComplete(u32, Box), +} + +/// Result of awaiting on a timer +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum TimerResult { + /// The timer was cancelled + Cancelled, + /// The timer elapsed and fired + Fired, +} + +/// Successful result of sending a signal to an external workflow +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SignalExternalOk; +/// Result of awaiting on sending a signal to an external workflow +pub type SignalExternalWfResult = Result; + +/// Successful result of sending a cancel request to an external workflow +#[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) trait Unblockable { + type OtherDat; + + fn unblock(ue: UnblockEvent, od: Self::OtherDat) -> Self; +} + +impl Unblockable for TimerResult { + type OtherDat = (); + + fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { + match ue { + UnblockEvent::Timer(_, result) => result, + _ => panic!("Invalid unblock event for timer"), + } + } +} + +impl Unblockable for ActivityResolution { + type OtherDat = (); + + fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { + match ue { + UnblockEvent::Activity(_, result) => *result, + _ => panic!("Invalid unblock event for activity"), + } + } +} + +impl Unblockable for PendingChildWorkflow { + type OtherDat = ChildWfCommon; + + fn unblock(ue: UnblockEvent, od: Self::OtherDat) -> Self { + match ue { + UnblockEvent::WorkflowStart(_, result) => Self { + status: *result, + common: od, + _phantom: std::marker::PhantomData, + }, + _ => panic!("Invalid unblock event for child workflow start"), + } + } +} + +impl Unblockable for ChildWorkflowResult { + type OtherDat = (); + + fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { + match ue { + UnblockEvent::WorkflowComplete(_, result) => *result, + _ => panic!("Invalid unblock event for child workflow complete"), + } + } +} + +impl Unblockable for SignalExternalWfResult { + type OtherDat = (); + + fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { + match ue { + UnblockEvent::SignalExternal(_, maybefail) => { + maybefail.map_or(Ok(SignalExternalOk), Err) + } + _ => panic!("Invalid unblock event for signal external workflow result"), + } + } +} + +impl Unblockable for CancelExternalWfResult { + type OtherDat = (); + + fn unblock(ue: UnblockEvent, _: Self::OtherDat) -> Self { + match ue { + UnblockEvent::CancelExternal(_, maybefail) => { + maybefail.map_or(Ok(CancelExternalOk), Err) + } + _ => panic!("Invalid unblock event for cancel external workflow result"), + } + } +} + +pub(crate) type NexusStartResult = Result; + +impl Unblockable for NexusStartResult { + type OtherDat = NexusUnblockData; + + fn unblock(ue: UnblockEvent, od: Self::OtherDat) -> Self { + match ue { + UnblockEvent::NexusOperationStart(_, result) => match *result { + resolve_nexus_operation_start::Status::OperationToken(op_token) => { + Ok(StartedNexusOperation { + operation_token: Some(op_token), + unblock_dat: od, + }) + } + resolve_nexus_operation_start::Status::StartedSync(_) => { + Ok(StartedNexusOperation { + operation_token: None, + unblock_dat: od, + }) + } + 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), + Activity(u32), + LocalActivity(u32), + ChildWorkflow { seqnum: u32, reason: String }, + SignalExternalWorkflow(u32), + NexusOp(u32), +} + +pub(crate) trait SupportsCancelReason { + fn with_reason(self, reason: String) -> CancellableID; +} + +#[derive(Debug, Clone)] +pub enum CancellableIDWithReason { + ChildWorkflow { seqnum: u32 }, +} + +impl SupportsCancelReason for CancellableIDWithReason { + fn with_reason(self, reason: String) -> CancellableID { + match self { + CancellableIDWithReason::ChildWorkflow { seqnum } => { + CancellableID::ChildWorkflow { seqnum, reason } + } + } + } +} + +impl From for CancellableID { + fn from(v: CancellableIDWithReason) -> Self { + v.with_reason(String::new()) + } +} + +/// The result of running a workflow. +pub type WorkflowResult = Result; + +/// Represents ways a workflow can terminate without producing a normal result. +#[derive(Debug, thiserror::Error)] +pub enum WorkflowTermination { + #[error("Workflow cancelled")] + Cancelled, + #[error("Workflow evicted from cache")] + Evicted, + #[error("Continue as new")] + ContinueAsNew(Box), + #[error("Workflow failed: {0}")] + Failed(#[source] anyhow::Error), +} + +impl WorkflowTermination { + pub fn continue_as_new(can: ContinueAsNewRequest) -> Self { + Self::ContinueAsNew(Box::new(can)) + } + + pub fn failed(err: impl Into) -> Self { + Self::Failed(err.into()) + } +} + +impl From for WorkflowTermination { + fn from(err: anyhow::Error) -> Self { + Self::Failed(err) + } +} + +impl From for WorkflowTermination { + fn from(value: ActivityExecutionError) -> Self { + Self::failed(value) + } +} + +impl From for WorkflowTermination { + fn from(value: ChildWorkflowExecutionError) -> Self { + Self::failed(value) + } +} + +impl From for WorkflowTermination { + fn from(value: ChildWorkflowSignalError) -> Self { + Self::failed(value) + } +} diff --git a/crates/workflow/src/runtime/types.rs b/crates/workflow/src/runtime/types.rs new file mode 100644 index 000000000..3edcc4769 --- /dev/null +++ b/crates/workflow/src/runtime/types.rs @@ -0,0 +1,470 @@ +//! Shared runtime model types mirroring the checked-in WIT interface. + +#![allow(missing_docs)] + +use crate::runtime::model::{ + CancelExternalOk, CancelExternalWfResult, CancellableID, CancellableIDWithReason, + SignalExternalOk, SignalExternalWfResult, TimerResult, UnblockEvent, WorkflowResult, + WorkflowTermination, +}; +use std::{ + collections::HashMap, + time::{Duration, SystemTime}, +}; +use temporalio_common_wasm::{ + Priority, WorkerDeploymentVersion, + protos::{ + coresdk::{ + activity_result::ActivityResolution, + child_workflow::{ + ChildWorkflowCancellationType, ChildWorkflowResult, + StartChildWorkflowExecutionFailedCause, + }, + nexus::{NexusOperationCancellationType, NexusOperationResult}, + workflow_activation::{ + resolve_child_workflow_execution_start::Status as ChildWorkflowStartStatus, + resolve_nexus_operation_start::Status as NexusOperationStartStatus, + }, + workflow_commands::ActivityCancellationType, + }, + temporal::api::{ + common::v1::{Payload, RetryPolicy}, + enums::v1::{ + ContinueAsNewVersioningBehavior, ParentClosePolicy, SuggestContinueAsNewReason, + WorkflowIdReusePolicy, + }, + failure::v1::Failure, + }, + }, +}; + +pub use temporalio_common_wasm::protos::coresdk::common::VersioningIntent; + +#[derive(Clone, Debug, PartialEq)] +pub struct NamedPayload { + pub key: String, + pub value: Payload, +} + +pub trait IntoPayloadMap { + fn into_payload_map(self) -> HashMap; +} + +impl IntoPayloadMap for I +where + I: IntoIterator, +{ + fn into_payload_map(self) -> HashMap { + self.into_iter() + .map(|entry| (entry.key, entry.value)) + .collect() + } +} + +pub trait IntoNamedPayloads { + fn into_named_payloads(self) -> Vec; +} + +impl IntoNamedPayloads for I +where + I: IntoIterator, +{ + fn into_named_payloads(self) -> Vec { + self.into_iter() + .map(|(key, value)| NamedPayload { key, value }) + .collect() + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct StringHeader { + pub key: String, + pub value: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkflowExecutionRef { + pub namespace: String, + pub workflow_id: String, + pub run_id: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WorkflowInit { + pub namespace: String, + pub task_queue: String, + pub workflow_id: String, + pub run_id: String, + pub workflow_type: String, + pub attempt: u32, + pub first_execution_run_id: String, + pub continued_from_run_id: Option, + pub start_time: Option, + pub execution_timeout: Option, + pub run_timeout: Option, + pub task_timeout: Option, + pub parent: Option, + pub root: Option, + pub retry_policy: Option, + pub cron_schedule: Option, + pub memo: Vec, + pub search_attributes: Vec, + pub headers: Vec, + pub identity: Option, + pub priority: Option, + pub randomness_seed: u64, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ActivationContext { + pub workflow_time: Option, + pub is_replaying: bool, + pub history_length: u32, + pub history_size_bytes: u64, + pub continue_as_new_suggested: bool, + pub current_deployment_version: Option, + pub last_sdk_version: Option, + pub available_internal_flags: Vec, + pub updated_randomness_seed: Option, + pub target_worker_deployment_version_changed: bool, + pub suggest_continue_as_new_reasons: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkflowDefinitionDescriptor { + pub workflow_type: String, + pub has_init: bool, + pub init_takes_input: bool, + pub signals: Vec, + pub queries: Vec, + pub updates: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateDefinitionDescriptor { + pub name: String, + pub has_validator: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SignalInvocation { + pub name: String, + pub args: Vec, + pub headers: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct UpdateInvocation { + pub update_id: String, + pub protocol_instance_id: String, + pub name: String, + pub args: Vec, + pub headers: Vec, + pub run_validator: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct QueryInvocation { + pub name: String, + pub args: Vec, + pub headers: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct QueryResponse { + pub result: Result, +} + +pub type RoutineId = u64; +pub const MAIN_ROUTINE_ID: RoutineId = 0; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TimerFiredEvent { + pub seq: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ActivityResolutionEvent { + pub seq: u32, + pub result: ActivityResolution, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ChildWorkflowStartResolutionEvent { + pub seq: u32, + pub status: ChildWorkflowStartStatus, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ChildWorkflowResolutionEvent { + pub seq: u32, + pub result: ChildWorkflowResult, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ExternalSignalResolutionEvent { + pub seq: u32, + pub failure: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ExternalCancelResolutionEvent { + pub seq: u32, + pub failure: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct NexusStartResolutionEvent { + pub seq: u32, + pub status: NexusOperationStartStatus, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct NexusResolutionEvent { + pub seq: u32, + pub result: NexusOperationResult, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum WorkflowResolution { + TimerFired(TimerFiredEvent), + Activity(ActivityResolutionEvent), + ChildWorkflowStart(ChildWorkflowStartResolutionEvent), + ChildWorkflow(ChildWorkflowResolutionEvent), + ExternalSignal(ExternalSignalResolutionEvent), + ExternalCancel(ExternalCancelResolutionEvent), + NexusStart(NexusStartResolutionEvent), + Nexus(NexusResolutionEvent), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum WorkflowActivationJob { + NotifyPatch { patch_id: String }, + Cancel { reason: String }, + Signal(SignalInvocation), + Update(UpdateInvocation), + Query(QueryInvocation), + Resolution(WorkflowResolution), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WorkflowActivation { + pub context: ActivationContext, + pub jobs: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RoutineKind { + Main, + Signal { + name: String, + }, + Update { + name: String, + update_id: String, + protocol_instance_id: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct StartedRoutine { + pub routine_id: RoutineId, + pub kind: RoutineKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ActivationJobResult { + None, + StartedRoutine(StartedRoutine), + QueryResponse(Box), + UpdateRejected(WorkflowFailure), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ActivationResult { + pub job_results: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct StartTimerRequest { + pub seq: u32, + pub timeout: Duration, + pub summary: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleActivityRequest { + pub seq: u32, + pub activity_type: String, + pub activity_id: Option, + pub task_queue: Option, + pub args: Vec, + pub schedule_to_start_timeout: Option, + pub start_to_close_timeout: Option, + pub schedule_to_close_timeout: Option, + pub heartbeat_timeout: Option, + pub cancellation_type: ActivityCancellationType, + pub retry_policy: Option, + pub priority: Option, + pub summary: Option, + pub do_not_eagerly_execute: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleLocalActivityRequest { + pub seq: u32, + pub activity_type: String, + pub activity_id: Option, + pub args: Vec, + pub retry_policy: RetryPolicy, + pub attempt: Option, + pub original_schedule_time: Option, + pub timer_backoff_threshold: Option, + pub cancellation_type: ActivityCancellationType, + pub schedule_to_close_timeout: Option, + pub schedule_to_start_timeout: Option, + pub start_to_close_timeout: Option, + pub summary: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct StartChildWorkflowRequest { + pub seq: u32, + pub workflow_type: String, + pub workflow_id: String, + pub task_queue: Option, + pub args: Vec, + pub cancellation_type: ChildWorkflowCancellationType, + pub parent_close_policy: ParentClosePolicy, + pub static_summary: Option, + pub static_details: Option, + pub id_reuse_policy: WorkflowIdReusePolicy, + pub execution_timeout: Option, + pub run_timeout: Option, + pub task_timeout: Option, + pub cron_schedule: Option, + pub search_attributes: Vec, + pub priority: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CancelChildWorkflowRequest { + pub seq: u32, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RequestCancelExternalWorkflowRequest { + pub seq: u32, + pub namespace: Option, + pub workflow_id: String, + pub run_id: Option, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SignalWorkflowTarget { + WorkflowExecution(WorkflowExecutionRef), + ChildWorkflowId(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SignalExternalWorkflowRequest { + pub seq: u32, + pub target: SignalWorkflowTarget, + pub signal: SignalInvocation, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ScheduleNexusOperationRequest { + pub seq: u32, + pub endpoint: String, + pub service: String, + pub operation: String, + pub input: Option, + pub schedule_to_close_timeout: Option, + pub schedule_to_start_timeout: Option, + pub start_to_close_timeout: Option, + pub headers: Vec, + pub cancellation_type: NexusOperationCancellationType, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RequestCancelNexusOperationRequest { + pub seq: u32, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ContinueAsNewRequest { + pub workflow_type: Option, + pub task_queue: Option, + pub args: Vec, + pub run_timeout: Option, + pub task_timeout: Option, + pub memo: Vec, + pub headers: Vec, + pub search_attributes: Option>, + pub retry_policy: Option, + pub versioning_intent: Option, + pub initial_versioning_behavior: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TaskFailure { + pub failure: WorkflowFailure, + pub force_cause: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum TerminalOutcome { + Completed(Payload), + Failed(WorkflowFailure), + Cancelled, + ContinueAsNew(ContinueAsNewRequest), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum MainRoutineCompletion { + Blocked, + TaskFailed(TaskFailure), + Terminal(Box), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum UpdateRoutineCompletion { + Completed { + protocol_instance_id: String, + result: Payload, + }, + Rejected { + protocol_instance_id: String, + failure: WorkflowFailure, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RoutineCompletion { + Main(MainRoutineCompletion), + Signal(Result<(), WorkflowFailure>), + Update(UpdateRoutineCompletion), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RoutinePollResult { + pub completion: Option>, + pub made_progress: bool, +} + +pub type SearchAttributeMap = HashMap; +pub type StartChildWorkflowFailedCause = StartChildWorkflowExecutionFailedCause; +pub type WorkflowFailure = Box; +pub type WorkflowSignalResult = SignalExternalWfResult; +pub type WorkflowCancelResult = CancelExternalWfResult; +pub type WorkflowSignalOk = SignalExternalOk; +pub type WorkflowCancelOk = CancelExternalOk; +pub type RuntimeUnblockEvent = UnblockEvent; +pub type RuntimeTimerResult = TimerResult; +pub type RuntimeWorkflowResult = WorkflowResult; +pub type RuntimeWorkflowTermination = WorkflowTermination; +pub type RuntimeCancellableId = CancellableID; +pub type RuntimeCancellableIdWithReason = CancellableIDWithReason; diff --git a/crates/sdk/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs similarity index 81% rename from crates/sdk/src/workflow_context.rs rename to crates/workflow/src/workflow_context.rs index a77b40a4f..c924605c4 100644 --- a/crates/sdk/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -4,13 +4,22 @@ pub use options::{ ActivityCloseTimeouts, ActivityOptions, ChildWorkflowOptions, ContinueAsNewOptions, LocalActivityOptions, NexusOperationOptions, Signal, SignalData, TimerOptions, }; -pub use temporalio_common::protos::coresdk::child_workflow::StartChildWorkflowExecutionFailedCause; - -use crate::{ - CancelExternalWfResult, CancellableID, CancellableIDWithReason, CommandCreateRequest, - CommandSubscribeChildWorkflowCompletion, NexusStartResult, RustWfCmd, SignalExternalWfResult, - SupportsCancelReason, TimerResult, UnblockEvent, Unblockable, WorkflowTermination, - workflow_context::options::IntoWorkflowCommand, workflow_executor::SdkWakeGuard, +pub use temporalio_common_wasm::protos::coresdk::child_workflow::StartChildWorkflowExecutionFailedCause; + +use crate::runtime::{ + SdkWakeGuard, + entry::WorkflowImplementation, + host::WorkflowHost, + model::{ + CancelExternalWfResult, CancellableID, CancellableIDWithReason, NexusStartResult, + SignalExternalWfResult, SupportsCancelReason, TimerResult, UnblockEvent, Unblockable, + WorkflowTermination, + }, + types::{ + CancelChildWorkflowRequest, IntoNamedPayloads, RequestCancelExternalWorkflowRequest, + RequestCancelNexusOperationRequest, SignalExternalWorkflowRequest, SignalWorkflowTarget, + WorkflowExecutionRef, + }, }; use futures_util::{ FutureExt, @@ -22,17 +31,14 @@ use std::{ collections::HashMap, future::{self, Future}, marker::PhantomData, - ops::{Deref, DerefMut}, + ops::Deref, pin::Pin, rc::Rc, - sync::{ - atomic::{AtomicBool, Ordering}, - mpsc::{Receiver, Sender}, - }, + sync::atomic::{AtomicBool, Ordering}, task::{Poll, Waker}, time::{Duration, SystemTime}, }; -use temporalio_common::{ +use temporalio_common_wasm::{ ActivityDefinition, SignalDefinition, WorkflowDefinition, data_converters::{ ActivityExecutionDecodeHint, ChildWorkflowExecutionDecodeHint, @@ -48,23 +54,15 @@ use temporalio_common::{ coresdk::{ activity_result::{ActivityResolution, Cancellation, activity_resolution}, child_workflow::ChildWorkflowResult, - common::NamespacedWorkflowExecution, nexus::NexusOperationResult, workflow_activation::{ InitializeWorkflow, resolve_child_workflow_execution_start::Status as ChildWorkflowStartStatus, }, - workflow_commands::{ - CancelChildWorkflowExecution, ModifyWorkflowProperties, - RequestCancelExternalWorkflowExecution, SetPatchMarker, - SignalExternalWorkflowExecution, StartTimer, UpsertWorkflowSearchAttributes, - WorkflowCommand, signal_external_workflow_execution as sig_we, workflow_command, - }, }, temporal::api::{ common::v1::{Memo, Payload, SearchAttributes}, failure::v1::{CanceledFailureInfo, Failure, failure::FailureInfo}, - sdk::v1::UserMetadata, }, }, worker::WorkerDeploymentVersion, @@ -79,8 +77,20 @@ pub struct BaseWorkflowContext { inner: Rc, } impl BaseWorkflowContext { - pub(crate) fn shared_mut(&self) -> impl DerefMut { - self.inner.shared.borrow_mut() + pub(crate) fn apply_activation_context(&self, ctx: &crate::runtime::types::ActivationContext) { + let mut shared = self.inner.shared.borrow_mut(); + shared.activation = ctx.clone(); + if let Some(seed) = ctx.updated_randomness_seed { + shared.random_seed = seed; + } + } + + pub(crate) fn record_patch(&self, patch_id: String, present: bool) { + self.inner + .shared + .borrow_mut() + .changes + .insert(patch_id, present); } /// Create a read-only view of this context. @@ -94,12 +104,92 @@ impl BaseWorkflowContext { } } +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +enum PendingCommandId { + Timer(u32), + Activity(u32), + ChildWorkflowStart(u32), + ChildWorkflowComplete(u32), + SignalExternal(u32), + CancelExternal(u32), + NexusOpStart(u32), + NexusOpComplete(u32), +} + +impl PendingCommandId { + fn from_unblock_event(event: &UnblockEvent) -> Self { + match event { + UnblockEvent::Timer(seq, _) => Self::Timer(*seq), + UnblockEvent::Activity(seq, _) => Self::Activity(*seq), + UnblockEvent::WorkflowStart(seq, _) => Self::ChildWorkflowStart(*seq), + UnblockEvent::WorkflowComplete(seq, _) => Self::ChildWorkflowComplete(*seq), + UnblockEvent::SignalExternal(seq, _) => Self::SignalExternal(*seq), + UnblockEvent::CancelExternal(seq, _) => Self::CancelExternal(*seq), + UnblockEvent::NexusOperationStart(seq, _) => Self::NexusOpStart(*seq), + UnblockEvent::NexusOperationComplete(seq, _) => Self::NexusOpComplete(*seq), + } + } +} + +struct WorkflowRuntimeState { + host: Rc, + pending_unblocks: RefCell>>, + forced_wft_failure: RefCell>, + progress_made: Cell, +} + +impl WorkflowRuntimeState { + fn new(host: Rc) -> Self { + Self { + host, + pending_unblocks: RefCell::new(HashMap::new()), + forced_wft_failure: RefCell::new(None), + progress_made: Cell::new(false), + } + } + + fn register_unblocker(&self, id: PendingCommandId, unblocker: oneshot::Sender) { + self.pending_unblocks.borrow_mut().insert(id, unblocker); + } + + fn unblock(&self, event: UnblockEvent) -> Result<(), anyhow::Error> { + let id = PendingCommandId::from_unblock_event(&event); + let unblocker = self + .pending_unblocks + .borrow_mut() + .remove(&id) + .ok_or_else(|| anyhow::anyhow!("Command {id:?} not found to unblock"))?; + self.progress_made.set(true); + let _guard = SdkWakeGuard::new(); + let _ = unblocker.send(event); + Ok(()) + } + + fn set_forced_wft_failure(&self, err: anyhow::Error) { + *self.forced_wft_failure.borrow_mut() = Some(err); + self.progress_made.set(true); + } + + fn take_forced_wft_failure(&self) -> Option { + self.forced_wft_failure.borrow_mut().take() + } + + fn mark_progress(&self) { + self.progress_made.set(true); + } + + fn take_progress(&self) -> bool { + self.progress_made.replace(false) + } +} + struct WorkflowContextInner { namespace: String, task_queue: String, run_id: String, inital_information: InitializeWorkflow, - chan: Sender, + runtime: WorkflowRuntimeState, + am_cancelled_tx: watch::Sender>, am_cancelled: watch::Receiver>, shared: RefCell, seq_nums: RefCell, @@ -194,7 +284,8 @@ pub struct WorkflowContextView { pub root: Option, /// The workflow's retry policy - pub retry_policy: Option, + pub retry_policy: + Option, /// If this workflow runs on a cron schedule pub cron_schedule: Option, /// User-defined memo @@ -285,54 +376,46 @@ impl WorkflowContextView { } impl BaseWorkflowContext { - /// Create a new base context, returning the context itself and a receiver which outputs commands - /// sent from the workflow. - pub(crate) fn new( + /// Create a new base context backed by the provided runtime host. + #[doc(hidden)] + pub fn new( namespace: String, task_queue: String, run_id: String, init_workflow_job: InitializeWorkflow, - am_cancelled: watch::Receiver>, data_converter: DataConverter, - ) -> (Self, Receiver) { - // The receiving side is non-async - let (chan, rx) = std::sync::mpsc::channel(); - ( - Self { - inner: Rc::new(WorkflowContextInner { - namespace, - task_queue, - run_id, - shared: RefCell::new(WorkflowContextSharedData { - random_seed: init_workflow_job.randomness_seed, - search_attributes: init_workflow_job - .search_attributes - .clone() - .unwrap_or_default(), - ..Default::default() - }), - inital_information: init_workflow_job, - chan, - am_cancelled, - seq_nums: RefCell::new(WfCtxProtectedDat { - next_timer_sequence_number: 1, - next_activity_sequence_number: 1, - next_child_workflow_sequence_number: 1, - next_cancel_external_wf_sequence_number: 1, - next_signal_external_wf_sequence_number: 1, - next_nexus_op_sequence_number: 1, - }), - data_converter, - state_mutated: Cell::new(false), + host: Rc, + ) -> Self { + let (am_cancelled_tx, am_cancelled) = watch::channel(None); + Self { + inner: Rc::new(WorkflowContextInner { + namespace, + task_queue, + run_id, + shared: RefCell::new(WorkflowContextSharedData { + random_seed: init_workflow_job.randomness_seed, + search_attributes: init_workflow_job + .search_attributes + .clone() + .unwrap_or_default(), + ..Default::default() }), - }, - rx, - ) - } - - /// Buffer a command to be sent in the activation reply - pub(crate) fn send(&self, c: RustWfCmd) { - self.inner.chan.send(c).expect("command channel intact"); + inital_information: init_workflow_job, + runtime: WorkflowRuntimeState::new(host), + am_cancelled_tx, + am_cancelled, + seq_nums: RefCell::new(WfCtxProtectedDat { + next_timer_sequence_number: 1, + next_activity_sequence_number: 1, + next_child_workflow_sequence_number: 1, + next_cancel_external_wf_sequence_number: 1, + next_signal_external_wf_sequence_number: 1, + next_nexus_op_sequence_number: 1, + }), + data_converter, + state_mutated: Cell::new(false), + }), + } } /// Check and clear the state_mutated flag. Returns `true` if `state_mut` @@ -346,14 +429,59 @@ impl BaseWorkflowContext { self.inner.state_mutated.set(true); } - /// Return the current value of current_details. - pub(crate) fn current_details(&self) -> String { - self.inner.shared.borrow().current_details.clone() + pub(crate) fn take_runtime_progress(&self) -> bool { + self.inner.runtime.take_progress() + } + + pub(crate) fn take_forced_wft_failure(&self) -> Option { + self.inner.runtime.take_forced_wft_failure() + } + + pub(crate) fn notify_cancel(&self, reason: String) { + let _guard = SdkWakeGuard::new(); + self.inner + .am_cancelled_tx + .send(Some(reason)) + .expect("Cancel receiver not dropped"); + self.inner.runtime.mark_progress(); + } + + pub(crate) fn unblock(&self, event: UnblockEvent) -> Result<(), anyhow::Error> { + self.inner.runtime.unblock(event) } /// Cancel any cancellable operation by ID fn cancel(&self, cancellable_id: CancellableID) { - self.send(RustWfCmd::Cancel(cancellable_id)); + match cancellable_id { + CancellableID::Timer(seq) => { + self.inner.runtime.host.cancel_timer(seq); + self.unblock(UnblockEvent::Timer(seq, TimerResult::Cancelled)) + .expect("timer cancellation should have a registered unblocker"); + } + CancellableID::Activity(seq) => self.inner.runtime.host.cancel_activity(seq), + CancellableID::LocalActivity(seq) => self.inner.runtime.host.cancel_local_activity(seq), + CancellableID::ChildWorkflow { seqnum, reason } => self + .inner + .runtime + .host + .cancel_child_workflow(CancelChildWorkflowRequest { + seq: seqnum, + reason: Some(reason), + }), + CancellableID::SignalExternalWorkflow(seq) => { + self.inner.runtime.host.cancel_signal_external_workflow(seq) + } + CancellableID::NexusOp(seq) => self + .inner + .runtime + .host + .cancel_nexus_operation(RequestCancelNexusOperationRequest { seq }), + } + } + + /// Return the current value of current_details. + pub fn current_details(&self) -> String { + self.inner.shared.borrow().current_details.clone() } /// Request to create a timer @@ -365,38 +493,10 @@ impl BaseWorkflowContext { let seq = self.inner.seq_nums.borrow_mut().next_timer_seq(); let (cmd, unblocker) = CancellableWFCommandFut::new(CancellableID::Timer(seq), self.clone()); - let payload_converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; - self.send( - CommandCreateRequest { - cmd: WorkflowCommand { - variant: Some( - StartTimer { - seq, - start_to_fire_timeout: Some( - opts.duration - .try_into() - .expect("Durations must fit into 64 bits"), - ), - } - .into(), - ), - user_metadata: Some(UserMetadata { - summary: opts.summary.map(|summary| { - payload_converter - .to_payload(&context, &summary) - .expect("String-to-JSON payload serialization is infallible") - }), - details: None, - }), - }, - unblocker, - } - .into(), - ); + self.inner + .runtime + .register_unblocker(PendingCommandId::Timer(seq), unblocker); + self.inner.runtime.host.start_timer(opts.into_request(seq)); cmd } @@ -425,16 +525,17 @@ impl BaseWorkflowContext { let seq = self.inner.seq_nums.borrow_mut().next_activity_seq(); let (cmd, unblocker) = CancellableWFCommandFut::new(CancellableID::Activity(seq), self.clone()); + self.inner + .runtime + .register_unblocker(PendingCommandId::Activity(seq), unblocker); if opts.task_queue.is_none() { opts.task_queue = Some(self.inner.task_queue.clone()); } - self.send( - CommandCreateRequest { - cmd: opts.into_command(AD::name().to_string(), payloads, seq), - unblocker, - } - .into(), - ); + self.inner.runtime.host.schedule_activity(opts.into_request( + seq, + AD::name().to_string(), + payloads, + )); ActivityFut::running(cmd, self.inner.data_converter.clone()) } @@ -498,12 +599,9 @@ impl BaseWorkflowContext { CancellableIDWithReason::ChildWorkflow { seqnum: child_seq }, self.clone(), ); - self.send( - CommandSubscribeChildWorkflowCompletion { - seq: child_seq, - unblocker, - } - .into(), + self.inner.runtime.register_unblocker( + PendingCommandId::ChildWorkflowComplete(child_seq), + unblocker, ); let common = ChildWfCommon { @@ -519,13 +617,13 @@ impl BaseWorkflowContext { common, self.clone(), ); - self.send( - CommandCreateRequest { - cmd: opts.into_command(workflow_type, payloads, child_seq), - unblocker, - } - .into(), - ); + self.inner + .runtime + .register_unblocker(PendingCommandId::ChildWorkflowStart(child_seq), unblocker); + self.inner + .runtime + .host + .start_child_workflow(opts.into_request(child_seq, workflow_type, payloads)); ChildWorkflowStartFut::Running(cmd) } @@ -541,21 +639,18 @@ impl BaseWorkflowContext { let (cmd, unblocker) = CancellableWFCommandFut::new(CancellableID::LocalActivity(seq), self.clone()); self.inner - .chan - .send( - CommandCreateRequest { - cmd: opts.into_command(activity_type, arguments, seq), - unblocker, - } - .into(), - ) - .expect("command channel intact"); + .runtime + .register_unblocker(PendingCommandId::Activity(seq), unblocker); + self.inner + .runtime + .host + .schedule_local_activity(opts.into_request(seq, activity_type, arguments)); cmd } fn send_signal_wf( self, - target: sig_we::Target, + target: SignalWorkflowTarget, signal: Signal, ) -> impl CancellableFuture { let seq = self @@ -565,25 +660,17 @@ impl BaseWorkflowContext { .next_signal_external_wf_seq(); let (cmd, unblocker) = CancellableWFCommandFut::new(CancellableID::SignalExternalWorkflow(seq), self.clone()); - self.send( - CommandCreateRequest { - cmd: WorkflowCommand { - variant: Some( - SignalExternalWorkflowExecution { - seq, - signal_name: signal.signal_name, - args: signal.data.input, - target: Some(target), - headers: signal.data.headers, - } - .into(), - ), - user_metadata: None, - }, - unblocker, - } - .into(), - ); + self.inner + .runtime + .register_unblocker(PendingCommandId::SignalExternal(seq), unblocker); + self.inner + .runtime + .host + .signal_external_workflow(SignalExternalWorkflowRequest { + seq, + target, + signal: signal.into_invocation(), + }); cmd } } @@ -611,12 +698,12 @@ impl SyncWorkflowContext { /// Return the current time according to the workflow (which is not wall-clock time). pub fn workflow_time(&self) -> Option { - self.base.inner.shared.borrow().wf_time + self.base.inner.shared.borrow().activation.workflow_time } /// Return the length of history so far at this point in the workflow pub fn history_length(&self) -> u32 { - self.base.inner.shared.borrow().history_length + self.base.inner.shared.borrow().activation.history_length } /// Return the deployment version, if any, as it was when this point in the workflow was first @@ -627,6 +714,7 @@ impl SyncWorkflowContext { .inner .shared .borrow() + .activation .current_deployment_version .clone() } @@ -643,12 +731,17 @@ impl SyncWorkflowContext { /// Returns true if the current workflow task is happening under replay pub fn is_replaying(&self) -> bool { - self.base.inner.shared.borrow().is_replaying + self.base.inner.shared.borrow().activation.is_replaying } /// Returns true if the server suggests this workflow should continue-as-new pub fn continue_as_new_suggested(&self) -> bool { - self.base.inner.shared.borrow().continue_as_new_suggested + self.base + .inner + .shared + .borrow() + .activation + .continue_as_new_suggested } /// Returns the headers for the current handler invocation (signal, update, query, etc.). @@ -697,7 +790,7 @@ impl SyncWorkflowContext { opts: ContinueAsNewOptions, ) -> Result where - W: crate::workflows::WorkflowImplementation, + W: WorkflowImplementation, { let pc = self.base.inner.data_converter.payload_converter(); let ctx = SerializationContext { @@ -708,8 +801,8 @@ impl SyncWorkflowContext { .to_payloads(&ctx, input) .map_err(WorkflowTermination::from)?; let workflow_type = self.workflow_initial_info().workflow_type.clone(); - let proto = opts.into_proto(workflow_type, arguments); - Err(WorkflowTermination::continue_as_new(proto)) + let request = opts.into_request(workflow_type, arguments); + Err(WorkflowTermination::continue_as_new(request)) } /// Request to create a timer @@ -769,13 +862,11 @@ impl SyncWorkflowContext { } fn patch_impl(&self, patch_id: &str, deprecated: bool) -> bool { - self.base.send( - workflow_command::Variant::SetPatchMarker(SetPatchMarker { - patch_id: patch_id.to_string(), - deprecated, - }) - .into(), - ); + self.base + .inner + .runtime + .host + .set_patch_marker(patch_id.to_string(), deprecated); // See if we already know about the status of this change if let Some(present) = self.base.inner.shared.borrow().changes.get(patch_id) { return *present; @@ -783,7 +874,7 @@ impl SyncWorkflowContext { // If we don't already know about the change, that means there is no marker in history, // and we should return false if we are replaying - let res = !self.base.inner.shared.borrow().is_replaying; + let res = !self.base.inner.shared.borrow().activation.is_replaying; self.base .inner @@ -811,26 +902,20 @@ impl SyncWorkflowContext { /// Add or create a set of search attributes pub fn upsert_search_attributes(&self, attr_iter: impl IntoIterator) { - self.base.send(RustWfCmd::NewNonblockingCmd( - workflow_command::Variant::UpsertWorkflowSearchAttributes( - UpsertWorkflowSearchAttributes { - search_attributes: Some(SearchAttributes { - indexed_fields: HashMap::from_iter(attr_iter), - }), - }, - ), - )) + self.base + .inner + .runtime + .host + .upsert_search_attributes(attr_iter.into_named_payloads()); } /// Add or create a set of search attributes pub fn upsert_memo(&self, attr_iter: impl IntoIterator) { - self.base.send(RustWfCmd::NewNonblockingCmd( - workflow_command::Variant::ModifyWorkflowProperties(ModifyWorkflowProperties { - upserted_memo: Some(Memo { - fields: HashMap::from_iter(attr_iter), - }), - }), - )) + self.base + .inner + .runtime + .host + .upsert_memo(attr_iter.into_named_payloads()); } /// Set the current details string for this workflow execution. @@ -838,12 +923,14 @@ impl SyncWorkflowContext { /// The value is surfaced to the Temporal server UI in real time via the /// the workflow metadata query. pub fn set_current_details(&self, details: impl Into) { - self.base.inner.shared.borrow_mut().current_details = details.into(); + let details = details.into(); + self.base.inner.shared.borrow_mut().current_details = details.clone(); + self.base.inner.runtime.host.set_current_details(details); } /// Force a workflow task failure (EX: in order to retry on non-sticky queue) pub fn force_task_fail(&self, with: anyhow::Error) { - self.base.send(with.into()); + self.base.inner.runtime.set_forced_wft_failure(with); } /// Start a nexus operation @@ -854,7 +941,9 @@ impl SyncWorkflowContext { let seq = self.base.inner.seq_nums.borrow_mut().next_nexus_op_seq(); let (result_future, unblocker) = WFCommandFut::new(); self.base - .send(RustWfCmd::SubscribeNexusOperationCompletion { seq, unblocker }); + .inner + .runtime + .register_unblocker(PendingCommandId::NexusOpComplete(seq), unblocker); let (cmd, unblocker) = CancellableWFCommandFut::new_with_dat( CancellableID::NexusOp(seq), NexusUnblockData { @@ -864,13 +953,15 @@ impl SyncWorkflowContext { }, self.base.clone(), ); - self.base.send( - CommandCreateRequest { - cmd: opts.into_command(seq), - unblocker, - } - .into(), - ); + self.base + .inner + .runtime + .register_unblocker(PendingCommandId::NexusOpStart(seq), unblocker); + self.base + .inner + .runtime + .host + .schedule_nexus_operation(opts.into_request(seq)); cmd } @@ -912,6 +1003,11 @@ impl WorkflowContext { self.sync.clone() } + /// Create a read-only view of this context. + pub(crate) fn view(&self) -> WorkflowContextView { + self.sync.view() + } + // --- Delegated methods from SyncWorkflowContext --- /// Return the workflow's unique identifier @@ -1085,11 +1181,6 @@ impl WorkflowContext { self.sync.start_nexus_operation(opts) } - /// Create a read-only view of this context. - pub(crate) fn view(&self) -> WorkflowContextView { - self.sync.view() - } - /// Access workflow state immutably via closure. /// /// The borrow is scoped to the closure and cannot escape, preventing @@ -1126,7 +1217,7 @@ impl WorkflowContext { opts: ContinueAsNewOptions, ) -> Result where - W: crate::workflows::WorkflowImplementation, + W: WorkflowImplementation, { self.sync.continue_as_new(input, opts) } @@ -1196,16 +1287,12 @@ impl WfCtxProtectedDat { #[derive(Clone, Debug, Default)] pub(crate) struct WorkflowContextSharedData { /// Maps change ids -> resolved status - pub(crate) changes: HashMap, - pub(crate) is_replaying: bool, - pub(crate) wf_time: Option, - pub(crate) history_length: u32, - pub(crate) continue_as_new_suggested: bool, - pub(crate) current_deployment_version: Option, - pub(crate) search_attributes: SearchAttributes, - pub(crate) random_seed: u64, + changes: HashMap, + activation: crate::runtime::types::ActivationContext, + search_attributes: SearchAttributes, + random_seed: u64, /// Current details string, surfaced via the workflow metadata query. - pub(crate) current_details: String, + current_details: String, } /// A Future that can be cancelled. @@ -1660,7 +1747,7 @@ where } => match Pin::new(inner).poll(cx) { Poll::Pending => Poll::Pending, Poll::Ready(result) => Poll::Ready({ - use temporalio_common::protos::coresdk::child_workflow::child_workflow_result; + use temporalio_common_wasm::protos::coresdk::child_workflow::child_workflow_result; let status = result.status.ok_or_else(|| { data_converter .to_error( @@ -1944,13 +2031,15 @@ where /// Cancel the child workflow pub fn cancel(&self, reason: String) { - self.common.base_ctx.send(RustWfCmd::NewNonblockingCmd( - CancelChildWorkflowExecution { - child_workflow_seq: self.common.child_seq, - reason, - } - .into(), - )); + self.common + .base_ctx + .inner + .runtime + .host + .cancel_child_workflow(CancelChildWorkflowRequest { + seq: self.common.child_seq, + reason: Some(reason), + }); } /// Send a typed signal to the child workflow. @@ -1971,7 +2060,7 @@ where } }; let signal = Signal::new(S::name(&signal), payloads); - let target = sig_we::Target::ChildWorkflowId(self.common.workflow_id.clone()); + let target = SignalWorkflowTarget::ChildWorkflowId(self.common.workflow_id.clone()); SignalChildFut::Running { inner: self.common.base_ctx.clone().send_signal_wf(target, signal), data_converter: self.common.data_converter.clone(), @@ -2021,10 +2110,10 @@ impl ExternalWorkflowHandle { } }; let signal = Signal::new(S::name(&signal), payloads); - let target = sig_we::Target::WorkflowExecution(NamespacedWorkflowExecution { + let target = SignalWorkflowTarget::WorkflowExecution(WorkflowExecutionRef { namespace: self.namespace.clone(), workflow_id: self.workflow_id.clone(), - run_id: self.run_id.clone().unwrap_or_default(), + run_id: self.run_id.clone(), }); SignalExternalFut::Running(self.base_ctx.clone().send_signal_wf(target, signal)) } @@ -2041,27 +2130,21 @@ impl ExternalWorkflowHandle { .borrow_mut() .next_cancel_external_wf_seq(); let (cmd, unblocker) = WFCommandFut::new(); - self.base_ctx.send( - CommandCreateRequest { - cmd: WorkflowCommand { - variant: Some( - RequestCancelExternalWorkflowExecution { - seq, - workflow_execution: Some(NamespacedWorkflowExecution { - namespace: self.namespace.clone(), - workflow_id: self.workflow_id.clone(), - run_id: self.run_id.clone().unwrap_or_default(), - }), - reason: reason.unwrap_or_default(), - } - .into(), - ), - user_metadata: None, - }, - unblocker, - } - .into(), - ); + self.base_ctx + .inner + .runtime + .register_unblocker(PendingCommandId::CancelExternal(seq), unblocker); + self.base_ctx + .inner + .runtime + .host + .request_cancel_external_workflow(RequestCancelExternalWorkflowRequest { + seq, + namespace: Some(self.namespace.clone()), + workflow_id: self.workflow_id.clone(), + run_id: self.run_id.clone(), + reason, + }); cmd } } @@ -2150,8 +2233,13 @@ impl StartedNexusOperation { #[cfg(test)] mod tests { use super::*; + use crate::runtime::types::{ + NamedPayload, RequestCancelExternalWorkflowRequest, RequestCancelNexusOperationRequest, + ScheduleActivityRequest, ScheduleLocalActivityRequest, ScheduleNexusOperationRequest, + SignalExternalWorkflowRequest, StartChildWorkflowRequest, StartTimerRequest, + }; use std::collections::HashMap; - use temporalio_common::{ + use temporalio_common_wasm::{ data_converters::{TemporalDeserializable, TemporalSerializable}, protos::{ coresdk::{AsJsonPayloadExt, common::VersioningIntent}, @@ -2160,6 +2248,30 @@ mod tests { }; use temporalio_macros::{workflow, workflow_methods}; + #[derive(Default)] + struct NoopHost; + + impl WorkflowHost for NoopHost { + fn set_current_details(&self, _details: String) {} + fn start_timer(&self, _req: StartTimerRequest) {} + fn cancel_timer(&self, _seq: u32) {} + fn schedule_activity(&self, _req: ScheduleActivityRequest) {} + fn cancel_activity(&self, _seq: u32) {} + fn schedule_local_activity(&self, _req: ScheduleLocalActivityRequest) {} + fn cancel_local_activity(&self, _seq: u32) {} + fn start_child_workflow(&self, _req: StartChildWorkflowRequest) {} + fn cancel_child_workflow(&self, _req: CancelChildWorkflowRequest) {} + fn request_cancel_external_workflow(&self, _req: RequestCancelExternalWorkflowRequest) {} + fn signal_external_workflow(&self, _req: SignalExternalWorkflowRequest) {} + fn cancel_signal_external_workflow(&self, _seq: u32) {} + fn schedule_nexus_operation(&self, _req: ScheduleNexusOperationRequest) {} + fn cancel_nexus_operation(&self, _req: RequestCancelNexusOperationRequest) {} + fn upsert_search_attributes(&self, _entries: Vec) {} + fn upsert_memo(&self, _entries: Vec) {} + fn set_patch_marker(&self, _patch_id: String, _deprecated: bool) {} + fn continue_as_new(&self, _req: crate::runtime::types::ContinueAsNewRequest) {} + } + #[workflow] #[derive(Default)] struct TestWorkflow; @@ -2177,14 +2289,13 @@ mod tests { workflow_type: TestWorkflow.name().to_string(), ..Default::default() }; - let (_, cancelled_rx) = watch::channel(None); - let (base, _cmd_rx) = BaseWorkflowContext::new( + let base = BaseWorkflowContext::new( "default".to_string(), "orig-task-queue".to_string(), "run-id".to_string(), init, - cancelled_rx, DataConverter::default(), + Rc::new(NoopHost), ); WorkflowContext::from_base(base, Rc::new(RefCell::new(TestWorkflow))) } @@ -2206,11 +2317,18 @@ mod tests { assert_eq!( *cmd, - temporalio_common::protos::coresdk::workflow_commands::ContinueAsNewWorkflowExecution { - workflow_type: TestWorkflow.name().to_string(), - arguments: vec![7u8.as_json_payload().unwrap()], - versioning_intent: VersioningIntent::Unspecified as i32, - ..Default::default() + crate::runtime::types::ContinueAsNewRequest { + workflow_type: Some(TestWorkflow.name().to_string()), + task_queue: None, + args: vec![7u8.as_json_payload().unwrap()], + run_timeout: None, + task_timeout: None, + memo: vec![], + headers: vec![], + search_attributes: None, + retry_policy: None, + versioning_intent: Some(VersioningIntent::Unspecified), + initial_versioning_behavior: None, } ); } @@ -2264,25 +2382,46 @@ mod tests { assert_eq!( *cmd, - temporalio_common::protos::coresdk::workflow_commands::ContinueAsNewWorkflowExecution { - 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()), - memo, - headers, - search_attributes: Some(search_attributes), + crate::runtime::types::ContinueAsNewRequest { + workflow_type: Some("next-workflow".to_string()), + task_queue: Some("next-task-queue".to_string()), + args: vec![11u8.as_json_payload().unwrap()], + run_timeout: Some(Duration::from_secs(10)), + task_timeout: Some(Duration::from_secs(3)), + memo: memo.into_named_payloads(), + headers: headers.into_named_payloads(), + search_attributes: Some(search_attributes.indexed_fields.into_named_payloads()), retry_policy: Some(RetryPolicy { maximum_attempts: 5, ..Default::default() }), - versioning_intent: VersioningIntent::Compatible as i32, - ..Default::default() + versioning_intent: Some(VersioningIntent::Compatible), + initial_versioning_behavior: None, } ); } + #[test] + fn continue_as_new_preserves_explicit_empty_search_attributes() { + let ctx = test_context(); + let sync = ctx.sync_context(); + + let termination = sync + .continue_as_new( + &11, + ContinueAsNewOptions { + search_attributes: Some(SearchAttributes::default()), + ..Default::default() + }, + ) + .expect_err("continue_as_new should terminate the workflow"); + let WorkflowTermination::ContinueAsNew(cmd) = termination else { + unreachable!() + }; + + assert_eq!(cmd.search_attributes, Some(vec![])); + } + #[test] fn continue_as_new_reports_serialization_errors() { #[derive(Debug)] @@ -2291,11 +2430,11 @@ mod tests { impl TemporalSerializable for FailingInput { fn to_payload( &self, - _ctx: &temporalio_common::data_converters::SerializationContext<'_>, - ) -> Result + _ctx: &temporalio_common_wasm::data_converters::SerializationContext<'_>, + ) -> Result { Err( - temporalio_common::data_converters::PayloadConversionError::EncodingError( + temporalio_common_wasm::data_converters::PayloadConversionError::EncodingError( std::io::Error::other("serialization failure").into(), ), ) @@ -2304,9 +2443,9 @@ mod tests { impl TemporalDeserializable for FailingInput { fn from_payload( - _ctx: &temporalio_common::data_converters::SerializationContext<'_>, + _ctx: &temporalio_common_wasm::data_converters::SerializationContext<'_>, _payload: Payload, - ) -> Result + ) -> Result { unreachable!("test input is only serialized") } @@ -2331,14 +2470,13 @@ mod tests { workflow_type: "failing-workflow".to_string(), ..Default::default() }; - let (_, cancelled_rx) = watch::channel(None); - let (base, _cmd_rx) = BaseWorkflowContext::new( + let base = BaseWorkflowContext::new( "default".to_string(), "orig-task-queue".to_string(), "run-id".to_string(), init, - cancelled_rx, DataConverter::default(), + Rc::new(NoopHost), ); let ctx = WorkflowContext::from_base(base, Rc::new(RefCell::new(FailingWorkflow))); diff --git a/crates/sdk/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs similarity index 58% rename from crates/sdk/src/workflow_context/options.rs rename to crates/workflow/src/workflow_context/options.rs index b04ec71d2..ac34378ed 100644 --- a/crates/sdk/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -1,35 +1,25 @@ use std::{collections::HashMap, time::Duration}; -use temporalio_client::Priority; -use temporalio_common::{ - data_converters::{ - GenericPayloadConverter, PayloadConverter, SerializationContext, SerializationContextData, - }, +use crate::runtime::types::{ + ContinueAsNewRequest, IntoNamedPayloads, ScheduleActivityRequest, ScheduleLocalActivityRequest, + ScheduleNexusOperationRequest, SignalInvocation, StartChildWorkflowRequest, StartTimerRequest, + StringHeader, +}; +use temporalio_common_wasm::{ + Priority, protos::{ coresdk::{ - child_workflow::ChildWorkflowCancellationType, - common::VersioningIntent, - nexus::NexusOperationCancellationType, - workflow_commands::{ - ActivityCancellationType, ContinueAsNewWorkflowExecution, ScheduleActivity, - ScheduleLocalActivity, ScheduleNexusOperation, StartChildWorkflowExecution, - WorkflowCommand, - }, + child_workflow::ChildWorkflowCancellationType, common::VersioningIntent, + nexus::NexusOperationCancellationType, workflow_commands::ActivityCancellationType, }, temporal::api::{ common::v1::{Payload, RetryPolicy, SearchAttributes}, enums::v1::{ParentClosePolicy, WorkflowIdReusePolicy}, - sdk::v1::UserMetadata, }, }, }; // TODO: Before release, probably best to avoid using proto types entirely here. They're awkward. -pub(crate) trait IntoWorkflowCommand { - /// Produces a workflow command from some options - fn into_command(self, seq: u32) -> WorkflowCommand; -} - /// Options for scheduling an activity #[derive(Debug, bon::Builder, Clone)] #[non_exhaustive] @@ -98,74 +88,22 @@ impl ActivityOptions { pub fn schedule_to_close_timeout(duration: Duration) -> Self { Self::with_schedule_to_close_timeout(duration).build() } - - pub(crate) fn into_command( - self, - activity_type: String, - arguments: Vec, - seq: u32, - ) -> WorkflowCommand { - let payload_converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; - let (start_to_close_timeout, schedule_to_close_timeout) = - self.close_timeouts.into_durations(); - WorkflowCommand { - variant: Some( - ScheduleActivity { - seq, - activity_id: match self.activity_id { - None => seq.to_string(), - Some(aid) => aid, - }, - activity_type, - task_queue: self.task_queue.unwrap_or_default(), - schedule_to_close_timeout: schedule_to_close_timeout - .and_then(|d| d.try_into().ok()), - schedule_to_start_timeout: self - .schedule_to_start_timeout - .and_then(|d| d.try_into().ok()), - start_to_close_timeout: start_to_close_timeout.and_then(|d| d.try_into().ok()), - heartbeat_timeout: self.heartbeat_timeout.and_then(|d| d.try_into().ok()), - cancellation_type: self.cancellation_type as i32, - arguments, - retry_policy: self.retry_policy, - priority: self.priority.map(Into::into), - do_not_eagerly_execute: self.do_not_eagerly_execute, - ..Default::default() - } - .into(), - ), - user_metadata: self - .summary - .map(|s| { - payload_converter - .to_payload(&context, &s) - .expect("String-to-JSON payload serialization is infallible") - }) - .map(|summary| UserMetadata { - summary: Some(summary), - details: None, - }), - } - } } /// 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). + /// `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. + /// 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 { @@ -189,6 +127,34 @@ impl ActivityCloseTimeouts { } } +impl ActivityOptions { + pub(crate) fn into_request( + self, + seq: u32, + activity_type: String, + args: Vec, + ) -> ScheduleActivityRequest { + let (start_to_close_timeout, schedule_to_close_timeout) = + self.close_timeouts.into_durations(); + ScheduleActivityRequest { + seq, + activity_type, + activity_id: self.activity_id, + task_queue: self.task_queue, + args, + schedule_to_close_timeout, + schedule_to_start_timeout: self.schedule_to_start_timeout, + start_to_close_timeout, + heartbeat_timeout: self.heartbeat_timeout, + cancellation_type: self.cancellation_type, + retry_policy: self.retry_policy, + priority: self.priority, + summary: self.summary, + do_not_eagerly_execute: self.do_not_eagerly_execute, + } + } +} + /// Options for scheduling a local activity #[derive(Default, Debug, Clone)] pub struct LocalActivityOptions { @@ -230,63 +196,30 @@ pub struct LocalActivityOptions { } impl LocalActivityOptions { - pub(crate) fn into_command( + pub(crate) fn into_request( mut self, - activity_type: String, - arguments: Vec, seq: u32, - ) -> WorkflowCommand { - let payload_converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; - // Allow tests to avoid extra verbosity when they don't care about timeouts - // TODO: Builderize LA options + activity_type: String, + args: Vec, + ) -> ScheduleLocalActivityRequest { + // Tests and some workflow code rely on the historical SDK behavior where omitted local + // activity timeouts are normalized before the command is emitted. self.schedule_to_close_timeout .get_or_insert(Duration::from_secs(100)); - - WorkflowCommand { - variant: Some( - ScheduleLocalActivity { - seq, - attempt: self.attempt.unwrap_or(1), - original_schedule_time: self.original_schedule_time, - activity_id: match self.activity_id { - None => seq.to_string(), - Some(aid) => aid, - }, - activity_type, - arguments, - retry_policy: Some(self.retry_policy), - local_retry_threshold: self - .timer_backoff_threshold - .and_then(|d| d.try_into().ok()), - cancellation_type: self.cancel_type.into(), - schedule_to_close_timeout: self - .schedule_to_close_timeout - .and_then(|d| d.try_into().ok()), - schedule_to_start_timeout: self - .schedule_to_start_timeout - .and_then(|d| d.try_into().ok()), - start_to_close_timeout: self - .start_to_close_timeout - .and_then(|d| d.try_into().ok()), - ..Default::default() - } - .into(), - ), - user_metadata: self - .summary - .map(|summary| { - payload_converter - .to_payload(&context, &summary) - .expect("String-to-JSON payload serialization is infallible") - }) - .map(|summary| UserMetadata { - summary: Some(summary), - details: None, - }), + ScheduleLocalActivityRequest { + seq, + activity_type, + activity_id: self.activity_id, + args, + retry_policy: self.retry_policy, + attempt: self.attempt, + original_schedule_time: self.original_schedule_time.and_then(|t| t.try_into().ok()), + timer_backoff_threshold: self.timer_backoff_threshold, + cancellation_type: self.cancel_type, + schedule_to_close_timeout: self.schedule_to_close_timeout, + schedule_to_start_timeout: self.schedule_to_start_timeout, + start_to_close_timeout: self.start_to_close_timeout, + summary: self.summary, } } } @@ -325,59 +258,32 @@ pub struct ChildWorkflowOptions { } impl ChildWorkflowOptions { - pub(crate) fn into_command( + pub(crate) fn into_request( self, - workflow_type: String, - input: Vec, seq: u32, - ) -> WorkflowCommand { - let payload_converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &payload_converter, - }; - let user_metadata = if self.static_summary.is_some() || self.static_details.is_some() { - Some(UserMetadata { - summary: self.static_summary.map(|s| { - payload_converter - .to_payload(&context, &s) - .expect("String-to-JSON payload serialization is infallible") - }), - details: self.static_details.map(|s| { - payload_converter - .to_payload(&context, &s) - .expect("String-to-JSON payload serialization is infallible") - }), - }) - } else { - None - }; - WorkflowCommand { - variant: Some( - StartChildWorkflowExecution { - seq, - workflow_id: self.workflow_id, - workflow_type, - task_queue: self.task_queue.unwrap_or_default(), - input, - cancellation_type: self.cancel_type as i32, - workflow_id_reuse_policy: self.id_reuse_policy as i32, - workflow_execution_timeout: self - .execution_timeout - .and_then(|d| d.try_into().ok()), - workflow_run_timeout: self.run_timeout.and_then(|d| d.try_into().ok()), - workflow_task_timeout: self.task_timeout.and_then(|d| d.try_into().ok()), - search_attributes: self - .search_attributes - .map(|sa| SearchAttributes { indexed_fields: sa }), - cron_schedule: self.cron_schedule.unwrap_or_default(), - parent_close_policy: self.parent_close_policy as i32, - priority: self.priority.map(Into::into), - ..Default::default() - } - .into(), - ), - user_metadata, + workflow_type: String, + args: Vec, + ) -> StartChildWorkflowRequest { + StartChildWorkflowRequest { + seq, + workflow_type, + workflow_id: self.workflow_id, + task_queue: self.task_queue, + args, + cancellation_type: self.cancel_type, + parent_close_policy: self.parent_close_policy, + static_summary: self.static_summary, + static_details: self.static_details, + id_reuse_policy: self.id_reuse_policy, + execution_timeout: self.execution_timeout, + run_timeout: self.run_timeout, + task_timeout: self.task_timeout, + cron_schedule: self.cron_schedule, + search_attributes: self + .search_attributes + .unwrap_or_default() + .into_named_payloads(), + priority: self.priority, } } } @@ -402,6 +308,14 @@ impl Signal { data: SignalData::new(input), } } + + pub(crate) fn into_invocation(self) -> SignalInvocation { + SignalInvocation { + name: self.signal_name, + args: self.data.input, + headers: self.data.headers.into_named_payloads(), + } + } } /// Data contained within a signal @@ -451,6 +365,16 @@ impl From for TimerOptions { } } +impl TimerOptions { + pub(crate) fn into_request(self, seq: u32) -> StartTimerRequest { + StartTimerRequest { + seq, + timeout: self.duration, + summary: self.summary, + } + } +} + /// Options for Nexus Operations #[derive(Default, Debug, Clone)] pub struct NexusOperationOptions { @@ -493,33 +417,20 @@ pub struct NexusOperationOptions { pub start_to_close_timeout: Option, } -impl IntoWorkflowCommand for NexusOperationOptions { - fn into_command(self, seq: u32) -> WorkflowCommand { - WorkflowCommand { - user_metadata: None, - variant: Some( - 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(|t| t.try_into().ok()), - schedule_to_start_timeout: self - .schedule_to_start_timeout - .and_then(|t| t.try_into().ok()), - start_to_close_timeout: self - .start_to_close_timeout - .and_then(|t| t.try_into().ok()), - nexus_header: self.nexus_header, - cancellation_type: self - .cancellation_type - .unwrap_or(NexusOperationCancellationType::WaitCancellationCompleted) - .into(), - } - .into(), +impl NexusOperationOptions { + pub(crate) fn into_request(self, seq: u32) -> ScheduleNexusOperationRequest { + ScheduleNexusOperationRequest { + seq, + endpoint: self.endpoint, + service: self.service, + operation: self.operation, + input: self.input, + schedule_to_close_timeout: self.schedule_to_close_timeout, + schedule_to_start_timeout: self.schedule_to_start_timeout, + start_to_close_timeout: self.start_to_close_timeout, + headers: string_headers(self.nexus_header), + cancellation_type: self.cancellation_type.unwrap_or( + temporalio_common_wasm::protos::coresdk::nexus::NexusOperationCancellationType::WaitCancellationCompleted, ), } } @@ -553,34 +464,42 @@ pub struct ContinueAsNewOptions { } impl ContinueAsNewOptions { - pub(crate) fn into_proto( + pub(crate) fn into_request( self, workflow_type: String, arguments: Vec, - ) -> ContinueAsNewWorkflowExecution { - ContinueAsNewWorkflowExecution { - workflow_type: self.workflow_type.unwrap_or(workflow_type), - task_queue: self.task_queue.unwrap_or_default(), - arguments, - workflow_run_timeout: self.run_timeout.and_then(|t| t.try_into().ok()), - workflow_task_timeout: self.task_timeout.and_then(|t| t.try_into().ok()), - memo: self.memo.unwrap_or_default(), - headers: self.headers.unwrap_or_default(), - search_attributes: self.search_attributes, + ) -> ContinueAsNewRequest { + ContinueAsNewRequest { + workflow_type: Some(self.workflow_type.unwrap_or(workflow_type)), + task_queue: self.task_queue, + args: arguments, + run_timeout: self.run_timeout, + task_timeout: self.task_timeout, + memo: self.memo.unwrap_or_default().into_named_payloads(), + headers: self.headers.unwrap_or_default().into_named_payloads(), + search_attributes: self + .search_attributes + .map(|attrs| attrs.indexed_fields.into_named_payloads()), retry_policy: self.retry_policy, - versioning_intent: self - .versioning_intent - .unwrap_or(VersioningIntent::Unspecified) - .into(), - ..Default::default() + versioning_intent: Some( + self.versioning_intent + .unwrap_or(VersioningIntent::Unspecified), + ), + initial_versioning_behavior: None, } } } +fn string_headers(entries: impl IntoIterator) -> Vec { + entries + .into_iter() + .map(|(key, value)| StringHeader { key, value }) + .collect() +} + #[cfg(test)] mod tests { use super::*; - use temporalio_common::protos::coresdk::workflow_commands::workflow_command::Variant; #[test] fn activity_options_with_start_to_close_timeout_wrapper_supports_builder_chaining() { @@ -610,19 +529,14 @@ mod tests { #[test] fn activity_options_both_close_timeouts_map_to_command() { - let cmd = ActivityOptions::with_close_timeouts(ActivityCloseTimeouts::Both { + let req = ActivityOptions::with_close_timeouts(ActivityCloseTimeouts::Both { start_to_close: Duration::from_secs(3), schedule_to_close: Duration::from_secs(8), }) .build() - .into_command("test".to_string(), vec![], 7); - let schedule_cmd = match cmd.variant.unwrap() { - Variant::ScheduleActivity(cmd) => cmd, - other => panic!("Expected ScheduleActivity, got {other:?}"), - }; - - assert_eq!(schedule_cmd.start_to_close_timeout.unwrap().seconds, 3); - assert_eq!(schedule_cmd.schedule_to_close_timeout.unwrap().seconds, 8); + .into_request(7, "test".to_string(), vec![]); + assert_eq!(req.start_to_close_timeout.unwrap().as_secs(), 3); + assert_eq!(req.schedule_to_close_timeout.unwrap().as_secs(), 8); } #[test] @@ -633,17 +547,11 @@ mod tests { run_timeout: Some(Duration::from_secs(10)), ..Default::default() }; - let cmd = opts.into_command("TestWorkflow".to_string(), vec![], 1); - let variant = cmd.variant.unwrap(); - let start_cmd: StartChildWorkflowExecution = match variant { - temporalio_common::protos::coresdk::workflow_commands::workflow_command::Variant::StartChildWorkflowExecution(s) => s, - other => panic!("Expected StartChildWorkflowExecution, got {other:?}"), - }; - - let exec_timeout = start_cmd.workflow_execution_timeout.unwrap(); - let run_timeout = start_cmd.workflow_run_timeout.unwrap(); - assert_eq!(exec_timeout.seconds, 60); - assert_eq!(run_timeout.seconds, 10); + let req = opts.into_request(1, "TestWorkflow".to_string(), vec![]); + let exec_timeout = req.execution_timeout.unwrap(); + let run_timeout = req.run_timeout.unwrap(); + assert_eq!(exec_timeout.as_secs(), 60); + assert_eq!(run_timeout.as_secs(), 10); } #[test] @@ -653,14 +561,9 @@ mod tests { execution_timeout: Some(Duration::from_secs(60)), ..Default::default() }; - let cmd = opts.into_command("TestWorkflow".to_string(), vec![], 1); - let variant = cmd.variant.unwrap(); - let start_cmd: StartChildWorkflowExecution = match variant { - temporalio_common::protos::coresdk::workflow_commands::workflow_command::Variant::StartChildWorkflowExecution(s) => s, - other => panic!("Expected StartChildWorkflowExecution, got {other:?}"), - }; - - assert_eq!(start_cmd.workflow_execution_timeout.unwrap().seconds, 60); - assert!(start_cmd.workflow_run_timeout.is_none()); + let req = opts.into_request(1, "TestWorkflow".to_string(), vec![]); + let exec_timeout = req.execution_timeout.unwrap(); + assert_eq!(exec_timeout.as_secs(), 60); + assert!(req.run_timeout.is_none()); } } diff --git a/crates/workflow/src/workflows.rs b/crates/workflow/src/workflows.rs new file mode 100644 index 000000000..86f954b71 --- /dev/null +++ b/crates/workflow/src/workflows.rs @@ -0,0 +1,141 @@ +//! Functionality related to defining and interacting with workflows +//! +//! This module contains traits and types for implementing workflows using the +//! `#[workflow]` and `#[workflow_methods]` macros. +//! +//! Example usage: +//! ``` +//! 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 method uses ctx.state() for reading +//! #[run] +//! pub async fn run(ctx: &mut WorkflowContext) -> WorkflowResult { +//! let counter = ctx.state(|s| s.counter); +//! Ok(format!("Done with counter: {}", counter)) +//! } +//! +//! // Sync signals use &mut self for direct mutations +//! #[signal] +//! pub fn increment(&mut self, ctx: &mut SyncWorkflowContext, amount: u32) { +//! self.counter += amount; +//! } +//! +//! // Queries use &self with read-only context +//! #[query] +//! pub fn get_counter(&self, ctx: &WorkflowContextView) -> u32 { +//! self.counter +//! } +//! } +//! ``` + +/// Deterministic `select!` for use in Temporal workflows. +/// +/// Polls branches in declaration order (top to bottom), ensuring deterministic +/// behavior across workflow replays. Delegates to [`futures_util::select_biased!`]. +/// +/// All workflow futures (timers, activities, child workflows, etc.) implement +/// `FusedFuture`, so they can be stored in variables and passed to `select!` +/// without needing `.fuse()`. +/// +/// # Example +/// +/// ```ignore +/// use temporalio_sdk::workflows::select; +/// use temporalio_sdk::WorkflowContext; +/// use std::time::Duration; +/// +/// # async fn hidden(ctx: &mut WorkflowContext<()>) { +/// select! { +/// _ = ctx.timer(Duration::from_secs(60)) => { /* timer fired */ } +/// reason = ctx.cancelled() => { /* cancelled */ } +/// }; +/// # } +/// ``` +#[doc(inline)] +pub use crate::__temporal_select as select; + +/// Deterministic `join!` for use in Temporal workflows. +/// +/// Polls all futures concurrently to completion in declaration order, +/// ensuring deterministic behavior across workflow replays. Delegates +/// to [`futures_util::join!`]. +/// +/// # Example +/// +/// ```ignore +/// use temporalio_sdk::workflows::join; +/// +/// # async fn hidden() { +/// let future_a = async { 1 }; +/// let future_b = async { 2 }; +/// let (a, b) = join!(future_a, future_b); +/// # } +/// ``` +#[doc(inline)] +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, deserialize_input, + serialize_output, serialize_result, wrap_handler_error, +}; + +/// Deterministic `join_all` for use in Temporal workflows. +/// +/// Polls a collection of futures concurrently to completion in declaration order, +/// returning a `Vec` of their results. +/// +/// # Example +/// +/// ```ignore +/// use temporalio_sdk::workflows::join_all; +/// use temporalio_sdk::WorkflowContext; +/// use std::time::Duration; +/// +/// # async fn hidden(ctx: &mut WorkflowContext<()>) { +/// let timers = vec![ +/// ctx.timer(Duration::from_secs(1)), +/// ctx.timer(Duration::from_secs(2)), +/// ]; +/// let results = join_all(timers).await; +/// # } +/// ``` +pub fn join_all(iter: I) -> JoinAll +where + I: IntoIterator, + I::Item: std::future::Future, +{ + JoinAll(SdkGuardedFuture(futures_util::future::join_all(iter))) +} + +/// Future returned by [`join_all`]. +pub struct JoinAll(SdkGuardedFuture>); + +impl std::future::Future for JoinAll { + type Output = Vec; + + fn poll( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + self.0.poll_unpin(cx) + } +} diff --git a/crates/workflow/wit/README.md b/crates/workflow/wit/README.md new file mode 100644 index 000000000..3996ed1b1 --- /dev/null +++ b/crates/workflow/wit/README.md @@ -0,0 +1,43 @@ +# Workflow Runtime WIT + +This directory defines the canonical high-level workflow guest interface for the future WASM SDK. + +This is intentionally **not** the existing core activation/completion protocol. Core remains the +worker-facing protocol for the foreseeable future, and other language SDKs will continue to use it. +The Rust worker will translate core activations and completions into this higher-level interface. + +## Why this lives in `temporalio-workflow` + +`temporalio-workflow` is the crate that workflow implementations compile against. Checking the WIT +in here gives the Rust runtime refactor a concrete target: + +- `temporalio_workflow::runtime::*` should evolve toward a direct Rust mirror of these interfaces. +- The native Rust worker should keep calling those Rust traits directly, with no WIT serialization + in the hot path. +- A future WASM backend should expose the same interface through the component model. + +## Layering + +The layers are: + +1. Core activation/completion protocol +2. Native worker translation layer +3. This WIT-shaped workflow runtime interface +4. Workflow guest code + +That translation layer is where activation ordering and other core-specific details stay hidden. +The guest interface here is deliberately higher level: + +- the host instantiates a workflow run +- the host applies activation-wide context +- the host notifies signals, cancellation, patches, updates, and operation resolutions +- the guest polls until it blocks or terminates + +## Native and WASM execution + +The runtime should support two backends behind the same worker translation logic: + +- a native backend that invokes Rust traits directly in-process +- a WASM backend that invokes a component implementing `workflow-module` + +The goal is one logical execution model with two transport backends, not two independent workers. diff --git a/crates/workflow/wit/guest.wit b/crates/workflow/wit/guest.wit new file mode 100644 index 000000000..46ecea857 --- /dev/null +++ b/crates/workflow/wit/guest.wit @@ -0,0 +1,18 @@ +package temporal:workflow-runtime@0.1.0; + +interface workflow-guest { + list-workflows: func() -> list; + + instantiate-workflow: + func(workflow-type: string, init: types.workflow-init, args: list) + -> result; + + resource workflow-instance { + activate: + func(activation: types.workflow-activation) + -> result; + poll-routine: + func(routine-id: types.routine-id) + -> result; + } +} diff --git a/crates/workflow/wit/host.wit b/crates/workflow/wit/host.wit new file mode 100644 index 000000000..d9f7afcef --- /dev/null +++ b/crates/workflow/wit/host.wit @@ -0,0 +1,31 @@ +package temporal:workflow-runtime@0.1.0; + +interface workflow-host { + set-current-details: func(details: string); + + start-timer: func(req: types.start-timer-request); + cancel-timer: func(seq: u32); + + schedule-activity: func(req: types.schedule-activity-request); + cancel-activity: func(seq: u32); + + schedule-local-activity: func(req: types.schedule-local-activity-request); + cancel-local-activity: func(seq: u32); + + start-child-workflow: func(req: types.start-child-workflow-request); + cancel-child-workflow: func(req: types.cancel-child-workflow-request); + + request-cancel-external-workflow: func(req: types.request-cancel-external-workflow-request); + signal-external-workflow: func(req: types.signal-external-workflow-request); + cancel-signal-external-workflow: func(seq: u32); + + schedule-nexus-operation: func(req: types.schedule-nexus-operation-request); + cancel-nexus-operation: func(req: types.request-cancel-nexus-operation-request); + + upsert-search-attributes: func(entries: list); + upsert-memo: func(entries: list); + set-patch-marker: func(patch-id: string, deprecated: bool); + + complete-update: func(protocol-instance-id: string, result: types.payload); + reject-update: func(protocol-instance-id: string, failure: types.failure); +} diff --git a/crates/workflow/wit/types.wit b/crates/workflow/wit/types.wit new file mode 100644 index 000000000..9a0258391 --- /dev/null +++ b/crates/workflow/wit/types.wit @@ -0,0 +1,480 @@ +package temporal:workflow-runtime@0.1.0; + +record payload-metadata-entry { + key: string, + value: list, +} + +record payload { + metadata: list, + data: list, +} + +record named-payload { + key: string, + value: payload, +} + +record duration { + seconds: s64, + nanos: u32, +} + +record timestamp { + seconds: s64, + nanos: u32, +} + +record workflow-execution-ref { + namespace: string, + workflow-id: string, + run-id: option, +} + +record worker-deployment-version { + deployment-name: string, + build-id: string, +} + +record priority { + priority-key: option, + fairness-key: option, + fairness-weight: option, +} + +record retry-policy { + initial-interval: option, + backoff-coefficient: option, + maximum-interval: option, + maximum-attempts: option, + non-retryable-error-types: list, +} + +record failure { + message: string, + source: option, + stack-trace: option, + encoded-attributes: list, + cause: option, +} + +enum workflow-id-reuse-policy { + allow-duplicate, + allow-duplicate-failed-only, + reject-duplicate, + terminate-if-running, +} + +enum parent-close-policy { + unspecified, + terminate, + abandon, + request-cancel, +} + +enum activity-cancellation-type { + try-cancel, + wait-cancellation-completed, + abandon, +} + +enum child-workflow-cancellation-type { + abandon, + try-cancel, + wait-cancellation-completed, + wait-cancellation-requested, +} + +enum nexus-operation-cancellation-type { + wait-cancellation-completed, + abandon, + try-cancel, + wait-cancellation-requested, +} + +enum versioning-intent { + unspecified, + compatible, + default, +} + +enum versioning-behavior { + unspecified, + pinned, + auto-upgrade, +} + +type workflow-task-failed-cause = u32; +type suggest-continue-as-new-reason = u32; +type start-child-workflow-failed-cause = u32; + +record workflow-init { + namespace: string, + task-queue: string, + workflow-id: string, + run-id: string, + workflow-type: string, + attempt: u32, + first-execution-run-id: string, + continued-from-run-id: option, + start-time: option, + execution-timeout: option, + run-timeout: option, + task-timeout: option, + parent: option, + root: option, + retry-policy: option, + cron-schedule: option, + memo: list, + search-attributes: list, + headers: list, + identity: option, + priority: option, + randomness-seed: u64, +} + +record activation-context { + workflow-time: option, + is-replaying: bool, + history-length: u32, + history-size-bytes: u64, + continue-as-new-suggested: bool, + current-deployment-version: option, + last-sdk-version: option, + available-internal-flags: list, + updated-randomness-seed: option, + target-worker-deployment-version-changed: bool, + suggest-continue-as-new-reasons: list, +} + +record workflow-definition { + workflow-type: string, + has-init: bool, + init-takes-input: bool, + signals: list, + queries: list, + updates: list, +} + +record update-definition { + name: string, + has-validator: bool, +} + +record signal-invocation { + name: string, + args: list, + headers: list, +} + +record update-invocation { + update-id: string, + protocol-instance-id: string, + name: string, + args: list, + headers: list, + run-validator: bool, +} + +record query-invocation { + name: string, + args: list, + headers: list, +} + +record query-response { + result: result, +} + +type routine-id = u64; + +record timer-fired { + seq: u32, +} + +variant activity-result { + completed(payload), + failed(failure), + cancelled(failure), + backoff(duration), +} + +record activity-resolution { + seq: u32, + result: activity-result, +} + +variant child-workflow-start-status { + succeeded(string), + failed(start-child-workflow-failed-cause), + cancelled(failure), +} + +record child-workflow-start-resolution { + seq: u32, + status: child-workflow-start-status, +} + +variant child-workflow-result { + completed(payload), + failed(failure), + cancelled(failure), +} + +record child-workflow-resolution { + seq: u32, + result: child-workflow-result, +} + +record external-signal-resolution { + seq: u32, + failure: option, +} + +record external-cancel-resolution { + seq: u32, + failure: option, +} + +variant nexus-start-status { + operation-token(string), + started-sync, + failed(failure), +} + +record nexus-start-resolution { + seq: u32, + status: nexus-start-status, +} + +variant nexus-result { + completed(payload), + failed(failure), + cancelled(failure), + timed-out(failure), +} + +record nexus-resolution { + seq: u32, + result: nexus-result, +} + +variant workflow-resolution { + timer-fired(timer-fired), + activity(activity-resolution), + child-workflow-start(child-workflow-start-resolution), + child-workflow(child-workflow-resolution), + external-signal(external-signal-resolution), + external-cancel(external-cancel-resolution), + nexus-start(nexus-start-resolution), + nexus(nexus-resolution), +} + +variant workflow-activation-job { + notify-patch(string), + cancel(string), + signal(signal-invocation), + update(update-invocation), + query(query-invocation), + resolution(workflow-resolution), +} + +record workflow-activation { + context: activation-context, + jobs: list, +} + +variant routine-kind { + main, + signal(string), + update(update-routine-kind), +} + +record update-routine-kind { + name: string, + update-id: string, + protocol-instance-id: string, +} + +record started-routine { + routine-id: routine-id, + kind: routine-kind, +} + +variant activation-job-result { + none, + started-routine(started-routine), + query-response(query-response), + update-rejected(failure), +} + +record activation-result { + job-results: list, +} + +record start-timer-request { + seq: u32, + timeout: duration, + summary: option, +} + +record schedule-activity-request { + seq: u32, + activity-type: string, + activity-id: option, + task-queue: option, + args: list, + schedule-to-start-timeout: option, + start-to-close-timeout: option, + schedule-to-close-timeout: option, + heartbeat-timeout: option, + cancellation-type: activity-cancellation-type, + retry-policy: option, + priority: option, + summary: option, + do-not-eagerly-execute: bool, +} + +record schedule-local-activity-request { + seq: u32, + activity-type: string, + activity-id: option, + args: list, + retry-policy: retry-policy, + attempt: option, + original-schedule-time: option, + timer-backoff-threshold: option, + cancellation-type: activity-cancellation-type, + schedule-to-close-timeout: option, + schedule-to-start-timeout: option, + start-to-close-timeout: option, + summary: option, +} + +record start-child-workflow-request { + seq: u32, + workflow-type: string, + workflow-id: string, + task-queue: option, + args: list, + cancellation-type: child-workflow-cancellation-type, + parent-close-policy: parent-close-policy, + static-summary: option, + static-details: option, + id-reuse-policy: workflow-id-reuse-policy, + execution-timeout: option, + run-timeout: option, + task-timeout: option, + cron-schedule: option, + search-attributes: list, + priority: option, +} + +record cancel-child-workflow-request { + seq: u32, + reason: option, +} + +record request-cancel-external-workflow-request { + seq: u32, + namespace: option, + workflow-id: string, + run-id: option, + reason: option, +} + +record signal-external-workflow-request { + seq: u32, + target: signal-workflow-target, + signal: signal-invocation, +} + +variant signal-workflow-target { + workflow-execution(workflow-execution-ref), + child-workflow-id(string), +} + +record schedule-nexus-operation-request { + seq: u32, + endpoint: string, + service: string, + operation: string, + input: option, + schedule-to-close-timeout: option, + schedule-to-start-timeout: option, + start-to-close-timeout: option, + headers: list, + cancellation-type: nexus-operation-cancellation-type, +} + +record request-cancel-nexus-operation-request { + seq: u32, +} + +record continue-as-new-request { + workflow-type: option, + task-queue: option, + args: list, + run-timeout: option, + task-timeout: option, + memo: list, + headers: list, + search-attributes: option>, + retry-policy: option, + versioning-intent: option, + initial-versioning-behavior: option, +} + +record string-header { + key: string, + value: string, +} + +record task-failure { + failure: failure, + force-cause: option, +} + +variant terminal-outcome { + completed(payload), + failed(failure), + cancelled, + continue-as-new(continue-as-new-request), +} + +variant main-routine-completion { + blocked, + task-failed(task-failure), + terminal(terminal-outcome), +} + +variant update-routine-completion { + completed(update-routine-success), + rejected(update-routine-rejection), +} + +record update-routine-success { + protocol-instance-id: string, + result: payload, +} + +record update-routine-rejection { + protocol-instance-id: string, + failure: failure, +} + +variant signal-routine-completion { + succeeded, + failed(failure), +} + +variant routine-completion { + main(main-routine-completion), + signal(signal-routine-completion), + update(update-routine-completion), +} + +record routine-poll-result { + completion: option, + made-progress: bool, +} diff --git a/crates/workflow/wit/world.wit b/crates/workflow/wit/world.wit new file mode 100644 index 000000000..12f2a5df9 --- /dev/null +++ b/crates/workflow/wit/world.wit @@ -0,0 +1,6 @@ +package temporal:workflow-runtime@0.1.0; + +world workflow-module { + import workflow-host; + export workflow-guest; +} From 9b4947893a518d12e393376a9292a0f6a1a031b6 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Fri, 24 Apr 2026 15:05:46 -0700 Subject: [PATCH 2/9] Implement WASM workflow --- AGENTS.md | 1 + crates/common-wasm/Cargo.toml | 5 +- crates/common-wasm/build.rs | 6 +- crates/common-wasm/src/protos/mod.rs | 2 +- crates/common/Cargo.toml | 1 + crates/common/build.rs | 2 +- crates/macros/src/workflow_definitions.rs | 152 +-- crates/sdk-core/Cargo.toml | 2 +- crates/sdk-core/tests/common/mod.rs | 9 +- .../tests/integ_tests/wasm_workflow_tests.rs | 85 ++ .../workflow_tests/child_workflows.rs | 5 +- .../workflow_tests/continue_as_new.rs | 4 +- crates/sdk-core/tests/main.rs | 1 + crates/sdk/Cargo.toml | 3 + crates/sdk/src/lib.rs | 64 +- crates/sdk/src/workflow_future.rs | 901 +++++------------- crates/sdk/src/workflow_registry.rs | 140 ++- crates/sdk/src/workflow_wasm.rs | 395 ++++++++ crates/workflow/Cargo.toml | 8 +- crates/workflow/src/component.rs | 257 +++++ crates/workflow/src/lib.rs | 73 +- crates/workflow/src/runtime/entry.rs | 6 +- crates/workflow/src/runtime/guest.rs | 18 +- crates/workflow/src/runtime/host.rs | 45 +- crates/workflow/src/runtime/instance.rs | 238 +++-- crates/workflow/src/runtime/model.rs | 22 +- crates/workflow/src/runtime/types.rs | 380 +------- crates/workflow/src/workflow_context.rs | 393 ++++---- .../workflow/src/workflow_context/options.rs | 341 ++++--- crates/workflow/src/workflows.rs | 2 +- crates/workflow/wit/guest.wit | 16 +- crates/workflow/wit/host.wit | 28 +- crates/workflow/wit/types.wit | 381 +------- samples/wasm-workflows/hello/.gitignore | 1 + samples/wasm-workflows/hello/Cargo.toml | 16 + samples/wasm-workflows/hello/README.md | 9 + samples/wasm-workflows/hello/src/lib.rs | 19 + 37 files changed, 1953 insertions(+), 2078 deletions(-) create mode 100644 crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs create mode 100644 crates/sdk/src/workflow_wasm.rs create mode 100644 crates/workflow/src/component.rs create mode 100644 samples/wasm-workflows/hello/.gitignore create mode 100644 samples/wasm-workflows/hello/Cargo.toml create mode 100644 samples/wasm-workflows/hello/README.md create mode 100644 samples/wasm-workflows/hello/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index 6d24057e6..9324e4ea8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ document as your quick reference when submitting pull requests. so helps prevent ambiguous method resolution because of traits. Putting them at the top of a tests module is also acceptable. - If you want to format, don't bother checking first. Just run formatting, and run it by using `cargo +nightly fmt`, because some settings require the nightly formatter. +- Do not extract functions for relatively simple helpers that are only used in one location. ## Repo Specific Utilities diff --git a/crates/common-wasm/Cargo.toml b/crates/common-wasm/Cargo.toml index 5e0944623..8c0f3a2ad 100644 --- a/crates/common-wasm/Cargo.toml +++ b/crates/common-wasm/Cargo.toml @@ -15,6 +15,7 @@ exclude = ["protos/*/.github/*"] [features] serde_serialize = [] +grpc-clients = ["tonic/channel"] [dependencies] anyhow = "1.0" @@ -32,7 +33,7 @@ prost-types = { workspace = true } serde = { version = "1.0", features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } -tonic = { workspace = true, default-features = false, features = ["transport", "codegen"] } +tonic = { workspace = true, default-features = false, features = ["codegen"] } tonic-prost = { workspace = true } tracing = "0.1" tracing-subscriber = { version = "0.3", default-features = false, features = [ @@ -43,8 +44,8 @@ tracing-subscriber = { version = "0.3", default-features = false, features = [ ] } tracing-core = "0.1" url = "2.5" -uuid = { version = "1.18", default-features = false, features = ["v4"] } pbjson = { workspace = true } +http = "1" [build-dependencies] prost = { workspace = true } diff --git a/crates/common-wasm/build.rs b/crates/common-wasm/build.rs index cf8ccff81..d2750b21b 100644 --- a/crates/common-wasm/build.rs +++ b/crates/common-wasm/build.rs @@ -47,10 +47,10 @@ fn main() -> Result<(), Box> { let out = PathBuf::from(env::var("OUT_DIR").unwrap()); let descriptor_file = out.join("descriptors.bin"); let mut builder = tonic_prost_build::configure() - // We don't actually want to build the grpc definitions - we don't need them (for now). - // Just build the message structs. + // Workflow guests need message structs, while the native common crate enables this + // feature to preserve the generated clients it re-exports today. .build_server(false) - .build_client(true) + .build_client(env::var_os("CARGO_FEATURE_GRPC_CLIENTS").is_some()) // Make conversions easier for some types .type_attribute( "temporal.api.history.v1.HistoryEvent.attributes", diff --git a/crates/common-wasm/src/protos/mod.rs b/crates/common-wasm/src/protos/mod.rs index ddc00a30c..dccdbe6a2 100644 --- a/crates/common-wasm/src/protos/mod.rs +++ b/crates/common-wasm/src/protos/mod.rs @@ -2597,12 +2597,12 @@ pub mod temporal { }, }; use anyhow::{anyhow, bail}; + use http::Uri; use prost::Name; use std::{ collections::HashMap, fmt::{Display, Formatter}, }; - use tonic::transport::Uri; tonic::include_proto!("temporal.api.nexus.v1"); diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 7bae0d4bd..c69cc7470 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -99,6 +99,7 @@ pbjson = { workspace = true } [dependencies.temporalio-common-wasm] path = "../common-wasm" version = "0.2" +features = ["grpc-clients"] [build-dependencies] prost = { workspace = true } diff --git a/crates/common/build.rs b/crates/common/build.rs index 76008f7f8..92220480f 100644 --- a/crates/common/build.rs +++ b/crates/common/build.rs @@ -38,7 +38,7 @@ fn main() -> Result<(), Box> { let out = PathBuf::from(env::var("OUT_DIR").unwrap()); let descriptor_file = out.join("descriptors.bin"); - let mut protoc = Command::new("protoc"); + let mut protoc = Command::new(env::var_os("PROTOC").unwrap_or_else(|| "protoc".into())); protoc.arg(format!( "--descriptor_set_out={}", descriptor_file.display() diff --git a/crates/macros/src/workflow_definitions.rs b/crates/macros/src/workflow_definitions.rs index 9166fba8c..0a85e79d2 100644 --- a/crates/macros/src/workflow_definitions.rs +++ b/crates/macros/src/workflow_definitions.rs @@ -68,41 +68,6 @@ fn generate_method_call(prefixed_method: &syn::Ident, has_input: bool) -> TokenS } } -/// Generate an async handler body. The async move block is required because the underlying -/// method borrows ctx, and we need to move ctx into the block to make the future 'static. -fn generate_async_handler_body( - impl_type: &Type, - prefixed_method: &syn::Ident, - has_input: bool, -) -> TokenStream2 { - let method_call = if has_input { - quote! { #impl_type::#prefixed_method(&mut ctx, input) } - } else { - quote! { #impl_type::#prefixed_method(&mut ctx) } - }; - quote! { async move { #method_call.await }.boxed_local() } -} - -/// Generate an async update handler body that returns Result. -/// If is_fallible, the method already returns Result; otherwise wrap in Ok via .map(). -fn generate_async_update_handler_body( - impl_type: &Type, - prefixed_method: &syn::Ident, - has_input: bool, - is_fallible: bool, -) -> TokenStream2 { - let method_call = if has_input { - quote! { #impl_type::#prefixed_method(&mut ctx, input) } - } else { - quote! { #impl_type::#prefixed_method(&mut ctx) } - }; - if is_fallible { - quote! { async move { #method_call.await }.boxed_local() } - } else { - quote! { async move { #method_call.await }.map(Ok).boxed_local() } - } -} - /// Parsed representation of a `#[workflow_methods]` impl block pub(crate) struct WorkflowMethodsDefinition { impl_block: ItemImpl, @@ -772,16 +737,21 @@ impl WorkflowMethodsDefinition { let has_input = signal.input_type.is_some(); let executable_impl = if signal.is_async { - let handle_body = - generate_async_handler_body(impl_type, &info.prefixed_method, has_input); + let prefixed_method = &info.prefixed_method; + let method_call = if has_input { + quote! { #impl_type::#prefixed_method(&mut ctx, input) } + } else { + quote! { #impl_type::#prefixed_method(&mut ctx) } + }; quote! { impl ::temporalio_workflow::workflows::ExecutableAsyncSignal<#module_ident::#struct_ident> for #impl_type { fn handle( mut ctx: ::temporalio_workflow::WorkflowContext, input: <#module_ident::#struct_ident as ::temporalio_workflow::common::SignalDefinition>::Input, - ) -> ::futures_util::future::LocalBoxFuture<'static, ()> { - use ::futures_util::FutureExt; - #handle_body + ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, ()> { + ::temporalio_workflow::__private::futures_util::FutureExt::boxed_local( + async move { #method_call.await } + ) } } } @@ -903,19 +873,34 @@ impl WorkflowMethodsDefinition { }; let executable_impl = if update.is_async { - let handle_body = generate_async_update_handler_body( - impl_type, - &info.prefixed_method, - has_input, - update.is_fallible, - ); + let prefixed_method = &info.prefixed_method; + let method_call = if has_input { + quote! { #impl_type::#prefixed_method(&mut ctx, input) } + } else { + quote! { #impl_type::#prefixed_method(&mut ctx) } + }; + let handle_body = if update.is_fallible { + quote! { + ::temporalio_workflow::__private::futures_util::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( + async move { #method_call.await }, + Ok, + ) + ) + } + }; quote! { impl ::temporalio_workflow::workflows::ExecutableAsyncUpdate<#module_ident::#struct_ident> for #impl_type { fn handle( mut ctx: ::temporalio_workflow::WorkflowContext, input: <#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Input, - ) -> ::futures_util::future::LocalBoxFuture<'static, Result<<#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Output, Box>> { - use ::futures_util::FutureExt; + ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<<#module_ident::#struct_ident as ::temporalio_workflow::common::UpdateDefinition>::Output, Box>> { #handle_body } @@ -1004,18 +989,31 @@ impl WorkflowMethodsDefinition { }; let run_impl_body = quote! { - use ::futures_util::FutureExt; - async move { + ::temporalio_workflow::__private::futures_util::FutureExt::boxed_local(async move { let result = #run_call; match result { Ok(value) => ::temporalio_workflow::workflows::serialize_result(value, &ctx.payload_converter()) .map_err(|e| ::temporalio_workflow::WorkflowTermination::from(::anyhow::Error::new(e))), Err(e) => Err(e), } - }.boxed_local() + }) }; // Generate signal dispatch match arms + let signal_names: Vec = self + .signals + .iter() + .map(|s| { + let info = HandlerCodegenInfo::new( + &s.method, + &s.attributes, + s.input_type.as_ref(), + module_ident, + ); + let handler_name = &info.handler_name; + quote! { (#handler_name).to_string() } + }) + .collect(); let dispatch_signal_arms: Vec = self .signals .iter() @@ -1051,7 +1049,7 @@ impl WorkflowMethodsDefinition { name: &str, payloads: ::temporalio_workflow::common::protos::temporal::api::common::v1::Payloads, converter: &::temporalio_workflow::common::data_converters::PayloadConverter, - ) -> Option<::futures_util::future::LocalBoxFuture<'static, Result<(), ::temporalio_workflow::workflows::WorkflowError>>> { + ) -> Option<::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<(), ::temporalio_workflow::workflows::WorkflowError>>> { match name { #(#dispatch_signal_arms)* _ => None, @@ -1086,6 +1084,27 @@ impl WorkflowMethodsDefinition { }) .collect(); + let update_definitions: Vec = self + .updates + .iter() + .map(|u| { + let info = HandlerCodegenInfo::new( + &u.method, + &u.attributes, + u.input_type.as_ref(), + module_ident, + ); + let handler_name = &info.handler_name; + let has_validator = u.validator.is_some(); + quote! { + ::temporalio_workflow::runtime::types::UpdateDefinitionDescriptor { + name: (#handler_name).to_string(), + has_validator: #has_validator, + } + } + }) + .collect(); + // Generate validate_update match arms let validate_update_arms: Vec = self .updates @@ -1122,7 +1141,7 @@ impl WorkflowMethodsDefinition { name: &str, payloads: ::temporalio_workflow::common::protos::temporal::api::common::v1::Payloads, converter: &::temporalio_workflow::common::data_converters::PayloadConverter, - ) -> Option<::futures_util::future::LocalBoxFuture<'static, Result<::temporalio_workflow::common::protos::temporal::api::common::v1::Payload, ::temporalio_workflow::workflows::WorkflowError>>> { + ) -> Option<::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<::temporalio_workflow::common::protos::temporal::api::common::v1::Payload, ::temporalio_workflow::workflows::WorkflowError>>> { match name { #(#dispatch_update_arms)* _ => None, @@ -1146,6 +1165,20 @@ impl WorkflowMethodsDefinition { }; // Generate dispatch_query match arms + let query_names: Vec = self + .queries + .iter() + .map(|q| { + let info = HandlerCodegenInfo::new( + &q.method, + &q.attributes, + q.input_type.as_ref(), + module_ident, + ); + let handler_name = &info.handler_name; + quote! { (#handler_name).to_string() } + }) + .collect(); let dispatch_query_arms: Vec = self .queries .iter() @@ -1196,6 +1229,17 @@ impl WorkflowMethodsDefinition { <#impl_type>::name() } + fn definition() -> ::temporalio_workflow::runtime::types::WorkflowDefinitionDescriptor { + ::temporalio_workflow::runtime::types::WorkflowDefinitionDescriptor { + workflow_type: Self::name().to_string(), + has_init: #has_init, + init_takes_input: #init_has_input, + signals: vec![#(#signal_names),*], + queries: vec![#(#query_names),*], + updates: vec![#(#update_definitions),*], + } + } + fn init( ctx: ::temporalio_workflow::WorkflowContextView, input: ::std::option::Option<::Input>, @@ -1206,7 +1250,7 @@ impl WorkflowMethodsDefinition { fn run( mut ctx: ::temporalio_workflow::WorkflowContext, input: ::std::option::Option<::Input>, - ) -> ::futures_util::future::LocalBoxFuture<'static, Result<::temporalio_workflow::common::protos::temporal::api::common::v1::Payload, ::temporalio_workflow::WorkflowTermination>> { + ) -> ::temporalio_workflow::__private::futures_util::future::LocalBoxFuture<'static, Result<::temporalio_workflow::common::protos::temporal::api::common::v1::Payload, ::temporalio_workflow::WorkflowTermination>> { #run_impl_body } diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index 1b0e62a7c..571bb5c7a 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -151,7 +151,7 @@ hyper-util = { version = "0.1", features = [ ] } rstest = "0.26" semver = "1.0" -temporalio-sdk = { path = "../sdk" } +temporalio-sdk = { path = "../sdk", features = ["wasm-workflows"] } temporalio-common = { path = "../common", version = "0.4", default-features = false } temporalio-workflow = { path = "../workflow" } tokio = { version = "1.47", default-features = false, features = [ diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index ec17fd98b..b08c5067f 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -347,13 +347,12 @@ impl CoreWfStarter { pub(crate) async fn worker(&mut self) -> TestWorker { let worker = self.get_worker().await; let client = self.get_client().await; - let mut sdk = Worker::new_from_core_definitions( + let sdk = Worker::new_from_core_options( worker, client.data_converter().clone(), - self.sdk_config.activities(), - self.sdk_config.workflows(), - ); - sdk.set_detect_nondeterministic_futures(self.sdk_config.detect_nondeterministic_futures); + self.sdk_config.clone(), + ) + .expect("SDK worker should initialize from core worker and options"); let mut w = TestWorker::new(sdk); w.client = Some(client); diff --git a/crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs b/crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs new file mode 100644 index 000000000..d964f11f5 --- /dev/null +++ b/crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs @@ -0,0 +1,85 @@ +use crate::common::CoreWfStarter; +use std::{path::PathBuf, time::Duration}; +use temporalio_client::{UntypedWorkflow, WorkflowStartOptions}; +use temporalio_common::{ + data_converters::{PayloadConverter, RawValue}, + worker::WorkerTaskTypes, +}; +use temporalio_sdk::WasmWorkflowComponent; +use tokio::process::Command; + +const WASM_COMPONENT_ID: &str = "hello-workflow-component"; +const WASM_WORKFLOW_TYPE: &str = "HelloWorkflow"; + +#[tokio::test] +async fn wasm_workflow_component_executes() { + let component_path = build_wasm_hello_component().await; + let mut starter = CoreWfStarter::new("wasm_workflow_component_executes"); + starter.sdk_config.task_types = WorkerTaskTypes::workflow_only(); + starter.sdk_config.register_wasm_workflow( + WasmWorkflowComponent::from_file(WASM_COMPONENT_ID, component_path) + .expect("sample WASM component should be loadable"), + ); + + let mut worker = starter.worker().await; + let client = starter.get_client().await; + let payload_converter = PayloadConverter::default(); + let input = RawValue::from_value(&"workflow", &payload_converter); + let workflow_id = starter.get_wf_id().to_owned(); + + let mut start_options = + WorkflowStartOptions::new(starter.get_task_queue().to_owned(), workflow_id.clone()).build(); + start_options.execution_timeout = Some(Duration::from_secs(60)); + worker + .submit_wf(WASM_WORKFLOW_TYPE, input.payloads, start_options) + .await + .expect("WASM workflow should start"); + worker + .run_until_done() + .await + .expect("WASM workflow should complete"); + + let result = client + .get_workflow_handle::(&workflow_id) + .get_result(Default::default()) + .await + .expect("WASM workflow result should be available"); + let greeting: String = result.to_value(&payload_converter); + assert_eq!(greeting, "Hello, workflow!"); +} + +async fn build_wasm_hello_component() -> PathBuf { + let sample_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("sdk-core crate should live under crates/") + .join("samples/wasm-workflows/hello"); + let output = Command::new(env!("CARGO")) + .args([ + "component", + "build", + "--release", + "--target", + "wasm32-unknown-unknown", + ]) + .current_dir(&sample_dir) + .output() + .await + .expect("cargo component should be runnable"); + + assert!( + output.status.success(), + "cargo component build --release --target wasm32-unknown-unknown failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let component_path = + sample_dir.join("target/wasm32-unknown-unknown/release/temporal_wasm_hello_workflow.wasm"); + assert!( + component_path.exists(), + "cargo component did not create {}", + component_path.display() + ); + component_path +} 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 2bf19ce0d..b701a9646 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 @@ -11,7 +11,8 @@ use temporalio_common::{ coresdk::{ AsJsonPayloadExt, child_workflow::{ - ChildWorkflowCancellationType, StartChildWorkflowExecutionFailedCause, + ChildWorkflowCancellationType, ParentClosePolicy, + StartChildWorkflowExecutionFailedCause, }, workflow_activation::{WorkflowActivationJob, workflow_activation_job}, workflow_commands::{ @@ -21,7 +22,7 @@ use temporalio_common::{ workflow_completion::WorkflowActivationCompletion, }, temporal::api::{ - enums::v1::{CommandType, EventType, ParentClosePolicy, WorkflowTaskFailedCause}, + enums::v1::{CommandType, EventType, WorkflowTaskFailedCause}, history::v1::{ StartChildWorkflowExecutionFailedEventAttributes, StartChildWorkflowExecutionInitiatedEventAttributes, history_event, 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 dadada351..48bceba64 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 @@ -93,8 +93,8 @@ impl WfWithTimer { async fn run(ctx: &mut WorkflowContext) -> WorkflowResult<()> { ctx.timer(Duration::from_millis(500)).await; Err(WorkflowTermination::continue_as_new(ContinueAsNewRequest { - args: vec![[1].into()], - initial_versioning_behavior: Some(ContinueAsNewVersioningBehavior::AutoUpgrade), + arguments: vec![[1].into()], + initial_versioning_behavior: ContinueAsNewVersioningBehavior::AutoUpgrade.into(), ..Default::default() })) } diff --git a/crates/sdk-core/tests/main.rs b/crates/sdk-core/tests/main.rs index c9e885844..4e330066b 100644 --- a/crates/sdk-core/tests/main.rs +++ b/crates/sdk-core/tests/main.rs @@ -25,6 +25,7 @@ mod integ_tests { mod schedule_tests; mod update_tests; mod visibility_tests; + mod wasm_workflow_tests; mod worker_heartbeat_tests; mod worker_tests; mod worker_versioning_tests; diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 3a7bb3572..741b1d343 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -22,6 +22,7 @@ futures-util = { version = "0.3", default-features = false, features = [ ] } gethostname = "1.0.2" parking_lot = { version = "0.12" } +prost = { workspace = true } prost-types = { workspace = true } serde = "1.0" thiserror = "2" @@ -37,6 +38,7 @@ tokio-util = { version = "0.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"] } [dependencies.temporalio-sdk-core] path = "../sdk-core" @@ -71,6 +73,7 @@ envconfig = ["temporalio-sdk-core/envconfig"] prometheus = ["temporalio-sdk-core/prometheus"] otel = ["temporalio-sdk-core/otel"] examples = ["serde/derive", "dep:serde_json", "envconfig"] +wasm-workflows = ["dep:wasmtime"] [dependencies.serde_json] version = "1" diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index e5198222b..770db75d8 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -77,6 +77,8 @@ pub mod interceptors; mod workflow_executor; mod workflow_future; mod workflow_registry; +#[cfg(feature = "wasm-workflows")] +mod workflow_wasm; pub mod workflows; pub use temporalio_client::Namespace; @@ -88,6 +90,8 @@ pub use temporalio_workflow::{ TimerOptions, TimerResult, WorkflowContext, WorkflowContextView, WorkflowResult, WorkflowTermination, }; +#[cfg(feature = "wasm-workflows")] +pub use workflow_wasm::WasmWorkflowComponent; use crate::{ activities::{ @@ -96,7 +100,7 @@ use crate::{ }, interceptors::WorkerInterceptor, workflow_executor::{TaskHandle, WorkflowExecutor}, - workflow_future::WorkflowFunction, + workflow_future::start_workflow, workflow_registry::WorkflowDefinitions, }; use anyhow::{Context, anyhow, bail}; @@ -161,6 +165,10 @@ pub struct WorkerOptions { #[builder(field)] workflows: WorkflowDefinitions, + #[cfg(feature = "wasm-workflows")] + #[builder(field)] + wasm_workflow_components: Vec, + /// Set the deployment options for this worker. Defaults to a hash of the currently running /// executable. #[builder(default = def_build_id())] @@ -303,6 +311,13 @@ impl WorkerOptionsBuilder { .register_workflow_run_with_factory::(factory); self } + + /// Register a prebuilt WASM workflow component that exports one or more workflows. + #[cfg(feature = "wasm-workflows")] + pub fn register_wasm_workflow(mut self, component: WasmWorkflowComponent) -> Self { + self.wasm_workflow_components.push(component); + self + } } // Needs to exist to avoid https://github.com/elastio/bon/issues/359 @@ -355,6 +370,13 @@ impl WorkerOptions { self } + /// Register a prebuilt WASM workflow component that exports one or more workflows. + #[cfg(feature = "wasm-workflows")] + pub fn register_wasm_workflow(&mut self, component: WasmWorkflowComponent) -> &mut Self { + self.wasm_workflow_components.push(component); + self + } + /// Returns all the registered workflows by cloning the current set. pub fn workflows(&self) -> WorkflowDefinitions { self.workflows.clone() @@ -440,28 +462,17 @@ struct ActivityHalf { } impl Worker { - /// Create a new worker from an existing connection, and options. + /// Create a new worker from an existing client, and options. pub fn new( runtime: &CoreRuntime, client: Client, - mut options: WorkerOptions, + options: WorkerOptions, ) -> Result> { - let acts = std::mem::take(&mut options.activities); - let wfs = std::mem::take(&mut options.workflows); let wc = options .to_core_options(client.namespace(), client.identity()) .map_err(|s| anyhow::anyhow!("{s}"))?; let core = init_worker(runtime, wc, client.connection().clone())?; - let mut me = Self::new_from_core_definitions( - Arc::new(core), - client.data_converter().clone(), - Default::default(), - Default::default(), - ); - me.set_detect_nondeterministic_futures(options.detect_nondeterministic_futures); - me.activity_half.activities = acts; - me.workflow_half.workflow_definitions = wfs; - Ok(me) + Self::new_from_core_options(Arc::new(core), client.data_converter().clone(), options) } // TODO [rust-sdk-branch]: Eliminate this constructor in favor of passing in fake connection @@ -477,7 +488,25 @@ impl Worker { // TODO [rust-sdk-branch]: Eliminate this constructor in favor of passing in fake connection #[doc(hidden)] - pub fn new_from_core_definitions( + pub fn new_from_core_options( + worker: Arc, + data_converter: DataConverter, + mut options: WorkerOptions, + ) -> Result> { + let acts = std::mem::take(&mut options.activities); + let wfs = std::mem::take(&mut options.workflows); + #[cfg(feature = "wasm-workflows")] + let wasm_components = std::mem::take(&mut options.wasm_workflow_components); + let mut me = Self::new_from_core_definitions(worker, data_converter, acts, wfs); + me.set_detect_nondeterministic_futures(options.detect_nondeterministic_futures); + #[cfg(feature = "wasm-workflows")] + me.workflow_half + .workflow_definitions + .register_wasm_workflows(wasm_components)?; + Ok(me) + } + + fn new_from_core_definitions( worker: Arc, data_converter: DataConverter, activities: ActivityDefinitions, @@ -784,7 +813,8 @@ impl WorkflowHalf { let workflow_type = sw.workflow_type.clone(); let (wff, activations) = { if let Some(factory) = self.workflow_definitions.get_workflow(&workflow_type) { - match WorkflowFunction::from_invocation(factory).start_workflow( + match start_workflow( + factory, common.worker.get_config().namespace.clone(), common.task_queue.clone(), run_id.clone(), diff --git a/crates/sdk/src/workflow_future.rs b/crates/sdk/src/workflow_future.rs index f8cf88cb9..eed9ac188 100644 --- a/crates/sdk/src/workflow_future.rs +++ b/crates/sdk/src/workflow_future.rs @@ -9,28 +9,16 @@ use std::{ task::{Context, Poll}, }; use temporalio_common::{ - data_converters::{ - DataConverter, GenericPayloadConverter, PayloadConverter, SerializationContext, - SerializationContextData, - }, + data_converters::{DataConverter, PayloadConverter}, protos::{ coresdk::{ workflow_activation::{ - FireTimer, InitializeWorkflow, NotifyHasPatch, ResolveActivity, - ResolveChildWorkflowExecution, ResolveChildWorkflowExecutionStart, - WorkflowActivation as CoreWorkflowActivation, - WorkflowActivationJob as CoreWorkflowActivationJob, + InitializeWorkflow, WorkflowActivation as CoreWorkflowActivation, workflow_activation_job::Variant, }, workflow_commands::{ - CancelChildWorkflowExecution, CancelSignalWorkflow, CancelTimer, - CancelWorkflowExecution, CompleteWorkflowExecution, ContinueAsNewWorkflowExecution, - FailWorkflowExecution, ModifyWorkflowProperties, QueryResult, QuerySuccess, - RequestCancelActivity, RequestCancelExternalWorkflowExecution, - RequestCancelLocalActivity, RequestCancelNexusOperation, ScheduleActivity, - ScheduleLocalActivity, ScheduleNexusOperation, SetPatchMarker, - SignalExternalWorkflowExecution, StartChildWorkflowExecution, StartTimer, - UpdateResponse, UpsertWorkflowSearchAttributes, WorkflowCommand, query_result, + CancelWorkflowExecution, CompleteWorkflowExecution, FailWorkflowExecution, + QueryResult, QuerySuccess, UpdateResponse, WorkflowCommand, query_result, update_response, workflow_command, }, workflow_completion::{ @@ -38,105 +26,85 @@ use temporalio_common::{ }, }, temporal::api::{ - common::v1::{Memo, Payload, SearchAttributes}, - enums::v1::{SuggestContinueAsNewReason, VersioningBehavior, WorkflowTaskFailedCause}, - sdk::v1::UserMetadata, + common::v1::Payload, + enums::v1::{VersioningBehavior, WorkflowTaskFailedCause}, }, - utilities::TryIntoOrNone, }, }; use temporalio_workflow::runtime::{ - BaseWorkflowContext, guest::WorkflowInstance, host::WorkflowHost, model::{WorkflowResult, WorkflowTermination}, types::{ - ActivationContext, ActivationJobResult, ActivationResult, CancelChildWorkflowRequest, - IntoNamedPayloads, IntoPayloadMap, MainRoutineCompletion, NamedPayload, - RequestCancelExternalWorkflowRequest, RequestCancelNexusOperationRequest, - RoutineCompletion, RoutineId, RoutineKind, RoutinePollResult, ScheduleActivityRequest, - ScheduleLocalActivityRequest, ScheduleNexusOperationRequest, SignalExternalWorkflowRequest, - SignalWorkflowTarget, StartChildWorkflowRequest, StartTimerRequest, TerminalOutcome, - UpdateRoutineCompletion, WorkflowActivation, WorkflowActivationJob, WorkflowResolution, + ActivationJobResult, ActivationResult, MainRoutineCompletion, RoutineCompletion, RoutineId, + RoutineKind, RoutinePollResult, TerminalOutcome, UpdateRoutineCompletion, + WorkflowActivation, }, }; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; use crate::{ - panic_formatter, workflow_executor::WakeTracker, workflow_registry::WorkflowExecutionFactory, + panic_formatter, + workflow_executor::WakeTracker, + workflow_registry::{WorkflowExecutionFactory, WorkflowExecutionInput}, }; -pub(crate) struct WorkflowFunction { +/// Start a workflow function, returning a future that will resolve when the workflow does, +/// and a channel that can be used to send it activations. +#[allow(clippy::too_many_arguments)] +pub(crate) fn start_workflow( factory: WorkflowExecutionFactory, -} - -impl WorkflowFunction { - /// Create a workflow driver from a registered workflow factory. - pub(crate) fn from_invocation(factory: WorkflowExecutionFactory) -> Self { - WorkflowFunction { factory } - } - - /// Start a workflow function, returning a future that will resolve when the workflow does, - /// and a channel that can be used to send it activations. - #[allow(clippy::too_many_arguments)] - pub(crate) fn start_workflow( - &self, - namespace: String, - task_queue: String, - run_id: String, - init_workflow_job: InitializeWorkflow, - outgoing_completions: UnboundedSender, - data_converter: DataConverter, - detect_nondeterministic: bool, - ) -> Result< - ( - impl Future> + use<>, - UnboundedSender, - ), - anyhow::Error, - > { - let span = info_span!( - "RunWorkflow", - "otel.name" = format!("RunWorkflow:{}", &init_workflow_job.workflow_type), - "otel.kind" = "server" - ); - - let payload_converter = data_converter.payload_converter().clone(); - let input = init_workflow_job.arguments.clone(); - let host = Rc::new(NativeWorkflowHost::default()); - let base_ctx = BaseWorkflowContext::new( - namespace, - task_queue, - run_id, - init_workflow_job, - data_converter.clone(), - host.clone(), - ); - - // Create the workflow execution using the factory - let execution = (self.factory)(input, payload_converter, base_ctx.clone()) - .context("Failed to create workflow execution")?; + namespace: String, + task_queue: String, + run_id: String, + init_workflow_job: InitializeWorkflow, + outgoing_completions: UnboundedSender, + payload_converter: PayloadConverter, + detect_nondeterministic: bool, +) -> Result< + ( + impl Future> + use<>, + UnboundedSender, + ), + anyhow::Error, +> { + let span = info_span!( + "RunWorkflow", + "otel.name" = format!("RunWorkflow:{}", &init_workflow_job.workflow_type), + "otel.kind" = "server" + ); + + let host = Rc::new(NativeWorkflowHost::default()); + + let execution = factory(WorkflowExecutionInput { + namespace, + task_queue, + run_id, + init_workflow_job, + payload_converter: payload_converter.clone(), + host: host.clone(), + }) + .context("Failed to create workflow execution")?; - let wake_tracking = if detect_nondeterministic { - Some(WakeTracker::new()) - } else { - None - }; + let wake_tracking = if detect_nondeterministic { + Some(WakeTracker::new()) + } else { + None + }; - let (tx, incoming_activations) = unbounded_channel(); - Ok(( - WorkflowFuture { - execution, - host, - span, - outgoing_completions, - incoming_activations, - wake_tracking, - active_routines: Vec::new(), - }, - tx, - )) - } + let (tx, incoming_activations) = unbounded_channel(); + Ok(( + WorkflowFuture { + execution, + host, + span, + outgoing_completions, + incoming_activations, + wake_tracking, + active_routines: Vec::new(), + }, + tx, + )) } #[derive(Default)] @@ -145,15 +113,12 @@ struct NativeWorkflowHost { } impl NativeWorkflowHost { - fn push_command( - &self, - variant: workflow_command::Variant, - user_metadata: Option, - ) { - self.activation_cmds.borrow_mut().push(WorkflowCommand { - variant: Some(variant), - user_metadata, - }); + fn push_command_variant(&self, variant: workflow_command::Variant) { + self.push_command(variant.into()); + } + + fn push_command(&self, command: WorkflowCommand) { + self.activation_cmds.borrow_mut().push(command); } fn take_commands(&self) -> Vec { @@ -164,281 +129,8 @@ impl NativeWorkflowHost { impl WorkflowHost for NativeWorkflowHost { fn set_current_details(&self, _details: String) {} - fn start_timer(&self, req: StartTimerRequest) { - self.push_command( - workflow_command::Variant::StartTimer(StartTimer { - seq: req.seq, - start_to_fire_timeout: Some( - req.timeout - .try_into() - .expect("workflow timer timeout must fit into protobuf duration"), - ), - }), - string_user_metadata(req.summary, None), - ); - } - - fn cancel_timer(&self, seq: u32) { - self.push_command( - workflow_command::Variant::CancelTimer(CancelTimer { seq }), - None, - ); - } - - fn schedule_activity(&self, req: ScheduleActivityRequest) { - self.push_command( - workflow_command::Variant::ScheduleActivity(ScheduleActivity { - seq: req.seq, - activity_type: req.activity_type, - activity_id: req.activity_id.unwrap_or_else(|| req.seq.to_string()), - task_queue: req.task_queue.unwrap_or_default(), - schedule_to_start_timeout: req - .schedule_to_start_timeout - .and_then(|v| v.try_into().ok()), - start_to_close_timeout: req.start_to_close_timeout.and_then(|v| v.try_into().ok()), - schedule_to_close_timeout: req - .schedule_to_close_timeout - .and_then(|v| v.try_into().ok()), - heartbeat_timeout: req.heartbeat_timeout.and_then(|v| v.try_into().ok()), - cancellation_type: req.cancellation_type.into(), - arguments: req.args, - retry_policy: req.retry_policy, - priority: req.priority.map(Into::into), - do_not_eagerly_execute: req.do_not_eagerly_execute, - ..Default::default() - }), - string_user_metadata(req.summary, None), - ); - } - - fn cancel_activity(&self, seq: u32) { - self.push_command( - workflow_command::Variant::RequestCancelActivity(RequestCancelActivity { seq }), - None, - ); - } - - fn schedule_local_activity(&self, req: ScheduleLocalActivityRequest) { - self.push_command( - workflow_command::Variant::ScheduleLocalActivity(ScheduleLocalActivity { - seq: req.seq, - activity_type: req.activity_type, - activity_id: req.activity_id.unwrap_or_else(|| req.seq.to_string()), - arguments: req.args, - retry_policy: Some(req.retry_policy), - attempt: req.attempt.unwrap_or(1), - original_schedule_time: req.original_schedule_time.map(Into::into), - local_retry_threshold: req.timer_backoff_threshold.and_then(|v| v.try_into().ok()), - cancellation_type: req.cancellation_type.into(), - schedule_to_close_timeout: req - .schedule_to_close_timeout - .and_then(|v| v.try_into().ok()), - schedule_to_start_timeout: req - .schedule_to_start_timeout - .and_then(|v| v.try_into().ok()), - start_to_close_timeout: req.start_to_close_timeout.and_then(|v| v.try_into().ok()), - ..Default::default() - }), - string_user_metadata(req.summary, None), - ); - } - - fn cancel_local_activity(&self, seq: u32) { - self.push_command( - workflow_command::Variant::RequestCancelLocalActivity(RequestCancelLocalActivity { - seq, - }), - None, - ); - } - - fn start_child_workflow(&self, req: StartChildWorkflowRequest) { - self.push_command( - workflow_command::Variant::StartChildWorkflowExecution(StartChildWorkflowExecution { - seq: req.seq, - workflow_type: req.workflow_type, - workflow_id: req.workflow_id, - task_queue: req.task_queue.unwrap_or_default(), - input: req.args, - cancellation_type: req.cancellation_type.into(), - workflow_id_reuse_policy: req.id_reuse_policy.into(), - workflow_execution_timeout: req.execution_timeout.and_then(|v| v.try_into().ok()), - workflow_run_timeout: req.run_timeout.and_then(|v| v.try_into().ok()), - workflow_task_timeout: req.task_timeout.and_then(|v| v.try_into().ok()), - search_attributes: (!req.search_attributes.is_empty()).then_some( - SearchAttributes { - indexed_fields: req.search_attributes.into_payload_map(), - }, - ), - cron_schedule: req.cron_schedule.unwrap_or_default(), - parent_close_policy: req.parent_close_policy.into(), - priority: req.priority.map(Into::into), - ..Default::default() - }), - string_user_metadata(req.static_summary, req.static_details), - ); - } - - fn cancel_child_workflow(&self, req: CancelChildWorkflowRequest) { - self.push_command( - workflow_command::Variant::CancelChildWorkflowExecution(CancelChildWorkflowExecution { - child_workflow_seq: req.seq, - reason: req.reason.unwrap_or_default(), - }), - None, - ); - } - - fn request_cancel_external_workflow(&self, req: RequestCancelExternalWorkflowRequest) { - let workflow_execution = - temporalio_common::protos::coresdk::common::NamespacedWorkflowExecution { - namespace: req.namespace.unwrap_or_default(), - workflow_id: req.workflow_id, - run_id: req.run_id.unwrap_or_default(), - }; - self.push_command( - workflow_command::Variant::RequestCancelExternalWorkflowExecution( - RequestCancelExternalWorkflowExecution { - seq: req.seq, - workflow_execution: Some(workflow_execution), - reason: req.reason.unwrap_or_default(), - }, - ), - None, - ); - } - - fn signal_external_workflow(&self, req: SignalExternalWorkflowRequest) { - let target = match req.target { - SignalWorkflowTarget::WorkflowExecution(target) => { - temporalio_common::protos::coresdk::workflow_commands::signal_external_workflow_execution::Target::WorkflowExecution( - temporalio_common::protos::coresdk::common::NamespacedWorkflowExecution { - namespace: target.namespace, - workflow_id: target.workflow_id, - run_id: target.run_id.unwrap_or_default(), - }, - ) - } - SignalWorkflowTarget::ChildWorkflowId(child_workflow_id) => { - temporalio_common::protos::coresdk::workflow_commands::signal_external_workflow_execution::Target::ChildWorkflowId( - child_workflow_id, - ) - } - }; - self.push_command( - workflow_command::Variant::SignalExternalWorkflowExecution( - SignalExternalWorkflowExecution { - seq: req.seq, - signal_name: req.signal.name, - args: req.signal.args, - target: Some(target), - headers: req.signal.headers.into_payload_map(), - }, - ), - None, - ); - } - - fn cancel_signal_external_workflow(&self, seq: u32) { - self.push_command( - workflow_command::Variant::CancelSignalWorkflow(CancelSignalWorkflow { seq }), - None, - ); - } - - fn schedule_nexus_operation(&self, req: ScheduleNexusOperationRequest) { - self.push_command( - workflow_command::Variant::ScheduleNexusOperation(ScheduleNexusOperation { - seq: req.seq, - endpoint: req.endpoint, - service: req.service, - operation: req.operation, - input: req.input, - schedule_to_close_timeout: req - .schedule_to_close_timeout - .and_then(|v| v.try_into().ok()), - schedule_to_start_timeout: req - .schedule_to_start_timeout - .and_then(|v| v.try_into().ok()), - start_to_close_timeout: req.start_to_close_timeout.and_then(|v| v.try_into().ok()), - nexus_header: req - .headers - .into_iter() - .map(|header| (header.key, header.value)) - .collect(), - cancellation_type: req.cancellation_type.into(), - }), - None, - ); - } - - fn cancel_nexus_operation(&self, req: RequestCancelNexusOperationRequest) { - self.push_command( - workflow_command::Variant::RequestCancelNexusOperation(RequestCancelNexusOperation { - seq: req.seq, - }), - None, - ); - } - - fn upsert_search_attributes(&self, entries: Vec) { - self.push_command( - workflow_command::Variant::UpsertWorkflowSearchAttributes( - UpsertWorkflowSearchAttributes { - search_attributes: Some(SearchAttributes { - indexed_fields: entries.into_payload_map(), - }), - }, - ), - None, - ); - } - - fn upsert_memo(&self, entries: Vec) { - self.push_command( - workflow_command::Variant::ModifyWorkflowProperties(ModifyWorkflowProperties { - upserted_memo: Some(Memo { - fields: entries.into_payload_map(), - }), - }), - None, - ); - } - - fn set_patch_marker(&self, patch_id: String, deprecated: bool) { - self.push_command( - workflow_command::Variant::SetPatchMarker(SetPatchMarker { - patch_id, - deprecated, - }), - None, - ); - } - - fn continue_as_new(&self, req: temporalio_workflow::runtime::types::ContinueAsNewRequest) { - self.push_command( - workflow_command::Variant::ContinueAsNewWorkflowExecution( - ContinueAsNewWorkflowExecution { - workflow_type: req.workflow_type.unwrap_or_default(), - task_queue: req.task_queue.unwrap_or_default(), - arguments: req.args, - workflow_run_timeout: req.run_timeout.and_then(|v| v.try_into().ok()), - workflow_task_timeout: req.task_timeout.and_then(|v| v.try_into().ok()), - memo: req.memo.into_payload_map(), - headers: req.headers.into_payload_map(), - search_attributes: req.search_attributes.map(|entries| SearchAttributes { - indexed_fields: entries.into_payload_map(), - }), - retry_policy: req.retry_policy, - versioning_intent: req.versioning_intent.unwrap_or_default().into(), - initial_versioning_behavior: req - .initial_versioning_behavior - .unwrap_or_default() - .into(), - }, - ), - None, - ); + fn push_command(&self, command: WorkflowCommand) { + NativeWorkflowHost::push_command(self, command); } } @@ -497,166 +189,96 @@ impl WorkflowFuture { fn translate_activation( &self, - activation: CoreWorkflowActivation, + mut activation: CoreWorkflowActivation, ) -> Result<(WorkflowActivation, Vec, bool), Error> { - let context = activation_context_from_activation(&activation); let mut should_poll_routines = false; - let mut jobs = Vec::with_capacity(activation.jobs.len()); let mut job_contexts = Vec::with_capacity(activation.jobs.len()); - macro_rules! push_job { - ($job:expr, $context:expr $(,)?) => {{ - jobs.push($job); + macro_rules! push_context { + ($context:expr $(,)?) => {{ job_contexts.push($context); }}; } - macro_rules! push_polled_job { - ($job:expr, $context:expr $(,)?) => {{ + macro_rules! push_polled_context { + ($context:expr $(,)?) => {{ should_poll_routines = true; - push_job!($job, $context); + push_context!($context); }}; } - macro_rules! push_resolution { - ($resolution:expr $(,)?) => { - push_polled_job!( - WorkflowActivationJob::Resolution($resolution), - ActivationJobContext::Passive - ) - }; - } - for CoreWorkflowActivationJob { variant } in activation.jobs { - match variant.context("Empty activation job variant")? { + for job in &mut activation.jobs { + match job + .variant + .as_mut() + .context("Empty activation job variant")? + { Variant::InitializeWorkflow(_) => { should_poll_routines = true; + push_context!(ActivationJobContext::Passive); } - Variant::FireTimer(FireTimer { seq }) => { - push_resolution!(WorkflowResolution::TimerFired( - temporalio_workflow::runtime::types::TimerFiredEvent { seq }, - )); + Variant::FireTimer(_) => { + push_polled_context!(ActivationJobContext::Passive); } - Variant::ResolveActivity(ResolveActivity { seq, result, .. }) => { - push_resolution!(WorkflowResolution::Activity( - temporalio_workflow::runtime::types::ActivityResolutionEvent { - seq, - result: result.context("Activity must have result")?, - }, - )); + Variant::ResolveActivity(attrs) => { + attrs.result.as_ref().context("Activity must have result")?; + push_polled_context!(ActivationJobContext::Passive); } - Variant::ResolveChildWorkflowExecutionStart( - ResolveChildWorkflowExecutionStart { seq, status }, - ) => { - push_resolution!(WorkflowResolution::ChildWorkflowStart( - temporalio_workflow::runtime::types::ChildWorkflowStartResolutionEvent { - seq, - status: status.context("Workflow start must have status")?, - }, - )); + Variant::ResolveChildWorkflowExecutionStart(attrs) => { + attrs + .status + .as_ref() + .context("Workflow start must have status")?; + push_polled_context!(ActivationJobContext::Passive); } - Variant::ResolveChildWorkflowExecution(ResolveChildWorkflowExecution { - seq, - result, - }) => { - push_resolution!(WorkflowResolution::ChildWorkflow( - temporalio_workflow::runtime::types::ChildWorkflowResolutionEvent { - seq, - result: result - .context("Child Workflow execution must have a result")?, - }, - )); + Variant::ResolveChildWorkflowExecution(attrs) => { + attrs + .result + .as_ref() + .context("Child Workflow execution must have a result")?; + push_polled_context!(ActivationJobContext::Passive); } Variant::UpdateRandomSeed(_) => { should_poll_routines = true; + push_context!(ActivationJobContext::Passive); } Variant::QueryWorkflow(q) => { debug!(query_type = %q.query_type, "Query received"); - push_job!( - WorkflowActivationJob::Query( - temporalio_workflow::runtime::types::QueryInvocation { - name: q.query_type, - args: q.arguments, - headers: q.headers.into_named_payloads(), - }, - ), - ActivationJobContext::Query { - query_id: q.query_id, - }, - ); + let query_id = q.query_id.clone(); + push_context!(ActivationJobContext::Query { query_id }); } - Variant::CancelWorkflow(c) => { - push_polled_job!( - WorkflowActivationJob::Cancel { reason: c.reason }, - ActivationJobContext::Passive, - ); + Variant::CancelWorkflow(_) => { + push_polled_context!(ActivationJobContext::Passive); } Variant::SignalWorkflow(sig) => { debug!(signal_name = %sig.signal_name, "Signal received"); - push_polled_job!( - WorkflowActivationJob::Signal( - temporalio_workflow::runtime::types::SignalInvocation { - name: sig.signal_name, - args: sig.input, - headers: sig.headers.into_named_payloads(), - }, - ), - ActivationJobContext::Signal, - ); + push_polled_context!(ActivationJobContext::Signal); } - Variant::NotifyHasPatch(NotifyHasPatch { patch_id }) => { - push_polled_job!( - WorkflowActivationJob::NotifyPatch { patch_id }, - ActivationJobContext::Passive, - ); + Variant::NotifyHasPatch(_) => { + push_polled_context!(ActivationJobContext::Passive); } - Variant::ResolveSignalExternalWorkflow(attrs) => { - push_resolution!(WorkflowResolution::ExternalSignal( - temporalio_workflow::runtime::types::ExternalSignalResolutionEvent { - seq: attrs.seq, - failure: attrs.failure, - }, - )); + Variant::ResolveSignalExternalWorkflow(_) => { + push_polled_context!(ActivationJobContext::Passive); } - Variant::ResolveRequestCancelExternalWorkflow(attrs) => { - push_resolution!(WorkflowResolution::ExternalCancel( - temporalio_workflow::runtime::types::ExternalCancelResolutionEvent { - seq: attrs.seq, - failure: attrs.failure, - }, - )); + Variant::ResolveRequestCancelExternalWorkflow(_) => { + push_polled_context!(ActivationJobContext::Passive); } Variant::DoUpdate(u) => { - let protocol_instance_id = u.protocol_instance_id; - push_polled_job!( - WorkflowActivationJob::Update( - temporalio_workflow::runtime::types::UpdateInvocation { - update_id: u.id, - protocol_instance_id: protocol_instance_id.clone(), - name: u.name, - args: u.input, - headers: u.headers.into_named_payloads(), - run_validator: u.run_validator, - }, - ), - ActivationJobContext::Update { - protocol_instance_id, - }, - ); + let protocol_instance_id = u.protocol_instance_id.clone(); + push_polled_context!(ActivationJobContext::Update { + protocol_instance_id, + }); } Variant::ResolveNexusOperationStart(attrs) => { - push_resolution!(WorkflowResolution::NexusStart( - temporalio_workflow::runtime::types::NexusStartResolutionEvent { - seq: attrs.seq, - status: attrs - .status - .context("Nexus operation start must have status")?, - }, - )); + attrs + .status + .as_ref() + .context("Nexus operation start must have status")?; + push_polled_context!(ActivationJobContext::Passive); } Variant::ResolveNexusOperation(attrs) => { - push_resolution!(WorkflowResolution::Nexus( - temporalio_workflow::runtime::types::NexusResolutionEvent { - seq: attrs.seq, - result: attrs.result.context("Nexus operation must have result")?, - }, - )); + attrs + .result + .as_ref() + .context("Nexus operation must have result")?; + push_polled_context!(ActivationJobContext::Passive); } Variant::RemoveFromCache(_) => { unreachable!("Cache removal should happen higher up"); @@ -664,11 +286,7 @@ impl WorkflowFuture { } } - Ok(( - WorkflowActivation { context, jobs }, - job_contexts, - should_poll_routines, - )) + Ok((activation, job_contexts, should_poll_routines)) } fn process_activation_results( @@ -694,9 +312,7 @@ impl WorkflowFuture { ActivationJobContext::Signal, ActivationJobResult::StartedRoutine(started_routine), ) => match started_routine.kind { - RoutineKind::Signal { .. } => { - self.active_routines.push(started_routine.routine_id) - } + RoutineKind::Signal(_) => self.active_routines.push(started_routine.routine_id), other => bail!("Signal job started unexpected routine kind {other:?}"), }, ( @@ -705,10 +321,8 @@ impl WorkflowFuture { }, ActivationJobResult::StartedRoutine(started_routine), ) => match started_routine.kind { - RoutineKind::Update { - protocol_instance_id: started_id, - .. - } => { + RoutineKind::Update(update_kind) => { + let started_id = update_kind.protocol_instance_id; if started_id != protocol_instance_id { bail!( "Update routine protocol instance id {} did not match {}", @@ -744,7 +358,19 @@ impl WorkflowFuture { ( ActivationJobContext::Query { query_id }, ActivationJobResult::QueryResponse(query_response), - ) => outgoing_cmds.push(query_response_command(query_id, *query_response)), + ) => outgoing_cmds.push({ + let response = *query_response; + workflow_command::Variant::RespondToQuery(QueryResult { + query_id, + variant: Some(match response.result { + Ok(payload) => query_result::Variant::Succeeded(QuerySuccess { + response: Some(payload), + }), + Err(failure) => query_result::Variant::Failed(failure), + }), + }) + .into() + }), (job_context, job_result) => { bail!("Unexpected activation result {job_result:?} for job {job_context:?}"); } @@ -761,23 +387,17 @@ impl WorkflowFuture { ) -> Result { let span = self.span.clone(); let _guard = span.enter(); - if let Some(ref tracker) = self.wake_tracking { - let waker = tracker.new_per_poll_waker(cx.waker()); - match panic::catch_unwind(AssertUnwindSafe(|| { - self.execution.poll_routine(routine_id, &waker) - })) { - Ok(Ok(result)) => Ok(result), - Ok(Err(err)) => Err(anyhow!(err.message)), - Err(e) => bail!("Workflow function panicked: {}", panic_formatter(e)), - } - } else { - match panic::catch_unwind(AssertUnwindSafe(|| { - self.execution.poll_routine(routine_id, cx.waker()) - })) { - Ok(Ok(result)) => Ok(result), - Ok(Err(err)) => Err(anyhow!(err.message)), - Err(e) => bail!("Workflow function panicked: {}", panic_formatter(e)), - } + let tracked_waker = self + .wake_tracking + .as_ref() + .map(|tracker| tracker.new_per_poll_waker(cx.waker())); + let waker = tracked_waker.as_ref().unwrap_or(cx.waker()); + match panic::catch_unwind(AssertUnwindSafe(|| { + self.execution.poll_routine(routine_id, waker) + })) { + Ok(Ok(result)) => Ok(result), + Ok(Err(err)) => Err(anyhow!(err.message)), + Err(e) => bail!("Workflow function panicked: {}", panic_formatter(e)), } } } @@ -883,7 +503,7 @@ impl Future for WorkflowFuture { pass_made_progress |= poll_result.made_progress; match poll_result.completion { None => still_active.push(routine_id), - Some(result) => match *result { + Some(result) => match result { RoutineCompletion::Signal(Ok(())) => {} RoutineCompletion::Signal(Err(failure)) => { self.fail_wft(run_id.clone(), anyhow!(failure.message), None); @@ -943,33 +563,66 @@ impl Future for WorkflowFuture { ); continue 'activations; } - Some(result) => match *result { - RoutineCompletion::Main(MainRoutineCompletion::Blocked) => {} - RoutineCompletion::Main(MainRoutineCompletion::TaskFailed( - task_failure, - )) => { - self.fail_wft( - run_id.clone(), - anyhow!(task_failure.failure.message), - None, - ); - continue 'activations; - } - RoutineCompletion::Main(MainRoutineCompletion::Terminal(outcome)) => { - emit_terminal_outcome(&self.host, *outcome); - should_stop_polling = true; - } - other => { - self.fail_wft( - run_id.clone(), - anyhow!( - "main routine returned unexpected completion {other:?}" - ), - None, - ); - continue 'activations; + Some(result) => { + match result { + RoutineCompletion::Main(MainRoutineCompletion::Blocked) => {} + RoutineCompletion::Main(MainRoutineCompletion::TaskFailed( + task_failure, + )) => { + self.fail_wft( + run_id.clone(), + anyhow!(task_failure.failure.message), + None, + ); + continue 'activations; + } + RoutineCompletion::Main(MainRoutineCompletion::Terminal( + outcome, + )) => { + { + let host: &NativeWorkflowHost = &self.host; + let outcome = *outcome; + match outcome { + TerminalOutcome::Completed(result) => { + host.push_command_variant(workflow_command::Variant::CompleteWorkflowExecution( + CompleteWorkflowExecution { + result: Some(result), + }, + )); + } + TerminalOutcome::Failed(failure) => { + host.push_command_variant(workflow_command::Variant::FailWorkflowExecution( + FailWorkflowExecution { + failure: Some(*failure), + }, + )); + } + TerminalOutcome::Cancelled => { + host.push_command_variant(workflow_command::Variant::CancelWorkflowExecution( + CancelWorkflowExecution {}, + )); + } + TerminalOutcome::ContinueAsNew(req) => { + host.push_command_variant(workflow_command::Variant::ContinueAsNewWorkflowExecution( + *req, + )); + } + } + }; + should_stop_polling = true; + } + other => { + self.fail_wft( + run_id.clone(), + anyhow!( + "main routine returned unexpected completion {other:?}" + ), + None, + ); + continue 'activations; + } } - }, + } } if should_stop_polling || !pass_made_progress { @@ -994,123 +647,3 @@ fn update_response( } .into() } - -fn query_response_command( - query_id: String, - response: temporalio_workflow::runtime::types::QueryResponse, -) -> WorkflowCommand { - workflow_command::Variant::RespondToQuery(QueryResult { - query_id, - variant: Some(match response.result { - Ok(payload) => query_result::Variant::Succeeded(QuerySuccess { - response: Some(payload), - }), - Err(failure) => query_result::Variant::Failed(failure), - }), - }) - .into() -} - -fn string_user_metadata(summary: Option, details: Option) -> Option { - if summary.is_none() && details.is_none() { - return None; - } - let converter = PayloadConverter::default(); - let context = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; - Some(UserMetadata { - summary: summary.map(|value| { - converter - .to_payload(&context, &value) - .expect("String-to-JSON payload serialization is infallible") - }), - details: details.map(|value| { - converter - .to_payload(&context, &value) - .expect("String-to-JSON payload serialization is infallible") - }), - }) -} - -fn emit_terminal_outcome(host: &NativeWorkflowHost, outcome: TerminalOutcome) { - match outcome { - TerminalOutcome::Completed(result) => { - host.push_command( - workflow_command::Variant::CompleteWorkflowExecution(CompleteWorkflowExecution { - result: Some(result), - }), - None, - ); - } - TerminalOutcome::Failed(failure) => { - host.push_command( - workflow_command::Variant::FailWorkflowExecution(FailWorkflowExecution { - failure: Some(*failure), - }), - None, - ); - } - TerminalOutcome::Cancelled => { - host.push_command( - workflow_command::Variant::CancelWorkflowExecution(CancelWorkflowExecution {}), - None, - ); - } - TerminalOutcome::ContinueAsNew(req) => host.continue_as_new(req), - } -} - -fn activation_context_from_activation(activation: &CoreWorkflowActivation) -> ActivationContext { - let updated_randomness_seed = activation.jobs.iter().find_map(|job| match &job.variant { - Some(Variant::UpdateRandomSeed(attrs)) => Some(attrs.randomness_seed), - _ => None, - }); - ActivationContext { - workflow_time: activation.timestamp.try_into_or_none(), - is_replaying: activation.is_replaying, - history_length: activation.history_length, - history_size_bytes: activation.history_size_bytes, - continue_as_new_suggested: activation.continue_as_new_suggested, - current_deployment_version: activation - .deployment_version_for_current_task - .clone() - .map(Into::into), - last_sdk_version: (!activation.last_sdk_version.is_empty()) - .then_some(activation.last_sdk_version.clone()), - available_internal_flags: activation.available_internal_flags.clone(), - updated_randomness_seed, - target_worker_deployment_version_changed: activation - .target_worker_deployment_version_changed, - suggest_continue_as_new_reasons: activation - .suggest_continue_as_new_reasons - .iter() - .filter_map(|v| SuggestContinueAsNewReason::try_from(*v).ok()) - .collect(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use temporalio_common::protos::coresdk::workflow_activation::{ - UpdateRandomSeed, WorkflowActivationJob, - }; - - #[test] - fn activation_context_preserves_updated_random_seed() { - let activation = CoreWorkflowActivation { - jobs: vec![WorkflowActivationJob { - variant: Some(Variant::UpdateRandomSeed(UpdateRandomSeed { - randomness_seed: 1234, - })), - }], - ..Default::default() - }; - - let context = activation_context_from_activation(&activation); - - assert_eq!(context.updated_randomness_seed, Some(1234)); - } -} diff --git a/crates/sdk/src/workflow_registry.rs b/crates/sdk/src/workflow_registry.rs index 1a1f03916..de6e58158 100644 --- a/crates/sdk/src/workflow_registry.rs +++ b/crates/sdk/src/workflow_registry.rs @@ -1,35 +1,51 @@ -use std::{collections::HashMap, fmt::Debug, sync::Arc}; +use std::{collections::HashMap, fmt::Debug, rc::Rc, sync::Arc}; +use anyhow::Context; use temporalio_common::{ WorkflowDefinition, data_converters::{ - GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, - SerializationContextData, + GenericPayloadConverter, PayloadConverter, SerializationContext, SerializationContextData, + }, + protos::{ + coresdk::workflow_activation::InitializeWorkflow, temporal::api::common::v1::Payload, }, - protos::temporal::api::common::v1::Payload, }; use temporalio_workflow::runtime::{ BaseWorkflowContext, entry::WorkflowImplementation, guest::WorkflowInstance, + host::WorkflowHost, instance::{GuestWorkflowInstance, instantiate_workflow}, + types::WorkflowDefinitionDescriptor, }; +/// Host-owned execution inputs used to instantiate a single workflow run. +pub(crate) struct WorkflowExecutionInput { + pub namespace: String, + pub task_queue: String, + pub run_id: String, + pub init_workflow_job: InitializeWorkflow, + pub payload_converter: PayloadConverter, + pub host: Rc, +} + /// Creates workflow execution instances from activation input payloads and context. pub(crate) type WorkflowExecutionFactory = Arc< - dyn Fn( - Vec, - PayloadConverter, - BaseWorkflowContext, - ) -> Result, PayloadConversionError> + dyn Fn(WorkflowExecutionInput) -> Result, anyhow::Error> + Send + Sync, >; +#[derive(Clone)] +struct RegisteredWorkflow { + definition: WorkflowDefinitionDescriptor, + factory: WorkflowExecutionFactory, +} + /// Contains workflow registrations in a form ready for execution by workers. #[derive(Default, Clone)] pub struct WorkflowDefinitions { - workflows: HashMap<&'static str, WorkflowExecutionFactory>, + workflows: HashMap, } impl WorkflowDefinitions { @@ -43,12 +59,13 @@ impl WorkflowDefinitions { where ::Input: Send, { - let workflow_name = W::name(); - let factory: WorkflowExecutionFactory = - Arc::new(move |payloads, converter: PayloadConverter, base_ctx| { - instantiate_workflow::(payloads, converter, base_ctx) - }); - self.workflows.insert(workflow_name, factory); + let factory = Arc::new(move |input| { + let (payloads, payload_converter, base_ctx) = workflow_input_parts(input); + instantiate_workflow::(payloads, payload_converter, base_ctx) + .context("Failed to instantiate native workflow") + }); + self.insert_workflow(W::definition(), factory) + .unwrap_or_else(|err| panic!("{err}")); self } @@ -65,26 +82,25 @@ impl WorkflowDefinitions { The factory replaces init for instance creation." ); - let workflow_name = W::name(); - let user_factory = Arc::new(user_factory); - let factory: WorkflowExecutionFactory = - Arc::new(move |payloads, converter: PayloadConverter, base_ctx| { - let ser_ctx = SerializationContext { - data: &SerializationContextData::Workflow, - converter: &converter, - }; - let input: ::Input = - converter.from_payloads(&ser_ctx, payloads)?; - - let workflow = user_factory(); - Ok(Box::new(GuestWorkflowInstance::::new_with_workflow( - workflow, - base_ctx, - Some(input), - )) as Box) - }); - - self.workflows.insert(workflow_name, factory); + 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 input: ::Input = + payload_converter.from_payloads(&ser_ctx, payloads)?; + + let workflow = user_factory(); + Ok(Box::new(GuestWorkflowInstance::::new_with_workflow( + workflow, + base_ctx, + Some(input), + )) as Box) + }); + + self.insert_workflow(W::definition(), factory) + .unwrap_or_else(|err| panic!("{err}")); self } @@ -93,16 +109,60 @@ impl WorkflowDefinitions { self.workflows.is_empty() } + pub(crate) fn insert_workflow( + &mut self, + definition: WorkflowDefinitionDescriptor, + factory: WorkflowExecutionFactory, + ) -> Result<(), anyhow::Error> { + let workflow_type = definition.workflow_type.clone(); + if self.workflows.contains_key(&workflow_type) { + anyhow::bail!("Workflow type {workflow_type} is already registered"); + } + self.workflows.insert( + workflow_type, + RegisteredWorkflow { + definition, + factory, + }, + ); + Ok(()) + } + pub(crate) fn get_workflow(&self, workflow_type: &str) -> Option { - self.workflows.get(workflow_type).cloned() + self.workflows + .get(workflow_type) + .map(|wf| wf.factory.clone()) } - /// Returns an iterator over registered workflow type names. - pub fn workflow_types(&self) -> impl Iterator + '_ { - self.workflows.keys().copied() + /// Returns an iterator over registered workflow definitions. + pub fn workflow_definitions(&self) -> impl Iterator + '_ { + self.workflows.values().map(|wf| &wf.definition) } } +fn workflow_input_parts( + input: WorkflowExecutionInput, +) -> (Vec, PayloadConverter, BaseWorkflowContext) { + let WorkflowExecutionInput { + namespace, + task_queue, + run_id, + init_workflow_job, + payload_converter, + host, + } = input; + let payloads = init_workflow_job.arguments.clone(); + let base_ctx = BaseWorkflowContext::new( + namespace, + task_queue, + run_id, + init_workflow_job, + payload_converter.clone(), + host, + ); + (payloads, payload_converter, base_ctx) +} + impl Debug for WorkflowDefinitions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WorkflowDefinitions") diff --git a/crates/sdk/src/workflow_wasm.rs b/crates/sdk/src/workflow_wasm.rs new file mode 100644 index 000000000..621aa3c8b --- /dev/null +++ b/crates/sdk/src/workflow_wasm.rs @@ -0,0 +1,395 @@ +use std::{fs, path::PathBuf, rc::Rc, sync::Arc}; + +use anyhow::{Context, bail}; +use prost::Message; +use temporalio_common::protos::{ + coresdk::workflow_commands::WorkflowCommand, temporal::api::failure::v1::Failure, +}; +use temporalio_workflow::runtime::{ + guest::WorkflowInstance, + host::WorkflowHost, + types::{ + ActivationJobResult, ActivationResult, MainRoutineCompletion, QueryResponse, + RoutineCompletion, RoutinePollResult, StartedRoutine, TaskFailure, TerminalOutcome, + UpdateRoutineCompletion, UpdateRoutineKind, WorkflowActivation, + WorkflowDefinitionDescriptor, WorkflowFailure, + }, +}; +use wasmtime::{ + Config, Engine, Store, + component::{Component, HasSelf, Linker, ResourceAny}, +}; + +use crate::workflow_registry::{ + WorkflowDefinitions, WorkflowExecutionFactory, WorkflowExecutionInput, +}; + +wasmtime::component::bindgen!({ + path: "../workflow/wit", + world: "workflow-module", +}); + +use self::{ + exports::temporal::workflow_runtime::workflow_guest as wit_guest, + temporal::workflow_runtime::{types as wit_types, workflow_host as wit_host}, +}; + +/// A prebuilt WebAssembly component that exports one or more Temporal workflows. +#[derive(Clone, Debug)] +pub struct WasmWorkflowComponent { + component_id: String, + source: WasmWorkflowComponentSource, +} + +#[derive(Clone, Debug)] +enum WasmWorkflowComponentSource { + File(PathBuf), + Bytes(Arc<[u8]>), +} + +impl WasmWorkflowComponent { + /// Register a workflow component from a component file on disk. + pub fn from_file( + component_id: impl Into, + path: impl Into, + ) -> Result { + let path = path.into(); + if !path.exists() { + bail!( + "WASM workflow component file does not exist: {}", + path.display() + ); + } + Ok(Self { + component_id: component_id.into(), + source: WasmWorkflowComponentSource::File(path), + }) + } + + /// Register a workflow component from in-memory bytes. + pub fn from_bytes( + component_id: impl Into, + bytes: impl Into>, + ) -> Result { + let bytes = bytes.into(); + if bytes.is_empty() { + bail!("WASM workflow component bytes must not be empty"); + } + Ok(Self { + component_id: component_id.into(), + source: WasmWorkflowComponentSource::Bytes(bytes), + }) + } +} + +impl WorkflowDefinitions { + pub(crate) fn register_wasm_workflows( + &mut self, + components: Vec, + ) -> Result<(), anyhow::Error> { + for component in components { + let module = Arc::new(CompiledWasmWorkflowModule::new(component)?); + for definition in &module.definitions { + let module = module.clone(); + let factory: WorkflowExecutionFactory = + Arc::new(move |input| module.instantiate(input)); + self.insert_workflow(definition.clone(), factory)?; + } + } + Ok(()) + } +} + +struct CompiledWasmWorkflowModule { + engine: Engine, + component: Component, + definitions: Vec, +} + +impl CompiledWasmWorkflowModule { + fn new(component: WasmWorkflowComponent) -> Result { + let mut config = Config::new(); + config.wasm_component_model(true); + let engine = Engine::new(&config)?; + let bytes: Arc<[u8]> = match &component.source { + WasmWorkflowComponentSource::File(path) => fs::read(path) + .with_context(|| format!("Failed reading WASM component {}", path.display()))? + .into(), + WasmWorkflowComponentSource::Bytes(bytes) => bytes.clone(), + }; + let component = Component::new(&engine, bytes.as_ref()).map_err(|err| { + anyhow::Error::msg(format!( + "Failed to compile WASM component {}: {err}", + component.component_id + )) + })?; + let definitions = Self::read_definitions(&engine, &component)?; + if definitions.is_empty() { + bail!("WASM component exports no workflows"); + } + Ok(Self { + engine, + component, + definitions, + }) + } + + fn read_definitions( + engine: &Engine, + component: &Component, + ) -> Result, anyhow::Error> { + let mut linker = Linker::new(engine); + WorkflowModule::add_to_linker::<_, HasSelf<_>>(&mut linker, |data| data)?; + let mut store = Store::new( + engine, + WasmWorkflowHostState::new(Rc::new(NoopWorkflowHost)), + ); + let module = WorkflowModule::instantiate(&mut store, component, &linker)?; + module + .temporal_workflow_runtime_workflow_guest() + .call_list_workflows(&mut store) + .map(|defs| { + defs.into_iter() + .map(|def| WorkflowDefinitionDescriptor { + workflow_type: def.workflow_type, + has_init: def.has_init, + init_takes_input: def.init_takes_input, + signals: def.signals, + queries: def.queries, + updates: def + .updates + .into_iter() + .map(|u| { + temporalio_workflow::runtime::types::UpdateDefinitionDescriptor { + name: u.name, + has_validator: u.has_validator, + } + }) + .collect(), + }) + .collect() + }) + .map_err(|err| { + anyhow::Error::msg(format!( + "Failed to list workflows exported by WASM component: {err}" + )) + }) + } + + fn instantiate( + &self, + input: WorkflowExecutionInput, + ) -> Result, anyhow::Error> { + let mut linker = Linker::new(&self.engine); + WorkflowModule::add_to_linker::<_, HasSelf<_>>(&mut linker, |data| data)?; + let mut store = Store::new(&self.engine, WasmWorkflowHostState::new(input.host.clone())); + let module = WorkflowModule::instantiate(&mut store, &self.component, &linker)?; + let guest = module.temporal_workflow_runtime_workflow_guest(); + let workflow_init = wit_types::WorkflowInit { + namespace: input.namespace.clone(), + task_queue: input.task_queue.clone(), + run_id: input.run_id.clone(), + initialize_workflow: input.init_workflow_job.encode_to_vec(), + }; + let workflow_instance = guest + .call_instantiate_workflow(&mut store, &workflow_init) + .map_err(|err| { + anyhow::Error::msg(format!("Failed to instantiate WASM workflow: {err}")) + })? + .map_err(convert_failure) + .map_err(|failure| { + anyhow::Error::msg(format!( + "WASM workflow initialization failed: {}", + failure.message + )) + })?; + + Ok(Box::new(WasmWorkflowInstance { + store, + guest: guest.clone(), + workflow_instance, + })) + } +} + +struct WasmWorkflowInstance { + store: Store, + guest: wit_guest::Guest, + workflow_instance: ResourceAny, +} + +impl WorkflowInstance for WasmWorkflowInstance { + fn activate( + &mut self, + activation: WorkflowActivation, + ) -> Result { + let result = self.guest.workflow_instance().call_activate( + &mut self.store, + self.workflow_instance, + &activation.encode_to_vec(), + ); + trap_to_failure(result, |result| ActivationResult { + job_results: result + .job_results + .into_iter() + .map(|job_result| match job_result { + wit_types::ActivationJobResult::None => ActivationJobResult::None, + wit_types::ActivationJobResult::StartedRoutine(routine) => { + 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::Update(update) => { + temporalio_workflow::runtime::types::RoutineKind::Update( + UpdateRoutineKind { + name: update.name, + update_id: update.update_id, + protocol_instance_id: update.protocol_instance_id, + }, + ) + } + }, + }) + } + wit_types::ActivationJobResult::QueryResponse(response) => { + ActivationJobResult::QueryResponse(Box::new(QueryResponse { + result: response + .response + .map(decode_proto) + .map_err(|failure| *convert_failure(failure)), + })) + } + wit_types::ActivationJobResult::UpdateRejected(failure) => { + ActivationJobResult::UpdateRejected(convert_failure(failure)) + } + }) + .collect(), + }) + } + + fn poll_routine( + &mut self, + routine_id: u64, + _waker: &std::task::Waker, + ) -> Result { + let result = self.guest.workflow_instance().call_poll_routine( + &mut self.store, + self.workflow_instance, + routine_id, + ); + trap_to_failure(result, |result| RoutinePollResult { + completion: result.completion.map(|completion| match completion { + wit_types::RoutineCompletion::Main(completion) => { + RoutineCompletion::Main(match completion { + wit_types::MainRoutineCompletion::Blocked => MainRoutineCompletion::Blocked, + wit_types::MainRoutineCompletion::TaskFailed(task_failure) => { + MainRoutineCompletion::TaskFailed(TaskFailure { + failure: convert_failure(task_failure.failure), + force_cause: task_failure.force_cause, + }) + } + wit_types::MainRoutineCompletion::Terminal(outcome) => { + MainRoutineCompletion::Terminal(Box::new(match outcome { + wit_types::TerminalOutcome::Completed(payload) => { + TerminalOutcome::Completed(decode_proto(payload)) + } + wit_types::TerminalOutcome::Failed(failure) => { + TerminalOutcome::Failed(convert_failure(failure)) + } + wit_types::TerminalOutcome::Cancelled => TerminalOutcome::Cancelled, + wit_types::TerminalOutcome::ContinueAsNew(req) => { + TerminalOutcome::ContinueAsNew(Box::new(decode_proto(req))) + } + })) + } + }) + } + wit_types::RoutineCompletion::Signal(result) => { + RoutineCompletion::Signal(match result { + wit_types::SignalRoutineCompletion::Succeeded => Ok(()), + wit_types::SignalRoutineCompletion::Failed(failure) => { + Err(convert_failure(failure)) + } + }) + } + wit_types::RoutineCompletion::Update(completion) => { + RoutineCompletion::Update(match completion { + wit_types::UpdateRoutineCompletion::Completed(success) => { + UpdateRoutineCompletion::Completed { + protocol_instance_id: success.protocol_instance_id, + result: decode_proto(success.value), + } + } + wit_types::UpdateRoutineCompletion::Rejected(rejection) => { + UpdateRoutineCompletion::Rejected { + protocol_instance_id: rejection.protocol_instance_id, + failure: convert_failure(rejection.failure), + } + } + }) + } + }), + made_progress: result.made_progress, + }) + } +} + +struct WasmWorkflowHostState { + host: Rc, +} + +impl WasmWorkflowHostState { + fn new(host: Rc) -> Self { + Self { host } + } +} + +impl wit_types::Host for WasmWorkflowHostState {} + +impl wit_host::Host for WasmWorkflowHostState { + fn set_current_details(&mut self, details: String) { + self.host.set_current_details(details); + } + + fn push_command(&mut self, command: wit_types::WorkflowCommand) { + self.host.push_command(decode_proto(command)); + } +} + +struct NoopWorkflowHost; + +impl WorkflowHost for NoopWorkflowHost { + fn set_current_details(&self, _details: String) {} + fn push_command(&self, _command: WorkflowCommand) {} +} + +fn trap_to_failure( + result: Result, wasmtime::Error>, + convert: impl FnOnce(T) -> U, +) -> Result { + result + .map_err(|trap| { + Box::new(Failure { + message: format!("WASM workflow trapped: {trap}"), + ..Default::default() + }) + })? + .map(convert) + .map_err(convert_failure) +} + +fn convert_failure(failure: wit_types::Failure) -> WorkflowFailure { + Box::new(decode_proto(failure)) +} + +fn decode_proto(bytes: Vec) -> M { + M::decode(bytes.as_slice()).unwrap_or_else(|err| { + let n = M::NAME; + panic!("failed to decode {n} from WASM boundary bytes: {err}") + }) +} diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index cc7c95795..8061b26c5 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -19,12 +19,14 @@ futures-util = { version = "0.3", default-features = false, features = [ "alloc", "std", ] } -parking_lot = { version = "0.12" } +futures-channel = { version = "0.3", default-features = false, features = [ + "alloc", +] } +prost = { workspace = true } prost-types = { workspace = true } serde = { version = "1.0", features = ["derive"] } thiserror = "2" -tokio = { version = "1.47", default-features = false, features = ["sync"] } -tracing = "0.1" +wit-bindgen = { version = "0.57.1", default-features = false, features = ["macros", "std", "realloc", "bitflags"] } [dependencies.temporalio-common-wasm] path = "../common-wasm" diff --git a/crates/workflow/src/component.rs b/crates/workflow/src/component.rs new file mode 100644 index 000000000..9dab65bcf --- /dev/null +++ b/crates/workflow/src/component.rs @@ -0,0 +1,257 @@ +//! Component-model guest export support for workflow crates. + +#![allow(missing_docs)] + +use crate::{ + BaseWorkflowContext, + runtime::{ + entry::WorkflowImplementation, + guest::WorkflowInstance as RuntimeWorkflowInstance, + host::WorkflowHost, + instance::instantiate_workflow, + types::{ + ActivationJobResult, MainRoutineCompletion, RoutineCompletion, TerminalOutcome, + UpdateRoutineCompletion, WorkflowDefinitionDescriptor, WorkflowFailure, WorkflowInit, + }, + }, +}; +use futures_util::task::noop_waker; +use prost::Message; +use std::{cell::RefCell, marker::PhantomData, rc::Rc}; +use temporalio_common_wasm::{ + data_converters::PayloadConverter, + protos::{coresdk::workflow_commands::WorkflowCommand, temporal::api::failure::v1::Failure}, +}; + +pub mod bindings { + wit_bindgen::generate!({ + path: "wit", + world: "workflow-module", + pub_export_macro: true, + with: { + "temporal:workflow-runtime/types@0.1.0/routine-kind": crate::runtime::types::RoutineKind, + "temporal:workflow-runtime/types@0.1.0/started-routine": crate::runtime::types::StartedRoutine, + "temporal:workflow-runtime/types@0.1.0/update-definition": crate::runtime::types::UpdateDefinitionDescriptor, + "temporal:workflow-runtime/types@0.1.0/update-routine-kind": crate::runtime::types::UpdateRoutineKind, + "temporal:workflow-runtime/types@0.1.0/workflow-definition": crate::runtime::types::WorkflowDefinitionDescriptor, + }, + }); +} + +pub use bindings::export as __wit_export; + +use self::bindings::{ + exports::temporal::workflow_runtime::workflow_guest as wit_guest, + temporal::workflow_runtime::{types as wit_types, workflow_host as wit_host}, +}; + +pub trait StaticWorkflowComponent { + fn list_workflows() -> Vec; + fn instantiate_workflow( + workflow_type: &str, + init: WorkflowInit, + host: Rc, + ) -> Result, WorkflowFailure>; +} + +pub struct ExportedComponent(PhantomData); + +impl wit_guest::Guest for ExportedComponent { + type WorkflowInstance = ExportedWorkflowInstance; + + fn list_workflows() -> Vec { + T::list_workflows() + } + + fn instantiate_workflow( + init: wit_guest::WorkflowInit, + ) -> Result { + let host: Rc = Rc::new(ImportedWorkflowHost); + let init = WorkflowInit { + namespace: init.namespace, + task_queue: init.task_queue, + run_id: init.run_id, + initialize_workflow: decode_proto(init.initialize_workflow), + }; + let workflow_type = init.initialize_workflow.workflow_type.clone(); + let instance = T::instantiate_workflow(&workflow_type, init, host) + .map_err(convert_failure_to_wit_box)?; + Ok(wit_guest::WorkflowInstance::new(ExportedWorkflowInstance( + RefCell::new(instance), + ))) + } +} + +pub struct ExportedWorkflowInstance(RefCell>); + +impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { + fn activate( + &self, + activation: wit_guest::WorkflowActivation, + ) -> Result { + self.0 + .borrow_mut() + .activate(decode_proto(activation)) + .map(|result| wit_types::ActivationResult { + job_results: result + .job_results + .into_iter() + .map(|result| match result { + ActivationJobResult::None => wit_types::ActivationJobResult::None, + ActivationJobResult::StartedRoutine(routine) => { + wit_types::ActivationJobResult::StartedRoutine(routine) + } + ActivationJobResult::QueryResponse(response) => { + wit_types::ActivationJobResult::QueryResponse( + wit_types::QueryResponse { + response: response + .result + .map(encode_proto) + .map_err(encode_proto), + }, + ) + } + ActivationJobResult::UpdateRejected(failure) => { + wit_types::ActivationJobResult::UpdateRejected( + convert_failure_to_wit_box(failure), + ) + } + }) + .collect(), + }) + .map_err(convert_failure_to_wit_box) + } + + fn poll_routine( + &self, + routine_id: wit_guest::RoutineId, + ) -> Result { + let waker = noop_waker(); + self.0 + .borrow_mut() + .poll_routine(routine_id, &waker) + .map(|result| wit_types::RoutinePollResult { + completion: result.completion.map(|completion| match completion { + RoutineCompletion::Main(completion) => { + wit_types::RoutineCompletion::Main(match completion { + MainRoutineCompletion::Blocked => { + wit_types::MainRoutineCompletion::Blocked + } + MainRoutineCompletion::TaskFailed(task_failure) => { + wit_types::MainRoutineCompletion::TaskFailed( + wit_types::TaskFailure { + failure: convert_failure_to_wit_box(task_failure.failure), + force_cause: task_failure.force_cause, + }, + ) + } + MainRoutineCompletion::Terminal(outcome) => { + wit_types::MainRoutineCompletion::Terminal(match *outcome { + TerminalOutcome::Completed(payload) => { + wit_types::TerminalOutcome::Completed(encode_proto(payload)) + } + TerminalOutcome::Failed(failure) => { + wit_types::TerminalOutcome::Failed( + convert_failure_to_wit_box(failure), + ) + } + TerminalOutcome::Cancelled => { + wit_types::TerminalOutcome::Cancelled + } + TerminalOutcome::ContinueAsNew(req) => { + wit_types::TerminalOutcome::ContinueAsNew(encode_proto( + *req, + )) + } + }) + } + }) + } + RoutineCompletion::Signal(result) => { + wit_types::RoutineCompletion::Signal(match result { + Ok(()) => wit_types::SignalRoutineCompletion::Succeeded, + Err(failure) => wit_types::SignalRoutineCompletion::Failed( + convert_failure_to_wit_box(failure), + ), + }) + } + RoutineCompletion::Update(completion) => { + wit_types::RoutineCompletion::Update(match completion { + UpdateRoutineCompletion::Completed { + protocol_instance_id, + result, + } => wit_types::UpdateRoutineCompletion::Completed( + wit_types::UpdateRoutineSuccess { + protocol_instance_id, + value: encode_proto(result), + }, + ), + UpdateRoutineCompletion::Rejected { + protocol_instance_id, + failure, + } => wit_types::UpdateRoutineCompletion::Rejected( + wit_types::UpdateRoutineRejection { + protocol_instance_id, + failure: convert_failure_to_wit_box(failure), + }, + ), + }) + } + }), + made_progress: result.made_progress, + }) + .map_err(convert_failure_to_wit_box) + } +} + +pub fn instantiate_component_workflow( + init: WorkflowInit, + host: Rc, +) -> Result, WorkflowFailure> +where + ::Input: Send, +{ + let args = init.initialize_workflow.arguments.clone(); + let payload_converter = PayloadConverter::default(); + let base_ctx = BaseWorkflowContext::new( + init.namespace, + init.task_queue, + init.run_id, + init.initialize_workflow, + payload_converter.clone(), + host, + ); + instantiate_workflow::(args, payload_converter, base_ctx).map_err(|err| { + Box::new(Failure { + message: format!("Workflow input deserialization failed: {err}"), + ..Default::default() + }) + }) +} + +struct ImportedWorkflowHost; + +impl WorkflowHost for ImportedWorkflowHost { + fn set_current_details(&self, details: String) { + wit_host::set_current_details(&details); + } + + fn push_command(&self, command: WorkflowCommand) { + wit_host::push_command(&encode_proto(command)); + } +} + +fn convert_failure_to_wit_box(failure: WorkflowFailure) -> wit_types::Failure { + encode_proto(*failure) +} + +fn encode_proto(message: M) -> Vec { + message.encode_to_vec() +} + +fn decode_proto(bytes: Vec) -> M { + M::decode(bytes.as_slice()).unwrap_or_else(|err| { + let n = M::NAME; + panic!("failed to decode {n} from WASM boundary bytes: {err}") + }) +} diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index a9ad0c7bb..85c5889ab 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -6,6 +6,13 @@ extern crate self as temporalio_workflow; pub use temporalio_common_wasm as common; +#[doc(hidden)] +pub mod __private { + pub use futures_util; +} + +#[doc(hidden)] +pub mod component; pub mod runtime; mod workflow_context; pub mod workflows; @@ -14,7 +21,7 @@ pub mod workflows; #[doc(hidden)] macro_rules! __temporal_select { ($($tokens:tt)*) => { - ::futures_util::select_biased! { $($tokens)* } + $crate::__private::futures_util::select_biased! { $($tokens)* } }; } @@ -22,12 +29,72 @@ macro_rules! __temporal_select { #[doc(hidden)] macro_rules! __temporal_join { ($($tokens:tt)*) => { - ::futures_util::join!($($tokens)*) + $crate::__private::futures_util::join!($($tokens)*) + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __temporalio_export_workflow_component { + ($export_type:ident) => { + $crate::component::__wit_export!( + $export_type with_types_in $crate::component::bindings + ); + }; +} + +#[macro_export] +/// Export one or more workflow implementations as a component-model workflow module. +macro_rules! export_workflow_module { + ([$($workflow:ty),+ $(,)?]) => { + const _: () = { + struct __TemporalWorkflowModule; + + impl ::temporalio_workflow::component::StaticWorkflowComponent for __TemporalWorkflowModule { + fn list_workflows( + ) -> ::std::vec::Vec<::temporalio_workflow::runtime::types::WorkflowDefinitionDescriptor> { + ::std::vec![$(<$workflow as ::temporalio_workflow::runtime::entry::WorkflowImplementation>::definition()),*] + } + + fn instantiate_workflow( + workflow_type: &str, + init: ::temporalio_workflow::runtime::types::WorkflowInit, + host: ::std::rc::Rc, + ) -> ::std::result::Result< + ::std::boxed::Box, + ::temporalio_workflow::runtime::types::WorkflowFailure, + > { + match workflow_type { + $( + name if name == <$workflow as ::temporalio_workflow::runtime::entry::WorkflowImplementation>::name() => { + ::temporalio_workflow::component::instantiate_component_workflow::<$workflow>(init, host) + } + )* + _ => Err(::std::boxed::Box::new( + ::temporalio_workflow::common::protos::temporal::api::failure::v1::Failure { + message: ::std::format!( + "No workflow named '{}' exported by this component", + workflow_type + ), + ..::std::default::Default::default() + }, + )), + } + } + } + + type __TemporalWorkflowComponentExport = + ::temporalio_workflow::component::ExportedComponent<__TemporalWorkflowModule>; + + ::temporalio_workflow::__temporalio_export_workflow_component!( + __TemporalWorkflowComponentExport + ); + }; }; } #[doc(hidden)] -pub use runtime::model::{CancellableID, CancellableIDWithReason, UnblockEvent}; +pub use runtime::model::{CancellableID, UnblockEvent}; pub use runtime::model::{TimerResult, WorkflowResult, WorkflowTermination}; #[doc(hidden)] pub use runtime::{SdkWakeGuard, is_sdk_wake}; diff --git a/crates/workflow/src/runtime/entry.rs b/crates/workflow/src/runtime/entry.rs index ff76a3985..5e707471a 100644 --- a/crates/workflow/src/runtime/entry.rs +++ b/crates/workflow/src/runtime/entry.rs @@ -1,7 +1,8 @@ //! Runtime entry traits implemented by workflow definitions and message handlers. use crate::{ - SyncWorkflowContext, WorkflowContext, WorkflowContextView, runtime::model::WorkflowTermination, + SyncWorkflowContext, WorkflowContext, WorkflowContextView, + runtime::{model::WorkflowTermination, types::WorkflowDefinitionDescriptor}, }; use futures_util::future::{FutureExt, LocalBoxFuture}; use temporalio_common_wasm::{ @@ -56,6 +57,9 @@ pub trait WorkflowImplementation: Sized + 'static { /// Returns the workflow type name. fn name() -> &'static str; + /// Returns the exported workflow definition metadata for this workflow. + fn definition() -> WorkflowDefinitionDescriptor; + /// Initialize the workflow instance. fn init( ctx: WorkflowContextView, diff --git a/crates/workflow/src/runtime/guest.rs b/crates/workflow/src/runtime/guest.rs index 9b0a8c01e..a257b23e5 100644 --- a/crates/workflow/src/runtime/guest.rs +++ b/crates/workflow/src/runtime/guest.rs @@ -3,25 +3,9 @@ #![allow(missing_docs)] use crate::runtime::types::{ - ActivationResult, RoutineId, RoutinePollResult, WorkflowActivation, - WorkflowDefinitionDescriptor, WorkflowFailure, WorkflowInit, + ActivationResult, RoutineId, RoutinePollResult, WorkflowActivation, WorkflowFailure, }; use std::task::Waker; -use temporalio_common_wasm::protos::temporal::api::common::v1::Payload; - -/// Runtime-facing workflow module interface for native and WASM backends. -pub trait WorkflowModule { - /// List workflow definitions exported by this module. - fn list_workflows(&self) -> Vec; - - /// Instantiate a workflow run by workflow type. - fn instantiate_workflow( - &self, - workflow_type: &str, - init: WorkflowInit, - args: Vec, - ) -> Result, WorkflowFailure>; -} /// Runtime-facing single-workflow execution interface for native and WASM backends. pub trait WorkflowInstance { diff --git a/crates/workflow/src/runtime/host.rs b/crates/workflow/src/runtime/host.rs index d17b43fd5..3c2648c79 100644 --- a/crates/workflow/src/runtime/host.rs +++ b/crates/workflow/src/runtime/host.rs @@ -1,50 +1,11 @@ //! Host-side command sink trait mirroring the checked-in WIT interface. -#![allow(missing_docs)] - -use crate::runtime::types::{ - CancelChildWorkflowRequest, ContinueAsNewRequest, NamedPayload, - RequestCancelExternalWorkflowRequest, RequestCancelNexusOperationRequest, - ScheduleActivityRequest, ScheduleLocalActivityRequest, ScheduleNexusOperationRequest, - SignalExternalWorkflowRequest, StartChildWorkflowRequest, StartTimerRequest, -}; +use temporalio_common_wasm::protos::coresdk::workflow_commands::WorkflowCommand; /// Runtime-facing workflow host interface for native and WASM backends. pub trait WorkflowHost { /// Update the details string surfaced through the workflow metadata query. fn set_current_details(&self, details: String); - /// Start a timer. - fn start_timer(&self, req: StartTimerRequest); - /// Cancel a timer by sequence number. - fn cancel_timer(&self, seq: u32); - /// Schedule an activity. - fn schedule_activity(&self, req: ScheduleActivityRequest); - /// Cancel a scheduled activity. - fn cancel_activity(&self, seq: u32); - /// Schedule a local activity. - fn schedule_local_activity(&self, req: ScheduleLocalActivityRequest); - /// Cancel a scheduled local activity. - fn cancel_local_activity(&self, seq: u32); - /// Start a child workflow. - fn start_child_workflow(&self, req: StartChildWorkflowRequest); - /// Cancel a child workflow. - fn cancel_child_workflow(&self, req: CancelChildWorkflowRequest); - /// Request cancellation of an external workflow. - fn request_cancel_external_workflow(&self, req: RequestCancelExternalWorkflowRequest); - /// Send a signal to an external workflow. - fn signal_external_workflow(&self, req: SignalExternalWorkflowRequest); - /// Cancel an in-flight signal-external request. - fn cancel_signal_external_workflow(&self, seq: u32); - /// Schedule a nexus operation. - fn schedule_nexus_operation(&self, req: ScheduleNexusOperationRequest); - /// Cancel a nexus operation. - fn cancel_nexus_operation(&self, req: RequestCancelNexusOperationRequest); - /// Upsert search attributes on the running workflow. - fn upsert_search_attributes(&self, entries: Vec); - /// Upsert memo entries on the running workflow. - fn upsert_memo(&self, entries: Vec); - /// Record a patch marker. - fn set_patch_marker(&self, patch_id: String, deprecated: bool); - /// Continue the workflow as new. - fn continue_as_new(&self, req: ContinueAsNewRequest); + /// Emit a workflow command for the current activation. + fn push_command(&self, command: WorkflowCommand); } diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index e9f90131e..ab52583e4 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -10,11 +10,10 @@ use crate::{ guest::WorkflowInstance, model::{TimerResult, UnblockEvent, WorkflowResult, WorkflowTermination}, types::{ - ActivationJobResult, ActivationResult, IntoPayloadMap, MAIN_ROUTINE_ID, - MainRoutineCompletion, NamedPayload, QueryInvocation, QueryResponse, RoutineCompletion, - RoutineId, RoutineKind, RoutinePollResult, SignalInvocation, StartedRoutine, - UpdateInvocation, UpdateRoutineCompletion, WorkflowActivation, WorkflowActivationJob, - WorkflowFailure, WorkflowResolution, + ActivationJobResult, ActivationResult, MAIN_ROUTINE_ID, MainRoutineCompletion, + QueryResponse, RoutineCompletion, RoutineId, RoutineKind, RoutinePollResult, + StartedRoutine, UpdateRoutineCompletion, UpdateRoutineKind, WorkflowActivation, + WorkflowFailure, }, }, }; @@ -34,9 +33,15 @@ use temporalio_common_wasm::{ GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext, SerializationContextData, }, - protos::temporal::api::{ - common::v1::{Payload, Payloads}, - failure::v1::Failure, + protos::{ + coresdk::workflow_activation::{ + DoUpdate, QueryWorkflow, SignalWorkflow, + workflow_activation_job::Variant as ActivationVariant, + }, + temporal::api::{ + common::v1::{Payload, Payloads}, + failure::v1::Failure, + }, }, }; @@ -72,24 +77,8 @@ enum RoutinePollState { }, } -struct DispatchData<'a> { - payloads: Payloads, - headers: HashMap, - converter: &'a PayloadConverter, -} - -impl<'a> DispatchData<'a> { - fn from_named_payloads( - payloads: Vec, - headers: Vec, - converter: &'a PayloadConverter, - ) -> Self { - Self { - payloads: Payloads { payloads }, - headers: headers.into_payload_map(), - converter, - } - } +fn expect_resolution(value: Option) -> T { + value.expect("resolution expected payload") } impl GuestWorkflowInstance @@ -111,17 +100,11 @@ where } else { (None, Some(input)) }; - Ok(Box::new(Self::new(base_ctx, init_input, run_input))) - } - - pub fn new( - base_ctx: BaseWorkflowContext, - init_input: Option<::Input>, - run_input: Option<::Input>, - ) -> Self { - let view = base_ctx.view(); - let workflow = W::init(view, init_input); - Self::new_with_workflow(workflow, base_ctx, run_input) + Ok(Box::new({ + let view = base_ctx.view(); + let workflow = W::init(view, init_input); + Self::new_with_workflow(workflow, base_ctx, run_input) + })) } pub fn new_with_workflow( @@ -181,40 +164,38 @@ where id } - fn start_signal_routine(&mut self, signal: SignalInvocation) -> ActivationJobResult { - let name = signal.name; - let data = DispatchData::from_named_payloads( - signal.args, - signal.headers, - self.ctx.payload_converter(), - ); - let ctx = self.ctx.with_headers(data.headers); - if let Some(future) = W::dispatch_signal(ctx, &name, data.payloads, data.converter) { + fn start_signal_routine(&mut self, signal: SignalWorkflow) -> ActivationJobResult { + let name = signal.signal_name; + let payloads = Payloads { + payloads: signal.input, + }; + let converter = self.ctx.payload_converter(); + let ctx = self.ctx.with_headers(signal.headers); + if let Some(future) = W::dispatch_signal(ctx, &name, payloads, converter) { let routine_id = self.next_routine_id(); self.routines .insert(routine_id, GuestRoutine::Signal { future }); ActivationJobResult::StartedRoutine(StartedRoutine { routine_id, - kind: RoutineKind::Signal { name }, + kind: RoutineKind::Signal(name), }) } else { ActivationJobResult::None } } - fn start_update_routine(&mut self, update: UpdateInvocation) -> ActivationJobResult { + fn start_update_routine(&mut self, update: DoUpdate) -> ActivationJobResult { let protocol_instance_id = update.protocol_instance_id.clone(); let name = update.name.clone(); if update.run_validator { - let data = DispatchData::from_named_payloads( - update.args.clone(), - update.headers.clone(), - self.ctx.payload_converter(), - ); + let payloads = Payloads { + payloads: update.input.clone(), + }; + let converter = self.ctx.payload_converter(); let view = self.ctx.view(); let validation = self .ctx - .state(|wf| wf.validate_update(view, &update.name, &data.payloads, data.converter)); + .state(|wf| wf.validate_update(view, &update.name, &payloads, converter)); match validation { Some(Ok(())) => {} Some(Err(e)) => { @@ -224,13 +205,12 @@ where } } - let data = DispatchData::from_named_payloads( - update.args, - update.headers, - self.ctx.payload_converter(), - ); - let ctx = self.ctx.with_headers(data.headers); - if let Some(future) = W::dispatch_update(ctx, &name, data.payloads, data.converter) { + let payloads = Payloads { + payloads: update.input, + }; + let converter = self.ctx.payload_converter(); + let ctx = self.ctx.with_headers(update.headers); + if let Some(future) = W::dispatch_update(ctx, &name, payloads, converter) { let routine_id = self.next_routine_id(); self.routines.insert( routine_id, @@ -241,36 +221,35 @@ where ); ActivationJobResult::StartedRoutine(StartedRoutine { routine_id, - kind: RoutineKind::Update { + kind: RoutineKind::Update(UpdateRoutineKind { name, - update_id: update.update_id, + update_id: update.id, protocol_instance_id, - }, + }), }) } else { Self::rejection_for_missing_update_handler(name) } } - fn query(&self, query: QueryInvocation) -> QueryResponse { - if query.name == "__temporal_workflow_metadata" { + fn query(&self, query: QueryWorkflow) -> QueryResponse { + if query.query_type == "__temporal_workflow_metadata" { return self.query_metadata(); } - let data = DispatchData::from_named_payloads( - query.args, - query.headers, - self.ctx.payload_converter(), - ); + let payloads = Payloads { + payloads: query.arguments, + }; + let converter = self.ctx.payload_converter(); let view = self.ctx.view(); QueryResponse { result: match self .ctx - .state(|wf| wf.dispatch_query(view, &query.name, &data.payloads, data.converter)) + .state(|wf| wf.dispatch_query(view, &query.query_type, &payloads, converter)) { Some(Ok(payload)) => Ok(payload), None => Err(Failure { - message: format!("No query handler for '{}'", query.name), + message: format!("No query handler for '{}'", query.query_type), ..Default::default() }), Some(Err(e)) => Err(e.into()), @@ -278,32 +257,39 @@ where } } - fn apply_resolution(&mut self, resolution: WorkflowResolution) { + fn apply_resolution(&mut self, resolution: ActivationVariant) { let event = match resolution { - WorkflowResolution::TimerFired(event) => { + ActivationVariant::FireTimer(event) => { UnblockEvent::Timer(event.seq, TimerResult::Fired) } - WorkflowResolution::Activity(event) => { - UnblockEvent::Activity(event.seq, Box::new(event.result)) + ActivationVariant::ResolveActivity(event) => { + UnblockEvent::Activity(event.seq, Box::new(expect_resolution(event.result))) } - WorkflowResolution::ChildWorkflowStart(event) => { - UnblockEvent::WorkflowStart(event.seq, Box::new(event.status)) + ActivationVariant::ResolveChildWorkflowExecutionStart(event) => { + UnblockEvent::WorkflowStart(event.seq, Box::new(expect_resolution(event.status))) } - WorkflowResolution::ChildWorkflow(event) => { - UnblockEvent::WorkflowComplete(event.seq, Box::new(event.result)) + ActivationVariant::ResolveChildWorkflowExecution(event) => { + UnblockEvent::WorkflowComplete(event.seq, Box::new(expect_resolution(event.result))) } - WorkflowResolution::ExternalSignal(event) => { + ActivationVariant::ResolveSignalExternalWorkflow(event) => { UnblockEvent::SignalExternal(event.seq, event.failure) } - WorkflowResolution::ExternalCancel(event) => { + ActivationVariant::ResolveRequestCancelExternalWorkflow(event) => { UnblockEvent::CancelExternal(event.seq, event.failure) } - WorkflowResolution::NexusStart(event) => { - UnblockEvent::NexusOperationStart(event.seq, Box::new(event.status)) + ActivationVariant::ResolveNexusOperationStart(event) => { + UnblockEvent::NexusOperationStart( + event.seq, + Box::new(expect_resolution(event.status)), + ) } - WorkflowResolution::Nexus(event) => { - UnblockEvent::NexusOperationComplete(event.seq, Box::new(event.result)) + ActivationVariant::ResolveNexusOperation(event) => { + UnblockEvent::NexusOperationComplete( + event.seq, + Box::new(expect_resolution(event.result)), + ) } + _ => unreachable!("only resolution jobs can be applied as resolutions"), }; self.base_ctx .unblock(event) @@ -316,7 +302,7 @@ where match result { Ok(result) => crate::runtime::types::TerminalOutcome::Completed(result), Err(WorkflowTermination::ContinueAsNew(req)) => { - crate::runtime::types::TerminalOutcome::ContinueAsNew(*req) + crate::runtime::types::TerminalOutcome::ContinueAsNew(req) } Err(WorkflowTermination::Cancelled) => { crate::runtime::types::TerminalOutcome::Cancelled @@ -333,15 +319,6 @@ where } } - fn forced_failure(base_ctx: &BaseWorkflowContext) -> Option { - base_ctx.take_forced_wft_failure().map(|err| { - Box::new(Failure { - message: err.to_string(), - ..Default::default() - }) - }) - } - fn poll_routine_loop( base_ctx: &BaseWorkflowContext, cx: &mut Context<'_>, @@ -352,7 +329,12 @@ where let mut made_progress = false; loop { - if let Some(failure) = Self::forced_failure(base_ctx) { + if let Some(failure) = base_ctx.take_forced_wft_failure().map(|err| { + Box::new(Failure { + message: err.to_string(), + ..Default::default() + }) + }) { return RoutinePollState::ForcedFailure { failure, made_progress, @@ -391,10 +373,8 @@ where result, made_progress, } => RoutinePollResult { - completion: Some(Box::new(RoutineCompletion::Main( - MainRoutineCompletion::Terminal(Box::new( - Self::terminal_outcome_from_result(result), - )), + completion: Some(RoutineCompletion::Main(MainRoutineCompletion::Terminal( + Box::new(Self::terminal_outcome_from_result(result)), ))), made_progress, }, @@ -402,18 +382,16 @@ where failure, made_progress, } => RoutinePollResult { - completion: Some(Box::new(RoutineCompletion::Main( - MainRoutineCompletion::TaskFailed(crate::runtime::types::TaskFailure { + completion: Some(RoutineCompletion::Main(MainRoutineCompletion::TaskFailed( + crate::runtime::types::TaskFailure { failure, force_cause: None, - }), + }, ))), made_progress, }, RoutinePollState::Stalled { made_progress } => RoutinePollResult { - completion: Some(Box::new(RoutineCompletion::Main( - MainRoutineCompletion::Blocked, - ))), + completion: Some(RoutineCompletion::Main(MainRoutineCompletion::Blocked)), made_progress, }, }, @@ -438,7 +416,7 @@ where }) }); Ok(RoutinePollResult { - completion: Some(Box::new(RoutineCompletion::Signal(result))), + completion: Some(RoutineCompletion::Signal(result)), made_progress, }) } @@ -477,7 +455,7 @@ where }, }; Ok(RoutinePollResult { - completion: Some(Box::new(RoutineCompletion::Update(completion))), + completion: Some(RoutineCompletion::Update(completion)), made_progress, }) } @@ -507,27 +485,47 @@ where &mut self, activation: WorkflowActivation, ) -> Result { - self.base_ctx.apply_activation_context(&activation.context); + self.base_ctx.apply_activation_context(&activation); let mut job_results = Vec::with_capacity(activation.jobs.len()); for job in activation.jobs { - let result = match job { - WorkflowActivationJob::NotifyPatch { patch_id } => { - self.base_ctx.record_patch(patch_id, true); + let result = match job.variant { + Some(ActivationVariant::InitializeWorkflow(_)) + | Some(ActivationVariant::UpdateRandomSeed(_)) => ActivationJobResult::None, + Some(ActivationVariant::NotifyHasPatch(patch)) => { + self.base_ctx.record_patch(patch.patch_id, true); ActivationJobResult::None } - WorkflowActivationJob::Cancel { reason } => { - self.base_ctx.notify_cancel(reason); + Some(ActivationVariant::CancelWorkflow(cancel)) => { + self.base_ctx.notify_cancel(cancel.reason); ActivationJobResult::None } - WorkflowActivationJob::Signal(signal) => self.start_signal_routine(signal), - WorkflowActivationJob::Update(update) => self.start_update_routine(update), - WorkflowActivationJob::Query(query) => { + Some(ActivationVariant::SignalWorkflow(signal)) => { + self.start_signal_routine(signal) + } + Some(ActivationVariant::DoUpdate(update)) => self.start_update_routine(update), + Some(ActivationVariant::QueryWorkflow(query)) => { ActivationJobResult::QueryResponse(Box::new(self.query(query))) } - WorkflowActivationJob::Resolution(resolution) => { + Some( + resolution @ (ActivationVariant::FireTimer(_) + | ActivationVariant::ResolveActivity(_) + | ActivationVariant::ResolveChildWorkflowExecutionStart(_) + | ActivationVariant::ResolveChildWorkflowExecution(_) + | ActivationVariant::ResolveSignalExternalWorkflow(_) + | ActivationVariant::ResolveRequestCancelExternalWorkflow(_) + | ActivationVariant::ResolveNexusOperationStart(_) + | ActivationVariant::ResolveNexusOperation(_)), + ) => { self.apply_resolution(resolution); ActivationJobResult::None } + Some(ActivationVariant::RemoveFromCache(_)) => ActivationJobResult::None, + None => { + return Err(Box::new(Failure { + message: "Activation job missing variant".to_string(), + ..Default::default() + })); + } }; job_results.push(result); } diff --git a/crates/workflow/src/runtime/model.rs b/crates/workflow/src/runtime/model.rs index 968a8dcc9..8955c6fe2 100644 --- a/crates/workflow/src/runtime/model.rs +++ b/crates/workflow/src/runtime/model.rs @@ -186,31 +186,17 @@ pub enum CancellableID { NexusOp(u32), } -pub(crate) trait SupportsCancelReason { - fn with_reason(self, reason: String) -> CancellableID; -} - -#[derive(Debug, Clone)] -pub enum CancellableIDWithReason { - ChildWorkflow { seqnum: u32 }, -} - -impl SupportsCancelReason for CancellableIDWithReason { - fn with_reason(self, reason: String) -> CancellableID { +impl CancellableID { + pub(crate) fn with_reason(self, reason: String) -> Self { match self { - CancellableIDWithReason::ChildWorkflow { seqnum } => { + CancellableID::ChildWorkflow { seqnum, .. } => { CancellableID::ChildWorkflow { seqnum, reason } } + other => other, } } } -impl From for CancellableID { - fn from(v: CancellableIDWithReason) -> Self { - v.with_reason(String::new()) - } -} - /// The result of running a workflow. pub type WorkflowResult = Result; diff --git a/crates/workflow/src/runtime/types.rs b/crates/workflow/src/runtime/types.rs index 3edcc4769..7af41e2e6 100644 --- a/crates/workflow/src/runtime/types.rs +++ b/crates/workflow/src/runtime/types.rs @@ -2,132 +2,20 @@ #![allow(missing_docs)] -use crate::runtime::model::{ - CancelExternalOk, CancelExternalWfResult, CancellableID, CancellableIDWithReason, - SignalExternalOk, SignalExternalWfResult, TimerResult, UnblockEvent, WorkflowResult, - WorkflowTermination, -}; -use std::{ - collections::HashMap, - time::{Duration, SystemTime}, -}; -use temporalio_common_wasm::{ - Priority, WorkerDeploymentVersion, - protos::{ - coresdk::{ - activity_result::ActivityResolution, - child_workflow::{ - ChildWorkflowCancellationType, ChildWorkflowResult, - StartChildWorkflowExecutionFailedCause, - }, - nexus::{NexusOperationCancellationType, NexusOperationResult}, - workflow_activation::{ - resolve_child_workflow_execution_start::Status as ChildWorkflowStartStatus, - resolve_nexus_operation_start::Status as NexusOperationStartStatus, - }, - workflow_commands::ActivityCancellationType, - }, - temporal::api::{ - common::v1::{Payload, RetryPolicy}, - enums::v1::{ - ContinueAsNewVersioningBehavior, ParentClosePolicy, SuggestContinueAsNewReason, - WorkflowIdReusePolicy, - }, - failure::v1::Failure, - }, +use temporalio_common_wasm::protos::{ + coresdk::{ + workflow_activation::{InitializeWorkflow, WorkflowActivation as CoreWorkflowActivation}, + workflow_commands::ContinueAsNewWorkflowExecution, }, + temporal::api::{common::v1::Payload, failure::v1::Failure}, }; -pub use temporalio_common_wasm::protos::coresdk::common::VersioningIntent; - -#[derive(Clone, Debug, PartialEq)] -pub struct NamedPayload { - pub key: String, - pub value: Payload, -} - -pub trait IntoPayloadMap { - fn into_payload_map(self) -> HashMap; -} - -impl IntoPayloadMap for I -where - I: IntoIterator, -{ - fn into_payload_map(self) -> HashMap { - self.into_iter() - .map(|entry| (entry.key, entry.value)) - .collect() - } -} - -pub trait IntoNamedPayloads { - fn into_named_payloads(self) -> Vec; -} - -impl IntoNamedPayloads for I -where - I: IntoIterator, -{ - fn into_named_payloads(self) -> Vec { - self.into_iter() - .map(|(key, value)| NamedPayload { key, value }) - .collect() - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct StringHeader { - pub key: String, - pub value: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct WorkflowExecutionRef { - pub namespace: String, - pub workflow_id: String, - pub run_id: Option, -} - #[derive(Clone, Debug, PartialEq)] pub struct WorkflowInit { pub namespace: String, pub task_queue: String, - pub workflow_id: String, pub run_id: String, - pub workflow_type: String, - pub attempt: u32, - pub first_execution_run_id: String, - pub continued_from_run_id: Option, - pub start_time: Option, - pub execution_timeout: Option, - pub run_timeout: Option, - pub task_timeout: Option, - pub parent: Option, - pub root: Option, - pub retry_policy: Option, - pub cron_schedule: Option, - pub memo: Vec, - pub search_attributes: Vec, - pub headers: Vec, - pub identity: Option, - pub priority: Option, - pub randomness_seed: u64, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ActivationContext { - pub workflow_time: Option, - pub is_replaying: bool, - pub history_length: u32, - pub history_size_bytes: u64, - pub continue_as_new_suggested: bool, - pub current_deployment_version: Option, - pub last_sdk_version: Option, - pub available_internal_flags: Vec, - pub updated_randomness_seed: Option, - pub target_worker_deployment_version_changed: bool, - pub suggest_continue_as_new_reasons: Vec, + pub initialize_workflow: InitializeWorkflow, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -146,30 +34,6 @@ pub struct UpdateDefinitionDescriptor { pub has_validator: bool, } -#[derive(Clone, Debug, PartialEq)] -pub struct SignalInvocation { - pub name: String, - pub args: Vec, - pub headers: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct UpdateInvocation { - pub update_id: String, - pub protocol_instance_id: String, - pub name: String, - pub args: Vec, - pub headers: Vec, - pub run_validator: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct QueryInvocation { - pub name: String, - pub args: Vec, - pub headers: Vec, -} - #[derive(Clone, Debug, PartialEq)] pub struct QueryResponse { pub result: Result, @@ -178,92 +42,20 @@ pub struct QueryResponse { pub type RoutineId = u64; pub const MAIN_ROUTINE_ID: RoutineId = 0; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TimerFiredEvent { - pub seq: u32, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ActivityResolutionEvent { - pub seq: u32, - pub result: ActivityResolution, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ChildWorkflowStartResolutionEvent { - pub seq: u32, - pub status: ChildWorkflowStartStatus, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ChildWorkflowResolutionEvent { - pub seq: u32, - pub result: ChildWorkflowResult, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ExternalSignalResolutionEvent { - pub seq: u32, - pub failure: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ExternalCancelResolutionEvent { - pub seq: u32, - pub failure: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct NexusStartResolutionEvent { - pub seq: u32, - pub status: NexusOperationStartStatus, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct NexusResolutionEvent { - pub seq: u32, - pub result: NexusOperationResult, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum WorkflowResolution { - TimerFired(TimerFiredEvent), - Activity(ActivityResolutionEvent), - ChildWorkflowStart(ChildWorkflowStartResolutionEvent), - ChildWorkflow(ChildWorkflowResolutionEvent), - ExternalSignal(ExternalSignalResolutionEvent), - ExternalCancel(ExternalCancelResolutionEvent), - NexusStart(NexusStartResolutionEvent), - Nexus(NexusResolutionEvent), -} - -#[derive(Clone, Debug, PartialEq)] -pub enum WorkflowActivationJob { - NotifyPatch { patch_id: String }, - Cancel { reason: String }, - Signal(SignalInvocation), - Update(UpdateInvocation), - Query(QueryInvocation), - Resolution(WorkflowResolution), -} - -#[derive(Clone, Debug, PartialEq)] -pub struct WorkflowActivation { - pub context: ActivationContext, - pub jobs: Vec, -} +pub type WorkflowActivation = CoreWorkflowActivation; #[derive(Clone, Debug, PartialEq)] pub enum RoutineKind { Main, - Signal { - name: String, - }, - Update { - name: String, - update_id: String, - protocol_instance_id: String, - }, + Signal(String), + Update(UpdateRoutineKind), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateRoutineKind { + pub name: String, + pub update_id: String, + pub protocol_instance_id: String, } #[derive(Clone, Debug, PartialEq)] @@ -285,129 +77,7 @@ pub struct ActivationResult { pub job_results: Vec, } -#[derive(Clone, Debug, PartialEq)] -pub struct StartTimerRequest { - pub seq: u32, - pub timeout: Duration, - pub summary: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ScheduleActivityRequest { - pub seq: u32, - pub activity_type: String, - pub activity_id: Option, - pub task_queue: Option, - pub args: Vec, - pub schedule_to_start_timeout: Option, - pub start_to_close_timeout: Option, - pub schedule_to_close_timeout: Option, - pub heartbeat_timeout: Option, - pub cancellation_type: ActivityCancellationType, - pub retry_policy: Option, - pub priority: Option, - pub summary: Option, - pub do_not_eagerly_execute: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ScheduleLocalActivityRequest { - pub seq: u32, - pub activity_type: String, - pub activity_id: Option, - pub args: Vec, - pub retry_policy: RetryPolicy, - pub attempt: Option, - pub original_schedule_time: Option, - pub timer_backoff_threshold: Option, - pub cancellation_type: ActivityCancellationType, - pub schedule_to_close_timeout: Option, - pub schedule_to_start_timeout: Option, - pub start_to_close_timeout: Option, - pub summary: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct StartChildWorkflowRequest { - pub seq: u32, - pub workflow_type: String, - pub workflow_id: String, - pub task_queue: Option, - pub args: Vec, - pub cancellation_type: ChildWorkflowCancellationType, - pub parent_close_policy: ParentClosePolicy, - pub static_summary: Option, - pub static_details: Option, - pub id_reuse_policy: WorkflowIdReusePolicy, - pub execution_timeout: Option, - pub run_timeout: Option, - pub task_timeout: Option, - pub cron_schedule: Option, - pub search_attributes: Vec, - pub priority: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CancelChildWorkflowRequest { - pub seq: u32, - pub reason: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RequestCancelExternalWorkflowRequest { - pub seq: u32, - pub namespace: Option, - pub workflow_id: String, - pub run_id: Option, - pub reason: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum SignalWorkflowTarget { - WorkflowExecution(WorkflowExecutionRef), - ChildWorkflowId(String), -} - -#[derive(Clone, Debug, PartialEq)] -pub struct SignalExternalWorkflowRequest { - pub seq: u32, - pub target: SignalWorkflowTarget, - pub signal: SignalInvocation, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ScheduleNexusOperationRequest { - pub seq: u32, - pub endpoint: String, - pub service: String, - pub operation: String, - pub input: Option, - pub schedule_to_close_timeout: Option, - pub schedule_to_start_timeout: Option, - pub start_to_close_timeout: Option, - pub headers: Vec, - pub cancellation_type: NexusOperationCancellationType, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RequestCancelNexusOperationRequest { - pub seq: u32, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ContinueAsNewRequest { - pub workflow_type: Option, - pub task_queue: Option, - pub args: Vec, - pub run_timeout: Option, - pub task_timeout: Option, - pub memo: Vec, - pub headers: Vec, - pub search_attributes: Option>, - pub retry_policy: Option, - pub versioning_intent: Option, - pub initial_versioning_behavior: Option, -} +pub type ContinueAsNewRequest = ContinueAsNewWorkflowExecution; #[derive(Clone, Debug, PartialEq)] pub struct TaskFailure { @@ -420,7 +90,7 @@ pub enum TerminalOutcome { Completed(Payload), Failed(WorkflowFailure), Cancelled, - ContinueAsNew(ContinueAsNewRequest), + ContinueAsNew(Box), } #[derive(Clone, Debug, PartialEq)] @@ -451,20 +121,8 @@ pub enum RoutineCompletion { #[derive(Clone, Debug, PartialEq)] pub struct RoutinePollResult { - pub completion: Option>, + pub completion: Option, pub made_progress: bool, } -pub type SearchAttributeMap = HashMap; -pub type StartChildWorkflowFailedCause = StartChildWorkflowExecutionFailedCause; pub type WorkflowFailure = Box; -pub type WorkflowSignalResult = SignalExternalWfResult; -pub type WorkflowCancelResult = CancelExternalWfResult; -pub type WorkflowSignalOk = SignalExternalOk; -pub type WorkflowCancelOk = CancelExternalOk; -pub type RuntimeUnblockEvent = UnblockEvent; -pub type RuntimeTimerResult = TimerResult; -pub type RuntimeWorkflowResult = WorkflowResult; -pub type RuntimeWorkflowTermination = WorkflowTermination; -pub type RuntimeCancellableId = CancellableID; -pub type RuntimeCancellableIdWithReason = CancellableIDWithReason; diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index c924605c4..3d9e8899d 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -11,16 +11,11 @@ use crate::runtime::{ entry::WorkflowImplementation, host::WorkflowHost, model::{ - CancelExternalWfResult, CancellableID, CancellableIDWithReason, NexusStartResult, - SignalExternalWfResult, SupportsCancelReason, TimerResult, UnblockEvent, Unblockable, - WorkflowTermination, - }, - types::{ - CancelChildWorkflowRequest, IntoNamedPayloads, RequestCancelExternalWorkflowRequest, - RequestCancelNexusOperationRequest, SignalExternalWorkflowRequest, SignalWorkflowTarget, - WorkflowExecutionRef, + CancelExternalWfResult, CancellableID, NexusStartResult, SignalExternalWfResult, + TimerResult, UnblockEvent, Unblockable, WorkflowTermination, }, }; +use futures_channel::oneshot; use futures_util::{ FutureExt, future::{FusedFuture, Shared}, @@ -53,21 +48,31 @@ use temporalio_common_wasm::{ protos::{ coresdk::{ activity_result::{ActivityResolution, Cancellation, activity_resolution}, - child_workflow::ChildWorkflowResult, + child_workflow::{ChildWorkflowResult, child_workflow_result}, + common::NamespacedWorkflowExecution, nexus::NexusOperationResult, workflow_activation::{ - InitializeWorkflow, + InitializeWorkflow, WorkflowActivation as CoreWorkflowActivation, resolve_child_workflow_execution_start::Status as ChildWorkflowStartStatus, + workflow_activation_job::Variant as ActivationVariant, + }, + workflow_commands::{ + CancelChildWorkflowExecution, CancelSignalWorkflow, CancelTimer, + ModifyWorkflowProperties, RequestCancelActivity, + RequestCancelExternalWorkflowExecution, RequestCancelLocalActivity, + RequestCancelNexusOperation, SetPatchMarker, SignalExternalWorkflowExecution, + UpsertWorkflowSearchAttributes, signal_external_workflow_execution, + workflow_command, }, }, temporal::api::{ common::v1::{Memo, Payload, SearchAttributes}, failure::v1::{CanceledFailureInfo, Failure, failure::FailureInfo}, }, + utilities::TryIntoOrNone, }, worker::WorkerDeploymentVersion, }; -use tokio::sync::{oneshot, watch}; /// Non-generic base context containing all workflow execution infrastructure. /// @@ -77,10 +82,13 @@ pub struct BaseWorkflowContext { inner: Rc, } impl BaseWorkflowContext { - pub(crate) fn apply_activation_context(&self, ctx: &crate::runtime::types::ActivationContext) { + pub(crate) fn apply_activation_context(&self, activation: &CoreWorkflowActivation) { let mut shared = self.inner.shared.borrow_mut(); - shared.activation = ctx.clone(); - if let Some(seed) = ctx.updated_randomness_seed { + shared.activation = activation.clone(); + if let Some(seed) = activation.jobs.iter().find_map(|job| match &job.variant { + Some(ActivationVariant::UpdateRandomSeed(attrs)) => Some(attrs.randomness_seed), + _ => None, + }) { shared.random_seed = seed; } } @@ -189,8 +197,8 @@ struct WorkflowContextInner { run_id: String, inital_information: InitializeWorkflow, runtime: WorkflowRuntimeState, - am_cancelled_tx: watch::Sender>, - am_cancelled: watch::Receiver>, + cancelled_reason: RefCell>, + cancel_wakers: RefCell>, shared: RefCell, seq_nums: RefCell, data_converter: DataConverter, @@ -386,7 +394,6 @@ impl BaseWorkflowContext { data_converter: DataConverter, host: Rc, ) -> Self { - let (am_cancelled_tx, am_cancelled) = watch::channel(None); Self { inner: Rc::new(WorkflowContextInner { namespace, @@ -402,8 +409,8 @@ impl BaseWorkflowContext { }), inital_information: init_workflow_job, runtime: WorkflowRuntimeState::new(host), - am_cancelled_tx, - am_cancelled, + cancelled_reason: RefCell::new(None), + cancel_wakers: RefCell::new(Vec::new()), seq_nums: RefCell::new(WfCtxProtectedDat { next_timer_sequence_number: 1, next_activity_sequence_number: 1, @@ -439,10 +446,10 @@ impl BaseWorkflowContext { pub(crate) fn notify_cancel(&self, reason: String) { let _guard = SdkWakeGuard::new(); - self.inner - .am_cancelled_tx - .send(Some(reason)) - .expect("Cancel receiver not dropped"); + *self.inner.cancelled_reason.borrow_mut() = Some(reason); + for waker in self.inner.cancel_wakers.borrow_mut().drain(..) { + waker.wake(); + } self.inner.runtime.mark_progress(); } @@ -454,28 +461,51 @@ impl BaseWorkflowContext { fn cancel(&self, cancellable_id: CancellableID) { match cancellable_id { CancellableID::Timer(seq) => { - self.inner.runtime.host.cancel_timer(seq); + self.inner.runtime.host.push_command( + workflow_command::Variant::CancelTimer(CancelTimer { seq }).into(), + ); self.unblock(UnblockEvent::Timer(seq, TimerResult::Cancelled)) .expect("timer cancellation should have a registered unblocker"); } - CancellableID::Activity(seq) => self.inner.runtime.host.cancel_activity(seq), - CancellableID::LocalActivity(seq) => self.inner.runtime.host.cancel_local_activity(seq), - CancellableID::ChildWorkflow { seqnum, reason } => self - .inner - .runtime - .host - .cancel_child_workflow(CancelChildWorkflowRequest { - seq: seqnum, - reason: Some(reason), - }), + CancellableID::Activity(seq) => { + self.inner.runtime.host.push_command( + workflow_command::Variant::RequestCancelActivity(RequestCancelActivity { seq }) + .into(), + ); + } + CancellableID::LocalActivity(seq) => { + self.inner.runtime.host.push_command( + workflow_command::Variant::RequestCancelLocalActivity( + RequestCancelLocalActivity { seq }, + ) + .into(), + ); + } + CancellableID::ChildWorkflow { seqnum, reason } => { + self.inner.runtime.host.push_command( + workflow_command::Variant::CancelChildWorkflowExecution( + CancelChildWorkflowExecution { + child_workflow_seq: seqnum, + reason, + }, + ) + .into(), + ); + } CancellableID::SignalExternalWorkflow(seq) => { - self.inner.runtime.host.cancel_signal_external_workflow(seq) + self.inner.runtime.host.push_command( + workflow_command::Variant::CancelSignalWorkflow(CancelSignalWorkflow { seq }) + .into(), + ); + } + CancellableID::NexusOp(seq) => { + self.inner.runtime.host.push_command( + workflow_command::Variant::RequestCancelNexusOperation( + RequestCancelNexusOperation { seq }, + ) + .into(), + ); } - CancellableID::NexusOp(seq) => self - .inner - .runtime - .host - .cancel_nexus_operation(RequestCancelNexusOperationRequest { seq }), } } @@ -496,7 +526,7 @@ impl BaseWorkflowContext { self.inner .runtime .register_unblocker(PendingCommandId::Timer(seq), unblocker); - self.inner.runtime.host.start_timer(opts.into_request(seq)); + self.inner.runtime.host.push_command(opts.into_command(seq)); cmd } @@ -531,7 +561,7 @@ impl BaseWorkflowContext { if opts.task_queue.is_none() { opts.task_queue = Some(self.inner.task_queue.clone()); } - self.inner.runtime.host.schedule_activity(opts.into_request( + self.inner.runtime.host.push_command(opts.into_command( seq, AD::name().to_string(), payloads, @@ -596,7 +626,10 @@ impl BaseWorkflowContext { // not await the result until *after* we receive an activation for it, there will be nothing // to match when unblocking. let (result_cmd, unblocker) = CancellableWFCommandFut::new( - CancellableIDWithReason::ChildWorkflow { seqnum: child_seq }, + CancellableID::ChildWorkflow { + seqnum: child_seq, + reason: String::new(), + }, self.clone(), ); self.inner.runtime.register_unblocker( @@ -613,7 +646,10 @@ impl BaseWorkflowContext { }; let (cmd, unblocker) = CancellableWFCommandFut::new_with_dat( - CancellableIDWithReason::ChildWorkflow { seqnum: child_seq }, + CancellableID::ChildWorkflow { + seqnum: child_seq, + reason: String::new(), + }, common, self.clone(), ); @@ -623,7 +659,7 @@ impl BaseWorkflowContext { self.inner .runtime .host - .start_child_workflow(opts.into_request(child_seq, workflow_type, payloads)); + .push_command(opts.into_command(child_seq, workflow_type, payloads)); ChildWorkflowStartFut::Running(cmd) } @@ -644,13 +680,13 @@ impl BaseWorkflowContext { self.inner .runtime .host - .schedule_local_activity(opts.into_request(seq, activity_type, arguments)); + .push_command(opts.into_command(seq, activity_type, arguments)); cmd } fn send_signal_wf( self, - target: SignalWorkflowTarget, + target: signal_external_workflow_execution::Target, signal: Signal, ) -> impl CancellableFuture { let seq = self @@ -663,14 +699,19 @@ impl BaseWorkflowContext { self.inner .runtime .register_unblocker(PendingCommandId::SignalExternal(seq), unblocker); - self.inner - .runtime - .host - .signal_external_workflow(SignalExternalWorkflowRequest { - seq, - target, - signal: signal.into_invocation(), - }); + let signal = signal.into_invocation(); + self.inner.runtime.host.push_command( + workflow_command::Variant::SignalExternalWorkflowExecution( + SignalExternalWorkflowExecution { + seq, + signal_name: signal.signal_name, + args: signal.input, + target: Some(target), + headers: signal.headers, + }, + ) + .into(), + ); cmd } } @@ -698,7 +739,13 @@ impl SyncWorkflowContext { /// Return the current time according to the workflow (which is not wall-clock time). pub fn workflow_time(&self) -> Option { - self.base.inner.shared.borrow().activation.workflow_time + self.base + .inner + .shared + .borrow() + .activation + .timestamp + .try_into_or_none() } /// Return the length of history so far at this point in the workflow @@ -715,8 +762,9 @@ impl SyncWorkflowContext { .shared .borrow() .activation - .current_deployment_version .clone() + .deployment_version_for_current_task + .map(Into::into) } /// Return current values for workflow search attributes @@ -765,18 +813,15 @@ impl SyncWorkflowContext { /// A future that resolves if/when the workflow is cancelled, with the user provided cause pub fn cancelled(&self) -> impl FusedFuture + '_ { - let am_cancelled = self.base.inner.am_cancelled.clone(); - async move { - if let Some(s) = am_cancelled.borrow().as_ref() { - return s.clone(); + let inner = self.base.inner.clone(); + future::poll_fn(move |cx| { + if let Some(reason) = inner.cancelled_reason.borrow().as_ref() { + Poll::Ready(reason.clone()) + } else { + inner.cancel_wakers.borrow_mut().push(cx.waker().clone()); + Poll::Pending } - am_cancelled - .clone() - .changed() - .await - .expect("Cancelled send half not dropped"); - am_cancelled.borrow().as_ref().cloned().unwrap_or_default() - } + }) .fuse() } @@ -862,11 +907,13 @@ impl SyncWorkflowContext { } fn patch_impl(&self, patch_id: &str, deprecated: bool) -> bool { - self.base - .inner - .runtime - .host - .set_patch_marker(patch_id.to_string(), deprecated); + self.base.inner.runtime.host.push_command( + workflow_command::Variant::SetPatchMarker(SetPatchMarker { + patch_id: patch_id.to_string(), + deprecated, + }) + .into(), + ); // See if we already know about the status of this change if let Some(present) = self.base.inner.shared.borrow().changes.get(patch_id) { return *present; @@ -902,20 +949,28 @@ impl SyncWorkflowContext { /// Add or create a set of search attributes pub fn upsert_search_attributes(&self, attr_iter: impl IntoIterator) { - self.base - .inner - .runtime - .host - .upsert_search_attributes(attr_iter.into_named_payloads()); + self.base.inner.runtime.host.push_command( + workflow_command::Variant::UpsertWorkflowSearchAttributes( + UpsertWorkflowSearchAttributes { + search_attributes: Some(SearchAttributes { + indexed_fields: attr_iter.into_iter().collect(), + }), + }, + ) + .into(), + ); } /// Add or create a set of search attributes pub fn upsert_memo(&self, attr_iter: impl IntoIterator) { - self.base - .inner - .runtime - .host - .upsert_memo(attr_iter.into_named_payloads()); + self.base.inner.runtime.host.push_command( + workflow_command::Variant::ModifyWorkflowProperties(ModifyWorkflowProperties { + upserted_memo: Some(Memo { + fields: attr_iter.into_iter().collect(), + }), + }) + .into(), + ); } /// Set the current details string for this workflow execution. @@ -961,7 +1016,7 @@ impl SyncWorkflowContext { .inner .runtime .host - .schedule_nexus_operation(opts.into_request(seq)); + .push_command(opts.into_command(seq)); cmd } @@ -1285,10 +1340,10 @@ impl WfCtxProtectedDat { } #[derive(Clone, Debug, Default)] -pub(crate) struct WorkflowContextSharedData { +struct WorkflowContextSharedData { /// Maps change ids -> resolved status changes: HashMap, - activation: crate::runtime::types::ActivationContext, + activation: CoreWorkflowActivation, search_attributes: SearchAttributes, random_seed: u64, /// Current details string, surfaced via the workflow metadata query. @@ -1359,22 +1414,22 @@ where } } -struct CancellableWFCommandFut { +struct CancellableWFCommandFut { cmd_fut: WFCommandFut, - cancellable_id: ID, + cancellable_id: CancellableID, base_ctx: BaseWorkflowContext, } -impl CancellableWFCommandFut { +impl CancellableWFCommandFut { fn new( - cancellable_id: ID, + cancellable_id: CancellableID, base_ctx: BaseWorkflowContext, ) -> (Self, oneshot::Sender) { Self::new_with_dat(cancellable_id, (), base_ctx) } } -impl CancellableWFCommandFut { +impl CancellableWFCommandFut { fn new_with_dat( - cancellable_id: ID, + cancellable_id: CancellableID, other_dat: D, base_ctx: BaseWorkflowContext, ) -> (Self, oneshot::Sender) { @@ -1389,8 +1444,8 @@ impl CancellableWFCommandFut { ) } } -impl Unpin for CancellableWFCommandFut where T: Unblockable {} -impl Future for CancellableWFCommandFut +impl Unpin for CancellableWFCommandFut where T: Unblockable {} +impl Future for CancellableWFCommandFut where T: Unblockable, { @@ -1400,7 +1455,7 @@ where self.cmd_fut.poll_unpin(cx) } } -impl FusedFuture for CancellableWFCommandFut +impl FusedFuture for CancellableWFCommandFut where T: Unblockable, { @@ -1409,22 +1464,21 @@ where } } -impl CancellableFuture for CancellableWFCommandFut +impl CancellableFuture for CancellableWFCommandFut where T: Unblockable, - ID: Clone + Into, { fn cancel(&self) { - self.base_ctx.cancel(self.cancellable_id.clone().into()); + self.base_ctx.cancel(self.cancellable_id.clone()); } } -impl CancellableFutureWithReason for CancellableWFCommandFut +impl CancellableFutureWithReason for CancellableWFCommandFut where T: Unblockable, { fn cancel_with_reason(&self, reason: String) { - let new_id = self.cancellable_id.clone().with_reason(reason); - self.base_ctx.cancel(new_id); + self.base_ctx + .cancel(self.cancellable_id.clone().with_reason(reason)); } } @@ -1441,7 +1495,7 @@ struct LATimerBackoffFut { terminated: bool, } impl LATimerBackoffFut { - pub(crate) fn new( + fn new( activity_type: String, arguments: Vec, opts: LocalActivityOptions, @@ -1691,7 +1745,7 @@ where pub(crate) struct ChildWfCommon { workflow_id: String, child_seq: u32, - result_future: CancellableWFCommandFut, + result_future: CancellableWFCommandFut, base_ctx: BaseWorkflowContext, data_converter: DataConverter, } @@ -1747,7 +1801,6 @@ where } => match Pin::new(inner).poll(cx) { Poll::Pending => Poll::Pending, Poll::Ready(result) => Poll::Ready({ - use temporalio_common_wasm::protos::coresdk::child_workflow::child_workflow_result; let status = result.status.ok_or_else(|| { data_converter .to_error( @@ -2031,15 +2084,13 @@ where /// Cancel the child workflow pub fn cancel(&self, reason: String) { - self.common - .base_ctx - .inner - .runtime - .host - .cancel_child_workflow(CancelChildWorkflowRequest { - seq: self.common.child_seq, - reason: Some(reason), - }); + self.common.base_ctx.inner.runtime.host.push_command( + workflow_command::Variant::CancelChildWorkflowExecution(CancelChildWorkflowExecution { + child_workflow_seq: self.common.child_seq, + reason, + }) + .into(), + ); } /// Send a typed signal to the child workflow. @@ -2060,7 +2111,9 @@ where } }; let signal = Signal::new(S::name(&signal), payloads); - let target = SignalWorkflowTarget::ChildWorkflowId(self.common.workflow_id.clone()); + let target = signal_external_workflow_execution::Target::ChildWorkflowId( + self.common.workflow_id.clone(), + ); SignalChildFut::Running { inner: self.common.base_ctx.clone().send_signal_wf(target, signal), data_converter: self.common.data_converter.clone(), @@ -2110,11 +2163,13 @@ impl ExternalWorkflowHandle { } }; let signal = Signal::new(S::name(&signal), payloads); - let target = SignalWorkflowTarget::WorkflowExecution(WorkflowExecutionRef { - namespace: self.namespace.clone(), - workflow_id: self.workflow_id.clone(), - run_id: self.run_id.clone(), - }); + let target = signal_external_workflow_execution::Target::WorkflowExecution( + NamespacedWorkflowExecution { + namespace: self.namespace.clone(), + workflow_id: self.workflow_id.clone(), + run_id: self.run_id.clone().unwrap_or_default(), + }, + ); SignalExternalFut::Running(self.base_ctx.clone().send_signal_wf(target, signal)) } @@ -2134,17 +2189,20 @@ impl ExternalWorkflowHandle { .inner .runtime .register_unblocker(PendingCommandId::CancelExternal(seq), unblocker); - self.base_ctx - .inner - .runtime - .host - .request_cancel_external_workflow(RequestCancelExternalWorkflowRequest { - seq, - namespace: Some(self.namespace.clone()), - workflow_id: self.workflow_id.clone(), - run_id: self.run_id.clone(), - reason, - }); + self.base_ctx.inner.runtime.host.push_command( + workflow_command::Variant::RequestCancelExternalWorkflowExecution( + RequestCancelExternalWorkflowExecution { + seq, + workflow_execution: Some(NamespacedWorkflowExecution { + namespace: self.namespace.clone(), + workflow_id: self.workflow_id.clone(), + run_id: self.run_id.clone().unwrap_or_default(), + }), + reason: reason.unwrap_or_default(), + }, + ) + .into(), + ); cmd } } @@ -2233,17 +2291,18 @@ impl StartedNexusOperation { #[cfg(test)] mod tests { use super::*; - use crate::runtime::types::{ - NamedPayload, RequestCancelExternalWorkflowRequest, RequestCancelNexusOperationRequest, - ScheduleActivityRequest, ScheduleLocalActivityRequest, ScheduleNexusOperationRequest, - SignalExternalWorkflowRequest, StartChildWorkflowRequest, StartTimerRequest, - }; use std::collections::HashMap; use temporalio_common_wasm::{ data_converters::{TemporalDeserializable, TemporalSerializable}, protos::{ - coresdk::{AsJsonPayloadExt, common::VersioningIntent}, - temporal::api::common::v1::{Payload, RetryPolicy}, + coresdk::{ + AsJsonPayloadExt, common::VersioningIntent as ProtoVersioningIntent, + workflow_commands::WorkflowCommand, + }, + temporal::api::{ + common::v1::{Payload, RetryPolicy}, + enums::v1::ContinueAsNewVersioningBehavior, + }, }, }; use temporalio_macros::{workflow, workflow_methods}; @@ -2253,23 +2312,7 @@ mod tests { impl WorkflowHost for NoopHost { fn set_current_details(&self, _details: String) {} - fn start_timer(&self, _req: StartTimerRequest) {} - fn cancel_timer(&self, _seq: u32) {} - fn schedule_activity(&self, _req: ScheduleActivityRequest) {} - fn cancel_activity(&self, _seq: u32) {} - fn schedule_local_activity(&self, _req: ScheduleLocalActivityRequest) {} - fn cancel_local_activity(&self, _seq: u32) {} - fn start_child_workflow(&self, _req: StartChildWorkflowRequest) {} - fn cancel_child_workflow(&self, _req: CancelChildWorkflowRequest) {} - fn request_cancel_external_workflow(&self, _req: RequestCancelExternalWorkflowRequest) {} - fn signal_external_workflow(&self, _req: SignalExternalWorkflowRequest) {} - fn cancel_signal_external_workflow(&self, _seq: u32) {} - fn schedule_nexus_operation(&self, _req: ScheduleNexusOperationRequest) {} - fn cancel_nexus_operation(&self, _req: RequestCancelNexusOperationRequest) {} - fn upsert_search_attributes(&self, _entries: Vec) {} - fn upsert_memo(&self, _entries: Vec) {} - fn set_patch_marker(&self, _patch_id: String, _deprecated: bool) {} - fn continue_as_new(&self, _req: crate::runtime::types::ContinueAsNewRequest) {} + fn push_command(&self, _command: WorkflowCommand) {} } #[workflow] @@ -2318,17 +2361,17 @@ mod tests { assert_eq!( *cmd, crate::runtime::types::ContinueAsNewRequest { - workflow_type: Some(TestWorkflow.name().to_string()), - task_queue: None, - args: vec![7u8.as_json_payload().unwrap()], - run_timeout: None, - task_timeout: None, - memo: vec![], - headers: vec![], + workflow_type: TestWorkflow.name().to_string(), + task_queue: String::new(), + arguments: vec![7u8.as_json_payload().unwrap()], + workflow_run_timeout: None, + workflow_task_timeout: None, + memo: HashMap::new(), + headers: HashMap::new(), search_attributes: None, retry_policy: None, - versioning_intent: Some(VersioningIntent::Unspecified), - initial_versioning_behavior: None, + versioning_intent: ProtoVersioningIntent::Unspecified.into(), + initial_versioning_behavior: ContinueAsNewVersioningBehavior::Unspecified.into(), } ); } @@ -2368,7 +2411,7 @@ mod tests { maximum_attempts: 5, ..Default::default() }), - versioning_intent: Some(VersioningIntent::Compatible), + versioning_intent: Some(ProtoVersioningIntent::Compatible), }, ) .expect_err("continue_as_new should terminate the workflow"); @@ -2383,20 +2426,20 @@ mod tests { assert_eq!( *cmd, crate::runtime::types::ContinueAsNewRequest { - workflow_type: Some("next-workflow".to_string()), - task_queue: Some("next-task-queue".to_string()), - args: vec![11u8.as_json_payload().unwrap()], - run_timeout: Some(Duration::from_secs(10)), - task_timeout: Some(Duration::from_secs(3)), - memo: memo.into_named_payloads(), - headers: headers.into_named_payloads(), - search_attributes: Some(search_attributes.indexed_fields.into_named_payloads()), + 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()), + memo, + headers, + search_attributes: Some(search_attributes), retry_policy: Some(RetryPolicy { maximum_attempts: 5, ..Default::default() }), - versioning_intent: Some(VersioningIntent::Compatible), - initial_versioning_behavior: None, + versioning_intent: ProtoVersioningIntent::Compatible.into(), + initial_versioning_behavior: ContinueAsNewVersioningBehavior::Unspecified.into(), } ); } @@ -2419,7 +2462,7 @@ mod tests { unreachable!() }; - assert_eq!(cmd.search_attributes, Some(vec![])); + assert_eq!(cmd.search_attributes, Some(SearchAttributes::default())); } #[test] diff --git a/crates/workflow/src/workflow_context/options.rs b/crates/workflow/src/workflow_context/options.rs index ac34378ed..9f264466a 100644 --- a/crates/workflow/src/workflow_context/options.rs +++ b/crates/workflow/src/workflow_context/options.rs @@ -1,25 +1,30 @@ use std::{collections::HashMap, time::Duration}; -use crate::runtime::types::{ - ContinueAsNewRequest, IntoNamedPayloads, ScheduleActivityRequest, ScheduleLocalActivityRequest, - ScheduleNexusOperationRequest, SignalInvocation, StartChildWorkflowRequest, StartTimerRequest, - StringHeader, -}; +use crate::runtime::types::ContinueAsNewRequest; use temporalio_common_wasm::{ Priority, + data_converters::{ + GenericPayloadConverter, PayloadConverter, SerializationContext, SerializationContextData, + }, protos::{ coresdk::{ - child_workflow::ChildWorkflowCancellationType, common::VersioningIntent, - nexus::NexusOperationCancellationType, workflow_commands::ActivityCancellationType, + child_workflow::{ChildWorkflowCancellationType, ParentClosePolicy}, + common::VersioningIntent, + nexus::NexusOperationCancellationType, + workflow_activation::SignalWorkflow, + workflow_commands::{ + ActivityCancellationType, ContinueAsNewWorkflowExecution, ScheduleActivity, + ScheduleLocalActivity, ScheduleNexusOperation, StartChildWorkflowExecution, + StartTimer, WorkflowCommand, workflow_command, + }, }, temporal::api::{ common::v1::{Payload, RetryPolicy, SearchAttributes}, - enums::v1::{ParentClosePolicy, WorkflowIdReusePolicy}, + enums::v1::{ContinueAsNewVersioningBehavior, WorkflowIdReusePolicy}, + sdk::v1::UserMetadata, }, }, }; -// TODO: Before release, probably best to avoid using proto types entirely here. They're awkward. - /// Options for scheduling an activity #[derive(Debug, bon::Builder, Clone)] #[non_exhaustive] @@ -128,30 +133,40 @@ impl ActivityCloseTimeouts { } impl ActivityOptions { - pub(crate) fn into_request( + pub(crate) fn into_command( self, seq: u32, activity_type: String, args: Vec, - ) -> ScheduleActivityRequest { + ) -> WorkflowCommand { let (start_to_close_timeout, schedule_to_close_timeout) = self.close_timeouts.into_durations(); - ScheduleActivityRequest { - seq, - activity_type, - activity_id: self.activity_id, - task_queue: self.task_queue, - args, - schedule_to_close_timeout, - schedule_to_start_timeout: self.schedule_to_start_timeout, - start_to_close_timeout, - heartbeat_timeout: self.heartbeat_timeout, - cancellation_type: self.cancellation_type, - retry_policy: self.retry_policy, - priority: self.priority, - summary: self.summary, - do_not_eagerly_execute: self.do_not_eagerly_execute, - } + command_with_metadata( + workflow_command::Variant::ScheduleActivity(ScheduleActivity { + seq, + activity_type, + activity_id: self.activity_id.unwrap_or_else(|| seq.to_string()), + task_queue: self.task_queue.unwrap_or_default(), + arguments: args, + schedule_to_close_timeout: 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: start_to_close_timeout + .and_then(|duration| duration.try_into().ok()), + heartbeat_timeout: self + .heartbeat_timeout + .and_then(|duration| duration.try_into().ok()), + cancellation_type: self.cancellation_type.into(), + retry_policy: self.retry_policy, + priority: self.priority.map(Into::into), + do_not_eagerly_execute: self.do_not_eagerly_execute, + ..Default::default() + }), + self.summary, + None, + ) } } @@ -196,31 +211,43 @@ pub struct LocalActivityOptions { } impl LocalActivityOptions { - pub(crate) fn into_request( + pub(crate) fn into_command( mut self, seq: u32, activity_type: String, args: Vec, - ) -> ScheduleLocalActivityRequest { + ) -> WorkflowCommand { // Tests and some workflow code rely on the historical SDK behavior where omitted local // activity timeouts are normalized before the command is emitted. self.schedule_to_close_timeout .get_or_insert(Duration::from_secs(100)); - ScheduleLocalActivityRequest { - seq, - activity_type, - activity_id: self.activity_id, - args, - retry_policy: self.retry_policy, - attempt: self.attempt, - original_schedule_time: self.original_schedule_time.and_then(|t| t.try_into().ok()), - timer_backoff_threshold: self.timer_backoff_threshold, - cancellation_type: self.cancel_type, - schedule_to_close_timeout: self.schedule_to_close_timeout, - schedule_to_start_timeout: self.schedule_to_start_timeout, - start_to_close_timeout: self.start_to_close_timeout, - summary: self.summary, - } + command_with_metadata( + workflow_command::Variant::ScheduleLocalActivity(ScheduleLocalActivity { + seq, + activity_type, + activity_id: self.activity_id.unwrap_or_else(|| seq.to_string()), + arguments: args, + retry_policy: Some(self.retry_policy), + attempt: self.attempt.unwrap_or(1), + original_schedule_time: self.original_schedule_time, + local_retry_threshold: self + .timer_backoff_threshold + .and_then(|duration| duration.try_into().ok()), + cancellation_type: self.cancel_type.into(), + 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()), + ..Default::default() + }), + self.summary, + None, + ) } } @@ -258,33 +285,47 @@ pub struct ChildWorkflowOptions { } impl ChildWorkflowOptions { - pub(crate) fn into_request( + pub(crate) fn into_command( self, seq: u32, workflow_type: String, args: Vec, - ) -> StartChildWorkflowRequest { - StartChildWorkflowRequest { - seq, - workflow_type, - workflow_id: self.workflow_id, - task_queue: self.task_queue, - args, - cancellation_type: self.cancel_type, - parent_close_policy: self.parent_close_policy, - static_summary: self.static_summary, - static_details: self.static_details, - id_reuse_policy: self.id_reuse_policy, - execution_timeout: self.execution_timeout, - run_timeout: self.run_timeout, - task_timeout: self.task_timeout, - cron_schedule: self.cron_schedule, - search_attributes: self - .search_attributes - .unwrap_or_default() - .into_named_payloads(), - priority: self.priority, - } + ) -> WorkflowCommand { + command_with_metadata( + workflow_command::Variant::StartChildWorkflowExecution(StartChildWorkflowExecution { + seq, + workflow_type, + workflow_id: self.workflow_id, + task_queue: self.task_queue.unwrap_or_default(), + input: args, + cancellation_type: self.cancel_type.into(), + parent_close_policy: self.parent_close_policy.into(), + workflow_id_reuse_policy: match self.id_reuse_policy { + WorkflowIdReusePolicy::Unspecified => WorkflowIdReusePolicy::AllowDuplicate, + policy => policy, + } + .into(), + workflow_execution_timeout: self + .execution_timeout + .and_then(|duration| duration.try_into().ok()), + workflow_run_timeout: self + .run_timeout + .and_then(|duration| duration.try_into().ok()), + workflow_task_timeout: self + .task_timeout + .and_then(|duration| duration.try_into().ok()), + cron_schedule: self.cron_schedule.unwrap_or_default(), + search_attributes: self.search_attributes.and_then(|attrs| { + (!attrs.is_empty()).then_some(SearchAttributes { + indexed_fields: attrs, + }) + }), + priority: self.priority.map(Into::into), + ..Default::default() + }), + self.static_summary, + self.static_details, + ) } } @@ -309,11 +350,12 @@ impl Signal { } } - pub(crate) fn into_invocation(self) -> SignalInvocation { - SignalInvocation { - name: self.signal_name, - args: self.data.input, - headers: self.data.headers.into_named_payloads(), + pub(crate) fn into_invocation(self) -> SignalWorkflow { + SignalWorkflow { + signal_name: self.signal_name, + input: self.data.input, + identity: String::new(), + headers: self.data.headers, } } } @@ -366,12 +408,19 @@ impl From for TimerOptions { } impl TimerOptions { - pub(crate) fn into_request(self, seq: u32) -> StartTimerRequest { - StartTimerRequest { - seq, - timeout: self.duration, - summary: self.summary, - } + pub(crate) fn into_command(self, seq: u32) -> WorkflowCommand { + command_with_metadata( + workflow_command::Variant::StartTimer(StartTimer { + seq, + start_to_fire_timeout: Some( + self.duration + .try_into() + .expect("workflow timer timeout must fit into protobuf duration"), + ), + }), + self.summary, + None, + ) } } @@ -418,21 +467,29 @@ pub struct NexusOperationOptions { } impl NexusOperationOptions { - pub(crate) fn into_request(self, seq: u32) -> ScheduleNexusOperationRequest { - ScheduleNexusOperationRequest { + 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, - schedule_to_start_timeout: self.schedule_to_start_timeout, - start_to_close_timeout: self.start_to_close_timeout, - headers: string_headers(self.nexus_header), - cancellation_type: self.cancellation_type.unwrap_or( - temporalio_common_wasm::protos::coresdk::nexus::NexusOperationCancellationType::WaitCancellationCompleted, - ), - } + 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: self + .cancellation_type + .unwrap_or(NexusOperationCancellationType::WaitCancellationCompleted) + .into(), + }) + .into() } } @@ -469,32 +526,61 @@ impl ContinueAsNewOptions { workflow_type: String, arguments: Vec, ) -> ContinueAsNewRequest { - ContinueAsNewRequest { - workflow_type: Some(self.workflow_type.unwrap_or(workflow_type)), - task_queue: self.task_queue, - args: arguments, - run_timeout: self.run_timeout, - task_timeout: self.task_timeout, - memo: self.memo.unwrap_or_default().into_named_payloads(), - headers: self.headers.unwrap_or_default().into_named_payloads(), - search_attributes: self - .search_attributes - .map(|attrs| attrs.indexed_fields.into_named_payloads()), + ContinueAsNewWorkflowExecution { + workflow_type: self.workflow_type.unwrap_or(workflow_type), + task_queue: self.task_queue.unwrap_or_default(), + arguments, + workflow_run_timeout: self + .run_timeout + .and_then(|duration| duration.try_into().ok()), + workflow_task_timeout: self + .task_timeout + .and_then(|duration| duration.try_into().ok()), + memo: self.memo.unwrap_or_default(), + headers: self.headers.unwrap_or_default(), + search_attributes: self.search_attributes, retry_policy: self.retry_policy, - versioning_intent: Some( - self.versioning_intent - .unwrap_or(VersioningIntent::Unspecified), - ), - initial_versioning_behavior: None, + versioning_intent: self + .versioning_intent + .unwrap_or(VersioningIntent::Unspecified) + .into(), + initial_versioning_behavior: ContinueAsNewVersioningBehavior::Unspecified.into(), } } } -fn string_headers(entries: impl IntoIterator) -> Vec { - entries - .into_iter() - .map(|(key, value)| StringHeader { key, value }) - .collect() +fn command_with_metadata( + variant: workflow_command::Variant, + summary: Option, + details: Option, +) -> WorkflowCommand { + WorkflowCommand { + variant: Some(variant), + user_metadata: string_user_metadata(summary, details), + } +} + +fn string_user_metadata(summary: Option, details: Option) -> Option { + if summary.is_none() && details.is_none() { + return None; + } + let converter = PayloadConverter::default(); + let context = SerializationContext { + data: &SerializationContextData::Workflow, + converter: &converter, + }; + Some(UserMetadata { + summary: summary.map(|value| { + converter + .to_payload(&context, &value) + .expect("String-to-JSON payload serialization is infallible") + }), + details: details.map(|value| { + converter + .to_payload(&context, &value) + .expect("String-to-JSON payload serialization is infallible") + }), + }) } #[cfg(test)] @@ -534,9 +620,12 @@ mod tests { schedule_to_close: Duration::from_secs(8), }) .build() - .into_request(7, "test".to_string(), vec![]); - assert_eq!(req.start_to_close_timeout.unwrap().as_secs(), 3); - assert_eq!(req.schedule_to_close_timeout.unwrap().as_secs(), 8); + .into_command(7, "test".to_string(), vec![]); + let Some(workflow_command::Variant::ScheduleActivity(req)) = req.variant else { + panic!("expected ScheduleActivity command"); + }; + assert_eq!(req.start_to_close_timeout.unwrap().seconds, 3); + assert_eq!(req.schedule_to_close_timeout.unwrap().seconds, 8); } #[test] @@ -547,11 +636,15 @@ mod tests { run_timeout: Some(Duration::from_secs(10)), ..Default::default() }; - let req = opts.into_request(1, "TestWorkflow".to_string(), vec![]); - let exec_timeout = req.execution_timeout.unwrap(); - let run_timeout = req.run_timeout.unwrap(); - assert_eq!(exec_timeout.as_secs(), 60); - assert_eq!(run_timeout.as_secs(), 10); + let command = opts.into_command(1, "TestWorkflow".to_string(), vec![]); + let Some(workflow_command::Variant::StartChildWorkflowExecution(req)) = command.variant + else { + panic!("expected StartChildWorkflowExecution command"); + }; + let exec_timeout = req.workflow_execution_timeout.unwrap(); + let run_timeout = req.workflow_run_timeout.unwrap(); + assert_eq!(exec_timeout.seconds, 60); + assert_eq!(run_timeout.seconds, 10); } #[test] @@ -561,9 +654,13 @@ mod tests { execution_timeout: Some(Duration::from_secs(60)), ..Default::default() }; - let req = opts.into_request(1, "TestWorkflow".to_string(), vec![]); - let exec_timeout = req.execution_timeout.unwrap(); - assert_eq!(exec_timeout.as_secs(), 60); - assert!(req.run_timeout.is_none()); + let command = opts.into_command(1, "TestWorkflow".to_string(), vec![]); + let Some(workflow_command::Variant::StartChildWorkflowExecution(req)) = command.variant + else { + panic!("expected StartChildWorkflowExecution command"); + }; + let exec_timeout = req.workflow_execution_timeout.unwrap(); + assert_eq!(exec_timeout.seconds, 60); + assert!(req.workflow_run_timeout.is_none()); } } diff --git a/crates/workflow/src/workflows.rs b/crates/workflow/src/workflows.rs index 86f954b71..03f9f4988 100644 --- a/crates/workflow/src/workflows.rs +++ b/crates/workflow/src/workflows.rs @@ -6,7 +6,7 @@ //! Example usage: //! ``` //! use temporalio_macros::{workflow, workflow_methods}; -//! use temporalio_sdk::{ +//! use temporalio_workflow::{ //! SyncWorkflowContext, WorkflowContext, WorkflowContextView, WorkflowResult, //! }; //! diff --git a/crates/workflow/wit/guest.wit b/crates/workflow/wit/guest.wit index 46ecea857..4ffc2d83a 100644 --- a/crates/workflow/wit/guest.wit +++ b/crates/workflow/wit/guest.wit @@ -1,18 +1,20 @@ package temporal:workflow-runtime@0.1.0; interface workflow-guest { - list-workflows: func() -> list; + use types.{activation-result, failure, routine-id, routine-poll-result, workflow-activation, workflow-definition, workflow-init}; + + list-workflows: func() -> list; instantiate-workflow: - func(workflow-type: string, init: types.workflow-init, args: list) - -> result; + func(init: workflow-init) + -> result; resource workflow-instance { activate: - func(activation: types.workflow-activation) - -> result; + func(activation: workflow-activation) + -> result; poll-routine: - func(routine-id: types.routine-id) - -> result; + func(routine-id: routine-id) + -> result; } } diff --git a/crates/workflow/wit/host.wit b/crates/workflow/wit/host.wit index d9f7afcef..8b12aa087 100644 --- a/crates/workflow/wit/host.wit +++ b/crates/workflow/wit/host.wit @@ -1,31 +1,9 @@ package temporal:workflow-runtime@0.1.0; interface workflow-host { - set-current-details: func(details: string); - - start-timer: func(req: types.start-timer-request); - cancel-timer: func(seq: u32); - - schedule-activity: func(req: types.schedule-activity-request); - cancel-activity: func(seq: u32); - - schedule-local-activity: func(req: types.schedule-local-activity-request); - cancel-local-activity: func(seq: u32); + use types.{workflow-command}; - start-child-workflow: func(req: types.start-child-workflow-request); - cancel-child-workflow: func(req: types.cancel-child-workflow-request); - - request-cancel-external-workflow: func(req: types.request-cancel-external-workflow-request); - signal-external-workflow: func(req: types.signal-external-workflow-request); - cancel-signal-external-workflow: func(seq: u32); - - schedule-nexus-operation: func(req: types.schedule-nexus-operation-request); - cancel-nexus-operation: func(req: types.request-cancel-nexus-operation-request); - - upsert-search-attributes: func(entries: list); - upsert-memo: func(entries: list); - set-patch-marker: func(patch-id: string, deprecated: bool); + set-current-details: func(details: string); - complete-update: func(protocol-instance-id: string, result: types.payload); - reject-update: func(protocol-instance-id: string, failure: types.failure); + push-command: func(command: workflow-command); } diff --git a/crates/workflow/wit/types.wit b/crates/workflow/wit/types.wit index 9a0258391..3439addb6 100644 --- a/crates/workflow/wit/types.wit +++ b/crates/workflow/wit/types.wit @@ -1,150 +1,19 @@ package temporal:workflow-runtime@0.1.0; -record payload-metadata-entry { - key: string, - value: list, -} - -record payload { - metadata: list, - data: list, -} - -record named-payload { - key: string, - value: payload, -} - -record duration { - seconds: s64, - nanos: u32, -} - -record timestamp { - seconds: s64, - nanos: u32, -} - -record workflow-execution-ref { - namespace: string, - workflow-id: string, - run-id: option, -} - -record worker-deployment-version { - deployment-name: string, - build-id: string, -} - -record priority { - priority-key: option, - fairness-key: option, - fairness-weight: option, -} - -record retry-policy { - initial-interval: option, - backoff-coefficient: option, - maximum-interval: option, - maximum-attempts: option, - non-retryable-error-types: list, -} - -record failure { - message: string, - source: option, - stack-trace: option, - encoded-attributes: list, - cause: option, -} - -enum workflow-id-reuse-policy { - allow-duplicate, - allow-duplicate-failed-only, - reject-duplicate, - terminate-if-running, -} - -enum parent-close-policy { - unspecified, - terminate, - abandon, - request-cancel, -} - -enum activity-cancellation-type { - try-cancel, - wait-cancellation-completed, - abandon, -} - -enum child-workflow-cancellation-type { - abandon, - try-cancel, - wait-cancellation-completed, - wait-cancellation-requested, -} +interface types { +type payload = list; -enum nexus-operation-cancellation-type { - wait-cancellation-completed, - abandon, - try-cancel, - wait-cancellation-requested, -} +type workflow-command = list; -enum versioning-intent { - unspecified, - compatible, - default, -} - -enum versioning-behavior { - unspecified, - pinned, - auto-upgrade, -} +type failure = list; type workflow-task-failed-cause = u32; -type suggest-continue-as-new-reason = u32; -type start-child-workflow-failed-cause = u32; record workflow-init { namespace: string, task-queue: string, - workflow-id: string, run-id: string, - workflow-type: string, - attempt: u32, - first-execution-run-id: string, - continued-from-run-id: option, - start-time: option, - execution-timeout: option, - run-timeout: option, - task-timeout: option, - parent: option, - root: option, - retry-policy: option, - cron-schedule: option, - memo: list, - search-attributes: list, - headers: list, - identity: option, - priority: option, - randomness-seed: u64, -} - -record activation-context { - workflow-time: option, - is-replaying: bool, - history-length: u32, - history-size-bytes: u64, - continue-as-new-suggested: bool, - current-deployment-version: option, - last-sdk-version: option, - available-internal-flags: list, - updated-randomness-seed: option, - target-worker-deployment-version-changed: bool, - suggest-continue-as-new-reasons: list, + initialize-workflow: list, } record workflow-definition { @@ -161,128 +30,13 @@ record update-definition { has-validator: bool, } -record signal-invocation { - name: string, - args: list, - headers: list, -} - -record update-invocation { - update-id: string, - protocol-instance-id: string, - name: string, - args: list, - headers: list, - run-validator: bool, -} - -record query-invocation { - name: string, - args: list, - headers: list, -} - record query-response { - result: result, + response: result, } type routine-id = u64; -record timer-fired { - seq: u32, -} - -variant activity-result { - completed(payload), - failed(failure), - cancelled(failure), - backoff(duration), -} - -record activity-resolution { - seq: u32, - result: activity-result, -} - -variant child-workflow-start-status { - succeeded(string), - failed(start-child-workflow-failed-cause), - cancelled(failure), -} - -record child-workflow-start-resolution { - seq: u32, - status: child-workflow-start-status, -} - -variant child-workflow-result { - completed(payload), - failed(failure), - cancelled(failure), -} - -record child-workflow-resolution { - seq: u32, - result: child-workflow-result, -} - -record external-signal-resolution { - seq: u32, - failure: option, -} - -record external-cancel-resolution { - seq: u32, - failure: option, -} - -variant nexus-start-status { - operation-token(string), - started-sync, - failed(failure), -} - -record nexus-start-resolution { - seq: u32, - status: nexus-start-status, -} - -variant nexus-result { - completed(payload), - failed(failure), - cancelled(failure), - timed-out(failure), -} - -record nexus-resolution { - seq: u32, - result: nexus-result, -} - -variant workflow-resolution { - timer-fired(timer-fired), - activity(activity-resolution), - child-workflow-start(child-workflow-start-resolution), - child-workflow(child-workflow-resolution), - external-signal(external-signal-resolution), - external-cancel(external-cancel-resolution), - nexus-start(nexus-start-resolution), - nexus(nexus-resolution), -} - -variant workflow-activation-job { - notify-patch(string), - cancel(string), - signal(signal-invocation), - update(update-invocation), - query(query-invocation), - resolution(workflow-resolution), -} - -record workflow-activation { - context: activation-context, - jobs: list, -} +type workflow-activation = list; variant routine-kind { main, @@ -312,123 +66,7 @@ record activation-result { job-results: list, } -record start-timer-request { - seq: u32, - timeout: duration, - summary: option, -} - -record schedule-activity-request { - seq: u32, - activity-type: string, - activity-id: option, - task-queue: option, - args: list, - schedule-to-start-timeout: option, - start-to-close-timeout: option, - schedule-to-close-timeout: option, - heartbeat-timeout: option, - cancellation-type: activity-cancellation-type, - retry-policy: option, - priority: option, - summary: option, - do-not-eagerly-execute: bool, -} - -record schedule-local-activity-request { - seq: u32, - activity-type: string, - activity-id: option, - args: list, - retry-policy: retry-policy, - attempt: option, - original-schedule-time: option, - timer-backoff-threshold: option, - cancellation-type: activity-cancellation-type, - schedule-to-close-timeout: option, - schedule-to-start-timeout: option, - start-to-close-timeout: option, - summary: option, -} - -record start-child-workflow-request { - seq: u32, - workflow-type: string, - workflow-id: string, - task-queue: option, - args: list, - cancellation-type: child-workflow-cancellation-type, - parent-close-policy: parent-close-policy, - static-summary: option, - static-details: option, - id-reuse-policy: workflow-id-reuse-policy, - execution-timeout: option, - run-timeout: option, - task-timeout: option, - cron-schedule: option, - search-attributes: list, - priority: option, -} - -record cancel-child-workflow-request { - seq: u32, - reason: option, -} - -record request-cancel-external-workflow-request { - seq: u32, - namespace: option, - workflow-id: string, - run-id: option, - reason: option, -} - -record signal-external-workflow-request { - seq: u32, - target: signal-workflow-target, - signal: signal-invocation, -} - -variant signal-workflow-target { - workflow-execution(workflow-execution-ref), - child-workflow-id(string), -} - -record schedule-nexus-operation-request { - seq: u32, - endpoint: string, - service: string, - operation: string, - input: option, - schedule-to-close-timeout: option, - schedule-to-start-timeout: option, - start-to-close-timeout: option, - headers: list, - cancellation-type: nexus-operation-cancellation-type, -} - -record request-cancel-nexus-operation-request { - seq: u32, -} - -record continue-as-new-request { - workflow-type: option, - task-queue: option, - args: list, - run-timeout: option, - task-timeout: option, - memo: list, - headers: list, - search-attributes: option>, - retry-policy: option, - versioning-intent: option, - initial-versioning-behavior: option, -} - -record string-header { - key: string, - value: string, -} +type continue-as-new-request = list; record task-failure { failure: failure, @@ -455,7 +93,7 @@ variant update-routine-completion { record update-routine-success { protocol-instance-id: string, - result: payload, + value: payload, } record update-routine-rejection { @@ -478,3 +116,4 @@ record routine-poll-result { completion: option, made-progress: bool, } +} diff --git a/samples/wasm-workflows/hello/.gitignore b/samples/wasm-workflows/hello/.gitignore new file mode 100644 index 000000000..b83d22266 --- /dev/null +++ b/samples/wasm-workflows/hello/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/samples/wasm-workflows/hello/Cargo.toml b/samples/wasm-workflows/hello/Cargo.toml new file mode 100644 index 000000000..7e73befb8 --- /dev/null +++ b/samples/wasm-workflows/hello/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "temporal-wasm-hello-workflow" +version = "0.1.0" +edition = "2024" + +[dependencies] +temporalio-macros = { path = "../../../crates/macros" } +temporalio-workflow = { path = "../../../crates/workflow" } + +[lib] +crate-type = ["cdylib"] + +[package.metadata.component] +package = "temporal:hello-workflow" + +[workspace] diff --git a/samples/wasm-workflows/hello/README.md b/samples/wasm-workflows/hello/README.md new file mode 100644 index 000000000..fbfe7c51e --- /dev/null +++ b/samples/wasm-workflows/hello/README.md @@ -0,0 +1,9 @@ +# WASM Hello Workflow + +Build the component with: + +```bash +cargo component build --release --target wasm32-unknown-unknown +``` + +The resulting component artifact is written under `target/wasm32-unknown-unknown/release/`. diff --git a/samples/wasm-workflows/hello/src/lib.rs b/samples/wasm-workflows/hello/src/lib.rs new file mode 100644 index 000000000..af26d2257 --- /dev/null +++ b/samples/wasm-workflows/hello/src/lib.rs @@ -0,0 +1,19 @@ +use temporalio_macros::{workflow, workflow_methods}; +use temporalio_workflow::{WorkflowContext, WorkflowResult, export_workflow_module}; + +#[workflow] +#[derive(Default)] +pub struct HelloWorkflow; + +#[workflow_methods] +impl HelloWorkflow { + #[run] + pub async fn run( + _ctx: &mut WorkflowContext, + name: String, + ) -> WorkflowResult { + Ok(format!("Hello, {name}!")) + } +} + +export_workflow_module!([HelloWorkflow]); From 8a01a747696474149e72797bab8de3c46c6a2d80 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Sun, 26 Apr 2026 22:06:43 -0700 Subject: [PATCH 3/9] Make activity_definitions consistent --- crates/common-wasm/src/activity_definition.rs | 49 +++- crates/common-wasm/src/lib.rs | 2 +- crates/common/src/lib.rs | 6 +- crates/macros/src/activities_definitions.rs | 243 +++++++++++++----- crates/macros/src/activity_definitions.rs | 211 --------------- crates/macros/src/lib.rs | 20 +- .../mismatched_definition_fail.rs | 26 ++ .../mismatched_definition_fail.stderr | 12 + .../integ_tests/workflow_tests/patches.rs | 8 +- crates/sdk/src/activities.rs | 64 +---- crates/sdk/src/lib.rs | 15 +- 11 files changed, 298 insertions(+), 358 deletions(-) delete mode 100644 crates/macros/src/activity_definitions.rs create mode 100644 crates/sdk-core/tests/activities_trybuild/mismatched_definition_fail.rs create mode 100644 crates/sdk-core/tests/activities_trybuild/mismatched_definition_fail.stderr diff --git a/crates/common-wasm/src/activity_definition.rs b/crates/common-wasm/src/activity_definition.rs index 8d10ee567..e45530bfd 100644 --- a/crates/common-wasm/src/activity_definition.rs +++ b/crates/common-wasm/src/activity_definition.rs @@ -1,7 +1,11 @@ //! Contains types for activity definitions, used by the code generated by the macros for defining //! activities, or directly by users targeting activities in other languages. -use crate::data_converters::{TemporalDeserializable, TemporalSerializable}; +use crate::{ + data_converters::{TemporalDeserializable, TemporalSerializable}, + protos::temporal::api::common::v1::Payload, +}; +use std::time::Duration; /// Implement on a marker struct to define an activity. /// @@ -18,3 +22,46 @@ pub trait ActivityDefinition { where Self: Sized; } + +/// Returned as errors from activity functions. +#[derive(Debug)] +pub enum ActivityError { + /// This error can be returned from activities to allow the explicit configuration of certain + /// error properties. It's also the default error type that arbitrary errors will be converted + /// into. + Retryable { + /// The underlying error + source: Box, + /// If specified, the next retry (if there is one) will occur after this delay + explicit_delay: Option, + }, + /// Return this error to indicate your activity is cancelling + Cancelled { + /// Some data to save as the cancellation reason + details: Option, + }, + /// Return this error to indicate that the activity should not be retried. + NonRetryable(Box), + /// Return this error to indicate that the activity will be completed outside of this activity + /// definition, by an external client. + WillCompleteAsync, +} + +impl ActivityError { + /// Construct a cancelled error without details + pub fn cancelled() -> Self { + Self::Cancelled { details: None } + } +} + +impl From for ActivityError +where + E: Into, +{ + fn from(source: E) -> Self { + Self::Retryable { + source: source.into().into_boxed_dyn_error(), + explicit_delay: None, + } + } +} diff --git a/crates/common-wasm/src/lib.rs b/crates/common-wasm/src/lib.rs index 9c0e64f44..137775f6b 100644 --- a/crates/common-wasm/src/lib.rs +++ b/crates/common-wasm/src/lib.rs @@ -14,7 +14,7 @@ pub mod protos; pub mod worker; mod workflow_definition; -pub use activity_definition::ActivityDefinition; +pub use activity_definition::{ActivityDefinition, ActivityError}; pub use priority::Priority; pub use worker::WorkerDeploymentVersion; pub use workflow_definition::{ diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 8d0b341a6..c5f416719 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -17,9 +17,9 @@ pub mod protos; pub mod telemetry; pub mod worker; pub use temporalio_common_wasm::{ - ActivityDefinition, HasWorkflowDefinition, Priority, QueryDefinition, SignalDefinition, - UntypedWorkflow, UpdateDefinition, WorkerDeploymentVersion, WorkflowDefinition, - data_converters, + ActivityDefinition, ActivityError, HasWorkflowDefinition, Priority, QueryDefinition, + SignalDefinition, UntypedWorkflow, UpdateDefinition, WorkerDeploymentVersion, + WorkflowDefinition, data_converters, }; macro_rules! dbg_panic { diff --git a/crates/macros/src/activities_definitions.rs b/crates/macros/src/activities_definitions.rs index 37399ba73..414c155ad 100644 --- a/crates/macros/src/activities_definitions.rs +++ b/crates/macros/src/activities_definitions.rs @@ -6,14 +6,42 @@ use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote, quote_spanned}; use syn::{ - Attribute, FnArg, ImplItem, ItemImpl, ReturnType, Type, TypePath, + Attribute, Block, Expr, FnArg, ImplItem, ItemImpl, ReturnType, Stmt, Type, TypePath, parse::{Parse, ParseStream}, spanned::Spanned, }; +#[derive(Copy, Clone, Eq, PartialEq)] +pub(crate) enum ParseMode { + /// `#[activities]` — a real implementation. Methods must take an `ActivityContext`. + Activities, + /// `#[activity_definitions]` — declarations only. Methods must not take an `ActivityContext`, + /// and their bodies must be `unimplemented!()`. + Definitions, +} + pub(crate) struct ActivitiesDefinition { impl_block: ItemImpl, activities: Vec, + mode: ParseMode, +} + +/// Newtype that implements `Parse` for `ParseMode::Activities`. +pub(crate) struct ActivitiesParse(pub ActivitiesDefinition); + +impl Parse for ActivitiesParse { + fn parse(input: ParseStream) -> syn::Result { + ActivitiesDefinition::parse_with_mode(input, ParseMode::Activities).map(Self) + } +} + +/// Newtype that implements `Parse` for `ParseMode::Definitions`. +pub(crate) struct DefinitionsParse(pub ActivitiesDefinition); + +impl Parse for DefinitionsParse { + fn parse(input: ParseStream) -> syn::Result { + ActivitiesDefinition::parse_with_mode(input, ParseMode::Definitions).map(Self) + } } #[derive(Default)] @@ -31,12 +59,11 @@ struct ActivityMethod { output_type: Option, } -impl Parse for ActivitiesDefinition { - fn parse(input: ParseStream) -> syn::Result { +impl ActivitiesDefinition { + fn parse_with_mode(input: ParseStream, mode: ParseMode) -> syn::Result { let impl_block: ItemImpl = input.parse()?; let mut activities = Vec::new(); - // Extract methods marked with #[activity] for item in &impl_block.items { if let ImplItem::Fn(method) = item { let has_activity_attr = method @@ -45,7 +72,7 @@ impl Parse for ActivitiesDefinition { .any(|attr| attr.path().is_ident("activity")); if has_activity_attr { - let activity = parse_activity_method(method)?; + let activity = parse_activity_method(method, mode)?; activities.push(activity); } } @@ -54,17 +81,39 @@ impl Parse for ActivitiesDefinition { Ok(ActivitiesDefinition { impl_block, activities, + mode, }) } } -fn parse_activity_method(method: &syn::ImplItemFn) -> syn::Result { +fn parse_activity_method(method: &syn::ImplItemFn, mode: ParseMode) -> syn::Result { let attributes = extract_activity_attributes(method.attrs.as_slice())?; let is_async = method.sig.asyncness.is_some(); + if mode == ParseMode::Definitions + && let Some(definition_attr) = method + .attrs + .iter() + .find(|a| a.path().is_ident("activity") && a.meta.require_list().is_ok()) + && attributes.definition_path.is_some() + { + return Err(syn::Error::new_spanned( + definition_attr, + "`definition = ...` is not allowed inside `#[activity_definitions]`; this block \ + *is* the definition", + )); + } + // Determine if static (no self receiver) or instance (Arc) let is_static = match method.sig.inputs.first() { Some(FnArg::Receiver(receiver)) => { + if mode == ParseMode::Definitions { + return Err(syn::Error::new_spanned( + receiver, + "Activity definitions must not take self; declare only the input/output \ + contract", + )); + } if receiver.colon_token.is_some() { validate_arc_self_type(&receiver.ty)?; false @@ -79,7 +128,11 @@ fn parse_activity_method(method: &syn::ImplItemFn) -> syn::Result true, }; - let input_types = extract_input_types(&method.sig)?; + if mode == ParseMode::Definitions { + validate_unimplemented_body(&method.block)?; + } + + let input_types = extract_input_types(&method.sig, mode)?; let output_type = extract_output_type(&method.sig); Ok(ActivityMethod { @@ -92,6 +145,27 @@ fn parse_activity_method(method: &syn::ImplItemFn) -> syn::Result syn::Result<()> { + let err = || { + syn::Error::new_spanned( + block, + "Activity definition bodies must be exactly `unimplemented!()`", + ) + }; + if block.stmts.len() != 1 { + return Err(err()); + } + let mac = match &block.stmts[0] { + Stmt::Macro(s) => &s.mac, + Stmt::Expr(Expr::Macro(e), _) => &e.mac, + _ => return Err(err()), + }; + if !mac.path.is_ident("unimplemented") || !mac.tokens.is_empty() { + return Err(err()); + } + Ok(()) +} + fn extract_activity_attributes(attrs: &[Attribute]) -> syn::Result { let mut activity_attributes = ActivityAttributes::default(); @@ -137,26 +211,33 @@ fn validate_arc_self_type(ty: &Type) -> syn::Result<()> { )) } -fn extract_input_types(sig: &syn::Signature) -> syn::Result> { +fn extract_input_types(sig: &syn::Signature, mode: ParseMode) -> syn::Result> { let mut found_ctx = false; let mut types = Vec::new(); for arg in &sig.inputs { if let FnArg::Typed(pat_type) = arg { - if found_ctx { - types.push((*pat_type.ty).clone()); - } else if let Type::Path(type_path) = &*pat_type.ty - && type_path + let is_ctx = matches!(&*pat_type.ty, Type::Path(type_path) + if type_path .path .segments .last() .map(|s| s.ident == "ActivityContext") - .unwrap_or(false) - { + .unwrap_or(false)); + if is_ctx { + if mode == ParseMode::Definitions { + return Err(syn::Error::new_spanned( + pat_type, + "`#[activity_definitions]` methods must not take an `ActivityContext`; \ + declare only the input/output contract", + )); + } found_ctx = true; + } else if found_ctx || mode == ParseMode::Definitions { + types.push((*pat_type.ty).clone()); } } } - if !found_ctx { + if mode == ParseMode::Activities && !found_ctx { return Err(syn::Error::new( sig.inputs.span(), "Activity functions must have an ActivityContext parameter as either the first \ @@ -236,29 +317,39 @@ impl ActivitiesDefinition { let impl_type_name = type_name_string(impl_type); let module_name = type_to_snake_case(impl_type); let module_ident = format_ident!("{}", module_name); - - // Generate the original impl block with: - // - #[activity] attributes stripped - // - Activity methods renamed with __ prefix - let mut cleaned_impl = self.impl_block.clone(); - for item in &mut cleaned_impl.items { - if let ImplItem::Fn(method) = item { - let is_activity = method - .attrs - .iter() - .any(|attr| attr.path().is_ident("activity")); - - method - .attrs - .retain(|attr| !attr.path().is_ident("activity")); - - // Rename activity methods with __ prefix - if is_activity { - let new_name = format_ident!("__{}", method.sig.ident); - method.sig.ident = new_name; + let is_definitions = self.mode == ParseMode::Definitions; + + // Re-emit the impl block with `#[activity]` attrs stripped and methods renamed + // `__greet` so the marker-struct const can keep the original name. Re-emitting also + // ensures any types referenced in the user's signatures (e.g. `ActivityError`) keep + // their imports live; in Definitions mode the bodies are `unimplemented!()` and + // never called, so the rewritten methods are tagged `#[allow(dead_code)]`. + let cleaned_impl = { + let mut cleaned = self.impl_block.clone(); + for item in &mut cleaned.items { + if let ImplItem::Fn(method) = item { + let is_activity = method + .attrs + .iter() + .any(|attr| attr.path().is_ident("activity")); + + method + .attrs + .retain(|attr| !attr.path().is_ident("activity")); + + if is_activity { + let new_name = format_ident!("__{}", method.sig.ident); + method.sig.ident = new_name; + if is_definitions { + method + .attrs + .push(syn::parse_quote!(#[allow(dead_code, unused_variables)])); + } + } } } - } + quote! { #cleaned } + }; // Generate marker structs (inside module, no external references) let activity_structs: Vec<_> = self @@ -286,14 +377,17 @@ impl ActivitiesDefinition { }) .collect(); - // Generate run methods on marker structs (outside module to reference impl_type) - let run_impls: Vec<_> = self - .activities - .iter() - .map(|act| self.generate_run_impl(act, impl_type, &module_ident)) - .collect(); + // Run methods and `ExecutableActivity`/`ActivityImplementer`/`HasOnlyStaticMethods` + // impls only make sense for real activities; definitions skip them entirely. + let run_impls: Vec<_> = if is_definitions { + Vec::new() + } else { + self.activities + .iter() + .map(|act| self.generate_run_impl(act, impl_type, &module_ident)) + .collect() + }; - // Generate ActivityDefinition and ExecutableActivity impls (outside module) let activity_impls: Vec<_> = self .activities .iter() @@ -307,9 +401,13 @@ impl ActivitiesDefinition { }) .collect(); - let implementer_impl = self.generate_activity_implementer_impl(impl_type, &module_ident); + let implementer_impl = if is_definitions { + quote! {} + } else { + self.generate_activity_implementer_impl(impl_type, &module_ident) + }; - let has_only_static = if self.activities.iter().all(|a| a.is_static) { + let has_only_static = if !is_definitions && self.activities.iter().all(|a| a.is_static) { quote! { impl ::temporalio_sdk::activities::HasOnlyStaticMethods for #impl_type {} } @@ -453,7 +551,6 @@ impl ActivitiesDefinition { ) -> TokenStream2 { let struct_name = method_name_to_pascal_case(&activity.method.sig.ident); let struct_ident = format_ident!("{}", struct_name); - let prefixed_method = format_ident!("__{}", activity.method.sig.ident); let input_type = multi_args_input_type(&activity.input_types); let output_type = &activity @@ -462,8 +559,6 @@ impl ActivitiesDefinition { .map(|t| quote! { #t }) .unwrap_or(quote! { () }); - let has_input = !activity.input_types.is_empty(); - let activity_name = if let Some(ref definition_path) = activity.attributes.definition_path { quote! { <#definition_path as ::temporalio_common::ActivityDefinition>::name() } } else if let Some(ref name_expr) = activity.attributes.name_override { @@ -479,21 +574,21 @@ impl ActivitiesDefinition { .map(|definition_path| { quote! { const _: () = { - trait __TemporalSame {} - impl __TemporalSame for T {} - fn __assert_same_input() + trait ActivityImplMustMatchDefinition {} + impl ActivityImplMustMatchDefinition for T {} + fn assert_input_matches_definition() where - A: __TemporalSame, + Impl: ActivityImplMustMatchDefinition, {} - fn __assert_same_output() + fn assert_output_matches_definition() where - A: __TemporalSame, + Impl: ActivityImplMustMatchDefinition, {} - let _ = __assert_same_input::< + let _ = assert_input_matches_definition::< <#module_ident::#struct_ident as ::temporalio_common::ActivityDefinition>::Input, <#definition_path as ::temporalio_common::ActivityDefinition>::Input, >; - let _ = __assert_same_output::< + let _ = assert_output_matches_definition::< <#module_ident::#struct_ident as ::temporalio_common::ActivityDefinition>::Output, <#definition_path as ::temporalio_common::ActivityDefinition>::Output, >; @@ -502,6 +597,30 @@ impl ActivitiesDefinition { }) .unwrap_or_default(); + let activity_definition_impl = quote! { + impl ::temporalio_common::ActivityDefinition for #module_ident::#struct_ident { + type Input = #input_type; + type Output = #output_type; + + fn name() -> &'static str + where + Self: Sized, + { + #activity_name + } + } + }; + + if self.mode == ParseMode::Definitions { + return quote! { + #activity_definition_impl + #definition_assertions + }; + } + + let prefixed_method = format_ident!("__{}", activity.method.sig.ident); + let has_input = !activity.input_types.is_empty(); + let receiver_pattern = if activity.is_static { quote! { _receiver } } else { @@ -555,17 +674,7 @@ impl ActivitiesDefinition { }; quote! { - impl ::temporalio_common::ActivityDefinition for #module_ident::#struct_ident { - type Input = #input_type; - type Output = #output_type; - - fn name() -> &'static str - where - Self: Sized, - { - #activity_name - } - } + #activity_definition_impl impl ::temporalio_sdk::activities::ExecutableActivity for #module_ident::#struct_ident { type Implementer = #impl_type; diff --git a/crates/macros/src/activity_definitions.rs b/crates/macros/src/activity_definitions.rs deleted file mode 100644 index 0cf786936..000000000 --- a/crates/macros/src/activity_definitions.rs +++ /dev/null @@ -1,211 +0,0 @@ -use crate::macro_utils::{ident_to_snake_case, method_name_to_pascal_case}; -use proc_macro::TokenStream; -use proc_macro2::TokenStream as TokenStream2; -use quote::{format_ident, quote, quote_spanned}; -use syn::{ - Attribute, FnArg, ItemTrait, ReturnType, TraitItem, Type, - parse::{Parse, ParseStream}, - spanned::Spanned, -}; - -pub(crate) struct ActivityDefinitionsTrait { - trait_item: ItemTrait, - activities: Vec, -} - -#[derive(Default)] -struct ActivityAttributes { - name_override: Option, -} - -struct ActivityDefMethod { - method: syn::TraitItemFn, - attributes: ActivityAttributes, - input_types: Vec, - output_type: Option, -} - -impl Parse for ActivityDefinitionsTrait { - fn parse(input: ParseStream) -> syn::Result { - let trait_item: ItemTrait = input.parse()?; - let mut activities = Vec::new(); - for item in &trait_item.items { - if let TraitItem::Fn(method) = item { - activities.push(parse_activity_def_method(method)?); - } - } - Ok(Self { - trait_item, - activities, - }) - } -} - -fn parse_activity_def_method(method: &syn::TraitItemFn) -> syn::Result { - if method.sig.asyncness.is_some() { - return Err(syn::Error::new_spanned( - &method.sig, - "Activity definitions must not be async; declare only the input/output contract", - )); - } - if matches!(method.sig.inputs.first(), Some(FnArg::Receiver(_))) { - return Err(syn::Error::new_spanned( - &method.sig, - "Activity definitions must not take self; declare only the input/output contract", - )); - } - Ok(ActivityDefMethod { - method: method.clone(), - attributes: extract_activity_attributes(&method.attrs)?, - input_types: method - .sig - .inputs - .iter() - .filter_map(|arg| match arg { - FnArg::Typed(p) => Some((*p.ty).clone()), - FnArg::Receiver(_) => None, - }) - .collect(), - output_type: extract_output_type(&method.sig), - }) -} - -fn extract_activity_attributes(attrs: &[Attribute]) -> syn::Result { - let mut activity_attributes = ActivityAttributes::default(); - - for attr in attrs { - if attr.path().is_ident("activity") && attr.meta.require_list().is_ok() { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("name") { - let value = meta.value()?; - let expr: syn::Expr = value.parse()?; - activity_attributes.name_override = Some(expr); - Ok(()) - } else { - Err(meta.error("unsupported activity attribute")) - } - })?; - } - } - - Ok(activity_attributes) -} - -fn extract_output_type(sig: &syn::Signature) -> Option { - match &sig.output { - ReturnType::Type(_, ty) => Some((**ty).clone()), - ReturnType::Default => None, - } -} - -fn multi_args_input_type(types: &[Type]) -> TokenStream2 { - match types.len() { - 0 => quote! { () }, - 1 => { - let t = &types[0]; - quote! { #t } - } - n => { - let multi_args = format_ident!("MultiArgs{}", n); - let types = types.iter(); - quote! { ::temporalio_workflow::common::data_converters::#multi_args<#(#types),*> } - } - } -} - -impl ActivityDefinitionsTrait { - pub(crate) fn codegen(&self) -> TokenStream { - let trait_ident = &self.trait_item.ident; - let module_ident = format_ident!("{}", ident_to_snake_case(&trait_ident.to_string())); - let module_vis = &self.trait_item.vis; - - let mut cleaned_trait = self.trait_item.clone(); - for item in &mut cleaned_trait.items { - if let TraitItem::Fn(method) = item { - method - .attrs - .retain(|attr| !attr.path().is_ident("activity")); - } - } - - let marker_structs: Vec<_> = self - .activities - .iter() - .map(|activity| { - let struct_ident = - format_ident!("{}", method_name_to_pascal_case(&activity.method.sig.ident)); - quote! { pub struct #struct_ident; } - }) - .collect(); - - let marker_consts: Vec<_> = self - .activities - .iter() - .map(|activity| { - let method_ident = &activity.method.sig.ident; - let struct_ident = - format_ident!("{}", method_name_to_pascal_case(&activity.method.sig.ident)); - let span = activity.method.span(); - quote_spanned! { span=> - #[allow(non_upper_case_globals, dead_code)] - pub const #method_ident: #struct_ident = #struct_ident; - } - }) - .collect(); - - let activity_impls: Vec<_> = self - .activities - .iter() - .map(|activity| { - let method_ident = &activity.method.sig.ident; - let struct_ident = - format_ident!("{}", method_name_to_pascal_case(&activity.method.sig.ident)); - let input_type = multi_args_input_type(&activity.input_types); - let output_type = activity - .output_type - .as_ref() - .map(|t| quote! { #t }) - .unwrap_or(quote! { () }); - let activity_name = if let Some(ref name_expr) = activity.attributes.name_override { - quote! { #name_expr } - } else { - let default_name = format!("{}::{}", trait_ident, method_ident); - quote! { #default_name } - }; - - quote! { - impl #module_ident::#struct_ident { - /// Returns the activity name (delegates to ActivityDefinition::name()). - pub fn name(&self) -> &'static str { - ::name() - } - } - - impl ::temporalio_workflow::common::ActivityDefinition for #module_ident::#struct_ident { - type Input = #input_type; - type Output = #output_type; - - fn name() -> &'static str - where - Self: Sized, - { - #activity_name - } - } - } - }) - .collect(); - - quote! { - #cleaned_trait - - #module_vis mod #module_ident { - #(#marker_structs)* - #(#marker_consts)* - } - - #(#activity_impls)* - } - .into() - } -} diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index d9eeb1916..5a13568f8 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -2,7 +2,6 @@ use proc_macro::TokenStream; use syn::parse_macro_input; mod activities_definitions; -mod activity_definitions; mod fsm_impl; mod macro_utils; mod workflow_definitions; @@ -13,9 +12,8 @@ mod workflow_definitions; /// For a usage example, see that crate's documentation. #[proc_macro_attribute] pub fn activities(_attr: TokenStream, item: TokenStream) -> TokenStream { - let def: activities_definitions::ActivitiesDefinition = - parse_macro_input!(item as activities_definitions::ActivitiesDefinition); - def.codegen() + let def = parse_macro_input!(item as activities_definitions::ActivitiesParse); + def.0.codegen() } /// Marks a method within an `#[activities]` impl block as an activity. @@ -25,15 +23,17 @@ pub fn activity(_attr: TokenStream, item: TokenStream) -> TokenStream { item } -/// Defines activity markers and shared contracts without providing native implementations. +/// Declares activities without providing implementations. Each method must omit the +/// `ActivityContext` parameter and have a body of exactly `unimplemented!()`. The macro emits +/// the same marker structs and inherent consts as `#[activities]` so call sites use the same +/// `MyActivities::greet` form, but no execution machinery is generated. /// -/// This macro is intended for workflow crates that need typed activity declarations which can be -/// implemented elsewhere by a native worker crate. +/// Intended for workflow crates that need typed activity declarations which are implemented +/// elsewhere by a separate worker crate (or in another language). #[proc_macro_attribute] pub fn activity_definitions(_attr: TokenStream, item: TokenStream) -> TokenStream { - let def: activity_definitions::ActivityDefinitionsTrait = - parse_macro_input!(item as activity_definitions::ActivityDefinitionsTrait); - def.codegen() + let def = parse_macro_input!(item as activities_definitions::DefinitionsParse); + def.0.codegen() } /// Marks a struct as a workflow definition. diff --git a/crates/sdk-core/tests/activities_trybuild/mismatched_definition_fail.rs b/crates/sdk-core/tests/activities_trybuild/mismatched_definition_fail.rs new file mode 100644 index 000000000..098df687d --- /dev/null +++ b/crates/sdk-core/tests/activities_trybuild/mismatched_definition_fail.rs @@ -0,0 +1,26 @@ +use temporalio_macros::{activities, activity_definitions}; +use temporalio_sdk::activities::{ActivityContext, ActivityError}; + +pub struct SharedActivities; + +#[activity_definitions] +impl SharedActivities { + #[activity] + pub fn greet(name: String) -> Result { + unimplemented!() + } +} + +pub struct MyImpl; + +#[activities] +impl MyImpl { + // Input type doesn't match the definition: the definition takes `String`, + // this impl takes `i32`. + #[activity(definition = shared_activities::Greet)] + pub async fn greet(_ctx: ActivityContext, _name: i32) -> Result { + Ok(String::new()) + } +} + +fn main() {} diff --git a/crates/sdk-core/tests/activities_trybuild/mismatched_definition_fail.stderr b/crates/sdk-core/tests/activities_trybuild/mismatched_definition_fail.stderr new file mode 100644 index 000000000..2bf94ce86 --- /dev/null +++ b/crates/sdk-core/tests/activities_trybuild/mismatched_definition_fail.stderr @@ -0,0 +1,12 @@ +error[E0277]: the trait bound `i32: ActivityImplMustMatchDefinition` is not satisfied + --> tests/activities_trybuild/mismatched_definition_fail.rs:16:1 + | +16 | #[activities] + | ^^^^^^^^^^^^^ the trait `ActivityImplMustMatchDefinition` is not implemented for `i32` + | +note: required by a bound in `assert_input_matches_definition` + --> tests/activities_trybuild/mismatched_definition_fail.rs:16:1 + | +16 | #[activities] + | ^^^^^^^^^^^^^ required by this bound in `assert_input_matches_definition` + = note: this error originates in the attribute macro `activities` (in Nightly builds, run with -Z macro-backtrace for more info) 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 82de13c7e..c50c0a736 100644 --- a/crates/sdk-core/tests/integ_tests/workflow_tests/patches.rs +++ b/crates/sdk-core/tests/integ_tests/workflow_tests/patches.rs @@ -37,10 +37,10 @@ use temporalio_common::{ }; use temporalio_common::worker::WorkerTaskTypes; -use temporalio_macros::{activities, workflow, workflow_methods}; +use temporalio_macros::{activity_definitions, workflow, workflow_methods}; use temporalio_sdk::{ ActivityOptions, SyncWorkflowContext, WorkflowContext, WorkflowResult, - activities::{ActivityContext, ActivityError}, + activities::ActivityError, }; use temporalio_sdk_core::{ replay::{DEFAULT_WORKFLOW_TYPE, TestHistoryBuilder}, @@ -400,10 +400,10 @@ fn patch_marker_single_activity( } struct FakeAct; -#[activities] +#[activity_definitions] impl FakeAct { #[activity(name = "")] - fn nameless(_: ActivityContext) -> Result { + fn nameless() -> Result { unimplemented!() } } diff --git a/crates/sdk/src/activities.rs b/crates/sdk/src/activities.rs index c35e7ca09..c0bf57dfa 100644 --- a/crates/sdk/src/activities.rs +++ b/crates/sdk/src/activities.rs @@ -7,7 +7,7 @@ //! Arc, //! atomic::{AtomicUsize, Ordering}, //! }; -//! use temporalio_macros::activities; +//! use temporalio_macros::{activities, activity_definitions}; //! use temporalio_sdk::activities::{ActivityContext, ActivityError}; //! //! struct MyActivities { @@ -29,20 +29,19 @@ //! } //! //! // If you need to refer to an activity that is defined externally, in a different codebase or -//! // possibly a differenet language, you can simply leave the function body unimplemented like so: +//! // possibly a different language, use `#[activity_definitions]`. Methods must omit the +//! // `ActivityContext` parameter and have a body of `unimplemented!()`. Workflows can then call +//! // these definitions just like real activities. //! //! struct ExternalActivities; -//! #[activities] +//! #[activity_definitions] //! impl ExternalActivities { //! #[activity(name = "foo")] -//! async fn foo(_ctx: ActivityContext, _: String) -> Result { +//! fn foo(_: String) -> Result { //! unimplemented!() //! } //! } //! ``` -//! -//! This will allows you to call the activity from workflow code still, but the actual function -//! will never be invoked, since you won't have registered it with the worker. #[doc(inline)] pub use temporalio_macros::activities; @@ -56,6 +55,7 @@ use std::{ time::{Duration as StdDuration, SystemTime}, }; use temporalio_client::Priority; +pub use temporalio_common::ActivityError; use temporalio_common::{ ActivityDefinition, data_converters::{ @@ -227,56 +227,6 @@ pub struct ActivityInfo { pub run_id: Option, } -/// Returned as errors from activity functions. -#[derive(Debug)] -pub enum ActivityError { - /// Return this error to attach application-failure metadata to an activity failure. - Application(Box), - /// Return this error to indicate your activity is cancelling - Cancelled { - /// Optional cancellation details. - details: Option, - }, - /// Return this error to indicate that the activity will be completed outside of this activity - /// definition, by an external client. - WillCompleteAsync, -} - -impl ActivityError { - /// Construct a cancelled error without details - pub fn cancelled() -> Self { - Self::Cancelled { details: None } - } - - /// Construct a cancelled error with details that will be converted using the active data - /// converter. - pub fn cancelled_with_details(details: T) -> Self - where - T: Into, - { - Self::Cancelled { - details: Some(details.into()), - } - } - - /// Construct an application activity error. - pub fn application(err: ApplicationFailure) -> Self { - Self::Application(err.into()) - } -} - -impl From for ActivityError -where - E: Into, -{ - fn from(source: E) -> Self { - match source.into().downcast::() { - Ok(application_failure) => Self::Application(Box::new(application_failure)), - Err(err) => Self::Application(ApplicationFailure::new(err).into()), - } - } -} - /// Deadline calculation. This is a port of /// https://github.com/temporalio/sdk-go/blob/8651550973088f27f678118f997839fb1bb9e62f/internal/activity.go#L225 fn calculate_deadline( diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 770db75d8..1ab03684e 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -1079,17 +1079,19 @@ impl PrintablePanicType for EndPrintingAttempts { } #[cfg(test)] -#[allow(dead_code, unreachable_pub)] mod tests { use super::*; use temporalio_macros::{activities, activity_definitions, workflow, workflow_methods}; struct MyActivities {} + struct SharedActivities; #[activity_definitions] - trait SharedActivities { + impl SharedActivities { #[activity(name = "shared-greet")] - fn greet(name: String) -> String; + fn greet(name: String) -> Result { + unimplemented!() + } } #[activities] @@ -1130,7 +1132,12 @@ mod tests { ActivityOptions::start_to_close_timeout(Duration::from_secs(5)), ); wf_ctx.start_activity( - shared_activities::greet, + SharedActivities::greet, + "Hi".to_owned(), + ActivityOptions::start_to_close_timeout(Duration::from_secs(5)), + ); + wf_ctx.start_activity( + MyActivities::greet, "Hi".to_owned(), ActivityOptions::start_to_close_timeout(Duration::from_secs(5)), ); From 89a34b972c9988f78672f330021d0d0400846ee9 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Sun, 26 Apr 2026 22:38:47 -0700 Subject: [PATCH 4/9] Various final cleanup --- .../tests/integ_tests/wasm_workflow_tests.rs | 27 +++++++-- crates/sdk/src/workflow_registry.rs | 14 +++-- crates/workflow/src/component.rs | 57 ++++++++----------- crates/workflow/src/lib.rs | 30 +++++----- crates/workflow/src/runtime/entry.rs | 6 +- crates/workflow/src/runtime/guest.rs | 2 - crates/workflow/src/runtime/instance.rs | 5 +- crates/workflow/src/runtime/mod.rs | 5 +- crates/workflow/src/runtime/model.rs | 2 - crates/workflow/src/runtime/types.rs | 4 +- crates/workflow/src/workflows.rs | 3 +- crates/workflow/wit/README.md | 34 +++++++++++ 12 files changed, 111 insertions(+), 78 deletions(-) diff --git a/crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs b/crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs index d964f11f5..fedf0d046 100644 --- a/crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs +++ b/crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs @@ -14,12 +14,29 @@ const WASM_WORKFLOW_TYPE: &str = "HelloWorkflow"; #[tokio::test] async fn wasm_workflow_component_executes() { let component_path = build_wasm_hello_component().await; - let mut starter = CoreWfStarter::new("wasm_workflow_component_executes"); + let component = WasmWorkflowComponent::from_file(WASM_COMPONENT_ID, component_path) + .expect("sample WASM component should be loadable"); + run_hello_workflow("wasm_workflow_component_executes", component).await; +} + +// Mirrors `wasm_workflow_component_executes` but loads the component bytes into memory and +// registers via `from_bytes`, exercising the dynamic-blob loading path that callers will use +// for runtime-supplied components (e.g. fetched over the network rather than read from disk). +#[tokio::test] +async fn wasm_workflow_component_executes_from_bytes() { + let component_path = build_wasm_hello_component().await; + let bytes = tokio::fs::read(&component_path) + .await + .expect("WASM component file should be readable"); + let component = WasmWorkflowComponent::from_bytes(WASM_COMPONENT_ID, bytes) + .expect("WASM component bytes should be loadable"); + run_hello_workflow("wasm_workflow_component_executes_from_bytes", component).await; +} + +async fn run_hello_workflow(test_name: &'static str, component: WasmWorkflowComponent) { + let mut starter = CoreWfStarter::new(test_name); starter.sdk_config.task_types = WorkerTaskTypes::workflow_only(); - starter.sdk_config.register_wasm_workflow( - WasmWorkflowComponent::from_file(WASM_COMPONENT_ID, component_path) - .expect("sample WASM component should be loadable"), - ); + starter.sdk_config.register_wasm_workflow(component); let mut worker = starter.worker().await; let client = starter.get_client().await; diff --git a/crates/sdk/src/workflow_registry.rs b/crates/sdk/src/workflow_registry.rs index de6e58158..3f08b46e3 100644 --- a/crates/sdk/src/workflow_registry.rs +++ b/crates/sdk/src/workflow_registry.rs @@ -10,13 +10,15 @@ use temporalio_common::{ coresdk::workflow_activation::InitializeWorkflow, temporal::api::common::v1::Payload, }, }; -use temporalio_workflow::runtime::{ +use temporalio_workflow::{ BaseWorkflowContext, - entry::WorkflowImplementation, - guest::WorkflowInstance, - host::WorkflowHost, - instance::{GuestWorkflowInstance, instantiate_workflow}, - types::WorkflowDefinitionDescriptor, + runtime::{ + entry::WorkflowImplementation, + guest::WorkflowInstance, + host::WorkflowHost, + instance::{GuestWorkflowInstance, instantiate_workflow}, + types::WorkflowDefinitionDescriptor, + }, }; /// Host-owned execution inputs used to instantiate a single workflow run. diff --git a/crates/workflow/src/component.rs b/crates/workflow/src/component.rs index 9dab65bcf..999a22c69 100644 --- a/crates/workflow/src/component.rs +++ b/crates/workflow/src/component.rs @@ -1,7 +1,6 @@ //! Component-model guest export support for workflow crates. - -#![allow(missing_docs)] - +//! +//! Everything in this module is internal SDK/component glue. use crate::{ BaseWorkflowContext, runtime::{ @@ -74,8 +73,8 @@ impl wit_guest::Guest for ExportedComponent { initialize_workflow: decode_proto(init.initialize_workflow), }; let workflow_type = init.initialize_workflow.workflow_type.clone(); - let instance = T::instantiate_workflow(&workflow_type, init, host) - .map_err(convert_failure_to_wit_box)?; + let instance = + T::instantiate_workflow(&workflow_type, init, host).map_err(|e| e.encode_to_vec())?; Ok(wit_guest::WorkflowInstance::new(ExportedWorkflowInstance( RefCell::new(instance), ))) @@ -106,20 +105,18 @@ impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { wit_types::QueryResponse { response: response .result - .map(encode_proto) - .map_err(encode_proto), + .map(|e| e.encode_to_vec()) + .map_err(|e| e.encode_to_vec()), }, ) } ActivationJobResult::UpdateRejected(failure) => { - wit_types::ActivationJobResult::UpdateRejected( - convert_failure_to_wit_box(failure), - ) + wit_types::ActivationJobResult::UpdateRejected(failure.encode_to_vec()) } }) .collect(), }) - .map_err(convert_failure_to_wit_box) + .map_err(|e| e.encode_to_vec()) } fn poll_routine( @@ -140,7 +137,7 @@ impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { MainRoutineCompletion::TaskFailed(task_failure) => { wit_types::MainRoutineCompletion::TaskFailed( wit_types::TaskFailure { - failure: convert_failure_to_wit_box(task_failure.failure), + failure: task_failure.failure.encode_to_vec(), force_cause: task_failure.force_cause, }, ) @@ -148,20 +145,20 @@ impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { MainRoutineCompletion::Terminal(outcome) => { wit_types::MainRoutineCompletion::Terminal(match *outcome { TerminalOutcome::Completed(payload) => { - wit_types::TerminalOutcome::Completed(encode_proto(payload)) + wit_types::TerminalOutcome::Completed( + payload.encode_to_vec(), + ) } TerminalOutcome::Failed(failure) => { - wit_types::TerminalOutcome::Failed( - convert_failure_to_wit_box(failure), - ) + wit_types::TerminalOutcome::Failed(failure.encode_to_vec()) } TerminalOutcome::Cancelled => { wit_types::TerminalOutcome::Cancelled } TerminalOutcome::ContinueAsNew(req) => { - wit_types::TerminalOutcome::ContinueAsNew(encode_proto( - *req, - )) + wit_types::TerminalOutcome::ContinueAsNew( + req.encode_to_vec(), + ) } }) } @@ -170,9 +167,9 @@ impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { RoutineCompletion::Signal(result) => { wit_types::RoutineCompletion::Signal(match result { Ok(()) => wit_types::SignalRoutineCompletion::Succeeded, - Err(failure) => wit_types::SignalRoutineCompletion::Failed( - convert_failure_to_wit_box(failure), - ), + Err(failure) => { + wit_types::SignalRoutineCompletion::Failed(failure.encode_to_vec()) + } }) } RoutineCompletion::Update(completion) => { @@ -183,7 +180,7 @@ impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { } => wit_types::UpdateRoutineCompletion::Completed( wit_types::UpdateRoutineSuccess { protocol_instance_id, - value: encode_proto(result), + value: result.encode_to_vec(), }, ), UpdateRoutineCompletion::Rejected { @@ -192,7 +189,7 @@ impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { } => wit_types::UpdateRoutineCompletion::Rejected( wit_types::UpdateRoutineRejection { protocol_instance_id, - failure: convert_failure_to_wit_box(failure), + failure: failure.encode_to_vec(), }, ), }) @@ -200,7 +197,7 @@ impl wit_guest::GuestWorkflowInstance for ExportedWorkflowInstance { }), made_progress: result.made_progress, }) - .map_err(convert_failure_to_wit_box) + .map_err(|e| e.encode_to_vec()) } } @@ -237,18 +234,10 @@ impl WorkflowHost for ImportedWorkflowHost { } fn push_command(&self, command: WorkflowCommand) { - wit_host::push_command(&encode_proto(command)); + wit_host::push_command(&command.encode_to_vec()); } } -fn convert_failure_to_wit_box(failure: WorkflowFailure) -> wit_types::Failure { - encode_proto(*failure) -} - -fn encode_proto(message: M) -> Vec { - message.encode_to_vec() -} - fn decode_proto(bytes: Vec) -> M { M::decode(bytes.as_slice()).unwrap_or_else(|err| { let n = M::NAME; diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 85c5889ab..3541ebf8a 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -13,10 +13,26 @@ pub mod __private { #[doc(hidden)] pub mod component; +#[doc(hidden)] pub mod runtime; mod workflow_context; pub mod workflows; +#[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 workflow_context::{ + ActivityCloseTimeouts, ActivityExecutionError, ActivityOptions, BaseWorkflowContext, + CancellableFuture, ChildWorkflowExecutionError, ChildWorkflowOptions, ChildWorkflowSignalError, + ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, NexusOperationOptions, + ParentWorkflowInfo, RootWorkflowInfo, Signal, SignalData, + StartChildWorkflowExecutionFailedCause, StartedChildWorkflow, SyncWorkflowContext, + TimerOptions, WorkflowContext, WorkflowContextView, +}; +pub use workflows::{join, join_all, select}; + #[macro_export] #[doc(hidden)] macro_rules! __temporal_select { @@ -92,17 +108,3 @@ macro_rules! export_workflow_module { }; }; } - -#[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 workflow_context::{ - ActivityCloseTimeouts, ActivityExecutionError, ActivityOptions, BaseWorkflowContext, - CancellableFuture, ChildWorkflowExecutionError, ChildWorkflowOptions, ChildWorkflowSignalError, - ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, NexusOperationOptions, - ParentWorkflowInfo, RootWorkflowInfo, Signal, SignalData, - StartChildWorkflowExecutionFailedCause, StartedChildWorkflow, SyncWorkflowContext, - TimerOptions, WorkflowContext, WorkflowContextView, -}; diff --git a/crates/workflow/src/runtime/entry.rs b/crates/workflow/src/runtime/entry.rs index 5e707471a..7f347392c 100644 --- a/crates/workflow/src/runtime/entry.rs +++ b/crates/workflow/src/runtime/entry.rs @@ -277,7 +277,7 @@ pub trait ExecutableAsyncUpdate: WorkflowImplementation { } /// Deserialize handler input from payloads. -pub fn deserialize_input( +pub(crate) fn deserialize_input( payloads: Vec, converter: &PayloadConverter, ) -> Result { @@ -289,7 +289,7 @@ pub fn deserialize_input( } /// Serialize handler output to a payload. -pub fn serialize_output( +pub(crate) fn serialize_output( output: &O, converter: &PayloadConverter, ) -> Result { @@ -301,7 +301,7 @@ pub fn serialize_output( } /// Wrap a handler error into WorkflowError. -pub fn wrap_handler_error(e: Box) -> WorkflowError { +pub(crate) fn wrap_handler_error(e: Box) -> WorkflowError { WorkflowError::Execution(anyhow::anyhow!(e)) } diff --git a/crates/workflow/src/runtime/guest.rs b/crates/workflow/src/runtime/guest.rs index a257b23e5..ed2fbca0b 100644 --- a/crates/workflow/src/runtime/guest.rs +++ b/crates/workflow/src/runtime/guest.rs @@ -1,7 +1,5 @@ //! High-level guest-side runtime traits mirroring the checked-in WIT interface. -#![allow(missing_docs)] - use crate::runtime::types::{ ActivationResult, RoutineId, RoutinePollResult, WorkflowActivation, WorkflowFailure, }; diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index ab52583e4..c3077c6cd 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -1,11 +1,8 @@ //! Guest-side workflow execution implementation used by native and future WASM hosts. -#![allow(missing_docs)] - use crate::{ - WorkflowContext, + BaseWorkflowContext, WorkflowContext, runtime::{ - BaseWorkflowContext, entry::{WorkflowError, WorkflowImplementation}, guest::WorkflowInstance, model::{TimerResult, UnblockEvent, WorkflowResult, WorkflowTermination}, diff --git a/crates/workflow/src/runtime/mod.rs b/crates/workflow/src/runtime/mod.rs index bb7d6c0c4..23b17614d 100644 --- a/crates/workflow/src/runtime/mod.rs +++ b/crates/workflow/src/runtime/mod.rs @@ -1,8 +1,7 @@ //! Unstable runtime-facing APIs for workflow hosts and future WASM integrations. //! //! These modules collect the parts of the workflow crate that are intended for SDK/runtime glue -//! rather than normal workflow authors. The long-term target for this namespace is the WIT surface -//! checked in under `crates/workflow/wit/`. +//! rather than normal workflow authors. use std::{ cell::Cell, @@ -18,8 +17,6 @@ pub mod instance; pub mod model; pub mod types; -pub use crate::workflow_context::{BaseWorkflowContext, WorkflowContextView}; - thread_local! { static SDK_WAKE_DEPTH: Cell = const { Cell::new(0) }; } diff --git a/crates/workflow/src/runtime/model.rs b/crates/workflow/src/runtime/model.rs index 8955c6fe2..026863310 100644 --- a/crates/workflow/src/runtime/model.rs +++ b/crates/workflow/src/runtime/model.rs @@ -1,7 +1,5 @@ //! Runtime protocol and execution model types shared by workflow code and native hosts. -#![allow(missing_docs)] - use crate::{ runtime::types::ContinueAsNewRequest, workflow_context::{ diff --git a/crates/workflow/src/runtime/types.rs b/crates/workflow/src/runtime/types.rs index 7af41e2e6..f08331c2b 100644 --- a/crates/workflow/src/runtime/types.rs +++ b/crates/workflow/src/runtime/types.rs @@ -1,6 +1,6 @@ //! Shared runtime model types mirroring the checked-in WIT interface. - -#![allow(missing_docs)] +//! +//! All items here are SDK/runtime glue. use temporalio_common_wasm::protos::{ coresdk::{ diff --git a/crates/workflow/src/workflows.rs b/crates/workflow/src/workflows.rs index 03f9f4988..3abec6efc 100644 --- a/crates/workflow/src/workflows.rs +++ b/crates/workflow/src/workflows.rs @@ -94,8 +94,7 @@ use futures_util::FutureExt; pub use crate::runtime::entry::{ ExecutableAsyncSignal, ExecutableAsyncUpdate, ExecutableQuery, ExecutableSyncSignal, - ExecutableSyncUpdate, WorkflowError, WorkflowImplementation, deserialize_input, - serialize_output, serialize_result, wrap_handler_error, + ExecutableSyncUpdate, WorkflowError, WorkflowImplementation, serialize_result, }; /// Deterministic `join_all` for use in Temporal workflows. diff --git a/crates/workflow/wit/README.md b/crates/workflow/wit/README.md index 3996ed1b1..3be46f00d 100644 --- a/crates/workflow/wit/README.md +++ b/crates/workflow/wit/README.md @@ -41,3 +41,37 @@ The runtime should support two backends behind the same worker translation logic - a WASM backend that invokes a component implementing `workflow-module` The goal is one logical execution model with two transport backends, not two independent workers. + +## Stability + +The package is published as `temporal:workflow-runtime@0.1.0` and is **not yet stable**. Until +this is bumped to `1.0.0` any release may bump the minor version with breaking changes. Once +external SDKs (Python, TypeScript, Go, etc.) begin compiling guest workflows against this WIT, +breaking changes need to follow normal SemVer discipline: bump the package version, dual-export +the old and new world from the host for a deprecation window, and document the migration path. + +Changes that force a major bump include: + +- Renaming or removing any record field, variant case, function, interface, or world. +- Changing a field/case/parameter type (including widening `u32` to `u64` etc.). +- Reordering variant cases (the discriminant order is part of the ABI). +- Changing the proto messages encoded into `list`-typed fields (`failure`, `payload`, + `workflow-command`, `workflow-activation`, `continue-as-new-request`). + +## Synchronous ABI and the WASIp3 future + +The current guest interface is intentionally synchronous: `activate` and `poll-routine` both +return ordinary results, and the guest is expected to suspend by returning +`routine-poll-result.made-progress = false`. The host then re-enters `poll-routine` after the +relevant activation lands. + +This shape is what stable Wasmtime + `wit-bindgen` support today. When component-model async +(WASIp3 / Preview 3) lands as stable in Wasmtime, the natural evolution is: + +- `activate` and `poll-routine` become `async func`. +- `routine-poll-result.made-progress` and the explicit `routine-id`-keyed polling protocol + likely go away — the host can just `await` each routine directly. + +That is a breaking ABI change, so it will require a major-version WIT bump. Any other-language +SDK that ships a guest implementation should expect to regenerate bindings against the new +world at that point. From 139d7b80e884ddfbaa5cab31e4ef296d6b1da8bf Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Tue, 28 Apr 2026 16:55:35 -0700 Subject: [PATCH 5/9] Fix merge issues --- crates/common-wasm/build.rs | 2 ++ crates/common-wasm/src/protos/mod.rs | 14 ++++++++++++++ crates/common/src/protos/mod.rs | 18 ------------------ 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/crates/common-wasm/build.rs b/crates/common-wasm/build.rs index d2750b21b..590675f2e 100644 --- a/crates/common-wasm/build.rs +++ b/crates/common-wasm/build.rs @@ -19,8 +19,10 @@ const SERDE_DERIVE_PREFIXES: &[&str] = &[ ".grpc", ".temporal.api.activity", ".temporal.api.batch", + ".temporal.api.callback", ".temporal.api.cloud", ".temporal.api.command", + ".temporal.api.compute", ".temporal.api.deployment", ".temporal.api.filter", ".temporal.api.history", diff --git a/crates/common-wasm/src/protos/mod.rs b/crates/common-wasm/src/protos/mod.rs index dccdbe6a2..3b9275aab 100644 --- a/crates/common-wasm/src/protos/mod.rs +++ b/crates/common-wasm/src/protos/mod.rs @@ -299,6 +299,7 @@ pub mod coresdk { failure_info: Some(failure::FailureInfo::CanceledFailureInfo( CanceledFailureInfo { details: details.map(Into::into), + identity: Default::default(), }, )), ..Default::default() @@ -1638,6 +1639,11 @@ pub mod temporal { tonic::include_proto!("temporal.api.batch.v1"); } } + pub mod callback { + pub mod v1 { + tonic::include_proto!("temporal.api.callback.v1"); + } + } pub mod command { pub mod v1 { tonic::include_proto!("temporal.api.command.v1"); @@ -2164,6 +2170,11 @@ pub mod temporal { } } } + pub mod compute { + pub mod v1 { + tonic::include_proto!("temporal.api.compute.v1"); + } + } pub mod deployment { pub mod v1 { tonic::include_proto!("temporal.api.deployment.v1"); @@ -2398,6 +2409,8 @@ pub mod temporal { Attributes::WorkflowExecutionPausedEventAttributes(_) => true, // !! Ignorable !! Attributes::WorkflowExecutionUnpausedEventAttributes(_) => true, + // !! Ignorable !! + Attributes::WorkflowExecutionTimeSkippingTransitionedEventAttributes(_) => true, } } else { false @@ -2479,6 +2492,7 @@ pub mod temporal { Attributes::NexusOperationCancelRequestFailedEventAttributes(_) => { EventType::NexusOperationCancelRequestFailed } Attributes::WorkflowExecutionPausedEventAttributes(_) => { EventType::WorkflowExecutionPaused } Attributes::WorkflowExecutionUnpausedEventAttributes(_) => { EventType::WorkflowExecutionUnpaused } + Attributes::WorkflowExecutionTimeSkippingTransitionedEventAttributes(_) => { EventType::WorkflowExecutionTimeSkippingTransitioned } } } } diff --git a/crates/common/src/protos/mod.rs b/crates/common/src/protos/mod.rs index 40915429b..8968bd69f 100644 --- a/crates/common/src/protos/mod.rs +++ b/crates/common/src/protos/mod.rs @@ -3,21 +3,3 @@ //! that will match the generated structs in this module. pub use temporalio_common_wasm::protos::*; - -#[cfg(feature = "test-utilities")] -/// Pre-built test histories for common workflow patterns. -pub mod canned_histories; -#[cfg(feature = "history_builders")] -mod history_builder; -#[cfg(feature = "history_builders")] -mod history_info; -#[cfg(feature = "test-utilities")] -pub mod test_utils; - -#[cfg(feature = "history_builders")] -pub use history_builder::{ - DEFAULT_ACTIVITY_TYPE, DEFAULT_WORKFLOW_TYPE, TestHistoryBuilder, default_act_sched, - default_wes_attribs, -}; -#[cfg(feature = "history_builders")] -pub use history_info::HistoryInfo; From 10e70b072431b2f61c2583d513d76e185f4b8c6e Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Tue, 28 Apr 2026 17:31:26 -0700 Subject: [PATCH 6/9] Continued cleanup --- .cargo/config.toml | 4 + .github/workflows/per-pr.yml | 23 ++++ crates/sdk-core/Cargo.toml | 13 +++ crates/sdk-core/tests/main.rs | 1 - .../{integ_tests => }/wasm_workflow_tests.rs | 7 ++ crates/sdk/src/workflows.rs | 2 + crates/workflow/Cargo.toml | 7 +- crates/workflow/src/lib.rs | 3 + crates/workflow/wit/README.md | 104 ++++++++---------- samples/wasm-workflows/hello/Cargo.toml | 1 - samples/wasm-workflows/hello/src/lib.rs | 10 +- 11 files changed, 105 insertions(+), 70 deletions(-) rename crates/sdk-core/tests/{integ_tests => }/wasm_workflow_tests.rs (93%) diff --git a/.cargo/config.toml b/.cargo/config.toml index c183ad0d0..88e48bc46 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -32,6 +32,8 @@ lint = [ "--test", "manual_tests", "--test", + "wasm_workflow_tests", + "--test", "cloud_tests", "--test", "integ_runner", @@ -53,6 +55,8 @@ lint-fix = [ "--test", "manual_tests", "--test", + "wasm_workflow_tests", + "--test", "cloud_tests", "--test", "integ_runner", diff --git a/.github/workflows/per-pr.yml b/.github/workflows/per-pr.yml index c3a48fff9..833615f1a 100644 --- a/.github/workflows/per-pr.yml +++ b/.github/workflows/per-pr.yml @@ -188,6 +188,29 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo integ-test + wasm-workflow-tests: + name: WASM workflow tests + timeout-minutes: ${{ github.ref == 'refs/heads/main' && 30 || 25 }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + targets: wasm32-unknown-unknown + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3 + with: + # TODO: Upgrade proto once https://github.com/arduino/setup-protoc/issues/99 is fixed + version: "23.x" + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + key: wasm-workflow-tests + - name: Install cargo-component + run: cargo install --locked cargo-component + - run: cargo integ-test -t wasm_workflow_tests + cloud-tests: if: github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-rust' name: Cloud tests diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index 571bb5c7a..4c48cd911 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -198,6 +198,19 @@ path = "tests/manual_tests.rs" test = false required-features = ["test-utilities"] +[[test]] +name = "wasm_workflow_tests" +path = "tests/wasm_workflow_tests.rs" +# Prevents autodiscovery, and hence these getting run with `cargo test`. Run with +# `cargo integ-test -t wasm_workflow_tests`. Building these requires the `wasm32-unknown-unknown` +# target and `cargo component`, which aren't installed by default. +test = false +required-features = [ + "temporalio-common/serde_serialize", + "test-utilities", + "ephemeral-server", +] + [[test]] name = "global_metric_tests" path = "tests/global_metric_tests.rs" diff --git a/crates/sdk-core/tests/main.rs b/crates/sdk-core/tests/main.rs index 4e330066b..c9e885844 100644 --- a/crates/sdk-core/tests/main.rs +++ b/crates/sdk-core/tests/main.rs @@ -25,7 +25,6 @@ mod integ_tests { mod schedule_tests; mod update_tests; mod visibility_tests; - mod wasm_workflow_tests; mod worker_heartbeat_tests; mod worker_tests; mod worker_versioning_tests; diff --git a/crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs b/crates/sdk-core/tests/wasm_workflow_tests.rs similarity index 93% rename from crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs rename to crates/sdk-core/tests/wasm_workflow_tests.rs index fedf0d046..17890cfce 100644 --- a/crates/sdk-core/tests/integ_tests/wasm_workflow_tests.rs +++ b/crates/sdk-core/tests/wasm_workflow_tests.rs @@ -1,3 +1,10 @@ +//! Tests that exercise the WASM workflow execution path. These are kept in a separate test binary +//! because they require `cargo component` and the `wasm32-unknown-unknown` target to build the +//! sample components, which not every CI environment has installed. + +#[allow(dead_code)] +mod common; + use crate::common::CoreWfStarter; use std::{path::PathBuf, time::Duration}; use temporalio_client::{UntypedWorkflow, WorkflowStartOptions}; diff --git a/crates/sdk/src/workflows.rs b/crates/sdk/src/workflows.rs index aab9d7594..65ee8c21d 100644 --- a/crates/sdk/src/workflows.rs +++ b/crates/sdk/src/workflows.rs @@ -3,3 +3,5 @@ pub use temporalio_workflow::workflows::*; pub use crate::workflow_registry::WorkflowDefinitions; +#[doc(inline)] +pub use temporalio_macros::{workflow, workflow_methods}; diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index 8061b26c5..1e8206884 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -32,8 +32,9 @@ wit-bindgen = { version = "0.57.1", default-features = false, features = ["macro path = "../common-wasm" version = "0.2" +[dependencies.temporalio-macros] +path = "../macros" +version = "0.3" + [lints] workspace = true - -[dev-dependencies] -temporalio-macros = { path = "../macros" } diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 3541ebf8a..b5613a497 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -5,6 +5,9 @@ extern crate self as temporalio_workflow; pub use temporalio_common_wasm as common; +pub use temporalio_macros::{ + init, query, run, signal, update, update_validator, workflow, workflow_methods, +}; #[doc(hidden)] pub mod __private { diff --git a/crates/workflow/wit/README.md b/crates/workflow/wit/README.md index 3be46f00d..ea0291baa 100644 --- a/crates/workflow/wit/README.md +++ b/crates/workflow/wit/README.md @@ -1,54 +1,58 @@ # Workflow Runtime WIT -This directory defines the canonical high-level workflow guest interface for the future WASM SDK. +This directory defines `temporal:workflow-runtime@0.1.0`, the high-level guest interface that +workflow code targets when it is compiled to a WebAssembly component. It is intentionally separate +from the core activation/completion protocol that worker SDKs speak to the Temporal cluster: core +remains the worker-facing protocol, and a worker is responsible for translating core activations +and completions into calls against this interface. + +## What's in here + +- `world.wit` — the `workflow-module` world: imports `workflow-host`, exports `workflow-guest`. +- `guest.wit` — `workflow-guest`: the entry points the guest exports for the host to drive + (`list-workflows`, `instantiate-workflow`, and the `workflow-instance` resource with `activate` + and `poll-routine`). +- `host.wit` — `workflow-host`: the host capabilities the guest imports (e.g. `set-current-details`, + `push-command`). +- `types.wit` — shared records and variants used by both sides (init/activation/completion shapes, + routine kinds, terminal outcomes, etc.). Some fields are typed as `list` and carry encoded + protobuf messages — those proto schemas are part of the ABI; see *Stability* below. + +## How a workflow runs against it + +1. The host instantiates a workflow run by calling `instantiate-workflow` with `workflow-init`. +2. For each activation, the host calls `activate` on the `workflow-instance` resource. The guest + processes the activation's jobs and reports per-job results (started routines, query responses, + update rejections). +3. The guest drives its routines (main, signals, updates) by being polled — the host calls + `poll-routine(routine-id)` until the routine either completes or reports it made no progress. +4. While running, the guest invokes host functions to push commands and update execution state. + +The guest interface is deliberately higher-level than core's activation protocol: ordering rules +and worker bookkeeping live in the host translation layer, not in the guest contract. -This is intentionally **not** the existing core activation/completion protocol. Core remains the -worker-facing protocol for the foreseeable future, and other language SDKs will continue to use it. -The Rust worker will translate core activations and completions into this higher-level interface. - -## Why this lives in `temporalio-workflow` - -`temporalio-workflow` is the crate that workflow implementations compile against. Checking the WIT -in here gives the Rust runtime refactor a concrete target: - -- `temporalio_workflow::runtime::*` should evolve toward a direct Rust mirror of these interfaces. -- The native Rust worker should keep calling those Rust traits directly, with no WIT serialization - in the hot path. -- A future WASM backend should expose the same interface through the component model. - -## Layering - -The layers are: - -1. Core activation/completion protocol -2. Native worker translation layer -3. This WIT-shaped workflow runtime interface -4. Workflow guest code - -That translation layer is where activation ordering and other core-specific details stay hidden. -The guest interface here is deliberately higher level: - -- the host instantiates a workflow run -- the host applies activation-wide context -- the host notifies signals, cancellation, patches, updates, and operation resolutions -- the guest polls until it blocks or terminates +## Synchronous ABI and the WASIp3 future -## Native and WASM execution +The guest interface is synchronous: `activate` and `poll-routine` return ordinary results, and a +routine signals "blocked" by returning `routine-poll-result.made-progress = false`. The host +re-enters `poll-routine` after the relevant activation lands. This shape is what stable Wasmtime +and `wit-bindgen` support today. -The runtime should support two backends behind the same worker translation logic: +When component-model async (WASIp3 / Preview 3) is stable in Wasmtime, the natural evolution is: -- a native backend that invokes Rust traits directly in-process -- a WASM backend that invokes a component implementing `workflow-module` +- `activate` and `poll-routine` become `async func`. +- `routine-poll-result.made-progress` and the explicit `routine-id`-keyed polling protocol can + go away — the host can `await` each routine directly. -The goal is one logical execution model with two transport backends, not two independent workers. +That is a breaking ABI change and will require a major-version WIT bump. ## Stability -The package is published as `temporal:workflow-runtime@0.1.0` and is **not yet stable**. Until -this is bumped to `1.0.0` any release may bump the minor version with breaking changes. Once -external SDKs (Python, TypeScript, Go, etc.) begin compiling guest workflows against this WIT, -breaking changes need to follow normal SemVer discipline: bump the package version, dual-export -the old and new world from the host for a deprecation window, and document the migration path. +The package is published as `temporal:workflow-runtime@0.1.0` and is **not yet stable**. Until it +is bumped to `1.0.0`, any release may bump the minor version with breaking changes. Once external +SDKs (Python, TypeScript, Go, etc.) begin compiling guest workflows against this WIT, breaking +changes need to follow normal SemVer discipline: bump the package version, dual-export the old +and new world from the host for a deprecation window, and document the migration path. Changes that force a major bump include: @@ -57,21 +61,3 @@ Changes that force a major bump include: - Reordering variant cases (the discriminant order is part of the ABI). - Changing the proto messages encoded into `list`-typed fields (`failure`, `payload`, `workflow-command`, `workflow-activation`, `continue-as-new-request`). - -## Synchronous ABI and the WASIp3 future - -The current guest interface is intentionally synchronous: `activate` and `poll-routine` both -return ordinary results, and the guest is expected to suspend by returning -`routine-poll-result.made-progress = false`. The host then re-enters `poll-routine` after the -relevant activation lands. - -This shape is what stable Wasmtime + `wit-bindgen` support today. When component-model async -(WASIp3 / Preview 3) lands as stable in Wasmtime, the natural evolution is: - -- `activate` and `poll-routine` become `async func`. -- `routine-poll-result.made-progress` and the explicit `routine-id`-keyed polling protocol - likely go away — the host can just `await` each routine directly. - -That is a breaking ABI change, so it will require a major-version WIT bump. Any other-language -SDK that ships a guest implementation should expect to regenerate bindings against the new -world at that point. diff --git a/samples/wasm-workflows/hello/Cargo.toml b/samples/wasm-workflows/hello/Cargo.toml index 7e73befb8..5805b7298 100644 --- a/samples/wasm-workflows/hello/Cargo.toml +++ b/samples/wasm-workflows/hello/Cargo.toml @@ -4,7 +4,6 @@ version = "0.1.0" edition = "2024" [dependencies] -temporalio-macros = { path = "../../../crates/macros" } temporalio-workflow = { path = "../../../crates/workflow" } [lib] diff --git a/samples/wasm-workflows/hello/src/lib.rs b/samples/wasm-workflows/hello/src/lib.rs index af26d2257..05ba28d6c 100644 --- a/samples/wasm-workflows/hello/src/lib.rs +++ b/samples/wasm-workflows/hello/src/lib.rs @@ -1,5 +1,6 @@ -use temporalio_macros::{workflow, workflow_methods}; -use temporalio_workflow::{WorkflowContext, WorkflowResult, export_workflow_module}; +use temporalio_workflow::{ + WorkflowContext, WorkflowResult, export_workflow_module, workflow, workflow_methods, +}; #[workflow] #[derive(Default)] @@ -8,10 +9,7 @@ pub struct HelloWorkflow; #[workflow_methods] impl HelloWorkflow { #[run] - pub async fn run( - _ctx: &mut WorkflowContext, - name: String, - ) -> WorkflowResult { + pub async fn run(_ctx: &mut WorkflowContext, name: String) -> WorkflowResult { Ok(format!("Hello, {name}!")) } } From f91310070a33ca6756f91816691b111b7b5dd3bf Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Wed, 29 Apr 2026 17:41:18 -0700 Subject: [PATCH 7/9] Handle failure conversion merge --- crates/common-wasm/Cargo.toml | 3 +- crates/common-wasm/src/activity_definition.rs | 42 +++++++------ .../src/data_converters/failure_converter.rs | 0 crates/{common => common-wasm}/src/error.rs | 0 crates/common-wasm/src/lib.rs | 1 + crates/common-wasm/src/protos/mod.rs | 8 +++ crates/common/Cargo.toml | 2 +- crates/common/src/lib.rs | 3 +- crates/sdk/Cargo.toml | 2 +- crates/sdk/src/activities.rs | 2 +- crates/sdk/src/lib.rs | 9 ++- crates/sdk/src/workflow_future.rs | 6 +- crates/sdk/src/workflow_registry.rs | 10 +-- crates/workflow/Cargo.toml | 6 +- crates/workflow/src/component.rs | 7 ++- crates/workflow/src/lib.rs | 11 ++-- crates/workflow/src/runtime/instance.rs | 61 +++++++++++-------- crates/workflow/src/runtime/model.rs | 32 +++++++--- crates/workflow/src/workflow_context.rs | 5 ++ 19 files changed, 133 insertions(+), 77 deletions(-) rename crates/{common => common-wasm}/src/data_converters/failure_converter.rs (100%) rename crates/{common => common-wasm}/src/error.rs (100%) diff --git a/crates/common-wasm/Cargo.toml b/crates/common-wasm/Cargo.toml index 8c0f3a2ad..bb5998716 100644 --- a/crates/common-wasm/Cargo.toml +++ b/crates/common-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-common-wasm" -version = "0.2.0" +version = "0.4.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -58,5 +58,6 @@ workspace = true [dev-dependencies] futures-util = { version = "0.3", default-features = false } +rstest = "0.26" tempfile = "3.21" tokio = { version = "1.47", features = ["macros", "rt"] } diff --git a/crates/common-wasm/src/activity_definition.rs b/crates/common-wasm/src/activity_definition.rs index e45530bfd..fe03e0271 100644 --- a/crates/common-wasm/src/activity_definition.rs +++ b/crates/common-wasm/src/activity_definition.rs @@ -3,9 +3,8 @@ use crate::{ data_converters::{TemporalDeserializable, TemporalSerializable}, - protos::temporal::api::common::v1::Payload, + error::{ApplicationFailure, FailurePayloads}, }; -use std::time::Duration; /// Implement on a marker struct to define an activity. /// @@ -26,22 +25,13 @@ pub trait ActivityDefinition { /// Returned as errors from activity functions. #[derive(Debug)] pub enum ActivityError { - /// This error can be returned from activities to allow the explicit configuration of certain - /// error properties. It's also the default error type that arbitrary errors will be converted - /// into. - Retryable { - /// The underlying error - source: Box, - /// If specified, the next retry (if there is one) will occur after this delay - explicit_delay: Option, - }, + /// Return this error to attach application-failure metadata to an activity failure. + Application(Box), /// Return this error to indicate your activity is cancelling Cancelled { - /// Some data to save as the cancellation reason - details: Option, + /// Optional cancellation details. + details: Option, }, - /// Return this error to indicate that the activity should not be retried. - NonRetryable(Box), /// Return this error to indicate that the activity will be completed outside of this activity /// definition, by an external client. WillCompleteAsync, @@ -52,6 +42,22 @@ impl ActivityError { pub fn cancelled() -> Self { Self::Cancelled { details: None } } + + /// Construct a cancelled error with details that will be converted using the active data + /// converter. + pub fn cancelled_with_details(details: T) -> Self + where + T: Into, + { + Self::Cancelled { + details: Some(details.into()), + } + } + + /// Construct an application activity error. + pub fn application(err: ApplicationFailure) -> Self { + Self::Application(err.into()) + } } impl From for ActivityError @@ -59,9 +65,9 @@ where E: Into, { fn from(source: E) -> Self { - Self::Retryable { - source: source.into().into_boxed_dyn_error(), - explicit_delay: None, + match source.into().downcast::() { + Ok(application_failure) => Self::Application(Box::new(application_failure)), + Err(err) => Self::Application(ApplicationFailure::new(err).into()), } } } diff --git a/crates/common/src/data_converters/failure_converter.rs b/crates/common-wasm/src/data_converters/failure_converter.rs similarity index 100% rename from crates/common/src/data_converters/failure_converter.rs rename to crates/common-wasm/src/data_converters/failure_converter.rs diff --git a/crates/common/src/error.rs b/crates/common-wasm/src/error.rs similarity index 100% rename from crates/common/src/error.rs rename to crates/common-wasm/src/error.rs diff --git a/crates/common-wasm/src/lib.rs b/crates/common-wasm/src/lib.rs index 137775f6b..93567cf3b 100644 --- a/crates/common-wasm/src/lib.rs +++ b/crates/common-wasm/src/lib.rs @@ -9,6 +9,7 @@ extern crate tracing; mod activity_definition; pub mod data_converters; +pub mod error; mod priority; pub mod protos; pub mod worker; diff --git a/crates/common-wasm/src/protos/mod.rs b/crates/common-wasm/src/protos/mod.rs index 3b9275aab..42963e3ff 100644 --- a/crates/common-wasm/src/protos/mod.rs +++ b/crates/common-wasm/src/protos/mod.rs @@ -161,6 +161,14 @@ pub mod coresdk { } } + pub fn cancel(fail: APIFailure) -> Self { + Self { + status: Some(aer::Status::Cancelled(Cancellation { + failure: Some(fail), + })), + } + } + pub fn cancel_from_details(payload: Option) -> Self { Self { status: Some(aer::Status::Cancelled(Cancellation::from_details(payload))), diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index c69cc7470..681318439 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -98,7 +98,7 @@ pbjson = { workspace = true } [dependencies.temporalio-common-wasm] path = "../common-wasm" -version = "0.2" +version = "0.4" features = ["grpc-clients"] [build-dependencies] diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index c5f416719..d2553cff5 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -9,7 +9,6 @@ extern crate tracing; #[cfg(feature = "envconfig")] pub mod envconfig; -pub mod error; #[doc(hidden)] pub mod fsm_trait; pub mod payload_visitor; @@ -19,7 +18,7 @@ pub mod worker; pub use temporalio_common_wasm::{ ActivityDefinition, ActivityError, HasWorkflowDefinition, Priority, QueryDefinition, SignalDefinition, UntypedWorkflow, UpdateDefinition, WorkerDeploymentVersion, - WorkflowDefinition, data_converters, + WorkflowDefinition, data_converters, error, }; macro_rules! dbg_panic { diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 741b1d343..ba69d2390 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -47,7 +47,7 @@ default-features = false [dependencies.temporalio-workflow] path = "../workflow" -version = "0.2" +version = "0.4" [dependencies.temporalio-common] path = "../common" diff --git a/crates/sdk/src/activities.rs b/crates/sdk/src/activities.rs index c0bf57dfa..5ed3d193f 100644 --- a/crates/sdk/src/activities.rs +++ b/crates/sdk/src/activities.rs @@ -61,7 +61,6 @@ use temporalio_common::{ data_converters::{ DataConverter, GenericPayloadConverter, SerializationContext, SerializationContextData, }, - error::{ApplicationFailure, FailurePayloads}, protos::{ coresdk::{ActivityHeartbeat, activity_task}, temporal::api::common::v1::{Payload, RetryPolicy, WorkflowExecution}, @@ -382,6 +381,7 @@ impl Debug for ActivityDefinitions { mod test { use super::*; use rstest::rstest; + use temporalio_common::error::ApplicationFailure; #[rstest] #[case(true)] diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index 1ab03684e..e369ed893 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -81,6 +81,11 @@ mod workflow_registry; mod workflow_wasm; pub mod workflows; +pub use crate::error::{ + ActivityExecutionError, ApplicationFailure, ChildWorkflowExecutionError, + ChildWorkflowSignalError, ChildWorkflowStartError, OutgoingActivityError, OutgoingError, + OutgoingWorkflowError, +}; pub use temporalio_client::Namespace; pub use temporalio_workflow::{ ActivityCloseTimeouts, ActivityOptions, BaseWorkflowContext, CancellableFuture, @@ -129,9 +134,7 @@ use temporalio_common::{ workflow_activation::{WorkflowActivation, workflow_activation_job::Variant}, workflow_completion::WorkflowActivationCompletion, }, - temporal::api::{ - common::v1::Payload, enums::v1::WorkflowTaskFailedCause, failure::v1::Failure, - }, + temporal::api::{common::v1::Payload, enums::v1::WorkflowTaskFailedCause}, }, worker::{WorkerDeploymentOptions, WorkerTaskTypes, build_id_from_current_exe}, }; diff --git a/crates/sdk/src/workflow_future.rs b/crates/sdk/src/workflow_future.rs index eed9ac188..d62b6598c 100644 --- a/crates/sdk/src/workflow_future.rs +++ b/crates/sdk/src/workflow_future.rs @@ -9,7 +9,7 @@ use std::{ task::{Context, Poll}, }; use temporalio_common::{ - data_converters::{DataConverter, PayloadConverter}, + data_converters::DataConverter, protos::{ coresdk::{ workflow_activation::{ @@ -59,7 +59,7 @@ pub(crate) fn start_workflow( run_id: String, init_workflow_job: InitializeWorkflow, outgoing_completions: UnboundedSender, - payload_converter: PayloadConverter, + data_converter: DataConverter, detect_nondeterministic: bool, ) -> Result< ( @@ -81,7 +81,7 @@ pub(crate) fn start_workflow( task_queue, run_id, init_workflow_job, - payload_converter: payload_converter.clone(), + data_converter: data_converter.clone(), host: host.clone(), }) .context("Failed to create workflow execution")?; diff --git a/crates/sdk/src/workflow_registry.rs b/crates/sdk/src/workflow_registry.rs index 3f08b46e3..f13c41e9c 100644 --- a/crates/sdk/src/workflow_registry.rs +++ b/crates/sdk/src/workflow_registry.rs @@ -4,7 +4,8 @@ use anyhow::Context; use temporalio_common::{ WorkflowDefinition, data_converters::{ - GenericPayloadConverter, PayloadConverter, SerializationContext, SerializationContextData, + DataConverter, GenericPayloadConverter, PayloadConverter, SerializationContext, + SerializationContextData, }, protos::{ coresdk::workflow_activation::InitializeWorkflow, temporal::api::common::v1::Payload, @@ -27,7 +28,7 @@ pub(crate) struct WorkflowExecutionInput { pub task_queue: String, pub run_id: String, pub init_workflow_job: InitializeWorkflow, - pub payload_converter: PayloadConverter, + pub data_converter: DataConverter, pub host: Rc, } @@ -150,16 +151,17 @@ fn workflow_input_parts( task_queue, run_id, init_workflow_job, - payload_converter, + data_converter, host, } = input; let payloads = init_workflow_job.arguments.clone(); + let payload_converter = data_converter.payload_converter().clone(); let base_ctx = BaseWorkflowContext::new( namespace, task_queue, run_id, init_workflow_job, - payload_converter.clone(), + data_converter, host, ); (payloads, payload_converter, base_ctx) diff --git a/crates/workflow/Cargo.toml b/crates/workflow/Cargo.toml index 1e8206884..99aafa819 100644 --- a/crates/workflow/Cargo.toml +++ b/crates/workflow/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalio-workflow" -version = "0.2.0" +version = "0.4.0" edition = "2024" authors = ["Temporal Technologies Inc. "] license-file = { workspace = true } @@ -30,11 +30,11 @@ wit-bindgen = { version = "0.57.1", default-features = false, features = ["macro [dependencies.temporalio-common-wasm] path = "../common-wasm" -version = "0.2" +version = "0.4" [dependencies.temporalio-macros] path = "../macros" -version = "0.3" +version = "0.4" [lints] workspace = true diff --git a/crates/workflow/src/component.rs b/crates/workflow/src/component.rs index 999a22c69..0b7b74d9a 100644 --- a/crates/workflow/src/component.rs +++ b/crates/workflow/src/component.rs @@ -18,7 +18,7 @@ use futures_util::task::noop_waker; use prost::Message; use std::{cell::RefCell, marker::PhantomData, rc::Rc}; use temporalio_common_wasm::{ - data_converters::PayloadConverter, + data_converters::DataConverter, protos::{coresdk::workflow_commands::WorkflowCommand, temporal::api::failure::v1::Failure}, }; @@ -209,13 +209,14 @@ where ::Input: Send, { let args = init.initialize_workflow.arguments.clone(); - let payload_converter = PayloadConverter::default(); + let data_converter = DataConverter::default(); + let payload_converter = data_converter.payload_converter().clone(); let base_ctx = BaseWorkflowContext::new( init.namespace, init.task_queue, init.run_id, init.initialize_workflow, - payload_converter.clone(), + data_converter, host, ); instantiate_workflow::(args, payload_converter, base_ctx).map_err(|err| { diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index b5613a497..334ec9d7b 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -26,11 +26,14 @@ 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::error::{ + ActivityExecutionError, ChildWorkflowExecutionError, ChildWorkflowSignalError, + ChildWorkflowStartError, +}; pub use workflow_context::{ - ActivityCloseTimeouts, ActivityExecutionError, ActivityOptions, BaseWorkflowContext, - CancellableFuture, ChildWorkflowExecutionError, ChildWorkflowOptions, ChildWorkflowSignalError, - ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, NexusOperationOptions, - ParentWorkflowInfo, RootWorkflowInfo, Signal, SignalData, + ActivityCloseTimeouts, ActivityOptions, BaseWorkflowContext, CancellableFuture, + ChildWorkflowOptions, ContinueAsNewOptions, ExternalWorkflowHandle, LocalActivityOptions, + NexusOperationOptions, ParentWorkflowInfo, RootWorkflowInfo, Signal, SignalData, StartChildWorkflowExecutionFailedCause, StartedChildWorkflow, SyncWorkflowContext, TimerOptions, WorkflowContext, WorkflowContextView, }; diff --git a/crates/workflow/src/runtime/instance.rs b/crates/workflow/src/runtime/instance.rs index c3077c6cd..ccf2ad14a 100644 --- a/crates/workflow/src/runtime/instance.rs +++ b/crates/workflow/src/runtime/instance.rs @@ -148,11 +148,24 @@ where } } - fn rejection_for_missing_update_handler(name: String) -> ActivationJobResult { - ActivationJobResult::UpdateRejected(Box::new(Failure { - message: format!("No update handler registered for update name {name}"), - ..Default::default() - })) + fn rejection_for_missing_update_handler(&self, name: String) -> ActivationJobResult { + ActivationJobResult::UpdateRejected(Box::new(self.workflow_error_to_failure( + WorkflowError::Execution(anyhow::anyhow!( + "No update handler registered for update name {name}" + )), + ))) + } + + fn workflow_error_to_failure(&self, err: WorkflowError) -> Failure { + use temporalio_common_wasm::error::{OutgoingError, OutgoingWorkflowError}; + let outgoing: OutgoingWorkflowError = match err { + WorkflowError::PayloadConversion(err) => OutgoingWorkflowError::from(err), + WorkflowError::Execution(err) => OutgoingWorkflowError::from(err), + }; + self.base_ctx.data_converter().to_failure( + &SerializationContextData::Workflow, + OutgoingError::Workflow(outgoing), + ) } fn next_routine_id(&mut self) -> RoutineId { @@ -196,9 +209,11 @@ where match validation { Some(Ok(())) => {} Some(Err(e)) => { - return ActivationJobResult::UpdateRejected(Box::new(e.into())); + return ActivationJobResult::UpdateRejected(Box::new( + self.workflow_error_to_failure(e), + )); } - None => return Self::rejection_for_missing_update_handler(name), + None => return self.rejection_for_missing_update_handler(name), } } @@ -225,7 +240,7 @@ where }), }) } else { - Self::rejection_for_missing_update_handler(name) + self.rejection_for_missing_update_handler(name) } } @@ -245,11 +260,10 @@ where .state(|wf| wf.dispatch_query(view, &query.query_type, &payloads, converter)) { Some(Ok(payload)) => Ok(payload), - None => Err(Failure { - message: format!("No query handler for '{}'", query.query_type), - ..Default::default() - }), - Some(Err(e)) => Err(e.into()), + None => Err(self.workflow_error_to_failure(WorkflowError::Execution( + anyhow::anyhow!("No query handler for '{}'", query.query_type), + ))), + Some(Err(e)) => Err(self.workflow_error_to_failure(e)), }, } } @@ -294,6 +308,7 @@ where } fn terminal_outcome_from_result( + &self, result: WorkflowResult, ) -> crate::runtime::types::TerminalOutcome { match result { @@ -308,10 +323,11 @@ where panic!("workflow instances must not explicitly return eviction") } Err(WorkflowTermination::Failed(err)) => { - crate::runtime::types::TerminalOutcome::Failed(Box::new(Failure { - message: format!("Workflow execution error: {err}"), - ..Default::default() - })) + let failure = self.base_ctx.data_converter().to_failure( + &SerializationContextData::Workflow, + temporalio_common_wasm::error::OutgoingError::Workflow(err), + ); + crate::runtime::types::TerminalOutcome::Failed(Box::new(failure)) } } } @@ -371,7 +387,7 @@ where made_progress, } => RoutinePollResult { completion: Some(RoutineCompletion::Main(MainRoutineCompletion::Terminal( - Box::new(Self::terminal_outcome_from_result(result)), + Box::new(self.terminal_outcome_from_result(result)), ))), made_progress, }, @@ -406,12 +422,7 @@ where result, made_progress, } => { - let result = result.map_err(|err| { - Box::new(Failure { - message: format!("Signal handler error: {err}"), - ..Default::default() - }) - }); + let result = result.map_err(|err| Box::new(self.workflow_error_to_failure(err))); Ok(RoutinePollResult { completion: Some(RoutineCompletion::Signal(result)), made_progress, @@ -448,7 +459,7 @@ where }, Err(err) => UpdateRoutineCompletion::Rejected { protocol_instance_id, - failure: Box::new(err.into()), + failure: Box::new(self.workflow_error_to_failure(err)), }, }; Ok(RoutinePollResult { diff --git a/crates/workflow/src/runtime/model.rs b/crates/workflow/src/runtime/model.rs index 026863310..9e9cc123f 100644 --- a/crates/workflow/src/runtime/model.rs +++ b/crates/workflow/src/runtime/model.rs @@ -3,12 +3,15 @@ use crate::{ runtime::types::ContinueAsNewRequest, workflow_context::{ - ActivityExecutionError, ChildWfCommon, ChildWorkflowExecutionError, - ChildWorkflowSignalError, NexusUnblockData, PendingChildWorkflow, StartedNexusOperation, + ChildWfCommon, NexusUnblockData, PendingChildWorkflow, StartedNexusOperation, }, }; use temporalio_common_wasm::{ WorkflowDefinition, + error::{ + ActivityExecutionError, ApplicationFailure, ChildWorkflowExecutionError, + ChildWorkflowSignalError, + }, protos::{ coresdk::{ activity_result::ActivityResolution, @@ -208,7 +211,7 @@ pub enum WorkflowTermination { #[error("Continue as new")] ContinueAsNew(Box), #[error("Workflow failed: {0}")] - Failed(#[source] anyhow::Error), + Failed(#[source] temporalio_common_wasm::error::OutgoingWorkflowError), } impl WorkflowTermination { @@ -216,31 +219,44 @@ impl WorkflowTermination { Self::ContinueAsNew(Box::new(can)) } - pub fn failed(err: impl Into) -> Self { + /// Construct a [`WorkflowTermination::Failed`] from an [`ApplicationFailure`]. + pub fn failed_application(err: ApplicationFailure) -> Self { Self::Failed(err.into()) } } impl From for WorkflowTermination { fn from(err: anyhow::Error) -> Self { - Self::Failed(err) + Self::Failed(err.into()) + } +} + +impl From for WorkflowTermination { + fn from(value: temporalio_common_wasm::data_converters::PayloadConversionError) -> Self { + Self::Failed(value.into()) } } impl From for WorkflowTermination { fn from(value: ActivityExecutionError) -> Self { - Self::failed(value) + Self::Failed(value.into()) } } impl From for WorkflowTermination { fn from(value: ChildWorkflowExecutionError) -> Self { - Self::failed(value) + Self::Failed(value.into()) } } impl From for WorkflowTermination { fn from(value: ChildWorkflowSignalError) -> Self { - Self::failed(value) + Self::Failed(value.into()) + } +} + +impl From for WorkflowTermination { + fn from(value: temporalio_common_wasm::error::ChildWorkflowStartError) -> Self { + Self::Failed(value.into()) } } diff --git a/crates/workflow/src/workflow_context.rs b/crates/workflow/src/workflow_context.rs index 3d9e8899d..db46111d0 100644 --- a/crates/workflow/src/workflow_context.rs +++ b/crates/workflow/src/workflow_context.rs @@ -93,6 +93,11 @@ impl BaseWorkflowContext { } } + /// Returns the [`DataConverter`] associated with this workflow's worker. + pub fn data_converter(&self) -> &DataConverter { + &self.inner.data_converter + } + pub(crate) fn record_patch(&self, patch_id: String, present: bool) { self.inner .shared From e7d83a4b1752003cf9ed70ec55a40c2d94cccfbf Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Fri, 8 May 2026 11:38:09 -0700 Subject: [PATCH 8/9] Initial WASM-snapshot PoC draft --- Cargo.toml | 3 + crates/sdk/Cargo.toml | 3 +- crates/sdk/src/lib.rs | 56 ++- crates/sdk/src/workflow_future.rs | 9 + crates/sdk/src/workflow_registry.rs | 4 + crates/sdk/src/workflow_wasm.rs | 544 ++++++++++++++++++++++++++- crates/workflow/src/runtime/guest.rs | 4 + 7 files changed, 606 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 26484d534..03667f5bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,9 @@ unreachable_pub = "warn" [workspace.lints.clippy] dbg_macro = "warn" +[patch.crates-io] +wasmtime = { path = "../wasmtime/crates/wasmtime" } + [profile.release-lto] inherits = "release" lto = true diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index ba69d2390..55879da9b 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -25,6 +25,7 @@ parking_lot = { version = "0.12" } prost = { workspace = true } prost-types = { workspace = true } serde = "1.0" +sha2 = { version = "0.10", optional = true } thiserror = "2" tokio = { version = "1.47", default-features = false, features = [ "rt", @@ -73,7 +74,7 @@ envconfig = ["temporalio-sdk-core/envconfig"] prometheus = ["temporalio-sdk-core/prometheus"] otel = ["temporalio-sdk-core/otel"] examples = ["serde/derive", "dep:serde_json", "envconfig"] -wasm-workflows = ["dep:wasmtime"] +wasm-workflows = ["dep:sha2", "dep:wasmtime"] [dependencies.serde_json] version = "1" diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index e369ed893..a9427f3c5 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -96,7 +96,7 @@ pub use temporalio_workflow::{ WorkflowTermination, }; #[cfg(feature = "wasm-workflows")] -pub use workflow_wasm::WasmWorkflowComponent; +pub use workflow_wasm::{WasmWorkflowComponent, WasmWorkflowSnapshotOptions}; use crate::{ activities::{ @@ -110,6 +110,8 @@ use crate::{ }; use anyhow::{Context, anyhow, bail}; use futures_util::{FutureExt, StreamExt, TryFutureExt, TryStreamExt}; +#[cfg(feature = "wasm-workflows")] +use std::rc::Rc; use std::{ any::{Any, TypeId}, cell::RefCell, @@ -172,6 +174,10 @@ pub struct WorkerOptions { #[builder(field)] wasm_workflow_components: Vec, + #[cfg(feature = "wasm-workflows")] + #[builder(field)] + wasm_workflow_snapshots: workflow_wasm::WasmWorkflowSnapshotOptions, + /// Set the deployment options for this worker. Defaults to a hash of the currently running /// executable. #[builder(default = def_build_id())] @@ -321,6 +327,23 @@ impl WorkerOptionsBuilder { self.wasm_workflow_components.push(component); self } + + /// Enable the experimental in-memory WASM workflow snapshot PoC. + #[cfg(feature = "wasm-workflows")] + pub fn enable_wasm_workflow_snapshots(mut self) -> Self { + self.wasm_workflow_snapshots = workflow_wasm::WasmWorkflowSnapshotOptions::enabled(); + self + } + + /// Enable the experimental in-memory WASM workflow snapshot PoC with explicit options. + #[cfg(feature = "wasm-workflows")] + pub fn enable_wasm_workflow_snapshots_with_options( + mut self, + options: WasmWorkflowSnapshotOptions, + ) -> Self { + self.wasm_workflow_snapshots = options; + self + } } // Needs to exist to avoid https://github.com/elastio/bon/issues/359 @@ -380,6 +403,23 @@ impl WorkerOptions { self } + /// Enable the experimental in-memory WASM workflow snapshot PoC. + #[cfg(feature = "wasm-workflows")] + pub fn enable_wasm_workflow_snapshots(&mut self) -> &mut Self { + self.wasm_workflow_snapshots = workflow_wasm::WasmWorkflowSnapshotOptions::enabled(); + self + } + + /// Enable the experimental in-memory WASM workflow snapshot PoC with explicit options. + #[cfg(feature = "wasm-workflows")] + pub fn enable_wasm_workflow_snapshots_with_options( + &mut self, + options: WasmWorkflowSnapshotOptions, + ) -> &mut Self { + self.wasm_workflow_snapshots = options; + self + } + /// Returns all the registered workflows by cloning the current set. pub fn workflows(&self) -> WorkflowDefinitions { self.workflows.clone() @@ -446,6 +486,8 @@ struct WorkflowHalf { workflow_definitions: WorkflowDefinitions, workflow_removed_from_map: Notify, detect_nondeterministic_futures: bool, + #[cfg(feature = "wasm-workflows")] + wasm_runtime: Rc, } struct WorkflowData { /// Channel used to send the workflow activations @@ -503,6 +545,12 @@ impl Worker { let mut me = Self::new_from_core_definitions(worker, data_converter, acts, wfs); me.set_detect_nondeterministic_futures(options.detect_nondeterministic_futures); #[cfg(feature = "wasm-workflows")] + { + me.workflow_half.wasm_runtime = Rc::new(workflow_wasm::WasmRuntimeServices::new( + options.wasm_workflow_snapshots, + )); + } + #[cfg(feature = "wasm-workflows")] me.workflow_half .workflow_definitions .register_wasm_workflows(wasm_components)?; @@ -527,6 +575,10 @@ impl Worker { workflow_definitions: workflows, workflow_removed_from_map: Default::default(), detect_nondeterministic_futures: false, + #[cfg(feature = "wasm-workflows")] + wasm_runtime: Rc::new(workflow_wasm::WasmRuntimeServices::new( + workflow_wasm::WasmWorkflowSnapshotOptions::disabled(), + )), }, activity_half: ActivityHalf { activities, @@ -825,6 +877,8 @@ impl WorkflowHalf { completions_tx.clone(), common.data_converter.clone(), self.detect_nondeterministic_futures, + #[cfg(feature = "wasm-workflows")] + Some(self.wasm_runtime.clone()), ) { Ok(result) => result, Err(e) => { diff --git a/crates/sdk/src/workflow_future.rs b/crates/sdk/src/workflow_future.rs index d62b6598c..e620012ad 100644 --- a/crates/sdk/src/workflow_future.rs +++ b/crates/sdk/src/workflow_future.rs @@ -61,6 +61,9 @@ pub(crate) fn start_workflow( outgoing_completions: UnboundedSender, data_converter: DataConverter, detect_nondeterministic: bool, + #[cfg(feature = "wasm-workflows")] wasm_runtime: Option< + Rc, + >, ) -> Result< ( impl Future> + use<>, @@ -83,6 +86,8 @@ pub(crate) fn start_workflow( init_workflow_job, data_converter: data_converter.clone(), host: host.clone(), + #[cfg(feature = "wasm-workflows")] + wasm_runtime, }) .context("Failed to create workflow execution")?; @@ -632,6 +637,10 @@ impl Future for WorkflowFuture { } activation_cmds.extend(self.host.take_commands()); + if let Err(e) = self.execution.checkpoint_activation() { + self.fail_wft(run_id.clone(), anyhow!(e.message), None); + continue 'activations; + } self.send_completion(run_id, activation_cmds); } } diff --git a/crates/sdk/src/workflow_registry.rs b/crates/sdk/src/workflow_registry.rs index f13c41e9c..698ebc6e1 100644 --- a/crates/sdk/src/workflow_registry.rs +++ b/crates/sdk/src/workflow_registry.rs @@ -30,6 +30,8 @@ pub(crate) struct WorkflowExecutionInput { pub init_workflow_job: InitializeWorkflow, pub data_converter: DataConverter, pub host: Rc, + #[cfg(feature = "wasm-workflows")] + pub wasm_runtime: Option>, } /// Creates workflow execution instances from activation input payloads and context. @@ -153,6 +155,8 @@ fn workflow_input_parts( init_workflow_job, data_converter, host, + #[cfg(feature = "wasm-workflows")] + wasm_runtime: _, } = input; let payloads = init_workflow_job.arguments.clone(); let payload_converter = data_converter.payload_converter().clone(); diff --git a/crates/sdk/src/workflow_wasm.rs b/crates/sdk/src/workflow_wasm.rs index 621aa3c8b..ec810a0de 100644 --- a/crates/sdk/src/workflow_wasm.rs +++ b/crates/sdk/src/workflow_wasm.rs @@ -1,7 +1,8 @@ -use std::{fs, path::PathBuf, rc::Rc, sync::Arc}; +use std::{cell::Cell, fs, path::PathBuf, rc::Rc, sync::Arc}; use anyhow::{Context, bail}; use prost::Message; +use sha2::{Digest, Sha256}; use temporalio_common::protos::{ coresdk::workflow_commands::WorkflowCommand, temporal::api::failure::v1::Failure, }; @@ -16,7 +17,7 @@ use temporalio_workflow::runtime::{ }, }; use wasmtime::{ - Config, Engine, Store, + Config, Engine, Store, TemporalStoreSnapshot, component::{Component, HasSelf, Linker, ResourceAny}, }; @@ -34,6 +35,221 @@ use self::{ temporal::workflow_runtime::{types as wit_types, workflow_host as wit_host}, }; +const SNAPSHOT_FORMAT_VERSION: u32 = 1; +const SYNC_WIT_ABI_VERSION: &str = "temporal:workflow-runtime@0.1.0/sync-poll"; +const WASMTIME_COMPATIBILITY_KEY: &str = + "wasmtime-44.0.1+temporal-snapshot-poc/component-model/default-config"; +const DEFAULT_SNAPSHOT_CONTEXT_POOL_SIZE: usize = 2; +#[allow(dead_code)] +const WASM_PAGE_SIZE: usize = 64 * 1024; + +/// Options for the experimental WASM workflow snapshot proof-of-concept. +#[derive(Clone, Debug)] +pub struct WasmWorkflowSnapshotOptions { + enabled: bool, + context_pool_size: usize, +} + +impl Default for WasmWorkflowSnapshotOptions { + fn default() -> Self { + Self::disabled() + } +} + +impl WasmWorkflowSnapshotOptions { + /// Return options with snapshotting disabled. + pub fn disabled() -> Self { + Self { + enabled: false, + context_pool_size: DEFAULT_SNAPSHOT_CONTEXT_POOL_SIZE, + } + } + + /// Return options with snapshotting enabled and the default context pool size. + pub fn enabled() -> Self { + Self { + enabled: true, + context_pool_size: DEFAULT_SNAPSHOT_CONTEXT_POOL_SIZE, + } + } + + /// Set the maximum number of active WASM execution contexts. + pub fn with_context_pool_size(mut self, context_pool_size: usize) -> Self { + self.context_pool_size = context_pool_size.max(1); + self + } + + fn is_enabled(&self) -> bool { + self.enabled + } +} + +#[derive(Debug)] +pub(crate) struct WasmRuntimeServices { + snapshot_options: WasmWorkflowSnapshotOptions, + context_pool: WasmExecutionContextPool, +} + +impl WasmRuntimeServices { + pub(crate) fn new(snapshot_options: WasmWorkflowSnapshotOptions) -> Self { + Self { + context_pool: WasmExecutionContextPool::new(snapshot_options.context_pool_size), + snapshot_options, + } + } + + fn snapshot_options(&self) -> &WasmWorkflowSnapshotOptions { + &self.snapshot_options + } +} + +#[derive(Debug)] +struct WasmExecutionContextPool { + capacity: usize, + leased: Cell, +} + +impl WasmExecutionContextPool { + fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + leased: Cell::new(0), + } + } + + fn try_acquire( + runtime: &Rc, + ) -> Result { + let leased = runtime.context_pool.leased.get(); + if leased >= runtime.context_pool.capacity { + bail!( + "WASM workflow snapshot execution context pool exhausted: {leased}/{} leased", + runtime.context_pool.capacity + ); + } + runtime.context_pool.leased.set(leased + 1); + Ok(WasmExecutionContextLease { + runtime: runtime.clone(), + }) + } + + fn release(&self) { + let leased = self.leased.get(); + debug_assert!(leased > 0, "releasing an unleased WASM execution context"); + self.leased.set(leased.saturating_sub(1)); + } +} + +#[derive(Debug)] +struct WasmExecutionContextLease { + runtime: Rc, +} + +impl Drop for WasmExecutionContextLease { + fn drop(&mut self) { + self.runtime.context_pool.release(); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WasmSnapshotCapabilityFlags { + sync_boundary_only: bool, + guest_threads_or_shared_memory: bool, + sparse_host_resources: bool, +} + +impl Default for WasmSnapshotCapabilityFlags { + fn default() -> Self { + Self { + sync_boundary_only: true, + guest_threads_or_shared_memory: false, + sparse_host_resources: true, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WasmSnapshotMetadata { + snapshot_format_version: u32, + wit_abi_version: String, + wasmtime_compatibility_key: String, + component_id: String, + workflow_type: String, + run_id: String, + source_wasm_sha256: String, + compiled_component_key: String, + capabilities: WasmSnapshotCapabilityFlags, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WasmWorkflowSnapshot { + metadata: WasmSnapshotMetadata, + store: TemporalStoreSnapshot, + resources: Vec, +} + +#[cfg(test)] +#[derive(Clone, Debug, PartialEq, Eq)] +struct SparseMemorySnapshot { + memory_index: u32, + current_pages: u64, + nonzero_pages: Vec, +} + +#[cfg(test)] +impl SparseMemorySnapshot { + fn from_memory_bytes(memory_index: u32, current_pages: u64, bytes: &[u8]) -> Self { + let mut nonzero_pages = Vec::new(); + for (page_index, chunk) in bytes.chunks(WASM_PAGE_SIZE).enumerate() { + if chunk.iter().any(|byte| *byte != 0) { + nonzero_pages.push(SparseMemoryPage { + page_index: page_index as u64, + bytes: chunk.to_vec(), + }); + } + } + Self { + memory_index, + current_pages, + nonzero_pages, + } + } + + #[allow(dead_code)] + fn restore_bytes(&self) -> Vec { + let mut bytes = vec![0; self.current_pages as usize * WASM_PAGE_SIZE]; + for page in &self.nonzero_pages { + let start = page.page_index as usize * WASM_PAGE_SIZE; + let end = start + page.bytes.len(); + bytes[start..end].copy_from_slice(&page.bytes); + } + bytes + } +} + +#[cfg(test)] +#[derive(Clone, Debug, PartialEq, Eq)] +struct SparseMemoryPage { + page_index: u64, + bytes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct WasmResourceSnapshot { + resource_id: u64, + type_name: String, +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + /// A prebuilt WebAssembly component that exports one or more Temporal workflows. #[derive(Clone, Debug)] pub struct WasmWorkflowComponent { @@ -103,6 +319,9 @@ impl WorkflowDefinitions { struct CompiledWasmWorkflowModule { engine: Engine, component: Component, + component_id: String, + source_wasm_sha256: String, + compiled_component_key: String, definitions: Vec, } @@ -117,10 +336,13 @@ impl CompiledWasmWorkflowModule { .into(), WasmWorkflowComponentSource::Bytes(bytes) => bytes.clone(), }; + let component_id = component.component_id; + let source_wasm_sha256 = sha256_hex(bytes.as_ref()); + let compiled_component_key = + format!("{WASMTIME_COMPATIBILITY_KEY}/source-sha256:{source_wasm_sha256}"); let component = Component::new(&engine, bytes.as_ref()).map_err(|err| { anyhow::Error::msg(format!( - "Failed to compile WASM component {}: {err}", - component.component_id + "Failed to compile WASM component {component_id}: {err}" )) })?; let definitions = Self::read_definitions(&engine, &component)?; @@ -130,6 +352,9 @@ impl CompiledWasmWorkflowModule { Ok(Self { engine, component, + component_id, + source_wasm_sha256, + compiled_component_key, definitions, }) } @@ -177,19 +402,75 @@ impl CompiledWasmWorkflowModule { } fn instantiate( - &self, + self: &Arc, input: WorkflowExecutionInput, ) -> Result, anyhow::Error> { + let snapshot_options = input + .wasm_runtime + .as_ref() + .map(|runtime| runtime.snapshot_options().clone()) + .unwrap_or_else(WasmWorkflowSnapshotOptions::disabled); + let context_lease = if snapshot_options.is_enabled() { + let runtime = input + .wasm_runtime + .as_ref() + .context("WASM workflow snapshotting requires runtime services")?; + Some(WasmExecutionContextPool::try_acquire(runtime)?) + } else { + None + }; + let rehydrate_input = WasmWorkflowRehydrateInput { + namespace: input.namespace.clone(), + task_queue: input.task_queue.clone(), + run_id: input.run_id.clone(), + workflow_type: input.init_workflow_job.workflow_type.clone(), + initialize_workflow: input.init_workflow_job.encode_to_vec(), + host: input.host.clone(), + runtime: input.wasm_runtime.clone(), + }; + let snapshot_metadata = self.snapshot_metadata(&rehydrate_input); + let live = self.instantiate_live_context(&rehydrate_input, input.host, context_lease)?; + + Ok(Box::new(WasmWorkflowInstance { + module: self.clone(), + live: Some(live), + snapshot_metadata, + snapshot_options, + rehydrate_input, + last_snapshot: None, + })) + } + + fn snapshot_metadata(&self, input: &WasmWorkflowRehydrateInput) -> WasmSnapshotMetadata { + WasmSnapshotMetadata { + snapshot_format_version: SNAPSHOT_FORMAT_VERSION, + wit_abi_version: SYNC_WIT_ABI_VERSION.to_string(), + wasmtime_compatibility_key: WASMTIME_COMPATIBILITY_KEY.to_string(), + component_id: self.component_id.clone(), + workflow_type: input.workflow_type.clone(), + run_id: input.run_id.clone(), + source_wasm_sha256: self.source_wasm_sha256.clone(), + compiled_component_key: self.compiled_component_key.clone(), + capabilities: WasmSnapshotCapabilityFlags::default(), + } + } + + fn instantiate_live_context( + &self, + input: &WasmWorkflowRehydrateInput, + host: Rc, + context_lease: Option, + ) -> Result { let mut linker = Linker::new(&self.engine); WorkflowModule::add_to_linker::<_, HasSelf<_>>(&mut linker, |data| data)?; - let mut store = Store::new(&self.engine, WasmWorkflowHostState::new(input.host.clone())); + let mut store = Store::new(&self.engine, WasmWorkflowHostState::new(host)); let module = WorkflowModule::instantiate(&mut store, &self.component, &linker)?; let guest = module.temporal_workflow_runtime_workflow_guest(); let workflow_init = wit_types::WorkflowInit { namespace: input.namespace.clone(), task_queue: input.task_queue.clone(), run_id: input.run_id.clone(), - initialize_workflow: input.init_workflow_job.encode_to_vec(), + initialize_workflow: input.initialize_workflow.clone(), }; let workflow_instance = guest .call_instantiate_workflow(&mut store, &workflow_init) @@ -204,18 +485,39 @@ impl CompiledWasmWorkflowModule { )) })?; - Ok(Box::new(WasmWorkflowInstance { + Ok(WasmLiveWorkflowContext { store, guest: guest.clone(), workflow_instance, - })) + _context_lease: context_lease, + }) } } +struct WasmWorkflowRehydrateInput { + namespace: String, + task_queue: String, + run_id: String, + workflow_type: String, + initialize_workflow: Vec, + host: Rc, + runtime: Option>, +} + struct WasmWorkflowInstance { + module: Arc, + live: Option, + snapshot_metadata: WasmSnapshotMetadata, + snapshot_options: WasmWorkflowSnapshotOptions, + rehydrate_input: WasmWorkflowRehydrateInput, + last_snapshot: Option, +} + +struct WasmLiveWorkflowContext { store: Store, guest: wit_guest::Guest, workflow_instance: ResourceAny, + _context_lease: Option, } impl WorkflowInstance for WasmWorkflowInstance { @@ -223,9 +525,10 @@ impl WorkflowInstance for WasmWorkflowInstance { &mut self, activation: WorkflowActivation, ) -> Result { - let result = self.guest.workflow_instance().call_activate( - &mut self.store, - self.workflow_instance, + let live = self.live_mut()?; + let result = live.guest.workflow_instance().call_activate( + &mut live.store, + live.workflow_instance, &activation.encode_to_vec(), ); trap_to_failure(result, |result| ActivationResult { @@ -277,9 +580,10 @@ impl WorkflowInstance for WasmWorkflowInstance { routine_id: u64, _waker: &std::task::Waker, ) -> Result { - let result = self.guest.workflow_instance().call_poll_routine( - &mut self.store, - self.workflow_instance, + let live = self.live_mut()?; + let result = live.guest.workflow_instance().call_poll_routine( + &mut live.store, + live.workflow_instance, routine_id, ); trap_to_failure(result, |result| RoutinePollResult { @@ -337,6 +641,125 @@ impl WorkflowInstance for WasmWorkflowInstance { made_progress: result.made_progress, }) } + + fn checkpoint_activation(&mut self) -> Result<(), WorkflowFailure> { + if !self.snapshot_options.is_enabled() { + return Ok(()); + } + + match self.snapshot_quiescent() { + Ok(snapshot) => { + self.last_snapshot = Some(snapshot); + self.live.take(); + Ok(()) + } + Err(err) => Err(Box::new(Failure { + message: err.to_string(), + ..Default::default() + })), + } + } +} + +impl WasmWorkflowInstance { + fn live_mut(&mut self) -> Result<&mut WasmLiveWorkflowContext, WorkflowFailure> { + if self.live.is_none() { + self.rehydrate_live_context().map_err(|err| { + Box::new(Failure { + message: err.to_string(), + ..Default::default() + }) + })?; + } + + self.live.as_mut().ok_or_else(|| { + Box::new(Failure { + message: + "WASM workflow snapshot rehydration did not produce a live execution context" + .to_string(), + ..Default::default() + }) + }) + } + + fn snapshot_quiescent(&mut self) -> Result { + let live = self + .live + .as_mut() + .context("WASM workflow has no live execution context to snapshot")?; + let store = live.store.temporal_snapshot().map_err(|err| { + anyhow::Error::msg(format!("failed to snapshot WASM workflow store: {err}")) + })?; + Ok(WasmWorkflowSnapshot { + metadata: self.snapshot_metadata.clone(), + store, + resources: Vec::new(), + }) + } + + fn rehydrate_live_context(&mut self) -> Result<(), anyhow::Error> { + let snapshot = self + .last_snapshot + .as_ref() + .context("WASM workflow has no snapshot to rehydrate from")?; + self.ensure_snapshot_compatible(snapshot)?; + let context_lease = if self.snapshot_options.is_enabled() { + let runtime = self + .rehydrate_input + .runtime + .as_ref() + .context("WASM workflow snapshot rehydration requires runtime services")?; + Some(WasmExecutionContextPool::try_acquire(runtime)?) + } else { + None + }; + let noop_host: Rc = Rc::new(NoopWorkflowHost); + let mut live = self.module.instantiate_live_context( + &self.rehydrate_input, + noop_host, + context_lease, + )?; + live.store + .temporal_restore_snapshot(&snapshot.store) + .map_err(|err| { + anyhow::Error::msg(format!( + "failed to restore WASM workflow store snapshot: {err}" + )) + })?; + live.store.data_mut().host = self.rehydrate_input.host.clone(); + self.live = Some(live); + Ok(()) + } + + fn ensure_snapshot_compatible( + &self, + snapshot: &WasmWorkflowSnapshot, + ) -> Result<(), anyhow::Error> { + let current = &self.snapshot_metadata; + let metadata = &snapshot.metadata; + if metadata.snapshot_format_version != SNAPSHOT_FORMAT_VERSION { + bail!( + "unsupported WASM workflow snapshot format version {}", + metadata.snapshot_format_version + ); + } + if metadata.wit_abi_version != current.wit_abi_version + || metadata.wasmtime_compatibility_key != current.wasmtime_compatibility_key + || metadata.component_id != current.component_id + || metadata.workflow_type != current.workflow_type + || metadata.run_id != current.run_id + || metadata.source_wasm_sha256 != current.source_wasm_sha256 + || metadata.compiled_component_key != current.compiled_component_key + { + bail!( + "WASM workflow snapshot metadata does not match the currently registered component" + ); + } + if !snapshot.resources.is_empty() { + bail!("WASM workflow snapshot contains host resources that this PoC cannot restore"); + } + Ok(()) + } } struct WasmWorkflowHostState { @@ -393,3 +816,94 @@ fn decode_proto(bytes: Vec) -> M { panic!("failed to decode {n} from WASM boundary bytes: {err}") }) } + +#[cfg(test)] +mod tests { + use super::*; + use wasmtime::{Instance, Module, Val}; + + #[test] + fn sha256_hex_uses_expected_encoding() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn sparse_memory_snapshot_omits_zero_pages_and_restores_bytes() { + let mut memory = vec![0; WASM_PAGE_SIZE * 3]; + memory[7] = 1; + memory[WASM_PAGE_SIZE * 2 + 13] = 2; + + let snapshot = SparseMemorySnapshot::from_memory_bytes(0, 3, &memory); + + assert_eq!(snapshot.current_pages, 3); + assert_eq!(snapshot.nonzero_pages.len(), 2); + assert_eq!(snapshot.nonzero_pages[0].page_index, 0); + assert_eq!(snapshot.nonzero_pages[1].page_index, 2); + assert_eq!(snapshot.restore_bytes(), memory); + } + + #[test] + fn snapshot_options_clamp_pool_size() { + let opts = WasmWorkflowSnapshotOptions::enabled().with_context_pool_size(0); + let runtime = Rc::new(WasmRuntimeServices::new(opts)); + + let _lease = WasmExecutionContextPool::try_acquire(&runtime).unwrap(); + assert!(WasmExecutionContextPool::try_acquire(&runtime).is_err()); + } + + #[test] + fn vendored_wasmtime_snapshot_restores_store_state() { + let engine = Engine::default(); + let module = Module::new( + &engine, + r#" + (module + (memory (export "memory") 1 2) + (global (export "g") (mut i32) (i32.const 7)) + (func (export "write") (param i32) (param i32) + local.get 0 + local.get 1 + i32.store8) + ) + "#, + ) + .unwrap(); + + let mut source_store = Store::new(&engine, ()); + let source_instance = Instance::new(&mut source_store, &module, &[]).unwrap(); + source_instance + .get_typed_func::<(i32, i32), ()>(&mut source_store, "write") + .unwrap() + .call(&mut source_store, (17, 99)) + .unwrap(); + source_instance + .get_global(&mut source_store, "g") + .unwrap() + .set(&mut source_store, Val::I32(42)) + .unwrap(); + + let snapshot = source_store.temporal_snapshot().unwrap(); + assert_eq!(snapshot.memories[0].nonzero_pages.len(), 1); + + let mut target_store = Store::new(&engine, ()); + let target_instance = Instance::new(&mut target_store, &module, &[]).unwrap(); + target_store.temporal_restore_snapshot(&snapshot).unwrap(); + + let target_memory = target_instance + .get_memory(&mut target_store, "memory") + .unwrap(); + assert_eq!(target_memory.data(&target_store)[17], 99); + assert_eq!( + target_instance + .get_global(&mut target_store, "g") + .unwrap() + .get(&mut target_store) + .i32() + .unwrap(), + 42 + ); + } +} diff --git a/crates/workflow/src/runtime/guest.rs b/crates/workflow/src/runtime/guest.rs index ed2fbca0b..975a9dab5 100644 --- a/crates/workflow/src/runtime/guest.rs +++ b/crates/workflow/src/runtime/guest.rs @@ -19,4 +19,8 @@ pub trait WorkflowInstance { routine_id: RoutineId, waker: &Waker, ) -> Result; + /// Called after an activation has been fully driven and before the completion is reported. + fn checkpoint_activation(&mut self) -> Result<(), WorkflowFailure> { + Ok(()) + } } From b8de7d146f6dbda5db8286751af7c84451744859 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 13 May 2026 12:42:21 -0500 Subject: [PATCH 9/9] Add WASM workflow snapshot rehydration tests Pin Wasmtime to the Temporal fork revision that provides the experimental store snapshot API. Update the WASM workflow cache snapshot path to use the generic Wasmtime snapshot/restore APIs and preserve the workflow-instance resource representation across rehydration. Extend the sample WASM workflow component with timer-based workflows that exercise snapshot rehydration. Add an explicit WAT-backed funcref table mutation helper and integration coverage that verifies the final component contains table.get/table.set/call_indirect and that the mutated funcref table state survives snapshot restore. --- Cargo.toml | 3 +- crates/sdk-core/Cargo.toml | 1 + crates/sdk-core/tests/wasm_workflow_tests.rs | 133 +++++++++++ crates/sdk/src/workflow_wasm.rs | 59 ++++- samples/wasm-workflows/hello/Cargo.toml | 5 + samples/wasm-workflows/hello/build.rs | 216 ++++++++++++++++++ .../hello/src/funcref_table_mutation.wat | 23 ++ samples/wasm-workflows/hello/src/lib.rs | 71 +++++- 8 files changed, 499 insertions(+), 12 deletions(-) create mode 100644 samples/wasm-workflows/hello/build.rs create mode 100644 samples/wasm-workflows/hello/src/funcref_table_mutation.wat diff --git a/Cargo.toml b/Cargo.toml index 03667f5bb..a28dbb2c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,8 @@ unreachable_pub = "warn" dbg_macro = "warn" [patch.crates-io] -wasmtime = { path = "../wasmtime/crates/wasmtime" } +# Fork branch: temporal-snapshotting +wasmtime = { git = "https://github.com/temporalio/wasmtime", rev = "2d47ad7a295d8a6d7be24aaff6e56b0bb742a922" } [profile.release-lto] inherits = "release" diff --git a/crates/sdk-core/Cargo.toml b/crates/sdk-core/Cargo.toml index 4c48cd911..3da91bac3 100644 --- a/crates/sdk-core/Cargo.toml +++ b/crates/sdk-core/Cargo.toml @@ -168,6 +168,7 @@ tokio-stream = { version = "0.1", default-features = false, features = ["net"] } tonic = { workspace = true, default-features = false, features = ["router"] } tracing-subscriber = { version = "0.3", default-features = false } trybuild = { version = "1.0", features = ["diff"] } +wasmparser = "=0.246.2" [[bin]] name = "histfetch" diff --git a/crates/sdk-core/tests/wasm_workflow_tests.rs b/crates/sdk-core/tests/wasm_workflow_tests.rs index 17890cfce..4c2b1e7e7 100644 --- a/crates/sdk-core/tests/wasm_workflow_tests.rs +++ b/crates/sdk-core/tests/wasm_workflow_tests.rs @@ -14,9 +14,12 @@ use temporalio_common::{ }; use temporalio_sdk::WasmWorkflowComponent; use tokio::process::Command; +use wasmparser::{Operator, Parser, Payload}; const WASM_COMPONENT_ID: &str = "hello-workflow-component"; const WASM_WORKFLOW_TYPE: &str = "HelloWorkflow"; +const WASM_TIMER_WORKFLOW_TYPE: &str = "TimerWorkflow"; +const WASM_FUNCREF_WORKFLOW_TYPE: &str = "FuncrefWorkflow"; #[tokio::test] async fn wasm_workflow_component_executes() { @@ -40,6 +43,104 @@ async fn wasm_workflow_component_executes_from_bytes() { run_hello_workflow("wasm_workflow_component_executes_from_bytes", component).await; } +#[tokio::test] +async fn wasm_workflow_snapshot_rehydrates_after_timer() { + let component_path = build_wasm_hello_component().await; + let component = WasmWorkflowComponent::from_file(WASM_COMPONENT_ID, component_path) + .expect("sample WASM component should be loadable"); + run_timer_based_workflow( + "wasm_workflow_snapshot_rehydrates_after_timer", + component, + WASM_TIMER_WORKFLOW_TYPE, + "Timer fired for workflow!", + true, + ) + .await; +} + +#[tokio::test] +async fn wasm_workflow_snapshot_rehydrates_funcref_across_timer() { + let component_path = build_wasm_hello_component().await; + assert_component_contains_funcref_table_mutation(&component_path); + let component = WasmWorkflowComponent::from_file(WASM_COMPONENT_ID, component_path) + .expect("sample WASM component should be loadable"); + run_timer_based_workflow( + "wasm_workflow_snapshot_rehydrates_funcref_across_timer", + component, + WASM_FUNCREF_WORKFLOW_TYPE, + "Funcref restored for workflow! table=22->22", + true, + ) + .await; +} + +#[tokio::test] +async fn wasm_workflow_timer_executes_without_snapshots() { + let component_path = build_wasm_hello_component().await; + let component = WasmWorkflowComponent::from_file(WASM_COMPONENT_ID, component_path) + .expect("sample WASM component should be loadable"); + run_timer_based_workflow( + "wasm_workflow_timer_executes_without_snapshots", + component, + WASM_TIMER_WORKFLOW_TYPE, + "Timer fired for workflow!", + false, + ) + .await; +} + +async fn run_timer_based_workflow( + test_name: &'static str, + component: WasmWorkflowComponent, + workflow_type: &'static str, + expected_result: &'static str, + enable_snapshots: bool, +) { + let mut starter = CoreWfStarter::new(test_name); + starter.sdk_config.task_types = WorkerTaskTypes::workflow_only(); + starter.sdk_config.max_cached_workflows = 1; + starter.sdk_config.register_wasm_workflow(component); + if enable_snapshots { + starter.sdk_config.enable_wasm_workflow_snapshots(); + } + + let mut worker = starter.worker().await; + let client = starter.get_client().await; + let submitter = worker.get_submitter_handle(); + let shutdown_handle = worker.inner_mut().shutdown_handle(); + let payload_converter = PayloadConverter::default(); + let input = RawValue::from_value(&"workflow", &payload_converter); + let workflow_id = starter.get_wf_id().to_owned(); + + let mut start_options = + WorkflowStartOptions::new(starter.get_task_queue().to_owned(), workflow_id.clone()).build(); + start_options.execution_timeout = Some(Duration::from_secs(60)); + let client_task = async { + submitter + .submit_wf(workflow_type, input.payloads, start_options) + .await + .expect("WASM workflow should start"); + let result = client + .get_workflow_handle::(&workflow_id) + .get_result(Default::default()) + .await; + shutdown_handle(); + let result = result.expect("WASM workflow result should be available"); + let greeting: String = result.to_value(&payload_converter); + assert_eq!(greeting, expected_result); + }; + tokio::join!( + async { + worker + .inner_mut() + .run() + .await + .expect("WASM worker should run"); + }, + client_task + ); +} + async fn run_hello_workflow(test_name: &'static str, component: WasmWorkflowComponent) { let mut starter = CoreWfStarter::new(test_name); starter.sdk_config.task_types = WorkerTaskTypes::workflow_only(); @@ -72,6 +173,38 @@ async fn run_hello_workflow(test_name: &'static str, component: WasmWorkflowComp assert_eq!(greeting, "Hello, workflow!"); } +fn assert_component_contains_funcref_table_mutation(component_path: &PathBuf) { + let bytes = std::fs::read(component_path).expect("WASM component should be readable"); + let mut table_get = 0; + let mut table_set = 0; + let mut call_indirect = 0; + for payload in Parser::new(0).parse_all(&bytes) { + if let Payload::CodeSectionEntry(body) = + payload.expect("WASM component should parse for instruction verification") + { + let mut operators = body + .get_operators_reader() + .expect("WASM function body should parse"); + while !operators.eof() { + match operators + .read() + .expect("WASM operator should parse for instruction verification") + { + Operator::TableGet { .. } => table_get += 1, + Operator::TableSet { .. } => table_set += 1, + Operator::CallIndirect { .. } => call_indirect += 1, + _ => {} + } + } + } + } + + assert!( + table_get > 0 && table_set > 0 && call_indirect > 0, + "WASM component should contain dynamic funcref table mutation instructions; found table.get={table_get}, table.set={table_set}, call_indirect={call_indirect}" + ); +} + async fn build_wasm_hello_component() -> PathBuf { let sample_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .ancestors() diff --git a/crates/sdk/src/workflow_wasm.rs b/crates/sdk/src/workflow_wasm.rs index ec810a0de..0432d606a 100644 --- a/crates/sdk/src/workflow_wasm.rs +++ b/crates/sdk/src/workflow_wasm.rs @@ -17,7 +17,7 @@ use temporalio_workflow::runtime::{ }, }; use wasmtime::{ - Config, Engine, Store, TemporalStoreSnapshot, + Config, Engine, Store, StoreSnapshot, component::{Component, HasSelf, Linker, ResourceAny}, }; @@ -40,6 +40,9 @@ const SYNC_WIT_ABI_VERSION: &str = "temporal:workflow-runtime@0.1.0/sync-poll"; const WASMTIME_COMPATIBILITY_KEY: &str = "wasmtime-44.0.1+temporal-snapshot-poc/component-model/default-config"; const DEFAULT_SNAPSHOT_CONTEXT_POOL_SIZE: usize = 2; +const WORKFLOW_INSTANCE_RESOURCE_ID: u64 = 0; +const WORKFLOW_INSTANCE_RESOURCE_TYPE: &str = + "temporal:workflow-runtime/workflow-guest.workflow-instance"; #[allow(dead_code)] const WASM_PAGE_SIZE: usize = 64 * 1024; @@ -184,7 +187,7 @@ struct WasmSnapshotMetadata { #[derive(Clone, Debug, PartialEq, Eq)] struct WasmWorkflowSnapshot { metadata: WasmSnapshotMetadata, - store: TemporalStoreSnapshot, + store: StoreSnapshot, resources: Vec, } @@ -238,6 +241,7 @@ struct SparseMemoryPage { struct WasmResourceSnapshot { resource_id: u64, type_name: String, + resource_rep: u32, } fn sha256_hex(bytes: &[u8]) -> String { @@ -687,13 +691,23 @@ impl WasmWorkflowInstance { .live .as_mut() .context("WASM workflow has no live execution context to snapshot")?; - let store = live.store.temporal_snapshot().map_err(|err| { + let store = live.store.snapshot().map_err(|err| { anyhow::Error::msg(format!("failed to snapshot WASM workflow store: {err}")) })?; + let workflow_instance_rep = live + .workflow_instance + .resource_rep(&mut live.store) + .map_err(|err| { + anyhow::Error::msg(format!("failed to snapshot WASM workflow resource: {err}")) + })?; Ok(WasmWorkflowSnapshot { metadata: self.snapshot_metadata.clone(), store, - resources: Vec::new(), + resources: vec![WasmResourceSnapshot { + resource_id: WORKFLOW_INSTANCE_RESOURCE_ID, + type_name: WORKFLOW_INSTANCE_RESOURCE_TYPE.to_string(), + resource_rep: workflow_instance_rep, + }], }) } @@ -720,12 +734,20 @@ impl WasmWorkflowInstance { context_lease, )?; live.store - .temporal_restore_snapshot(&snapshot.store) + .restore_snapshot(&snapshot.store) .map_err(|err| { anyhow::Error::msg(format!( "failed to restore WASM workflow store snapshot: {err}" )) })?; + let workflow_instance_resource = self.workflow_instance_resource(snapshot)?; + live.workflow_instance + .set_resource_rep(&mut live.store, workflow_instance_resource.resource_rep) + .map_err(|err| { + anyhow::Error::msg(format!( + "failed to restore WASM workflow resource snapshot: {err}" + )) + })?; live.store.data_mut().host = self.rehydrate_input.host.clone(); self.live = Some(live); Ok(()) @@ -755,11 +777,28 @@ impl WasmWorkflowInstance { "WASM workflow snapshot metadata does not match the currently registered component" ); } - if !snapshot.resources.is_empty() { - bail!("WASM workflow snapshot contains host resources that this PoC cannot restore"); - } + self.workflow_instance_resource(snapshot)?; Ok(()) } + + fn workflow_instance_resource<'a>( + &self, + snapshot: &'a WasmWorkflowSnapshot, + ) -> Result<&'a WasmResourceSnapshot, anyhow::Error> { + if snapshot.resources.len() != 1 { + bail!( + "WASM workflow snapshot must contain exactly one workflow-instance resource, found {}", + snapshot.resources.len() + ); + } + let resource = &snapshot.resources[0]; + if resource.resource_id != WORKFLOW_INSTANCE_RESOURCE_ID + || resource.type_name != WORKFLOW_INSTANCE_RESOURCE_TYPE + { + bail!("WASM workflow snapshot contains an unsupported resource"); + } + Ok(resource) + } } struct WasmWorkflowHostState { @@ -885,12 +924,12 @@ mod tests { .set(&mut source_store, Val::I32(42)) .unwrap(); - let snapshot = source_store.temporal_snapshot().unwrap(); + let snapshot = source_store.snapshot().unwrap(); assert_eq!(snapshot.memories[0].nonzero_pages.len(), 1); let mut target_store = Store::new(&engine, ()); let target_instance = Instance::new(&mut target_store, &module, &[]).unwrap(); - target_store.temporal_restore_snapshot(&snapshot).unwrap(); + target_store.restore_snapshot(&snapshot).unwrap(); let target_memory = target_instance .get_memory(&mut target_store, "memory") diff --git a/samples/wasm-workflows/hello/Cargo.toml b/samples/wasm-workflows/hello/Cargo.toml index 5805b7298..d854fe4d9 100644 --- a/samples/wasm-workflows/hello/Cargo.toml +++ b/samples/wasm-workflows/hello/Cargo.toml @@ -4,8 +4,13 @@ version = "0.1.0" edition = "2024" [dependencies] +anyhow = "1.0" temporalio-workflow = { path = "../../../crates/workflow" } +[build-dependencies] +wasm-encoder = "=0.247.0" +wat = "=1.248.0" + [lib] crate-type = ["cdylib"] diff --git a/samples/wasm-workflows/hello/build.rs b/samples/wasm-workflows/hello/build.rs new file mode 100644 index 000000000..29aed342a --- /dev/null +++ b/samples/wasm-workflows/hello/build.rs @@ -0,0 +1,216 @@ +use std::{borrow::Cow, env, fs, path::PathBuf}; +use wasm_encoder::{ + CodeSection, CustomSection, Encode, Function, FunctionSection, ImportSection, Instruction, + LinkingSection, Module, RefType, SymbolTable, TableType, TypeSection, ValType, +}; + +const R_WASM_TABLE_INDEX_SLEB: u8 = 1; +const R_WASM_TYPE_INDEX_LEB: u8 = 6; +const R_WASM_TABLE_NUMBER_LEB: u8 = 20; + +fn main() { + println!("cargo:rerun-if-changed=src/funcref_table_mutation.wat"); + wat::parse_file("src/funcref_table_mutation.wat") + .expect("funcref table mutation WAT should parse"); + + if env::var("CARGO_CFG_TARGET_ARCH").as_deref() != Ok("wasm32") { + return; + } + + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR should be set")); + let object_path = out_dir.join("funcref_table_mutation.o"); + fs::write(&object_path, funcref_table_mutation_object()) + .expect("funcref table mutation object should be writable"); + println!("cargo:rustc-link-arg={}", object_path.display()); +} + +fn funcref_table_mutation_object() -> Vec { + let mut module = Module::new(); + + let mut types = TypeSection::new(); + types.ty().function([], [ValType::I32]); + types.ty().function([], []); + module.section(&types); + + let mut imports = ImportSection::new(); + imports.import( + "env", + "__indirect_function_table", + TableType { + element_type: RefType::FUNCREF, + minimum: 1, + maximum: None, + table64: false, + shared: false, + }, + ); + module.section(&imports); + + let mut functions = FunctionSection::new(); + functions.function(0); + functions.function(0); + functions.function(1); + functions.function(0); + module.section(&functions); + + let mut code = CodeSection::new(); + let mut before = Function::new([]); + before.instruction(&Instruction::I32Const(11)); + before.instruction(&Instruction::End); + code.function(&before); + + let mut after = Function::new([]); + after.instruction(&Instruction::I32Const(22)); + after.instruction(&Instruction::End); + code.function(&after); + + let mut set_to_after = Vec::new(); + set_to_after.push(0); + set_to_after.push(0x41); + set_to_after.push(0); + set_to_after.push(0x41); + let set_source_table_index_offset = set_to_after.len() as u32; + padded_u32(0, &mut set_to_after); + set_to_after.push(0x25); + let set_table_get_offset = set_to_after.len() as u32; + padded_u32(0, &mut set_to_after); + set_to_after.push(0x26); + let set_table_offset = set_to_after.len() as u32; + padded_u32(0, &mut set_to_after); + set_to_after.push(0x0b); + code.raw(&set_to_after); + + let mut call_slot = Vec::new(); + call_slot.push(0); + call_slot.push(0x41); + call_slot.push(0); + call_slot.push(0x11); + let call_type_offset = call_slot.len() as u32; + padded_u32(0, &mut call_slot); + let call_table_offset = call_slot.len() as u32; + padded_u32(0, &mut call_slot); + call_slot.push(0x0b); + code.raw(&call_slot); + module.section(&code); + + let mut linking = LinkingSection::new(); + let mut symbols = SymbolTable::new(); + let local = SymbolTable::WASM_SYM_BINDING_LOCAL; + symbols.function(local, 0, Some("temporal_funcref_before")); + symbols.function( + SymbolTable::WASM_SYM_EXPORTED | SymbolTable::WASM_SYM_NO_STRIP, + 1, + Some("temporal_funcref_after"), + ); + symbols.function( + SymbolTable::WASM_SYM_EXPORTED | SymbolTable::WASM_SYM_NO_STRIP, + 2, + Some("temporal_funcref_table_set_to_after"), + ); + symbols.function( + SymbolTable::WASM_SYM_EXPORTED | SymbolTable::WASM_SYM_NO_STRIP, + 3, + Some("temporal_funcref_table_call"), + ); + symbols.table( + SymbolTable::WASM_SYM_UNDEFINED | SymbolTable::WASM_SYM_NO_STRIP, + 0, + None, + ); + linking.symbol_table(&symbols); + module.section(&linking); + + module.section(&CustomSection { + name: "reloc.CODE".into(), + data: Cow::Owned(reloc_code_section( + set_source_table_index_offset, + set_table_get_offset, + set_table_offset, + set_to_after.len() as u32, + call_type_offset, + call_table_offset, + )), + }); + + module.section(&CustomSection { + name: "target_features".into(), + data: Cow::Owned(target_features_section()), + }); + + module.finish() +} + +fn reloc_code_section( + set_source_table_index_offset: u32, + set_table_get_offset: u32, + set_table_offset: u32, + set_to_after_len: u32, + call_type_offset: u32, + call_table_offset: u32, +) -> Vec { + let mut reloc = Vec::new(); + 3u32.encode(&mut reloc); + + let before_function_len = 5; + let after_function_len = 5; + let set_body_start = 1 + before_function_len + after_function_len + 1; + let call_body_start = set_body_start + set_to_after_len + 1; + + let mut entries = Vec::new(); + push_reloc( + &mut entries, + R_WASM_TABLE_INDEX_SLEB, + set_body_start + set_source_table_index_offset, + 1, + ); + push_reloc( + &mut entries, + R_WASM_TABLE_NUMBER_LEB, + set_body_start + set_table_get_offset, + 4, + ); + push_reloc( + &mut entries, + R_WASM_TABLE_NUMBER_LEB, + set_body_start + set_table_offset, + 4, + ); + push_reloc( + &mut entries, + R_WASM_TYPE_INDEX_LEB, + call_body_start + call_type_offset, + 0, + ); + push_reloc( + &mut entries, + R_WASM_TABLE_NUMBER_LEB, + call_body_start + call_table_offset, + 4, + ); + 5u32.encode(&mut reloc); + reloc.extend(entries); + reloc +} + +fn target_features_section() -> Vec { + let mut features = Vec::new(); + 1u32.encode(&mut features); + features.push(b'+'); + "reference-types".encode(&mut features); + features +} + +fn padded_u32(n: u32, out: &mut Vec) { + let mut value = n; + for _ in 0..4 { + out.push(((value as u8) & 0x7f) | 0x80); + value >>= 7; + } + out.push(value as u8); +} + +fn push_reloc(out: &mut Vec, ty: u8, offset: u32, index: u32) { + out.push(ty); + offset.encode(out); + index.encode(out); +} diff --git a/samples/wasm-workflows/hello/src/funcref_table_mutation.wat b/samples/wasm-workflows/hello/src/funcref_table_mutation.wat new file mode 100644 index 000000000..737b332e3 --- /dev/null +++ b/samples/wasm-workflows/hello/src/funcref_table_mutation.wat @@ -0,0 +1,23 @@ +(module + (type $thunk (func (result i32))) + + (table $__indirect_function_table (import "env" "__indirect_function_table") 1 funcref) + + (func $before (result i32) + i32.const 11) + + (func $after (result i32) + i32.const 22) + + (func $set_to_after (export "temporal_funcref_table_set_to_after") + i32.const 0 + (; build.rs emits this immediate as a table-index relocation against $after. ;) + i32.const 0 + table.get $__indirect_function_table + table.set $__indirect_function_table) + + (func $call_slot (export "temporal_funcref_table_call") (result i32) + i32.const 0 + call_indirect (type $thunk)) + +) diff --git a/samples/wasm-workflows/hello/src/lib.rs b/samples/wasm-workflows/hello/src/lib.rs index 05ba28d6c..302ab1dd8 100644 --- a/samples/wasm-workflows/hello/src/lib.rs +++ b/samples/wasm-workflows/hello/src/lib.rs @@ -1,7 +1,14 @@ +use std::time::Duration; + use temporalio_workflow::{ WorkflowContext, WorkflowResult, export_workflow_module, workflow, workflow_methods, }; +unsafe extern "C" { + fn temporal_funcref_table_set_to_after(); + fn temporal_funcref_table_call() -> i32; +} + #[workflow] #[derive(Default)] pub struct HelloWorkflow; @@ -14,4 +21,66 @@ impl HelloWorkflow { } } -export_workflow_module!([HelloWorkflow]); +#[workflow] +#[derive(Default)] +pub struct TimerWorkflow; + +#[workflow_methods] +impl TimerWorkflow { + #[run] + pub async fn run(ctx: &mut WorkflowContext, name: String) -> WorkflowResult { + ctx.timer(Duration::from_millis(1)).await; + Ok(format!("Timer fired for {name}!")) + } +} + +type FuncrefFormatter = fn(&str) -> String; + +struct FuncrefState { + formatter: FuncrefFormatter, +} + +#[inline(never)] +fn restored_funcref_message(name: &str) -> String { + format!("Funcref restored for {name}!") +} + +#[inline(never)] +fn alternate_funcref_message(name: &str) -> String { + format!("Alternate funcref restored for {name}!") +} + +#[inline(never)] +fn choose_funcref_formatter(name: &str) -> FuncrefFormatter { + if name.len() % 2 == 0 { + restored_funcref_message + } else { + alternate_funcref_message + } +} + +#[workflow] +#[derive(Default)] +pub struct FuncrefWorkflow; + +#[workflow_methods] +impl FuncrefWorkflow { + #[run] + pub async fn run(ctx: &mut WorkflowContext, name: String) -> WorkflowResult { + let state = FuncrefState { + formatter: choose_funcref_formatter(&name), + }; + unsafe { + temporal_funcref_table_set_to_after(); + } + let table_value_before_snapshot = unsafe { temporal_funcref_table_call() }; + ctx.timer(Duration::from_millis(1)).await; + let table_value_after_snapshot = unsafe { temporal_funcref_table_call() }; + Ok(format!( + "{} table={table_value_before_snapshot}->{table_value_after_snapshot}", + (state.formatter)(&name) + )) + } +} + +export_workflow_module!([HelloWorkflow, TimerWorkflow, FuncrefWorkflow]);