activejob-temporal 0.1.0 → 0.3.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +1 -0
  3. data/CHANGELOG.md +29 -0
  4. data/CLAUDE.md +274 -0
  5. data/CONTRIBUTING.md +69 -0
  6. data/README.md +34 -33
  7. data/activejob-temporal.gemspec +9 -13
  8. data/api/job_payload_schema.json +51 -6
  9. data/bin/temporal-worker +25 -17
  10. data/gemfiles/activejob_contract.gemfile +8 -0
  11. data/gemfiles/temporalio_contract.gemfile +7 -0
  12. data/lib/activejob/temporal/activities/aj_runner_activity.rb +75 -19
  13. data/lib/activejob/temporal/activities/dependency_status_activity.rb +36 -19
  14. data/lib/activejob/temporal/adapter.rb +4 -3
  15. data/lib/activejob/temporal/batch_enqueuer.rb +42 -33
  16. data/lib/activejob/temporal/bind_policy.rb +1 -1
  17. data/lib/activejob/temporal/cancel.rb +66 -29
  18. data/lib/activejob/temporal/certificate_watcher.rb +41 -6
  19. data/lib/activejob/temporal/client.rb +24 -6
  20. data/lib/activejob/temporal/conditional_enqueue.rb +2 -1
  21. data/lib/activejob/temporal/configurable.rb +56 -13
  22. data/lib/activejob/temporal/configuration.rb +218 -7
  23. data/lib/activejob/temporal/configured_job_compatibility.rb +91 -9
  24. data/lib/activejob/temporal/connection_worker_pool.rb +18 -1
  25. data/lib/activejob/temporal/dead_letter_queue.rb +64 -18
  26. data/lib/activejob/temporal/dependency_options.rb +126 -16
  27. data/lib/activejob/temporal/health_check_server.rb +14 -17
  28. data/lib/activejob/temporal/http_line_reader.rb +20 -2
  29. data/lib/activejob/temporal/http_request_failure_handling.rb +41 -0
  30. data/lib/activejob/temporal/inspect.rb +17 -7
  31. data/lib/activejob/temporal/job_id_validation.rb +41 -0
  32. data/lib/activejob/temporal/job_payload_dependencies.rb +23 -0
  33. data/lib/activejob/temporal/locales/en.yml +30 -3
  34. data/lib/activejob/temporal/metrics_server.rb +15 -18
  35. data/lib/activejob/temporal/middleware/chain.rb +7 -0
  36. data/lib/activejob/temporal/observability.rb +14 -2
  37. data/lib/activejob/temporal/payload.rb +64 -25
  38. data/lib/activejob/temporal/payload_encryption.rb +9 -1
  39. data/lib/activejob/temporal/payload_serializers.rb +3 -0
  40. data/lib/activejob/temporal/payload_storage.rb +4 -4
  41. data/lib/activejob/temporal/rails_environment_loader.rb +1 -8
  42. data/lib/activejob/temporal/reload_signal_queue.rb +19 -19
  43. data/lib/activejob/temporal/retry_handler_extractor.rb +18 -3
  44. data/lib/activejob/temporal/schedulable.rb +5 -7
  45. data/lib/activejob/temporal/schedule.rb +2 -2
  46. data/lib/activejob/temporal/signal_query.rb +35 -23
  47. data/lib/activejob/temporal/temporal_options.rb +26 -1
  48. data/lib/activejob/temporal/tls_file.rb +16 -16
  49. data/lib/activejob/temporal/transaction_safety.rb +102 -1
  50. data/lib/activejob/temporal/version.rb +1 -1
  51. data/lib/activejob/temporal/visibility_query.rb +16 -1
  52. data/lib/activejob/temporal/worker_pool.rb +13 -0
  53. data/lib/activejob/temporal/worker_registrations.rb +66 -0
  54. data/lib/activejob/temporal/worker_runtime.rb +17 -0
  55. data/lib/activejob/temporal/workflow_enqueuer.rb +2 -1
  56. data/lib/activejob/temporal/workflow_types.rb +10 -0
  57. data/lib/activejob/temporal/workflows/aj_workflow.rb +17 -4
  58. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +148 -15
  59. data/lib/activejob/temporal.rb +21 -13
  60. data/test/mutant_unit_test.rb +13 -0
  61. metadata +46 -50
