From c036b28d6703a5a29e34882c7a193f048302557c Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Wed, 26 Mar 2025 09:40:03 -0700 Subject: [PATCH 01/14] Improvement for more deterministic workflow behavior --- CHANGELOG.md | 3 ++ lib/temporal/concerns/input_deserializer.rb | 26 ++++++++++++ lib/temporal/version.rb | 2 +- lib/temporal/workflow/history/event.rb | 14 +++++++ lib/temporal/workflow/history/event_target.rb | 42 ++++++++++++++++--- lib/temporal/workflow/state_manager.rb | 32 ++------------ .../grpc/history_event_fabricator.rb | 6 ++- 7 files changed, 87 insertions(+), 38 deletions(-) create mode 100644 lib/temporal/concerns/input_deserializer.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index e604aca7..dcc3f63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ # Changelog +## 0.0.4 +- Patching + ## 0.0.1 - First release diff --git a/lib/temporal/concerns/input_deserializer.rb b/lib/temporal/concerns/input_deserializer.rb new file mode 100644 index 00000000..8f636df8 --- /dev/null +++ b/lib/temporal/concerns/input_deserializer.rb @@ -0,0 +1,26 @@ +module Temporal + module Concerns + module InputDeserializer + def deserialize(input) + JSON.deserialize(input) + rescue Oj::ParseError + # Copied over from the Cadence side, similar situation happening with Temporal + # + # cadence official go-client serializes / deserializes input in a different format than this ruby client + # adding additional deserialization logic here to help read input that is passed from go-client + # https://github.com/uber-go/cadence-client/blob/0.18.x/internal/encoding.go#L45-L58 + # + # this ruby client serializes / deserializes everything as one big string like below: + # [1012474654, "second input"] + # + # while go client serializes input as separate input followed by line break + # 1012474654 + # second input + args = input.split(/\n/) + res = args.map do |arg| + JSON.deserialize(arg) + end + end + end + end +end \ No newline at end of file diff --git a/lib/temporal/version.rb b/lib/temporal/version.rb index dde4f73c..d5e85893 100644 --- a/lib/temporal/version.rb +++ b/lib/temporal/version.rb @@ -1,3 +1,3 @@ module Temporal - VERSION = '0.0.3'.freeze + VERSION = '0.0.4'.freeze end diff --git a/lib/temporal/workflow/history/event.rb b/lib/temporal/workflow/history/event.rb index 562fd018..a6aa591b 100644 --- a/lib/temporal/workflow/history/event.rb +++ b/lib/temporal/workflow/history/event.rb @@ -2,6 +2,7 @@ module Temporal class Workflow class History class Event + include Temporal::Concerns::InputDeserializer EVENT_TYPES = %w[ ACTIVITY_TASK_STARTED ACTIVITY_TASK_COMPLETED @@ -52,6 +53,19 @@ def originating_event_id end end + def target_attributes + case type + when 'ActivityTaskScheduled' + { + activity_id: attributes.activityId.to_i, # activityId is a string from thrift + activity_type: attributes.activityType.name, + input: deserialize(attributes.input) + } + else + {} + end + end + private def extract_attributes(raw_event) diff --git a/lib/temporal/workflow/history/event_target.rb b/lib/temporal/workflow/history/event_target.rb index d054947f..40d2f8e6 100644 --- a/lib/temporal/workflow/history/event_target.rb +++ b/lib/temporal/workflow/history/event_target.rb @@ -5,6 +5,7 @@ class Workflow class History class EventTarget class UnexpectedEventType < InternalError; end + class UnexpectedDecisionType < InternalError; end ACTIVITY_TYPE = :activity CANCEL_ACTIVITY_REQUEST_TYPE = :cancel_activity_request @@ -19,7 +20,7 @@ class UnexpectedEventType < InternalError; end UPSERT_SEARCH_ATTRIBUTES_REQUEST_TYPE = :upsert_search_attributes_request # NOTE: The order is important, first prefix match wins (will be a longer match) - TARGET_TYPES = { + EVENT_TARGET_TYPES = { 'ACTIVITY_TASK_CANCEL_REQUESTED' => CANCEL_ACTIVITY_REQUEST_TYPE, 'ACTIVITY_TASK' => ACTIVITY_TYPE, 'REQUEST_CANCEL_ACTIVITY_TASK' => CANCEL_ACTIVITY_REQUEST_TYPE, @@ -38,25 +39,54 @@ class UnexpectedEventType < InternalError; end 'WORKFLOW_EXECUTION' => WORKFLOW_TYPE, }.freeze - attr_reader :id, :type + DECISION_TARGET_TYPES = { + 'Cadence::Workflow::Decision::ScheduleActivity' => ACTIVITY_TYPE, + 'Cadence::Workflow::Decision::RequestActivityCancellation' => CANCEL_ACTIVITY_REQUEST_TYPE, + 'Cadence::Workflow::Decision::RecordMarker' => MARKER_TYPE, + 'Cadence::Workflow::Decision::StartTimer' => TIMER_TYPE, + 'Cadence::Workflow::Decision::CancelTimer' => CANCEL_TIMER_REQUEST_TYPE, + 'Cadence::Workflow::Decision::CompleteWorkflow' => WORKFLOW_TYPE, + 'Cadence::Workflow::Decision::FailWorkflow' => WORKFLOW_TYPE, + 'Cadence::Workflow::Decision::StartChildWorkflow' => CHILD_WORKFLOW_TYPE, + }.freeze + + DECISION_ATTRIBUTE_LISTS = { + 'Cadence::Workflow::Decision::ScheduleActivity' => [:activity_id, :activity_type, :input], + } + + attr_reader :id, :type, :attributes def self.workflow @workflow ||= new(1, WORKFLOW_TYPE) end def self.from_event(event) - _, target_type = TARGET_TYPES.find { |type, _| event.type.start_with?(type) } + _, target_type = EVENT_TARGET_TYPES.find { |type, _| event.type.start_with?(type) } unless target_type raise UnexpectedEventType, "Unexpected event #{event.type}" end - new(event.originating_event_id, target_type) + new(event.decision_id, target_type, attributes: event.target_attributes) + end + + def self.from_decision(decision_id, decision) + decision_type = decision.class.name + target_type = DECISION_TARGET_TYPES[decision_type] + + unless target_type + raise UnexpectedDecisionType, "Unexpected decision type #{decision_type}" + end + + attribute_list = DECISION_ATTRIBUTE_LISTS.fetch(decision_type, []) + + new(decision_id, target_type, attributes: decision.to_h.slice(*attribute_list)) end - def initialize(id, type) + def initialize(id, type, attributes: {}) @id = id @type = type + @attributes = attributes freeze end @@ -74,7 +104,7 @@ def hash end def to_s - "#{type} (#{id})" + "#{type}: #{id} (#{attributes})" end end end diff --git a/lib/temporal/workflow/state_manager.rb b/lib/temporal/workflow/state_manager.rb index 6fbf8983..8d141eed 100644 --- a/lib/temporal/workflow/state_manager.rb +++ b/lib/temporal/workflow/state_manager.rb @@ -66,7 +66,7 @@ def schedule(command) validate_append_command(command) commands << [command_id, command] - return [event_target_from(command_id, command), cancelation_id] + [History::EventTarget.from_decision(decision_id, decision), cancelation_id] end def release?(release_name) @@ -309,32 +309,6 @@ def apply_event(event) end end - def event_target_from(command_id, command) - target_type = - case command - when Command::ScheduleActivity - History::EventTarget::ACTIVITY_TYPE - when Command::RequestActivityCancellation - History::EventTarget::CANCEL_ACTIVITY_REQUEST_TYPE - when Command::RecordMarker - History::EventTarget::MARKER_TYPE - when Command::StartTimer - History::EventTarget::TIMER_TYPE - when Command::CancelTimer - History::EventTarget::CANCEL_TIMER_REQUEST_TYPE - when Command::CompleteWorkflow, Command::FailWorkflow - History::EventTarget::WORKFLOW_TYPE - when Command::StartChildWorkflow - History::EventTarget::CHILD_WORKFLOW_TYPE - when Command::UpsertSearchAttributes - History::EventTarget::UPSERT_SEARCH_ATTRIBUTES_REQUEST_TYPE - when Command::SignalExternalWorkflow - History::EventTarget::EXTERNAL_WORKFLOW_TYPE - end - - History::EventTarget.new(command_id, target_type) - end - def dispatch(history_target, name, *attributes) dispatcher.dispatch(history_target, name, attributes) end @@ -352,8 +326,8 @@ def discard_command(history_target) "A command in the history of previous executions, #{history_target}, was not scheduled upon replay. " + NONDETERMINISM_ERROR_SUGGESTION end - replay_target = event_target_from(replay_command_id, replay_command) - if history_target != replay_target + replay_target = History::EventTarget.from_decision(replay_command_id, replay_command) + if history_target != replay_target || history_target.attributes != replay_target.attributes raise NonDeterministicWorkflowError, "Unexpected command. The replaying code is issuing: #{replay_target}, "\ "but the history of previous executions recorded: #{history_target}. " + NONDETERMINISM_ERROR_SUGGESTION diff --git a/spec/fabricators/grpc/history_event_fabricator.rb b/spec/fabricators/grpc/history_event_fabricator.rb index 6f043b4d..634b1536 100644 --- a/spec/fabricators/grpc/history_event_fabricator.rb +++ b/spec/fabricators/grpc/history_event_fabricator.rb @@ -1,5 +1,6 @@ require 'securerandom' require 'temporal/concerns/payloads' +require 'temporal/json' class TestSerializer extend Temporal::Concerns::Payloads @@ -10,9 +11,10 @@ class TestSerializer Fabricator(:api_history_event, from: Temporalio::Api::History::V1::HistoryEvent) do event_id { 1 } event_time { Time.now } + transient input: nil end -Fabricator(:api_workflow_execution_started_event, from: :api_history_event) do +Fabricator(:api_workflow_execution_started_eevent, from: :api_history_event) do transient :headers, :search_attributes event_type { Temporalio::Api::Enums::V1::EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED } event_time { Time.now } @@ -24,7 +26,7 @@ class TestSerializer Temporalio::Api::History::V1::WorkflowExecutionStartedEventAttributes.new( workflow_type: Fabricate(:api_workflow_type), task_queue: Fabricate(:api_task_queue), - input: nil, + input: Temporal::JSON.serialize(attrs[:input]), workflow_execution_timeout: 60, workflow_task_timeout: 15, original_execution_run_id: SecureRandom.uuid, From 5d98fd016a391672403e46f326d3a0b41d6a4350 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Mon, 9 Feb 2026 18:20:28 -0800 Subject: [PATCH 02/14] Defer gRPC loading in connections and pollers. Use autoload for Temporal::Connection::GRPC and lazy-load grpc/errors in retryer and pollers to avoid pre-fork gRPC initialization. Co-authored-by: Cursor --- lib/temporal/activity/poller.rb | 1 + lib/temporal/connection.rb | 8 ++++---- lib/temporal/connection/retryer.rb | 3 +-- lib/temporal/workflow/poller.rb | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/temporal/activity/poller.rb b/lib/temporal/activity/poller.rb index 55271593..159756ef 100644 --- a/lib/temporal/activity/poller.rb +++ b/lib/temporal/activity/poller.rb @@ -88,6 +88,7 @@ def poll_loop end def poll_for_task + require 'grpc/errors' connection.poll_activity_task_queue(namespace: namespace, task_queue: task_queue) rescue ::GRPC::Cancelled # We're shutting down and we've already reported that in the logs diff --git a/lib/temporal/connection.rb b/lib/temporal/connection.rb index b70bcbed..367a2967 100644 --- a/lib/temporal/connection.rb +++ b/lib/temporal/connection.rb @@ -1,13 +1,13 @@ -require 'temporal/connection/grpc' - module Temporal module Connection + autoload :GRPC, 'temporal/connection/grpc' + CLIENT_TYPES_MAP = { - grpc: Temporal::Connection::GRPC + grpc: :GRPC }.freeze def self.generate(configuration) - connection_class = CLIENT_TYPES_MAP[configuration.type] + connection_class = const_get(CLIENT_TYPES_MAP.fetch(configuration.type)) host = configuration.host port = configuration.port credentials = configuration.credentials diff --git a/lib/temporal/connection/retryer.rb b/lib/temporal/connection/retryer.rb index 2948f05f..95167095 100644 --- a/lib/temporal/connection/retryer.rb +++ b/lib/temporal/connection/retryer.rb @@ -1,5 +1,3 @@ -require 'grpc/errors' - module Temporal module Connection module Retryer @@ -12,6 +10,7 @@ module Retryer # https://github.com/temporalio/sdk-java/blob/ad8831d4a4d9d257baf3482ab49f1aa681895c0e/temporal-serviceclient/src/main/java/io/temporal/serviceclient/RpcRetryOptions.java#L32 # No amount of retrying will help in these cases. def self.do_not_retry_errors + require 'grpc/errors' [ ::GRPC::AlreadyExists, ::GRPC::Cancelled, diff --git a/lib/temporal/workflow/poller.rb b/lib/temporal/workflow/poller.rb index 07162ce1..107ee271 100644 --- a/lib/temporal/workflow/poller.rb +++ b/lib/temporal/workflow/poller.rb @@ -1,4 +1,3 @@ -require 'grpc/errors' require 'temporal/connection' require 'temporal/thread_pool' require 'temporal/middleware/chain' @@ -89,6 +88,7 @@ def poll_loop end def poll_for_task + require 'grpc/errors' connection.poll_workflow_task_queue(namespace: namespace, task_queue: task_queue, binary_checksum: binary_checksum) rescue ::GRPC::Cancelled # We're shutting down and we've already reported that in the logs From 479f765b7dd1ded864a56a5e237bb567e9fd8a8c Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Mon, 9 Feb 2026 19:23:32 -0800 Subject: [PATCH 03/14] Align merge resolution with transfers-master Co-authored-by: Cursor --- lib/temporal/workflow/history/event_target.rb | 1 + lib/temporal/workflow/state_manager.rb | 1 + spec/fabricators/grpc/history_event_fabricator.rb | 3 +-- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/temporal/workflow/history/event_target.rb b/lib/temporal/workflow/history/event_target.rb index 8066fc77..df38ceeb 100644 --- a/lib/temporal/workflow/history/event_target.rb +++ b/lib/temporal/workflow/history/event_target.rb @@ -74,6 +74,7 @@ def self.from_event(event) end def self.from_command(command_id, command) + command_type = command.class.name target_type = WORKFLOW_TARGET_TYPES[command_type] diff --git a/lib/temporal/workflow/state_manager.rb b/lib/temporal/workflow/state_manager.rb index f6218cc6..6753d344 100644 --- a/lib/temporal/workflow/state_manager.rb +++ b/lib/temporal/workflow/state_manager.rb @@ -327,6 +327,7 @@ def discard_command(history_target) end replay_target = History::EventTarget.from_command(replay_command_id, replay_command) + if history_target != replay_target || history_target.attributes != replay_target.attributes raise NonDeterministicWorkflowError, "Unexpected command. The replaying code is issuing: #{replay_target}, "\ diff --git a/spec/fabricators/grpc/history_event_fabricator.rb b/spec/fabricators/grpc/history_event_fabricator.rb index ac2e7553..34affe13 100644 --- a/spec/fabricators/grpc/history_event_fabricator.rb +++ b/spec/fabricators/grpc/history_event_fabricator.rb @@ -12,10 +12,9 @@ class TestSerializer transient :eventId, :input event_id { |attrs| attrs[:eventId] || attrs[:event_id] || 1 } event_time { Time.now } - transient input: nil end -Fabricator(:api_workflow_execution_started_eevent, from: :api_history_event) do +Fabricator(:api_workflow_execution_started_event, from: :api_history_event) do transient :headers, :search_attributes event_type { Temporalio::Api::Enums::V1::EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED } event_time { Time.now } From 99d7bb440eeb02fc1e67d745576e8bc6c4104fb5 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Mon, 9 Feb 2026 20:49:46 -0800 Subject: [PATCH 04/14] Require payloads for history event Co-authored-by: Cursor --- lib/temporal/workflow/history/event.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/temporal/workflow/history/event.rb b/lib/temporal/workflow/history/event.rb index f3076c26..10ebcf80 100644 --- a/lib/temporal/workflow/history/event.rb +++ b/lib/temporal/workflow/history/event.rb @@ -1,4 +1,5 @@ require 'temporal/concerns/input_deserializer' +require 'temporal/concerns/payloads' module Temporal class Workflow From cd63611f2099fffa2d16f1532aea0180e998e51f Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Mon, 9 Feb 2026 21:08:08 -0800 Subject: [PATCH 05/14] Require enum proto for workflow errors Co-authored-by: Cursor --- lib/temporal/workflow/errors.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/temporal/workflow/errors.rb b/lib/temporal/workflow/errors.rb index 42157376..064fccdd 100644 --- a/lib/temporal/workflow/errors.rb +++ b/lib/temporal/workflow/errors.rb @@ -1,4 +1,6 @@ require 'temporal/errors' +require 'temporal/concerns/payloads' +require 'gen/temporal/api/enums/v1/failed_cause_pb' module Temporal class Workflow From 9a8427f273bbe07d3905a32f86a96aa0774ccb4a Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Mon, 9 Feb 2026 21:11:50 -0800 Subject: [PATCH 06/14] Move non-gRPC requires to client Co-authored-by: Cursor --- lib/temporal/client.rb | 5 +++++ lib/temporal/connection/grpc.rb | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/temporal/client.rb b/lib/temporal/client.rb index af6ae2bf..3e705d61 100644 --- a/lib/temporal/client.rb +++ b/lib/temporal/client.rb @@ -1,5 +1,10 @@ require 'temporal/execution_options' require 'temporal/connection' +require 'temporal/concerns/payloads' +require 'temporal/connection/errors' +require 'temporal/connection/serializer' +require 'temporal/connection/serializer/failure' +require 'temporal/connection/serializer/workflow_id_reuse_policy' require 'temporal/activity' require 'temporal/activity/async_token' require 'temporal/workflow' diff --git a/lib/temporal/connection/grpc.rb b/lib/temporal/connection/grpc.rb index 092faf96..c0cbca54 100644 --- a/lib/temporal/connection/grpc.rb +++ b/lib/temporal/connection/grpc.rb @@ -7,12 +7,7 @@ require 'gen/temporal/api/operatorservice/v1/service_services_pb' require 'gen/temporal/api/enums/v1/workflow_pb' require 'gen/temporal/api/enums/v1/common_pb' -require 'temporal/connection/errors' require 'temporal/connection/interceptors/client_name_version_interceptor' -require 'temporal/connection/serializer' -require 'temporal/connection/serializer/failure' -require 'temporal/connection/serializer/workflow_id_reuse_policy' -require 'temporal/concerns/payloads' module Temporal module Connection From 53e9043189d0c56129e13e9378e7724d457ce816 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Mon, 9 Feb 2026 21:28:40 -0800 Subject: [PATCH 07/14] Add load-order guard spec Co-authored-by: Cursor --- lib/temporal/workflow/task_processor.rb | 1 + spec/unit/lib/temporal/load_order_spec.rb | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 spec/unit/lib/temporal/load_order_spec.rb diff --git a/lib/temporal/workflow/task_processor.rb b/lib/temporal/workflow/task_processor.rb index 7a8cd4e0..d891edd3 100644 --- a/lib/temporal/workflow/task_processor.rb +++ b/lib/temporal/workflow/task_processor.rb @@ -5,6 +5,7 @@ require 'temporal/workflow/history' require 'temporal/workflow/stack_trace_tracker' require 'temporal/metric_keys' +require 'gen/temporal/api/enums/v1/failed_cause_pb' module Temporal class Workflow diff --git a/spec/unit/lib/temporal/load_order_spec.rb b/spec/unit/lib/temporal/load_order_spec.rb new file mode 100644 index 00000000..0d5aa831 --- /dev/null +++ b/spec/unit/lib/temporal/load_order_spec.rb @@ -0,0 +1,19 @@ +require 'temporal' + +describe 'Temporal load order' do + it 'loads payload concerns without gRPC' do + expect(Temporal::Concerns::Payloads).to be_a(Module) + end + + it 'loads serializer constants without gRPC' do + expect(Temporal::Connection::Serializer::Failure).to be_a(Class) + end + + it 'loads workflow error enums at boot' do + expect(Temporal::Workflow::Errors::WORKFLOW_ALREADY_EXISTS_SYM).not_to be_nil + end + + it 'loads workflow history event class at boot' do + expect(Temporal::Workflow::History::Event).to be_a(Class) + end +end From b5325f41a4de1f1c47bc1cc31763ed3ade53ad17 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Mon, 9 Feb 2026 22:22:42 -0800 Subject: [PATCH 08/14] fix: add value-aware attribute comparison for workflow replay During workflow replay, Temporal deserializes activity inputs from history and compares them to the current execution. Ruby's default Object#== uses object identity, so two Request objects with identical data compare as unequal, causing false NonDeterministicWorkflowError. Add EventTarget#attributes_equal? that falls back to instance-variable comparison for plain Ruby objects that don't define their own ==. Update StateManager#discard_command to use this value-aware comparison. This eliminates the need for downstream consumers to inherit from a special base class or include equality mixins in their Request classes. Co-authored-by: Cursor --- CHANGELOG.md.orig | 46 ++++++ lib/temporal/version.rb.orig | 7 + lib/temporal/workflow/history/event_target.rb | 51 ++++++ lib/temporal/workflow/state_manager.rb | 2 +- .../workflow/history/event_target_spec.rb | 149 ++++++++++++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md.orig create mode 100644 lib/temporal/version.rb.orig diff --git a/CHANGELOG.md.orig b/CHANGELOG.md.orig new file mode 100644 index 00000000..6f4e95ef --- /dev/null +++ b/CHANGELOG.md.orig @@ -0,0 +1,46 @@ +# Changelog + +<<<<<<< HEAD +## 0.1.1 +Allows signals to be processed within the first workflow task. + +**IMPORTANT:** This change is backward compatible, but workflows started +on this version cannot run on earlier versions. If you roll back, you will +see workflow task failures mentioning an unknown SDK flag. This will prevent +those workflows from making progress until your code is rolled forward +again. If you'd like to roll this out more gradually, you can, +1. Set the `no_signals_in_first_task` configuration option to `true` +2. Deploy your worker +3. Wait until you are certain you won't need to roll back +4. Remove the configuration option, which will default it to `false` +5. Deploy your worker + +## 0.1.0 + +This introduces signal first ordering. See https://github.com/coinbase/temporal-ruby/issues/258 for +details on why this is necessary for correct handling of signals. + +**IMPORTANT: ** This feature requires Temporal server 1.20.0 or newer. If you are running an older +version of the server, you must either upgrade to at least this version, or you can set the +`.legacy_signals` configuration option to true until you can upgrade. + +If you do not have existing workflows with signals running or are standing up a worker service +for the first time, you can ignore all the below instructions. + +If you have any workflows with signals running during a deployment and run more than one worker +process, you must follow these rollout steps to avoid non-determinism errors: +1. Set `.legacy_signals` in `Temporal::Configuration` to true +2. Deploy your worker +3. Remove the `.legacy_signals` setting or set it to `false` +4. Deploy your worker + +These steps ensure any workflow that executes in signals first mode will continue to be executed +in this order on replay. If you don't follow these steps, you may see failed workflow tasks, which +in some cases could result in unrecoverable history corruption. +======= +## 0.0.4 +- Patching + +## 0.0.1 +- First release +>>>>>>> c036b28 (Improvement for more deterministic workflow behavior) diff --git a/lib/temporal/version.rb.orig b/lib/temporal/version.rb.orig new file mode 100644 index 00000000..2220073a --- /dev/null +++ b/lib/temporal/version.rb.orig @@ -0,0 +1,7 @@ +module Temporal +<<<<<<< HEAD + VERSION = '0.1.1'.freeze +======= + VERSION = '0.0.4'.freeze +>>>>>>> c036b28 (Improvement for more deterministic workflow behavior) +end diff --git a/lib/temporal/workflow/history/event_target.rb b/lib/temporal/workflow/history/event_target.rb index df38ceeb..fce2ba22 100644 --- a/lib/temporal/workflow/history/event_target.rb +++ b/lib/temporal/workflow/history/event_target.rb @@ -107,6 +107,57 @@ def hash [id, type].hash end + # Value-aware comparison for attributes that handles plain Ruby objects + # without custom == (e.g., workflow/activity Request classes). + # + # During replay, Temporal deserializes inputs from history and compares + # them to the current execution. Ruby's default Object#== uses object + # identity, so two Request objects with identical data compare as unequal, + # causing false NonDeterministicWorkflowError. This method falls back to + # instance-variable comparison for objects that don't define their own ==. + def attributes_equal?(other_attributes) + return true if attributes == other_attributes + return false if attributes.nil? || other_attributes.nil? + return false if attributes.keys.sort != other_attributes.keys.sort + + attributes.all? do |key, value| + self.class.deep_value_equal?(value, other_attributes[key]) + end + end + + def self.deep_value_equal?(a, b) + # Fast path: identical object + return true if a.equal?(b) + + # Standard types that define meaningful == + return a == b if a.is_a?(String) || a.is_a?(Numeric) || a.is_a?(Symbol) || + a.is_a?(TrueClass) || a.is_a?(FalseClass) || a.nil? + + # Arrays: compare element-by-element + if a.is_a?(Array) && b.is_a?(Array) + return false if a.length != b.length + return a.zip(b).all? { |x, y| deep_value_equal?(x, y) } + end + + # Hashes: compare key-by-key + if a.is_a?(Hash) && b.is_a?(Hash) + return false if a.keys.sort_by(&:to_s) != b.keys.sort_by(&:to_s) + return a.all? { |k, v| deep_value_equal?(v, b[k]) } + end + + # If the class defines its own == (not inherited from BasicObject/Object) + eq_owner = a.class.instance_method(:==).owner + return a == b if eq_owner != ::BasicObject && eq_owner != ::Object + + # Fallback: compare by class + instance variables for plain objects + return false unless a.class == b.class + return true if a.instance_variables.empty? && b.instance_variables.empty? + + a.instance_variables.all? do |var| + deep_value_equal?(a.instance_variable_get(var), b.instance_variable_get(var)) + end + end + def to_s "#{type}: #{id} (#{attributes})" end diff --git a/lib/temporal/workflow/state_manager.rb b/lib/temporal/workflow/state_manager.rb index 6753d344..800320e3 100644 --- a/lib/temporal/workflow/state_manager.rb +++ b/lib/temporal/workflow/state_manager.rb @@ -328,7 +328,7 @@ def discard_command(history_target) replay_target = History::EventTarget.from_command(replay_command_id, replay_command) - if history_target != replay_target || history_target.attributes != replay_target.attributes + if history_target != replay_target || !replay_target.attributes_equal?(history_target.attributes) raise NonDeterministicWorkflowError, "Unexpected command. The replaying code is issuing: #{replay_target}, "\ "but the history of previous executions recorded: #{history_target}. " + NONDETERMINISM_ERROR_SUGGESTION diff --git a/spec/unit/lib/temporal/workflow/history/event_target_spec.rb b/spec/unit/lib/temporal/workflow/history/event_target_spec.rb index 32311474..eb0e51fb 100644 --- a/spec/unit/lib/temporal/workflow/history/event_target_spec.rb +++ b/spec/unit/lib/temporal/workflow/history/event_target_spec.rb @@ -104,6 +104,155 @@ end end + describe '#attributes_equal?' do + # Plain Ruby class without custom == (simulates workflow/activity Request) + let(:request_class) do + Class.new do + attr_reader :id, :name + + def initialize(id:, name:) + @id = id + @name = name + end + end + end + + context 'when attributes contain plain objects without custom ==' do + let(:target_a) do + described_class.new(1, :activity, attributes: { + activity_id: 1, + activity_type: 'MyActivity', + input: [request_class.new(id: 42, name: 'test')] + }) + end + + let(:target_b) do + described_class.new(1, :activity, attributes: { + activity_id: 1, + activity_type: 'MyActivity', + input: [request_class.new(id: 42, name: 'test')] + }) + end + + it 'returns true when values are identical' do + expect(target_a.attributes_equal?(target_b.attributes)).to be true + end + + it 'would fail with default != comparison' do + # Prove that Ruby default == would fail (different object identities) + expect(target_a.attributes != target_b.attributes).to be true + end + end + + context 'when attributes contain different values' do + let(:target_a) do + described_class.new(1, :activity, attributes: { + input: [request_class.new(id: 42, name: 'test')] + }) + end + + let(:target_b) do + described_class.new(1, :activity, attributes: { + input: [request_class.new(id: 99, name: 'other')] + }) + end + + it 'returns false' do + expect(target_a.attributes_equal?(target_b.attributes)).to be false + end + end + + context 'when attributes contain standard types only' do + let(:target_a) do + described_class.new(1, :activity, attributes: { + activity_id: 1, + activity_type: 'MyActivity', + input: ['foo', 'bar', { 'key' => 'value' }] + }) + end + + let(:target_b) do + described_class.new(1, :activity, attributes: { + activity_id: 1, + activity_type: 'MyActivity', + input: ['foo', 'bar', { 'key' => 'value' }] + }) + end + + it 'returns true' do + expect(target_a.attributes_equal?(target_b.attributes)).to be true + end + end + + context 'when attributes have different keys' do + let(:target_a) do + described_class.new(1, :activity, attributes: { a: 1 }) + end + + let(:target_b) do + described_class.new(1, :activity, attributes: { b: 1 }) + end + + it 'returns false' do + expect(target_a.attributes_equal?(target_b.attributes)).to be false + end + end + + context 'when both attributes are empty' do + let(:target_a) { described_class.new(1, :timer, attributes: {}) } + let(:target_b) { described_class.new(1, :timer, attributes: {}) } + + it 'returns true' do + expect(target_a.attributes_equal?(target_b.attributes)).to be true + end + end + + context 'when one attributes is nil' do + let(:target) { described_class.new(1, :timer, attributes: { a: 1 }) } + + it 'returns false' do + expect(target.attributes_equal?(nil)).to be false + end + end + + context 'with nested plain objects' do + let(:inner_class) do + Class.new do + attr_reader :value + def initialize(value:) + @value = value + end + end + end + + let(:outer_class) do + ic = inner_class + Class.new do + attr_reader :inner + define_method(:initialize) do |inner:| + @inner = ic.new(value: inner) + end + end + end + + let(:target_a) do + described_class.new(1, :activity, attributes: { + input: [outer_class.new(inner: 'hello')] + }) + end + + let(:target_b) do + described_class.new(1, :activity, attributes: { + input: [outer_class.new(inner: 'hello')] + }) + end + + it 'recursively compares nested objects' do + expect(target_a.attributes_equal?(target_b.attributes)).to be true + end + end + end + describe '.from_event for specific event types' do subject { described_class.from_event(event) } let(:event) { Temporal::Workflow::History::Event.new(raw_event) } From 5406a57a5e11870adc4aad30ef3ead0e226af7e0 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Tue, 10 Feb 2026 05:42:15 -0800 Subject: [PATCH 09/14] Remove stray changelog backup file Co-authored-by: Cursor --- CHANGELOG.md.orig | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 CHANGELOG.md.orig diff --git a/CHANGELOG.md.orig b/CHANGELOG.md.orig deleted file mode 100644 index 6f4e95ef..00000000 --- a/CHANGELOG.md.orig +++ /dev/null @@ -1,46 +0,0 @@ -# Changelog - -<<<<<<< HEAD -## 0.1.1 -Allows signals to be processed within the first workflow task. - -**IMPORTANT:** This change is backward compatible, but workflows started -on this version cannot run on earlier versions. If you roll back, you will -see workflow task failures mentioning an unknown SDK flag. This will prevent -those workflows from making progress until your code is rolled forward -again. If you'd like to roll this out more gradually, you can, -1. Set the `no_signals_in_first_task` configuration option to `true` -2. Deploy your worker -3. Wait until you are certain you won't need to roll back -4. Remove the configuration option, which will default it to `false` -5. Deploy your worker - -## 0.1.0 - -This introduces signal first ordering. See https://github.com/coinbase/temporal-ruby/issues/258 for -details on why this is necessary for correct handling of signals. - -**IMPORTANT: ** This feature requires Temporal server 1.20.0 or newer. If you are running an older -version of the server, you must either upgrade to at least this version, or you can set the -`.legacy_signals` configuration option to true until you can upgrade. - -If you do not have existing workflows with signals running or are standing up a worker service -for the first time, you can ignore all the below instructions. - -If you have any workflows with signals running during a deployment and run more than one worker -process, you must follow these rollout steps to avoid non-determinism errors: -1. Set `.legacy_signals` in `Temporal::Configuration` to true -2. Deploy your worker -3. Remove the `.legacy_signals` setting or set it to `false` -4. Deploy your worker - -These steps ensure any workflow that executes in signals first mode will continue to be executed -in this order on replay. If you don't follow these steps, you may see failed workflow tasks, which -in some cases could result in unrecoverable history corruption. -======= -## 0.0.4 -- Patching - -## 0.0.1 -- First release ->>>>>>> c036b28 (Improvement for more deterministic workflow behavior) From 34775029d014b5a28c11362c7a637909bf179f55 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Tue, 10 Feb 2026 05:52:33 -0800 Subject: [PATCH 10/14] Remove stray version backup file Co-authored-by: Cursor --- lib/temporal/version.rb.orig | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 lib/temporal/version.rb.orig diff --git a/lib/temporal/version.rb.orig b/lib/temporal/version.rb.orig deleted file mode 100644 index 2220073a..00000000 --- a/lib/temporal/version.rb.orig +++ /dev/null @@ -1,7 +0,0 @@ -module Temporal -<<<<<<< HEAD - VERSION = '0.1.1'.freeze -======= - VERSION = '0.0.4'.freeze ->>>>>>> c036b28 (Improvement for more deterministic workflow behavior) -end From 95273748b76b4e9bc8dd1b1400b4c2cc988349ed Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Tue, 10 Feb 2026 05:55:15 -0800 Subject: [PATCH 11/14] Require serializer and payloads in grpc connection Co-authored-by: Cursor --- lib/temporal/connection/grpc.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/temporal/connection/grpc.rb b/lib/temporal/connection/grpc.rb index c0cbca54..092faf96 100644 --- a/lib/temporal/connection/grpc.rb +++ b/lib/temporal/connection/grpc.rb @@ -7,7 +7,12 @@ require 'gen/temporal/api/operatorservice/v1/service_services_pb' require 'gen/temporal/api/enums/v1/workflow_pb' require 'gen/temporal/api/enums/v1/common_pb' +require 'temporal/connection/errors' require 'temporal/connection/interceptors/client_name_version_interceptor' +require 'temporal/connection/serializer' +require 'temporal/connection/serializer/failure' +require 'temporal/connection/serializer/workflow_id_reuse_policy' +require 'temporal/concerns/payloads' module Temporal module Connection From 9107848549c9757b804a84fae772a8e9696229a5 Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Tue, 10 Feb 2026 06:42:04 -0800 Subject: [PATCH 12/14] Test custom equality during replay. Cover objects with custom == to ensure replay comparison uses the class-defined implementation. Co-authored-by: Cursor --- .../workflow/history/event_target_spec.rb | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/spec/unit/lib/temporal/workflow/history/event_target_spec.rb b/spec/unit/lib/temporal/workflow/history/event_target_spec.rb index eb0e51fb..1c4ee237 100644 --- a/spec/unit/lib/temporal/workflow/history/event_target_spec.rb +++ b/spec/unit/lib/temporal/workflow/history/event_target_spec.rb @@ -117,6 +117,21 @@ def initialize(id:, name:) end end + let(:custom_eq_class) do + Class.new do + attr_reader :id, :name + + def initialize(id:, name:) + @id = id + @name = name + end + + def ==(other) + other.is_a?(self.class) && id == other.id + end + end + end + context 'when attributes contain plain objects without custom ==' do let(:target_a) do described_class.new(1, :activity, attributes: { @@ -144,6 +159,24 @@ def initialize(id:, name:) end end + context 'when attributes contain objects with custom ==' do + let(:target_a) do + described_class.new(1, :activity, attributes: { + input: [custom_eq_class.new(id: 7, name: 'alpha')] + }) + end + + let(:target_b) do + described_class.new(1, :activity, attributes: { + input: [custom_eq_class.new(id: 7, name: 'beta')] + }) + end + + it 'uses the custom == implementation' do + expect(target_a.attributes_equal?(target_b.attributes)).to be true + end + end + context 'when attributes contain different values' do let(:target_a) do described_class.new(1, :activity, attributes: { From 93cb818d43fab4531219ba00b4b7362730cc135a Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Tue, 10 Feb 2026 07:00:09 -0800 Subject: [PATCH 13/14] Add autoload gRPC load-order checks. Cover that gRPC stays unloaded on require and loads when the connection class is referenced. Co-authored-by: Cursor --- spec/unit/lib/temporal/load_order_spec.rb | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/spec/unit/lib/temporal/load_order_spec.rb b/spec/unit/lib/temporal/load_order_spec.rb index 0d5aa831..c7446132 100644 --- a/spec/unit/lib/temporal/load_order_spec.rb +++ b/spec/unit/lib/temporal/load_order_spec.rb @@ -1,6 +1,24 @@ +require 'open3' +require 'rbconfig' require 'temporal' describe 'Temporal load order' do + def run_clean_ruby(script) + root = File.expand_path('../../../..', __dir__) + lib_path = File.join(root, 'lib') + gemfile = File.join(root, 'Gemfile') + env = { 'BUNDLE_GEMFILE' => gemfile } + + Open3.capture3( + env, + RbConfig.ruby, + '-rbundler/setup', + '-I', lib_path, + '-e', script, + chdir: root + ) + end + it 'loads payload concerns without gRPC' do expect(Temporal::Concerns::Payloads).to be_a(Module) end @@ -16,4 +34,28 @@ it 'loads workflow history event class at boot' do expect(Temporal::Workflow::History::Event).to be_a(Class) end + + it 'does not load gRPC at require time' do + script = <<~RUBY + require 'temporal' + puts(defined?(::GRPC::Core::Channel) ? 'loaded' : 'not_loaded') + RUBY + + stdout, stderr, status = run_clean_ruby(script) + expect(status.success?).to be(true), "stderr: #{stderr}" + expect(stdout.strip).to eq('not_loaded') + end + + it 'autoloads gRPC when the connection class is referenced' do + script = <<~RUBY + require 'temporal' + puts(defined?(::GRPC::Core::Channel) ? 'loaded_before' : 'not_loaded_before') + Temporal::Connection::GRPC + puts(defined?(::GRPC::Core::Channel) ? 'loaded_after' : 'not_loaded_after') + RUBY + + stdout, stderr, status = run_clean_ruby(script) + expect(status.success?).to be(true), "stderr: #{stderr}" + expect(stdout.split("\n")).to eq(%w[not_loaded_before loaded_after]) + end end From 40e11c020451e92905b4d71597572cd25a4ef79e Mon Sep 17 00:00:00 2001 From: Ian Yap Date: Tue, 10 Feb 2026 18:07:35 -0800 Subject: [PATCH 14/14] Normalize replay attribute hash comparison Treat symbol/string keys consistently while rejecting ambiguous duplicates so replay determinism checks remain stable. Co-authored-by: Cursor --- lib/temporal/workflow/history/event_target.rb | 34 ++++++++++++++---- .../workflow/history/event_target_spec.rb | 36 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/lib/temporal/workflow/history/event_target.rb b/lib/temporal/workflow/history/event_target.rb index fce2ba22..e0c27ef7 100644 --- a/lib/temporal/workflow/history/event_target.rb +++ b/lib/temporal/workflow/history/event_target.rb @@ -118,10 +118,14 @@ def hash def attributes_equal?(other_attributes) return true if attributes == other_attributes return false if attributes.nil? || other_attributes.nil? - return false if attributes.keys.sort != other_attributes.keys.sort - attributes.all? do |key, value| - self.class.deep_value_equal?(value, other_attributes[key]) + normalized_attributes = self.class.normalize_hash_for_compare(attributes) + normalized_other = self.class.normalize_hash_for_compare(other_attributes) + return false if normalized_attributes.nil? || normalized_other.nil? + return false unless normalized_attributes.size == normalized_other.size + + normalized_attributes.all? do |key, value| + normalized_other.key?(key) && self.class.deep_value_equal?(value, normalized_other[key]) end end @@ -139,10 +143,16 @@ def self.deep_value_equal?(a, b) return a.zip(b).all? { |x, y| deep_value_equal?(x, y) } end - # Hashes: compare key-by-key + # Hashes: compare key-by-key (normalize symbol keys to strings) if a.is_a?(Hash) && b.is_a?(Hash) - return false if a.keys.sort_by(&:to_s) != b.keys.sort_by(&:to_s) - return a.all? { |k, v| deep_value_equal?(v, b[k]) } + normalized_a = normalize_hash_for_compare(a) + normalized_b = normalize_hash_for_compare(b) + return false if normalized_a.nil? || normalized_b.nil? + return false unless normalized_a.size == normalized_b.size + + return normalized_a.all? do |key, value| + normalized_b.key?(key) && deep_value_equal?(value, normalized_b[key]) + end end # If the class defines its own == (not inherited from BasicObject/Object) @@ -161,6 +171,18 @@ def self.deep_value_equal?(a, b) def to_s "#{type}: #{id} (#{attributes})" end + + def self.normalize_hash_for_compare(hash) + normalized = {} + hash.each do |key, value| + normalized_key = key.is_a?(Symbol) ? key.to_s : key + return nil if normalized.key?(normalized_key) + + normalized[normalized_key] = value + end + + normalized + end end end end diff --git a/spec/unit/lib/temporal/workflow/history/event_target_spec.rb b/spec/unit/lib/temporal/workflow/history/event_target_spec.rb index 1c4ee237..44b1a35a 100644 --- a/spec/unit/lib/temporal/workflow/history/event_target_spec.rb +++ b/spec/unit/lib/temporal/workflow/history/event_target_spec.rb @@ -217,6 +217,42 @@ def ==(other) end end + context 'when attributes contain hashes with symbol and string keys' do + let(:target_a) do + described_class.new(1, :activity, attributes: { + input: [{ foo: 'bar', nested: { baz: 1 } }] + }) + end + + let(:target_b) do + described_class.new(1, :activity, attributes: { + input: [{ 'foo' => 'bar', 'nested' => { 'baz' => 1 } }] + }) + end + + it 'treats symbol and string keys as equivalent' do + expect(target_a.attributes_equal?(target_b.attributes)).to be true + end + end + + context 'when hashes contain duplicate symbol and string keys' do + let(:target_a) do + described_class.new(1, :activity, attributes: { + input: [{ foo: 'bar', 'foo' => 'bar' }] + }) + end + + let(:target_b) do + described_class.new(1, :activity, attributes: { + input: [{ 'foo' => 'bar' }] + }) + end + + it 'returns false to avoid ambiguous key comparisons' do + expect(target_a.attributes_equal?(target_b.attributes)).to be false + end + end + context 'when attributes have different keys' do let(:target_a) do described_class.new(1, :activity, attributes: { a: 1 })