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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "active_support/concern"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ module SignalQueryOptions
9
+ extend ActiveSupport::Concern
10
+
11
+ HANDLER_NAME_PATTERN = /\A[a-zA-Z_]\w*\z/
12
+ BUILT_IN_SIGNAL_NAMES = %w[pause resume].freeze
13
+ BUILT_IN_QUERY_NAMES = %w[pause_reason paused phase signals state].freeze
14
+ BUILT_IN_UPDATE_NAMES = [].freeze
15
+
16
+ module ClassMethods
17
+ def temporal_signal(name, &block)
18
+ handler_name = normalize_handler_name(name)
19
+ validate_custom_handler_name!(handler_name, "signal", BUILT_IN_SIGNAL_NAMES)
20
+ local_temporal_signal_handlers[handler_name] = block || default_signal_handler(handler_name)
21
+ end
22
+
23
+ def temporal_query(name, &block)
24
+ raise ArgumentError, "temporal_query requires a block" unless block
25
+
26
+ handler_name = normalize_handler_name(name)
27
+ validate_custom_handler_name!(handler_name, "query", BUILT_IN_QUERY_NAMES)
28
+ local_temporal_query_handlers[handler_name] = block
29
+ end
30
+
31
+ def temporal_update(name, &block)
32
+ raise ArgumentError, "temporal_update requires a block" unless block
33
+
34
+ handler_name = normalize_handler_name(name)
35
+ validate_custom_handler_name!(handler_name, "update", BUILT_IN_UPDATE_NAMES)
36
+ local_temporal_update_handlers[handler_name] = block
37
+ end
38
+
39
+ def temporal_signal_handlers
40
+ inherited_temporal_handlers(:temporal_signal_handlers).merge(local_temporal_signal_handlers)
41
+ end
42
+
43
+ def temporal_query_handlers
44
+ inherited_temporal_handlers(:temporal_query_handlers).merge(local_temporal_query_handlers)
45
+ end
46
+
47
+ def temporal_update_handlers
48
+ inherited_temporal_handlers(:temporal_update_handlers).merge(local_temporal_update_handlers)
49
+ end
50
+
51
+ def temporal_signal_handler_names
52
+ temporal_signal_handlers.keys
53
+ end
54
+
55
+ def temporal_query_handler_names
56
+ temporal_query_handlers.keys
57
+ end
58
+
59
+ def temporal_update_handler_names
60
+ temporal_update_handlers.keys
61
+ end
62
+
63
+ private
64
+
65
+ def local_temporal_signal_handlers
66
+ @local_temporal_signal_handlers ||= {}
67
+ end
68
+
69
+ def local_temporal_query_handlers
70
+ @local_temporal_query_handlers ||= {}
71
+ end
72
+
73
+ def local_temporal_update_handlers
74
+ @local_temporal_update_handlers ||= {}
75
+ end
76
+
77
+ def normalize_handler_name(name)
78
+ handler_name = name.to_s
79
+ return handler_name if handler_name.match?(HANDLER_NAME_PATTERN)
80
+
81
+ raise ArgumentError, "signal and query names must start with a letter or underscore and contain word chars"
82
+ end
83
+
84
+ def validate_custom_handler_name!(handler_name, handler_type, built_in_names)
85
+ return unless built_in_names.include?(handler_name)
86
+
87
+ raise ArgumentError, "#{handler_type} name #{handler_name.inspect} is reserved by ActiveJob::Temporal"
88
+ end
89
+
90
+ def inherited_temporal_handlers(method_name)
91
+ return {} unless superclass.respond_to?(method_name)
92
+
93
+ superclass.public_send(method_name)
94
+ end
95
+
96
+ def default_signal_handler(handler_name)
97
+ lambda do |state, *args|
98
+ state[handler_name] = args.length == 1 ? args.first : args
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ ActiveJob::Base.include(ActiveJob::Temporal::SignalQueryOptions) if defined?(ActiveJob::Base)
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ # Provides per-job timeout configuration via the +temporal_options+ class method.
8
+ #
9
+ # @example Basic timeout override
10
+ # class QuickJob < ApplicationJob
11
+ # temporal_options start_to_close_timeout: 30.seconds
12
+ # end
13
+ #
14
+ # @example Long-running job with heartbeat
15
+ # class DataProcessingJob < ApplicationJob
16
+ # temporal_options(
17
+ # start_to_close_timeout: 2.hours,
18
+ # heartbeat_timeout: 30.seconds
19
+ # )
20
+ # end
21
+ #
22
+ # @example All timeout types configured
23
+ # class CriticalJob < ApplicationJob
24
+ # temporal_options(
25
+ # start_to_close_timeout: 10.minutes,
26
+ # schedule_to_start_timeout: 1.minute,
27
+ # schedule_to_close_timeout: 15.minutes,
28
+ # heartbeat_timeout: 10.seconds
29
+ # )
30
+ # end
31
+ module TemporalOptions
32
+ extend ActiveSupport::Concern
33
+
34
+ VALID_TIMEOUT_KEYS = %i[
35
+ start_to_close_timeout
36
+ schedule_to_close_timeout
37
+ schedule_to_start_timeout
38
+ heartbeat_timeout
39
+ ].freeze
40
+
41
+ # rubocop:disable Metrics/BlockLength
42
+ class_methods do
43
+ # Define Temporal activity timeout options for this job class.
44
+ #
45
+ # @param options [Hash] Timeout configuration options
46
+ # @option options [Integer, ActiveSupport::Duration] :start_to_close_timeout
47
+ # Maximum execution time for a single activity attempt
48
+ # @option options [Integer, ActiveSupport::Duration] :schedule_to_close_timeout
49
+ # Total time including all retries from schedule to completion
50
+ # @option options [Integer, ActiveSupport::Duration] :schedule_to_start_timeout
51
+ # Maximum wait time before activity starts after scheduling
52
+ # @option options [Integer, ActiveSupport::Duration] :heartbeat_timeout
53
+ # Maximum interval between heartbeats before activity is considered failed
54
+ #
55
+ # @return [Hash] The stored timeout options (when called without arguments)
56
+ #
57
+ # @note At least one of +start_to_close_timeout+ or +schedule_to_close_timeout+
58
+ # must be specified. Temporal SDK will validate this requirement.
59
+ #
60
+ # @note Timeout values can be specified as either integers (seconds) or
61
+ # ActiveSupport::Duration objects (e.g., 2.hours, 30.seconds)
62
+ def temporal_options(options = nil)
63
+ if options
64
+ validate_timeout_keys!(options)
65
+ @temporal_options = normalize_timeout_values(options)
66
+ end
67
+ @temporal_options || {}
68
+ end
69
+
70
+ private
71
+
72
+ # Validates that only recognized timeout keys are provided
73
+ #
74
+ # @param options [Hash] The options hash to validate
75
+ # @raise [ArgumentError] If invalid keys are present
76
+ # @api private
77
+ def validate_timeout_keys!(options)
78
+ invalid_keys = options.keys - VALID_TIMEOUT_KEYS
79
+ return if invalid_keys.empty?
80
+
81
+ raise ArgumentError,
82
+ "Invalid temporal_options keys: #{invalid_keys.join(', ')}. " \
83
+ "Valid keys are: #{VALID_TIMEOUT_KEYS.join(', ')}"
84
+ end
85
+
86
+ # Normalizes timeout values to numeric seconds
87
+ #
88
+ # Converts ActiveSupport::Duration objects to their numeric second values
89
+ # while preserving integer/float values as-is.
90
+ #
91
+ # @param options [Hash] Raw timeout options with mixed value types
92
+ # @return [Hash] Normalized options with all values as numbers
93
+ # @api private
94
+ def normalize_timeout_values(options)
95
+ options.transform_values do |value|
96
+ case value
97
+ when ActiveSupport::Duration
98
+ value.to_f
99
+ when Numeric
100
+ value
101
+ else
102
+ raise ArgumentError,
103
+ "Timeout values must be numeric or ActiveSupport::Duration, got: #{value.class}"
104
+ end
105
+ end
106
+ end
107
+ end
108
+ # rubocop:enable Metrics/BlockLength
109
+ end
110
+ end
111
+ end
112
+
113
+ # Automatically include TemporalOptions into ActiveJob::Base when this file is loaded
114
+ ActiveJob::Base.include(ActiveJob::Temporal::TemporalOptions) if defined?(ActiveJob::Base)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module TLSFile
6
+ class Error < StandardError; end
7
+ OPEN_FLAGS = File::RDONLY | (File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0)
8
+ USES_NOFOLLOW = File.const_defined?(:NOFOLLOW)
9
+
10
+ module_function
11
+
12
+ def readable_regular_file?(path)
13
+ expanded_path = File.expand_path(path)
14
+ stat = File.lstat(expanded_path)
15
+ return false if stat.symlink?
16
+
17
+ stat.file? && File.readable?(expanded_path)
18
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
19
+ false
20
+ end
21
+
22
+ def read(path)
23
+ return nil if path.nil? || path.to_s.empty?
24
+
25
+ expanded_path = File.expand_path(path)
26
+ reject_symlink!(expanded_path) unless USES_NOFOLLOW
27
+ File.open(expanded_path, OPEN_FLAGS) do |file|
28
+ raise Error, "TLS file path must point to a regular file: #{path}" unless file.stat.file?
29
+
30
+ file.read
31
+ end
32
+ rescue Errno::ELOOP
33
+ raise Error, "TLS file path must not be a symlink: #{path}"
34
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
35
+ raise Error, "TLS file path is not readable: #{path}"
36
+ end
37
+
38
+ def reject_symlink!(path)
39
+ return unless File.lstat(path).symlink?
40
+
41
+ raise Error, "TLS file path must not be a symlink: #{path}"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/lazy_load_hooks"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module TransactionSafety
8
+ module QueueAdapterSetter
9
+ def queue_adapter=(adapter)
10
+ super.tap do
11
+ self.enqueue_after_transaction_commit = true if temporal_queue_adapter?(adapter)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def temporal_queue_adapter?(adapter)
18
+ case adapter
19
+ when Symbol, String
20
+ adapter.to_s == "temporal"
21
+ else
22
+ defined?(ActiveJob::QueueAdapters::TemporalAdapter) &&
23
+ adapter.is_a?(ActiveJob::QueueAdapters::TemporalAdapter)
24
+ end
25
+ end
26
+ end
27
+
28
+ module_function
29
+
30
+ def install!
31
+ ActiveSupport.on_load(:active_job) do
32
+ singleton_class.prepend(QueueAdapterSetter) unless singleton_class < QueueAdapterSetter
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ ActiveJob::Temporal::TransactionSafety.install!
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module VisibilityQuery
6
+ module_function
7
+
8
+ def quote(value)
9
+ "'#{value.to_s.gsub("'", "''")}'"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ # Rebuilds the Temporal client and swaps it into a running worker.
6
+ class WorkerClientReloader
7
+ def initialize(worker:, logger: ActiveJob::Temporal::Logger, reload_client: ActiveJob::Temporal.method(:reload_client!))
8
+ @worker = worker
9
+ @logger = logger
10
+ @reload_client = reload_client
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def reload(source:)
15
+ @mutex.synchronize do
16
+ @logger.log_event("certificate_reload_started", source: source)
17
+ new_client = @reload_client.call do |fresh_client|
18
+ @worker.client = fresh_client
19
+ end
20
+ @logger.log_event("certificate_reload_succeeded", source: source)
21
+ new_client
22
+ rescue StandardError => e
23
+ @logger.error(
24
+ "certificate_reload_failed",
25
+ source: source,
26
+ error_class: e.class.name,
27
+ message: e.message
28
+ )
29
+ raise
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "temporalio/worker/interceptor"
5
+
6
+ require_relative "observability"
7
+
8
+ module ActiveJob
9
+ module Temporal
10
+ class WorkerHealth
11
+ include Temporalio::Worker::Interceptor::Activity
12
+
13
+ def initialize(task_queue:, namespace:, target:, max_concurrent_activities:, max_concurrent_workflows:)
14
+ @task_queue = task_queue
15
+ @namespace = namespace
16
+ @target = target
17
+ @max_concurrent_activities = max_concurrent_activities
18
+ @max_concurrent_workflows = max_concurrent_workflows
19
+ @mutex = Mutex.new
20
+ @started_at = nil
21
+ @worker_running = false
22
+ @active_tasks = 0
23
+ @last_poll = nil
24
+ end
25
+
26
+ def mark_started!
27
+ @mutex.synchronize do
28
+ @started_at ||= Time.now
29
+ @worker_running = true
30
+ end
31
+ Observability.emit(:worker_start, observability_attributes)
32
+ end
33
+
34
+ def mark_stopped!
35
+ @mutex.synchronize do
36
+ @worker_running = false
37
+ end
38
+ Observability.emit(:worker_stop, observability_attributes)
39
+ end
40
+
41
+ def record_task_started!(now: Time.now)
42
+ active_tasks = @mutex.synchronize do
43
+ @active_tasks += 1
44
+ @last_poll = now
45
+ @active_tasks
46
+ end
47
+ Observability.emit(:active_tasks, observability_attributes(count: active_tasks))
48
+ end
49
+
50
+ def record_task_finished!
51
+ active_tasks = @mutex.synchronize do
52
+ @active_tasks = [@active_tasks - 1, 0].max
53
+ @active_tasks
54
+ end
55
+ Observability.emit(:active_tasks, observability_attributes(count: active_tasks))
56
+ end
57
+
58
+ def snapshot(now: Time.now)
59
+ @mutex.synchronize do
60
+ {
61
+ status: @worker_running ? "ok" : "stopped",
62
+ worker_running: @worker_running,
63
+ started_at: iso8601(@started_at),
64
+ last_poll: iso8601(@last_poll),
65
+ active_tasks: @active_tasks,
66
+ uptime_seconds: uptime_seconds(now),
67
+ task_queue: @task_queue,
68
+ namespace: @namespace,
69
+ target: @target,
70
+ max_concurrent_activities: @max_concurrent_activities,
71
+ max_concurrent_workflows: @max_concurrent_workflows,
72
+ pid: Process.pid
73
+ }
74
+ end
75
+ end
76
+
77
+ def intercept_activity(next_interceptor)
78
+ ActivityInbound.new(self, next_interceptor)
79
+ end
80
+
81
+ private
82
+
83
+ def iso8601(time)
84
+ time&.utc&.iso8601
85
+ end
86
+
87
+ def uptime_seconds(now)
88
+ return 0 unless @started_at
89
+
90
+ [now - @started_at, 0].max.round
91
+ end
92
+
93
+ def observability_attributes(**attributes)
94
+ {
95
+ task_queue: @task_queue,
96
+ namespace: @namespace,
97
+ target: @target,
98
+ worker_id: Process.pid
99
+ }.merge(attributes)
100
+ end
101
+
102
+ class ActivityInbound < Temporalio::Worker::Interceptor::Activity::Inbound
103
+ def initialize(worker_health, next_interceptor)
104
+ super(next_interceptor)
105
+ @worker_health = worker_health
106
+ end
107
+
108
+ def execute(input)
109
+ @worker_health.record_task_started!
110
+ super
111
+ ensure
112
+ @worker_health.record_task_finished!
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end