@@ -6,14 +6,115 @@ module ActiveJob
6
6
  module Temporal
7
7
  module TransactionSafety
8
8
  module QueueAdapterSetter
9
+ INHERITED_TRANSACTION_SETTING_MISSING = Object.new.freeze
10
+
9
11
  def queue_adapter=(adapter)
12
+ previous_transaction_setting = enqueue_after_transaction_commit
13
+
10
14
  super.tap do
11
- self.enqueue_after_transaction_commit = true if temporal_queue_adapter?(adapter)
15
+ if temporal_queue_adapter?(adapter)
16
+ apply_temporal_transaction_safety(previous_transaction_setting)
17
+ else
18
+ restore_temporal_transaction_safety
19
+ end
20
+ end
21
+ end
22
+
23
+ def enqueue_after_transaction_commit=(value)
24
+ super.tap do
25
+ next if setting_temporal_transaction_safety?
26
+
27
+ @activejob_temporal_transaction_setting_explicit = true
28
+ @activejob_temporal_transaction_setting_changed_after_safety = true if temporal_transaction_safety_applied?
12
29
  end
13
30
  end
14
31
 
15
32
  private
16
33
 
34
+ def apply_temporal_transaction_safety(previous_transaction_setting)
35
+ return if temporal_transaction_safety_applied?
36
+ return if temporal_transaction_setting_explicit?
37
+
38
+ @activejob_temporal_previous_transaction_setting =
39
+ transaction_setting_before_temporal_safety(previous_transaction_setting)
40
+ apply_enqueue_after_transaction_commit_for_temporal(true)
41
+ @activejob_temporal_transaction_safety_applied = true
42
+ @activejob_temporal_transaction_setting_changed_after_safety = false
43
+ end
44
+
45
+ def restore_temporal_transaction_safety
46
+ unless temporal_transaction_safety_applied?
47
+ restore_inherited_temporal_transaction_safety
48
+ return
49
+ end
50
+
51
+ unless @activejob_temporal_transaction_setting_changed_after_safety
52
+ apply_enqueue_after_transaction_commit_for_temporal(@activejob_temporal_previous_transaction_setting)
53
+ end
54
+
55
+ remove_instance_variable(:@activejob_temporal_previous_transaction_setting)
56
+ @activejob_temporal_transaction_safety_applied = false
57
+ @activejob_temporal_transaction_setting_changed_after_safety = false
58
+ end
59
+
60
+ def restore_inherited_temporal_transaction_safety
61
+ return if temporal_transaction_setting_explicit_locally?
62
+
63
+ inherited_setting = inherited_temporal_transaction_previous_setting
64
+ return if inherited_setting.equal?(INHERITED_TRANSACTION_SETTING_MISSING)
65
+
66
+ apply_enqueue_after_transaction_commit_for_temporal(inherited_setting)
67
+ end
68
+
69
+ def apply_enqueue_after_transaction_commit_for_temporal(value)
70
+ @activejob_temporal_setting_transaction_safety = true
71
+ self.enqueue_after_transaction_commit = value
72
+ ensure
73
+ @activejob_temporal_setting_transaction_safety = false
74
+ end
75
+
76
+ def transaction_setting_before_temporal_safety(current_setting)
77
+ inherited_setting = inherited_temporal_transaction_previous_setting
78
+ return current_setting if inherited_setting.equal?(INHERITED_TRANSACTION_SETTING_MISSING)
79
+
80
+ inherited_setting
81
+ end
82
+
83
+ def inherited_temporal_transaction_previous_setting
84
+ return INHERITED_TRANSACTION_SETTING_MISSING unless superclass.respond_to?(
85
+ :temporal_transaction_safety_applied?, true
86
+ )
87
+
88
+ if superclass.send(:temporal_transaction_safety_applied?)
89
+ superclass.send(:temporal_previous_transaction_setting)
90
+ else
91
+ superclass.send(:inherited_temporal_transaction_previous_setting)
92
+ end
93
+ end
94
+
95
+ def temporal_previous_transaction_setting
96
+ @activejob_temporal_previous_transaction_setting
97
+ end
98
+
99
+ def temporal_transaction_setting_explicit?
100
+ return true if temporal_transaction_setting_explicit_locally?
101
+ return false unless superclass.respond_to?(:temporal_transaction_setting_explicit?, true)
102
+
103
+ superclass.send(:temporal_transaction_setting_explicit?)
104
+ end
105
+
106
+ def temporal_transaction_setting_explicit_locally?
107
+ @activejob_temporal_transaction_setting_explicit == true
108
+ end
109
+
110
+ def temporal_transaction_safety_applied?
111
+ @activejob_temporal_transaction_safety_applied == true
112
+ end
113
+
114
+ def setting_temporal_transaction_safety?
115
+ @activejob_temporal_setting_transaction_safety == true
116
+ end
117
+
17
118
  def temporal_queue_adapter?(adapter)
