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
@@ -38,7 +38,10 @@ module ActiveJob
38
38
  # job_class: "MyJob",
39
39
  # job_id: "abc-123",
40
40
  # queue_name: "default",
41
- # arguments: [{"_aj_serialized"=>"ActiveJob::Serializers::ObjectSerializer", ...}],
41
+ # active_job: {
42
+ # "arguments" => [{"_aj_serialized"=>"ActiveJob::Serializers::ObjectSerializer", ...}],
43
+ # ...
44
+ # },
42
45
  # executions: 0,
43
46
  # exception_executions: {},
44
47
  # scheduled_at: "2025-10-29T12:00:00Z" # optional
@@ -83,7 +86,7 @@ module ActiveJob
83
86
  # - :job_class [String] Fully-qualified job class name
84
87
  # - :job_id [String] Unique job identifier
85
88
  # - :queue_name [String] Target queue name
86
- # - :arguments [Array] Serialized job arguments (via ActiveJob::Arguments)
89
+ # - :active_job [Hash] ActiveJob serialized payload, including arguments
87
90
  # - :executions [Integer] Current execution count (default 0)
88
91
  # - :exception_executions [Hash] Exception execution counts (default {})
89
92
  # - :scheduled_at [String] ISO8601 timestamp (optional)
@@ -98,7 +101,7 @@ module ActiveJob
98
101
  # @example Basic job payload
99
102
  # job = MyJob.new
100
103
  # payload = Payload.from_job(job)
101
- # # => { job_class: "MyJob", job_id: "...", arguments: [...], ... }
104
+ # # => { job_class: "MyJob", job_id: "...", active_job: { "arguments" => [...] }, ... }
102
105
  #
103
106
  # @example Scheduled job payload
104
107
  # job = MyJob.new
@@ -155,19 +158,25 @@ module ActiveJob
155
158
  final_payload = serializer_for(config).dump(payload)
156
159
  final_payload[:scheduled_at] = scheduled_timestamp if scheduled_timestamp
157
160
  final_payload = encrypt_payload_for_transport(final_payload, encrypt, config, encryption_context)
158
- final_payload = offload_payload_for_transport(final_payload, storage_metadata, config) if offload
159
- enforce_size!(final_payload, metrics_payload: payload, config: config) if enforce_size
160
- final_payload
161
+ finalize_transport_payload(
162
+ final_payload,
163
+ metrics_payload: payload,
164
+ config: config,
165
+ offload: offload ? storage_metadata || {} : nil,
166
+ enforce_size: enforce_size
167
+ )
161
168
  end
162
169
 
163
170
  # Deserializes job arguments from a payload hash.
164
171
  #
165
- # Extracts the serialized arguments array from the payload and uses
166
- # ActiveJob's built-in deserialization to reconstruct Ruby objects
172
+ # Extracts the serialized arguments array from ActiveJob's canonical
173
+ # serialized job data, falling back to legacy top-level arguments, then
174
+ # uses ActiveJob's built-in deserialization to reconstruct Ruby objects
167
175
  # (including GlobalID references to ActiveRecord models).
168
176
  #
169
- # @param payload [Hash] Payload hash containing serialized arguments
170
- # @option payload [Array] :arguments Serialized arguments (required)
177
+ # @param payload [Hash] Payload hash containing serialized ActiveJob data
178
+ # @option payload [Hash] :active_job Full ActiveJob serialized payload
179
+ # @option payload [Array] :arguments Legacy serialized arguments
171
180
  #
172
181
  # @return [Array] Deserialized arguments array ready for job.perform(*args)
173
182
  #
@@ -175,13 +184,13 @@ module ActiveJob
175
184
  # @raise [GlobalID::RecordNotFound] if a GlobalID reference points to a deleted record
176
185
  #
177
186
  # @example Deserialize arguments
178
- # payload = { arguments: [{"_aj_serialized"=>"..."}] }
187
+ # payload = { active_job: { "arguments" => [{"_aj_serialized"=>"..."}] } }
179
188
  # args = Payload.deserialize_args(payload)
180
189
  # # => [actual_ruby_object]
181
190
  #
