activejob-temporal 0.1.0 → 0.2.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +1 -0
  3. data/CHANGELOG.md +18 -0
  4. data/CLAUDE.md +274 -0
  5. data/CONTRIBUTING.md +69 -0
  6. data/README.md +34 -33
  7. data/activejob-temporal.gemspec +8 -12
  8. data/api/job_payload_schema.json +51 -6
  9. data/bin/temporal-worker +3 -8
  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 +4 -3
  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 +139 -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 +15 -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_runtime.rb +16 -0
  54. data/lib/activejob/temporal/workflow_enqueuer.rb +2 -1
  55. data/lib/activejob/temporal/workflow_types.rb +10 -0
  56. data/lib/activejob/temporal/workflows/aj_workflow.rb +17 -4
  57. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +148 -15
  58. data/lib/activejob/temporal.rb +2 -13
  59. data/test/mutant_unit_test.rb +13 -0
  60. metadata +43 -48
@@ -131,18 +131,33 @@ module ActiveJob
131
131
  handlers = job_class.rescue_handlers
132
132
  return [] unless handlers.respond_to?(:reverse_each)
133
133
 
134
- signature = rescue_handlers_signature(handlers)
134
+ cached = cache[job_class]
135
+ return cached[:entries] if fresh_cache_entry?(cached, handlers)
135
136
 
136
137
  @cache_mutex.synchronize do
137
138
  cached = cache[job_class]
138
- return cached[:entries] if cached && cached[:signature] == signature
139
+ return cached[:entries] if fresh_cache_entry?(cached, handlers)
139
140
 
140
141
  entries = yield(handlers).map(&:freeze).freeze
141
- cache[job_class] = { signature: signature, entries: entries }.freeze
142
+ cache[job_class] = {
143
+ handlers: handlers,
144
+ signature: rescue_handlers_signature(handlers),
145
+ entries: entries
146
+ }.freeze
142
147
  entries
143
148
  end
144
149
  end
145
150
 
151
+ # ActiveJob replaces the rescue_handlers array on every declaration, so
152
+ # identity is a sound (and allocation-free) freshness check; the signature
153
+ # comparison stays as a fallback for in-place mutation.
154
+ def fresh_cache_entry?(cached, handlers)
155
+ return false unless cached
156
+ return true if cached[:handlers].equal?(handlers)
157
+
158
+ cached[:signature] == rescue_handlers_signature(handlers)
159
+ end
160
+
146
161
  def rescue_handlers_signature(handlers)
147
162
  handlers.reverse_each.map do |class_or_name, handler|
148
163
  [class_or_name, handler.object_id]
@@ -10,22 +10,20 @@ module ActiveJob
10
10
  end
11
11
 
12
12
  module ClassMethods
13
- def schedule(options = nil, **kwargs)
13
+ def temporal_schedule(options = nil, **kwargs)
14
+ return @temporal_schedule if options.nil? && kwargs.empty?
15
+
14
16
  schedule_options = normalize_schedule_options(options, kwargs)
15
17
  @temporal_schedule = ActiveJob::Temporal::Schedule.new(self, schedule_options)
16
18
  end
17
19
 
18
- def temporal_schedule
19
- @temporal_schedule
20
- end
21
-
22
20
  def create_temporal_schedule(options = nil, **kwargs)
23
21
  if options || kwargs.any?
24
22
  schedule_options = merged_schedule_options(normalize_schedule_options(options, kwargs))
25
23
  return ActiveJob::Temporal::Schedule.new(self, schedule_options).create
26
24
  end
27
25
 
28
- raise ArgumentError, "No schedule defined for #{name}" unless temporal_schedule
26
+ raise ArgumentError, "No temporal_schedule defined for #{name}" unless temporal_schedule
29
27
 
30
28
  temporal_schedule.create
31
29
  end
@@ -43,7 +41,7 @@ module ActiveJob
43
41
  when Hash
44
42
  options.merge(kwargs)
45
43
  else
46
- raise ArgumentError, "schedule options must be a Hash"
44
+ raise ArgumentError, "temporal_schedule options must be a Hash"
47
45
  end
48
46
  end
49
47
 
@@ -9,7 +9,7 @@ require_relative "job_payload_builder"
9
9
  require_relative "logger"
10
10
  require_relative "schedule_options"
11
11
  require_relative "search_attributes"
12
- require_relative "workflows/aj_workflow"
12
+ require_relative "workflow_types"
13
13
 
14
14
  module ActiveJob
15
15
  module Temporal
@@ -96,7 +96,7 @@ module ActiveJob
96
96
  payload = annotate_scheduled_payload(payload, workflow_id)
97
97
 