18
119
  case adapter
19
120
  when Symbol, String
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveJob
4
4
  module Temporal
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -3,10 +3,25 @@
3
3
  module ActiveJob
4
4
  module Temporal
5
5
  module VisibilityQuery
6
+ SAFE_VALUE_PATTERN = /\A[A-Za-z0-9_.:-]+\z/
7
+
6
8
  module_function
7
9
 
10
+ # Quotes a value for use in a Temporal visibility list filter.
11
+ #
12
+ # Escaping alone is not enough: Temporal's list filter grammar also treats
13
+ # backslashes as escapes, so a value containing one can break out of the
14
+ # quoted literal. Values are restricted to an allowlist instead.
15
+ #
16
+ # @param value [#to_s] Value to embed in a list filter
17
+ # @return [String] Single-quoted literal
18
+ # @raise [ArgumentError] if the value contains characters outside the allowlist
8
19
  def quote(value)
9
- "'#{value.to_s.gsub("'", "''")}'"
20
+ string_value = value.to_s
21
+ return "'#{string_value}'" if string_value.match?(SAFE_VALUE_PATTERN)
22
+
23
+ raise ArgumentError,
24
+ "visibility query values may only contain letters, numbers, underscore, hyphen, period, and colon"
10
25
  end
11
26
  end
12
27
  end
@@ -140,6 +140,19 @@ module ActiveJob
140
140
  end
141
141
 
142
142
  validate_public_binds!
143
+ validate_port_ranges!
144
+ end
145
+
146
+ # Each worker binds base_port + index, so overlapping ranges would make
147
+ # workers fight over the same port and crash-loop.
148
+ def validate_port_ranges!
149
+ return unless @health_check_port && @metrics_port
150
+ return unless @health_check_port < @metrics_port + @size && @metrics_port < @health_check_port + @size
151
+
152
+ raise ArgumentError,
153
+ "health_check_port and metrics_port ranges overlap for #{@size} workers: " \
154
+ "health check #{@health_check_port}-#{@health_check_port + @size - 1}, " \
155
+ "metrics #{@metrics_port}-#{@metrics_port + @size - 1}"
143
156
  end
144
157
 
145
158
  def positive_integer(value, name)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "temporalio/activity"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ # Resolves which workflows and activities a worker registers, from configuration.