182
191
  # @example GlobalID deserialization with deleted record
183
192
  # begin
184
- # payload = { arguments: [{"_aj_globalid"=>"gid://app/User/999"}] }
193
+ # payload = { active_job: { "arguments" => [{"_aj_globalid"=>"gid://app/User/999"}] } }
185
194
  # args = Payload.deserialize_args(payload)
186
195
  # rescue ActiveRecord::RecordNotFound => e
187
196
  # # Record was deleted between enqueue and execution
@@ -192,7 +201,7 @@ module ActiveJob
192
201
  end
193
202
 
194
203
  def deserialize_payload_args(payload)
195
- serialized_args = payload[:arguments] || payload["arguments"]
204
+ serialized_args = active_job_arguments(payload) || payload[:arguments] || payload["arguments"]
196
205
  ActiveJob::Arguments.deserialize(serialized_args)
197
206
  rescue ActiveJob::SerializationError, ActiveJob::Temporal::ConfigurationError
198
207
  raise
@@ -216,12 +225,13 @@ module ActiveJob
216
225
  encrypt_payload_if_configured(payload, config, encryption_context: encryption_context)
217
226
  end
218
227
 
219
- def offload_payload(payload, metadata:, config: ActiveJob::Temporal.config)
228
+ def offload_payload(payload, metadata:, config: ActiveJob::Temporal.config, byte_size: nil)
220
229
  PayloadStorage.offload_if_needed(
221
230
  payload,
222
231
  config: config,
223
232
  metadata: metadata,
224
- workflow_control_fields: WORKFLOW_CONTROL_FIELDS
233
+ workflow_control_fields: WORKFLOW_CONTROL_FIELDS,
234
+ byte_size: byte_size
225
235
  )
226
236
  end
227
237
 
@@ -229,19 +239,19 @@ module ActiveJob
229
239
  PayloadStorage.delete(payload, config: config)
230
240
  end
231
241
 
232
- def enforce_size!(payload, metrics_payload: payload, config: ActiveJob::Temporal.config)
233
- json = JSON.generate(payload)
242
+ def enforce_size!(payload, metrics_payload: payload, config: ActiveJob::Temporal.config, byte_size: nil)
243
+ bytes = byte_size || JSON.generate(payload).bytesize
234
244
  max_size_kb = config.max_payload_size_kb || 250
235
245
  size_limit_bytes = max_size_kb * 1024
236
- actual_size_kb = json.bytesize / 1024.0
237
- usage_ratio = json.bytesize.to_f / size_limit_bytes
246
+ actual_size_kb = bytes / 1024.0
247
+ usage_ratio = bytes.to_f / size_limit_bytes
238
248
 
239
249
  Observability.emit(
240
250
  :payload_serialize,
241
- Observability.attributes_from_payload(metrics_payload, bytes: json.bytesize)
251
+ Observability.attributes_from_payload(metrics_payload, bytes: bytes)
242
252
  )
243
253
  log_payload_size(metrics_payload, actual_size_kb, max_size_kb, usage_ratio)
244
- return if json.bytesize <= size_limit_bytes
254
+ return if bytes <= size_limit_bytes
245
255
 
