activejob-temporal 0.2.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9ea3a46138c59ad643942b6de5431013e9679812845aa2df1e465dfd368120c
4
- data.tar.gz: 63232ab58e9ae604053cf68751ba803f5a66e5ad416f6762ad5ee74ea7eef4a5
3
+ metadata.gz: 79166555209f6e6cf73641c9415feac14e6cffad299940c94ebcba0568c3cfdd
4
+ data.tar.gz: 4e3f843f44c93c4ce383415b32616f7770c407dec314dbf5e305a820e9095480
5
5
  SHA512:
6
- metadata.gz: 02c60806fecabadd3744ca003ab45a7ef4331c7f48a361bf3045d22f18f2386278b9c47b729f8d8bd4ad7d17605b3526955f0bd0bf4c2e3a75a9639ec4998620
7
- data.tar.gz: 4b3a8d2a876fc3874be63c8c400d7a991c4f4a89d3579c56941eff32e1530a6658bdf338570ffa0c5705872618b39044c5c324646d01c4bbf8317f37def25b04
6
+ metadata.gz: f1dd16daf2739bbefdb568eb08423ca071fdc4ef96cee1b775d1fbb383728abec3c226d4f52d1ee633db6b7048ae5e6d68c0842a72dedf40cb03fdb4fe7d41ca
7
+ data.tar.gz: 9c17b2bf542f9990b68db645b32edd0176d8250309e70577d583f7d2102ed5cf176000ad5a5384a51c5ac4aa58fd18af161c226f389af413d66a0b00f01c84bd
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-08-06
11
+
12
+ ### Fixed
13
+ - `api_key_watch` and `tls_cert_watch` now fire on Kubernetes projected and secret volumes: kubelet rotates them atomically by swapping a `..data` symlink into a new timestamped directory, so the watched file's own path never appears in the change events and the previous exact-path filter dropped every rotation. Any change inside a watched file's directory now counts as relevant.
14
+
15
+ ## [0.3.0] - 2026-08-06
16
+
17
+ ### Added
18
+ - Bearer-token authentication: `api_key` (sent as the `Authorization: Bearer` header) and `api_key_file`, read through the same hardened path as TLS files — for example a projected Kubernetes ServiceAccount token. `api_key_watch` watches the token file and applies rotated tokens to the live connection via the new `ActiveJob::Temporal.refresh_api_key!` (no reconnect); enqueue-side processes using a rotating token file should call `refresh_api_key!` on their own schedule. `api_key` is redacted from `Configuration#inspect`. Caution: the Temporal SDK enables TLS when an API key is set and `tls` is nil — set `tls = false` explicitly for plaintext in-cluster servers (see the worker setup guide).
19
+ - Custom activity hosting: `worker_activities` registers additional activity classes (or class names) on the worker — for example activities invoked by workflows owned by another service, where only the activity name and JSON payloads travel over the wire. `worker_activejob_workloads = false` turns off the built-in ActiveJob workflows and activities for activities-only workers. Entries must be `Temporalio::Activity::Definition` subclasses; anything else fails worker startup with a `WorkerRegistrationError`.
20
+ - `graceful_shutdown_period` is passed to the Temporal worker, so a shutting-down worker lets running activities finish before their tasks are cancelled instead of cancelling them immediately.
21
+ - Configuration validation for the new attributes: `api_key_file` must be a readable regular file, `api_key_watch` requires `api_key_file`, disabling `worker_activejob_workloads` requires `worker_activities`, and `graceful_shutdown_period` must be >= 0.
22
+
23
+ ### Changed
24
+ - Allow temporalio 1.5 and 1.6 (dependency constraint is now `>= 1.4.0, < 1.7`); both added to the SDK contract-test matrix. Note for consumers upgrading within the range: temporalio 1.6 enables gzip gRPC transport compression by default — opt out with `grpc_compression` if an intermediary cannot handle it.
25
+
10
26
  ## [0.2.0] - 2026-08-06
11
27
 
12
28
  ### Security
@@ -37,7 +37,7 @@ Gem::Specification.new do |spec|
37
37
  spec.add_dependency "activemodel", ">= 7.2", "< 9"
38
38
  spec.add_dependency "concurrent-ruby", "~> 1.1"
39
39
  spec.add_dependency "globalid", ">= 0.3"
