activejob-temporal 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +1 -0
  3. data/CHANGELOG.md +29 -0
  4. data/CLAUDE.md +274 -0
  5. data/CONTRIBUTING.md +69 -0
  6. data/README.md +34 -33
  7. data/activejob-temporal.gemspec +9 -13
  8. data/api/job_payload_schema.json +51 -6
  9. data/bin/temporal-worker +25 -17
  10. data/gemfiles/activejob_contract.gemfile +8 -0
  11. data/gemfiles/temporalio_contract.gemfile +7 -0
  12. data/lib/activejob/temporal/activities/aj_runner_activity.rb +75 -19
  13. data/lib/activejob/temporal/activities/dependency_status_activity.rb +36 -19
  14. data/lib/activejob/temporal/adapter.rb +4 -3
  15. data/lib/activejob/temporal/batch_enqueuer.rb +42 -33
  16. data/lib/activejob/temporal/bind_policy.rb +1 -1
  17. data/lib/activejob/temporal/cancel.rb +66 -29
  18. data/lib/activejob/temporal/certificate_watcher.rb +41 -6
  19. data/lib/activejob/temporal/client.rb +24 -6
  20. data/lib/activejob/temporal/conditional_enqueue.rb +2 -1
  21. data/lib/activejob/temporal/configurable.rb +56 -13
  22. data/lib/activejob/temporal/configuration.rb +218 -7
  23. data/lib/activejob/temporal/configured_job_compatibility.rb +91 -9
  24. data/lib/activejob/temporal/connection_worker_pool.rb +18 -1
  25. data/lib/activejob/temporal/dead_letter_queue.rb +64 -18
  26. data/lib/activejob/temporal/dependency_options.rb +126 -16
  27. data/lib/activejob/temporal/health_check_server.rb +14 -17
  28. data/lib/activejob/temporal/http_line_reader.rb +20 -2
  29. data/lib/activejob/temporal/http_request_failure_handling.rb +41 -0
  30. data/lib/activejob/temporal/inspect.rb +17 -7
  31. data/lib/activejob/temporal/job_id_validation.rb +41 -0
  32. data/lib/activejob/temporal/job_payload_dependencies.rb +23 -0
  33. data/lib/activejob/temporal/locales/en.yml +30 -3
  34. data/lib/activejob/temporal/metrics_server.rb +15 -18
  35. data/lib/activejob/temporal/middleware/chain.rb +7 -0
  36. data/lib/activejob/temporal/observability.rb +14 -2
  37. data/lib/activejob/temporal/payload.rb +64 -25
  38. data/lib/activejob/temporal/payload_encryption.rb +9 -1
  39. data/lib/activejob/temporal/payload_serializers.rb +3 -0
  40. data/lib/activejob/temporal/payload_storage.rb +4 -4
  41. data/lib/activejob/temporal/rails_environment_loader.rb +1 -8
  42. data/lib/activejob/temporal/reload_signal_queue.rb +19 -19
  43. data/lib/activejob/temporal/retry_handler_extractor.rb +18 -3
  44. data/lib/activejob/temporal/schedulable.rb +5 -7
  45. data/lib/activejob/temporal/schedule.rb +2 -2
  46. data/lib/activejob/temporal/signal_query.rb +35 -23
  47. data/lib/activejob/temporal/temporal_options.rb +26 -1
  48. data/lib/activejob/temporal/tls_file.rb +16 -16
  49. data/lib/activejob/temporal/transaction_safety.rb +102 -1
  50. data/lib/activejob/temporal/version.rb +1 -1
  51. data/lib/activejob/temporal/visibility_query.rb +16 -1
  52. data/lib/activejob/temporal/worker_pool.rb +13 -0
  53. data/lib/activejob/temporal/worker_registrations.rb +66 -0
  54. data/lib/activejob/temporal/worker_runtime.rb +17 -0
  55. data/lib/activejob/temporal/workflow_enqueuer.rb +2 -1
  56. data/lib/activejob/temporal/workflow_types.rb +10 -0
  57. data/lib/activejob/temporal/workflows/aj_workflow.rb +17 -4
  58. data/lib/activejob/temporal/workflows/workflow_dependencies.rb +148 -15
  59. data/lib/activejob/temporal.rb +21 -13
  60. data/test/mutant_unit_test.rb +13 -0
  61. metadata +46 -50
