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