40
- spec.add_dependency "temporalio", ">= 1.4.0", "< 1.5"
40
+ spec.add_dependency "temporalio", ">= 1.4.0", "< 1.7"
41
41
 
42
42
  spec.add_development_dependency "benchmark-ips", "~> 2.14"
43
43
  spec.add_development_dependency "github_changelog_generator", "~> 1.18"
data/bin/temporal-worker CHANGED
@@ -183,23 +183,24 @@ worker_health = ActiveJob::Temporal::WorkerHealth.new(
183
183
  max_concurrent_workflows: max_concurrent_workflows
184
184
  )
185
185
 
186
+ begin
187
+ registrations = ActiveJob::Temporal::WorkerRegistrations.resolve(config)
188
+ rescue ActiveJob::Temporal::WorkerRegistrationError => e
189
+ warn "Error: #{e.message}"
190
+ exit(1)
191
+ end
192
+
186
193
  worker = Temporalio::Worker.new(
187
194
  client: client,
188
195
  task_queue: task_queue,
189
- workflows: [
190
- ActiveJob::Temporal::Workflows::AjWorkflow,
191
- ActiveJob::Temporal::Workflows::DeadLetterWorkflow
192
- ],
193
- activities: [
194
- ActiveJob::Temporal::Activities::RateLimitActivity,
195
- ActiveJob::Temporal::Activities::DependencyStatusActivity,
196
- ActiveJob::Temporal::Activities::AjRunnerActivity
197
- ],
196
+ workflows: registrations.workflows,
197
+ activities: registrations.activities,
198
198
  tuner: Temporalio::Worker::Tuner.create_fixed(
199
199
  workflow_slots: max_concurrent_workflows,
200
200
  activity_slots: max_concurrent_activities,
201
201
  local_activity_slots: max_concurrent_activities
202
202
  ),
203
+ graceful_shutdown_period: config.graceful_shutdown_period,
203
204
  interceptors: [worker_health]
204
205
  )
205
206
 
@@ -268,6 +269,17 @@ if config.tls_cert_watch
268
269
  ).start
269
270
  end
270
271
 
272
+ # Token rotation mutates the live connection (the SDK sends the header per-RPC from the
273
+ # connection's stored options) - no client rebuild, unlike certificate rotation above.
274
+ # Configuration validation guarantees api_key_file is set when api_key_watch is on.
275
+ api_key_watcher = nil
276
+ if config.api_key_watch
277
+ api_key_watcher = ActiveJob::Temporal::CertificateWatcher.new(
278
+ paths: [config.api_key_file],
279
+ reload_callback: -> { ActiveJob::Temporal.refresh_api_key! }
280
+ ).start
281
+ end
282
+
271
283
  begin
272
284
  worker_health.mark_started!
273
285
  Temporalio::Worker.run_all(
@@ -279,6 +291,7 @@ rescue Interrupt
279
291
  ensure
280
292
  worker_health.mark_stopped!
281
293
  certificate_watcher&.stop
294
+ api_key_watcher&.stop
282
295
  reload_signal_queue.close
283
296
  reload_signal_thread.join(1)
284
297
  health_check_server&.stop
@@ -89,9 +89,14 @@ module ActiveJob
89
89
  end
90
90
  end
91
91
 
92
+ # Kubernetes projected and secret volumes rotate atomically: kubelet writes a new timestamped
93
+ # directory and swaps a `..data` symlink, so the watched file's own path never appears in the
94
+ # change events. Any change inside a watched file's directory therefore counts as relevant;
95
+ # the debounce absorbs the multi-event burst a rotation produces.
92
96
  def relevant_change?(changed_paths)
93
97
  changed_paths.any? do |path|
94
- @paths.include?(File.expand_path(path))
98
+ expanded = File.expand_path(path)
99
+ @paths.include?(expanded) || directories.any? { |dir| expanded.start_with?("#{dir}/") }
95
100
  end
96
101
  end
97
102
 
@@ -116,16 +116,33 @@ module ActiveJob
116
116
  )
117
117
  end
118
118
 
119
- # Builds connection keyword arguments (including TLS options).
119
+ # Builds connection keyword arguments (TLS options and API key).
120
120
  # @api private
121
121
  def connection_kwargs(configuration)
122
+ kwargs = {}
123
+
122
124
  tls = tls_options(configuration)