246
256
  message = format(
247
257
  "Job payload size (%<actual>.1f KB) exceeds maximum allowed size (%<max>d KB). " \
@@ -262,8 +272,18 @@ module ActiveJob
262
272
  encrypt_payload(payload, config: config, encryption_context: encryption_context)
263
273
  end
264
274
 
265
- def offload_payload_for_transport(payload, storage_metadata, config)
266
- offload_payload(payload, metadata: storage_metadata || {}, config: config)
275
+ # One JSON pass feeds both the storage threshold check and the size limit check.
276
+ def finalize_transport_payload(payload, metrics_payload:, config:, offload:, enforce_size:)
277
+ return payload unless offload || enforce_size
278
+
279
+ byte_size = JSON.generate(payload).bytesize
280
+ if offload
281
+ offloaded = offload_payload(payload, metadata: offload, config: config, byte_size: byte_size)
282
+ byte_size = nil unless offloaded.equal?(payload)
283
+ payload = offloaded
284
+ end
285
+ enforce_size!(payload, metrics_payload: metrics_payload, config: config, byte_size: byte_size) if enforce_size
286
+ payload
267
287
  end
268
288
 
269
289
  def decrypt_transport_payload(payload, config, encryption_context)
@@ -296,12 +316,24 @@ module ActiveJob
296
316
  PayloadSerializers.fetch(config.payload_serializer)
297
317
  end
298
318
 
299
- def serializer_for_transport_payload(payload, _config)
319
+ def serializer_for_transport_payload(payload, config)
300
320
  payload_serializer = payload_serializer_name(payload)
301
321
  validate_payload_serializer_version!(payload) if payload_serializer_metadata?(payload)
322
+ validate_payload_serializer_allowed!(payload_serializer, config)
302
323
  PayloadSerializers.fetch(payload_serializer)
303
324
  end
304
325
 
326
+ # The payload names its own serializer, so an attacker-supplied workflow input could
327
+ # otherwise select Marshal (arbitrary code execution) regardless of configuration.
328
+ def validate_payload_serializer_allowed!(payload_serializer, config)
329
+ return if PayloadSerializers::DATA_ONLY.include?(payload_serializer)
330
+ return if payload_serializer == PayloadSerializers.normalize_name(config.payload_serializer)
331
+
332
+ raise ActiveJob::SerializationError,
333
+ "Payload serializer #{payload_serializer.inspect} is not permitted by the configured " \
334
+ "payload_serializer (#{config.payload_serializer.inspect})"
335
+ end
336
+
305
337
  def payload_serializer_name(payload)
306
338
  serializer_name = payload[:payload_serializer] || payload["payload_serializer"]
307
339
  return PayloadSerializers::JSON unless serializer_name
@@ -327,6 +359,13 @@ module ActiveJob
327
359
  end
328
360
  end
329
361
 
362
+ def active_job_arguments(payload)
363
+ active_job_payload = payload[:active_job] || payload["active_job"]
364
+ return unless active_job_payload.respond_to?(:[])
365
+
366
+ active_job_payload[:arguments] || active_job_payload["arguments"]
367
+ end
368
+
330
369
  # Serializes job arguments using ActiveJob's built-in serializer.
331
370
  # @api private
332
371
  def serialize_arguments(arguments)
@@ -352,12 +391,12 @@ module ActiveJob
352
391
  job_class: job.class.name,
353
392
  job_id: job.job_id,
354
393
  queue_name: job.queue_name,
355
- arguments: serialize_arguments(job.arguments || []),
356
394
  executions: job.executions || 0,
357
395
  exception_executions: job.exception_executions || {}
358
396
  }
359
397
  active_job_payload = serialized_active_job(job)
360
398
  payload[:active_job] = active_job_payload if active_job_payload
399
+ payload[:arguments] = serialize_arguments(job.arguments || []) unless active_job_payload
361
400
  payload
362
401
  end
363
402
 
@@ -32,7 +32,15 @@ module ActiveJob
32
32
 
33
33
  def decrypt(payload, config, context: nil)
34
34
  version = payload[:encrypted_payload_version] || payload["encrypted_payload_version"]
35
- return decrypt_legacy(payload, config) if version == LEGACY_VERSION
35
+ if version == LEGACY_VERSION
36
+ unless config.allow_legacy_encrypted_payloads
37
+ raise ActiveJob::SerializationError,
38
+ "Version 1 encrypted payloads are rejected; set allow_legacy_encrypted_payloads " \
39
+ "to accept payloads enqueued before context-bound encryption"
40
+ end
41
+
42
+ return decrypt_legacy(payload, config)
43
+ end
36
44
  return decrypt_v2(payload, config, context) if version == VERSION
37
45
 
38
46
  raise ActiveJob::SerializationError, "Unsupported encrypted payload version: #{version.inspect}"
@@ -15,6 +15,9 @@ module ActiveJob
15
15
  MESSAGE_PACK_ALIAS = :msgpack
16
16
  MARSHAL = :marshal
17
17
  SUPPORTED = [JSON, MESSAGE_PACK, MESSAGE_PACK_ALIAS, MARSHAL].freeze