8
+ #
9
+ # By default a worker hosts the built-in ActiveJob workloads. `worker_activities`
10
+ # adds custom activity classes - for example activities invoked by workflows owned
11
+ # by another service, where only the activity name and JSON payloads travel over
12
+ # the wire. Setting `worker_activejob_workloads = false` turns the worker into an
13
+ # activities-only worker that hosts nothing but those custom activities.
14
+ module WorkerRegistrations
15
+ Result = Struct.new(:workflows, :activities, keyword_init: true)
16
+
17
+ class << self
18
+ # Configuration validation rejects the nothing-to-register combination up front
19
+ # (see Configuration#validate_worker_registration_settings).
20
+ #
21
+ # @param configuration [ActiveJob::Temporal::Configuration]
22
+ # @return [Result] workflows and activities to pass to Temporalio::Worker
23
+ # @raise [WorkerRegistrationError] when a worker_activities entry does not resolve
24
+ # to a Temporalio::Activity::Definition subclass
25
+ def resolve(configuration)
26
+ workflows = []
27
+ activities = custom_activities(configuration)
28
+
29
+ if configuration.worker_activejob_workloads
30
+ workflows += [
31
+ Workflows::AjWorkflow,
32
+ Workflows::DeadLetterWorkflow
33
+ ]
34
+ activities += [
35
+ Activities::RateLimitActivity,
36
+ Activities::DependencyStatusActivity,
37
+ Activities::AjRunnerActivity
38
+ ]
39
+ end
40
+
41
+ Result.new(workflows: workflows, activities: activities)
42
+ end
43
+
44
+ private
45
+
46
+ def custom_activities(configuration)
47
+ Array(configuration.worker_activities).map { |entry| resolve_activity(entry) }
48
+ end
49
+
50
+ def resolve_activity(entry)
51
+ activity = entry.is_a?(String) ? constantize_activity(entry) : entry
52
+ return activity if activity.is_a?(Class) && activity < Temporalio::Activity::Definition
53
+
54
+ raise WorkerRegistrationError,
55
+ "worker_activities entry #{entry.inspect} is not a Temporalio::Activity::Definition subclass"
56
+ end
57
+
58
+ def constantize_activity(name)
59
+ name.constantize
60
+ rescue NameError
61
+ raise WorkerRegistrationError, "worker_activities entry #{name.inspect} does not resolve to a class"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../temporal"
4
+ require_relative "rails_environment_loader"
5
+ require_relative "certificate_watcher"
6
+ require_relative "reload_signal_queue"
7
+ require_relative "worker_client_reloader"
8
+ require_relative "worker_pool"
9
+ require_relative "metrics_server"
10
+ require_relative "worker_health"
11
+ require_relative "health_check_server"
12
+ require_relative "workflows/aj_workflow"
13
+ require_relative "workflows/dead_letter_workflow"
14
+ require_relative "activities/rate_limit_activity"
15
+ require_relative "activities/dependency_status_activity"
16
+ require_relative "activities/aj_runner_activity"
17
+ require_relative "worker_registrations"
@@ -7,6 +7,7 @@ require_relative "job_payload_builder"
7
7
  require_relative "observability"
8
8
  require_relative "workflow_enqueuer_batch"
9
9
  require_relative "workflow_id_builder"
10
+ require_relative "workflow_types"
10
11
 
11
12
  module ActiveJob
12
13
  module Temporal
@@ -128,7 +129,7 @@ module ActiveJob
128
129
  # Starts the Temporal workflow with the given options.
129
130
  # @api private
130
131
  def start_workflow(job, payload, options)
131
- workflow_class = Workflows::AjWorkflow
132
+ workflow_class = WorkflowTypes::ACTIVE_JOB
132
133
  handle = start_temporal_workflow(workflow_class, job, payload, options)
133
134
 
134
135
  log_enqueued(job, options, payload, duplicate: false)
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module WorkflowTypes
6
+ ACTIVE_JOB = "AjWorkflow"
7
+ DEAD_LETTER = "ActiveJobTemporalDeadLetterWorkflow"
8
+ end
9
+ end
10
+ end
@@ -8,6 +8,7 @@ require "temporalio/workflow"
8
8
 
9
9
  require_relative "../activities/rate_limit_activity"
10
10
  require_relative "dead_letter_support"
11
+ require_relative "dead_letter_workflow"
11
12
  require_relative "workflow_continue_as_new"
12
13
  require_relative "workflow_child_workflows"
13
14
  require_relative "workflow_chaining"
@@ -109,7 +110,8 @@ module ActiveJob
109
110
  # @option payload [String] :job_class Fully-qualified job class name (required)
110
111
  # @option payload [String] :job_id Unique job identifier (required)
111
112
  # @option payload [String] :queue_name Target queue name (required)
112
- # @option payload [Array] :arguments Serialized job arguments (required)
113
+ # @option payload [Hash] :active_job Full ActiveJob serialized payload
114
+ # @option payload [Array] :arguments Legacy serialized job arguments
113
115
  # @option payload [Hash] :default_activity_options Global activity timeout defaults (required)