@@ -38,6 +38,8 @@ module ActiveJob
38
38
  TARGET_HOST_LABEL_PATTERN = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
39
39
  TARGET_PORT_PATTERN = /\A[1-9]\d{0,4}\z/
40
40
  NAMESPACE_PATTERN = /\A[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?\z/
41
+ ENV_BOOLEAN_TRUE_VALUES = %w[1 true yes on].freeze
42
+ ENV_BOOLEAN_FALSE_VALUES = %w[0 false no off].freeze
41
43
 
42
44
  # Central registry of all configuration attributes.
43
45
  #
@@ -134,6 +136,29 @@ module ActiveJob
134
136
  description: "Signal used by workers to reload TLS certificates manually"
135
137
  },
136
138
 
139
+ api_key: {
140
+ default: nil,
141
+ env_var: "ACTIVEJOB_TEMPORAL_API_KEY",
142
+ type: :string,
143
+ description: "Optional API key sent as the Authorization bearer token"
144
+ },
145
+
146
+ api_key_file: {
147
+ default: nil,
148
+ env_var: "ACTIVEJOB_TEMPORAL_API_KEY_FILE",
149
+ type: :string,
150
+ description: "Optional file read for the API key when the client is built, for example a " \
151
+ "projected Kubernetes ServiceAccount token. An explicit api_key takes precedence"
152
+ },
153
+
154
+ api_key_watch: {
155
+ default: false,
156
+ env_var: "ACTIVEJOB_TEMPORAL_API_KEY_WATCH",
157
+ type: :boolean,
158
+ description: "Watch api_key_file and reload worker clients when it changes (kubelet rewrites " \
159
+ "projected tokens in place long before they expire)"
160
+ },
161
+
137
162
  priority_task_queues: {
138
163
  default: -> { {} },
139
164
  type: :hash,
@@ -311,6 +336,13 @@ module ActiveJob
311
336
  description: "Base64-encoded 32-byte AES-256-GCM payload encryption key or key metadata"
312
337
  },
313
338
 
339
+ allow_legacy_encrypted_payloads: {
340
+ default: false,
341
+ env_var: "ACTIVEJOB_TEMPORAL_ALLOW_LEGACY_ENCRYPTED_PAYLOADS",
342
+ type: :boolean,
343
+ description: "Accept version 1 encrypted payloads, which are not bound to a namespace/workflow context"
344
+ },
345
+
314
346
  encryption_old_keys: {
315
347
  default: -> { [] },
316
348
  type: :array,
@@ -330,6 +362,34 @@ module ActiveJob
330
362
  description: "Optional workflow history event threshold for continuing ActiveJob workflows as new"
331
363
  },
332
364
 
