kaal 0.2.1 → 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -286
  3. data/Rakefile +4 -2
  4. data/config/kaal.rb +15 -0
  5. data/config/scheduler.yml +12 -0
  6. data/{lib/tasks/kaal_tasks.rake → exe/kaal} +5 -3
  7. data/lib/kaal/backend/adapter.rb +0 -1
  8. data/lib/kaal/backend/dispatch_attempt_logger.rb +33 -0
  9. data/lib/kaal/backend/dispatch_logging.rb +36 -23
  10. data/lib/kaal/backend/dispatch_registry_accessor.rb +43 -0
  11. data/lib/kaal/backend/memory_adapter.rb +7 -5
  12. data/lib/kaal/backend/redis_adapter.rb +6 -6
  13. data/lib/kaal/cli.rb +230 -0
  14. data/lib/kaal/{configuration.rb → config/configuration.rb} +0 -1
  15. data/lib/kaal/{scheduler_config_error.rb → config/scheduler_config_error.rb} +0 -1
  16. data/lib/kaal/config/scheduler_time_zone_resolver.rb +50 -0
  17. data/lib/kaal/config.rb +19 -0
  18. data/lib/kaal/{coordinator.rb → core/coordinator.rb} +42 -62
  19. data/lib/kaal/core/enabled_entry_enumerator.rb +51 -0
  20. data/lib/kaal/core/occurrence_finder.rb +38 -0
  21. data/lib/kaal/core.rb +18 -0
  22. data/lib/kaal/definition/memory_engine.rb +11 -18
  23. data/lib/kaal/definition/persistence_helpers.rb +31 -0
  24. data/lib/kaal/definition/redis_engine.rb +9 -6
  25. data/lib/kaal/definition/registry.rb +24 -2
  26. data/lib/kaal/definitions/registration_service.rb +62 -0
  27. data/lib/kaal/definitions/registry_accessor.rb +33 -0
  28. data/lib/kaal/dispatch/memory_engine.rb +3 -4
  29. data/lib/kaal/dispatch/redis_engine.rb +2 -3
  30. data/lib/kaal/dispatch/registry.rb +0 -1
  31. data/lib/kaal/register_conflict_support.rb +0 -1
  32. data/lib/kaal/registry.rb +0 -1
  33. data/lib/kaal/runtime/runtime_context.rb +41 -0
  34. data/lib/kaal/runtime/scheduler_boot_loader.rb +52 -0
  35. data/lib/kaal/runtime/signal_handler_chain.rb +42 -0
  36. data/lib/kaal/runtime/signal_handler_installer.rb +39 -0
  37. data/lib/kaal/runtime.rb +20 -0
  38. data/lib/kaal/scheduler_file/hash_transform.rb +22 -0
  39. data/lib/kaal/scheduler_file/helper_bundle.rb +28 -0
  40. data/lib/kaal/scheduler_file/job_applier.rb +242 -0
  41. data/lib/kaal/scheduler_file/job_normalizer.rb +90 -0
  42. data/lib/kaal/scheduler_file/loader.rb +152 -0
  43. data/lib/kaal/scheduler_file/payload_loader.rb +95 -0
  44. data/lib/kaal/{scheduler_placeholder_support.rb → scheduler_file/placeholder_support.rb} +0 -1
  45. data/lib/kaal/scheduler_file.rb +18 -0
  46. data/lib/kaal/support/hash_tools.rb +93 -0
  47. data/lib/kaal/{cron_humanizer.rb → utils/cron_humanizer.rb} +19 -1
  48. data/lib/kaal/{cron_utils.rb → utils/cron_utils.rb} +0 -1
  49. data/lib/kaal/{idempotency_key_generator.rb → utils/idempotency_key_generator.rb} +3 -3
  50. data/lib/kaal/utils.rb +18 -0
  51. data/lib/kaal/version.rb +1 -2
  52. data/lib/kaal.rb +77 -397
  53. metadata +64 -44
  54. data/app/models/kaal/cron_definition.rb +0 -76
  55. data/app/models/kaal/cron_dispatch.rb +0 -50
  56. data/app/models/kaal/cron_lock.rb +0 -38
  57. data/lib/generators/kaal/install/install_generator.rb +0 -72
  58. data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +0 -21
  59. data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +0 -20
  60. data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +0 -17
  61. data/lib/generators/kaal/install/templates/kaal.rb.tt +0 -31
  62. data/lib/generators/kaal/install/templates/scheduler.yml.tt +0 -22
  63. data/lib/kaal/backend/mysql_adapter.rb +0 -170
  64. data/lib/kaal/backend/postgres_adapter.rb +0 -134
  65. data/lib/kaal/backend/sqlite_adapter.rb +0 -116
  66. data/lib/kaal/definition/database_engine.rb +0 -50
  67. data/lib/kaal/dispatch/database_engine.rb +0 -94
  68. data/lib/kaal/railtie.rb +0 -183
  69. data/lib/kaal/rake_tasks.rb +0 -184
  70. data/lib/kaal/scheduler_file_loader.rb +0 -321
  71. data/lib/kaal/scheduler_hash_transform.rb +0 -45
