activejob-temporal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +130 -0
  3. data/LICENSE +21 -0
  4. data/README.md +198 -0
  5. data/activejob-temporal.gemspec +58 -0
  6. data/api/job_payload_schema.json +318 -0
  7. data/bin/temporal-worker +295 -0
  8. data/lib/activejob/temporal/active_job_handler_source.rb +84 -0
  9. data/lib/activejob/temporal/activities/aj_runner_activity.rb +454 -0
  10. data/lib/activejob/temporal/activities/best_effort_side_effects.rb +49 -0
  11. data/lib/activejob/temporal/activities/dependency_status_activity.rb +160 -0
  12. data/lib/activejob/temporal/activities/rate_limit_activity.rb +41 -0
  13. data/lib/activejob/temporal/adapter.rb +257 -0
  14. data/lib/activejob/temporal/audit_log.rb +118 -0
  15. data/lib/activejob/temporal/batch_enqueue_result.rb +110 -0
  16. data/lib/activejob/temporal/batch_enqueuer.rb +141 -0
  17. data/lib/activejob/temporal/bind_policy.rb +44 -0
  18. data/lib/activejob/temporal/cancel/batch_canceller.rb +154 -0
  19. data/lib/activejob/temporal/cancel/batch_summary.rb +45 -0
  20. data/lib/activejob/temporal/cancel.rb +236 -0
  21. data/lib/activejob/temporal/certificate_watcher.rb +76 -0
  22. data/lib/activejob/temporal/chain_options.rb +83 -0
  23. data/lib/activejob/temporal/child_workflow_options.rb +102 -0
  24. data/lib/activejob/temporal/client.rb +215 -0
  25. data/lib/activejob/temporal/conditional_enqueue.rb +56 -0
  26. data/lib/activejob/temporal/configurable.rb +55 -0
  27. data/lib/activejob/temporal/configuration.rb +981 -0
  28. data/lib/activejob/temporal/configured_job_compatibility.rb +44 -0
  29. data/lib/activejob/temporal/connection_worker_pool.rb +88 -0
  30. data/lib/activejob/temporal/dead_letter_payload_validation.rb +34 -0
  31. data/lib/activejob/temporal/dead_letter_queue.rb +163 -0
  32. data/lib/activejob/temporal/dependency_options.rb +134 -0
  33. data/lib/activejob/temporal/external_operation.rb +193 -0
  34. data/lib/activejob/temporal/health_check_server.rb +159 -0
  35. data/lib/activejob/temporal/http_line_reader.rb +36 -0
  36. data/lib/activejob/temporal/inspect.rb +184 -0
  37. data/lib/activejob/temporal/job_descriptor.rb +37 -0
  38. data/lib/activejob/temporal/job_payload_builder.rb +209 -0
  39. data/lib/activejob/temporal/job_payload_chain_builder.rb +106 -0
  40. data/lib/activejob/temporal/job_payload_child_workflows.rb +127 -0
  41. data/lib/activejob/temporal/job_payload_dependencies.rb +40 -0
  42. data/lib/activejob/temporal/job_payload_rate_limits.rb +53 -0
  43. data/lib/activejob/temporal/job_payload_workflow_interactions.rb +31 -0
  44. data/lib/activejob/temporal/job_tags.rb +40 -0
  45. data/lib/activejob/temporal/locales/en.yml +126 -0
  46. data/lib/activejob/temporal/logger.rb +214 -0
  47. data/lib/activejob/temporal/metrics_server.rb +150 -0
  48. data/lib/activejob/temporal/middleware/chain.rb +106 -0
  49. data/lib/activejob/temporal/middleware.rb +11 -0
  50. data/lib/activejob/temporal/observability/datadog.rb +167 -0
  51. data/lib/activejob/temporal/observability/opentelemetry.rb +107 -0
  52. data/lib/activejob/temporal/observability/prometheus.rb +271 -0
  53. data/lib/activejob/temporal/observability.rb +260 -0
  54. data/lib/activejob/temporal/payload.rb +415 -0
  55. data/lib/activejob/temporal/payload_encryption.rb +215 -0
  56. data/lib/activejob/temporal/payload_serializers/json.rb +23 -0
  57. data/lib/activejob/temporal/payload_serializers/marshal.rb +53 -0
  58. data/lib/activejob/temporal/payload_serializers/message_pack.rb +59 -0
  59. data/lib/activejob/temporal/payload_serializers.rb +37 -0
  60. data/lib/activejob/temporal/payload_storage.rb +103 -0
  61. data/lib/activejob/temporal/rails_environment_loader.rb +143 -0
  62. data/lib/activejob/temporal/rate_limit_options.rb +94 -0
  63. data/lib/activejob/temporal/rate_limiters/memory.rb +198 -0
  64. data/lib/activejob/temporal/reload_signal_queue.rb +40 -0
  65. data/lib/activejob/temporal/retry_handler_extractor.rb +361 -0
  66. data/lib/activejob/temporal/retry_mapper.rb +264 -0
  67. data/lib/activejob/temporal/schedulable.rb +60 -0
  68. data/lib/activejob/temporal/schedule.rb +181 -0
  69. data/lib/activejob/temporal/schedule_options.rb +105 -0
  70. data/lib/activejob/temporal/search_attributes.rb +173 -0
  71. data/lib/activejob/temporal/signal_query.rb +161 -0
  72. data/lib/activejob/temporal/signal_query_options.rb +106 -0
  73. data/lib/activejob/temporal/temporal_options.rb +114 -0
  74. data/lib/activejob/temporal/tls_file.rb +45 -0
  75. data/lib/activejob/temporal/transaction_safety.rb +39 -0
  76. data/lib/activejob/temporal/version.rb +7 -0
  77. data/lib/activejob/temporal/visibility_query.rb +13 -0
  78. data/lib/activejob/temporal/worker_client_reloader.rb +34 -0
  79. data/lib/activejob/temporal/worker_health.rb +117 -0
  80. data/lib/activejob/temporal/worker_pool.rb +408 -0
  81. data/lib/activejob/temporal/workflow_enqueuer.rb +271 -0
  82. data/lib/activejob/temporal/workflow_enqueuer_batch.rb +17 -0
  83. data/lib/activejob/temporal/workflow_id_builder.rb +155 -0
  84. data/lib/activejob/temporal/workflow_identity.rb +62 -0
  85. data/lib/activejob/temporal/workflows/aj_workflow.rb +282 -0
  86. data/lib/activejob/temporal/workflows/dead_letter_support.rb +134 -0
  87. data/lib/activejob/temporal/workflows/dead_letter_workflow.rb +114 -0
  88. data/lib/activejob/temporal/workflows/workflow_chaining.rb +194 -0
  89. data/lib/activejob/temporal/workflows/workflow_child_workflows.rb +140 -0
  90. data/lib/activejob/temporal/workflows/workflow_continue_as_new.rb +44 -0
  91. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +115 -0
  92. data/lib/activejob/temporal/workflows/workflow_execution_steps.rb +22 -0
  93. data/lib/activejob/temporal/workflows/workflow_interactions.rb +215 -0
  94. data/lib/activejob/temporal/workflows/workflow_local_activities.rb +29 -0
  95. data/lib/activejob/temporal/workflows/workflow_nexus.rb +15 -0
  96. data/lib/activejob/temporal/workflows/workflow_versioning.rb +21 -0
  97. data/lib/activejob/temporal.rb +297 -0
  98. data/lib/activejob-temporal.rb +3 -0
  99. metadata +423 -0
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require_relative "logger"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ module ConfiguredJobCompatibility
9
+ module_function
10
+
11
+ def payload(value, feature:, normalize_options:)
12
+ return unless configured_job?(value)
13
+
14
+ log_private_api(feature)
15
+
16
+ job_class = value.instance_variable_get(:@job_class)
17
+ return unless active_job_class?(job_class)
18
+
19
+ {
20
+ job_class: job_class.name,
21
+ options: normalize_options.call(value.instance_variable_get(:@options) || {})
22
+ }
23
+ end
24
+
25
+ def configured_job?(value)
26
+ defined?(ActiveJob::ConfiguredJob) && value.is_a?(ActiveJob::ConfiguredJob)
27
+ end
28
+
29
+ def active_job_class?(job_class)
30
+ job_class.is_a?(Class) && job_class < ActiveJob::Base && job_class.name
31
+ end
32
+
33
+ def log_private_api(feature)
34
+ ActiveJob::Temporal::Logger.warn(
35
+ "active_job_configured_job_private_api",
36
+ feature: feature,
37
+ replacement: "ActiveJob::Temporal.job"
38
+ )
39
+ rescue StandardError
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ class ConnectionWorkerPool
6
+ def initialize(size:, queue_size:, name:, &handler)
7
+ @size = Integer(size)
8
+ @queue_size = Integer(queue_size)
9
+ @name = name
10
+ @handler = handler
11
+ @mutex = Mutex.new
12
+ @started = false
13
+
14
+ raise ArgumentError, "size must be positive" unless @size.positive?
15
+ raise ArgumentError, "queue_size must be positive" unless @queue_size.positive?
16
+ raise ArgumentError, "handler is required" unless @handler
17
+ end
18
+
19
+ def start
20
+ @mutex.synchronize do
21
+ return self if @started
22
+
23
+ @queue = SizedQueue.new(@queue_size)
24
+ queue = @queue
25
+ @workers = Array.new(@size) do |index|
26
+ Thread.new(queue, index) { |worker_queue, worker_index| run_worker(worker_queue, worker_index) }
27
+ end
28
+ @started = true
29
+ end
30
+
31
+ self
32
+ end
33
+
34
+ def enqueue(connection)
35
+ queue = @mutex.synchronize { @queue if @started }
36
+ unless queue
37
+ close_connection(connection)
38
+ return false
39
+ end
40
+
41
+ queue.push(connection, true)
42
+ true
43
+ rescue ClosedQueueError, ThreadError
44
+ close_connection(connection)
45
+ false
46
+ end
47
+
48
+ def stop(timeout:)
49
+ queue = nil
50
+ workers = nil
51
+
52
+ @mutex.synchronize do
53
+ return unless @started
54
+
55
+ queue = @queue
56
+ workers = @workers
57
+ @queue = nil
58
+ @workers = []
59
+ @started = false
60
+ end
61
+
62
+ queue.close
63
+ workers.each { |worker| worker.join(timeout) }
64
+ end
65
+
66
+ private
67
+
68
+ def run_worker(queue, index)
69
+ Thread.current.name = "#{@name}-#{index}" if Thread.current.respond_to?(:name=)
70
+
71
+ loop do
72
+ connection = queue.pop
73
+ break unless connection
74
+
75
+ @handler.call(connection)
76
+ end
77
+ rescue ClosedQueueError
78
+ nil
79
+ end
80
+
81
+ def close_connection(connection)
82
+ connection.close
83
+ rescue IOError, SystemCallError
84
+ nil
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module DeadLetterPayloadValidation
6
+ extend self
7
+
8
+ def validate!(payload)
9
+ validate_metadata!(payload_value(payload, :dead_letter), "dead_letter.queue")
10
+
11
+ Array(payload_value(payload, :chain)).each do |chain_step|
12
+ validate_metadata!(payload_value(chain_step, :dead_letter), "chain.dead_letter.queue")
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def validate_metadata!(metadata, queue_path)
19
+ return unless metadata
20
+ return if payload_value(metadata, :queue).to_s.strip.present?
21
+
22
+ raise ConfigurationError, "#{queue_path} cannot be blank"
23
+ end
24
+
25
+ def payload_value(payload, key)
26
+ return unless payload.respond_to?(:[])
27
+
28
+ payload[key] || payload[key.to_s]
29
+ rescue TypeError
30
+ nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "temporalio/client"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module DeadLetterQueue
8
+ WORKFLOW_TYPE = "ActiveJobTemporalDeadLetterWorkflow"
9
+ DEFAULT_ENTRIES_LIMIT = 100
10
+
11
+ module_function
12
+
13
+ def entry(job_class, job_id, run_id: nil, client: ActiveJob::Temporal.client)
14
+ handle_for(job_class, job_id, run_id: run_id, client: client).query(:entry)
15
+ end
16
+
17
+ def entries(queue: nil, limit: DEFAULT_ENTRIES_LIMIT, client: ActiveJob::Temporal.client)
18
+ validate_limit!(limit)
19
+
20
+ client.list_workflows(entries_query(queue)).each_with_object([]) do |workflow, entries|
21
+ entry = query_workflow_entry(client, workflow)
22
+ entries << entry if entry
23
+ break entries if entries.size >= limit
24
+ end
25
+ end
26
+
27
+ def retry(job_class, job_id, queue: nil, client: ActiveJob::Temporal.client)
28
+ handle = handle_for(job_class, job_id, client: client)
29
+ entry = handle.query(:entry)
30
+ return entry.fetch("retry_workflow_id") if retried_entry?(entry)
31
+
32
+ ensure_pending_entry!(entry)
33
+
34
+ workflow_id = retry_workflow_id(entry)
35
+ start_retry_workflow(client, entry, workflow_id, queue)
36
+ mark_retried_entry(handle, workflow_id)
37
+ workflow_id
38
+ end
39
+
40
+ # rubocop:disable Naming/PredicateMethod
41
+ def discard(job_class, job_id, reason: nil, client: ActiveJob::Temporal.client)
42
+ handle_for(job_class, job_id, client: client).signal(:discard, reason)
43
+ true
44
+ end
45
+ # rubocop:enable Naming/PredicateMethod
46
+
47
+ def workflow_id(job_class, job_id)
48
+ class_name = job_class.is_a?(Class) ? job_class.name : job_class.to_s
49
+ "ajdlq:#{class_name}:#{job_id}"
50
+ end
51
+
52
+ def handle_for(job_class, job_id, run_id: nil, client: ActiveJob::Temporal.client)
53
+ client.workflow_handle(workflow_id(job_class, job_id), run_id: run_id)
54
+ end
55
+ private_class_method :handle_for
56
+
57
+ def entries_query(queue)
58
+ query = ["WorkflowType='#{WORKFLOW_TYPE}'", "ExecutionStatus='Running'"]
59
+ query << "TaskQueue='#{escape_query_value(queue)}'" if queue.to_s.strip.present?
60
+ query.join(" AND ")
61
+ end
62
+ private_class_method :entries_query
63
+
64
+ def query_workflow_entry(client, workflow)
65
+ client.workflow_handle(workflow.id, run_id: workflow_run_id(workflow)).query(:entry)
66
+ rescue Temporalio::Error
67
+ nil
68
+ end
69
+ private_class_method :query_workflow_entry
70
+
71
+ def start_retry_workflow(client, entry, workflow_id, queue)
72
+ client.start_workflow(
73
+ ActiveJob::Temporal::Workflows::AjWorkflow,
74
+ retry_payload(entry),
75
+ id: workflow_id,
76
+ task_queue: retry_task_queue(entry, queue),
77
+ id_conflict_policy: Temporalio::WorkflowIDConflictPolicy::FAIL
78
+ )
79
+ rescue StandardError => e
80
+ raise unless workflow_already_started?(e)
81
+ end
82
+ private_class_method :start_retry_workflow
83
+
84
+ def mark_retried_entry(handle, workflow_id)
85
+ handle.signal(:mark_retried, workflow_id)
86
+ rescue StandardError => e
87
+ return if entry_retried_with_workflow_id?(query_entry_after_mark_failure(handle), workflow_id)
88
+
89
+ message = "Retry workflow #{workflow_id} may be running, but could not mark dead letter entry retried"
90
+ raise ActiveJob::Temporal::Error.new(message), cause: e
91
+ end
92
+ private_class_method :mark_retried_entry
93
+
94
+ def query_entry_after_mark_failure(handle)
95
+ handle.query(:entry)
96
+ rescue StandardError
97
+ nil
98
+ end
99
+ private_class_method :query_entry_after_mark_failure
100
+
101
+ def ensure_pending_entry!(entry)
102
+ state = entry.fetch("state", "pending")
103
+ return if state == "pending"
104
+
105
+ message = "Cannot retry dead letter entry #{entry.fetch('id')} with state #{state.inspect}"
106
+ raise ActiveJob::Temporal::Error, message
107
+ end
108
+ private_class_method :ensure_pending_entry!
109
+
110
+ def retried_entry?(entry)
111
+ entry["state"] == "retried" && entry["retry_workflow_id"].to_s.strip.present?
112
+ end
113
+ private_class_method :retried_entry?
114
+
115
+ def entry_retried_with_workflow_id?(entry, workflow_id)
116
+ return false unless entry
117
+
118
+ entry["state"] == "retried" && entry["retry_workflow_id"] == workflow_id
119
+ end
120
+ private_class_method :entry_retried_with_workflow_id?
121
+
122
+ def retry_workflow_id(entry)
123
+ "ajdlq-retry:#{entry.fetch('id')}"
124
+ end
125
+ private_class_method :retry_workflow_id
126
+
127
+ def retry_payload(entry)
128
+ entry.fetch("payload").reject { |key, _value| key.to_s == "scheduled_at" }
129
+ end
130
+ private_class_method :retry_payload
131
+
132
+ def retry_task_queue(entry, queue)
133
+ queue || entry.dig("metadata", "original_task_queue") || entry.dig("metadata", "original_queue_name")
134
+ end
135
+ private_class_method :retry_task_queue
136
+
137
+ def workflow_run_id(workflow)
138
+ workflow.respond_to?(:run_id) ? workflow.run_id : nil
139
+ end
140
+ private_class_method :workflow_run_id
141
+
142
+ def validate_limit!(limit)
143
+ return if limit.is_a?(Integer) && limit.positive?
144
+
145
+ raise ArgumentError, "limit must be a positive integer"
146
+ end
147
+ private_class_method :validate_limit!
148
+
149
+ def escape_query_value(value)
150
+ value.to_s.gsub("'", "''")
151
+ end
152
+ private_class_method :escape_query_value
153
+
154
+ def workflow_already_started?(error)
155
+ (defined?(Temporalio::Error::WorkflowAlreadyStartedError) &&
156
+ error.is_a?(Temporalio::Error::WorkflowAlreadyStartedError)) ||
157
+ (defined?(Temporalio::Client::WorkflowAlreadyStartedError) &&
158
+ error.is_a?(Temporalio::Client::WorkflowAlreadyStartedError))
159
+ end
160
+ private_class_method :workflow_already_started?
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require_relative "workflow_id_builder"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ module DependencyOptions
9
+ JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
10
+ SAFE_ID_PATTERN = /\A[A-Za-z0-9_.:-]+\z/
11
+ FAILURE_POLICIES = %i[fail ignore].freeze
12
+
13
+ attr_reader :temporal_dependencies, :temporal_dependency_failure_policy
14
+
15
+ def self.normalize(depends_on)
16
+ dependencies = depends_on.is_a?(Array) ? depends_on : [depends_on]
17
+ raise ArgumentError, "depends_on must contain at least one job dependency" if dependencies.empty?
18
+
19
+ dependencies.map { |dependency| normalize_dependency(dependency) }
20
+ end
21
+
22
+ def self.normalize_failure_policy(policy)
23
+ normalized_policy = policy.to_sym
24
+ return normalized_policy if FAILURE_POLICIES.include?(normalized_policy)
25
+
26
+ raise ArgumentError, "on_dependency_failure must be :fail or :ignore"
27
+ rescue NoMethodError
28
+ raise ArgumentError, "on_dependency_failure must be :fail or :ignore"
29
+ end
30
+
31
+ def self.normalize_dependency(dependency)
32
+ return normalize_job_dependency(dependency) if dependency.is_a?(ActiveJob::Base)
33
+ return normalize_hash_dependency(dependency) if dependency.is_a?(Hash)
34
+ return { job_id: normalize_id(dependency, "job_id") } if dependency.is_a?(String)
35
+
36
+ raise ArgumentError, "depends_on entries must be ActiveJob instances, job IDs, or dependency hashes"
37
+ end
38
+ private_class_method :normalize_dependency
39
+
40
+ def self.normalize_job_dependency(job)
41
+ {
42
+ job_class: normalize_job_class(job.class),
43
+ job_id: normalize_id(job.job_id, "job_id"),
44
+ workflow_id: WorkflowIdBuilder.new(configured_workflow_id_generator).build(job)
45
+ }
46
+ end
47
+ private_class_method :normalize_job_dependency
48
+
49
+ def self.configured_workflow_id_generator
50
+ ActiveJob::Temporal.config.workflow_id_generator if ActiveJob::Temporal.respond_to?(:config)
51
+ end
52
+ private_class_method :configured_workflow_id_generator
53
+
54
+ def self.normalize_hash_dependency(dependency)
55
+ normalized = {}
56
+ job_id = hash_value(dependency, :job_id)
57
+ workflow_id = hash_value(dependency, :workflow_id)
58
+ job_class = hash_value(dependency, :job_class)
59
+
60
+ normalized[:job_id] = normalize_id(job_id, "job_id") if job_id
61
+ normalized[:workflow_id] = normalize_id(workflow_id, "workflow_id") if workflow_id
62
+ normalized[:job_class] = normalize_job_class(job_class) if job_class
63
+
64
+ if normalized[:job_id].nil? && normalized[:workflow_id].nil?
65
+ raise ArgumentError, "dependency hashes must include job_id or workflow_id"
66
+ end
67
+
68
+ normalized
69
+ end
70
+ private_class_method :normalize_hash_dependency
71
+
72
+ def self.normalize_job_class(job_class)
73
+ name = if job_class.is_a?(Class) && job_class < ActiveJob::Base
74
+ job_class.name
75
+ else
76
+ job_class.to_s
77
+ end
78
+ unless name.match?(JOB_CLASS_NAME_PATTERN)
79
+ raise ArgumentError, "dependency job_class must be a named ActiveJob class or valid class name"
80
+ end
81
+
82
+ name
83
+ end
84
+ private_class_method :normalize_job_class
85
+
86
+ def self.normalize_id(value, name)
87
+ id = value.to_s
88
+ raise ArgumentError, "dependency #{name} must not be blank" if id.strip.empty?
89
+ raise ArgumentError, "dependency #{name} contains unsupported characters" unless id.match?(SAFE_ID_PATTERN)
90
+
91
+ id
92
+ end
93
+ private_class_method :normalize_id
94
+
95
+ def self.hash_value(hash, key)
96
+ hash[key] || hash[key.to_s]
97
+ end
98
+ private_class_method :hash_value
99
+
100
+ def set(options = {})
101
+ enqueue_options = options.dup
102
+ dependencies_configured = enqueue_options.key?(:depends_on)
103
+ failure_policy_configured = enqueue_options.key?(:on_dependency_failure)
104
+
105
+ normalized_dependencies = if dependencies_configured
106
+ DependencyOptions.normalize(enqueue_options.delete(:depends_on))
107
+ end
108
+ normalized_failure_policy = normalize_dependency_failure_policy(
109
+ enqueue_options.delete(:on_dependency_failure),
110
+ failure_policy_configured
111
+ )
112
+
113
+ if failure_policy_configured && !dependencies_configured && normalized_dependencies.nil?
114
+ raise ArgumentError, "on_dependency_failure requires depends_on"
115
+ end
116
+
117
+ super(enqueue_options).tap do
118
+ @temporal_dependencies = normalized_dependencies if dependencies_configured
119
+ @temporal_dependency_failure_policy = normalized_failure_policy || :fail if dependencies_configured
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def normalize_dependency_failure_policy(policy, configured)
126
+ return unless configured
127
+
128
+ DependencyOptions.normalize_failure_policy(policy)
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ ActiveJob::Base.prepend(ActiveJob::Temporal::DependencyOptions) if defined?(ActiveJob::Base)
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/duration"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ class ExternalOperation
8
+ ACTIVITY = "activity"
9
+ WORKFLOW = "workflow"
10
+
11
+ attr_reader :operation, :temporal_type, :options
12
+
13
+ def self.activity(temporal_type, **options)
14
+ new(ACTIVITY, temporal_type, options)
15
+ end
16
+
17
+ def self.workflow(temporal_type, **options)
18
+ new(WORKFLOW, temporal_type, options)
19
+ end
20
+
21
+ def self.normalize(value)
22
+ return value.to_h if value.is_a?(self)
23
+ return normalize_hash(value) if external_operation_hash?(value)
24
+
25
+ nil
26
+ end
27
+
28
+ def self.external_operation_hash?(value)
29
+ value.respond_to?(:[]) &&
30
+ !payload_value(value, :temporal_operation).nil? &&
31
+ !payload_value(value, :temporal_type).nil?
32
+ end
33
+
34
+ def initialize(operation, temporal_type, options)
35
+ @operation = normalize_operation(operation)
36
+ @temporal_type = normalize_temporal_type(temporal_type)
37
+ @options = ExternalOperationOptions.normalize(@operation, options)
38
+ end
39
+
40
+ def to_h
41
+ {
42
+ temporal_operation: operation,
43
+ temporal_type: temporal_type,
44
+ options: options.dup
45
+ }
46
+ end
47
+
48
+ class << self
49
+ def normalize_hash(value)
50
+ operation = payload_value(value, :temporal_operation)
51
+ temporal_type = payload_value(value, :temporal_type)
52
+ options = payload_value(value, :options) || {}
53
+
54
+ new(operation, temporal_type, options).to_h
55
+ end
56
+
57
+ def payload_value(payload, key)
58
+ payload[key] || payload[key.to_s]
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def normalize_operation(operation)
65
+ case operation.to_s
66
+ when ACTIVITY then ACTIVITY
67
+ when WORKFLOW then WORKFLOW
68
+ else raise ArgumentError, "external Temporal operation must be activity or workflow"
69
+ end
70
+ end
71
+
72
+ def normalize_temporal_type(value)
73
+ unless value.is_a?(String) && !value.strip.empty?
74
+ raise ArgumentError, "external Temporal type name must be a non-empty String"
75
+ end
76
+
77
+ value
78
+ end
79
+ end
80
+
81
+ class ExternalOperationOptions
82
+ ACTIVITY_OPTION_KEYS = %i[
83
+ activity_id
84
+ heartbeat_timeout
85
+ retry_policy
86
+ schedule_to_close_timeout
87
+ schedule_to_start_timeout
88
+ start_to_close_timeout
89
+ summary
90
+ task_queue
91
+ ].freeze
92
+
93
+ WORKFLOW_OPTION_KEYS = %i[
94
+ cron_schedule
95
+ execution_timeout
96
+ id
97
+ memo
98
+ retry_policy
99
+ run_timeout
100
+ static_details
101
+ static_summary
102
+ task_queue
103
+ task_timeout
104
+ ].freeze
105
+
106
+ DURATION_OPTION_KEYS = %i[
107
+ execution_timeout
108
+ heartbeat_timeout
109
+ run_timeout
110
+ schedule_to_close_timeout
111
+ schedule_to_start_timeout
112
+ start_to_close_timeout
113
+ task_timeout
114
+ ].freeze
115
+
116
+ RETRY_POLICY_DURATION_KEYS = %i[initial_interval max_interval].freeze
117
+
118
+ def self.normalize(operation, options)
119
+ supported_keys = option_keys_for(operation)
120
+ normalized_options = options.each_with_object({}) do |(key, value), normalized|
121
+ normalized_key = key.to_sym
122
+ unless supported_keys.include?(normalized_key)
123
+ raise ArgumentError, external_options_error(operation, supported_keys)
124
+ end
125
+
126
+ normalized[normalized_key] = normalize_option_value(normalized_key, value)
127
+ end
128
+ normalize_task_queue!(normalized_options)
129
+ normalized_options
130
+ end
131
+
132
+ class << self
133
+ private
134
+
135
+ def option_keys_for(operation)
136
+ case operation.to_s
137
+ when ExternalOperation::ACTIVITY then ACTIVITY_OPTION_KEYS
138
+ when ExternalOperation::WORKFLOW then WORKFLOW_OPTION_KEYS
139
+ else raise ArgumentError, "external Temporal operation must be activity or workflow"
140
+ end
141
+ end
142
+
143
+ def external_options_error(operation, supported_keys)
144
+ "external Temporal #{operation} options only support #{supported_keys.join(', ')}"
145
+ end
146
+
147
+ def normalize_option_value(key, value)
148
+ return normalize_duration(value) if DURATION_OPTION_KEYS.include?(key)
149
+ return normalize_retry_policy(value) if key == :retry_policy
150
+ return normalize_task_queue(value) if key == :task_queue
151
+
152
+ value
153
+ end
154
+
155
+ def normalize_retry_policy(value)
156
+ value = value.to_h if value.respond_to?(:to_h) && !value.is_a?(Hash)
157
+ return value unless value.is_a?(Hash)
158
+
159
+ value.each_with_object({}) do |(key, retry_value), normalized|
160
+ normalized_key = key.to_sym
161
+ normalized[normalized_key] = retry_policy_value(normalized_key, retry_value)
162
+ end
163
+ end
164
+
165
+ def retry_policy_value(key, value)
166
+ return normalize_duration(value) if RETRY_POLICY_DURATION_KEYS.include?(key)
167
+
168
+ value
169
+ end
170
+
171
+ def normalize_duration(value)
172
+ case value
173
+ when ActiveSupport::Duration, Numeric
174
+ value.to_f
175
+ else
176
+ raise ArgumentError, "Temporal timeout values must be numeric or ActiveSupport::Duration"
177
+ end
178
+ end
179
+
180
+ def normalize_task_queue!(options)
181
+ options[:task_queue] = normalize_task_queue(options[:task_queue])
182
+ end
183
+
184
+ def normalize_task_queue(value)
185
+ task_queue = value.to_s
186
+ return task_queue unless task_queue.strip.empty?
187
+
188
+ raise ArgumentError, "external Temporal steps require task_queue"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end