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.
- checksums.yaml +4 -4
- data/README.md +81 -286
- data/Rakefile +4 -2
- data/config/kaal.rb +15 -0
- data/config/scheduler.yml +12 -0
- data/{lib/tasks/kaal_tasks.rake → exe/kaal} +5 -3
- data/lib/kaal/backend/adapter.rb +0 -1
- data/lib/kaal/backend/dispatch_attempt_logger.rb +33 -0
- data/lib/kaal/backend/dispatch_logging.rb +36 -23
- data/lib/kaal/backend/dispatch_registry_accessor.rb +43 -0
- data/lib/kaal/backend/memory_adapter.rb +7 -5
- data/lib/kaal/backend/redis_adapter.rb +6 -6
- data/lib/kaal/cli.rb +230 -0
- data/lib/kaal/{configuration.rb → config/configuration.rb} +0 -1
- data/lib/kaal/{scheduler_config_error.rb → config/scheduler_config_error.rb} +0 -1
- data/lib/kaal/config/scheduler_time_zone_resolver.rb +50 -0
- data/lib/kaal/config.rb +19 -0
- data/lib/kaal/{coordinator.rb → core/coordinator.rb} +42 -62
- data/lib/kaal/core/enabled_entry_enumerator.rb +51 -0
- data/lib/kaal/core/occurrence_finder.rb +38 -0
- data/lib/kaal/core.rb +18 -0
- data/lib/kaal/definition/memory_engine.rb +11 -18
- data/lib/kaal/definition/persistence_helpers.rb +31 -0
- data/lib/kaal/definition/redis_engine.rb +9 -6
- data/lib/kaal/definition/registry.rb +24 -2
- data/lib/kaal/definitions/registration_service.rb +62 -0
- data/lib/kaal/definitions/registry_accessor.rb +33 -0
- data/lib/kaal/dispatch/memory_engine.rb +3 -4
- data/lib/kaal/dispatch/redis_engine.rb +2 -3
- data/lib/kaal/dispatch/registry.rb +0 -1
- data/lib/kaal/register_conflict_support.rb +0 -1
- data/lib/kaal/registry.rb +0 -1
- data/lib/kaal/runtime/runtime_context.rb +41 -0
- data/lib/kaal/runtime/scheduler_boot_loader.rb +52 -0
- data/lib/kaal/runtime/signal_handler_chain.rb +42 -0
- data/lib/kaal/runtime/signal_handler_installer.rb +39 -0
- data/lib/kaal/runtime.rb +20 -0
- data/lib/kaal/scheduler_file/hash_transform.rb +22 -0
- data/lib/kaal/scheduler_file/helper_bundle.rb +28 -0
- data/lib/kaal/scheduler_file/job_applier.rb +242 -0
- data/lib/kaal/scheduler_file/job_normalizer.rb +90 -0
- data/lib/kaal/scheduler_file/loader.rb +152 -0
- data/lib/kaal/scheduler_file/payload_loader.rb +95 -0
- data/lib/kaal/{scheduler_placeholder_support.rb → scheduler_file/placeholder_support.rb} +0 -1
- data/lib/kaal/scheduler_file.rb +18 -0
- data/lib/kaal/support/hash_tools.rb +93 -0
- data/lib/kaal/{cron_humanizer.rb → utils/cron_humanizer.rb} +19 -1
- data/lib/kaal/{cron_utils.rb → utils/cron_utils.rb} +0 -1
- data/lib/kaal/{idempotency_key_generator.rb → utils/idempotency_key_generator.rb} +3 -3
- data/lib/kaal/utils.rb +18 -0
- data/lib/kaal/version.rb +1 -2
- data/lib/kaal.rb +77 -397
- metadata +64 -44
- data/app/models/kaal/cron_definition.rb +0 -76
- data/app/models/kaal/cron_dispatch.rb +0 -50
- data/app/models/kaal/cron_lock.rb +0 -38
- data/lib/generators/kaal/install/install_generator.rb +0 -72
- data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +0 -21
- data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +0 -20
- data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +0 -17
- data/lib/generators/kaal/install/templates/kaal.rb.tt +0 -31
- data/lib/generators/kaal/install/templates/scheduler.yml.tt +0 -22
- data/lib/kaal/backend/mysql_adapter.rb +0 -170
- data/lib/kaal/backend/postgres_adapter.rb +0 -134
- data/lib/kaal/backend/sqlite_adapter.rb +0 -116
- data/lib/kaal/definition/database_engine.rb +0 -50
- data/lib/kaal/dispatch/database_engine.rb +0 -94
- data/lib/kaal/railtie.rb +0 -183
- data/lib/kaal/rake_tasks.rb +0 -184
- data/lib/kaal/scheduler_file_loader.rb +0 -321
- 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
|
data/lib/kaal/rake_tasks.rb
DELETED
|
@@ -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
|