365
+ dependency_wait_timeout: {
366
+ default: -> { 1.day },
367
+ env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_TIMEOUT_SECONDS",
368
+ type: :duration,
369
+ description: "Maximum time a workflow waits for dependencies before timing out"
370
+ },
371
+
372
+ dependency_wait_initial_interval: {
373
+ default: -> { 10.seconds },
374
+ env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_INITIAL_INTERVAL_SECONDS",
375
+ type: :duration,
376
+ description: "Initial durable sleep interval between dependency checks"
377
+ },
378
+
379
+ dependency_wait_max_interval: {
380
+ default: -> { 1.minute },
381
+ env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_MAX_INTERVAL_SECONDS",
382
+ type: :duration,
383
+ description: "Maximum durable sleep interval between dependency checks"
384
+ },
385
+
386
+ dependency_wait_backoff: {
387
+ default: 2.0,
388
+ env_var: "ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_BACKOFF",
389
+ type: :float,
390
+ description: "Backoff coefficient for dependency polling intervals"
391
+ },
392
+
333
393
  local_activity_helpers: {
334
394
  default: -> { [] },
335
395
  type: :array,
@@ -348,6 +408,29 @@ module ActiveJob
348
408
  env_var: "ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASKS",
349
409
  type: :integer,
350
410
  description: "Maximum concurrent workflow tasks per worker"
411
+ },
412
+
413
+ worker_activities: {
414
+ default: -> { [] },
415
+ type: :array,
416
+ description: "Additional activity classes (or class names) the worker registers, for example " \
417
+ "activities invoked by workflows owned by another service"
418
+ },
419
+
420
+ worker_activejob_workloads: {
421
+ default: true,
422
+ env_var: "ACTIVEJOB_TEMPORAL_WORKER_ACTIVEJOB_WORKLOADS",
423
+ type: :boolean,
424
+ description: "Register the built-in ActiveJob workflows and activities on the worker. Disable " \
425
+ "for workers that only host worker_activities"
426
+ },
427
+
428
+ graceful_shutdown_period: {
429
+ default: 0.0,
430
+ env_var: "ACTIVEJOB_TEMPORAL_GRACEFUL_SHUTDOWN_PERIOD",
431
+ type: :float,
432
+ description: "Seconds a shutting-down worker lets running activities finish before their " \
433
+ "tasks are cancelled (0 cancels immediately, matching the SDK default)"
351
434
  }
352
435
  }.freeze
353
436
 
@@ -357,13 +440,18 @@ module ActiveJob
357
440
  # Use {ActiveJob::Temporal.configure} to set values with automatic validation.
358
441
  #
359
442
  # @note Thread Safety
360
- # The global configuration object is synchronized by {Configurable} while
361
- # configure blocks mutate it. Complete configuration during application boot.
443
+ # The global configuration object is synchronized by {Configurable}.
444
+ # Configure blocks mutate a candidate copy that replaces the active
445
+ # configuration only after validation succeeds.
362
446
  #
363
447
  # @note Environment Variable Defaults
364
448
  # ACTIVEJOB_TEMPORAL_TARGET, ACTIVEJOB_TEMPORAL_NAMESPACE,
365
449
  # ACTIVEJOB_TEMPORAL_TASK_QUEUE_PREFIX, ACTIVEJOB_TEMPORAL_TASK_QUEUE,
366
450
  # ACTIVEJOB_TEMPORAL_MAX_PAYLOAD_SIZE_KB,
451
+ # ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_TIMEOUT_SECONDS,
452
+ # ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_INITIAL_INTERVAL_SECONDS,
453
+ # ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_MAX_INTERVAL_SECONDS,
454
+ # ACTIVEJOB_TEMPORAL_DEPENDENCY_WAIT_BACKOFF,
367
455
  # ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_ACTIVITIES,
368
456
  # ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASKS,
369
457
  # can provide defaults.
@@ -394,6 +482,14 @@ module ActiveJob
394
482
  end
395
483
  end
396
484
 
485
+ def initialize_copy(original)
486
+ super
487
+ @attributes = original.instance_variable_get(:@attributes).transform_values do |value|
488
+ duplicate_configuration_value(value)
489
+ end
490
+ @in_configure_block = false
491
+ end
492
+
397
493
  def [](key)
398
494
  @attributes[key]
399
495
  end
@@ -406,6 +502,26 @@ module ActiveJob
406
502
  middleware_chain.add(middleware, ...)
407
503
  end
408
504
 
505
+ # Attributes holding secrets, redacted by {#inspect}.
506
+ REDACTED_ATTRIBUTES = %i[encryption_key encryption_old_keys tls api_key].freeze
507
+ REDACTED_PLACEHOLDER = "[FILTERED]"
508
+
509
+ # Returns the configuration with secret attributes redacted.
510
+ #
511
+ # The default `Object#inspect` dumps `@attributes`, which exposes the payload
512
+ # encryption keys and the inline TLS private key in logs and exception output.
513
+ #
514
+ # @return [String] inspect output with encryption keys and TLS settings filtered
515
+ def inspect
516
+ pairs = @attributes.map do |attribute, value|
517
+ value = REDACTED_PLACEHOLDER if REDACTED_ATTRIBUTES.include?(attribute) && value.present?
518
+
519
+ "#{attribute}=#{value.inspect}"
520
+ end
521
+
522
+ "#<#{self.class.name} #{pairs.join(', ')}>"
523
+ end
524
+
409
525
  def validate!
