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,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