114
116
  # @option payload [Hash] :retry_policy Retry policy for activity execution (required)
115
117
  # @option payload [Hash] :temporal_options Per-job timeout configuration (optional)
@@ -127,14 +129,14 @@ module ActiveJob
127
129
  # The timer persists across worker restarts and does not block worker threads.
128
130
  #
129
131
  # @example Immediate execution
130
- # execute({ job_class: "MyJob", job_id: "123", arguments: ["arg1"] })
132
+ # execute({ job_class: "MyJob", job_id: "123", active_job: { "arguments" => ["arg1"] } })
131
133
  #
132
134
  # @example Scheduled execution (non-blocking sleep)
133
135
  # execute({
134
136
  # job_class: "MyJob",
135
137
  # job_id: "123",
136
138
  # scheduled_at: "2025-10-31T12:00:00Z",
137
- # arguments: []
139
+ # active_job: { "arguments" => [] }
138
140
  # })
139
141
  # # Workflow sleeps until scheduled time without consuming worker resources
140
142
  #
@@ -175,13 +177,24 @@ module ActiveJob
175
177
  def schedule_execution_payload(payload)
176
178
  return payload unless payload_value(payload, :schedule_id)
177
179
 
178
- execution_job_id = Temporalio::Workflow.info.workflow_id
180
+ execution_job_id = schedule_execution_identity
179
181
  payload.merge(
180
182
  "job_id" => execution_job_id,
181
183
  "schedule_execution_job_id" => execution_job_id
182
184
  )
183
185
  end
184
186
 
187
+ def schedule_execution_identity
188
+ info = Temporalio::Workflow.info
189
+ run_id = if info.respond_to?(:first_execution_run_id)
190
+ info.first_execution_run_id
191
+ elsif info.respond_to?(:run_id)
192
+ info.run_id
193
+ end
194
+
195
+ [info.workflow_id, run_id].compact.join(":")
196
+ end
197
+
185
198
  # Extracts scheduled execution time from payload.
186
199
  # @api private
187
200
  def extract_scheduled_time(payload)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+ require "timeout"
3
5
  require "temporalio/error"
4
6
 
5
7
  require_relative "../activities/dependency_status_activity"
@@ -11,8 +13,11 @@ module ActiveJob
11
13
  DEPENDENCY_CHECK_ACTIVITY_TIMEOUT = 30.0
12
14
  DEPENDENCY_CHECK_RETRY_POLICY = Temporalio::RetryPolicy.new(max_attempts: 1)
13
15
  DEPENDENCY_WAIT_INTERVAL = 10.0
16
+ DEPENDENCY_WAIT_TIMEOUT = 86_400.0
17
+ DEPENDENCY_WAIT_MAX_INTERVAL = 60.0
18
+ DEPENDENCY_WAIT_BACKOFF = 2.0
14
19
  DEPENDENCY_NOT_FOUND_MAX_CHECKS = 6
15
- COMPLETED_DEPENDENCY_STATES = %w[completed].freeze
20
+ COMPLETED_DEPENDENCY_STATES = %w[completed continued_as_new].freeze
16
21
  FAILED_DEPENDENCY_STATES = %w[failed canceled terminated timed_out unknown].freeze
17
22
  NOT_FOUND_DEPENDENCY_STATES = %w[not_found].freeze
18
23
 
@@ -23,16 +28,19 @@ module ActiveJob
23
28
  return if dependencies.empty?
24
29
 
25
30
  workflow_state["phase"] = "waiting_dependencies"