410
526
  return if validation_level == :none
411
527
 
@@ -415,6 +531,11 @@ module ActiveJob
415
531
  handle_validation_errors(validator.errors)
416
532
  end
417
533
 
534
+ def finalize_configuration_copy!
535
+ observability.finalize_configuration_copy! if observability.respond_to?(:finalize_configuration_copy!)
536
+ self
537
+ end
538
+
418
539
  def self.format_validation_errors(errors)
419
540
  messages = errors.full_messages
420
541
  return messages.first if messages.size == 1
@@ -429,6 +550,19 @@ module ActiveJob
429
550
 
430
551
  private
431
552
 
553
+ def duplicate_configuration_value(value)
554
+ case value
555
+ when Hash
556
+ value.transform_values { |entry| duplicate_configuration_value(entry) }
557
+ when Array
558
+ value.map { |entry| duplicate_configuration_value(entry) }
559
+ when String, Middleware::Chain, Observability::Configuration
560
+ value.dup
561
+ else
562
+ value
563
+ end
564
+ end
565
+
432
566
  def build_validator
433
567
  validator = ConfigValidator.new
434
568
 
@@ -448,24 +582,37 @@ module ActiveJob
448
582
  end
449
583
 
450
584
  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])
585
+ env_var = metadata[:env_var]
586
+ if env_var && ENV[env_var]
587
+ value = ENV[env_var]
588
+ return convert_env_value(value, metadata[:type], env_var)
454
589
  end
455
590
 
456
591
  default = metadata[:default]
457
592
  default.is_a?(Proc) ? default.call : default
458
593
  end
459
594
 
460
- def convert_env_value(value, type)
595
+ def convert_env_value(value, type, env_var)
461
596
  case type
462
597
  when :integer then value.to_i
463
598
  when :float, :duration then value.to_f
464
- when :boolean then value == "true"
599
+ when :boolean then convert_env_boolean(value, env_var)
465
600
  when :symbol then value.to_sym
466
601
  else value
467
602
  end
468
603
  end
604
+
605
+ def convert_env_boolean(value, env_var)
606
+ normalized = value.to_s.strip.downcase
607
+
608
+ return true if ENV_BOOLEAN_TRUE_VALUES.include?(normalized)
609
+ return false if ENV_BOOLEAN_FALSE_VALUES.include?(normalized)
610
+
611
+ raise ConfigurationError,
612
+ "Invalid boolean value for #{env_var}: #{value.inspect}. " \
613
+ "Accepted true values: #{ENV_BOOLEAN_TRUE_VALUES.join(', ')}. " \
614
+ "Accepted false values: #{ENV_BOOLEAN_FALSE_VALUES.join(', ')}."
615
+ end
469
616
  end
470
617
 
471
618
  # Validates ActiveJob::Temporal configuration.
@@ -560,6 +707,13 @@ module ActiveJob
560
707
  allow_nil: false
561
708
  }
562
709
 
710
+ validates :graceful_shutdown_period,
711
+ numericality: {
712
+ greater_than_or_equal_to: 0,
713
+ message: :graceful_shutdown_period_negative,
714
+ allow_nil: false
715
+ }
716
+
563
717
  validates :continue_as_new_history_event_threshold,
