activejob-temporal 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a0aefb6003d5c49d08c531d80ac7ce5ff82ee038a81aee31d3c5f0d2d438f61
4
- data.tar.gz: c0f3f46a2e57f35a0c05290d129d9905f64c6dbe9be30371e79f99d0905d3cd0
3
+ metadata.gz: 0e1e7c38c2a169353b2d9f7edadca0614e32cb6c8c33ed87fe1d29b72a7dd5cb
4
+ data.tar.gz: 3255fbe711eac898e2b91a65fa2bc5f858eccda59e605774fa027a6a2ba72291
5
5
  SHA512:
6
- metadata.gz: 16bd26f8f58993b6c60b21a16f0216fe928eb4ad341aad19483fe77cd3f2c2c21ecf67695969df2137fa2b4953a8f984ff1d446696ce9691e33e0431fa3af177
7
- data.tar.gz: b06bd6d8ca772947228dacfe065e5582156d15fccc8501bff3ec14e8f3c18454d22d2b28c09cb1a59788462997067eaa9f12204ac265fd10273468cd653346ef
6
+ metadata.gz: b722896482be5075f145926ad914fedd43b860d438c0badf855f030aaa501d6839233315e0427b841e24d108e89fe775375f77e8e07f8ac983f43169eac383cd
7
+ data.tar.gz: 6f40349c1961652d0f4c1e242a383895264cf585a2a6bcf127e3ed6dc66f9025413a664289d3ac05325ed9f10dab0d3f427c240af600555dfab8fb50d37f46fc
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-08-07
11
+
12
+ ### Added
13
+ - `credential_poll_interval` (default 30 seconds) and `credential_file_events` (default false) configure how `tls_cert_watch` and `api_key_watch` detect rotation.
14
+ - `ActiveJob::Temporal::CredentialRefresher` replaces `CertificateWatcher`. Enqueue-side processes can now start it from an initializer instead of scheduling `refresh_api_key!` themselves - see [worker setup](docs/worker_setup.md#api-key-authentication).
15
+
16
+ ### Changed
17
+ - Credential rotation is detected by comparing a content digest on an interval rather than by filesystem events. Rotation on mounts whose events never reach the process (or where the `listen` gem is absent) now works, at up to one poll interval of latency. `credential_file_events = true` restores immediate reaction by waking the check early; it never decides whether a reload happens, so a missing `listen` gem costs latency, not correctness.
18
+ - A cert and key pair rotating together now triggers one client rebuild instead of a debounced pair, and TLS plus API key watching share a single watcher instead of two.
19
+ - **Breaking:** `ActiveJob::Temporal::CertificateWatcher` is removed. Applications constructing it directly should use `CredentialRefresher.from_config`.
20
+
21
+ ### Fixed
22
+ - Watching a Kubernetes projected or secret volume no longer prints listen's `directory is already being watched!` error at boot. kubelet keeps the live secret in a timestamped sibling of the `..data` symlink, so listen's recursive scan reached the same real directory twice; the versioned copy is now skipped.
23
+
24
+ ## [0.3.1] - 2026-08-06
25
+
26
+ ### Fixed
27
+ - `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.
28
+
10
29
  ## [0.3.0] - 2026-08-06
11
30
 
12
31
  ### Added
@@ -39,6 +39,8 @@ Gem::Specification.new do |spec|
39
39
  spec.add_dependency "globalid", ">= 0.3"
40
40
  spec.add_dependency "temporalio", ">= 1.4.0", "< 1.7"
41
41
 
42
+ # listen is an optional runtime dependency, required lazily only when credential_file_events is
43
+ # enabled. Without it credentials still rotate, just on the poll interval instead of instantly.
42
44
  spec.add_development_dependency "benchmark-ips", "~> 2.14"
43
45
  spec.add_development_dependency "github_changelog_generator", "~> 1.18"
44
46
  spec.add_development_dependency "listen", "~> 3.9"
data/bin/temporal-worker CHANGED
@@ -260,25 +260,14 @@ reload_signal_thread = Thread.new do
260
260
  end
261
261
  Signal.trap(reload_signal) { reload_signal_queue.push(reload_signal) }
262
262
 
263
- certificate_watcher = nil
264
- if config.tls_cert_watch
265
- certificate_watcher = ActiveJob::Temporal::CertificateWatcher.new(
266
- paths: ActiveJob::Temporal::CertificateWatcher.paths_from_config(config),
267
- # Failures are logged by the reloader and retried by the watcher, so let them propagate.
268
- reload_callback: -> { client_reloader.reload(source: "file_watch") }
269
- ).start
270
- end
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
263
+ # Only the TLS callback is overridden: certificate rotation has to swap the rebuilt client into
264
+ # the running worker, while token rotation mutates the live connection (the SDK sends the header
265
+ # per-RPC from the connection's stored options) and needs no worker awareness.
266
+ credential_refresher = ActiveJob::Temporal::CredentialRefresher.from_config(
267
+ config,
268
+ # Failures are logged by the reloader and retried on the next check, so let them propagate.
269
+ on_tls_change: -> { client_reloader.reload(source: "credential_refresh") }
270
+ ).start
282
271
 
283
272
  begin
284
273
  worker_health.mark_started!
@@ -290,8 +279,7 @@ rescue Interrupt
290
279
  # Temporal worker raises Interrupt on shutdown signals; swallow to allow clean exit.
291
280
  ensure
292
281
  worker_health.mark_stopped!
293
- certificate_watcher&.stop
294
- api_key_watcher&.stop
282
+ credential_refresher.stop
295
283
  reload_signal_queue.close
296
284
  reload_signal_thread.join(1)
297
285
  health_check_server&.stop
@@ -155,8 +155,23 @@ module ActiveJob
155
155
  default: false,
156
156
  env_var: "ACTIVEJOB_TEMPORAL_API_KEY_WATCH",
157
157
  type: :boolean,
158
- description: "Watch api_key_file and reload worker clients when it changes (kubelet rewrites " \
159
- "projected tokens in place long before they expire)"
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
+
162
+ credential_poll_interval: {
163
+ default: 30,
164
+ env_var: "ACTIVEJOB_TEMPORAL_CREDENTIAL_POLL_INTERVAL",
165
+ type: :integer,
166
+ description: "Seconds between credential file checks when tls_cert_watch or api_key_watch is enabled"
167
+ },
168
+
169
+ credential_file_events: {
170
+ default: false,
171
+ env_var: "ACTIVEJOB_TEMPORAL_CREDENTIAL_FILE_EVENTS",
172
+ type: :boolean,
173
+ description: "React to credential file changes immediately instead of waiting for the next " \
174
+ "poll (requires the optional listen gem; the poll interval stays the safety net)"
160
175
  },
161
176
 
162
177
  priority_task_queues: {
@@ -752,6 +767,7 @@ module ActiveJob
752
767
  validate :validate_payload_storage_settings
753
768
  validate :validate_tls_settings
754
769
  validate :validate_api_key_settings
770
+ validate :validate_credential_refresh_settings
755
771
  validate :validate_worker_registration_settings
756
772
  validate :validate_local_activity_helpers
757
773
  validate :validate_dependency_wait_settings
@@ -1092,6 +1108,16 @@ module ActiveJob
1092
1108
  errors.add(:api_key_watch, :requires_path)
1093
1109
  end
1094
1110
 
1111
+ def validate_credential_refresh_settings
1112
+ unless [true, false].include?(credential_file_events)
1113
+ errors.add(:credential_file_events, :not_boolean, value: credential_file_events.inspect)
1114
+ end
1115
+
1116
+ return if credential_poll_interval.is_a?(Integer) && credential_poll_interval.positive?
1117
+
1118
+ errors.add(:credential_poll_interval, :invalid, value: credential_poll_interval.inspect)
1119
+ end
1120
+
1095
1121
  def validate_worker_registration_settings
1096
1122
  unless [true, false].include?(worker_activejob_workloads)
1097
1123
  errors.add(:worker_activejob_workloads, :not_boolean, value: worker_activejob_workloads.inspect)
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../temporal"
5
+
6
+ module ActiveJob
7
+ module Temporal
8
+ # Keeps credential files fresh in a long-lived process: TLS material, API key files, or
9
+ # anything else read once at boot and rotated underneath the process later.
10
+ #
11
+ # Change detection is a content digest compared on an interval, so the only thing that can fire
12
+ # a callback is the bytes behind a path actually differing. Filesystem events (see :file_events)
13
+ # are an optional accelerator that wakes the loop early; they never decide whether a reload
14
+ # happens, so a missing `listen` gem or a mount without working inotify costs latency, not
15
+ # correctness.
16
+ #
17
+ # @example Worker: rebuild the client on cert rotation, patch the live connection on token rotation
18
+ # refresher = CredentialRefresher.from_config(
19
+ # ActiveJob::Temporal.config,
20
+ # on_tls_change: -> { client_reloader.reload(source: "credential_refresh") }
21
+ # ).start
22
+ #
23
+ # @example Enqueue-side process (web, Sidekiq) with a rotating projected token
24
+ # require "activejob/temporal/credential_refresher"
25
+ # ActiveJob::Temporal::CredentialRefresher.from_config(ActiveJob::Temporal.config).start
26
+ class CredentialRefresher
27
+ # kubelet keeps the live secret in a timestamped sibling of `..data`, so listen's recursive
28
+ # scan reaches the same real directory twice and prints a SymlinkLoop error to stderr.
29
+ # Getting this wrong costs that stderr noise and nothing else: listen only ever nudges the
30
+ # loop, so the digest still decides, and the interval still backs it up.
31
+ #
32
+ # @api private
33
+ KUBERNETES_VERSIONED_DIR = /\A\.\.\d/
34
+ private_constant :KUBERNETES_VERSIONED_DIR
35
+
36
+ DEFAULT_POLL_INTERVAL = 30
37
+
38
+ # Files digested as one unit. Cert and key rotate as separate writes, so grouping them turns
39
+ # a rotation into a single callback instead of a pair.
40
+ Source = Struct.new(:name, :paths, :on_change, keyword_init: true)
41
+
42
+ class << self
43
+ # Builds a refresher from the `tls_cert_watch` / `api_key_watch` configuration flags.
44
+ # The callbacks default to the process-wide reload paths, which is what an enqueue-side
45
+ # process wants; workers override the TLS one so the running worker gets the new client.
46
+ #
47
+ # @param configuration [Configuration] the gem configuration
48
+ # @param on_tls_change [#call] invoked when any TLS file changes
49
+ # @param on_api_key_change [#call] invoked when the API key file changes
50
+ # @param logger [#log_event, #warn, #error] structured logger
51
+ # @param listener_factory [#to, nil] injection point for tests, defaults to `Listen`
52
+ # @return [CredentialRefresher] a refresher with no sources when both flags are off
53
+ def from_config(configuration,
54
+ on_tls_change: -> { ActiveJob::Temporal.reload_client! },
55
+ on_api_key_change: -> { ActiveJob::Temporal.refresh_api_key! },
56
+ logger: ActiveJob::Temporal::Logger,
57
+ listener_factory: nil)
58
+ sources = []
59
+
60
+ if configuration.tls_cert_watch
61
+ sources << Source.new(name: "tls", paths: tls_paths(configuration), on_change: on_tls_change)
62
+ end
63
+
64
+ if configuration.api_key_watch
65
+ sources << Source.new(name: "api_key", paths: [configuration.api_key_file], on_change: on_api_key_change)
66
+ end
67
+
68
+ new(
69
+ sources: sources,
70
+ poll_interval: configuration.credential_poll_interval,
71
+ file_events: configuration.credential_file_events,
72
+ logger: logger,
73
+ listener_factory: listener_factory
74
+ )
75
+ end
76
+
77
+ # @param configuration [Configuration] the gem configuration
78
+ # @return [Array<String>] configured TLS file paths, blanks removed
79
+ def tls_paths(configuration)
80
+ [
81
+ configuration.tls_cert_path,
82
+ configuration.tls_key_path,
83
+ configuration.tls_server_root_ca_cert_path
84
+ ].compact.reject { |path| path.to_s.strip.empty? }
85
+ end
86
+ end
87
+
88
+ # @param sources [Array<Source>] credential groups to keep fresh
89
+ # @param poll_interval [Integer, Float] seconds between digest comparisons
90
+ # @param file_events [Boolean] wake the loop early on filesystem events, requires `listen`
91
+ # @param logger [#log_event, #warn, #error] structured logger
92
+ # @param listener_factory [#to, nil] injection point for tests, defaults to `Listen`
93
+ def initialize(sources:, poll_interval: DEFAULT_POLL_INTERVAL, file_events: false,
94
+ logger: ActiveJob::Temporal::Logger, listener_factory: nil)
95
+ @sources = sources.reject { |source| source.paths.compact.empty? }
96
+ @poll_interval = poll_interval
97
+ @file_events = file_events
98
+ @logger = logger
99
+ @listener_factory = listener_factory
100
+ @wakeups = Thread::Queue.new
101
+ @digests = {}
102
+ @thread = nil
103
+ @listener = nil
104
+ end
105
+
106
+ # Baselines each digest before polling, so a process booting on an already-rotated file does
107
+ # not fire a redundant reload on its first check.
108
+ #
109
+ # @return [self]
110
+ def start
111
+ return self if @sources.empty? || @thread
112
+
113
+ @sources.each { |source| @digests[source.name] = digest(source) }
114
+ @thread = Thread.new { run }
115
+ start_listener if @file_events
116
+ self
117
+ end
118
+
119
+ # @return [void]
120
+ def stop
121
+ @listener&.stop
122
+ @listener = nil
123
+ @wakeups.close
124
+ @thread&.join
125
+ @thread = nil
126
+ end
127
+
128
+ # Re-reads every source and fires the callbacks whose content changed. Safe to call at any
129
+ # rate: unchanged content is a no-op.
130
+ #
131
+ # @return [void]
132
+ def refresh_changed_sources
133
+ @sources.each { |source| refresh(source) }
134
+ end
135
+
136
+ # Wakes the poll loop so the next comparison happens now instead of on the next tick.
137
+ # Decides nothing on its own.
138
+ #
139
+ # @return [void]
140
+ def nudge
141
+ @wakeups << :wakeup
142
+ rescue ClosedQueueError
143
+ nil
144
+ end
145
+
146
+ private
147
+
148
+ def run
149
+ until @wakeups.closed?
150
+ @wakeups.pop(timeout: @poll_interval)
151
+ break if @wakeups.closed?
152
+
153
+ # A rotation emits a burst of events; collapse them into the single check that follows.
154
+ @wakeups.clear
155
+ refresh_changed_sources
156
+ end
157
+ end
158
+
159
+ def refresh(source)
160
+ current = digest(source)
161
+ return if current.nil? || current == @digests[source.name]
162
+
163
+ source.on_change.call
164
+ # Stamped only after the callback succeeds, so a failed reload is retried on the next tick.
165
+ @digests[source.name] = current
166
+ @logger.log_event("credential_refreshed", source: source.name)
167
+ rescue StandardError => e
168
+ @logger.error(
169
+ "credential_refresh_failed",
170
+ source: source.name,
171
+ error_class: e.class.name,
172
+ message: e.message
173
+ )
174
+ end
175
+
176
+ # A path can be briefly unreadable while kubelet swaps the mount. Treat that as "no reading"
177
+ # so the next tick retries, rather than reloading against a half-written credential.
178
+ def digest(source)
179
+ contents = source.paths.compact.map { |path| TLSFile.read(path) }
180
+ Digest::SHA256.hexdigest(contents.join("\0"))
181
+ rescue TLSFile::Error
182
+ nil
183
+ end
184
+
185
+ def start_listener
186
+ @listener = listener_factory.to(*watched_directories, ignore: KUBERNETES_VERSIONED_DIR) { nudge }
187
+ @listener.start
188
+ rescue LoadError => e
189
+ # Polling already guarantees the refresh, so a missing listen gem is a latency
190
+ # regression rather than a boot failure.
191
+ @logger.warn(
192
+ "credential_file_events_unavailable",
193
+ error_class: e.class.name,
194
+ message: e.message,
195
+ poll_interval_seconds: @poll_interval
196
+ )
197
+ end
198
+
199
+ def watched_directories
200
+ @sources.flat_map(&:paths).compact.map { |path| File.dirname(File.expand_path(path)) }.uniq
201
+ end
202
+
203
+ def listener_factory
204
+ @listener_factory ||= begin
205
+ require "listen"
206
+ Listen
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -145,6 +145,12 @@ en:
145
145
  not_boolean: "must be true or false, got: %{value}"
146
146
  requires_path: "requires api_key_file to watch"
147
147
 
148
+ credential_poll_interval:
149
+ invalid: "must be a positive number of seconds, got: %{value}"
150
+
151
+ credential_file_events:
152
+ not_boolean: "must be true or false, got: %{value}"
153
+
148
154
  worker_activejob_workloads:
149
155
  not_boolean: "must be true or false, got: %{value}"
150
156
  requires_activities: "requires worker_activities when disabled - the worker would have nothing to register"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveJob
4
4
  module Temporal
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "../temporal"
4
4
  require_relative "rails_environment_loader"
5
- require_relative "certificate_watcher"
5
+ require_relative "credential_refresher"
6
6
  require_relative "reload_signal_queue"
7
7
  require_relative "worker_client_reloader"
8
8
  require_relative "worker_pool"
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Temporal Technologies
@@ -309,7 +309,6 @@ files:
309
309
  - lib/activejob/temporal/cancel.rb
310
310
  - lib/activejob/temporal/cancel/batch_canceller.rb
311
311
  - lib/activejob/temporal/cancel/batch_summary.rb
312
- - lib/activejob/temporal/certificate_watcher.rb
313
312
  - lib/activejob/temporal/chain_options.rb
314
313
  - lib/activejob/temporal/child_workflow_options.rb
315
314
  - lib/activejob/temporal/client.rb
@@ -318,6 +317,7 @@ files:
318
317
  - lib/activejob/temporal/configuration.rb
319
318
  - lib/activejob/temporal/configured_job_compatibility.rb
320
319
  - lib/activejob/temporal/connection_worker_pool.rb
320
+ - lib/activejob/temporal/credential_refresher.rb
321
321
  - lib/activejob/temporal/dead_letter_payload_validation.rb
322
322
  - lib/activejob/temporal/dead_letter_queue.rb
323
323
  - lib/activejob/temporal/dependency_options.rb
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveJob
4
- module Temporal
5
- # Watches TLS certificate files and runs a reload callback when they change.
6
- class CertificateWatcher
7
- DEFAULT_DEBOUNCE_SECONDS = 1.0
8
-
9
- def self.paths_from_config(configuration)
10
- [
11
- configuration.tls_cert_path,
12
- configuration.tls_key_path,
13
- configuration.tls_server_root_ca_cert_path
14
- ].compact.reject { |path| path.to_s.strip.empty? }
15
- end
16
-
17
- def initialize(paths:, reload_callback:, listener_factory: nil, debounce_seconds: DEFAULT_DEBOUNCE_SECONDS)
18
- @paths = paths.map { |path| File.expand_path(path) }.uniq
19
- @reload_callback = reload_callback
20
- @listener_factory = listener_factory
21
- @debounce_seconds = debounce_seconds
22
- @mutex = Mutex.new
23
- @last_reload_at = nil
24
- @listener = nil
25
- @trailing_reload = nil
26
- end
27
-
28
- def start
29
- return self if @paths.empty? || @listener
30
-
31
- @listener = listener_factory.to(*directories) do |modified, added, removed|
32
- handle_changes(modified + added + removed)
33
- end
34
- @listener.start
35
- self
36
- end
37
-
38
- def stop
39
- @listener&.stop
40
- @listener = nil
41
- @mutex.synchronize { @trailing_reload }&.kill
42
- end
43
-
44
- def handle_changes(changed_paths)
45
- return unless relevant_change?(changed_paths)
46
-
47
- if debounced?
48
- schedule_trailing_reload
49
- else
50
- reload(retry_on_failure: true)
51
- end
52
- end
53
-
54
- private
55
-
56
- # A failed reload clears the debounce stamp so the next change reloads
57
- # immediately, and gets one retry in case no further change arrives.
58
- def reload(retry_on_failure:)
59
- @reload_callback.call
60
- rescue StandardError
61
- @mutex.synchronize { @last_reload_at = nil }
62
- schedule_trailing_reload if retry_on_failure
63
- end
64
-
65
- # Cert and key rotate as separate writes, so the second one lands inside
66
- # the debounce window. Flush it once the window closes instead of dropping it.
67
- def schedule_trailing_reload
68
- @mutex.synchronize do
69
- return if @trailing_reload&.alive?
70
-
71
- @trailing_reload = Thread.new do
72
- sleep(@debounce_seconds)
73
- @mutex.synchronize { @last_reload_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) }
74
- reload(retry_on_failure: false)
75
- end
76
- end
77
- end
78
-
79
- def directories
80
- @directories ||= @paths.map { |path| File.dirname(path) }.uniq
81
- end
82
-
83
- def listener_factory
84
- @listener_factory ||= begin
85
- require "listen"
86
- Listen
87
- rescue LoadError => e
88
- raise LoadError, "listen gem is required when tls_cert_watch is enabled: #{e.message}"
89
- end
90
- end
91
-
92
- def relevant_change?(changed_paths)
93
- changed_paths.any? do |path|
94
- @paths.include?(File.expand_path(path))
95
- end
96
- end
97
-
98
- def debounced?
99
- return false unless @debounce_seconds.positive?
100
-
101
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
- @mutex.synchronize do
103
- return true if @last_reload_at && (now - @last_reload_at) < @debounce_seconds
104
-
105
- @last_reload_at = now
106
- false
107
- end
108
- end
109
- end
110
- end
111
- end