18
+ # Serializers that cannot instantiate arbitrary objects, so a payload may select them
19
+ # even when the configuration names a different serializer.
20
+ DATA_ONLY = [JSON, MESSAGE_PACK].freeze
18
21
 
19
22
  def fetch(name)
20
23
  case normalize_name(name)
@@ -16,9 +16,9 @@ module ActiveJob
16
16
  payload[:external_payload] == true || payload["external_payload"] == true
17
17
  end
18
18
 
19
- def offload_if_needed(payload, config:, metadata:, workflow_control_fields:)
19
+ def offload_if_needed(payload, config:, metadata:, workflow_control_fields:, byte_size: nil)
20
20
  return payload unless configured?(config)
21
- return payload unless payload_exceeds_threshold?(payload, config)
21
+ return payload unless payload_exceeds_threshold?(payload, config, byte_size)
22
22
 
23
23
  reference = dump_payload(payload, config, metadata)
24
24
  envelope = {
@@ -62,8 +62,8 @@ module ActiveJob
62
62
  !config.payload_storage_adapter.nil? && !config.payload_storage_threshold_kb.nil?
63
63
  end
64
64
 
65
- def payload_exceeds_threshold?(payload, config)
66
- JSON.generate(payload).bytesize > (config.payload_storage_threshold_kb * 1024)
65
+ def payload_exceeds_threshold?(payload, config, byte_size = nil)
66
+ (byte_size || JSON.generate(payload).bytesize) > (config.payload_storage_threshold_kb * 1024)
67
67
  end
68
68
 
69
69
  def dump_payload(payload, config, metadata)
@@ -129,14 +129,7 @@ module ActiveJob
129
129
  return unless Object.const_defined?(:Rails)
130
130
 
131
131
  rails = Object.const_get(:Rails)
132
- return unless rails.respond_to?(:application) && rails.respond_to?(:env)
133
-
134
- environment = rails.env
135
- return unless environment.respond_to?(:development?) && environment.respond_to?(:test?)
136
- return unless environment.development? || environment.test?
137
-
138
- application = rails.application
139
- application.eager_load! if application.respond_to?(:eager_load!)
132
+ rails.application.eager_load! if rails.env.development? || rails.env.test?
140
133
  end
141
134
  end
142
135
  end
@@ -2,38 +2,38 @@
2
2
 
3
3
  module ActiveJob
4
4
  module Temporal
5
+ # Single-slot signal handoff between a trap handler and the reload thread.
6
+ #
7
+ # Backed by Thread::Queue because trap context forbids taking a Mutex.
5
8
  class ReloadSignalQueue
6
- POLL_INTERVAL_SECONDS = 0.05
7
-
8
9
  def initialize
9
- @pending_signal = nil
10
- @closed = false
10
+ @queue = Thread::Queue.new
11
11
  end
12
12
 
13
+ # Enqueues a reload signal unless one is already pending or the queue is closed.
14
+ #
15
+ # @param signal [String] Signal name
16
+ # @return [String, nil] The signal when enqueued, nil when coalesced or closed
13
17
  def push(signal)
14
- return if @closed || @pending_signal
18
+ return nil if @queue.closed? || !@queue.empty?
15
19
 
16
- @pending_signal = signal
20
+ @queue << signal
17
21
  signal
22
+ rescue ClosedQueueError
23
+ nil
18
24
  end
19
25
 
26
+ # Blocks until a signal is pending or the queue is closed.
27
+ #
28
+ # @return [String, nil] The pending signal, or nil once closed
20
29
  def pop
21
- loop do
22
- return nil if @closed
23
-
24
- if @pending_signal
25
- signal = @pending_signal
26
- @pending_signal = nil
27
- return signal
28
- end
29
-
30
- sleep(POLL_INTERVAL_SECONDS)
31
- end
30
+ @queue.pop
32
31
  end
33
32
 
33
+ # @return [void]
34
34
  def close
35
- @closed = true
36
- @pending_signal = nil
35
+ @queue.clear
36
+ @queue.close
37
37
  end
38
38
  end
39
39
  end
@@ -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