kaal 0.2.1 → 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 +4 -4
- data/README.md +79 -287
- 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/active_record_support.rb +82 -0
- 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/mysql.rb +41 -0
- data/lib/kaal/backend/postgres.rb +41 -0
- data/lib/kaal/backend/redis_adapter.rb +6 -6
- data/lib/kaal/backend/sqlite.rb +41 -0
- 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/database_engine.rb +54 -16
- 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/database_engine.rb +87 -61
- 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/internal/active_record/base_record.rb +16 -0
- data/lib/kaal/internal/active_record/connection_support.rb +96 -0
- data/lib/kaal/internal/active_record/database_backend.rb +73 -0
- data/lib/kaal/internal/active_record/definition_record.rb +16 -0
- data/lib/kaal/internal/active_record/definition_registry.rb +81 -0
- data/lib/kaal/internal/active_record/dispatch_record.rb +16 -0
- data/lib/kaal/internal/active_record/dispatch_registry.rb +100 -0
- data/lib/kaal/internal/active_record/lock_record.rb +16 -0
- data/lib/kaal/internal/active_record/migration_templates.rb +108 -0
- data/lib/kaal/internal/active_record/mysql_backend.rb +71 -0
- data/lib/kaal/internal/active_record/postgres_backend.rb +69 -0
- data/lib/kaal/internal/active_record.rb +17 -0
- data/lib/kaal/internal/sequel/database_backend.rb +74 -0
- data/lib/kaal/internal/sequel/mysql_backend.rb +69 -0
- data/lib/kaal/internal/sequel/postgres_backend.rb +67 -0
- data/lib/kaal/internal/sequel.rb +12 -0
- data/lib/kaal/persistence/database.rb +35 -0
- data/lib/kaal/persistence/migration_templates.rb +97 -0
- data/lib/kaal/register_conflict_support.rb +0 -1
- data/lib/kaal/registry.rb +0 -3
- 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/sequel_support.rb +82 -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 +83 -397
- metadata +87 -42
- 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/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/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
|
|
@@ -1,45 +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
|
-
module Kaal
|
|
9
|
-
# Shared deep hash key transformation helpers for scheduler payloads.
|
|
10
|
-
module SchedulerHashTransform
|
|
11
|
-
TO_STRING = :to_s.to_proc
|
|
12
|
-
TO_SYMBOL = ->(key) { key.to_s.to_sym }
|
|
13
|
-
|
|
14
|
-
private
|
|
15
|
-
|
|
16
|
-
def stringify_keys(object)
|
|
17
|
-
deep_transform(object, key_transform: TO_STRING)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def symbolize_keys_deep(object)
|
|
21
|
-
deep_transform(object, key_transform: TO_SYMBOL)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def deep_transform(object, key_transform:)
|
|
25
|
-
case object
|
|
26
|
-
when Hash
|
|
27
|
-
deep_transform_hash(object, key_transform:)
|
|
28
|
-
when Array
|
|
29
|
-
deep_transform_array(object, key_transform:)
|
|
30
|
-
else
|
|
31
|
-
object
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def deep_transform_hash(object, key_transform:)
|
|
36
|
-
object.each_with_object({}) do |(key, child), memo|
|
|
37
|
-
memo[key_transform.call(key)] = deep_transform(child, key_transform:)
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def deep_transform_array(object, key_transform:)
|
|
42
|
-
object.map { |child| deep_transform(child, key_transform:) }
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|