26
- not_found_counts = Hash.new(0)
27
- loop do
28
- wait_while_paused
29
- statuses = check_dependency_statuses(dependencies)
30
- expired_not_found_statuses = expired_not_found_statuses(statuses, not_found_counts)
31
- failed_statuses = failed_dependency_statuses(statuses) + expired_not_found_statuses
32
- fail_for_dependencies!(failed_statuses) if fail_on_dependency_failure?(payload) && failed_statuses.any?
33
- break if dependencies_satisfied?(statuses, expired_not_found_statuses)
34
-
35
- Temporalio::Workflow.sleep(DEPENDENCY_WAIT_INTERVAL)
31
+ wait_options = dependency_wait_options(payload)
32
+ deadline = dependency_wait_deadline(wait_options)
33
+ begin
34
+ with_dependency_wait_timeout(wait_options, deadline) do
35
+ wait_for_dependency_statuses(payload, dependencies, wait_options)
36
+ end
37
+ rescue Timeout::Error
38
+ timed_out_statuses = dependency_timeout_statuses(dependencies)
39
+ if fail_on_dependency_failure?(payload)
40
+ fail_for_dependencies!(timed_out_statuses)
41
+ else
42
+ workflow_state.delete("dependency_wait")
43
+ end
36
44
  end
37
45
  end
38
46
 
@@ -50,6 +58,116 @@ module ActiveJob
50
58
  )
51
59
  end
52
60
 
61
+ def wait_for_dependency_statuses(payload, dependencies, wait_options)
62
+ not_found_counts = dependency_wait_not_found_counts
63
+ wait_interval = dependency_wait_current_interval(wait_options)
64
+ loop do
65
+ wait_while_paused
66
+ statuses = check_dependency_statuses(dependencies)
67
+ expired_not_found_statuses = expired_not_found_statuses(statuses, not_found_counts)
68
+ failed_statuses = failed_dependency_statuses(statuses) + expired_not_found_statuses
69
+ fail_for_dependencies!(failed_statuses) if fail_on_dependency_failure?(payload) && failed_statuses.any?
70
+ break if dependencies_satisfied?(statuses, expired_not_found_statuses)
71
+
72
+ persist_dependency_wait_state(not_found_counts, wait_interval)
73
+ continue_as_new_if_needed(payload)
74
+ Temporalio::Workflow.sleep(wait_interval)
75
+ wait_interval = next_dependency_wait_interval(wait_interval, wait_options)
76
+ end
77
+ workflow_state.delete("dependency_wait")
78
+ end
79
+
80
+ def with_dependency_wait_timeout(wait_options, deadline, &)
81
+ remaining_timeout = dependency_wait_remaining_timeout(deadline)
82
+ raise Timeout::Error, "dependency wait timed out" unless remaining_timeout.positive?
83
+
84
+ Temporalio::Workflow.timeout(
85
+ remaining_timeout,
86
+ Timeout::Error,
87
+ "dependency wait timed out after #{wait_options[:timeout]} seconds",
88
+ summary: "Dependency wait timeout",
89
+ &
90
+ )
91
+ end
92
+
93
+ def dependency_wait_options(payload)
94
+ configured_options = payload[:dependency_wait] || payload["dependency_wait"] || {}
95
+ initial_interval = positive_float_option(configured_options, :initial_interval, DEPENDENCY_WAIT_INTERVAL)
96
+ max_interval = positive_float_option(configured_options, :max_interval, DEPENDENCY_WAIT_MAX_INTERVAL)
97
+ {
98
+ timeout: positive_float_option(configured_options, :timeout, DEPENDENCY_WAIT_TIMEOUT),
99
+ initial_interval: initial_interval,
100
+ max_interval: [max_interval, initial_interval].max,
101
+ backoff: backoff_option(configured_options)
102
+ }
103
+ end
104
+
105
+ def positive_float_option(options, key, default)
106
+ value = options[key] || options[key.to_s]
107
+ return default unless value
108
+
109
+ number = Float(value)
110
+ number.positive? ? number : default
111
+ rescue ArgumentError, TypeError
112
+ default
113
+ end
114
+
115
+ def backoff_option(options)
116
+ value = options[:backoff] || options["backoff"]
117
+ return DEPENDENCY_WAIT_BACKOFF unless value
118
+
119
+ backoff = Float(value)
120
+ backoff >= 1.0 ? backoff : DEPENDENCY_WAIT_BACKOFF
121
+ rescue ArgumentError, TypeError
122
+ DEPENDENCY_WAIT_BACKOFF
123
+ end
124
+
125
+ def dependency_wait_not_found_counts
126
+ Hash.new(0).merge(dependency_wait_state["not_found_counts"] || {})
127
+ end
128
+
129
+ def dependency_wait_current_interval(wait_options)
130
+ current_interval = dependency_wait_state["current_interval"]
131
+ return wait_options[:initial_interval] unless current_interval
132
+
133
+ interval = Float(current_interval)
134
+ interval.positive? ? interval : wait_options[:initial_interval]
135
+ rescue ArgumentError, TypeError
136
+ wait_options[:initial_interval]
137
+ end
138
+
139
+ def dependency_wait_state
140
+ workflow_state["dependency_wait"] ||= {}
141
+ end
142
+
143
+ def dependency_wait_deadline(wait_options)
144
+ deadline_at = dependency_wait_state["deadline_at"]
145
+ return Time.iso8601(deadline_at) if deadline_at
146
+
147
+ persist_dependency_wait_deadline(wait_options)
148
+ rescue ArgumentError, TypeError
149
+ persist_dependency_wait_deadline(wait_options)
150
+ end
151
+
152
+ def persist_dependency_wait_deadline(wait_options)
153
+ deadline = Temporalio::Workflow.now + wait_options[:timeout]
154
+ dependency_wait_state["deadline_at"] = deadline.iso8601
155
+ deadline
156
+ end
157
+
158
+ def dependency_wait_remaining_timeout(deadline)
159
+ deadline - Temporalio::Workflow.now
160
+ end
161
+
162
+ def persist_dependency_wait_state(not_found_counts, wait_interval)
163
+ dependency_wait_state["not_found_counts"] = not_found_counts
164
+ dependency_wait_state["current_interval"] = wait_interval
165
+ end
166
+
167
+ def next_dependency_wait_interval(wait_interval, wait_options)
168
+ [wait_interval * wait_options[:backoff], wait_options[:max_interval]].min
169
+ end
170
+
53
171
  def dependencies_satisfied?(statuses, expired_not_found_statuses)
