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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "batch_enqueue_result"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ class BatchEnqueuer
8
+ def initialize(enqueue:, validate_job:, validate_scheduled_at:)
9
+ @enqueue_job = enqueue
10
+ @validate_job = validate_job
11
+ @validate_scheduled_at = validate_scheduled_at
12
+ end
13
+
14
+ def enqueue(items, concurrency: 1)
15
+ entries = validate_entries!(items)
16
+ concurrency = validate_concurrency!(concurrency)
17
+ results = Array.new(entries.length)
18
+
19
+ enqueue_entries(entries, results, concurrency)
20
+
21
+ BatchEnqueueResult.new(results)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :enqueue_job, :validate_job, :validate_scheduled_at
27
+
28
+ def validate_entries!(items)
29
+ raise ArgumentError, "batch enqueue jobs must be an Enumerable" unless items.respond_to?(:each)
30
+
31
+ errors = []
32
+ entries = items.each_with_index.map do |item, index|
33
+ validate_entry(item, index, errors)
34
+ end
35
+
36
+ raise ArgumentError, "batch enqueue jobs cannot be empty" if entries.empty?
37
+ raise BatchEnqueueValidationError, errors if errors.any?
38
+
39
+ entries
40
+ end
41
+
42
+ def validate_entry(item, index, errors)
43
+ entry = normalize_entry(item)
44
+ entry[:index] = index
45
+ validate_job.call(entry[:job])
46
+ entry[:scheduled_at] = validate_scheduled_at.call(entry[:scheduled_at])
47
+ entry
48
+ rescue StandardError => e
49
+ errors << {
50
+ index: index,
51
+ error: "#{e.class}: #{e.message}"
52
+ }
53
+ nil
54
+ end
55
+
56
+ def normalize_entry(item)
57
+ return { job: item, scheduled_at: nil } if active_job_instance?(item)
58
+
59
+ raise ArgumentError, "must be an ActiveJob instance or a Hash with :job" unless item.respond_to?(:to_hash)
60
+
61
+ hash = item.to_hash
62
+ job = hash[:job] || hash["job"]
63
+ scheduled_at = hash[:scheduled_at] || hash["scheduled_at"]
64
+ raise ArgumentError, "must include an ActiveJob instance in :job" unless active_job_instance?(job)
65
+
66
+ { job: job, scheduled_at: scheduled_at }
67
+ end
68
+
69
+ def active_job_instance?(value)
70
+ value.respond_to?(:job_id) && value.respond_to?(:queue_name)
71
+ end
72
+
73
+ def validate_concurrency!(concurrency)
74
+ concurrency = Integer(concurrency)
75
+ raise ArgumentError unless concurrency.positive?
76
+
77
+ concurrency
78
+ rescue ArgumentError, TypeError
79
+ raise ArgumentError, "batch enqueue concurrency must be a positive integer"
80
+ end
81
+
82
+ def enqueue_entries(entries, results, concurrency)
83
+ return enqueue_sequentially(entries, results) if concurrency == 1
84
+
85
+ entry_queue = Queue.new
86
+ entries.each { |entry| entry_queue << entry }
87
+ worker_count = [concurrency, entries.length].min
88
+
89
+ Array.new(worker_count) do
90
+ Thread.new do
91
+ loop do
92
+ enqueue_entry(entry_queue.pop(true), results)
93
+ rescue ThreadError
94
+ break
95
+ end
96
+ end
97
+ end.each(&:value)
98
+ end
99
+
100
+ def enqueue_sequentially(entries, results)
101
+ entries.each { |entry| enqueue_entry(entry, results) }
102
+ end
103
+
104
+ def enqueue_entry(entry, results)
105
+ handle = enqueue_job.call(entry[:job], scheduled_at: entry[:scheduled_at])
106
+ results[entry[:index]] = success_result(entry, handle)
107
+ rescue DuplicateEnqueueError => e
108
+ results[entry[:index]] = duplicate_result(entry, e)
109
+ rescue StandardError => e
110
+ results[entry[:index]] = failed_result(entry, e)
111
+ end
112
+
113
+ def success_result(entry, handle)
114
+ BatchEnqueueItemResult.new(
115
+ index: entry[:index],
116
+ job: entry[:job],
117
+ status: :success,
118
+ handle: handle
119
+ )
120
+ end
121
+
122
+ def duplicate_result(entry, error)
123
+ BatchEnqueueItemResult.new(
124
+ index: entry[:index],
125
+ job: entry[:job],
126
+ status: :duplicate,
127
+ error: error
128
+ )
129
+ end
130
+
131
+ def failed_result(entry, error)
132
+ BatchEnqueueItemResult.new(
133
+ index: entry[:index],
134
+ job: entry[:job],
135
+ status: :failed,
136
+ error: error
137
+ )
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module BindPolicy
8
+ LOOPBACK_HOSTNAMES = %w[localhost].freeze
9
+ TRUE_VALUES = %w[1 true yes].freeze
10
+
11
+ module_function
12
+
13
+ def public_bind?(bind_address)
14
+ normalized = bind_address.to_s.strip
15
+ return false if normalized.empty? || LOOPBACK_HOSTNAMES.include?(normalized.downcase)
16
+
17
+ !IPAddr.new(normalized).loopback?
18
+ rescue IPAddr::InvalidAddressError
19
+ true
20
+ end
21
+
22
+ def allow_public_bind?(value)
23
+ TRUE_VALUES.include?(value.to_s.strip.downcase)
24
+ end
25
+
26
+ def validate!(endpoint:, bind_address:, allow_public_bind:, warn_on_allowed: true)
27
+ return unless public_bind?(bind_address)
28
+
29
+ unless allow_public_bind
30
+ raise ArgumentError,
31
+ "refusing to expose unauthenticated #{endpoint} endpoint on non-loopback address " \
32
+ "#{bind_address.inspect} without explicit public bind opt-in"
33
+ end
34
+
35
+ return unless warn_on_allowed
36
+
37
+ warn(
38
+ "Warning: exposing unauthenticated #{endpoint} endpoint on non-loopback address " \
39
+ "#{bind_address.inspect}. Protect it with network policy, a firewall, or an internal-only listener."
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "batch_summary"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ module Cancel
8
+ class BatchCanceller
9
+ PAGE_SIZE = 100
10
+ TERMINATION_CONCURRENCY = 5
11
+ MAX_REPORTED_ERRORS = BatchSummary::MAX_REPORTED_ERRORS
12
+ TERMINATION_REASON = "ActiveJob::Temporal.cancel_where"
13
+ SEARCH_ATTRIBUTE_TYPES = {
14
+ "ajClass" => :keyword,
15
+ "ajQueue" => :keyword,
16
+ "ajJobId" => :keyword,
17
+ "ajEnqueuedAt" => :datetime,
18
+ "ajTenantId" => :integer
19
+ }.freeze
20
+
21
+ def initialize(client)
22
+ @client = client
23
+ end
24
+
25
+ def cancel_where(filters)
26
+ query = workflows_query(normalize_filters(filters))
27
+ summary = BatchSummary.new
28
+
29
+ each_workflow_page(query) do |workflow_executions|
30
+ terminate_workflows(workflow_executions, summary)
31
+ end
32
+
33
+ summary.to_h
34
+ rescue ArgumentError
35
+ raise
36
+ rescue StandardError => e
37
+ raise ActiveJob::Temporal::TemporalConnectionError,
38
+ "Failed to query Temporal workflows for batch cancellation: #{e.message}"
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :client
44
+
45
+ def normalize_filters(filters)
46
+ unless filters.respond_to?(:to_hash)
47
+ raise ArgumentError, "cancel_where filters must be a Hash of search attributes"
48
+ end
49
+
50
+ normalized_filters = filters.to_hash.transform_keys(&:to_s)
51
+ raise ArgumentError, "cancel_where requires at least one search attribute" if normalized_filters.empty?
52
+
53
+ normalized_filters.each_with_object({}) do |(name, value), result|
54
+ result[validate_search_attribute_name!(name)] = format_search_attribute_value(name, value)
55
+ end
56
+ end
57
+
58
+ def validate_search_attribute_name!(name)
59
+ return name if SEARCH_ATTRIBUTE_TYPES.key?(name)
60
+
61
+ supported_attributes = SEARCH_ATTRIBUTE_TYPES.keys.join(", ")
62
+ raise ArgumentError,
63
+ "Unsupported search attribute #{name.inspect}. Supported attributes: #{supported_attributes}"
64
+ end
65
+
66
+ def format_search_attribute_value(name, value)
67
+ case SEARCH_ATTRIBUTE_TYPES.fetch(name)
68
+ when :integer
69
+ format_integer_search_attribute_value(name, value)
70
+ when :keyword
71
+ format_keyword_search_attribute_value(name, value)
72
+ when :datetime
73
+ format_datetime_search_attribute_value(name, value)
74
+ end
75
+ end
76
+
77
+ def format_integer_search_attribute_value(name, value)
78
+ return value.to_s if value.is_a?(Integer)
79
+
80
+ raise ArgumentError, "#{name} must be an Integer"
81
+ end
82
+
83
+ def format_keyword_search_attribute_value(name, value)
84
+ valid_value = value.is_a?(String) || value.is_a?(Symbol)
85
+ raise ArgumentError, "#{name} must be a String or Symbol" unless valid_value
86
+
87
+ quote_search_attribute_string(value.to_s)
88
+ end
89
+
90
+ def format_datetime_search_attribute_value(name, value)
91
+ value = value.iso8601 if value.respond_to?(:iso8601)
92
+ raise ArgumentError, "#{name} must be a String or ISO8601-compatible time value" unless value.is_a?(String)
93
+
94
+ quote_search_attribute_string(value)
95
+ end
96
+
97
+ def quote_search_attribute_string(value)
98
+ raise ArgumentError, "search attribute values cannot be empty" if value.empty?
99
+
100
+ "'#{value.gsub("'", "''")}'"
101
+ end
102
+
103
+ def workflows_query(filters)
104
+ (filters.map { |name, value| "#{name}=#{value}" } + ["ExecutionStatus='Running'"]).join(" AND ")
105
+ end
106
+
107
+ def each_workflow_page(query, &)
108
+ next_page_token = nil
109
+
110
+ loop do
111
+ page = client.list_workflow_page(query, page_size: PAGE_SIZE, next_page_token: next_page_token)
112
+ yield page.executions
113
+ next_page_token = page.next_page_token
114
+ break if next_page_token.to_s.empty?
115
+ end
116
+ end
117
+
118
+ def terminate_workflows(workflow_executions, summary)
119
+ workflow_executions = workflow_executions.to_a
120
+ worker_count = [workflow_executions.length, TERMINATION_CONCURRENCY].min
121
+ workflow_queue = Queue.new
122
+
123
+ workflow_executions.each { |workflow_execution| workflow_queue << workflow_execution }
124
+ Array.new(worker_count) do
125
+ Thread.new do
126
+ loop do
127
+ terminate_workflow(workflow_queue.pop(true), summary)
128
+ rescue ThreadError
129
+ break
130
+ end
131
+ end
132
+ end.each(&:value)
133
+ end
134
+
135
+ def terminate_workflow(workflow_execution, summary)
136
+ workflow_id = workflow_execution.id
137
+ run_id = workflow_execution.respond_to?(:run_id) ? workflow_execution.run_id : nil
138
+
139
+ client.workflow_handle(workflow_id, run_id: run_id).terminate(TERMINATION_REASON)
140
+ ActiveJob::Temporal::AuditLog.record(
141
+ "job.cancelled",
142
+ workflow_id: workflow_id,
143
+ run_id: run_id,
144
+ status: "terminated",
145
+ reason: TERMINATION_REASON
146
+ )
147
+ summary.record_terminated
148
+ rescue StandardError => e
149
+ summary.record_failure(workflow_id, run_id, e)
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJob
4
+ module Temporal
5
+ module Cancel
6
+ class BatchSummary
7
+ MAX_REPORTED_ERRORS = 100
8
+
9
+ def initialize
10
+ @mutex = Mutex.new
11
+ @value = { terminated: 0, failed: 0, errors: [] }
12
+ end
13
+
14
+ def record_terminated
15
+ mutex.synchronize { value[:terminated] += 1 }
16
+ end
17
+
18
+ def record_failure(workflow_id, run_id, error)
19
+ mutex.synchronize do
20
+ value[:failed] += 1
21
+ if value[:errors].length < MAX_REPORTED_ERRORS
22
+ value[:errors] << cancellation_error(workflow_id, run_id, error)
23
+ end
24
+ end
25
+ end
26
+
27
+ def to_h
28
+ value
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :mutex, :value
34
+
35
+ def cancellation_error(workflow_id, run_id, error)
36
+ {
37
+ workflow_id: workflow_id,
38
+ run_id: run_id,
39
+ error: "#{error.class}: #{error.message}"
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "visibility_query"
4
+ require_relative "workflow_id_builder"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ # Job cancellation with query-based workflow discovery.
9
+ #
10
+ # This module provides cancellation capabilities for ActiveJob workflows running on Temporal.
11
+ # Cancellation is asynchronous and best-effort: workflows will stop only if they are actively
12
+ # checking for cancellation signals (via heartbeating or cancellation checks).
13
+ #
14
+ # @note Best-Effort Semantics
15
+ # Temporal cancellation is cooperative, not forceful. Activities must check for cancellation
16
+ # by heartbeating or polling `Temporalio::Activity::Context.current.cancelled?`. Activities
17
+ # that do not check for cancellation will run to completion even after a cancel request.
18
+ #
19
+ # @note Query Strategy
20
+ # This module queries running workflows first, then closed workflows (completed, failed,
21
+ # cancelled, etc.) to determine the workflow state before issuing a cancellation request.
22
+ # This ensures idempotent cancellation behavior.
23
+ #
24
+ # @example Cancel a running job
25
+ # ActiveJob::Temporal.cancel(SendInvoiceJob, "550e8400-e29b-41d4-a716-446655440000")
26
+ #
27
+ # @see https://docs.temporal.io/activities#heartbeat Temporal Activity Heartbeating
28
+ # @see https://docs.temporal.io/workflows#cancellation Temporal Cancellation Guide
29
+ module Cancel
30
+ class << self
31
+ # Cancels a running Temporal workflow by sending a cancellation request.
32
+ #
33
+ # This method first queries Temporal to determine the workflow state before
34
+ # attempting cancellation. It will not cancel already-completed workflows.
35
+ #
36
+ # @param job_class [Class] The ActiveJob class for the job to cancel.
37
+ # @param job_id [String] Identifier of the job to cancel.
38
+ # @return [Boolean, nil] Returns false if workflow already completed, nil if cancellation requested
39
+ #
40
+ # @raise [WorkflowNotFoundError] if no workflow exists for the given job_id
41
+ # @raise [TemporalConnectionError] if the Temporal cluster cannot be reached
42
+ #
43
+ # @note Asynchronous Cancellation
44
+ # Cancellation requests are asynchronous. The method returns immediately after
45
+ # sending the request. The workflow will stop only if it checks for cancellation.
46
+ #
47
+ # @example Cancel a running job
48
+ # result = ActiveJob::Temporal.cancel(SendInvoiceJob, "550e8400-e29b-41d4-a716-446655440000")
49
+ # puts "Cancellation requested" if result.nil?
50
+ #
51
+ # @example Handle all outcomes
52
+ # begin
53
+ # result = ActiveJob::Temporal.cancel(MyJob, "abc-123")
54
+ # case result
55
+ # when false
56
+ # puts "Job already completed, cannot cancel"
57
+ # when nil
58
+ # puts "Cancellation sent to Temporal"
59
+ # end
60
+ # rescue ActiveJob::Temporal::WorkflowNotFoundError
61
+ # puts "Job never existed or already removed from history"
62
+ # rescue ActiveJob::Temporal::TemporalConnectionError => e
63
+ # puts "Cannot reach Temporal: #{e.message}"
64
+ # end
65
+ #
66
+ # @see #find_workflow
67
+ def cancel(job_class, job_id)
68
+ validate_job_id!(job_id)
69
+ client = ActiveJob::Temporal.client
70
+ workflow_state = find_workflow(client, job_class, job_id)
71
+ workflow_id = workflow_state[:workflow_id]
72
+
73
+ case workflow_state[:status]
74
+ when :closed
75
+ log_workflow_already_completed(job_class, job_id, workflow_id)
76
+ false
77
+ when :not_found
78
+ raise ActiveJob::Temporal::WorkflowNotFoundError,
79
+ "No workflow found for job_id #{job_id}. The job may have never existed."
80
+ when :running
81
+ client.workflow_handle(workflow_id).cancel
82
+ log_cancellation_requested(job_class, job_id, workflow_id)
83
+ log_audit_cancellation_requested(job_class, job_id, workflow_id)
84
+ nil
85
+ end
86
+ end
87
+
88
+ def cancel_all(job_class)
89
+ validate_job_class!(job_class)
90
+
91
+ cancel_where(ajClass: job_class.name)
92
+ end
93
+
94
+ def cancel_where(filters)
95
+ BatchCanceller.new(ActiveJob::Temporal.client).cancel_where(filters)
96
+ end
97
+
98
+ # UUID format regex (compliant with RFC 4122).
99
+ # Matches standard UUID format: 8-4-4-4-12 hexadecimal characters.
100
+ # @api private
101
+ UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
102
+
103
+ private
104
+
105
+ def validate_job_class!(job_class)
106
+ return if job_class.respond_to?(:name) && !job_class.name.to_s.empty?
107
+
108
+ raise ArgumentError, "job_class must be a named class"
109
+ end
110
+
111
+ # Validates that job_id is a valid UUID format.
112
+ #
113
+ # ActiveJob generates job IDs using SecureRandom.uuid, which produces RFC 4122
114
+ # compliant UUIDs. This validation prevents search query injection attacks by
115
+ # ensuring job_id contains only hexadecimal characters and hyphens, making it
116
+ # safe for direct use in Temporal queries.
117
+ #
118
+ # @param job_id [String] The job identifier to validate
119
+ # @raise [ArgumentError] if job_id is not a valid UUID format
120
+ # @api private
121
+ def validate_job_id!(job_id)
122
+ return if job_id.is_a?(String) && job_id.match?(UUID_REGEX)
123
+
124
+ raise ArgumentError,
125
+ "Invalid job_id format: expected UUID (e.g., '550e8400-e29b-41d4-a716-446655440000'), " \
126
+ "got: #{job_id.inspect}"
127
+ end
128
+
129
+ # Builds deterministic workflow ID from job class and job ID.
130
+ # @api private
131
+ def build_workflow_id(job_class, job_id)
132
+ WorkflowIdBuilder.new.build_from_job_class(job_class, job_id)
133
+ end
134
+
135
+ # Queries Temporal to determine the current state of the workflow.
136
+ # Checks running workflows first, then closed workflows.
137
+ #
138
+ # @param client [Temporalio::Client] The Temporal client instance
139
+ # @param job_class [Class] The ActiveJob class for fallback workflow ID generation
140
+ # @param job_id [String] The job identifier
141
+ # @return [Symbol] :running, :closed, or :not_found
142
+ # @raise [TemporalConnectionError] if connection to Temporal fails
143
+ def find_workflow(client, job_class, job_id)
144
+ # Query running workflows first
145
+ running_query = running_workflows_query(job_class, job_id)
146
+ running = client.list_workflows(running_query).first
147
+ return workflow_state(:running, running, job_class, job_id) if running
148
+
149
+ # Query closed workflows (completed, failed, cancelled, etc.)
150
+ closed_query = closed_workflows_query(job_class, job_id)
151
+ closed = client.list_workflows(closed_query).first
152
+ return workflow_state(:closed, closed, job_class, job_id) if closed
153
+
154
+ { status: :not_found, workflow_id: build_workflow_id(job_class, job_id) }
155
+ rescue StandardError => e
156
+ raise ActiveJob::Temporal::TemporalConnectionError,
157
+ "Failed to query Temporal workflows for job_id #{job_id}: #{e.message}"
158
+ end
159
+
160
+ def workflow_state(status, workflow_info, job_class, job_id)
161
+ workflow_id = if workflow_info.respond_to?(:id) && workflow_info.id
162
+ workflow_info.id
163
+ else
164
+ build_workflow_id(job_class, job_id)
165
+ end
166
+
167
+ { status: status, workflow_id: workflow_id }
168
+ end
169
+
170
+ # Builds Temporal query for running workflows by job class and job_id.
171
+ #
172
+ # Note: job_id is validated as a UUID before reaching this method, ensuring it
173
+ # contains only safe characters ([0-9a-fA-F-]) for direct query interpolation.
174
+ #
175
+ # @api private
176
+ def running_workflows_query(job_class, job_id)
177
+ workflow_search_query(job_class, job_id, "ExecutionStatus='Running'")
178
+ end
179
+
180
+ # Builds Temporal query for closed workflows by job class and job_id.
181
+ #
182
+ # Note: job_id is validated as a UUID before reaching this method, ensuring it
183
+ # contains only safe characters ([0-9a-fA-F-]) for direct query interpolation.
184
+ #
185
+ # @api private
186
+ def closed_workflows_query(job_class, job_id)
187
+ workflow_search_query(
188
+ job_class,
189
+ job_id,
190
+ "ExecutionStatus IN ('Completed', 'Failed', 'Cancelled', 'Terminated', 'TimedOut', 'ContinuedAsNew')"
191
+ )
192
+ end
193
+
194
+ def workflow_search_query(job_class, job_id, status_query)
195
+ "ajClass=#{VisibilityQuery.quote(job_class.name)} AND ajJobId=#{VisibilityQuery.quote(job_id)} " \
196
+ "AND #{status_query}"
197
+ end
198
+
199
+ # Logs cancellation request event.
200
+ # @api private
201
+ def log_cancellation_requested(job_class, job_id, workflow_id)
202
+ ActiveJob::Temporal::Logger.info(
203
+ "cancellation_requested",
204
+ workflow_id: workflow_id,
205
+ job_class: job_class.name,
206
+ job_id: job_id
207
+ )
208
+ end
209
+
210
+ # Logs event when attempting to cancel an already-completed workflow.
211
+ # @api private
212
+ def log_workflow_already_completed(job_class, job_id, workflow_id)
213
+ ActiveJob::Temporal::Logger.warn(
214
+ "cancellation_workflow_already_completed",
215
+ workflow_id: workflow_id,
216
+ job_class: job_class.name,
217
+ job_id: job_id,
218
+ status: "completed"
219
+ )
220
+ end
221
+
222
+ def log_audit_cancellation_requested(job_class, job_id, workflow_id)
223
+ ActiveJob::Temporal::AuditLog.record(
224
+ "job.cancelled",
225
+ workflow_id: workflow_id,
226
+ job_class: job_class.name,
227
+ job_id: job_id,
228
+ status: "requested"
229
+ )
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ require_relative "cancel/batch_canceller"
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "listen"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ # Watches TLS certificate files and runs a reload callback when they change.
8
+ class CertificateWatcher
9
+ DEFAULT_DEBOUNCE_SECONDS = 1.0
10
+
11
+ def self.paths_from_config(configuration)
12
+ [
13
+ configuration.tls_cert_path,
14
+ configuration.tls_key_path,
15
+ configuration.tls_server_root_ca_cert_path
16
+ ].compact.reject { |path| path.to_s.strip.empty? }
17
+ end
18
+
19
+ def initialize(paths:, reload_callback:, listener_factory: Listen, debounce_seconds: DEFAULT_DEBOUNCE_SECONDS)
20
+ @paths = paths.map { |path| File.expand_path(path) }.uniq
21
+ @reload_callback = reload_callback
22
+ @listener_factory = listener_factory
23
+ @debounce_seconds = debounce_seconds
24
+ @mutex = Mutex.new
25
+ @last_reload_at = nil
26
+ @listener = nil
27
+ end
28
+
29
+ def start
30
+ return self if @paths.empty? || @listener
31
+
32
+ @listener = @listener_factory.to(*directories) do |modified, added, removed|
33
+ handle_changes(modified + added + removed)
34
+ end
35
+ @listener.start
36
+ self
37
+ end
38
+
39
+ def stop
40
+ @listener&.stop
41
+ @listener = nil
42
+ end
43
+
44
+ def handle_changes(changed_paths)
45
+ return unless relevant_change?(changed_paths)
46
+ return if debounced?
47
+
48
+ @reload_callback.call
49
+ end
50
+
51
+ private
52
+
53
+ def directories
54
+ @directories ||= @paths.map { |path| File.dirname(path) }.uniq
55
+ end
56
+
57
+ def relevant_change?(changed_paths)
58
+ changed_paths.any? do |path|
59
+ @paths.include?(File.expand_path(path))
60
+ end
61
+ end
62
+
63
+ def debounced?
64
+ return false unless @debounce_seconds.positive?
65
+
66
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ @mutex.synchronize do
68
+ return true if @last_reload_at && (now - @last_reload_at) < @debounce_seconds
69
+
70
+ @last_reload_at = now
71
+ false
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end