564
718
  numericality: {
565
719
  greater_than: 0,
@@ -597,7 +751,10 @@ module ActiveJob
597
751
  validate :validate_encryption_settings
598
752
  validate :validate_payload_storage_settings
599
753
  validate :validate_tls_settings
754
+ validate :validate_api_key_settings
755
+ validate :validate_worker_registration_settings
600
756
  validate :validate_local_activity_helpers
757
+ validate :validate_dependency_wait_settings
601
758
 
602
759
  private
603
760
 
@@ -645,6 +802,30 @@ module ActiveJob
645
802
  validate_duration_attribute(:default_schedule_to_start_timeout, required: false)
646
803
  validate_duration_attribute(:default_schedule_to_close_timeout, required: false)
647
804
  validate_duration_attribute(:dead_letter_auto_discard_after, required: false)
805
+ validate_duration_attribute(:dependency_wait_timeout, required: true)
806
+ validate_duration_attribute(:dependency_wait_initial_interval, required: true)
807
+ validate_duration_attribute(:dependency_wait_max_interval, required: true)
808
+ end
809
+
810
+ def validate_dependency_wait_settings
811
+ validate_dependency_wait_backoff
812
+ validate_dependency_wait_interval_order
813
+ end
814
+
815
+ def validate_dependency_wait_backoff
816
+ value = dependency_wait_backoff
817
+ return if value.is_a?(Numeric) && value >= 1.0
818
+
819
+ errors.add(:dependency_wait_backoff, "must be greater than or equal to 1")
820
+ end
821
+
822
+ def validate_dependency_wait_interval_order
823
+ initial_interval = duration_seconds(dependency_wait_initial_interval)
824
+ max_interval = duration_seconds(dependency_wait_max_interval)
825
+ return unless initial_interval && max_interval
826
+ return if max_interval >= initial_interval
827
+
828
+ errors.add(:dependency_wait_max_interval, "must be greater than or equal to initial interval")
648
829
  end
649
830
 
650
831
  def validate_workflow_id_generator
@@ -898,6 +1079,30 @@ module ActiveJob
898
1079
  errors.add(:tls_cert_watch, :requires_paths)
899
1080
  end
900
1081
 
1082
+ def validate_api_key_settings
1083
+ validate_tls_file_path(:api_key_file)
1084
+
1085
+ unless [true, false].include?(api_key_watch)
1086
+ errors.add(:api_key_watch, :not_boolean, value: api_key_watch.inspect)
1087
+ return
1088
+ end
1089
+
1090
+ return unless api_key_watch && api_key_file.to_s.strip.empty?
1091
+
1092
+ errors.add(:api_key_watch, :requires_path)
1093
+ end
1094
+
1095
+ def validate_worker_registration_settings
1096
+ unless [true, false].include?(worker_activejob_workloads)
1097
+ errors.add(:worker_activejob_workloads, :not_boolean, value: worker_activejob_workloads.inspect)
1098
+ return
1099
+ end
1100
+
1101
+ return if worker_activejob_workloads || Array(worker_activities).any?
1102
+
1103
+ errors.add(:worker_activejob_workloads, :requires_activities)
1104
+ end
1105
+
901
1106
  def validate_tls_reload_signal
902
1107
  unless tls_reload_signal.is_a?(String) && tls_reload_signal.strip.present?
903
1108
  errors.add(:tls_reload_signal, :blank)
@@ -974,6 +1179,12 @@ module ActiveJob
974
1179
  seconds: seconds,
975
1180
  attribute: attr_name.to_s.humanize.downcase)
976
1181
  end
1182
+
1183
+ def duration_seconds(value)
1184
+ return value.to_f if value.is_a?(Numeric) || value.is_a?(ActiveSupport::Duration)
1185
+
1186
+ nil
1187
+ end
977
1188
  end
978
1189
  # rubocop:enable Metrics/ClassLength
979
1190
  end
@@ -6,39 +6,121 @@ require_relative "logger"
6
6
  module ActiveJob
7
7
  module Temporal
8
8
  module ConfiguredJobCompatibility
9
+ SUPPORTED_ACTIVE_JOB_MAJOR_MINOR = [
10
+ [7, 2],
11
+ [8, 0],
12
+ [8, 1]
13
+ ].freeze
14
+ FEATURE_REPLACEMENTS = {
15
+ "chain" => "ActiveJob::Temporal.job",
16
+ "child_workflows" => "ActiveJob::Temporal.job",
17
+ "conditional_enqueue" => "JobClass.perform_later_if or an explicit condition before " \
18
+ "JobClass.set(...).perform_later"
19
+ }.freeze
20
+ ExtractedConfiguredJob = Struct.new(:job_class, :options, keyword_init: true)
21
+
9
22
  module_function
