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,981 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "active_support/core_ext/numeric/time"
5
+ require "active_model"
6
+
7
+ require_relative "middleware"
8
+ require_relative "observability"
9
+ require_relative "payload_encryption"
10
+ require_relative "payload_serializers"
11
+ require_relative "rate_limit_options"
12
+ require_relative "tls_file"
13
+
14
+ # rubocop:disable Metrics/ModuleLength
15
+ module ActiveJob
16
+ module Temporal
17
+ LOCALE_PATH = File.expand_path("locales/en.yml", __dir__)
18
+ I18n.load_path << LOCALE_PATH unless I18n.load_path.include?(LOCALE_PATH)
19
+
20
+ # Base error class for all activejob-temporal errors.
21
+ class Error < StandardError; end
22
+
23
+ # Raised when configuration is invalid.
24
+ #
25
+ # @see Configuration#validate!
26
+ class ConfigurationError < Error; end
27
+
28
+ # Raised when Temporal rejects a workflow start because the job was already enqueued.
29
+ class DuplicateEnqueueError < ActiveJob::EnqueueError; end
30
+
31
+ VALIDATION_LEVELS = %i[strict warn none].freeze
32
+ PAYLOAD_SERIALIZERS = PayloadSerializers::SUPPORTED
33
+ LOCAL_ACTIVITY_HELPERS = %i[rate_limit].freeze
34
+ UNTRAPPABLE_SIGNALS = %w[CHLD INT KILL PIPE QUIT STOP TERM].freeze
35
+ POSITIONAL_PARAMETER_TYPES = %i[req opt rest].freeze
36
+ MAX_TARGET_HOST_LENGTH = 253
37
+ MAX_NAMESPACE_LENGTH = 1000
38
+ TARGET_HOST_LABEL_PATTERN = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
39
+ TARGET_PORT_PATTERN = /\A[1-9]\d{0,4}\z/
40
+ NAMESPACE_PATTERN = /\A[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?\z/
41
+
42
+ # Central registry of all configuration attributes.
43
+ #
44
+ # This is the single source of truth for attribute names, types, defaults, and env vars.
45
+ # Provides metadata for:
46
+ # - Automatic attribute accessor generation
47
+ # - Default value initialization (with lazy evaluation via Proc)
48
+ # - Environment variable mapping (e.g., TEMPORAL_TARGET)
49
+ # - Type information for validation and type coercion
50
+ #
51
+ # @example Accessing configuration metadata
52
+ # metadata = CONFIGURATION_ATTRIBUTES[:target]
53
+ # metadata[:default] # => "127.0.0.1:7233"
54
+ # metadata[:env_var] # => "TEMPORAL_TARGET"
55
+ # metadata[:type] # => :string
56
+ # metadata[:description] # => "Temporal server host:port"
57
+ #
58
+ # @api private
59
+ CONFIGURATION_ATTRIBUTES = {
60
+ # Connection Settings
61
+ target: {
62
+ default: "127.0.0.1:7233",
63
+ env_var: "ACTIVEJOB_TEMPORAL_TARGET",
64
+ type: :string,
65
+ description: "Temporal server host:port"
66
+ },
67
+
68
+ namespace: {
69
+ default: "default",
70
+ env_var: "ACTIVEJOB_TEMPORAL_NAMESPACE",
71
+ type: :string,
72
+ description: "Temporal namespace"
73
+ },
74
+
75
+ task_queue_prefix: {
76
+ default: nil,
77
+ env_var: "ACTIVEJOB_TEMPORAL_TASK_QUEUE_PREFIX",
78
+ type: :string,
79
+ description: "Optional prefix for task queue names"
80
+ },
81
+
82
+ task_queue: {
83
+ default: "default",
84
+ env_var: "ACTIVEJOB_TEMPORAL_TASK_QUEUE",
85
+ type: :string,
86
+ description: "Default task queue name for workers"
87
+ },
88
+
89
+ tls: {
90
+ default: nil,
91
+ type: :object,
92
+ description: "Optional SDK-native TLS options or hash-compatible TLS settings"
93
+ },
94
+
95
+ tls_cert_path: {
96
+ default: nil,
97
+ env_var: "ACTIVEJOB_TEMPORAL_TLS_CERT_PATH",
98
+ type: :string,
99
+ description: "Optional client certificate file path for mTLS"
100
+ },
101
+
102
+ tls_key_path: {
103
+ default: nil,
104
+ env_var: "ACTIVEJOB_TEMPORAL_TLS_KEY_PATH",
105
+ type: :string,
106
+ description: "Optional client private key file path for mTLS"
107
+ },
108
+
109
+ tls_server_root_ca_cert_path: {
110
+ default: nil,
111
+ env_var: "ACTIVEJOB_TEMPORAL_TLS_SERVER_ROOT_CA_CERT_PATH",
112
+ type: :string,
113
+ description: "Optional server root CA certificate file path for TLS verification"
114
+ },
115
+
116
+ tls_domain: {
117
+ default: nil,
118
+ env_var: "ACTIVEJOB_TEMPORAL_TLS_DOMAIN",
119
+ type: :string,
120
+ description: "Optional TLS SNI domain override"
121
+ },
122
+
123
+ tls_cert_watch: {
124
+ default: false,
125
+ env_var: "ACTIVEJOB_TEMPORAL_TLS_CERT_WATCH",
126
+ type: :boolean,
127
+ description: "Watch TLS certificate files and reload worker clients when they change"
128
+ },
129
+
130
+ tls_reload_signal: {
131
+ default: "HUP",
132
+ env_var: "ACTIVEJOB_TEMPORAL_TLS_RELOAD_SIGNAL",
133
+ type: :string,
134
+ description: "Signal used by workers to reload TLS certificates manually"
135
+ },
136
+
137
+ priority_task_queues: {
138
+ default: -> { {} },
139
+ type: :hash,
140
+ description: "Optional mapping from numeric ActiveJob priority values to Temporal task queues"
141
+ },
142
+
143
+ workflow_id_generator: {
144
+ default: nil,
145
+ type: :callable,
146
+ description: "Optional callable for custom Temporal workflow IDs"
147
+ },
148
+
149
+ rate_limiter: {
150
+ default: nil,
151
+ type: :object,
152
+ description: "Optional limiter backend responding to #wait_time_for or #call"
153
+ },
154
+
155
+ global_rate_limit: {
156
+ default: nil,
157
+ type: :hash,
158
+ description: "Optional global rate limit hash, for example { limit: 1000, per: :minute }"
159
+ },
160
+
161
+ # Timeouts (use Proc for lazy evaluation of ActiveSupport::Duration)
162
+ default_activity_timeout: {
163
+ default: -> { 15.minutes },
164
+ type: :duration,
165
+ description: "Default start_to_close_timeout for activity execution"
166
+ },
167
+
168
+ default_heartbeat_timeout: {
169
+ default: nil,
170
+ type: :duration,
171
+ description: "Default heartbeat_timeout for activity execution (optional)"
172
+ },
173
+
174
+ default_schedule_to_start_timeout: {
175
+ default: nil,
176
+ type: :duration,
177
+ description: "Default schedule_to_start_timeout for activity execution (optional)"
178
+ },
179
+
180
+ default_schedule_to_close_timeout: {
181
+ default: nil,
182
+ type: :duration,
183
+ description: "Default schedule_to_close_timeout for activity execution (optional)"
184
+ },
185
+
186
+ default_retry_initial_interval: {
187
+ default: -> { 30.seconds },
188
+ type: :duration,
189
+ description: "Initial retry interval"
190
+ },
191
+
192
+ # Retry Settings
193
+ default_retry_backoff: {
194
+ default: 2.0,
195
+ type: :float,
196
+ description: "Backoff coefficient for exponential retry"
197
+ },
198
+
199
+ default_retry_max_attempts: {
200
+ default: 1,
201
+ type: :integer,
202
+ description: "Maximum retry attempts for activities"
203
+ },
204
+
205
+ dead_letter_queue: {
206
+ default: nil,
207
+ env_var: "ACTIVEJOB_TEMPORAL_DEAD_LETTER_QUEUE",
208
+ type: :string,
209
+ description: "Optional Temporal task queue for failed job dead letter workflows"
210
+ },
211
+
212
+ dead_letter_after_attempts: {
213
+ default: nil,
214
+ env_var: "ACTIVEJOB_TEMPORAL_DEAD_LETTER_AFTER_ATTEMPTS",
215
+ type: :integer,
216
+ description: "Optional retry attempt limit before routing jobs to the dead letter queue"
217
+ },
218
+
219
+ dead_letter_auto_discard_after: {
220
+ default: nil,
221
+ env_var: "ACTIVEJOB_TEMPORAL_DEAD_LETTER_AUTO_DISCARD_AFTER_SECONDS",
222
+ type: :duration,
223
+ description: "Optional time to keep dead letter workflows queryable before auto-discarding them"
224
+ },
225
+
226
+ # Observability
227
+ logger: {
228
+ default: lambda {
229
+ (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) ||
230
+ ::Logger.new($stdout)
231
+ },
232
+ type: :object,
233
+ description: "Logger instance for gem output"
234
+ },
235
+
236
+ audit_log: {
237
+ default: false,
238
+ env_var: "ACTIVEJOB_TEMPORAL_AUDIT_LOG",
239
+ type: :boolean,
240
+ description: "Enable structured audit events for job lifecycle changes"
241
+ },
242
+
243
+ audit_logger: {
244
+ default: nil,
245
+ type: :object,
246
+ description: "Optional logger instance for audit events; falls back to logger"
247
+ },
248
+
249
+ validation_level: {
250
+ default: :strict,
251
+ type: :symbol,
252
+ description: "Configuration validation behavior: :strict, :warn, or :none"
253
+ },
254
+
255
+ observability: {
256
+ default: -> { Observability::Configuration.new },
257
+ type: :object,
258
+ description: "Optional observability adapters and trace propagation settings"
259
+ },
260
+
261
+ middleware_chain: {
262
+ default: -> { Middleware::Chain.new },
263
+ type: :object,
264
+ description: "Ordered middleware chain for activity job execution"
265
+ },
266
+
267
+ identity: {
268
+ default: nil,
269
+ type: :string,
270
+ description: "Optional worker identity for observability"
271
+ },
272
+
273
+ # Payload & Performance
274
+ max_payload_size_kb: {
275
+ default: 250,
276
+ env_var: "ACTIVEJOB_TEMPORAL_MAX_PAYLOAD_SIZE_KB",
277
+ type: :integer,
278
+ description: "Maximum job payload size in kilobytes"
279
+ },
280
+
281
+ payload_serializer: {
282
+ default: :json,
283
+ env_var: "ACTIVEJOB_TEMPORAL_PAYLOAD_SERIALIZER",
284
+ type: :symbol,
285
+ description: "Payload serializer for job execution data: :json, :message_pack, :msgpack, or :marshal"
286
+ },
287
+
288
+ payload_storage_adapter: {
289
+ default: nil,
290
+ type: :object,
291
+ description: "Optional external payload storage adapter responding to #dump and #load"
292
+ },
293
+
294
+ payload_storage_threshold_kb: {
295
+ default: nil,
296
+ type: :integer,
297
+ description: "Optional payload size threshold in kilobytes for external payload storage"
298
+ },
299
+
300
+ encrypt_payload: {
301
+ default: false,
302
+ env_var: "ACTIVEJOB_TEMPORAL_ENCRYPT_PAYLOAD",
303
+ type: :boolean,
304
+ description: "Encrypt serialized job execution payloads before sending them to Temporal"
305
+ },
306
+
307
+ encryption_key: {
308
+ default: nil,
309
+ env_var: "ACTIVEJOB_TEMPORAL_ENCRYPTION_KEY",
310
+ type: :object,
311
+ description: "Base64-encoded 32-byte AES-256-GCM payload encryption key or key metadata"
312
+ },
313
+
314
+ encryption_old_keys: {
315
+ default: -> { [] },
316
+ type: :array,
317
+ description: "Previous payload encryption keys or key metadata accepted for decryption"
318
+ },
319
+
320
+ enable_search_attributes: {
321
+ default: true,
322
+ type: :boolean,
323
+ description: "Enable Temporal search attributes for job metadata"
324
+ },
325
+
326
+ continue_as_new_history_event_threshold: {
327
+ default: nil,
328
+ env_var: "ACTIVEJOB_TEMPORAL_CONTINUE_AS_NEW_HISTORY_EVENT_THRESHOLD",
329
+ type: :integer,
330
+ description: "Optional workflow history event threshold for continuing ActiveJob workflows as new"
331
+ },
332
+
333
+ local_activity_helpers: {
334
+ default: -> { [] },
335
+ type: :array,
336
+ description: "Internal helper activity names that should run as Temporal local activities"
337
+ },
338
+
339
+ max_concurrent_activities: {
340
+ default: 100,
341
+ env_var: "ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_ACTIVITIES",
342
+ type: :integer,
343
+ description: "Maximum concurrent activities per worker"
344
+ },
345
+
346
+ max_concurrent_workflow_tasks: {
347
+ default: 5,
348
+ env_var: "ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASKS",
349
+ type: :integer,
350
+ description: "Maximum concurrent workflow tasks per worker"
351
+ }
352
+ }.freeze
353
+
354
+ # Configuration object for the activejob-temporal gem.
355
+ #
356
+ # Holds connection settings, timeouts, retry policies, and operational flags.
357
+ # Use {ActiveJob::Temporal.configure} to set values with automatic validation.
358
+ #
359
+ # @note Thread Safety
360
+ # The global configuration object is synchronized by {Configurable} while
361
+ # configure blocks mutate it. Complete configuration during application boot.
362
+ #
363
+ # @note Environment Variable Defaults
364
+ # ACTIVEJOB_TEMPORAL_TARGET, ACTIVEJOB_TEMPORAL_NAMESPACE,
365
+ # ACTIVEJOB_TEMPORAL_TASK_QUEUE_PREFIX, ACTIVEJOB_TEMPORAL_TASK_QUEUE,
366
+ # ACTIVEJOB_TEMPORAL_MAX_PAYLOAD_SIZE_KB,
367
+ # ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_ACTIVITIES,
368
+ # ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASKS,
369
+ # can provide defaults.
370
+ #
371
+ # @see ConfigValidator
372
+ # @see ActiveJob::Temporal.configure
373
+ # @api private
374
+ class Configuration
375
+ attr_accessor :in_configure_block
376
+
377
+ CONFIGURATION_ATTRIBUTES.each_key do |attribute|
378
+ define_method(attribute) do
379
+ @attributes[attribute]
380
+ end
381
+
382
+ define_method("#{attribute}=") do |value|
383
+ @attributes[attribute] = value
384
+
385
+ value
386
+ end
387
+ end
388
+
389
+ def initialize
390
+ @attributes = {}
391
+ @in_configure_block = false
392
+ CONFIGURATION_ATTRIBUTES.each do |attribute, metadata|
393
+ @attributes[attribute] = resolve_default_value(metadata)
394
+ end
395
+ end
396
+
397
+ def [](key)
398
+ @attributes[key]
399
+ end
400
+
401
+ def []=(key, value)
402
+ @attributes[key] = value
403
+ end
404
+
405
+ def add_middleware(middleware, ...)
406
+ middleware_chain.add(middleware, ...)
407
+ end
408
+
409
+ def validate!
410
+ return if validation_level == :none
411
+
412
+ validator = build_validator
413
+ return if validator.valid?
414
+
415
+ handle_validation_errors(validator.errors)
416
+ end
417
+
418
+ def self.format_validation_errors(errors)
419
+ messages = errors.full_messages
420
+ return messages.first if messages.size == 1
421
+
422
+ error_list = messages.each_with_index.map do |message, index|
423
+ " #{index + 1}. #{message}"
424
+ end.join("\n")
425
+
426
+ plural = "s" if messages.size > 1
427
+ "Configuration validation failed with #{messages.size} error#{plural}:\n#{error_list}"
428
+ end
429
+
430
+ private
431
+
432
+ def build_validator
433
+ validator = ConfigValidator.new
434
+
435
+ CONFIGURATION_ATTRIBUTES.each_key do |attribute|
436
+ validator.public_send("#{attribute}=", @attributes[attribute])
437
+ end
438
+
439
+ validator
440
+ end
441
+
442
+ def handle_validation_errors(errors)
443
+ message = self.class.format_validation_errors(errors)
444
+
445
+ raise ConfigurationError, message unless validation_level == :warn
446
+
447
+ logger.warn(message)
448
+ end
449
+
450
+ def resolve_default_value(metadata)
451
+ if metadata[:env_var] && ENV[metadata[:env_var]]
452
+ value = ENV[metadata[:env_var]]
453
+ return convert_env_value(value, metadata[:type])
454
+ end
455
+
456
+ default = metadata[:default]
457
+ default.is_a?(Proc) ? default.call : default
458
+ end
459
+
460
+ def convert_env_value(value, type)
461
+ case type
462
+ when :integer then value.to_i
463
+ when :float, :duration then value.to_f
464
+ when :boolean then value == "true"
465
+ when :symbol then value.to_sym
466
+ else value
467
+ end
468
+ end
469
+ end
470
+
471
+ # Validates ActiveJob::Temporal configuration.
472
+ #
473
+ # This class uses ActiveModel::Validations to provide declarative validation
474
+ # with i18n support. Attributes are automatically synchronized from the
475
+ # configuration via metaprogramming.
476
+ #
477
+ # **Validation Rules:**
478
+ # - `target`: Presence + host:port format validation
479
+ # - `namespace`: Presence + alphanumeric/hyphens/underscores format
480
+ # - `default_activity_timeout`: Duration type + positive value
481
+ # - `default_retry_initial_interval`: Duration type + positive value
482
+ # - `default_retry_backoff`: Numericality >= 1.0
483
+ # - `default_retry_max_attempts`: Numericality >= 0
484
+ # - `max_payload_size_kb`: Numericality 1..2GB
485
+ # - `max_concurrent_activities`: Numericality > 0
486
+ # - `max_concurrent_workflow_tasks`: Numericality > 0
487
+ # - `workflow_id_generator`: Optional callable
488
+ # - `middleware_chain`: Callable chain with registration support
489
+ #
490
+ # @example Using ConfigValidator directly
491
+ # validator = ConfigValidator.new
492
+ # validator.target = "localhost:7233"
493
+ # validator.namespace = "production"
494
+ # if validator.valid?
495
+ # puts "Configuration is valid"
496
+ # else
497
+ # puts validator.errors.full_messages
498
+ # end
499
+ #
500
+ # @see ActiveJob::Temporal::Configuration.validate!
501
+ # @api private
502
+ # rubocop:disable Metrics/ClassLength
503
+ class ConfigValidator
504
+ include ActiveModel::Validations
505
+
506
+ # Generate attr_accessor for all configuration attributes
507
+ attr_accessor(*CONFIGURATION_ATTRIBUTES.keys)
508
+
509
+ #
510
+ # STANDARD VALIDATIONS
511
+ # Use ActiveModel's built-in validators where possible
512
+ #
513
+
514
+ # Connection Settings
515
+ validates :target,
516
+ presence: { message: :target_required }
517
+
518
+ validates :namespace,
519
+ presence: { message: :namespace_required }
520
+
521
+ # Retry Settings
522
+ validates :default_retry_backoff,
523
+ numericality: {
524
+ greater_than_or_equal_to: 1.0,
525
+ message: :retry_backoff_too_small,
526
+ allow_nil: false
527
+ }
528
+
529
+ validates :default_retry_max_attempts,
530
+ numericality: {
531
+ greater_than_or_equal_to: 0,
532
+ only_integer: true,
533
+ message: :retry_max_attempts_negative,
534
+ allow_nil: false
535
+ }
536
+
537
+ # Payload & Performance
538
+ validates :max_payload_size_kb,
539
+ numericality: {
540
+ greater_than: 0,
541
+ less_than_or_equal_to: 2_097_152, # 2 GB in KB
542
+ only_integer: true,
543
+ message: :payload_size_invalid,
544
+ allow_nil: false
545
+ }
546
+
547
+ validates :max_concurrent_activities,
548
+ numericality: {
549
+ greater_than: 0,
550
+ only_integer: true,
551
+ message: :concurrent_activities_invalid,
552
+ allow_nil: false
553
+ }
554
+
555
+ validates :max_concurrent_workflow_tasks,
556
+ numericality: {
557
+ greater_than: 0,
558
+ only_integer: true,
559
+ message: :concurrent_workflow_tasks_invalid,
560
+ allow_nil: false
561
+ }
562
+
563
+ validates :continue_as_new_history_event_threshold,
564
+ numericality: {
565
+ greater_than: 0,
566
+ only_integer: true,
567
+ allow_nil: true
568
+ }
569
+
570
+ validates :validation_level,
571
+ inclusion: {
572
+ in: VALIDATION_LEVELS,
573
+ message: :invalid_level
574
+ }
575
+
576
+ validates :payload_serializer,
577
+ inclusion: {
578
+ in: PAYLOAD_SERIALIZERS,
579
+ message: :unsupported_serializer
580
+ }
581
+
582
+ #
583
+ # CUSTOM VALIDATORS
584
+ # For complex logic that doesn't fit standard validators
585
+ #
586
+
587
+ validate :validate_target_format
588
+ validate :validate_namespace_format
589
+ validate :validate_duration_values
590
+ validate :validate_workflow_id_generator
591
+ validate :validate_middleware_chain
592
+ validate :validate_priority_task_queues
593
+ validate :validate_rate_limit_settings
594
+ validate :validate_dead_letter_settings
595
+ validate :validate_observability_settings
596
+ validate :validate_audit_settings
597
+ validate :validate_encryption_settings
598
+ validate :validate_payload_storage_settings
599
+ validate :validate_tls_settings
600
+ validate :validate_local_activity_helpers
601
+
602
+ private
603
+
604
+ def validate_target_format
605
+ return if target.blank? # presence validation handles nil/empty
606
+
607
+ host, port = split_target
608
+ return if host && port && valid_target_host?(host) && valid_target_port?(port)
609
+
610
+ errors.add(:target, :invalid_format, target: target)
611
+ end
612
+
613
+ def validate_namespace_format
614
+ return if namespace.blank?
615
+ return if namespace.length <= MAX_NAMESPACE_LENGTH && namespace.match?(NAMESPACE_PATTERN)
616
+
617
+ errors.add(:namespace, :invalid_format, namespace: namespace)
618
+ end
619
+
620
+ def split_target
621
+ return unless target.count(":") == 1
622
+
623
+ target.split(":", 2)
624
+ end
625
+
626
+ def valid_target_host?(host)
627
+ return false if host.length > MAX_TARGET_HOST_LENGTH
628
+
629
+ labels = host.split(".", -1)
630
+ labels.any? && labels.all? { |label| label.match?(TARGET_HOST_LABEL_PATTERN) }
631
+ end
632
+
633
+ def valid_target_port?(port)
634
+ port.match?(TARGET_PORT_PATTERN) && port.to_i.between?(1, 65_535)
635
+ end
636
+
637
+ # Validates duration attributes are positive durations
638
+ def validate_duration_values
639
+ # Required duration attributes
640
+ validate_duration_attribute(:default_activity_timeout, required: true)
641
+ validate_duration_attribute(:default_retry_initial_interval, required: true)
642
+
643
+ # Optional duration attributes
644
+ validate_duration_attribute(:default_heartbeat_timeout, required: false)
645
+ validate_duration_attribute(:default_schedule_to_start_timeout, required: false)
646
+ validate_duration_attribute(:default_schedule_to_close_timeout, required: false)
647
+ validate_duration_attribute(:dead_letter_auto_discard_after, required: false)
648
+ end
649
+
650
+ def validate_workflow_id_generator
651
+ return if workflow_id_generator.nil?
652
+
653
+ unless workflow_id_generator.respond_to?(:call)
654
+ errors.add(:workflow_id_generator, :not_callable, value: workflow_id_generator.inspect)
655
+ return
656
+ end
657
+
658
+ return if callable_accepts_positional_job?(workflow_id_generator)
659
+
660
+ errors.add(:workflow_id_generator, :wrong_arity, value: workflow_id_generator.inspect)
661
+ end
662
+
663
+ def validate_middleware_chain
664
+ return if middleware_chain.respond_to?(:add) && middleware_chain.respond_to?(:call)
665
+
666
+ errors.add(:middleware_chain, :invalid, value: middleware_chain.inspect)
667
+ end
668
+
669
+ def validate_priority_task_queues
670
+ unless priority_task_queues.is_a?(Hash)
671
+ errors.add(:priority_task_queues, :not_a_hash, value: priority_task_queues.inspect)
672
+ return
673
+ end
674
+
675
+ if non_integer_priority_key
676
+ errors.add(:priority_task_queues, :non_integer_priority, value: non_integer_priority_key.inspect)
677
+ return
678
+ end
679
+
680
+ return unless blank_priority_task_queue
681
+
682
+ errors.add(:priority_task_queues, :blank_queue, value: blank_priority_task_queue.inspect)
683
+ end
684
+
685
+ def non_integer_priority_key
686
+ priority_task_queues.keys.find { |priority| !priority.is_a?(Integer) }
687
+ end
688
+
689
+ def blank_priority_task_queue
690
+ priority_task_queues.values.find { |task_queue| task_queue.to_s.strip.empty? }
691
+ end
692
+
693
+ def validate_rate_limit_settings
694
+ validate_rate_limiter
695
+ validate_global_rate_limit
696
+ end
697
+
698
+ def validate_local_activity_helpers
699
+ unless local_activity_helpers.is_a?(Array)
700
+ errors.add(:local_activity_helpers, :invalid, value: local_activity_helpers.inspect)
701
+ return
702
+ end
703
+
704
+ unsupported_helper = local_activity_helpers.find do |helper|
705
+ !helper.respond_to?(:to_sym) || !LOCAL_ACTIVITY_HELPERS.include?(helper.to_sym)
706
+ end
707
+ errors.add(:local_activity_helpers, :invalid, value: unsupported_helper.inspect) if unsupported_helper
708
+ end
709
+
710
+ def validate_rate_limiter
711
+ return if rate_limiter.nil?
712
+
713
+ unless rate_limiter.respond_to?(:wait_time_for) || rate_limiter.respond_to?(:call)
714
+ errors.add(:rate_limiter, :invalid, value: rate_limiter.inspect)
715
+ return
716
+ end
717
+
718
+ return if rate_limiter_accepts_rate_limits_argument?
719
+
720
+ errors.add(:rate_limiter, :wrong_arity, value: rate_limiter.inspect)
721
+ end
722
+
723
+ def validate_global_rate_limit
724
+ return if global_rate_limit.nil?
725
+
726
+ if rate_limiter.nil?
727
+ errors.add(:global_rate_limit, :requires_rate_limiter)
728
+ return
729
+ end
730
+
731
+ RateLimitOptions.normalize_hash(global_rate_limit)
732
+ rescue ArgumentError
733
+ errors.add(:global_rate_limit, :invalid, value: global_rate_limit.inspect)
734
+ end
735
+
736
+ def validate_dead_letter_settings
737
+ validate_dead_letter_queue
738
+ validate_dead_letter_after_attempts
739
+ validate_dead_letter_auto_discard_after
740
+ end
741
+
742
+ def validate_dead_letter_queue
743
+ return if dead_letter_queue.nil? || dead_letter_queue.to_s.strip.present?
744
+
745
+ errors.add(:dead_letter_queue, :blank)
746
+ end
747
+
748
+ def validate_dead_letter_after_attempts
749
+ return if dead_letter_after_attempts.nil?
750
+
751
+ unless dead_letter_queue.to_s.strip.present?
752
+ errors.add(:dead_letter_after_attempts, :requires_queue)
753
+ return
754
+ end
755
+
756
+ return if dead_letter_after_attempts.is_a?(Integer) && dead_letter_after_attempts.positive?
757
+
758
+ errors.add(:dead_letter_after_attempts, :invalid, value: dead_letter_after_attempts.inspect)
759
+ end
760
+
761
+ def validate_dead_letter_auto_discard_after
762
+ return if dead_letter_auto_discard_after.nil?
763
+ return if dead_letter_queue.to_s.strip.present?
764
+
765
+ errors.add(:dead_letter_auto_discard_after, :requires_queue)
766
+ end
767
+
768
+ def validate_observability_settings
769
+ return if observability.respond_to?(:validate!)
770
+
771
+ errors.add(:observability, :invalid, value: observability.inspect)
772
+ end
773
+
774
+ def validate_audit_settings
775
+ validate_audit_log
776
+ validate_audit_logger
777
+ end
778
+
779
+ def validate_audit_log
780
+ return if [true, false].include?(audit_log)
781
+
782
+ errors.add(:audit_log, :not_boolean, value: audit_log.inspect)
783
+ end
784
+
785
+ def validate_audit_logger
786
+ return if audit_logger.nil? || audit_logger.respond_to?(:info)
787
+
788
+ errors.add(:audit_logger, :invalid, value: audit_logger.inspect)
789
+ end
790
+
791
+ def validate_encryption_settings
792
+ validate_encrypt_payload
793
+ validate_encryption_key
794
+ validate_encryption_old_keys
795
+ end
796
+
797
+ def validate_encrypt_payload
798
+ return if [true, false].include?(encrypt_payload)
799
+
800
+ errors.add(:encrypt_payload, :not_boolean, value: encrypt_payload.inspect)
801
+ end
802
+
803
+ def validate_encryption_key
804
+ if encrypt_payload && encryption_key.to_s.empty?
805
+ errors.add(:encryption_key, :required)
806
+ return
807
+ end
808
+
809
+ return if encryption_key.nil? || PayloadEncryption.valid_key?(encryption_key)
810
+
811
+ errors.add(:encryption_key, :invalid, bytes: PayloadEncryption.key_length, value: "[FILTERED]")
812
+ end
813
+
814
+ def validate_encryption_old_keys
815
+ unless encryption_old_keys.is_a?(Array)
816
+ errors.add(:encryption_old_keys, :not_an_array, value: encryption_old_keys.inspect)
817
+ return
818
+ end
819
+
820
+ return if encryption_old_keys.all? { |key| PayloadEncryption.valid_key?(key) }
821
+
822
+ errors.add(:encryption_old_keys, :invalid, bytes: PayloadEncryption.key_length, value: "[FILTERED]")
823
+ end
824
+
825
+ def validate_payload_storage_settings
826
+ validate_payload_storage_adapter
827
+ validate_payload_storage_threshold
828
+ end
829
+
830
+ def validate_payload_storage_adapter
831
+ return if payload_storage_adapter.nil?
832
+ return if payload_storage_adapter.respond_to?(:dump) && payload_storage_adapter.respond_to?(:load)
833
+
834
+ errors.add(:payload_storage_adapter, :invalid, value: payload_storage_adapter.inspect)
835
+ end
836
+
837
+ def validate_payload_storage_threshold
838
+ if payload_storage_adapter && payload_storage_threshold_kb.nil?
839
+ errors.add(:payload_storage_threshold_kb, :required)
840
+ return
841
+ end
842
+
843
+ if payload_storage_threshold_kb && payload_storage_adapter.nil?
844
+ errors.add(:payload_storage_threshold_kb, :requires_adapter)
845
+ return
846
+ end
847
+
848
+ return if payload_storage_threshold_kb.nil?
849
+ return if payload_storage_threshold_kb.is_a?(Integer) && payload_storage_threshold_kb.positive?
850
+
851
+ errors.add(:payload_storage_threshold_kb, :invalid, value: payload_storage_threshold_kb.inspect)
852
+ end
853
+
854
+ def validate_tls_settings
855
+ validate_tls_cert_key_pair
856
+ validate_tls_file_path(:tls_cert_path)
857
+ validate_tls_file_path(:tls_key_path)
858
+ validate_tls_file_path(:tls_server_root_ca_cert_path)
859
+ validate_tls_domain
860
+ validate_tls_cert_watch
861
+ validate_tls_reload_signal
862
+ end
863
+
864
+ def validate_tls_cert_key_pair
865
+ return if tls_cert_path.to_s.empty? == tls_key_path.to_s.empty?
866
+
867
+ errors.add(:tls_cert_path, :requires_key_path)
868
+ end
869
+
870
+ def validate_tls_file_path(attribute)
871
+ path = public_send(attribute)
872
+ return if path.nil?
873
+
874
+ unless path.is_a?(String) && path.strip.present?
875
+ errors.add(attribute, :invalid_path, value: path.inspect)
876
+ return
877
+ end
878
+
879
+ return if TLSFile.readable_regular_file?(path)
880
+
881
+ errors.add(attribute, :unreadable_path, value: path)
882
+ end
883
+
884
+ def validate_tls_domain
885
+ return if tls_domain.nil? || tls_domain.to_s.strip.present?
886
+
887
+ errors.add(:tls_domain, :blank)
888
+ end
889
+
890
+ def validate_tls_cert_watch
891
+ unless [true, false].include?(tls_cert_watch)
892
+ errors.add(:tls_cert_watch, :not_boolean, value: tls_cert_watch.inspect)
893
+ return
894
+ end
895
+
896
+ return unless tls_cert_watch && tls_watch_paths.empty?
897
+
898
+ errors.add(:tls_cert_watch, :requires_paths)
899
+ end
900
+
901
+ def validate_tls_reload_signal
902
+ unless tls_reload_signal.is_a?(String) && tls_reload_signal.strip.present?
903
+ errors.add(:tls_reload_signal, :blank)
904
+ return
905
+ end
906
+
907
+ normalized_signal = tls_reload_signal.sub(/\ASIG/i, "").upcase
908
+ return if Signal.list.key?(normalized_signal) && !UNTRAPPABLE_SIGNALS.include?(normalized_signal)
909
+
910
+ errors.add(:tls_reload_signal, :invalid, value: tls_reload_signal.inspect)
911
+ end
912
+
913
+ def tls_watch_paths
914
+ [tls_cert_path, tls_key_path, tls_server_root_ca_cert_path].compact.reject { |path| path.to_s.strip.empty? }
915
+ end
916
+
917
+ def callable_accepts_positional_job?(callable)
918
+ callable_accepts_one_positional_argument?(callable)
919
+ end
920
+
921
+ def rate_limiter_accepts_rate_limits_argument?
922
+ callable = if rate_limiter.respond_to?(:wait_time_for)
923
+ rate_limiter.method(:wait_time_for)
924
+ else
925
+ rate_limiter
926
+ end
927
+
928
+ callable_accepts_one_positional_argument?(callable)
929
+ end
930
+
931
+ def callable_accepts_one_positional_argument?(callable)
932
+ parameters = callable_parameters(callable)
933
+ return accepts_one_positional_argument_from_parameters?(parameters) if parameters
934
+
935
+ arity = callable.respond_to?(:arity) ? callable.arity : callable.method(:call).arity
936
+
937
+ arity == 1 || arity.negative?
938
+ end
939
+
940
+ def accepts_one_positional_argument_from_parameters?(parameters)
941
+ required_positional_count = parameters.count { |type, _name| type == :req }
942
+
943
+ required_positional_count <= 1 &&
944
+ parameters.any? { |type, _name| POSITIONAL_PARAMETER_TYPES.include?(type) }
945
+ end
946
+
947
+ def callable_parameters(callable)
948
+ if callable.respond_to?(:parameters)
949
+ callable.parameters
950
+ else
951
+ callable.method(:call).parameters
952
+ end
953
+ rescue NameError
954
+ nil
955
+ end
956
+
957
+ def validate_duration_attribute(attr_name, required: true)
958
+ value = public_send(attr_name)
959
+
960
+ # Allow nil for optional attributes
961
+ return if value.nil? && !required
962
+
963
+ # Check if it's a duration-like object (Numeric or ActiveSupport::Duration)
964
+ unless value.is_a?(Numeric) || value.is_a?(ActiveSupport::Duration)
965
+ errors.add(attr_name, :not_a_duration, value: value.inspect)
966
+ return
967
+ end
968
+
969
+ # Check if positive
970
+ seconds = value.to_f
971
+ return if seconds.positive?
972
+
973
+ errors.add(attr_name, :duration_not_positive,
974
+ seconds: seconds,
975
+ attribute: attr_name.to_s.humanize.downcase)
976
+ end
977
+ end
978
+ # rubocop:enable Metrics/ClassLength
979
+ end
980
+ end
981
+ # rubocop:enable Metrics/ModuleLength