Skip to content

Commit 350951a

Browse files
authored
Dynamic activities (#198)
Fixes #166
1 parent a9034b8 commit 350951a

File tree

7 files changed

+113
-23
lines changed

7 files changed

+113
-23
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,11 @@ Some notes about activity definition:
906906
"Activity Concurrency and Executors" section later for more details.
907907
* Technically an activity definition can be created manually via `Temporalio::Activity::Definition::Info.new` that
908908
accepts a proc or a block, but the class form is recommended.
909+
* `activity_dynamic` can be used to mark an activity dynamic. Dynamic activities do not have names and handle any
910+
activity that is not otherwise registered. A worker can only have one dynamic activity.
911+
* `workflow_raw_args` can be used to have activity arguments delivered to `execute` as
912+
`Temporalio::Converters::RawValue`s. These are wrappers for the raw payloads that have not been converted to types
913+
(but they have been decoded by the codec if present). They can be converted with `payload_converter` on the context.
909914

910915
#### Activity Context
911916

temporalio/lib/temporalio/activity/definition.rb

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,47 @@ def activity_executor(executor_name)
4949
# @param cancel_raise [Boolean] Whether to raise.
5050
def activity_cancel_raise(cancel_raise)
5151
unless cancel_raise.is_a?(TrueClass) || cancel_raise.is_a?(FalseClass)
52-
raise ArgumentError,
53-
'Must be a boolean'
52+
raise ArgumentError, 'Must be a boolean'
5453
end
5554

5655
@activity_cancel_raise = cancel_raise
5756
end
57+
58+
# Set an activity as dynamic. Dynamic activities do not have names and handle any activity that is not otherwise
59+
# registered. A worker can only have one dynamic activity. It is often useful to use {activity_raw_args} with
60+
# this.
61+
#
62+
# @param value [Boolean] Whether the activity is dynamic.
63+
def activity_dynamic(value = true) # rubocop:disable Style/OptionalBooleanParameter
64+
raise ArgumentError, 'Must be a boolean' unless value.is_a?(TrueClass) || value.is_a?(FalseClass)
65+
66+
@activity_dynamic = value
67+
end
68+
69+
# Have activity arguments delivered to `execute` as {Converters::RawValue}s. These are wrappers for the raw
70+
# payloads that have not been converted to types (but they have been decoded by the codec if present). They can
71+
# be converted with {Context#payload_converter}.
72+
#
73+
# @param value [Boolean] Whether the activity accepts raw arguments.
74+
def activity_raw_args(value = true) # rubocop:disable Style/OptionalBooleanParameter
75+
raise ArgumentError, 'Must be a boolean' unless value.is_a?(TrueClass) || value.is_a?(FalseClass)
76+
77+
@activity_raw_args = value
78+
end
5879
end
5980

6081
# @!visibility private
6182
def self._activity_definition_details
83+
activity_name = @activity_name
84+
raise 'Cannot have activity name specified for dynamic activity' if activity_name && @activity_dynamic
85+
86+
# Default to unqualified class name if not dynamic
87+
activity_name ||= name.to_s.split('::').last unless @activity_dynamic
6288
{
63-
activity_name: @activity_name || name.to_s.split('::').last,
89+
activity_name:,
6490
activity_executor: @activity_executor || :default,
65-
activity_cancel_raise: @activity_cancel_raise.nil? ? true : @activity_cancel_raise
91+
activity_cancel_raise: @activity_cancel_raise.nil? ? true : @activity_cancel_raise,
92+
activity_raw_args: @activity_raw_args.nil? ? false : @activity_raw_args
6693
}
6794
end
6895

@@ -75,7 +102,7 @@ def execute(*args)
75102
# Definition info of an activity. Activities are usually classes/instances that extend {Definition}, but
76103
# definitions can also be manually created with a block via {initialize} here.
77104
class Info
78-
# @return [String, Symbol] Name of the activity.
105+
# @return [String, Symbol, nil] Name of the activity, or nil if the activity is dynamic.
79106
attr_reader :name
80107

81108
# @return [Proc] Proc for the activity.
@@ -87,6 +114,9 @@ class Info
87114
# @return [Boolean] Whether to raise in thread/fiber on cancellation. Default is `true`.
88115
attr_reader :cancel_raise
89116

117+
# @return [Boolean] Whether to use {Converters::RawValue}s as arguments.
118+
attr_reader :raw_args
119+
90120
# Obtain definition info representing the given activity, which can be a class, instance, or definition info.
91121
#
92122
# @param activity [Definition, Class<Definition>, Info] Activity to get info for.
@@ -105,14 +135,16 @@ def self.from_activity(activity)
105135
new(
106136
name: details[:activity_name],
107137
executor: details[:activity_executor],
108-
cancel_raise: details[:activity_cancel_raise]
138+
cancel_raise: details[:activity_cancel_raise],
139+
raw_args: details[:activity_raw_args]
109140
) { |*args| activity.new.execute(*args) } # Instantiate and call
110141
when Definition
111142
details = activity.class._activity_definition_details
112143
new(
113144
name: details[:activity_name],
114145
executor: details[:activity_executor],
115-
cancel_raise: details[:activity_cancel_raise]
146+
cancel_raise: details[:activity_cancel_raise],
147+
raw_args: details[:activity_raw_args]
116148
) { |*args| activity.execute(*args) } # Just and call
117149
when Info
118150
activity
@@ -123,17 +155,19 @@ def self.from_activity(activity)
123155

124156
# Manually create activity definition info. Most users will use an instance/class of {Definition}.
125157
#
126-
# @param name [String, Symbol] Name of the activity.
158+
# @param name [String, Symbol, nil] Name of the activity or nil for dynamic activity.
127159
# @param executor [Symbol] Name of the executor.
128160
# @param cancel_raise [Boolean] Whether to raise in thread/fiber on cancellation.
161+
# @param raw_args [Boolean] Whether to use {Converters::RawValue}s as arguments.
129162
# @yield Use this block as the activity.
130-
def initialize(name:, executor: :default, cancel_raise: true, &block)
163+
def initialize(name:, executor: :default, cancel_raise: true, raw_args: false, &block)
131164
@name = name
132165
raise ArgumentError, 'Must give block' unless block_given?
133166

134167
@proc = block
135168
@executor = executor
136169
@cancel_raise = cancel_raise
170+
@raw_args = raw_args
137171
end
138172
end
139173
end

temporalio/lib/temporalio/internal/worker/activity_worker.rb

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require 'temporalio/activity'
44
require 'temporalio/activity/definition'
55
require 'temporalio/cancellation'
6+
require 'temporalio/converters/raw_value'
67
require 'temporalio/internal/bridge/api'
78
require 'temporalio/internal/proto_utils'
89
require 'temporalio/scoped_logger'
@@ -29,12 +30,13 @@ def initialize(worker:, bridge_worker:)
2930
Activity::Context.current_or_nil&._scoped_logger_info
3031
}
3132