98
98
  Temporalio::Client::Schedule::Action::StartWorkflow.new(
99
- Workflows::AjWorkflow,
99
+ WorkflowTypes::ACTIVE_JOB,
100
100
  payload,
101
101
  id: workflow_id,
102
102
  task_queue: Adapter.resolve_task_queue(job, config: @config),
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "temporalio/error"
4
+ require_relative "job_id_validation"
4
5
  require_relative "visibility_query"
5
6
  require_relative "workflow_id_builder"
6
7
 
7
8
  module ActiveJob
8
9
  module Temporal
9
10
  module SignalQuery
10
- UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
11
11
  JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
12
12
  HANDLER_NAME_PATTERN = /\A[a-zA-Z_]\w*\z/
13
13
  DEFAULT_REJECT_CONDITION = Object.new.freeze
14
+ WORKFLOW_NOT_FOUND = Object.new.freeze
14
15
 
15
16
  class << self
16
17
  def signal(job_class, job_id, signal_name, *)
@@ -57,33 +58,48 @@ module ActiveJob
57
58
  raise ActiveJob::Temporal::TemporalConnectionError,
58
59
  "Failed to update Temporal workflow for job_id #{job_id}: #{e.message}"
59
60
  end
61
+ end
60
62
 
63
+ class << self
61
64
  private
62
65
 
63
- def with_running_workflow_handle(job_class, job_id)
66
+ def with_running_workflow_handle(job_class, job_id, &)
64
67
  client = ActiveJob::Temporal.client
65
- default_handle = client.workflow_handle(default_workflow_id(job_class, job_id), run_id: nil)
68
+ result = yield_schedule_execution_handle(client, job_id, &)
69
+ return result unless result.equal?(WORKFLOW_NOT_FOUND)
66
70
 
67
- begin
68
- return yield(default_handle)
69
- rescue Temporalio::Error::RPCError => e
70
- raise unless rpc_not_found?(e)
71
- end
71
+ result = yield_handle(client.workflow_handle(default_workflow_id(job_class, job_id), run_id: nil), &)
72
+ return result unless result.equal?(WORKFLOW_NOT_FOUND)
72
73
 
73
74
  workflow_reference = find_running_workflow_reference(client, job_class, job_id)
74
75
  raise workflow_not_found(job_id) unless workflow_reference
75
76
 
76
- fallback_handle = client.workflow_handle(
77
+ result = yield_handle(workflow_handle(client, workflow_reference), &)
78
+ raise workflow_not_found(job_id) if result.equal?(WORKFLOW_NOT_FOUND)
79
+
80
+ result
81
+ end
82
+
83
+ def yield_schedule_execution_handle(client, job_id, &)
84
+ workflow_reference = JobIdValidation.schedule_execution_reference(job_id)
85
+ return WORKFLOW_NOT_FOUND unless workflow_reference
86
+
87
+ yield_handle(workflow_handle(client, workflow_reference), &)
88
+ end
89
+
90
+ def yield_handle(handle, &block)
91
+ block.call(handle)
92
+ rescue Temporalio::Error::RPCError => e
93
+ raise unless rpc_not_found?(e)
94
+
95
+ WORKFLOW_NOT_FOUND
96
+ end
97
+
98
+ def workflow_handle(client, workflow_reference)
99
+ client.workflow_handle(
77
100
  workflow_reference.fetch(:workflow_id),
78
101
  run_id: workflow_reference[:run_id]
79
102
  )
80
- begin
81
- yield(fallback_handle)
82
- rescue Temporalio::Error::RPCError => e
83
- raise workflow_not_found(job_id) if rpc_not_found?(e)
84
-
85
- raise
86
- end
87
103
  end
88
104
 
89
105
  def query_workflow(handle, handler_name, args, reject_condition)
@@ -119,11 +135,7 @@ module ActiveJob
119
135
  end
120
136
 
121
137
  def validate_job_id!(job_id)
122
- return if job_id.is_a?(String) && job_id.match?(UUID_REGEX)
123
-
124
- raise ArgumentError,
125
- "Invalid job_id format: expected UUID (e.g., '550e8400-e29b-41d4-a716-446655440000'), " \
126
- "got: #{job_id.inspect}"
138
+ JobIdValidation.validate!(job_id)
127
139
  end
128
140
 
129
141
  def normalize_handler_name!(name, handler_type)
@@ -149,11 +161,11 @@ module ActiveJob
149
161
  end
150
162
 
151
163
  def rpc_not_found?(error)
152
- error.code == Temporalio::Error::RPCError::Code::NOT_FOUND
164
+ error.respond_to?(:code) && error.code == Temporalio::Error::RPCError::Code::NOT_FOUND
153
165
  end
154
166
 
155
167
  def rpc_invalid_argument?(error)
156
- error.code == Temporalio::Error::RPCError::Code::INVALID_ARGUMENT
168
+ error.respond_to?(:code) && error.code == Temporalio::Error::RPCError::Code::INVALID_ARGUMENT
157
169
  end
158
170
  end
159
171
  end
@@ -11,6 +11,21 @@ module ActiveJob
11
11
  # temporal_options start_to_close_timeout: 30.seconds
12
12
  # end
13
13
  #
14
+ # @example ApplicationJob defaults inherited by subclasses
15
+ # class ApplicationJob < ActiveJob::Base
16
+ # temporal_options start_to_close_timeout: 2.minutes
17
+ # end
18
+ #
19
+ # class QuickJob < ApplicationJob
20
+ # end
21
+ #
22
+ # @example Merge inherited defaults intentionally
23
+ # class DataProcessingJob < ApplicationJob
24
+ # temporal_options ApplicationJob.temporal_options.merge(
25
+ # heartbeat_timeout: 30.seconds
26
+ # )
27
+ # end
28
+ #
14
29
  # @example Long-running job with heartbeat
15
30
  # class DataProcessingJob < ApplicationJob
16
31
  # temporal_options(
@@ -59,16 +74,26 @@ module ActiveJob
59
74
  #
60
75
  # @note Timeout values can be specified as either integers (seconds) or
61
76
  # ActiveSupport::Duration objects (e.g., 2.hours, 30.seconds)
77
+ #
78
+ # @note Subclasses inherit parent options unless they declare their own.
79
+ # A subclass declaration replaces the inherited hash. Use +merge+ to
80
+ # keep inherited keys while overriding selected values.
62
81
  def temporal_options(options = nil)
63
82
  if options
64
83
  validate_timeout_keys!(options)
65
84
  @temporal_options = normalize_timeout_values(options)
66
85
  end
67
- @temporal_options || {}
86
+ @temporal_options || inherited_temporal_options || {}
68
87
  end
69
88
 
70
89
  private
71
90
 
91
+ def inherited_temporal_options
92
+ return unless superclass.respond_to?(:temporal_options)
93
+
94
+ superclass.temporal_options
95
+ end
96
+
72
97
  # Validates that only recognized timeout keys are provided
73
98
  #
74
99
  # @param options [Hash] The options hash to validate
@@ -4,42 +4,42 @@ module ActiveJob
4
4
  module Temporal
5
5
  module TLSFile
6
6
  class Error < StandardError; end
7
+ # Symlinks are resolved before opening, so NOFOLLOW only trips when the
8
+ # resolved path is swapped for a symlink between resolution and open.
7
9
  OPEN_FLAGS = File::RDONLY | (File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0)
8
- USES_NOFOLLOW = File.const_defined?(:NOFOLLOW)
9
10
 
10
11
  module_function
11
12
 
13
+ # @param path [String, nil] path to a TLS file, symlinks allowed
14
+ # @return [Boolean] true when the path resolves to a readable regular file
12
15
  def readable_regular_file?(path)
13
- expanded_path = File.expand_path(path)
14
- stat = File.lstat(expanded_path)
15
- return false if stat.symlink?
16
+ resolved_path = File.realpath(File.expand_path(path))
16
17
 
17
- stat.file? && File.readable?(expanded_path)
18
- rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
18
+ File.stat(resolved_path).file? && File.readable?(resolved_path)
19
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, Errno::ELOOP
19
20
  false
20
21
  end
21
22
 
23
+ # Reads a TLS file, resolving symlinks first so Kubernetes secret mounts
24
+ # (path -> ..data/path -> timestamped directory) work.
25
+ #
26
+ # @param path [String, nil] path to a TLS file
27
+ # @return [String, nil] file contents, or nil when path is blank
28
+ # @raise [Error] when the path does not resolve to a readable regular file
22
29
  def read(path)
23
30
  return nil if path.nil? || path.to_s.empty?
24
31
 
25
- expanded_path = File.expand_path(path)
26
- reject_symlink!(expanded_path) unless USES_NOFOLLOW
27
- File.open(expanded_path, OPEN_FLAGS) do |file|
32
+ resolved_path = File.realpath(File.expand_path(path))
33
+ File.open(resolved_path, OPEN_FLAGS) do |file|
28
34
  raise Error, "TLS file path must point to a regular file: #{path}" unless file.stat.file?
29
35
 
30
36
  file.read
31
37
  end
32
38
  rescue Errno::ELOOP
33
- raise Error, "TLS file path must not be a symlink: #{path}"
39
+ raise Error, "TLS file path could not be resolved: #{path}"
34
40
  rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
35
41
  raise Error, "TLS file path is not readable: #{path}"
36
42
  end
37
-
38
- def reject_symlink!(path)
39
- return unless File.lstat(path).symlink?
40
-
41
- raise Error, "TLS file path must not be a symlink: #{path}"
42
- end
43
43
  end
44
44
  end
45
45
  end
@@ -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.2.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,16 @@
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"
@@ -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)