123
- return {} if tls.nil?
125
+ kwargs[:tls] = tls unless tls.nil?
126
+
127
+ api_key = resolve_api_key(configuration)
128
+ kwargs[:api_key] = api_key if api_key
124
129
 
125
- { tls: tls }
130
+ kwargs
126
131
  end
127
132
  private_class_method :connection_kwargs
128
133
 
134
+ # Resolves the API key: an explicit api_key wins, otherwise api_key_file is read - at
135
+ # connection build time, and again by {ActiveJob::Temporal.refresh_api_key!} when the
136
+ # token file rotates.
137
+ # @api private
138
+ def resolve_api_key(configuration)
139
+ inline = configuration.api_key if configuration.respond_to?(:api_key)
140
+ return inline unless inline.nil? || inline.to_s.strip.empty?
141
+
142
+ path = configuration.api_key_file if configuration.respond_to?(:api_key_file)
143
+ read_tls_file(path)&.strip.presence
144
+ end
145
+
129
146
  # Extracts TLS options from config or environment variables.
130
147
  # @api private
131
148
  def tls_options(configuration)
@@ -136,6 +136,29 @@ module ActiveJob
136
136
  description: "Signal used by workers to reload TLS certificates manually"
137
137
  },
138
138
 
139
+ api_key: {
140
+ default: nil,
141
+ env_var: "ACTIVEJOB_TEMPORAL_API_KEY",
142
+ type: :string,
143
+ description: "Optional API key sent as the Authorization bearer token"
144
+ },
145
+
146
+ api_key_file: {
147
+ default: nil,
148
+ env_var: "ACTIVEJOB_TEMPORAL_API_KEY_FILE",
149
+ type: :string,
150
+ description: "Optional file read for the API key when the client is built, for example a " \
151
+ "projected Kubernetes ServiceAccount token. An explicit api_key takes precedence"
152
+ },
153
+
154
+ api_key_watch: {
155
+ default: false,
156
+ env_var: "ACTIVEJOB_TEMPORAL_API_KEY_WATCH",
157
+ type: :boolean,
158
+ description: "Watch api_key_file and refresh the token when it changes (kubelet rotates " \
159
+ "projected tokens atomically, long before they expire)"
160
+ },
161
+
139
162
  priority_task_queues: {
140
163
  default: -> { {} },
141
164
  type: :hash,
@@ -385,6 +408,29 @@ module ActiveJob
385
408
  env_var: "ACTIVEJOB_TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASKS",
386
409
  type: :integer,
387
410
  description: "Maximum concurrent workflow tasks per worker"
411
+ },
412
+
413
+ worker_activities: {
414
+ default: -> { [] },
415
+ type: :array,
416
+ description: "Additional activity classes (or class names) the worker registers, for example " \
417
+ "activities invoked by workflows owned by another service"
418
+ },
419
+
420
+ worker_activejob_workloads: {
421
+ default: true,
422
+ env_var: "ACTIVEJOB_TEMPORAL_WORKER_ACTIVEJOB_WORKLOADS",
423
+ type: :boolean,
424
+ description: "Register the built-in ActiveJob workflows and activities on the worker. Disable " \
425
+ "for workers that only host worker_activities"
426
+ },
427
+
428
+ graceful_shutdown_period: {
429
+ default: 0.0,
430
+ env_var: "ACTIVEJOB_TEMPORAL_GRACEFUL_SHUTDOWN_PERIOD",
431
+ type: :float,
432
+ description: "Seconds a shutting-down worker lets running activities finish before their " \
433
+ "tasks are cancelled (0 cancels immediately, matching the SDK default)"
388
434
  }
389
435
  }.freeze
390
436
 
@@ -457,7 +503,7 @@ module ActiveJob
457
503
  end
458
504
 
459
505
  # Attributes holding secrets, redacted by {#inspect}.
460
- REDACTED_ATTRIBUTES = %i[encryption_key encryption_old_keys tls].freeze
506
+ REDACTED_ATTRIBUTES = %i[encryption_key encryption_old_keys tls api_key].freeze
461
507
  REDACTED_PLACEHOLDER = "[FILTERED]"
462
508
 
463
509
  # Returns the configuration with secret attributes redacted.
@@ -661,6 +707,13 @@ module ActiveJob
661
707
  allow_nil: false
662
708
  }
663
709
 
