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,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ require_relative "bind_policy"
6
+
7
+ module ActiveJob
8
+ module Temporal
9
+ # rubocop:disable Metrics/ClassLength
10
+ class WorkerPool
11
+ Child = Data.define(:pid, :index, :restarts)
12
+
13
+ DEFAULT_RESTART_DELAY = 1.0
14
+ DEFAULT_SHUTDOWN_TIMEOUT = 10.0
15
+ MAX_RESTART_COUNT = 1_000
16
+ SHUTDOWN_SIGNALS = %w[INT TERM].freeze
17
+ VALID_OPTIONS = %i[
18
+ worker_command
19
+ process_adapter
20
+ health_check_port
21
+ health_check_bind
22
+ health_check_allow_public_bind
23
+ metrics_port
24
+ metrics_bind
25
+ metrics_allow_public_bind
26
+ max_concurrent_activities
27
+ max_concurrent_workflows
28
+ restart_delay
29
+ shutdown_timeout
30
+ install_signal_handlers
31
+ ].freeze
32
+
33
+ def initialize(size:, **options)
34
+ validate_options!(options)
35
+
36
+ @size = positive_integer(size, "pool size")
37
+ configure_process_options(options)
38
+ configure_runtime_options(options)
39
+ initialize_state
40
+ validate!
41
+ end
42
+
43
+ def self.default_worker_command
44
+ bundled_executable = File.expand_path("../../../bin/temporal-worker", __dir__)
45
+ return [RbConfig.ruby, bundled_executable] if File.exist?(bundled_executable)
46
+
47
+ ["temporal-worker"]
48
+ end
49
+
50
+ def start(supervise: true)
51
+ ensure_fork_supported!
52
+
53
+ @mutex.synchronize do
54
+ return self if @running
55
+
56
+ @running = true
57
+ @stopping = false
58
+ end
59
+
60
+ install_signal_handlers if @install_signal_handlers
61
+ @size.times { |index| start_worker(index) }
62
+ @supervisor_thread = Thread.new { supervise_workers } if supervise
63
+ self
64
+ end
65
+
66
+ def wait
67
+ @supervisor_thread&.join
68
+ self
69
+ end
70
+
71
+ def stop
72
+ children = @mutex.synchronize do
73
+ if @stopping
74
+ nil
75
+ else
76
+ @stopping = true
77
+ @running = false
78
+
79
+ @children.values
80
+ end
81
+ end
82
+ return self unless children
83
+
84
+ children.each { |child| terminate_worker(child) }
85
+ deadline = monotonic_time + @shutdown_timeout
86
+ children.each { |child| wait_for_worker(child, deadline) }
87
+ @mutex.synchronize { children.each { |child| @children.delete(child.pid) } }
88
+ restore_signal_handlers if @install_signal_handlers
89
+ self
90
+ end
91
+
92
+ def running?
93
+ @mutex.synchronize { @running }
94
+ end
95
+
96
+ private
97
+
98
+ def validate_options!(options)
99
+ unknown_options = options.keys - VALID_OPTIONS
100
+ return if unknown_options.empty?
101
+
102
+ raise ArgumentError, "unknown worker pool options: #{unknown_options.join(', ')}"
103
+ end
104
+
105
+ def configure_process_options(options)
106
+ worker_command = options.fetch(:worker_command, nil)
107
+ @worker_command = Array(worker_command || self.class.default_worker_command)
108
+ @process_adapter = options.fetch(:process_adapter) { ProcessAdapter.new }
109
+ end
110
+
111
+ def configure_runtime_options(options)
112
+ @health_check_port = optional_positive_integer(options.fetch(:health_check_port, nil), "health_check_port")
113
+ @health_check_bind = options.fetch(:health_check_bind, nil)
114
+ @health_check_allow_public_bind = options.fetch(:health_check_allow_public_bind, false)
115
+ @metrics_port = optional_positive_integer(options.fetch(:metrics_port, nil), "metrics_port")
116
+ @metrics_bind = options.fetch(:metrics_bind, nil)
117
+ @metrics_allow_public_bind = options.fetch(:metrics_allow_public_bind, false)
118
+ @max_concurrent_activities =
119
+ optional_positive_integer(options.fetch(:max_concurrent_activities, nil), "max_concurrent_activities")
120
+ @max_concurrent_workflows =
121
+ optional_positive_integer(options.fetch(:max_concurrent_workflows, nil), "max_concurrent_workflows")
122
+ @restart_delay = Float(options.fetch(:restart_delay, DEFAULT_RESTART_DELAY))
123
+ @shutdown_timeout = Float(options.fetch(:shutdown_timeout, DEFAULT_SHUTDOWN_TIMEOUT))
124
+ @install_signal_handlers = options.fetch(:install_signal_handlers, true)
125
+ end
126
+
127
+ def initialize_state
128
+ @children = {}
129
+ @mutex = Mutex.new
130
+ @running = false
131
+ @stopping = false
132
+ @previous_signal_handlers = {}
133
+ end
134
+
135
+ def validate!
136
+ raise ArgumentError, "worker_command must not be empty" if @worker_command.empty?
137
+ raise ArgumentError, "restart_delay must be finite and non-negative" unless finite_non_negative?(@restart_delay)
138
+ unless finite_non_negative?(@shutdown_timeout)
139
+ raise ArgumentError, "shutdown_timeout must be finite and non-negative"
140
+ end
141
+
142
+ validate_public_binds!
143
+ end
144
+
145
+ def positive_integer(value, name)
146
+ integer = Integer(value)
147
+ return integer if integer.positive?
148
+
149
+ raise ArgumentError, "#{name} must be a positive integer"
150
+ rescue TypeError, ArgumentError
151
+ raise ArgumentError, "#{name} must be a positive integer"
152
+ end
153
+
154
+ def optional_positive_integer(value, name)
155
+ return if value.nil?
156
+
157
+ positive_integer(value, name)
158
+ end
159
+
160
+ def finite_non_negative?(value)
161
+ value.finite? && !value.negative?
162
+ end
163
+
164
+ def validate_public_binds!
165
+ if @health_check_port
166
+ BindPolicy.validate!(
167
+ endpoint: "health check",
168
+ bind_address: @health_check_bind,
169
+ allow_public_bind: @health_check_allow_public_bind,
170
+ warn_on_allowed: false
171
+ )
172
+ end
173
+
174
+ return unless @metrics_port
175
+
176
+ BindPolicy.validate!(
177
+ endpoint: "metrics",
178
+ bind_address: @metrics_bind,
179
+ allow_public_bind: @metrics_allow_public_bind,
180
+ warn_on_allowed: false
181
+ )
182
+ end
183
+
184
+ def ensure_fork_supported!
185
+ return if @process_adapter.fork_supported?
186
+
187
+ raise ActiveJob::Temporal::ConfigurationError, "worker pools require Process.fork support"
188
+ end
189
+
190
+ def start_worker(index, restarts: 0)
191
+ environment = worker_environment(index)
192
+ pid = @process_adapter.fork(environment, @worker_command)
193
+ child = Child.new(pid: pid, index: index, restarts: restarts)
194
+
195
+ unless register_worker_if_running(child)
196
+ terminate_worker(child)
197
+ wait_for_worker(child, monotonic_time + @shutdown_timeout)
198
+ return
199
+ end
200
+
201
+ ActiveJob::Temporal::Logger.log_event(
202
+ "worker_pool_worker_started",
203
+ worker_index: index,
204
+ pid: pid,
205
+ restarts: restarts
206
+ )
207
+ end
208
+
209
+ def register_worker_if_running(child)
210
+ @mutex.synchronize do
211
+ if @stopping || !@running
212
+ false
213
+ else
214
+ @children[child.pid] = child
215
+
216
+ true
217
+ end
218
+ end
219
+ end
220
+
221
+ def worker_environment(index)
222
+ environment = {
223
+ "ACTIVEJOB_TEMPORAL_WORKER_POOL_INDEX" => index.to_s,
224
+ "ACTIVEJOB_TEMPORAL_WORKER_POOL_SIZE" => "1"
225
+ }
226
+
227
+ environment["ACTIVEJOB_TEMPORAL_HEALTH_CHECK_PORT"] = (@health_check_port + index).to_s if @health_check_port
228
+ environment["ACTIVEJOB_TEMPORAL_HEALTH_CHECK_BIND"] = @health_check_bind.to_s if @health_check_bind
229
+ environment["ACTIVEJOB_TEMPORAL_HEALTH_CHECK_ALLOW_PUBLIC_BIND"] = "true" if @health_check_allow_public_bind
230
+ environment["ACTIVEJOB_TEMPORAL_METRICS_PORT"] = (@metrics_port + index).to_s if @metrics_port
231
+ environment["ACTIVEJOB_TEMPORAL_METRICS_BIND"] = @metrics_bind.to_s if @metrics_bind
232
+ environment["ACTIVEJOB_TEMPORAL_METRICS_ALLOW_PUBLIC_BIND"] = "true" if @metrics_allow_public_bind
233
+ if @max_concurrent_activities
234
+ environment["ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_ACTIVITIES"] = @max_concurrent_activities.to_s
235
+ end
236
+ if @max_concurrent_workflows
237
+ environment["ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASKS"] = @max_concurrent_workflows.to_s
238
+ end
239
+
240
+ environment
241
+ end
242
+
243
+ def supervise_workers
244
+ loop do
245
+ pid, status = @process_adapter.wait(worker_pids)
246
+ handle_worker_exit(pid, status)
247
+ break unless running? || child_count.positive?
248
+ rescue Errno::ECHILD
249
+ break unless running?
250
+
251
+ @process_adapter.sleep(0.1)
252
+ end
253
+ ensure
254
+ stop if running?
255
+ end
256
+
257
+ def handle_worker_exit(pid, status)
258
+ child = @mutex.synchronize { @children.delete(pid) }
259
+ return unless child
260
+
261
+ ActiveJob::Temporal::Logger.log_event(
262
+ "worker_pool_worker_exited",
263
+ worker_index: child.index,
264
+ pid: pid,
265
+ success: status.respond_to?(:success?) ? status.success? : nil
266
+ )
267
+
268
+ return unless restart_allowed?
269
+
270
+ @process_adapter.sleep(@restart_delay) if @restart_delay.positive?
271
+ return unless restart_allowed?
272
+
273
+ start_worker(child.index, restarts: next_restart_count(child))
274
+ end
275
+
276
+ def next_restart_count(child)
277
+ [child.restarts + 1, MAX_RESTART_COUNT].min
278
+ end
279
+
280
+ def child_count
281
+ @mutex.synchronize { @children.size }
282
+ end
283
+
284
+ def worker_pids
285
+ @mutex.synchronize { @children.keys }
286
+ end
287
+
288
+ def restart_allowed?
289
+ @mutex.synchronize { @running && !@stopping }
290
+ end
291
+
292
+ def terminate_worker(child)
293
+ @process_adapter.kill("TERM", child.pid)
294
+ rescue Errno::ESRCH
295
+ nil
296
+ end
297
+
298
+ def wait_for_worker(child, deadline)
299
+ loop do
300
+ return if @process_adapter.wait_nonblock(child.pid)
301
+
302
+ break if monotonic_time >= deadline
303
+
304
+ @process_adapter.sleep(0.1)
305
+ end
306
+
307
+ @process_adapter.kill("KILL", child.pid)
308
+ @process_adapter.wait(child.pid)
309
+ rescue Errno::ECHILD, Errno::ESRCH
310
+ nil
311
+ end
312
+
313
+ def install_signal_handlers
314
+ @mutex.synchronize do
315
+ signal_queue = Queue.new
316
+ @signal_queue = signal_queue
317
+ SHUTDOWN_SIGNALS.each do |signal|
318
+ @previous_signal_handlers[signal] = Signal.trap(signal) { signal_queue << signal }
319
+ end
320
+ @signal_thread = Thread.new do
321
+ signal = signal_queue.pop
322
+ unless signal == :shutdown
323
+ ActiveJob::Temporal::Logger.log_event("worker_pool_shutdown_requested", signal: signal)
324
+ stop
325
+ end
326
+ end
327
+ end
328
+ end
329
+
330
+ def restore_signal_handlers
331
+ previous_signal_handlers, signal_queue, signal_thread = @mutex.synchronize do
332
+ [
333
+ @previous_signal_handlers.dup,
334
+ @signal_queue,
335
+ @signal_thread
336
+ ].tap do
337
+ @previous_signal_handlers.clear
338
+ @signal_queue = nil
339
+ @signal_thread = nil
340
+ end
341
+ end
342
+
343
+ previous_signal_handlers.each do |signal, handler|
344
+ Signal.trap(signal, handler)
345
+ end
346
+ signal_queue << :shutdown if signal_queue && !signal_queue.closed?
347
+ signal_thread&.join(1) if signal_thread&.alive? && signal_thread != Thread.current
348
+ rescue ArgumentError
349
+ nil
350
+ end
351
+
352
+ def monotonic_time
353
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
354
+ end
355
+
356
+ class ProcessAdapter
357
+ def fork(environment, command)
358
+ Process.fork do
359
+ environment.each { |key, value| ENV[key] = value }
360
+ exec(*command)
361
+ end
362
+ end
363
+
364
+ def wait(pids)
365
+ if pids.is_a?(Integer)
366
+ Process.wait2(pids)
367
+ else
368
+ wait_for_any(pids)
369
+ end
370
+ end
371
+
372
+ def wait_nonblock(pid)
373
+ Process.wait(pid, Process::WNOHANG)
374
+ end
375
+
376
+ def kill(signal, pid)
377
+ Process.kill(signal, pid)
378
+ end
379
+
380
+ def sleep(duration)
381
+ Kernel.sleep(duration)
382
+ end
383
+
384
+ def fork_supported?
385
+ Process.respond_to?(:fork)
386
+ end
387
+
388
+ private
389
+
390
+ def wait_for_any(pids)
391
+ raise Errno::ECHILD if pids.empty?
392
+
393
+ loop do
394
+ pids.each do |pid|
395
+ waited_pid, status = Process.wait2(pid, Process::WNOHANG)
396
+ return [waited_pid, status] if waited_pid
397
+ rescue Errno::ECHILD
398
+ return [pid, nil]
399
+ end
400
+
401
+ sleep(0.1)
402
+ end
403
+ end
404
+ end
405
+ end
406
+ # rubocop:enable Metrics/ClassLength
407
+ end
408
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require_relative "dead_letter_payload_validation"
6
+ require_relative "job_payload_builder"
7
+ require_relative "observability"
8
+ require_relative "workflow_enqueuer_batch"
9
+ require_relative "workflow_id_builder"
10
+
11
+ module ActiveJob
12
+ module Temporal
13
+ # Service object for enqueueing jobs as Temporal workflows.
14
+ #
15
+ # This class handles the mechanics of converting an ActiveJob into a Temporal workflow
16
+ # execution, including payload serialization, workflow ID generation, and options building.
17
+ #
18
+ # @example Using with a job
19
+ # enqueuer = WorkflowEnqueuer.new(client, config)
20
+ # workflow_id = enqueuer.enqueue(job, scheduled_at: 5.minutes.from_now)
21
+ #
22
+ # @example Direct usage
23
+ # client = ActiveJob::Temporal.client
24
+ # config = ActiveJob::Temporal.config
25
+ # enqueuer = WorkflowEnqueuer.new(client, config)
26
+ # enqueuer.enqueue(job)
27
+ # rubocop:disable Metrics/ClassLength
28
+ class WorkflowEnqueuer
29
+ include WorkflowEnqueuerBatch
30
+
31
+ # @param client [Temporalio::Client] Temporal client connection
32
+ # @param config [ActiveJob::Temporal::Configuration] Configuration object
33
+ # @param logger [Logger] Optional logger instance
34
+ # @param workflow_id_builder [WorkflowIdBuilder] Builder for Temporal workflow IDs
35
+ def initialize(client, config, logger = nil, workflow_id_builder: nil, payload_builder: nil)
36
+ @client_provider = client if client.respond_to?(:call)
37
+ @client = client unless @client_provider
38
+ @config = config
39
+ @logger = logger || config.logger
40
+ @workflow_id_builder = workflow_id_builder || WorkflowIdBuilder.new(configured_workflow_id_generator)
41
+ @payload_builder = payload_builder || JobPayloadBuilder.new(config)
42
+ end
43
+
44
+ # Enqueue a job as a Temporal workflow.
45
+ #
46
+ # Performs validation, builds the payload, generates a workflow ID, constructs
47
+ # workflow options, and starts the workflow via the Temporal client.
48
+ #
49
+ # @param job [ActiveJob::Base] The job to enqueue
50
+ # @param scheduled_at [Time, nil] Time to schedule job, nil for immediate execution
51
+ # @return [Object] Workflow run handle
52
+ #
53
+ # @raise [ActiveJob::SerializationError] If payload serialization fails or exceeds max size
54
+ # @raise [ActiveJob::EnqueueError] If workflow cannot be started
55
+ # @raise [ActiveJob::Temporal::ConfigurationError] If job configuration is invalid
56
+ #
57
+ # @example Immediate execution
58
+ # enqueuer.enqueue(job) # => workflow handle
59
+ #
60
+ # @example Scheduled execution
61
+ # enqueuer.enqueue(job, scheduled_at: 1.hour.from_now) # => workflow handle
62
+ #
63
+ # @example Duplicate job (FAIL conflict policy)
64
+ # enqueuer.enqueue(job) # => handle
65
+ # enqueuer.enqueue(job) # raises DuplicateEnqueueError
66
+ def enqueue(job, scheduled_at: nil)
67
+ validate_job_for_enqueueing(job)
68
+ scheduled_at = validate_scheduled_at!(scheduled_at)
69
+ workflow_id = @workflow_id_builder.build(job)
70
+ payload = build_payload(job, workflow_id: workflow_id, scheduled_at: scheduled_at)
71
+ enqueue_with_payload(job, payload, workflow_id)
72
+ end
73
+
74
+ private
75
+
76
+ def configured_workflow_id_generator
77
+ return unless @config.respond_to?(:workflow_id_generator)
78
+
79
+ @config.workflow_id_generator
80
+ end
81
+
82
+ # Enqueues a workflow with the given payload and options.
83
+ # @api private
84
+ def enqueue_with_payload(job, payload, workflow_id)
85
+ DeadLetterPayloadValidation.validate!(payload)
86
+
87
+ task_queue = Adapter.resolve_task_queue(job, config: @config)
88
+ add_dead_letter_task_queue(payload, task_queue)
89
+
90
+ options = {
91
+ id: workflow_id,
92
+ task_queue: task_queue,
93
+ id_conflict_policy: Temporalio::WorkflowIDConflictPolicy::FAIL
94
+ }
95
+
96
+ # Add search attributes if configured
97
+ if @config.respond_to?(:enable_search_attributes) && @config.enable_search_attributes
98
+ search_attributes = SearchAttributes.for(job)
99
+ options[:search_attributes] = search_attributes
100
+ end
101
+
102
+ start_workflow(job, payload, options)
103
+ end
104
+
105
+ def add_dead_letter_task_queue(payload, task_queue)
106
+ dead_letter = payload[:dead_letter] || payload["dead_letter"]
107
+ return unless dead_letter
108
+
109
+ dead_letter[:task_queue] = task_queue if dead_letter.key?(:queue)
110
+ dead_letter["task_queue"] = task_queue if dead_letter.key?("queue")
111
+ end
112
+
113
+ # Builds a payload hash from a job instance.
114
+ # Includes the job's retry policy and temporal timeout options for use in the workflow.
115
+ # @api private
116
+ def build_payload(job, workflow_id:, scheduled_at: nil)
117
+ @payload_builder.build(
118
+ job,
119
+ scheduled_at: scheduled_at,
120
+ encryption_context: encryption_context_for(workflow_id)
121
+ )
122
+ end
123
+
124
+ def encryption_context_for(workflow_id)
125
+ { namespace: @config.namespace, workflow_id: workflow_id }
126
+ end
127
+
128
+ # Starts the Temporal workflow with the given options.
129
+ # @api private
130
+ def start_workflow(job, payload, options)
131
+ workflow_class = Workflows::AjWorkflow
132
+ handle = start_temporal_workflow(workflow_class, job, payload, options)
133
+
134
+ log_enqueued(job, options, payload, duplicate: false)
135
+ handle
136
+ end
137
+
138
+ def start_temporal_workflow(workflow_class, job, payload, options)
139
+ client.start_workflow(workflow_class, payload, **options)
140
+ rescue StandardError => e
141
+ if workflow_already_started?(e)
142
+ log_enqueued(job, options, payload, duplicate: true)
143
+ raise DuplicateEnqueueError, build_duplicate_enqueue_error_message(job, options)
144
+ end
145
+
146
+ raise ActiveJob::EnqueueError.new(build_enqueue_error_message(job, e)), cause: e
147
+ end
148
+
149
+ # Checks if error indicates workflow was already started (duplicate job_id).
150
+ # @api private
151
+ def workflow_already_started?(error)
152
+ return true if defined?(Temporalio::Error::WorkflowAlreadyStartedError) &&
153
+ error.is_a?(Temporalio::Error::WorkflowAlreadyStartedError)
154
+ return true if defined?(Temporalio::Client::WorkflowAlreadyStartedError) &&
155
+ error.is_a?(Temporalio::Client::WorkflowAlreadyStartedError)
156
+
157
+ defined?(Temporalio::Error::RPCError::Code::ALREADY_EXISTS) &&
158
+ error.respond_to?(:code) &&
159
+ error.code == Temporalio::Error::RPCError::Code::ALREADY_EXISTS
160
+ end
161
+
162
+ # Logs enqueue event with structured metadata.
163
+ # @api private
164
+ def log_enqueued(job, options, payload, duplicate:)
165
+ attributes = enqueue_attributes(job, options, payload, duplicate: duplicate)
166
+
167
+ emit_enqueue_side_effect("log", attributes) do
168
+ Logger.log_event("workflow_enqueued", **attributes)
169
+ end
170
+ emit_enqueue_side_effect("audit", attributes) do
171
+ AuditLog.record("job.enqueued", attributes)
172
+ end
173
+ emit_enqueue_side_effect("observability", attributes) do
174
+ Observability.emit(
175
+ :enqueue,
176
+ Observability.attributes_from_job(
177
+ job,
178
+ workflow_id: options[:id],
179
+ task_queue: options[:task_queue],
180
+ duplicate: duplicate
181
+ )
182
+ )
183
+ end
184
+ end
185
+
186
+ def enqueue_attributes(job, options, payload, duplicate:)
187
+ attributes = {
188
+ workflow_id: options[:id],
189
+ job_class: job.class.name,
190
+ job_id: job.job_id,
191
+ queue: job.queue_name,
192
+ task_queue: options[:task_queue],
193
+ duplicate: duplicate
194
+ }
195
+ attributes[:scheduled_at] = payload[:scheduled_at] if payload[:scheduled_at]
196
+
197
+ attributes
198
+ end
199
+
200
+ def emit_enqueue_side_effect(side_effect, attributes)
201
+ yield
202
+ rescue StandardError => e
203
+ report_enqueue_side_effect_failure(side_effect, attributes, e)
204
+ end
205
+
206
+ def report_enqueue_side_effect_failure(side_effect, attributes, error)
207
+ Logger.warn(
208
+ "workflow_enqueue_side_effect_failed",
209
+ attributes.merge(side_effect: side_effect, error_class: error.class.name)
210
+ )
211
+ rescue StandardError
212
+ nil
213
+ end
214
+
215
+ # Builds error message for enqueue failures.
216
+ # @api private
217
+ def build_enqueue_error_message(job, error)
218
+ format(
219
+ "Failed to enqueue job %<job_class>s (%<job_id>s): %<error>s",
220
+ job_class: job.class.name,
221
+ job_id: job.job_id,
222
+ error: error.message
223
+ )
224
+ end
225
+
226
+ def build_duplicate_enqueue_error_message(job, options)
227
+ format(
228
+ "Job %<job_class>s (%<job_id>s) was already enqueued as workflow %<workflow_id>s",
229
+ job_class: job.class.name,
230
+ job_id: job.job_id,
231
+ workflow_id: options[:id]
232
+ )
233
+ end
234
+
235
+ def client
236
+ return @client_provider.call if @client_provider
237
+
238
+ @client
239
+ end
240
+
241
+ # Validate job before enqueueing.
242
+ #
243
+ # @param job [ActiveJob::Base]
244
+ # @raise [ActiveJob::Temporal::ConfigurationError] If job configuration is invalid
245
+ # @api private
246
+ def validate_job_for_enqueueing(job)
247
+ raise ConfigurationError, "Job queue name cannot be blank" if job.queue_name.blank?
248
+ end
249
+
250
+ def validate_scheduled_at!(scheduled_at)
251
+ return if scheduled_at.nil?
252
+
253
+ scheduled_time = coerce_scheduled_at!(scheduled_at)
254
+ return if scheduled_time <= Time.now
255
+
256
+ scheduled_time
257
+ end
258
+
259
+ def coerce_scheduled_at!(scheduled_at)
260
+ scheduled_time = scheduled_at.is_a?(String) ? Time.iso8601(scheduled_at) : scheduled_at
261
+ scheduled_time = scheduled_time.to_time if !scheduled_time.is_a?(Time) && scheduled_time.respond_to?(:to_time)
262
+ raise ArgumentError unless scheduled_time.is_a?(Time)
263
+
264
+ scheduled_time
265
+ rescue ArgumentError, TypeError
266
+ raise ArgumentError, "scheduled_at must be an ISO8601 string or respond to to_time"
267
+ end
268
+ end
269
+ # rubocop:enable Metrics/ClassLength
270
+ end
271
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "batch_enqueuer"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module WorkflowEnqueuerBatch
8
+ def enqueue_batch(items, concurrency: 1)
9
+ BatchEnqueuer.new(
10
+ enqueue: method(:enqueue),
11
+ validate_job: method(:validate_job_for_enqueueing),
12
+ validate_scheduled_at: method(:validate_scheduled_at!)
13
+ ).enqueue(items, concurrency: concurrency)
14
+ end
15
+ end
16
+ end
17
+ end