10
23
 
11
24
  def payload(value, feature:, normalize_options:)
12
- return unless configured_job?(value)
13
-
14
- log_private_api(feature)
15
-
16
- job_class = value.instance_variable_get(:@job_class)
17
- return unless active_job_class?(job_class)
25
+ configured_job = extract(value, feature: feature)
26
+ return unless configured_job
18
27
 
19
28
  {
20
- job_class: job_class.name,
21
- options: normalize_options.call(value.instance_variable_get(:@options) || {})
29
+ job_class: configured_job.job_class.name,
30
+ options: normalize_options.call(configured_job.options)
22
31
  }
23
32
  end
24
33
 
34
+ def job_class(value, feature:)
35
+ extract(value, feature: feature)&.job_class
36
+ end
37
+
25
38
  def configured_job?(value)
26
39
  defined?(ActiveJob::ConfiguredJob) && value.is_a?(ActiveJob::ConfiguredJob)
27
40
  end
28
41
 
42
+ def extract(value, feature:)
43
+ return unless configured_job?(value)
44
+
45
+ validate_active_job_version!(feature)
46
+
47
+ job_class = configured_job_instance_variable(value, :@job_class, feature)
48
+ options = configured_job_instance_variable(value, :@options, feature)
49
+
50
+ validate_job_class!(job_class, feature)
51
+ validate_options!(options, feature)
52
+
53
+ log_private_api(feature)
54
+
55
+ ExtractedConfiguredJob.new(job_class: job_class, options: options.dup)
56
+ end
57
+ private_class_method :extract
58
+
29
59
  def active_job_class?(job_class)
30
60
  job_class.is_a?(Class) && job_class < ActiveJob::Base && job_class.name
31
61
  end
32
62
 
63
+ def validate_job_class!(job_class, feature)
64
+ return if active_job_class?(job_class)
65
+
66
+ raise ArgumentError, unsupported_internals_message(feature, "@job_class")
67
+ end
68
+ private_class_method :validate_job_class!
69
+
70
+ def validate_options!(options, feature)
71
+ return if options.is_a?(Hash)
72
+
73
+ raise ArgumentError, unsupported_internals_message(feature, "@options")
74
+ end
75
+ private_class_method :validate_options!
76
+
77
+ def configured_job_instance_variable(value, name, feature)
78
+ return value.instance_variable_get(name) if value.instance_variable_defined?(name)
79
+
80
+ raise ArgumentError, unsupported_internals_message(feature, name)
81
+ end
82
+ private_class_method :configured_job_instance_variable
83
+
84
+ def validate_active_job_version!(feature)
85
+ return if supported_active_job_version?
86
+
87
+ raise ArgumentError,
88
+ "ActiveJob::ConfiguredJob internals are not supported for #{feature} on ActiveJob " \
89
+ "#{active_job_version}; use #{replacement_for(feature)} instead"
90
+ end
91
+ private_class_method :validate_active_job_version!
92
+
93
+ def supported_active_job_version?
94
+ SUPPORTED_ACTIVE_JOB_MAJOR_MINOR.include?(active_job_version.segments.first(2))
95
+ end
96
+ private_class_method :supported_active_job_version?
97
+
98
+ def active_job_version
99
+ return ActiveJob.gem_version if ActiveJob.respond_to?(:gem_version)
100
+
101
+ Gem::Version.new(ActiveJob::VERSION::STRING)
102
+ end
103
+
104
+ def unsupported_internals_message(feature, name)
105
+ "ActiveJob::ConfiguredJob internals changed for #{feature}: expected #{name}; " \
106
+ "use #{replacement_for(feature)} instead"
107
+ end
108
+ private_class_method :unsupported_internals_message
109
+
33
110
  def log_private_api(feature)