710
+ validates :graceful_shutdown_period,
711
+ numericality: {
712
+ greater_than_or_equal_to: 0,
713
+ message: :graceful_shutdown_period_negative,
714
+ allow_nil: false
715
+ }
716
+
664
717
  validates :continue_as_new_history_event_threshold,
665
718
  numericality: {
666
719
  greater_than: 0,
@@ -698,6 +751,8 @@ module ActiveJob
698
751
  validate :validate_encryption_settings
699
752
  validate :validate_payload_storage_settings
700
753
  validate :validate_tls_settings
754
+ validate :validate_api_key_settings
755
+ validate :validate_worker_registration_settings
701
756
  validate :validate_local_activity_helpers
702
757
  validate :validate_dependency_wait_settings
703
758
 
@@ -1024,6 +1079,30 @@ module ActiveJob
1024
1079
  errors.add(:tls_cert_watch, :requires_paths)
1025
1080
  end
1026
1081
 
1082
+ def validate_api_key_settings
1083
+ validate_tls_file_path(:api_key_file)
1084
+
1085
+ unless [true, false].include?(api_key_watch)
1086
+ errors.add(:api_key_watch, :not_boolean, value: api_key_watch.inspect)
1087
+ return
1088
+ end
1089
+
1090
+ return unless api_key_watch && api_key_file.to_s.strip.empty?
1091
+
1092
+ errors.add(:api_key_watch, :requires_path)
1093
+ end
1094
+
1095
+ def validate_worker_registration_settings
1096
+ unless [true, false].include?(worker_activejob_workloads)
1097
+ errors.add(:worker_activejob_workloads, :not_boolean, value: worker_activejob_workloads.inspect)
1098
+ return
1099
+ end
1100
+
1101
+ return if worker_activejob_workloads || Array(worker_activities).any?
1102
+
1103
+ errors.add(:worker_activejob_workloads, :requires_activities)
1104
+ end
1105
+
1027
1106
  def validate_tls_reload_signal
1028
1107
  unless tls_reload_signal.is_a?(String) && tls_reload_signal.strip.present?
1029
1108
  errors.add(:tls_reload_signal, :blank)
@@ -136,3 +136,18 @@ en:
136
136
  tls_reload_signal:
137
137
  blank: "must be present when configured"
138
138
  invalid: "must be a signal name like HUP or USR1, got: %{value}"
139
+
140
+ api_key_file:
141
+ invalid_path: "must be a non-empty file path, got: %{value}"
142
+ unreadable_path: "must resolve to a readable regular file, got: %{value}"
143
+
144
+ api_key_watch:
145
+ not_boolean: "must be true or false, got: %{value}"
146
+ requires_path: "requires api_key_file to watch"
147
+
148
+ worker_activejob_workloads:
149
+ not_boolean: "must be true or false, got: %{value}"
150
+ requires_activities: "requires worker_activities when disabled - the worker would have nothing to register"
151
+
152
+ graceful_shutdown_period:
153
+ graceful_shutdown_period_negative: "must be >= 0 seconds, got: %{value}"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveJob
4
4
  module Temporal
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "temporalio/activity"
4
+
5
+ module ActiveJob
6
+ module Temporal
7
+ # Resolves which workflows and activities a worker registers, from configuration.
8
+ #
9
+ # By default a worker hosts the built-in ActiveJob workloads. `worker_activities`
10
+ # adds custom activity classes - for example activities invoked by workflows owned
11
+ # by another service, where only the activity name and JSON payloads travel over
12
+ # the wire. Setting `worker_activejob_workloads = false` turns the worker into an
13
+ # activities-only worker that hosts nothing but those custom activities.
14
+ module WorkerRegistrations
15
+ Result = Struct.new(:workflows, :activities, keyword_init: true)
16
+
17
+ class << self
18
+ # Configuration validation rejects the nothing-to-register combination up front
19
+ # (see Configuration#validate_worker_registration_settings).
20
+ #
21
+ # @param configuration [ActiveJob::Temporal::Configuration]
22
+ # @return [Result] workflows and activities to pass to Temporalio::Worker
23
+ # @raise [WorkerRegistrationError] when a worker_activities entry does not resolve
24
+ # to a Temporalio::Activity::Definition subclass
25
+ def resolve(configuration)
26
+ workflows = []
27
+ activities = custom_activities(configuration)
28
+
29
+ if configuration.worker_activejob_workloads
30
+ workflows += [
31
+ Workflows::AjWorkflow,
32
+ Workflows::DeadLetterWorkflow
33
+ ]
34
+ activities += [
35
+ Activities::RateLimitActivity,
36
+ Activities::DependencyStatusActivity,
37
+ Activities::AjRunnerActivity
38
+ ]
39
+ end
40
+
41
+ Result.new(workflows: workflows, activities: activities)
42
+ end
43
+
44
+ private
45
+
46
+ def custom_activities(configuration)
47
+ Array(configuration.worker_activities).map { |entry| resolve_activity(entry) }
48
+ end
49
+
50
+ def resolve_activity(entry)
51
+ activity = entry.is_a?(String) ? constantize_activity(entry) : entry
52
+ return activity if activity.is_a?(Class) && activity < Temporalio::Activity::Definition
53
+
54
+ raise WorkerRegistrationError,
55
+ "worker_activities entry #{entry.inspect} is not a Temporalio::Activity::Definition subclass"
56
+ end
57
+
58
+ def constantize_activity(name)
59
+ name.constantize
60
+ rescue NameError
61
+ raise WorkerRegistrationError, "worker_activities entry #{name.inspect} does not resolve to a class"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -14,3 +14,4 @@ require_relative "workflows/dead_letter_workflow"
14
14
  require_relative "activities/rate_limit_activity"
15
15
  require_relative "activities/dependency_status_activity"
16
16
  require_relative "activities/aj_runner_activity"
17
+ require_relative "worker_registrations"
@@ -99,6 +99,11 @@ module ActiveJob
99
99
  # @see Cancel.cancel
100
100
  class TemporalConnectionError < Error; end
101
101
 
102
+ # Raised when a worker_activities entry cannot be used as a Temporal activity.
103
+ #
104
+ # @see WorkerRegistrations.resolve
105
+ class WorkerRegistrationError < Error; end
106
+
102
107
  extend Configurable
103
108
 
104
109
  class << self
@@ -157,6 +162,20 @@ module ActiveJob
157
162
  fresh_client
158
163
  end
159
164
 
165
+ # Re-reads the API key (api_key or api_key_file) and applies it to the memoized
166
+ # client's live connection. No reconnect happens - the SDK sends the header per-RPC
167
+ # from the connection's stored options. Worker processes call this from the
168
+ # api_key_watch file watcher; a long-lived enqueue-side process using a rotating
169
+ # token file must call it on its own schedule (nothing watches the file there).
170
+ #
171
+ # @return [String, nil] the freshly resolved API key
172
+ def refresh_api_key!
173
+ fresh_key = Client.resolve_api_key(config)
174
+ client.connection.api_key = fresh_key
175
+ Logger.log_event("api_key_refreshed")
176
+ fresh_key
177
+ end
178
+
160
179
  # Cancels a running or scheduled job by job ID.
161
180
  #
162
181
  # This method requests cancellation for the Temporal workflow associated with the job.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activejob-temporal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Temporal Technologies
@@ -87,7 +87,7 @@ dependencies:
87
87
  version: 1.4.0
88
88
  - - "<"
89
89
  - !ruby/object:Gem::Version
90
- version: '1.5'
90
+ version: '1.7'
91
91
  type: :runtime
92
92
  prerelease: false
93
93
  version_requirements: !ruby/object:Gem::Requirement
@@ -97,7 +97,7 @@ dependencies:
97
97
  version: 1.4.0
98
98
  - - "<"
99
99
  - !ruby/object:Gem::Version
100
- version: '1.5'
100
+ version: '1.7'
101
101
  - !ruby/object:Gem::Dependency
102
102
  name: benchmark-ips
103
103
  requirement: !ruby/object:Gem::Requirement
@@ -371,6 +371,7 @@ files:
371
371
  - lib/activejob/temporal/worker_client_reloader.rb
372
372
  - lib/activejob/temporal/worker_health.rb
373
373
  - lib/activejob/temporal/worker_pool.rb
374
+ - lib/activejob/temporal/worker_registrations.rb
374
375
  - lib/activejob/temporal/worker_runtime.rb
375
376
  - lib/activejob/temporal/workflow_enqueuer.rb
376
377
  - lib/activejob/temporal/workflow_enqueuer_batch.rb