32-
# Build up activity hash by name, failing if any fail validation
33+
# Build up activity hash by name (can be nil for dynamic), failing if any fail validation
3334
@activities = worker.options.activities.each_with_object({}) do |act, hash|
3435
# Class means create each time, instance means just call, definition
3536
# does nothing special
3637
defn = Activity::Definition::Info.from_activity(act)
3738
# Confirm name not in use
39+
raise ArgumentError, 'Only one dynamic activity allowed' if !defn.name && hash.key?(defn.name)
3840
raise ArgumentError, "Multiple activities named #{defn.name}" if hash.key?(defn.name)
3941

4042
# Confirm executor is a known executor and let it initialize
@@ -91,8 +93,8 @@ def handle_task(task)
9193
def handle_start_task(task_token, start)
9294
set_running_activity(task_token, nil)
9395

94-
# Find activity definition
95-
defn = @activities[start.activity_type]
96+
# Find activity definition, falling back to dynamic if present
97+
defn = @activities[start.activity_type] || @activities[nil]
9698
if defn.nil?
9799
raise Error::ApplicationError.new(
98100
"Activity #{start.activity_type} for workflow #{start.workflow_execution.workflow_id} " \
@@ -185,10 +187,15 @@ def execute_activity(task_token, defn, start)
185187
# Build input
186188
input = Temporalio::Worker::Interceptor::Activity::ExecuteInput.new(
187189
proc: defn.proc,
188-
args: ProtoUtils.convert_from_payload_array(
189-
@worker.options.client.data_converter,
190-
start.input.to_ary
191-
),
190+
# If the activity wants raw_args, we only decode we don't convert
191+
args: if defn.raw_args
192+
payloads = start.input.to_ary
193+
codec = @worker.options.client.data_converter.payload_codec
194+
payloads = codec.decode(payloads) if codec
195+
payloads.map { |p| Temporalio::Converters::RawValue.new(p) }
196+
else
197+
ProtoUtils.convert_from_payload_array(@worker.options.client.data_converter, start.input.to_ary)
198+
end,
192199
headers: ProtoUtils.headers_from_proto_map(start.header_fields, @worker.options.client.data_converter) || {}
193200
)
194201

temporalio/lib/temporalio/internal/worker/workflow_instance/outbound_implementation.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def execute_activity(input)
6464
else
6565
raise ArgumentError, 'Activity must be a definition class, or a symbol/string'
6666
end
67+
raise 'Cannot invoke dynamic activities' unless activity_type
68+
6769
execute_activity_with_local_backoffs(local: false, cancellation: input.cancellation) do
6870
seq = (@activity_counter += 1)
6971
@instance.add_command(
@@ -102,6 +104,8 @@ def execute_local_activity(input)
102104
else
103105
raise ArgumentError, 'Activity must be a definition class, or a symbol/string'
104106
end
107+
raise 'Cannot invoke dynamic activities' unless activity_type
108+
105109
execute_activity_with_local_backoffs(local: true, cancellation: input.cancellation) do |do_backoff|
106110
seq = (@activity_counter += 1)
107111
@instance.add_command(

temporalio/lib/temporalio/workflow/definition.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ def workflow_dynamic(value = true) # rubocop:disable Style/OptionalBooleanParame
3838
end
3939

4040
# Have workflow arguments delivered to `execute` (and `initialize` if {workflow_init} in use) as
41-
# {Converters::RawValue}s. These are wrappers for the raw payloads that have not been decoded. They can be
42-
# decoded with {Workflow.payload_converter}.
41+
# {Converters::RawValue}s. These are wrappers for the raw payloads that have not been converted to types (but
42+
# they have been decoded by the codec if present). They can be converted with {Workflow.payload_converter}.
4343
#
4444
# @param value [Boolean] Whether the workflow accepts raw arguments.
4545
def workflow_raw_args(value = true) # rubocop:disable Style/OptionalBooleanParameter

temporalio/sig/temporalio/activity/definition.rbs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,32 @@ module Temporalio
44
def self.activity_name: (String | Symbol name) -> void
55
def self.activity_executor: (Symbol executor_name) -> void
66
def self.activity_cancel_raise: (bool cancel_raise) -> void
7+
def self.activity_dynamic: (?bool value) -> void
8+
def self.activity_raw_args: (?bool value) -> void
79

810
def self._activity_definition_details: -> {
9-
activity_name: String | Symbol,
11+
activity_name: String | Symbol | nil,
1012
activity_executor: Symbol,
11-
activity_cancel_raise: bool
13+
activity_cancel_raise: bool,
14+
activity_raw_args: bool
1215
}
1316

1417
def execute: (*untyped) -> untyped
1518

1619
class Info
17-
attr_reader name: String | Symbol
20+
attr_reader name: String | Symbol | nil
1821
attr_reader proc: Proc
1922
attr_reader executor: Symbol
2023
attr_reader cancel_raise: bool
24+
attr_reader raw_args: bool
2125

2226
def self.from_activity: (Definition | singleton(Definition) | Info activity) -> Info
2327

2428
def initialize: (
25-
name: String | Symbol,
29+
name: String | Symbol | nil,
2630
?executor: Symbol,
27-
?cancel_raise: bool
31+
?cancel_raise: bool,
32+
?raw_args: bool
2833
) { (?) -> untyped } -> void
2934
end
3035
end

temporalio/test/worker_activity_test.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,41 @@ def test_interceptor_from_client
822822
assert_equal ['heartbeat-val'], interceptor.calls[2][1].details
823823
end
824824

825+
class DynamicActivity < Temporalio::Activity::Definition
826+
activity_dynamic
827+
828+
def execute(*args)
829+
"Activity #{Temporalio::Activity::Context.current.info.activity_type} called with #{args}"
830+
end
831+
end
832+
833+
def test_dynamic_activity
834+
assert_equal 'Activity does-not-exist called with ["arg1", 123]',
835+
execute_activity(DynamicActivity, 'arg1', 123, override_name: 'does-not-exist')
836+
end
837+
838+
class DynamicActivityRawArgs < Temporalio::Activity::Definition
839+
activity_dynamic
840+
activity_raw_args
841+
842+
def execute(*args)
843+
metadata_encodings, decoded_args = args.map do |arg|
844+
raise 'Bad type' unless arg.is_a?(Temporalio::Converters::RawValue)
845+
846+
[arg.payload.metadata['encoding'],
847+
Temporalio::Activity::Context.current.payload_converter.from_payload(arg.payload)]
848+
end.transpose
849+
"Activity #{Temporalio::Activity::Context.current.info.activity_type} called with " \
850+
"#{decoded_args} that have encodings #{metadata_encodings}"
851+
end
852+
end
853+
854+
def test_dynamic_activity_raw_args
855+
assert_equal 'Activity does-not-exist called with ' \
856+
'["arg1", nil, 123] that have encodings ["json/plain", "binary/null", "json/plain"]',
857+
execute_activity(DynamicActivityRawArgs, 'arg1', nil, 123, override_name: 'does-not-exist')
858+
end
859+
825860
# steep:ignore
826861
def execute_activity(
827862
activity,

0 commit comments

Comments
 (0)