data/lib/kaal/railtie.rb DELETED
@@ -1,183 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Copyright Codevedas Inc. 2025-present
4
- #
5
- # This source code is licensed under the MIT license found in the
6
- # LICENSE file in the root directory of this source tree.
7
-
8
- require 'pathname'
9
-
10
- module Kaal
11
- ##
12
- # Railtie class to integrate Kaal with Rails applications.
13
- # Initializes configuration, sets up the default logger, and handles signal management.
14
- class Railtie < ::Rails::Railtie
15
- ##
16
- # Ensure configuration logger uses Rails.logger when available.
17
- def self.ensure_logger!
18
- logger = Rails.logger
19
- return unless logger
20
-
21
- Kaal.configure do |config|
22
- config.logger ||= logger
23
- end
24
- rescue NoMethodError
25
- nil
26
- end
27
-
28
- ##
29
- # Register signal handlers for graceful shutdown.
30
- # Captures and chains any previously registered handlers to cooperate with other components.
31
- def self.register_signal_handlers
32
- logger = Kaal.logger
33
-
34
- %w[TERM INT].each do |signal|
35
- # Capture the previous handler by temporarily setting to IGNORE and restoring
36
- old_handler = Signal.trap(signal, 'IGNORE')
37
- Signal.trap(signal, old_handler) if old_handler && old_handler != 'IGNORE'
38
-
39
- # Now install our handler that chains to the previous one
40
- Signal.trap(signal) do
41
- handle_shutdown_signal(signal, old_handler, logger)
42
- end
43
- end
44
- rescue StandardError => e
45
- logger&.warn("Failed to register signal handlers: #{e.full_message}")
46
- end
47
-
48
- ##
49
- # Handle a shutdown signal and chain to previous handler.
50
- def self.handle_shutdown_signal(signal, old_handler, logger)
51
- logger&.info("Received #{signal} signal, stopping scheduler...")
52
- begin
53
- stopped = Kaal.stop!(timeout: 30)
54
- logger&.warn('Scheduler did not stop within timeout, thread may still be running') unless stopped
55
- rescue StandardError => e
56
- logger&.error("Error stopping scheduler on #{signal} signal: #{e.full_message}")
57
- end
58
-
59
- chain_previous_handler(signal, old_handler, logger)
60
- end
61
-
62
- ##
63
- # Chain to a previous signal handler if it exists.
64
- def self.chain_previous_handler(signal, old_handler, logger)
65
- if old_handler.respond_to?(:call)
66
- old_handler.call
67
- elsif old_handler.is_a?(String) && old_handler != 'DEFAULT' && old_handler != 'IGNORE'
68
- # If previous handler was a command string, we can't easily re-invoke it
69
- logger&.debug("Previous #{signal} handler was a command: #{old_handler}")
70
- end
71
- end
72
-
73
- ##
74
- # Load scheduler file at boot while respecting missing-file policy.
75
- def self.load_scheduler_file_on_boot!
76
- configuration = fetch_configuration_for_boot
77
- return unless configuration
78
-
79
- if configuration.scheduler_missing_file_policy == :error
80
- load_scheduler_file_now!
81
- return
82
- end
83
-
84
- scheduler_path = configuration.scheduler_config_path.to_s.strip
85
- return if scheduler_path.empty?
86
-
87
- absolute_path = resolve_scheduler_path(scheduler_path)
88
- unless File.exist?(absolute_path)
89
- Kaal.logger&.warn("Scheduler file not found at #{absolute_path}")
90
- return
91
- end
92
-
93
- load_scheduler_file_now!
94
- end
95
-
96
- def self.resolve_scheduler_path(path)
97
- candidate = Pathname.new(path)
98
- candidate.absolute? ? candidate.to_s : Rails.root.join(candidate).to_s
99
- end
100
-
101
- def self.load_scheduler_file_now!
102
- Kaal.load_scheduler_file!
103
- end
104
-
105
- def self.fetch_configuration_for_boot
106
- Kaal.configuration
107
- rescue NameError => e
108
- Kaal.logger&.debug("Skipping scheduler file boot load due to configuration error: #{e.message}")
109
- nil
110
- end
111
-
112
- ##
113
- # Autoload paths for Kaal models and other components
114
- initializer 'kaal.autoload' do |_app|
115
- models_path = File.expand_path('../../app/models', __dir__)
116
- Rails.autoloaders.main.push_dir(models_path)
117
- end
118
-
119
- ##
120
- # Initialize Kaal when Rails boots.
121
- # Sets the default logger to Rails.logger if available.
122
- initializer 'kaal.configuration' do |_app|
123
- # Set default logger to Rails.logger if not already configured
124
- Kaal::Railtie.ensure_logger!
125
- end
126
-
127
- ##
128
- # Load gem i18n files into Rails I18n load path for host applications.
129
- initializer 'kaal.i18n', before: 'i18n.load_path' do |app|
130
- locales = Dir[File.expand_path('../../config/locales/*.yml', __dir__)]
131
- app.config.i18n.load_path |= locales
132
- end
133
-
134
- ##
135
- # Load rake tasks into host Rails applications.
136
- rake_tasks do
137
- load File.expand_path('../tasks/kaal_tasks.rake', __dir__)
138
- end
139
-
140
- ##
141
- # Load the default initializer after Rails has finished initialization.
142
- # This ensures Rails.logger is fully available and sets up signal handlers.
143
- config.after_initialize do
144
- # Re-ensure logger is set in case it wasn't available during first initializer
145
- Kaal::Railtie.ensure_logger!
146
-
147
- # Load scheduler definitions from file when available (or required by policy)
148
- Kaal::Railtie.load_scheduler_file_on_boot!
149
-
150
- # Register signal handlers for graceful shutdown
151
- Kaal::Railtie.register_signal_handlers
152
- end
153
-
154
- ##
155
- # Handle graceful shutdown when Rails exits.
156
- def self.handle_shutdown
157
- return unless Kaal.running?
158
-
159
- logger = Kaal.logger
160
-
161
- logger&.info('Rails is shutting down, stopping Kaal scheduler...')
162
- begin
163
- stopped = Kaal.stop!(timeout: 10)
164
- return if stopped
165
-
166
- pid = Process.pid
167
- message_array = [
168
- 'Kaal scheduler did not stop within timeout.',
169
- "Process #{pid} may still be running. You may need to kill it manually with `kill -9 #{pid}`."
170
- ]
171
- logger&.warn(message_array.join(' '))
172
- rescue StandardError => e
173
- logger&.error("Error stopping scheduler during shutdown: #{e.message}")
174
- end
175
- end
176
-
177
- ##
178
- # Ensure graceful shutdown on Rails shutdown.
179
- at_exit do
180
- Kaal::Railtie.handle_shutdown
181
- end
182
- end
183
- end
@@ -1,184 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Copyright Codevedas Inc. 2025-present
4
- #
5
- # This source code is licensed under the MIT license found in the
6
- # LICENSE file in the root directory of this source tree.
7
-
8
- require 'rake'
9
-
10
- module Kaal
11
- ##
12
- # Defines Kaal rake tasks on a given Rake application.
13
- module RakeTasks
14
- SIGNALS = %w[TERM INT].freeze
15
- SHUTDOWN_TIMEOUT = 30
16
- RESERVED_SIGNAL_HANDLERS = %w[DEFAULT IGNORE SYSTEM_DEFAULT EXIT].freeze
17
-
18
- module_function
19
-
20
- def install(rake_application = Rake.application)
21
- rake_application.extend(Rake::DSL)
22
-
23
- rake_application.instance_eval do
24
- namespace :kaal do
25
- Kaal::RakeTasks.define_tick_task(self)
26
- Kaal::RakeTasks.define_status_task(self)
27
- Kaal::RakeTasks.define_explain_task(self)
28
- Kaal::RakeTasks.define_start_task(self)
29
- end
30
- end
31
- end
32
-
33
- def define_tick_task(context)
34
- context.instance_eval do
35
- desc 'Perform a single scheduler tick'
36
- task tick: :environment do
37
- Kaal.tick!
38
- puts 'Kaal tick completed'
39
- rescue StandardError => e
40
- abort("kaal:tick failed: #{e.message}")
41
- end
42
- end
43
- end
44
-
45
- def define_status_task(context)
46
- context.instance_eval do
47
- desc 'Show scheduler status, configuration, and registered cron jobs'
48
- task status: :environment do
49
- puts "Kaal v#{Kaal::VERSION}"
50
- puts "Running: #{Kaal.running?}"
51
- puts "Tick interval: #{Kaal.tick_interval}s"
52
- puts "Window lookback: #{Kaal.window_lookback}s"
53
- puts "Window lookahead: #{Kaal.window_lookahead}s"
54
- puts "Lease TTL: #{Kaal.lease_ttl}s"
55
- puts "Namespace: #{Kaal.namespace}"
56
-
57
- entries = Kaal.registered
58
- puts "Registered jobs: #{entries.length}"
59
- entries.each do |entry|
60
- puts " - #{entry.key} (#{entry.cron})"
61
- end
62
- rescue StandardError => e
63
- abort("kaal:status failed: #{e.message}")
64
- end
65
- end
66
- end
67
-
68
- def define_explain_task(context)
69
- context.instance_eval do
70
- desc 'Humanize a cron expression, e.g. rake kaal:explain["*/5 * * * *"]'
71
- task :explain, [:expr] => :environment do |_task, args|
72
- expression = args[:expr].to_s.strip
73
- abort('kaal:explain requires expr argument') if expression.empty?
74
-
75
- puts Kaal.to_human(expression)
76
- rescue StandardError => e
77
- abort("kaal:explain failed: #{e.message}")
78
- end
79
- end
80
- end
81
-
82
- def define_start_task(context)
83
- context.instance_eval do
84
- desc 'Start scheduler loop in foreground (blocks until stopped)'
85
- task start: :environment do
86
- signal_state = {
87
- graceful_shutdown_started: false,
88
- shutdown_complete: false,
89
- force_exit_requested: false
90
- }
91
- previous_handlers = Kaal::RakeTasks.install_foreground_signal_handlers(signal_state)
92
-
93
- begin
94
- thread = Kaal.start!
95
- abort('kaal:start failed: scheduler is already running') unless thread
96
-
97
- puts 'Kaal scheduler started in foreground'
98
- thread.join
99
- ensure
100
- Kaal::RakeTasks.restore_signal_handlers(previous_handlers)
101
- end
102
- rescue Interrupt
103
- abort('kaal:start failed: shutdown timed out; forced exit requested') if signal_state[:force_exit_requested]
104
-
105
- Kaal::RakeTasks.shutdown_scheduler(signal: 'INT', signal_state: signal_state)
106
- rescue StandardError => e
107
- abort("kaal:start failed: #{e.message}")
108
- end
109
- end
110
- end
111
-
112
- def install_foreground_signal_handlers(signal_state)
113
- SIGNALS.each_with_object({}) do |signal, handlers|
114
- previous_handler = capture_previous_signal_handler(signal)
115
- Signal.trap(signal) do
116
- shutdown_scheduler(signal: signal, signal_state: signal_state, previous_handler: previous_handler)
117
- end
118
- handlers[signal] = previous_handler
119
- end
120
- end
121
-
122
- def capture_previous_signal_handler(signal)
123
- previous_handler = Signal.trap(signal, 'IGNORE')
124
- Signal.trap(signal, previous_handler)
125
- previous_handler
126
- end
127
-
128
- def restore_signal_handlers(previous_handlers)
129
- previous_handlers.each do |signal, handler|
130
- Signal.trap(signal, handler)
131
- rescue StandardError
132
- nil
133
- end
134
- end
135
-
136
- def shutdown_scheduler(signal:, signal_state:, previous_handler: nil)
137
- return if signal_state[:shutdown_complete]
138
-
139
- if signal_state[:graceful_shutdown_started]
140
- signal_state[:force_exit_requested] = true
141
- warn("Received #{signal} again; forcing scheduler shutdown")
142
- Thread.main.raise(Interrupt)
143
- return
144
- end
145
-
146
- signal_state[:graceful_shutdown_started] = true
147
- puts "Received #{signal}, stopping Kaal scheduler..."
148
- stopped = Kaal.stop!(timeout: SHUTDOWN_TIMEOUT)
149
- if stopped
150
- signal_state[:shutdown_complete] = true
151
- puts 'Kaal scheduler stopped'
152
- else
153
- warn('Kaal scheduler stop timed out; send TERM/INT again to force exit')
154
- end
155
- rescue StandardError => e
156
- warn("kaal:start shutdown failed: #{e.message}")
157
- ensure
158
- chain_previous_handler(signal, previous_handler)
159
- end
160
-
161
- def chain_previous_handler(signal, previous_handler)
162
- return unless previous_handler
163
-
164
- case previous_handler
165
- when Proc, Method
166
- invoke_previous_handler(previous_handler, signal)
167
- when String
168
- return if RESERVED_SIGNAL_HANDLERS.include?(previous_handler)
169
-
170
- warn("kaal:start previous #{signal} handler is a command: #{previous_handler}")
171
- end
172
- rescue StandardError
173
- nil
174
- end
175
-
176
- def invoke_previous_handler(handler, signal)
177
- signal_number = Signal.list[signal]
178
- callable_arity = handler.arity
179
- return handler.call if callable_arity.zero?
180
-
181
- handler.call(signal_number)
182
- end
183
- end
184
- end
@@ -1,321 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Copyright Codevedas Inc. 2025-present
4
- #
5
- # This source code is licensed under the MIT license found in the
6
- # LICENSE file in the root directory of this source tree.
7
-
8
- require 'erb'
9
- require 'pathname'
10
- require 'yaml'
11
- require 'active_support/core_ext/hash/deep_merge'
12
- require 'active_support/core_ext/object/deep_dup'
13
- require 'active_support/core_ext/string/inflections'
14
- require_relative 'scheduler_hash_transform'
15
- require_relative 'scheduler_placeholder_support'
16
-
17
- module Kaal
18
- # Loads scheduler definitions from config/scheduler.yml and registers them.
19
- class SchedulerFileLoader
20
- include SchedulerHashTransform
21
- include SchedulerPlaceholderSupport
22
-
23
- PLACEHOLDER_PATTERN = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
24
- ALLOWED_PLACEHOLDERS = {
25
- 'fire_time.iso8601' => ->(ctx) { ctx.fetch(:fire_time).iso8601 },
26
- 'fire_time.unix' => ->(ctx) { ctx.fetch(:fire_time).to_i },
27
- 'idempotency_key' => ->(ctx) { ctx.fetch(:idempotency_key) },
28
- 'key' => ->(ctx) { ctx.fetch(:key) }
29
- }.freeze
30
-
31
- def initialize(configuration:, definition_registry:, registry:, logger:, rails_context: Rails)
32
- @configuration = configuration
33
- @definition_registry = definition_registry
34
- @registry = registry
35
- @logger = logger
36
- @rails_env = rails_context.env.to_s
37
- @rails_root = rails_context.root
38
- @placeholder_resolvers = ALLOWED_PLACEHOLDERS
39
- end
40
-
41
- def load
42
- applied_job_contexts = []
43
- path = scheduler_file_path
44
- return handle_missing_file(path) unless File.exist?(path)
45
-
46
- payload = parse_yaml(path)
47
- jobs = extract_jobs(payload)
48
- validate_unique_keys(jobs)
49
- normalized_jobs = jobs.map { |job_payload| normalize_job(job_payload) }
50
- applied_jobs = []
51
- normalized_jobs.each do |job|
52
- applied_job_context = apply_job(**job)
53
- next unless applied_job_context
54
-
55
- applied_jobs << job
56
- applied_job_contexts << applied_job_context
57
- end
58
-
59
- applied_jobs
60
- rescue StandardError
61
- rollback_applied_jobs(applied_job_contexts)
62
- raise
63
- end
64
-
65
- private
66
-
67
- def scheduler_file_path
68
- configured_path = @configuration.scheduler_config_path.to_s.strip
69
- raise SchedulerConfigError, 'scheduler_config_path cannot be blank' if configured_path.empty?
70
-
71
- path = Pathname.new(configured_path)
72
- path.absolute? ? path.to_s : @rails_root.join(path).to_s
73
- end
74
-
75
- def handle_missing_file(path)
76
- message = "Scheduler file not found at #{path}"
77
- raise SchedulerConfigError, message if @configuration.scheduler_missing_file_policy == :error
78
-
79
- @logger&.warn(message)
80
- []
81
- end
82
-
83
- def parse_yaml(path)
84
- rendered = render_yaml_erb(path)
85
- parsed = YAML.safe_load(rendered) || {}
86
- raise SchedulerConfigError, "Expected scheduler YAML root to be a mapping in #{path}" unless parsed.is_a?(Hash)
87
-
88
- stringify_keys(parsed)
89
- rescue Psych::Exception => e
90
- raise SchedulerConfigError, "Failed to parse scheduler YAML at #{path}: #{e.message}"
91
- end
92
-
93
- def render_yaml_erb(path)
94
- ERB.new(File.read(path), trim_mode: '-').result
95
- rescue StandardError, SyntaxError => e
96
- raise SchedulerConfigError, "Failed to evaluate scheduler ERB at #{path}: #{e.message}"
97
- end
98
-
99
- def extract_jobs(payload)
100
- defaults = fetch_hash(payload, 'defaults')
101
- env_payload = fetch_hash(payload, @rails_env)
102
- default_jobs = defaults.fetch('jobs', [])
103
- env_jobs = env_payload.fetch('jobs', [])
104
- raise SchedulerConfigError, "Expected 'defaults.jobs' to be an array" unless default_jobs.is_a?(Array)
105
- raise SchedulerConfigError, "Expected '#{@rails_env}.jobs' to be an array" unless env_jobs.is_a?(Array)
106
-
107
- default_jobs + env_jobs
108
- end
109
-
110
- def fetch_hash(payload, key)
111
- section = payload.fetch(key)
112
-
113
- raise SchedulerConfigError, "Expected '#{key}' section to be a mapping" unless section.is_a?(Hash)
114
-
115
- section
116
- rescue KeyError
117
- {}
118
- end
119
-
120
- def validate_unique_keys(jobs)
121
- keys = jobs.map do |job_payload|
122
- raise SchedulerConfigError, "Each jobs entry must be a mapping, got #{job_payload.class}" unless job_payload.is_a?(Hash)
123
-
124
- stringify_keys(job_payload)['key'].to_s.strip
125
- end
126
- duplicates = keys.group_by(&:itself).select { |key, arr| !key.empty? && arr.size > 1 }.keys
127
- return if duplicates.empty?
128
-
129
- raise SchedulerConfigError, "Duplicate job keys in scheduler file: #{duplicates.join(', ')}"
130
- end
131
-
132
- def normalize_job(job_payload)
133
- payload = stringify_keys(job_payload)
134
- key = payload.fetch('key', '').to_s.strip
135
- raise SchedulerConfigError, 'Job key cannot be blank' if key.empty?
136
-
137
- cron = extract_required_string(payload, field: 'cron', error_prefix: "Job cron cannot be blank for key '#{key}'")
138
- job_class_name = extract_required_string(
139
- payload, field: 'job_class', error_prefix: "Job class cannot be blank for key '#{key}'"
140
- )
141
- validate_cron(key:, cron:)
142
- options = extract_job_options(payload, key:)
143
-
144
- {
145
- key:,
146
- cron:,
147
- job_class_name:,
148
- **options
149
- }
150
- end
151
-
152
- def extract_required_string(payload, field:, error_prefix:)
153
- value = payload.fetch(field, '').to_s.strip
154
-
155
- raise SchedulerConfigError, error_prefix if value.empty?
156
-
157
- value
158
- end
159
-
160
- def validate_cron(key:, cron:)
161
- return if Kaal.valid?(cron)
162
-
163
- raise SchedulerConfigError, "Invalid cron expression '#{cron}' for key '#{key}'"
164
- end
165
-
166
- def extract_job_options(payload, key:)
167
- metadata, args, kwargs, queue, enabled_value = payload.values_at('metadata', 'args', 'kwargs', 'queue', 'enabled')
168
- args ||= []
169
- kwargs ||= {}
170
- enabled = true
171
- if payload.key?('enabled')
172
- raise SchedulerConfigError, "enabled must be a boolean for key '#{key}'" unless enabled_value.is_a?(TrueClass) || enabled_value.is_a?(FalseClass)
173
-
174
- enabled = enabled_value
175
- end
176
-
177
- raise SchedulerConfigError, "metadata must be a mapping for key '#{key}'" if metadata && !metadata.is_a?(Hash)
178
-
179
- validate_job_option_types(key:, args:, kwargs:, queue:)
180
-
181
- validate_placeholders(args, key:)
182
- validate_placeholders(kwargs, key:)
183
-
184
- { queue: queue, args: args.deep_dup, kwargs: kwargs.deep_dup, enabled: enabled, metadata: metadata ? metadata.deep_dup : {} }
185
- end
186
-
187
- def validate_job_option_types(key:, args:, kwargs:, queue:)
188
- raise SchedulerConfigError, "args must be an array for key '#{key}'" unless args.is_a?(Array)
189
- raise SchedulerConfigError, "kwargs must be a mapping for key '#{key}'" unless kwargs.is_a?(Hash)
190
- raise SchedulerConfigError, "queue must be a string for key '#{key}'" if queue && !queue.is_a?(String)
191
- return if kwargs.keys.all? { |kwargs_key| kwargs_key.is_a?(String) || kwargs_key.is_a?(Symbol) }
192
-
193
- raise SchedulerConfigError, "kwargs keys must be strings or symbols for key '#{key}'"
194
- end
195
-
196
- def apply_job(key:, cron:, job_class_name:, queue:, args:, kwargs:, enabled:, metadata:)
197
- existing_definition = @definition_registry.find_definition(key)
198
- existing_registry_entry = @registry.find(key)
199
- return if skip_due_to_conflict?(key:, existing_definition:)
200
-
201
- callback = build_callback(
202
- key: key,
203
- job_class_name: job_class_name,
204
- queue: queue,
205
- args_template: args,
206
- kwargs_template: kwargs
207
- )
208
- normalized_metadata = stringify_keys(metadata.deep_dup)
209
- persisted_metadata = normalized_metadata.deep_merge(
210
- 'execution' => {
211
- 'target' => 'active_job',
212
- 'job_class' => job_class_name,
213
- 'queue' => queue,
214
- 'args' => args,
215
- 'kwargs' => kwargs
216
- }
217
- )
218
-
219
- @definition_registry.upsert_definition(
220
- key: key,
221
- cron: cron,
222
- enabled: enabled,
223
- source: 'file',
224
- metadata: persisted_metadata
225
- )
226
-
227
- begin
228
- @registry.upsert(key: key, cron: cron, enqueue: callback)
229
- rescue StandardError
230
- rollback_applied_job(key:, existing_definition:, existing_registry_entry:)
231
- raise
232
- end
233
-
234
- { key: key, existing_definition: existing_definition, existing_registry_entry: existing_registry_entry }
235
- end
236
-
237
- def rollback_applied_jobs(applied_job_contexts = [])
238
- applied_job_contexts.reverse_each do |applied_job_context|
239
- rollback_applied_job(**applied_job_context)
240
- end
241
- end
242
-
243
- def rollback_applied_job(key:, existing_definition:, existing_registry_entry:)
244
- if existing_definition
245
- definition_attributes = existing_definition.slice(:key, :cron, :enabled, :source, :metadata)
246
- @definition_registry.upsert_definition(**definition_attributes)
247
- else
248
- @definition_registry.remove_definition(key)
249
- end
250
-
251
- @registry.remove(key) if @registry.registered?(key)
252
-
253
- return unless existing_registry_entry
254
-
255
- @registry.upsert(
256
- key: existing_registry_entry.key,
257
- cron: existing_registry_entry.cron,
258
- enqueue: existing_registry_entry.enqueue
259
- )
260
- rescue StandardError => e
261
- @logger&.error("Failed to rollback scheduler file application for #{key}: #{e.message}")
262
- end
263
-
264
- def skip_due_to_conflict?(key:, existing_definition:)
265
- existing_source = existing_definition&.[](:source)
266
- return false unless existing_source && existing_source.to_s != 'file'
267
-
268
- policy = @configuration.scheduler_conflict_policy
269
- case policy
270
- when :error
271
- raise SchedulerConfigError, "Scheduler key conflict for '#{key}' with existing source '#{existing_source}'"
272
- when :code_wins
273
- @logger&.warn("Skipping scheduler file job '#{key}' because scheduler_conflict_policy is :code_wins")
274
- true
275
- when :file_wins
276
- false
277
- else
278
- raise SchedulerConfigError, "Unsupported scheduler_conflict_policy '#{policy}'"
279
- end
280
- end
281
-
282
- def build_callback(key:, job_class_name:, queue:, args_template:, kwargs_template:)
283
- job_class = resolve_job_class(job_class_name:, key:)
284
- lambda do |fire_time:, idempotency_key:|
285
- context = {
286
- fire_time: fire_time,
287
- idempotency_key: idempotency_key,
288
- key: key
289
- }
290
- resolved_args = resolve_placeholders(args_template.deep_dup, context)
291
- raw_kwargs = resolve_placeholders(kwargs_template.deep_dup, context) || {}
292
- raise SchedulerConfigError, "kwargs for scheduler job '#{key}' must be a mapping, got #{raw_kwargs.class}" unless raw_kwargs.is_a?(Hash)
293
-
294
- keys = raw_kwargs.keys
295
- index = 0
296
- while index < keys.length
297
- kwargs_key = keys[index]
298
- unless kwargs_key.is_a?(String) || kwargs_key.is_a?(Symbol)
299
- raise SchedulerConfigError,
300
- "Invalid keyword argument key #{kwargs_key.inspect} (#{kwargs_key.class}) for scheduler job '#{key}'"
301
- end
302
-
303
- index += 1
304
- end
305
-
306
- resolved_kwargs = raw_kwargs.transform_keys(&:to_sym)
307
-
308
- target = queue ? job_class.set(queue: queue) : job_class
309
- target.perform_later(*resolved_args, **resolved_kwargs)
310
- end
311
- end
312
-
313
- def resolve_job_class(job_class_name:, key:)
314
- job_class = job_class_name.safe_constantize
315
- raise SchedulerConfigError, "Unknown job_class '#{job_class_name}' for key '#{key}'" unless job_class
316
- raise SchedulerConfigError, "job_class '#{job_class_name}' must inherit from ActiveJob::Base for key '#{key}'" unless job_class <= ActiveJob::Base
317
-
318
- job_class
319
- end
320
- end
321
- end