54
172
  expired_not_found_keys = expired_not_found_statuses.map { |status| dependency_status_key(status) }
55
173
  statuses.all? do |status|
@@ -79,16 +197,23 @@ module ActiveJob
79
197
  end
80
198
 
81
199
  def dependency_state(status)
82
- status["state"] || status[:state]
200
+ status_value(status, :state)
83
201
  end
84
202
 
85
203
  def dependency_status_key(status)
86
- job_class = status["job_class"] || status[:job_class]
87
- job_id = status["job_id"] || status[:job_id]
204
+ job_class = status_value(status, :job_class)
205
+ job_id = status_value(status, :job_id)
206
+ workflow_id = status_value(status, :workflow_id)
207
+ run_id = status_value(status, :run_id)
88
208
  return "#{job_class}:#{job_id}" if job_class && job_id
89
209
  return job_id if job_id
210
+ return [workflow_id, run_id].compact.join(":") if workflow_id
211
+
212
+ status.hash
213
+ end
90
214
 
91
- status["workflow_id"] || status[:workflow_id] || status.hash
215
+ def status_value(status, key)
216
+ status[key.to_s] || status[key]
92
217
  end
93
218
 
94
219
  def fail_on_dependency_failure?(payload)
@@ -109,6 +234,14 @@ module ActiveJob
109
234
  non_retryable: true
110
235
  )
111
236
  end
237
+
238
+ def dependency_timeout_statuses(dependencies)
239
+ dependencies.map do |dependency|
240
+ dependency.each_with_object({ "state" => "timed_out" }) do |(key, value), status|
241
+ status[key.to_s] = value
242
+ end
243
+ end
244
+ end
112
245
  end
113
246
  end
114
247
  end
@@ -9,17 +9,9 @@ require_relative "temporal/configurable"
9
9
  require_relative "temporal/client"
10
10
  require_relative "temporal/logger"
11
11
  require_relative "temporal/bind_policy"
12
- require_relative "temporal/rails_environment_loader"
13
12
  require_relative "temporal/tls_file"