34
111
  ActiveJob::Temporal::Logger.warn(
35
112
  "active_job_configured_job_private_api",
36
113
  feature: feature,
37
- replacement: "ActiveJob::Temporal.job"
114
+ replacement: replacement_for(feature)
38
115
  )
39
116
  rescue StandardError
40
117
  nil
41
118
  end
119
+
120
+ def replacement_for(feature)
121
+ FEATURE_REPLACEMENTS.fetch(feature.to_s, "ActiveJob::Temporal.job")
122
+ end
123
+ private_class_method :replacement_for
42
124
  end
43
125
  end
44
126
  end
@@ -72,12 +72,29 @@ module ActiveJob
72
72
  connection = queue.pop
73
73
  break unless connection
74
74
 
75
- @handler.call(connection)
75
+ begin
76
+ @handler.call(connection)
77
+ rescue StandardError => e
78
+ log_handler_failure(index, e)
79
+ close_connection(connection)
80
+ end
76
81
  end
77
82
  rescue ClosedQueueError
78
83
  nil
79
84
  end
80
85
 
86
+ def log_handler_failure(index, error)
87
+ ActiveJob::Temporal::Logger.error(
88
+ "connection_worker_handler_failed",
89
+ pool: @name,
90
+ worker_index: index,
91
+ error_class: error.class.name,
92
+ message: error.message.to_s
93
+ )
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
81
98
  def close_connection(connection)
82
99
  connection.close
83
100
  rescue IOError, SystemCallError
@@ -2,11 +2,16 @@
2
2
 
3
3
  require "temporalio/client"
4
4
 
5
+ require_relative "logger"
6
+ require_relative "visibility_query"
7
+ require_relative "workflow_types"
8
+
5
9
  module ActiveJob
6
10
  module Temporal
7
11
  module DeadLetterQueue
8
- WORKFLOW_TYPE = "ActiveJobTemporalDeadLetterWorkflow"
12
+ WORKFLOW_TYPE = WorkflowTypes::DEAD_LETTER
9
13
  DEFAULT_ENTRIES_LIMIT = 100
14
+ ENTRY_QUERY_CONCURRENCY = 5
10
15
 
11
16
  module_function
12
17
 
@@ -17,22 +22,24 @@ module ActiveJob
17
22
  def entries(queue: nil, limit: DEFAULT_ENTRIES_LIMIT, client: ActiveJob::Temporal.client)
18
23
  validate_limit!(limit)
19
24
 
20
- client.list_workflows(entries_query(queue)).each_with_object([]) do |workflow, entries|
21
- entry = query_workflow_entry(client, workflow)
22
- entries << entry if entry
23
- break entries if entries.size >= limit
24
- end
25
+ workflows = client.list_workflows(entries_query(queue)).first(limit)
26
+ query_workflow_entries(client, workflows)
25
27
  end
26
28
 
27
29
  def retry(job_class, job_id, queue: nil, client: ActiveJob::Temporal.client)
28
30
  handle = handle_for(job_class, job_id, client: client)
29
31
  entry = handle.query(:entry)
30
- return entry.fetch("retry_workflow_id") if retried_entry?(entry)
32
+ if retried_entry?(entry)
33
+ workflow_id = entry.fetch("retry_workflow_id")
34
+ log_retry_requested(entry, workflow_id, queue, duplicate: true)
35
+ return workflow_id
36
+ end
31
37
 
32
38
  ensure_pending_entry!(entry)
33
39
 
34
40
  workflow_id = retry_workflow_id(entry)
35
- start_retry_workflow(client, entry, workflow_id, queue)
41
+ duplicate = start_retry_workflow(client, entry, workflow_id, queue)
42
+ log_retry_requested(entry, workflow_id, queue, duplicate: duplicate)
36
43
  mark_retried_entry(handle, workflow_id)
37
44
  workflow_id
38
45
  end
@@ -56,31 +63,72 @@ module ActiveJob
56
63
 
57
64
  def entries_query(queue)
58
65
  query = ["WorkflowType='#{WORKFLOW_TYPE}'", "ExecutionStatus='Running'"]
