From 99575499bc2235f084b0dd1cc66f96315049650b Mon Sep 17 00:00:00 2001 From: Nadav Ofir Date: Thu, 25 Sep 2025 15:58:25 +0300 Subject: [PATCH] gen-ai priority support (#1) --- lib/gen/temporal/api/command/v1/message_pb.rb | 1 + lib/gen/temporal/api/common/v1/message_pb.rb | 6 + .../workflowservice/v1/request_response_pb.rb | 2 + lib/temporal/client.rb | 5 + lib/temporal/concerns/executable.rb | 5 + lib/temporal/connection/grpc.rb | 9 +- .../connection/serializer/priority.rb | 20 +++ .../serializer/start_child_workflow.rb | 2 + lib/temporal/execution_options.rb | 12 +- lib/temporal/priority.rb | 35 ++++ lib/temporal/workflow/command.rb | 2 +- lib/temporal/workflow/context.rb | 1 + spec/shared_examples/an_executable.rb | 16 ++ spec/unit/lib/temporal/client_spec.rb | 32 ++++ .../connection/serializer/priority_spec.rb | 79 +++++++++ .../lib/temporal/execution_options_spec.rb | 36 ++++ spec/unit/lib/temporal/priority_spec.rb | 157 ++++++++++++++++++ 17 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 lib/temporal/connection/serializer/priority.rb create mode 100644 lib/temporal/priority.rb create mode 100644 spec/unit/lib/temporal/connection/serializer/priority_spec.rb create mode 100644 spec/unit/lib/temporal/priority_spec.rb diff --git a/lib/gen/temporal/api/command/v1/message_pb.rb b/lib/gen/temporal/api/command/v1/message_pb.rb index 0dd08254..5d52724b 100644 --- a/lib/gen/temporal/api/command/v1/message_pb.rb +++ b/lib/gen/temporal/api/command/v1/message_pb.rb @@ -107,6 +107,7 @@ optional :header, :message, 14, "temporal.api.common.v1.Header" optional :memo, :message, 15, "temporal.api.common.v1.Memo" optional :search_attributes, :message, 16, "temporal.api.common.v1.SearchAttributes" + optional :priority, :message, 18, "temporal.api.common.v1.Priority" end add_message "temporal.api.command.v1.ProtocolMessageCommandAttributes" do optional :message_id, :string, 1 diff --git a/lib/gen/temporal/api/common/v1/message_pb.rb b/lib/gen/temporal/api/common/v1/message_pb.rb index 52c50880..ed44465f 100644 --- a/lib/gen/temporal/api/common/v1/message_pb.rb +++ b/lib/gen/temporal/api/common/v1/message_pb.rb @@ -56,6 +56,11 @@ add_message "temporal.api.common.v1.WorkerVersionCapabilities" do optional :build_id, :string, 1 end + add_message "temporal.api.common.v1.Priority" do + optional :priority_key, :int32, 1 + optional :fairness_key, :string, 2 + optional :fairness_weight, :float, 3 + end end end @@ -76,6 +81,7 @@ module V1 MeteringMetadata = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("temporal.api.common.v1.MeteringMetadata").msgclass WorkerVersionStamp = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("temporal.api.common.v1.WorkerVersionStamp").msgclass WorkerVersionCapabilities = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("temporal.api.common.v1.WorkerVersionCapabilities").msgclass + Priority = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("temporal.api.common.v1.Priority").msgclass end end end diff --git a/lib/gen/temporal/api/workflowservice/v1/request_response_pb.rb b/lib/gen/temporal/api/workflowservice/v1/request_response_pb.rb index ab356996..2b9adbb1 100644 --- a/lib/gen/temporal/api/workflowservice/v1/request_response_pb.rb +++ b/lib/gen/temporal/api/workflowservice/v1/request_response_pb.rb @@ -114,6 +114,7 @@ optional :continued_failure, :message, 18, "temporal.api.failure.v1.Failure" optional :last_completion_result, :message, 19, "temporal.api.common.v1.Payloads" optional :workflow_start_delay, :message, 20, "google.protobuf.Duration" + optional :priority, :message, 27, "temporal.api.common.v1.Priority" end add_message "temporal.api.workflowservice.v1.StartWorkflowExecutionResponse" do optional :run_id, :string, 1 @@ -348,6 +349,7 @@ optional :header, :message, 19, "temporal.api.common.v1.Header" optional :workflow_start_delay, :message, 20, "google.protobuf.Duration" optional :skip_generate_workflow_task, :bool, 21 + optional :priority, :message, 26, "temporal.api.common.v1.Priority" end add_message "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse" do optional :run_id, :string, 1 diff --git a/lib/temporal/client.rb b/lib/temporal/client.rb index c2e83e1f..2cb4052a 100644 --- a/lib/temporal/client.rb +++ b/lib/temporal/client.rb @@ -38,6 +38,7 @@ def initialize(config) # options[:signal_input] is specified. # @option options [String, Array, nil] :signal_input corresponds to the 'input' argument to signal_workflow # @option options [Hash] :retry_policy check Temporal::RetryPolicy for available options + # @option options [Hash] :priority check Temporal::Priority for available options # @option options [Hash] :timeouts check Temporal::Configuration::DEFAULT_TIMEOUTS # @option options [Hash] :headers # @option options [Hash] :search_attributes @@ -68,6 +69,7 @@ def start_workflow(workflow, *input, options: {}, **args) headers: config.header_propagator_chain.inject(execution_options.headers), memo: execution_options.memo, search_attributes: Workflow::Context::Helpers.process_search_attributes(execution_options.search_attributes), + priority: execution_options.priority, start_delay: execution_options.start_delay ) else @@ -86,6 +88,7 @@ def start_workflow(workflow, *input, options: {}, **args) headers: config.header_propagator_chain.inject(execution_options.headers), memo: execution_options.memo, search_attributes: Workflow::Context::Helpers.process_search_attributes(execution_options.search_attributes), + priority: execution_options.priority, signal_name: signal_name, signal_input: signal_input, start_delay: execution_options.start_delay @@ -109,6 +112,7 @@ def start_workflow(workflow, *input, options: {}, **args) # @option options [String] :namespace # @option options [String] :task_queue # @option options [Hash] :retry_policy check Temporal::RetryPolicy for available options + # @option options [Hash] :priority check Temporal::Priority for available options # @option options [Hash] :timeouts check Temporal::Configuration::DEFAULT_TIMEOUTS # @option options [Hash] :headers # @option options [Hash] :search_attributes @@ -137,6 +141,7 @@ def schedule_workflow(workflow, cron_schedule, *input, options: {}, **args) cron_schedule: cron_schedule, memo: execution_options.memo, search_attributes: Workflow::Context::Helpers.process_search_attributes(execution_options.search_attributes), + priority: execution_options.priority, ) response.run_id diff --git a/lib/temporal/concerns/executable.rb b/lib/temporal/concerns/executable.rb index 17e15362..03ad3e55 100644 --- a/lib/temporal/concerns/executable.rb +++ b/lib/temporal/concerns/executable.rb @@ -29,6 +29,11 @@ def headers(*args) return @headers if args.empty? @headers = args.first end + + def priority(*args) + return @priority if args.empty? + @priority = args.first + end end end end diff --git a/lib/temporal/connection/grpc.rb b/lib/temporal/connection/grpc.rb index f35d2dc3..21dfd391 100644 --- a/lib/temporal/connection/grpc.rb +++ b/lib/temporal/connection/grpc.rb @@ -14,6 +14,7 @@ require 'temporal/connection/serializer/failure' require 'temporal/connection/serializer/backfill' require 'temporal/connection/serializer/schedule' +require 'temporal/connection/serializer/priority' require 'temporal/connection/serializer/workflow_id_reuse_policy' module Temporal @@ -117,6 +118,7 @@ def start_workflow_execution( task_timeout:, input: nil, workflow_id_reuse_policy: nil, + priority: nil, headers: nil, cron_schedule: nil, memo: nil, @@ -149,7 +151,8 @@ def start_workflow_execution( ), search_attributes: Temporalio::Api::Common::V1::SearchAttributes.new( indexed_fields: converter.to_payload_map_without_codec(search_attributes || {}) - ) + ), + priority: priority ? Temporal::Connection::Serializer::Priority.new(priority, converter).to_proto : nil ) client.start_workflow_execution(request) @@ -378,6 +381,7 @@ def signal_with_start_workflow_execution( task_queue:, execution_timeout:, run_timeout:, task_timeout:, signal_name:, signal_input:, input: nil, workflow_id_reuse_policy: nil, + priority: nil, headers: nil, cron_schedule: nil, memo: nil, @@ -422,7 +426,8 @@ def signal_with_start_workflow_execution( ), search_attributes: Temporalio::Api::Common::V1::SearchAttributes.new( indexed_fields: converter.to_payload_map_without_codec(search_attributes || {}) - ) + ), + priority: priority ? Temporal::Connection::Serializer::Priority.new(priority, converter).to_proto : nil ) client.signal_with_start_workflow_execution(request) diff --git a/lib/temporal/connection/serializer/priority.rb b/lib/temporal/connection/serializer/priority.rb new file mode 100644 index 00000000..19f29e64 --- /dev/null +++ b/lib/temporal/connection/serializer/priority.rb @@ -0,0 +1,20 @@ +require 'temporal/connection/serializer/base' + +module Temporal + module Connection + module Serializer + class Priority < Base + def to_proto + return unless object + + # Create a Priority proto object + Temporalio::Api::Common::V1::Priority.new( + priority_key: object.priority_key || 0, + fairness_key: object.fairness_key || '', + fairness_weight: object.fairness_weight || 0.0 + ) + end + end + end + end +end \ No newline at end of file diff --git a/lib/temporal/connection/serializer/start_child_workflow.rb b/lib/temporal/connection/serializer/start_child_workflow.rb index dcb2fbf0..f98d4fec 100644 --- a/lib/temporal/connection/serializer/start_child_workflow.rb +++ b/lib/temporal/connection/serializer/start_child_workflow.rb @@ -1,5 +1,6 @@ require 'temporal/connection/serializer/base' require 'temporal/connection/serializer/retry_policy' +require 'temporal/connection/serializer/priority' require 'temporal/connection/serializer/workflow_id_reuse_policy' module Temporal @@ -26,6 +27,7 @@ def to_proto workflow_run_timeout: object.timeouts[:run], workflow_task_timeout: object.timeouts[:task], retry_policy: Temporal::Connection::Serializer::RetryPolicy.new(object.retry_policy, converter).to_proto, + priority: Temporal::Connection::Serializer::Priority.new(object.priority, converter).to_proto, parent_close_policy: serialize_parent_close_policy(object.parent_close_policy), header: serialize_headers(object.headers), cron_schedule: object.cron_schedule, diff --git a/lib/temporal/execution_options.rb b/lib/temporal/execution_options.rb index d3319cb8..773008c4 100644 --- a/lib/temporal/execution_options.rb +++ b/lib/temporal/execution_options.rb @@ -1,9 +1,10 @@ require 'temporal/concerns/executable' require 'temporal/retry_policy' +require 'temporal/priority' module Temporal class ExecutionOptions - attr_reader :name, :namespace, :task_queue, :retry_policy, :timeouts, :headers, :memo, :search_attributes, + attr_reader :name, :namespace, :task_queue, :retry_policy, :priority, :timeouts, :headers, :memo, :search_attributes, :start_delay def initialize(object, options, defaults = nil) @@ -12,6 +13,7 @@ def initialize(object, options, defaults = nil) @namespace = options[:namespace] @task_queue = options[:task_queue] || options[:task_list] @retry_policy = options[:retry_policy] || {} + @priority = options[:priority] || {} @timeouts = options[:timeouts] || {} @headers = options[:headers] || {} @memo = options[:memo] || {} @@ -23,6 +25,7 @@ def initialize(object, options, defaults = nil) @namespace ||= object.namespace @task_queue ||= object.task_queue @retry_policy = object.retry_policy.merge(@retry_policy) if object.retry_policy + @priority = object.priority.merge(@priority) if object.priority @timeouts = object.timeouts.merge(@timeouts) if object.timeouts @headers = object.headers.merge(@headers) if object.headers end @@ -43,6 +46,13 @@ def initialize(object, options, defaults = nil) @retry_policy.validate! end + if @priority.empty? + @priority = nil + else + @priority = Temporal::Priority.new(@priority) + @priority.validate! + end + freeze end diff --git a/lib/temporal/priority.rb b/lib/temporal/priority.rb new file mode 100644 index 00000000..6ee5091b --- /dev/null +++ b/lib/temporal/priority.rb @@ -0,0 +1,35 @@ +require 'temporal/errors' + +module Temporal + # Priority configuration for workflow execution + # Similar to retry_policy, priority is represented as a JSON object with predefined keys + class Priority < Struct.new(:priority_key, :fairness_key, :fairness_weight, keyword_init: true) + + class InvalidPriority < ClientError; end + + def validate! + # PriorityKey should be a number if provided + if priority_key && !priority_key.is_a?(Numeric) + raise InvalidPriority, 'PriorityKey must be a number' + end + + # FairnessKey should be a string if provided + if fairness_key && !fairness_key.is_a?(String) + raise InvalidPriority, 'FairnessKey must be a string' + end + + # FairnessWeight should be a number if provided + if fairness_weight && !fairness_weight.is_a?(Numeric) + raise InvalidPriority, 'FairnessWeight must be a number' + end + end + + def to_hash + hash = {} + hash['PriorityKey'] = priority_key if priority_key + hash['FairnessKey'] = fairness_key if fairness_key + hash['FairnessWeight'] = fairness_weight if fairness_weight + hash + end + end +end \ No newline at end of file diff --git a/lib/temporal/workflow/command.rb b/lib/temporal/workflow/command.rb index 9d33aaea..8cef938b 100644 --- a/lib/temporal/workflow/command.rb +++ b/lib/temporal/workflow/command.rb @@ -3,7 +3,7 @@ class Workflow module Command # TODO: Move these classes into their own directories under workflow/command/* ScheduleActivity = Struct.new(:activity_type, :activity_id, :input, :task_queue, :retry_policy, :timeouts, :headers, keyword_init: true) - StartChildWorkflow = Struct.new(:workflow_type, :workflow_id, :input, :namespace, :task_queue, :retry_policy, :parent_close_policy, :timeouts, :headers, :cron_schedule, :memo, :workflow_id_reuse_policy, :search_attributes, keyword_init: true) + StartChildWorkflow = Struct.new(:workflow_type, :workflow_id, :input, :namespace, :task_queue, :retry_policy, :priority, :parent_close_policy, :timeouts, :headers, :cron_schedule, :memo, :workflow_id_reuse_policy, :search_attributes, keyword_init: true) ContinueAsNew = Struct.new(:workflow_type, :task_queue, :input, :timeouts, :retry_policy, :headers, :memo, :search_attributes, keyword_init: true) RequestActivityCancellation = Struct.new(:activity_id, keyword_init: true) RecordMarker = Struct.new(:name, :details, keyword_init: true) diff --git a/lib/temporal/workflow/context.rb b/lib/temporal/workflow/context.rb index 07b917a2..e0dcb138 100644 --- a/lib/temporal/workflow/context.rb +++ b/lib/temporal/workflow/context.rb @@ -143,6 +143,7 @@ def execute_workflow(workflow_class, *input, **args) namespace: execution_options.namespace, task_queue: execution_options.task_queue, retry_policy: execution_options.retry_policy, + priority: execution_options.priority, parent_close_policy: parent_close_policy, timeouts: execution_options.timeouts, headers: config.header_propagator_chain.inject(execution_options.headers), diff --git a/spec/shared_examples/an_executable.rb b/spec/shared_examples/an_executable.rb index 3dd24b9e..61c0370b 100644 --- a/spec/shared_examples/an_executable.rb +++ b/spec/shared_examples/an_executable.rb @@ -62,4 +62,20 @@ expect(described_class.instance_variable_get(:@timeouts)).to eq(:test) end end + + describe '.priority' do + after { described_class.remove_instance_variable(:@priority) } + + it 'gets current priority' do + described_class.instance_variable_set(:@priority, :test) + + expect(described_class.priority).to eq(:test) + end + + it 'sets new priority' do + described_class.priority(:test) + + expect(described_class.instance_variable_get(:@priority)).to eq(:test) + end + end end diff --git a/spec/unit/lib/temporal/client_spec.rb b/spec/unit/lib/temporal/client_spec.rb index 31dc4a78..24315539 100644 --- a/spec/unit/lib/temporal/client_spec.rb +++ b/spec/unit/lib/temporal/client_spec.rb @@ -65,6 +65,7 @@ def inject!(header) headers: { 'test' => 'asdf' }, memo: {}, search_attributes: {}, + priority: nil, start_delay: 0 ) end @@ -95,6 +96,7 @@ def inject!(header) headers: {}, memo: {}, search_attributes: {}, + priority: nil, start_delay: 0 ) end @@ -234,6 +236,35 @@ def inject!(header) search_attributes: {}, start_delay: 0 ) + end + + it 'starts a workflow with priority options' do + subject.start_workflow( + TestStartWorkflow, + 42, + options: { + priority: { priority_key: 10, fairness_key: 'production', fairness_weight: 0.8 } + } + ) + + expect(connection) + .to have_received(:start_workflow_execution) + .with( + namespace: 'default-test-namespace', + workflow_id: an_instance_of(String), + workflow_name: 'TestStartWorkflow', + task_queue: 'default-test-task-queue', + input: [42], + task_timeout: config.timeouts[:task], + run_timeout: config.timeouts[:run], + execution_timeout: config.timeouts[:execution], + workflow_id_reuse_policy: nil, + headers: {}, + memo: {}, + search_attributes: {}, + priority: an_instance_of(Temporal::Priority), + start_delay: 0 + ) end end end @@ -263,6 +294,7 @@ def expect_signal_with_start(expected_arguments, expected_signal_argument) search_attributes: {}, signal_name: 'the question', signal_input: expected_signal_argument, + priority: nil, start_delay: 0 ) end diff --git a/spec/unit/lib/temporal/connection/serializer/priority_spec.rb b/spec/unit/lib/temporal/connection/serializer/priority_spec.rb new file mode 100644 index 00000000..830645db --- /dev/null +++ b/spec/unit/lib/temporal/connection/serializer/priority_spec.rb @@ -0,0 +1,79 @@ +require 'temporal/connection/serializer/priority' + +describe Temporal::Connection::Serializer::Priority do + subject { described_class.new(priority) } + + describe '#to_proto' do + context 'when priority is nil' do + let(:priority) { nil } + + it 'returns nil' do + expect(subject.to_proto).to be_nil + end + end + + context 'when priority has all fields' do + let(:priority) { Temporal::Priority.new(priority_key: 10, fairness_key: 'production', fairness_weight: 0.8) } + + it 'creates correct proto object' do + proto = subject.to_proto + + expect(proto).to be_an_instance_of(Temporalio::Api::Common::V1::Priority) + expect(proto.priority_key).to eq(10) + expect(proto.fairness_key).to eq('production') + expect(proto.fairness_weight).to eq(0.8) + end + end + + context 'when priority has only priority_key' do + let(:priority) { Temporal::Priority.new(priority_key: 5, fairness_key: nil) } + + it 'creates proto object with only priority_key' do + proto = subject.to_proto + + expect(proto).to be_an_instance_of(Temporalio::Api::Common::V1::Priority) + expect(proto.priority_key).to eq(5) + expect(proto.fairness_key).to eq('') + end + end + + context 'when priority has only fairness_key' do + let(:priority) { Temporal::Priority.new(priority_key: nil, fairness_key: 'staging', fairness_weight: nil) } + + it 'creates proto object with only fairness_key' do + proto = subject.to_proto + + expect(proto).to be_an_instance_of(Temporalio::Api::Common::V1::Priority) + expect(proto.priority_key).to eq(0) + expect(proto.fairness_key).to eq('staging') + expect(proto.fairness_weight).to eq(0.0) + end + end + + context 'when priority has only fairness_weight' do + let(:priority) { Temporal::Priority.new(priority_key: nil, fairness_key: nil, fairness_weight: 0.5) } + + it 'creates proto object with only fairness_weight' do + proto = subject.to_proto + + expect(proto).to be_an_instance_of(Temporalio::Api::Common::V1::Priority) + expect(proto.priority_key).to eq(0) + expect(proto.fairness_key).to eq('') + expect(proto.fairness_weight).to eq(0.5) + end + end + + context 'when priority has no keys' do + let(:priority) { Temporal::Priority.new(priority_key: nil, fairness_key: nil, fairness_weight: nil) } + + it 'creates empty proto object' do + proto = subject.to_proto + + expect(proto).to be_an_instance_of(Temporalio::Api::Common::V1::Priority) + expect(proto.priority_key).to eq(0) + expect(proto.fairness_key).to eq('') + expect(proto.fairness_weight).to eq(0.0) + end + end + end +end \ No newline at end of file diff --git a/spec/unit/lib/temporal/execution_options_spec.rb b/spec/unit/lib/temporal/execution_options_spec.rb index d0c9d017..6d3f62d1 100644 --- a/spec/unit/lib/temporal/execution_options_spec.rb +++ b/spec/unit/lib/temporal/execution_options_spec.rb @@ -128,6 +128,32 @@ class TestExecutionOptionsWorkflow < Temporal::Workflow ) end end + + context 'when priority options are provided' do + let(:options) do + { + priority: { priority_key: 10, fairness_key: 'production', fairness_weight: 0.8 } + } + end + + it 'is initialized with priority' do + expect(subject.priority).to be_an_instance_of(Temporal::Priority) + expect(subject.priority.priority_key).to eq(10) + expect(subject.priority.fairness_key).to eq('production') + expect(subject.priority.fairness_weight).to eq(0.8) + end + end + + context 'when priority options are invalid' do + let(:options) { { priority: { priority_key: 'invalid' } } } + + it 'raises' do + expect { subject }.to raise_error( + Temporal::Priority::InvalidPriority, + 'PriorityKey must be a number' + ) + end + end end @@ -136,6 +162,7 @@ class TestWorkflow < Temporal::Workflow namespace 'namespace' task_queue 'task-queue' retry_policy interval: 1, backoff: 2, max_attempts: 5 + priority priority_key: 5, fairness_key: 'test-fairness', fairness_weight: 0.75 timeouts start_to_close: 10 headers 'HeaderA' => 'TestA', 'HeaderB' => 'TestB' end @@ -151,6 +178,10 @@ class TestWorkflow < Temporal::Workflow expect(subject.retry_policy.interval).to eq(1) expect(subject.retry_policy.backoff).to eq(2) expect(subject.retry_policy.max_attempts).to eq(5) + expect(subject.priority).to be_an_instance_of(Temporal::Priority) + expect(subject.priority.priority_key).to eq(5) + expect(subject.priority.fairness_key).to eq('test-fairness') + expect(subject.priority.fairness_weight).to eq(0.75) expect(subject.timeouts).to eq(start_to_close: 10) expect(subject.headers).to eq('HeaderA' => 'TestA', 'HeaderB' => 'TestB') end @@ -161,6 +192,7 @@ class TestWorkflow < Temporal::Workflow name: 'OtherTestWorkflow', task_queue: 'test-task-queue', retry_policy: { interval: 2, max_attempts: 10 }, + priority: { priority_key: 15, fairness_key: 'production', fairness_weight: 0.9 }, timeouts: { schedule_to_close: 20 }, headers: { 'TestHeader' => 'Value', 'HeaderB' => 'ValueB' } } @@ -174,6 +206,10 @@ class TestWorkflow < Temporal::Workflow expect(subject.retry_policy.interval).to eq(2) expect(subject.retry_policy.backoff).to eq(2) expect(subject.retry_policy.max_attempts).to eq(10) + expect(subject.priority).to be_an_instance_of(Temporal::Priority) + expect(subject.priority.priority_key).to eq(15) + expect(subject.priority.fairness_key).to eq('production') + expect(subject.priority.fairness_weight).to eq(0.9) expect(subject.timeouts).to eq(schedule_to_close: 20, start_to_close: 10) expect(subject.headers).to eq( 'TestHeader' => 'Value', diff --git a/spec/unit/lib/temporal/priority_spec.rb b/spec/unit/lib/temporal/priority_spec.rb new file mode 100644 index 00000000..651b1f9d --- /dev/null +++ b/spec/unit/lib/temporal/priority_spec.rb @@ -0,0 +1,157 @@ +require 'temporal/priority' + +describe Temporal::Priority do + describe '#validate!' do + subject { described_class.new(attributes) } + + let(:valid_attributes) do + { + priority_key: 10, + fairness_key: 'test-fairness', + fairness_weight: 0.75 + } + end + + let(:empty_attributes) do + { + priority_key: nil, + fairness_key: nil, + fairness_weight: nil + } + end + + let(:partial_attributes) do + { + priority_key: 5, + fairness_key: nil, + fairness_weight: nil + } + end + + shared_examples 'error' do |message| + it 'raises InvalidPriority error' do + expect { subject.validate! }.to raise_error(described_class::InvalidPriority, message) + end + end + + context 'with valid attributes' do + let(:attributes) { valid_attributes } + + it 'does not raise' do + expect { subject.validate! }.not_to raise_error + end + + it 'has correct values' do + expect(subject.priority_key).to eq(10) + expect(subject.fairness_key).to eq('test-fairness') + expect(subject.fairness_weight).to eq(0.75) + end + end + + context 'with empty attributes' do + let(:attributes) { empty_attributes } + + it 'does not raise' do + expect { subject.validate! }.not_to raise_error + end + end + + context 'with partial attributes' do + let(:attributes) { partial_attributes } + + it 'does not raise' do + expect { subject.validate! }.not_to raise_error + end + end + + context 'with invalid priority_key' do + context 'when priority_key is a string' do + let(:attributes) { { priority_key: 'invalid', fairness_key: 'test' } } + include_examples 'error', 'PriorityKey must be a number' + end + + context 'when priority_key is an array' do + let(:attributes) { { priority_key: [1, 2, 3], fairness_key: 'test' } } + include_examples 'error', 'PriorityKey must be a number' + end + end + + context 'with invalid fairness_key' do + context 'when fairness_key is a number' do + let(:attributes) { { priority_key: 10, fairness_key: 123 } } + include_examples 'error', 'FairnessKey must be a string' + end + + context 'when fairness_key is a symbol' do + let(:attributes) { { priority_key: 10, fairness_key: :test } } + include_examples 'error', 'FairnessKey must be a string' + end + end + + context 'with invalid fairness_weight' do + context 'when fairness_weight is a string' do + let(:attributes) { { priority_key: 10, fairness_key: 'test', fairness_weight: 'invalid' } } + include_examples 'error', 'FairnessWeight must be a number' + end + + context 'when fairness_weight is an array' do + let(:attributes) { { priority_key: 10, fairness_key: 'test', fairness_weight: [1, 2, 3] } } + include_examples 'error', 'FairnessWeight must be a number' + end + end + end + + describe '#to_hash' do + subject { described_class.new(attributes).to_hash } + + context 'with all fields' do + let(:attributes) { { priority_key: 15, fairness_key: 'production', fairness_weight: 0.8 } } + + it 'returns hash with all fields' do + expect(subject).to eq({ + 'PriorityKey' => 15, + 'FairnessKey' => 'production', + 'FairnessWeight' => 0.8 + }) + end + end + + context 'with only priority_key' do + let(:attributes) { { priority_key: 5, fairness_key: nil } } + + it 'returns hash with only PriorityKey' do + expect(subject).to eq({ + 'PriorityKey' => 5 + }) + end + end + + context 'with only fairness_key' do + let(:attributes) { { priority_key: nil, fairness_key: 'staging', fairness_weight: nil } } + + it 'returns hash with only FairnessKey' do + expect(subject).to eq({ + 'FairnessKey' => 'staging' + }) + end + end + + context 'with only fairness_weight' do + let(:attributes) { { priority_key: nil, fairness_key: nil, fairness_weight: 0.5 } } + + it 'returns hash with only FairnessWeight' do + expect(subject).to eq({ + 'FairnessWeight' => 0.5 + }) + end + end + + context 'with no keys' do + let(:attributes) { { priority_key: nil, fairness_key: nil, fairness_weight: nil } } + + it 'returns empty hash' do + expect(subject).to eq({}) + end + end + end +end \ No newline at end of file