14
- require_relative "temporal/certificate_watcher"
15
- require_relative "temporal/reload_signal_queue"
16
- require_relative "temporal/worker_client_reloader"
17
- require_relative "temporal/worker_pool"
18
13
  require_relative "temporal/audit_log"
19
- require_relative "temporal/metrics_server"
20
14
  require_relative "temporal/observability"
21
- require_relative "temporal/worker_health"
22
- require_relative "temporal/health_check_server"
23
15
  require_relative "temporal/middleware"
24
16
  require_relative "temporal/payload_serializers"
25
17
  require_relative "temporal/rate_limit_options"
@@ -36,6 +28,7 @@ require_relative "temporal/child_workflow_options"
36
28
  require_relative "temporal/chain_options"
37
29
  require_relative "temporal/dependency_options"
38
30
  require_relative "temporal/workflow_identity"
31
+ require_relative "temporal/workflow_types"
39
32
  require_relative "temporal/job_payload_builder"
40
33
  require_relative "temporal/schedule_options"
41
34
  require_relative "temporal/conditional_enqueue"
@@ -44,17 +37,13 @@ require_relative "temporal/temporal_options"
44
37
  require_relative "temporal/schedule"
45
38
  require_relative "temporal/schedulable"
46
39
  require_relative "temporal/workflow_id_builder"
40
+ require_relative "temporal/job_id_validation"
47
41
  require_relative "temporal/batch_enqueue_result"
48
42
  require_relative "temporal/batch_enqueuer"
49
43
  require_relative "temporal/workflow_enqueuer_batch"
50
44
  require_relative "temporal/workflow_enqueuer"
51
45
  require_relative "temporal/adapter"
52
46
  require_relative "temporal/transaction_safety"
53
- require_relative "temporal/workflows/aj_workflow"
54
- require_relative "temporal/workflows/dead_letter_workflow"
55
- require_relative "temporal/activities/rate_limit_activity"
56
- require_relative "temporal/activities/dependency_status_activity"
57
- require_relative "temporal/activities/aj_runner_activity"
58
47
  require_relative "temporal/cancel"
59
48
  require_relative "temporal/inspect"
60
49
  require_relative "temporal/signal_query"
@@ -110,6 +99,11 @@ module ActiveJob
110
99
  # @see Cancel.cancel
111
100
  class TemporalConnectionError < Error; end
112
101
 
102
+ # Raised when a worker_activities entry cannot be used as a Temporal activity.
103
+ #
104
+ # @see WorkerRegistrations.resolve
105
+ class WorkerRegistrationError < Error; end
106
+
113
107
  extend Configurable
114
108
 
115
109
  class << self
@@ -168,6 +162,20 @@ module ActiveJob
168
162
  fresh_client
169
163
  end
170
164
 
165
+ # Re-reads the API key (api_key or api_key_file) and applies it to the memoized
166
+ # client's live connection. No reconnect happens - the SDK sends the header per-RPC
167
+ # from the connection's stored options. Worker processes call this from the
168
+ # api_key_watch file watcher; a long-lived enqueue-side process using a rotating
169
+ # token file must call it on its own schedule (nothing watches the file there).
170
+ #
171
+ # @return [String, nil] the freshly resolved API key
172
+ def refresh_api_key!
173
+ fresh_key = Client.resolve_api_key(config)
174
+ client.connection.api_key = fresh_key
175
+ Logger.log_event("api_key_refreshed")
176
+ fresh_key
177
+ end
178
+
171
179
  # Cancels a running or scheduled job by job ID.
172
180
  #
173
181
  # This method requests cancellation for the Temporal workflow associated with the job.
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mutant/minitest/coverage"
4
+ require_relative "../spec/spec_helper"
5
+
6
+ # mutant-minitest discovers test/**/*_test.rb, while the migrated suite keeps
7
+ # the existing spec/ split for CI and developer workflows.
8
+ Minitest::Spec.cover "ActiveJob::Temporal::WorkflowIdBuilder#build"
9
+ Minitest::Spec.cover "ActiveJob::Temporal::WorkflowIdBuilder.default_for"
10
+
11
+ Dir[File.expand_path("../spec/unit/**/*_spec.rb", __dir__)].each do |path|
12
+ require path
13
+ end