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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +130 -0
- data/LICENSE +21 -0
- data/README.md +198 -0
- data/activejob-temporal.gemspec +58 -0
- data/api/job_payload_schema.json +318 -0
- data/bin/temporal-worker +295 -0
- data/lib/activejob/temporal/active_job_handler_source.rb +84 -0
- data/lib/activejob/temporal/activities/aj_runner_activity.rb +454 -0
- data/lib/activejob/temporal/activities/best_effort_side_effects.rb +49 -0
- data/lib/activejob/temporal/activities/dependency_status_activity.rb +160 -0
- data/lib/activejob/temporal/activities/rate_limit_activity.rb +41 -0
- data/lib/activejob/temporal/adapter.rb +257 -0
- data/lib/activejob/temporal/audit_log.rb +118 -0
- data/lib/activejob/temporal/batch_enqueue_result.rb +110 -0
- data/lib/activejob/temporal/batch_enqueuer.rb +141 -0
- data/lib/activejob/temporal/bind_policy.rb +44 -0
- data/lib/activejob/temporal/cancel/batch_canceller.rb +154 -0
- data/lib/activejob/temporal/cancel/batch_summary.rb +45 -0
- data/lib/activejob/temporal/cancel.rb +236 -0
- data/lib/activejob/temporal/certificate_watcher.rb +76 -0
- data/lib/activejob/temporal/chain_options.rb +83 -0
- data/lib/activejob/temporal/child_workflow_options.rb +102 -0
- data/lib/activejob/temporal/client.rb +215 -0
- data/lib/activejob/temporal/conditional_enqueue.rb +56 -0
- data/lib/activejob/temporal/configurable.rb +55 -0
- data/lib/activejob/temporal/configuration.rb +981 -0
- data/lib/activejob/temporal/configured_job_compatibility.rb +44 -0
- data/lib/activejob/temporal/connection_worker_pool.rb +88 -0
- data/lib/activejob/temporal/dead_letter_payload_validation.rb +34 -0
- data/lib/activejob/temporal/dead_letter_queue.rb +163 -0
- data/lib/activejob/temporal/dependency_options.rb +134 -0
- data/lib/activejob/temporal/external_operation.rb +193 -0
- data/lib/activejob/temporal/health_check_server.rb +159 -0
- data/lib/activejob/temporal/http_line_reader.rb +36 -0
- data/lib/activejob/temporal/inspect.rb +184 -0
- data/lib/activejob/temporal/job_descriptor.rb +37 -0
- data/lib/activejob/temporal/job_payload_builder.rb +209 -0
- data/lib/activejob/temporal/job_payload_chain_builder.rb +106 -0
- data/lib/activejob/temporal/job_payload_child_workflows.rb +127 -0
- data/lib/activejob/temporal/job_payload_dependencies.rb +40 -0
- data/lib/activejob/temporal/job_payload_rate_limits.rb +53 -0
- data/lib/activejob/temporal/job_payload_workflow_interactions.rb +31 -0
- data/lib/activejob/temporal/job_tags.rb +40 -0
- data/lib/activejob/temporal/locales/en.yml +126 -0
- data/lib/activejob/temporal/logger.rb +214 -0
- data/lib/activejob/temporal/metrics_server.rb +150 -0
- data/lib/activejob/temporal/middleware/chain.rb +106 -0
- data/lib/activejob/temporal/middleware.rb +11 -0
- data/lib/activejob/temporal/observability/datadog.rb +167 -0
- data/lib/activejob/temporal/observability/opentelemetry.rb +107 -0
- data/lib/activejob/temporal/observability/prometheus.rb +271 -0
- data/lib/activejob/temporal/observability.rb +260 -0
- data/lib/activejob/temporal/payload.rb +415 -0
- data/lib/activejob/temporal/payload_encryption.rb +215 -0
- data/lib/activejob/temporal/payload_serializers/json.rb +23 -0
- data/lib/activejob/temporal/payload_serializers/marshal.rb +53 -0
- data/lib/activejob/temporal/payload_serializers/message_pack.rb +59 -0
- data/lib/activejob/temporal/payload_serializers.rb +37 -0
- data/lib/activejob/temporal/payload_storage.rb +103 -0
- data/lib/activejob/temporal/rails_environment_loader.rb +143 -0
- data/lib/activejob/temporal/rate_limit_options.rb +94 -0
- data/lib/activejob/temporal/rate_limiters/memory.rb +198 -0
- data/lib/activejob/temporal/reload_signal_queue.rb +40 -0
- data/lib/activejob/temporal/retry_handler_extractor.rb +361 -0
- data/lib/activejob/temporal/retry_mapper.rb +264 -0
- data/lib/activejob/temporal/schedulable.rb +60 -0
- data/lib/activejob/temporal/schedule.rb +181 -0
- data/lib/activejob/temporal/schedule_options.rb +105 -0
- data/lib/activejob/temporal/search_attributes.rb +173 -0
- data/lib/activejob/temporal/signal_query.rb +161 -0
- data/lib/activejob/temporal/signal_query_options.rb +106 -0
- data/lib/activejob/temporal/temporal_options.rb +114 -0
- data/lib/activejob/temporal/tls_file.rb +45 -0
- data/lib/activejob/temporal/transaction_safety.rb +39 -0
- data/lib/activejob/temporal/version.rb +7 -0
- data/lib/activejob/temporal/visibility_query.rb +13 -0
- data/lib/activejob/temporal/worker_client_reloader.rb +34 -0
- data/lib/activejob/temporal/worker_health.rb +117 -0
- data/lib/activejob/temporal/worker_pool.rb +408 -0
- data/lib/activejob/temporal/workflow_enqueuer.rb +271 -0
- data/lib/activejob/temporal/workflow_enqueuer_batch.rb +17 -0
- data/lib/activejob/temporal/workflow_id_builder.rb +155 -0
- data/lib/activejob/temporal/workflow_identity.rb +62 -0
- data/lib/activejob/temporal/workflows/aj_workflow.rb +282 -0
- data/lib/activejob/temporal/workflows/dead_letter_support.rb +134 -0
- data/lib/activejob/temporal/workflows/dead_letter_workflow.rb +114 -0
- data/lib/activejob/temporal/workflows/workflow_chaining.rb +194 -0
- data/lib/activejob/temporal/workflows/workflow_child_workflows.rb +140 -0
- data/lib/activejob/temporal/workflows/workflow_continue_as_new.rb +44 -0
- data/lib/activejob/temporal/workflows/workflow_dependencies.rb +115 -0
- data/lib/activejob/temporal/workflows/workflow_execution_steps.rb +22 -0
- data/lib/activejob/temporal/workflows/workflow_interactions.rb +215 -0
- data/lib/activejob/temporal/workflows/workflow_local_activities.rb +29 -0
- data/lib/activejob/temporal/workflows/workflow_nexus.rb +15 -0
- data/lib/activejob/temporal/workflows/workflow_versioning.rb +21 -0
- data/lib/activejob/temporal.rb +297 -0
- data/lib/activejob-temporal.rb +3 -0
- 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
|