59
- query << "TaskQueue='#{escape_query_value(queue)}'" if queue.to_s.strip.present?
66
+ query << "TaskQueue=#{VisibilityQuery.quote(queue)}" if queue.to_s.strip.present?
60
67
  query.join(" AND ")
61
68
  end
62
69
  private_class_method :entries_query
63
70
 
71
+ def query_workflow_entries(client, workflows)
72
+ entries = Array.new(workflows.size)
73
+ pending = Queue.new
74
+ workflows.each_with_index { |workflow, index| pending << [workflow, index] }
75
+
76
+ Array.new([workflows.size, ENTRY_QUERY_CONCURRENCY].min) do
77
+ worker = Thread.new do
78
+ loop do
79
+ workflow, index = pending.pop(true)
80
+ entries[index] = query_workflow_entry(client, workflow)
81
+ rescue ThreadError
82
+ break
83
+ end
84
+ end
85
+ # Failures are re-raised by Thread#value below, not lost.
86
+ worker.report_on_exception = false
87
+ worker
88
+ end.each(&:value)
89
+
90
+ entries
91
+ end
92
+ private_class_method :query_workflow_entries
93
+
64
94
  def query_workflow_entry(client, workflow)
65
95
  client.workflow_handle(workflow.id, run_id: workflow_run_id(workflow)).query(:entry)
66
- rescue Temporalio::Error
67
- nil
68
96
  end
69
97
  private_class_method :query_workflow_entry
70
98
 
71
99
  def start_retry_workflow(client, entry, workflow_id, queue)
72
100
  client.start_workflow(
73
- ActiveJob::Temporal::Workflows::AjWorkflow,
101
+ WorkflowTypes::ACTIVE_JOB,
74
102
  retry_payload(entry),
75
103
  id: workflow_id,
76
104
  task_queue: retry_task_queue(entry, queue),
77
105
  id_conflict_policy: Temporalio::WorkflowIDConflictPolicy::FAIL
78
106
  )
107
+ false
79
108
  rescue StandardError => e
80
109
  raise unless workflow_already_started?(e)
110
+
111
+ true
81
112
  end
82
113
  private_class_method :start_retry_workflow
83
114
 
115
+ def log_retry_requested(entry, workflow_id, queue, duplicate:)
116
+ Logger.log_event(
117
+ "dead_letter_retry_requested",
118
+ {
119
+ entry_id: entry.fetch("id"),
120
+ workflow_id: workflow_id,
121
+ job_class: entry.dig("payload", "job_class"),
122
+ job_id: entry.dig("payload", "job_id"),
123
+ task_queue: retry_task_queue(entry, queue),
124
+ duplicate: duplicate
125
+ }.compact
126
+ )
127
+ rescue StandardError
128
+ nil
129
+ end
130
+ private_class_method :log_retry_requested
131
+
84
132
  def mark_retried_entry(handle, workflow_id)
85
133
  handle.signal(:mark_retried, workflow_id)
86
134
  rescue StandardError => e
@@ -146,16 +194,14 @@ module ActiveJob
146
194
  end
147
195
  private_class_method :validate_limit!
148
196
 
149
- def escape_query_value(value)
150
- value.to_s.gsub("'", "''")
151
- end
152
- private_class_method :escape_query_value
153
-
154
197
  def workflow_already_started?(error)
155
198
  (defined?(Temporalio::Error::WorkflowAlreadyStartedError) &&
156
199
  error.is_a?(Temporalio::Error::WorkflowAlreadyStartedError)) ||
157
200
  (defined?(Temporalio::Client::WorkflowAlreadyStartedError) &&
158
- error.is_a?(Temporalio::Client::WorkflowAlreadyStartedError))
201
+ error.is_a?(Temporalio::Client::WorkflowAlreadyStartedError)) ||
202
+ (defined?(Temporalio::Error::RPCError::Code::ALREADY_EXISTS) &&
203
+ error.respond_to?(:code) &&
204
+ error.code == Temporalio::Error::RPCError::Code::ALREADY_EXISTS)
159
205
  end
160
206
  private_class_method :workflow_already_started?
161
207
  end