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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module ActiveJob
|
|
6
|
+
module Temporal
|
|
7
|
+
module Schedulable
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def schedule(options = nil, **kwargs)
|
|
14
|
+
schedule_options = normalize_schedule_options(options, kwargs)
|
|
15
|
+
@temporal_schedule = ActiveJob::Temporal::Schedule.new(self, schedule_options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def temporal_schedule
|
|
19
|
+
@temporal_schedule
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_temporal_schedule(options = nil, **kwargs)
|
|
23
|
+
if options || kwargs.any?
|
|
24
|
+
schedule_options = merged_schedule_options(normalize_schedule_options(options, kwargs))
|
|
25
|
+
return ActiveJob::Temporal::Schedule.new(self, schedule_options).create
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, "No schedule defined for #{name}" unless temporal_schedule
|
|
29
|
+
|
|
30
|
+
temporal_schedule.create
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def temporal_schedule_handle(id: nil, client: ActiveJob::Temporal.client)
|
|
34
|
+
client.schedule_handle(id || temporal_schedule&.id || "ajsch:#{name}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def normalize_schedule_options(options, kwargs)
|
|
40
|
+
case options
|
|
41
|
+
when nil
|
|
42
|
+
kwargs
|
|
43
|
+
when Hash
|
|
44
|
+
options.merge(kwargs)
|
|
45
|
+
else
|
|
46
|
+
raise ArgumentError, "schedule options must be a Hash"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def merged_schedule_options(overrides)
|
|
51
|
+
return overrides unless temporal_schedule
|
|
52
|
+
|
|
53
|
+
temporal_schedule.options.merge(overrides)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
ActiveJob::Base.include(ActiveJob::Temporal::Schedulable) if defined?(ActiveJob::Base)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
require "temporalio/client/schedule"
|
|
5
|
+
|
|
6
|
+
require_relative "adapter"
|
|
7
|
+
require_relative "audit_log"
|
|
8
|
+
require_relative "job_payload_builder"
|
|
9
|
+
require_relative "logger"
|
|
10
|
+
require_relative "schedule_options"
|
|
11
|
+
require_relative "search_attributes"
|
|
12
|
+
require_relative "workflows/aj_workflow"
|
|
13
|
+
|
|
14
|
+
module ActiveJob
|
|
15
|
+
module Temporal
|
|
16
|
+
class Schedule
|
|
17
|
+
attr_reader :job_class
|
|
18
|
+
|
|
19
|
+
def initialize(job_class, options = {})
|
|
20
|
+
options = options.transform_keys(&:to_sym)
|
|
21
|
+
client = options.delete(:client)
|
|
22
|
+
config = options.delete(:config) || ActiveJob::Temporal.config
|
|
23
|
+
payload_builder = options.delete(:payload_builder)
|
|
24
|
+
|
|
25
|
+
@job_class = job_class
|
|
26
|
+
@options = ScheduleOptions.new(job_class, options)
|
|
27
|
+
@client = client
|
|
28
|
+
@config = config
|
|
29
|
+
@payload_builder = payload_builder || JobPayloadBuilder.new(config)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def id = @options.id
|
|
33
|
+
|
|
34
|
+
def cron_expressions = @options.cron_expressions
|
|
35
|
+
|
|
36
|
+
def timezone = @options.timezone
|
|
37
|
+
|
|
38
|
+
def overlap_policy = @options.overlap_policy
|
|
39
|
+
|
|
40
|
+
def args = @options.args
|
|
41
|
+
|
|
42
|
+
def queue = @options.queue
|
|
43
|
+
|
|
44
|
+
def paused = @options.paused
|
|
45
|
+
|
|
46
|
+
def trigger_immediately = @options.trigger_immediately
|
|
47
|
+
|
|
48
|
+
def cron
|
|
49
|
+
@options.cron
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create
|
|
53
|
+
temporal_schedule = to_temporal_schedule
|
|
54
|
+
handle = client.create_schedule(
|
|
55
|
+
id,
|
|
56
|
+
temporal_schedule,
|
|
57
|
+
trigger_immediately: trigger_immediately,
|
|
58
|
+
memo: nil,
|
|
59
|
+
search_attributes: nil
|
|
60
|
+
)
|
|
61
|
+
log_created(task_queue: temporal_schedule.action.task_queue, duplicate: false)
|
|
62
|
+
handle
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
raise unless schedule_already_running?(e)
|
|
65
|
+
|
|
66
|
+
handle_existing_schedule
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def handle
|
|
70
|
+
client.schedule_handle(id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def options
|
|
74
|
+
@options.to_h
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_temporal_schedule
|
|
78
|
+
Temporalio::Client::Schedule.new(
|
|
79
|
+
action: schedule_action,
|
|
80
|
+
spec: schedule_spec,
|
|
81
|
+
policy: schedule_policy,
|
|
82
|
+
state: schedule_state
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def client
|
|
89
|
+
@client || ActiveJob::Temporal.client
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def schedule_action
|
|
93
|
+
job = build_job
|
|
94
|
+
workflow_id = workflow_id_prefix
|
|
95
|
+
payload = @payload_builder.build(job, encryption_context: encryption_context_for(workflow_id))
|
|
96
|
+
payload = annotate_scheduled_payload(payload, workflow_id)
|
|
97
|
+
|
|
98
|
+
Temporalio::Client::Schedule::Action::StartWorkflow.new(
|
|
99
|
+
Workflows::AjWorkflow,
|
|
100
|
+
payload,
|
|
101
|
+
id: workflow_id,
|
|
102
|
+
task_queue: Adapter.resolve_task_queue(job, config: @config),
|
|
103
|
+
search_attributes: search_attributes_for(job)
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def schedule_spec
|
|
108
|
+
Temporalio::Client::Schedule::Spec.new(
|
|
109
|
+
cron_expressions: cron_expressions,
|
|
110
|
+
time_zone_name: timezone
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def schedule_policy
|
|
115
|
+
Temporalio::Client::Schedule::Policy.new(overlap: @options.temporal_overlap_policy)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def schedule_state
|
|
119
|
+
Temporalio::Client::Schedule::State.new(
|
|
120
|
+
paused: paused,
|
|
121
|
+
note: "ActiveJob Temporal schedule for #{job_class.name}"
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_job
|
|
126
|
+
job = job_class.new(*args)
|
|
127
|
+
job.job_id = id if job.respond_to?(:job_id=)
|
|
128
|
+
job.queue_name = queue if queue && job.respond_to?(:queue_name=)
|
|
129
|
+
job
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def search_attributes_for(job)
|
|
133
|
+
return unless @config.respond_to?(:enable_search_attributes) && @config.enable_search_attributes
|
|
134
|
+
|
|
135
|
+
SearchAttributes.for(job)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def workflow_id_prefix
|
|
139
|
+
"ajschwf:#{id}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def encryption_context_for(workflow_id)
|
|
143
|
+
{ namespace: @config.namespace, workflow_id: workflow_id }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def annotate_scheduled_payload(payload, workflow_id)
|
|
147
|
+
payload.merge(
|
|
148
|
+
schedule_id: id,
|
|
149
|
+
schedule_workflow_id_prefix: workflow_id,
|
|
150
|
+
payload_encryption_context: encryption_context_for(workflow_id)
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def schedule_already_running?(error)
|
|
155
|
+
defined?(Temporalio::Error::ScheduleAlreadyRunningError) &&
|
|
156
|
+
error.is_a?(Temporalio::Error::ScheduleAlreadyRunningError)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def handle_existing_schedule
|
|
160
|
+
existing_handle = handle
|
|
161
|
+
log_created(task_queue: Adapter.resolve_task_queue(build_job, config: @config), duplicate: true)
|
|
162
|
+
existing_handle
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def log_created(task_queue:, duplicate:)
|
|
166
|
+
attributes = {
|
|
167
|
+
schedule_id: id,
|
|
168
|
+
job_class: job_class.name,
|
|
169
|
+
cron: cron,
|
|
170
|
+
timezone: timezone,
|
|
171
|
+
overlap_policy: overlap_policy,
|
|
172
|
+
task_queue: task_queue,
|
|
173
|
+
duplicate: duplicate
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
Logger.log_event("schedule_created", attributes)
|
|
177
|
+
AuditLog.record("schedule.created", attributes)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "temporalio/client/schedule"
|
|
4
|
+
|
|
5
|
+
module ActiveJob
|
|
6
|
+
module Temporal
|
|
7
|
+
class ScheduleOptions
|
|
8
|
+
OVERLAP_POLICIES = {
|
|
9
|
+
skip: Temporalio::Client::Schedule::OverlapPolicy::SKIP,
|
|
10
|
+
buffer: Temporalio::Client::Schedule::OverlapPolicy::BUFFER_ONE,
|
|
11
|
+
buffer_one: Temporalio::Client::Schedule::OverlapPolicy::BUFFER_ONE,
|
|
12
|
+
buffer_all: Temporalio::Client::Schedule::OverlapPolicy::BUFFER_ALL,
|
|
13
|
+
allow_all: Temporalio::Client::Schedule::OverlapPolicy::ALLOW_ALL,
|
|
14
|
+
cancel_other: Temporalio::Client::Schedule::OverlapPolicy::CANCEL_OTHER,
|
|
15
|
+
terminate_other: Temporalio::Client::Schedule::OverlapPolicy::TERMINATE_OTHER
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :job_class, :id, :cron_expressions, :timezone, :overlap_policy, :args, :queue,
|
|
19
|
+
:paused, :trigger_immediately
|
|
20
|
+
|
|
21
|
+
def initialize(job_class, options)
|
|
22
|
+
options = options.transform_keys(&:to_sym)
|
|
23
|
+
|
|
24
|
+
@job_class = job_class
|
|
25
|
+
@id = normalize_id(options.fetch(:id, default_id))
|
|
26
|
+
@cron_expressions = normalize_cron(options.fetch(:cron))
|
|
27
|
+
@timezone = normalize_timezone(options.fetch(:timezone, "UTC"))
|
|
28
|
+
@overlap_policy = normalize_overlap_policy(options.fetch(:overlap_policy, :skip))
|
|
29
|
+
@args = normalize_args(options.fetch(:args, []))
|
|
30
|
+
@queue = options[:queue]&.to_s
|
|
31
|
+
@paused = options.fetch(:paused, false)
|
|
32
|
+
@trigger_immediately = options.fetch(:trigger_immediately, false)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cron
|
|
36
|
+
return cron_expressions.first if cron_expressions.one?
|
|
37
|
+
|
|
38
|
+
cron_expressions
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def temporal_overlap_policy
|
|
42
|
+
OVERLAP_POLICIES.fetch(overlap_policy)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
{
|
|
47
|
+
id: id,
|
|
48
|
+
cron: cron,
|
|
49
|
+
timezone: timezone,
|
|
50
|
+
overlap_policy: overlap_policy,
|
|
51
|
+
args: args,
|
|
52
|
+
queue: queue,
|
|
53
|
+
paused: paused,
|
|
54
|
+
trigger_immediately: trigger_immediately
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def default_id
|
|
61
|
+
"ajsch:#{job_class.name}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalize_id(value)
|
|
65
|
+
value = value.to_s.strip
|
|
66
|
+
raise ArgumentError, "schedule id must be present" if value.empty?
|
|
67
|
+
|
|
68
|
+
value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_cron(value)
|
|
72
|
+
expressions = value.is_a?(Array) ? value : [value]
|
|
73
|
+
expressions.map do |expression|
|
|
74
|
+
expression = expression.to_s.strip
|
|
75
|
+
raise ArgumentError, "cron must be present" if expression.empty?
|
|
76
|
+
|
|
77
|
+
expression
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_timezone(value)
|
|
82
|
+
value = value.to_s.strip
|
|
83
|
+
raise ArgumentError, "timezone must be present" if value.empty?
|
|
84
|
+
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalize_overlap_policy(value)
|
|
89
|
+
policy = value.to_sym
|
|
90
|
+
return policy if OVERLAP_POLICIES.key?(policy)
|
|
91
|
+
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"Unsupported overlap_policy #{value.inspect}. " \
|
|
94
|
+
"Supported policies are: #{OVERLAP_POLICIES.keys.join(', ')}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalize_args(value)
|
|
98
|
+
return [] if value.nil?
|
|
99
|
+
return value if value.is_a?(Array)
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "args must be an Array"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Temporal
|
|
5
|
+
# Builds Temporal search attributes for job metadata.
|
|
6
|
+
#
|
|
7
|
+
# This module constructs typed search attributes that enable filtering and searching
|
|
8
|
+
# workflows in the Temporal UI and via the Temporal API. Attributes include job class,
|
|
9
|
+
# queue name, job ID, enqueued timestamp, and optionally tenant ID.
|
|
10
|
+
#
|
|
11
|
+
# @note Pre-Registration Required
|
|
12
|
+
# Search attributes MUST be pre-registered in the Temporal cluster with the correct
|
|
13
|
+
# type (KEYWORD, TIME, INTEGER, etc.) before use. Unregistered attributes will cause
|
|
14
|
+
# workflow start failures. Use the Temporal CLI to register attributes:
|
|
15
|
+
#
|
|
16
|
+
# tctl admin cluster add-search-attributes --name ajClass --type Keyword
|
|
17
|
+
# tctl admin cluster add-search-attributes --name ajQueue --type Keyword
|
|
18
|
+
# tctl admin cluster add-search-attributes --name ajJobId --type Keyword
|
|
19
|
+
# tctl admin cluster add-search-attributes --name ajEnqueuedAt --type Datetime
|
|
20
|
+
# tctl admin cluster add-search-attributes --name ajTenantId --type Int
|
|
21
|
+
# tctl admin cluster add-search-attributes --name ajTags --type KeywordList
|
|
22
|
+
#
|
|
23
|
+
# @note Tenant ID Extraction
|
|
24
|
+
# The module automatically extracts tenant_id from the first job argument if it
|
|
25
|
+
# responds to the `tenant_id` method. This supports multi-tenant architectures
|
|
26
|
+
# where jobs operate on tenant-specific data.
|
|
27
|
+
#
|
|
28
|
+
# @example Created attributes
|
|
29
|
+
# - ajClass: KEYWORD (job class name)
|
|
30
|
+
# - ajQueue: KEYWORD (queue name)
|
|
31
|
+
# - ajJobId: KEYWORD (unique job ID)
|
|
32
|
+
# - ajEnqueuedAt: TIME (enqueue timestamp)
|
|
33
|
+
# - ajTenantId: INTEGER (optional, if first argument responds to :tenant_id)
|
|
34
|
+
# - ajTags: KEYWORD_LIST (optional, if tags are configured)
|
|
35
|
+
#
|
|
36
|
+
# @example Querying with search attributes (Temporal CLI)
|
|
37
|
+
# # Find all jobs in the "mailers" queue
|
|
38
|
+
# tctl workflow list --query "ajQueue='mailers'"
|
|
39
|
+
#
|
|
40
|
+
# # Find specific job by ID
|
|
41
|
+
# tctl workflow list --query "ajJobId='abc-123'"
|
|
42
|
+
#
|
|
43
|
+
# # Find all tenant jobs enqueued today
|
|
44
|
+
# tctl workflow list --query "ajTenantId=42 AND ajEnqueuedAt > '2025-10-31T00:00:00Z'"
|
|
45
|
+
#
|
|
46
|
+
# # Find jobs tagged as urgent
|
|
47
|
+
# tctl workflow list --query "ajTags='urgent'"
|
|
48
|
+
#
|
|
49
|
+
# @see https://docs.temporal.io/visibility Search Attributes documentation
|
|
50
|
+
module SearchAttributes
|
|
51
|
+
extend self
|
|
52
|
+
|
|
53
|
+
SEARCH_ATTRIBUTE_KEY_DEFINITIONS = {
|
|
54
|
+
aj_class: ["ajClass", :KEYWORD],
|
|
55
|
+
aj_queue: ["ajQueue", :KEYWORD],
|
|
56
|
+
aj_job_id: ["ajJobId", :KEYWORD],
|
|
57
|
+
aj_enqueued_at: ["ajEnqueuedAt", :TIME],
|
|
58
|
+
aj_tenant_id: ["ajTenantId", :INTEGER],
|
|
59
|
+
aj_tags: ["ajTags", :KEYWORD_LIST]
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
# Builds a Temporalio::SearchAttributes object for a job.
|
|
63
|
+
#
|
|
64
|
+
# Creates typed search attribute keys and populates them with job metadata.
|
|
65
|
+
# If `enable_search_attributes` is disabled in configuration, this should
|
|
66
|
+
# still be called but may return an empty attributes object (handled by caller).
|
|
67
|
+
#
|
|
68
|
+
# @param job [ActiveJob::Base] The job instance to extract metadata from
|
|
69
|
+
#
|
|
70
|
+
# @return [Temporalio::SearchAttributes] Typed search attributes for Temporal
|
|
71
|
+
#
|
|
72
|
+
# @raise [Temporalio::Error::WorkflowUpdateFailedError] if attributes are not pre-registered in Temporal
|
|
73
|
+
# @raise [ArgumentError] if job is nil
|
|
74
|
+
# @raise [TypeError] if job does not respond to required methods
|
|
75
|
+
# @raise [NameError] if Temporalio::SearchAttributes is not defined
|
|
76
|
+
#
|
|
77
|
+
# @example Basic usage
|
|
78
|
+
# job = MyJob.new
|
|
79
|
+
# attributes = SearchAttributes.for(job)
|
|
80
|
+
# # attributes contains ajClass, ajQueue, ajJobId, ajEnqueuedAt
|
|
81
|
+
#
|
|
82
|
+
# @example With tenant ID (automatic extraction)
|
|
83
|
+
# class TenantJob < ApplicationJob
|
|
84
|
+
# def perform(tenant)
|
|
85
|
+
# # tenant.tenant_id is extracted automatically
|
|
86
|
+
# end
|
|
87
|
+
# end
|
|
88
|
+
# tenant = Tenant.find(42)
|
|
89
|
+
# job = TenantJob.new(tenant)
|
|
90
|
+
# attributes = SearchAttributes.for(job)
|
|
91
|
+
# # attributes also contains ajTenantId: 42
|
|
92
|
+
#
|
|
93
|
+
# @example Without tenant ID
|
|
94
|
+
# job = MyJob.new("plain_string_arg")
|
|
95
|
+
# attributes = SearchAttributes.for(job)
|
|
96
|
+
# # attributes does NOT contain ajTenantId (first arg doesn't respond to tenant_id)
|
|
97
|
+
def for(job)
|
|
98
|
+
# Create Temporal search attributes with typed keys
|
|
99
|
+
attributes = Temporalio::SearchAttributes.new
|
|
100
|
+
|
|
101
|
+
# Define and set core attributes
|
|
102
|
+
set_core_attributes(attributes, job)
|
|
103
|
+
|
|
104
|
+
# Add tenant ID if available
|
|
105
|
+
add_tenant_attribute(attributes, job)
|
|
106
|
+
|
|
107
|
+
# Add job tags if available
|
|
108
|
+
add_tags_attribute(attributes, job)
|
|
109
|
+
|
|
110
|
+
attributes
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Sets core search attributes (ajClass, ajQueue, ajJobId, ajEnqueuedAt).
|
|
116
|
+
# @api private
|
|
117
|
+
def set_core_attributes(attributes, job)
|
|
118
|
+
attributes[search_attribute_key(:aj_class)] = job.class.name
|
|
119
|
+
attributes[search_attribute_key(:aj_queue)] = job.queue_name || "default"
|
|
120
|
+
attributes[search_attribute_key(:aj_job_id)] = job.job_id
|
|
121
|
+
attributes[search_attribute_key(:aj_enqueued_at)] = Time.now
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Adds tenant ID attribute if first argument responds to tenant_id.
|
|
125
|
+
# @api private
|
|
126
|
+
def add_tenant_attribute(attributes, job)
|
|
127
|
+
tenant_id = extract_tenant_id(job.arguments)
|
|
128
|
+
return unless tenant_id
|
|
129
|
+
|
|
130
|
+
attributes[search_attribute_key(:aj_tenant_id)] = tenant_id
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def add_tags_attribute(attributes, job)
|
|
134
|
+
tags = extract_tags(job)
|
|
135
|
+
return if tags.empty?
|
|
136
|
+
|
|
137
|
+
attributes[search_attribute_key(:aj_tags)] = tags
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def search_attribute_key(key)
|
|
141
|
+
search_attribute_keys.fetch(key)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def search_attribute_keys
|
|
145
|
+
@search_attribute_keys ||= SEARCH_ATTRIBUTE_KEY_DEFINITIONS.each_with_object({}) do |(key, (name, type)), keys|
|
|
146
|
+
keys[key] = create_key(name, type).freeze
|
|
147
|
+
end.freeze
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Creates a typed Temporal search attribute key.
|
|
151
|
+
# @api private
|
|
152
|
+
def create_key(name, type)
|
|
153
|
+
type_constant = Temporalio::SearchAttributes::IndexedValueType.const_get(type)
|
|
154
|
+
Temporalio::SearchAttributes::Key.new(name, type_constant)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Extracts tenant_id from first argument if it responds to tenant_id method.
|
|
158
|
+
# @api private
|
|
159
|
+
def extract_tenant_id(arguments)
|
|
160
|
+
first_argument = arguments&.first
|
|
161
|
+
return unless first_argument.respond_to?(:tenant_id)
|
|
162
|
+
|
|
163
|
+
first_argument.tenant_id
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def extract_tags(job)
|
|
167
|
+
return [] unless job.respond_to?(:temporal_tags)
|
|
168
|
+
|
|
169
|
+
job.temporal_tags || []
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "temporalio/error"
|
|
4
|
+
require_relative "visibility_query"
|
|
5
|
+
require_relative "workflow_id_builder"
|
|
6
|
+
|
|
7
|
+
module ActiveJob
|
|
8
|
+
module Temporal
|
|
9
|
+
module SignalQuery
|
|
10
|
+
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
|
|
11
|
+
JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
12
|
+
HANDLER_NAME_PATTERN = /\A[a-zA-Z_]\w*\z/
|
|
13
|
+
DEFAULT_REJECT_CONDITION = Object.new.freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def signal(job_class, job_id, signal_name, *)
|
|
17
|
+
validate_job_class!(job_class)
|
|
18
|
+
validate_job_id!(job_id)
|
|
19
|
+
handler_name = normalize_handler_name!(signal_name, "signal")
|
|
20
|
+
|
|
21
|
+
with_running_workflow_handle(job_class, job_id) do |handle|
|
|
22
|
+
handle.signal(handler_name, *)
|
|
23
|
+
end
|
|
24
|
+
rescue ArgumentError, ActiveJob::Temporal::WorkflowNotFoundError
|
|
25
|
+
raise
|
|
26
|
+
rescue Temporalio::Error::RPCError => e
|
|
27
|
+
raise ActiveJob::Temporal::TemporalConnectionError,
|
|
28
|
+
"Failed to signal Temporal workflow for job_id #{job_id}: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def query(job_class, job_id, query_name, *args, reject_condition: DEFAULT_REJECT_CONDITION)
|
|
32
|
+
validate_job_class!(job_class)
|
|
33
|
+
validate_job_id!(job_id)
|
|
34
|
+
handler_name = normalize_handler_name!(query_name, "query")
|
|
35
|
+
|
|
36
|
+
with_running_workflow_handle(job_class, job_id) do |handle|
|
|
37
|
+
query_workflow(handle, handler_name, args, reject_condition)
|
|
38
|
+
end
|
|
39
|
+
rescue ArgumentError, ActiveJob::Temporal::WorkflowNotFoundError
|
|
40
|
+
raise
|
|
41
|
+
rescue Temporalio::Error::RPCError => e
|
|
42
|
+
raise ActiveJob::Temporal::TemporalConnectionError,
|
|
43
|
+
"Failed to query Temporal workflow for job_id #{job_id}: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def update(job_class, job_id, update_name, *)
|
|
47
|
+
validate_job_class!(job_class)
|
|
48
|
+
validate_job_id!(job_id)
|
|
49
|
+
handler_name = normalize_handler_name!(update_name, "update")
|
|
50
|
+
|
|
51
|
+
with_running_workflow_handle(job_class, job_id) do |handle|
|
|
52
|
+
handle.execute_update(handler_name, *)
|
|
53
|
+
end
|
|
54
|
+
rescue ArgumentError, ActiveJob::Temporal::WorkflowNotFoundError
|
|
55
|
+
raise
|
|
56
|
+
rescue Temporalio::Error::RPCError => e
|
|
57
|
+
raise ActiveJob::Temporal::TemporalConnectionError,
|
|
58
|
+
"Failed to update Temporal workflow for job_id #{job_id}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def with_running_workflow_handle(job_class, job_id)
|
|
64
|
+
client = ActiveJob::Temporal.client
|
|
65
|
+
default_handle = client.workflow_handle(default_workflow_id(job_class, job_id), run_id: nil)
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
return yield(default_handle)
|
|
69
|
+
rescue Temporalio::Error::RPCError => e
|
|
70
|
+
raise unless rpc_not_found?(e)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
workflow_reference = find_running_workflow_reference(client, job_class, job_id)
|
|
74
|
+
raise workflow_not_found(job_id) unless workflow_reference
|
|
75
|
+
|
|
76
|
+
fallback_handle = client.workflow_handle(
|
|
77
|
+
workflow_reference.fetch(:workflow_id),
|
|
78
|
+
run_id: workflow_reference[:run_id]
|
|
79
|
+
)
|
|
80
|
+
begin
|
|
81
|
+
yield(fallback_handle)
|
|
82
|
+
rescue Temporalio::Error::RPCError => e
|
|
83
|
+
raise workflow_not_found(job_id) if rpc_not_found?(e)
|
|
84
|
+
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def query_workflow(handle, handler_name, args, reject_condition)
|
|
90
|
+
if reject_condition.equal?(DEFAULT_REJECT_CONDITION)
|
|
91
|
+
handle.query(handler_name, *args)
|
|
92
|
+
else
|
|
93
|
+
handle.query(handler_name, *args, reject_condition: reject_condition)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def find_running_workflow_reference(client, job_class, job_id)
|
|
98
|
+
workflow = client.list_workflows(workflow_search_query(job_class, job_id)).first
|
|
99
|
+
return unless workflow
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
workflow_id: workflow.id,
|
|
103
|
+
run_id: workflow.respond_to?(:run_id) ? workflow.run_id : nil
|
|
104
|
+
}
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
return nil if rpc_invalid_argument?(e)
|
|
107
|
+
|
|
108
|
+
raise
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_job_class!(job_class)
|
|
112
|
+
unless job_class.is_a?(Class) && !job_class.name.to_s.empty?
|
|
113
|
+
raise ArgumentError, "job_class must be a named class"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return if job_class.name.match?(JOB_CLASS_NAME_PATTERN)
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "job_class must have a valid constant name"
|
|
119
|
+
end
|
|
120
|
+
|
|
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
|
+
def normalize_handler_name!(name, handler_type)
|
|
130
|
+
handler_name = name.to_s
|
|
131
|
+
return handler_name if handler_name.match?(HANDLER_NAME_PATTERN)
|
|
132
|
+
|
|
133
|
+
raise ArgumentError, "#{handler_type} names must start with a letter or underscore and contain word chars"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def workflow_search_query(job_class, job_id)
|
|
137
|
+
"ajClass=#{VisibilityQuery.quote(job_class.name)} AND ajJobId=#{VisibilityQuery.quote(job_id)} " \
|
|
138
|
+
"AND ExecutionStatus='Running'"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def default_workflow_id(job_class, job_id)
|
|
142
|
+
WorkflowIdBuilder.new.build_from_job_class(job_class, job_id)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def workflow_not_found(job_id)
|
|
146
|
+
ActiveJob::Temporal::WorkflowNotFoundError.new(
|
|
147
|
+
"No running workflow found for job_id #{job_id}. The job may have completed or never existed."
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def rpc_not_found?(error)
|
|
152
|
+
error.code == Temporalio::Error::RPCError::Code::NOT_FOUND
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def rpc_invalid_argument?(error)
|
|
156
|
+
error.code == Temporalio::Error::RPCError::Code::INVALID_ARGUMENT
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|