kaal 0.2.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +340 -0
- data/Rakefile +6 -0
- data/app/models/kaal/cron_definition.rb +71 -0
- data/app/models/kaal/cron_dispatch.rb +50 -0
- data/app/models/kaal/cron_lock.rb +38 -0
- data/config/locales/en.yml +46 -0
- data/lib/generators/kaal/install/install_generator.rb +67 -0
- data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +21 -0
- data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +20 -0
- data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +17 -0
- data/lib/generators/kaal/install/templates/kaal.rb.tt +31 -0
- data/lib/generators/kaal/install/templates/scheduler.yml.tt +22 -0
- data/lib/kaal/backend/adapter.rb +147 -0
- data/lib/kaal/backend/dispatch_logging.rb +79 -0
- data/lib/kaal/backend/memory_adapter.rb +99 -0
- data/lib/kaal/backend/mysql_adapter.rb +170 -0
- data/lib/kaal/backend/postgres_adapter.rb +134 -0
- data/lib/kaal/backend/redis_adapter.rb +145 -0
- data/lib/kaal/backend/sqlite_adapter.rb +116 -0
- data/lib/kaal/configuration.rb +231 -0
- data/lib/kaal/coordinator.rb +437 -0
- data/lib/kaal/cron_humanizer.rb +182 -0
- data/lib/kaal/cron_utils.rb +233 -0
- data/lib/kaal/definition/database_engine.rb +45 -0
- data/lib/kaal/definition/memory_engine.rb +61 -0
- data/lib/kaal/definition/redis_engine.rb +93 -0
- data/lib/kaal/definition/registry.rb +46 -0
- data/lib/kaal/dispatch/database_engine.rb +94 -0
- data/lib/kaal/dispatch/memory_engine.rb +99 -0
- data/lib/kaal/dispatch/redis_engine.rb +103 -0
- data/lib/kaal/dispatch/registry.rb +62 -0
- data/lib/kaal/idempotency_key_generator.rb +26 -0
- data/lib/kaal/railtie.rb +183 -0
- data/lib/kaal/rake_tasks.rb +184 -0
- data/lib/kaal/register_conflict_support.rb +54 -0
- data/lib/kaal/registry.rb +242 -0
- data/lib/kaal/scheduler_config_error.rb +6 -0
- data/lib/kaal/scheduler_file_loader.rb +316 -0
- data/lib/kaal/scheduler_hash_transform.rb +40 -0
- data/lib/kaal/scheduler_placeholder_support.rb +80 -0
- data/lib/kaal/version.rb +10 -0
- data/lib/kaal.rb +571 -0
- data/lib/tasks/kaal_tasks.rake +10 -0
- metadata +142 -0
|
@@ -0,0 +1,103 @@
|
|
|
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 'json'
|
|
9
|
+
require_relative 'registry'
|
|
10
|
+
|
|
11
|
+
module Kaal
|
|
12
|
+
module Dispatch
|
|
13
|
+
##
|
|
14
|
+
# Redis-backed dispatch registry.
|
|
15
|
+
#
|
|
16
|
+
# Stores dispatch records in Redis as JSON-serialized values.
|
|
17
|
+
# Keys are automatically expired based on TTL to prevent unbounded growth.
|
|
18
|
+
#
|
|
19
|
+
# @example Usage
|
|
20
|
+
# redis = Redis.new(url: ENV['REDIS_URL'])
|
|
21
|
+
# registry = Kaal::Dispatch::RedisEngine.new(redis, namespace: 'myapp')
|
|
22
|
+
# registry.log_dispatch('daily_report', Time.current, 'node-1')
|
|
23
|
+
class RedisEngine < Registry
|
|
24
|
+
# Default TTL for dispatch records (7 days in seconds)
|
|
25
|
+
DEFAULT_TTL = 7 * 24 * 60 * 60
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# Initialize a new Redis-backed registry.
|
|
29
|
+
#
|
|
30
|
+
# @param redis [Redis] Redis client instance
|
|
31
|
+
# @param namespace [String] namespace prefix for Redis keys
|
|
32
|
+
# @param ttl [Integer] TTL in seconds for dispatch records
|
|
33
|
+
def initialize(redis, namespace: 'kaal', ttl: DEFAULT_TTL)
|
|
34
|
+
super()
|
|
35
|
+
@redis = redis
|
|
36
|
+
@namespace = namespace
|
|
37
|
+
@ttl = ttl
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# Log a dispatch attempt in Redis.
|
|
42
|
+
#
|
|
43
|
+
# @param key [String] the cron job key
|
|
44
|
+
# @param fire_time [Time] when the job was scheduled to fire
|
|
45
|
+
# @param node_id [String] identifier for the dispatching node
|
|
46
|
+
# @param status [String] dispatch status ('dispatched', 'failed', etc.)
|
|
47
|
+
# @return [Hash] the stored dispatch record
|
|
48
|
+
def log_dispatch(key, fire_time, node_id, status = 'dispatched')
|
|
49
|
+
redis_key = build_redis_key(key, fire_time)
|
|
50
|
+
record = {
|
|
51
|
+
key: key,
|
|
52
|
+
fire_time: fire_time.to_i,
|
|
53
|
+
dispatched_at: Time.current.to_i,
|
|
54
|
+
node_id: node_id,
|
|
55
|
+
status: status
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@redis.setex(redis_key, @ttl, JSON.generate(record))
|
|
59
|
+
record
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# Find a dispatch record for a specific job and fire time.
|
|
64
|
+
#
|
|
65
|
+
# @param key [String] the cron job key
|
|
66
|
+
# @param fire_time [Time] when the job was scheduled to fire
|
|
67
|
+
# @return [Hash, nil] dispatch record or nil if not found
|
|
68
|
+
def find_dispatch(key, fire_time)
|
|
69
|
+
redis_key = build_redis_key(key, fire_time)
|
|
70
|
+
value = @redis.get(redis_key)
|
|
71
|
+
return nil unless value
|
|
72
|
+
|
|
73
|
+
record = JSON.parse(value, symbolize_names: true)
|
|
74
|
+
convert_timestamps(record)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# Build a Redis key from job key and fire time.
|
|
81
|
+
#
|
|
82
|
+
# Format: "namespace:cron_dispatch:key:fire_time"
|
|
83
|
+
#
|
|
84
|
+
# @param key [String] the cron job key
|
|
85
|
+
# @param fire_time [Time] the fire time
|
|
86
|
+
# @return [String] Redis key
|
|
87
|
+
def build_redis_key(key, fire_time)
|
|
88
|
+
"#{@namespace}:cron_dispatch:#{key}:#{fire_time.to_i}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
##
|
|
92
|
+
# Convert Unix timestamps in record to Time objects.
|
|
93
|
+
#
|
|
94
|
+
# @param record [Hash] the dispatch record with Unix timestamps
|
|
95
|
+
# @return [Hash] the record with Time objects
|
|
96
|
+
def convert_timestamps(record)
|
|
97
|
+
record[:fire_time] = Time.at(record[:fire_time])
|
|
98
|
+
record[:dispatched_at] = Time.at(record[:dispatched_at])
|
|
99
|
+
record
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
module Dispatch
|
|
10
|
+
##
|
|
11
|
+
# Base abstraction for dispatch audit logging.
|
|
12
|
+
#
|
|
13
|
+
# Provides a pluggable interface for logging cron job dispatch attempts
|
|
14
|
+
# across different storage backends (memory, Redis, database, etc.).
|
|
15
|
+
#
|
|
16
|
+
# @example Implementing a custom registry
|
|
17
|
+
# class MyRegistry < Kaal::Dispatch::Registry
|
|
18
|
+
# def log_dispatch(key, fire_time, node_id, status = 'dispatched')
|
|
19
|
+
# # Your custom implementation
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# def find_dispatch(key, fire_time)
|
|
23
|
+
# # Your custom implementation
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
class Registry
|
|
27
|
+
##
|
|
28
|
+
# Log a dispatch attempt for a cron job.
|
|
29
|
+
#
|
|
30
|
+
# @param key [String] the cron job key (without namespace prefix)
|
|
31
|
+
# @param fire_time [Time] when the job was scheduled to fire
|
|
32
|
+
# @param node_id [String] identifier for the dispatching node
|
|
33
|
+
# @param status [String] dispatch status ('dispatched', 'failed', etc.)
|
|
34
|
+
# @raise [NotImplementedError] if not overridden by a subclass
|
|
35
|
+
# @return [void]
|
|
36
|
+
def log_dispatch(_key, _fire_time, _node_id, _status = 'dispatched')
|
|
37
|
+
raise NotImplementedError, "#{self.class.name} must implement #log_dispatch"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# Find a dispatch record for a specific job and fire time.
|
|
42
|
+
#
|
|
43
|
+
# @param key [String] the cron job key
|
|
44
|
+
# @param fire_time [Time] when the job was scheduled to fire
|
|
45
|
+
# @raise [NotImplementedError] if not overridden by a subclass
|
|
46
|
+
# @return [Hash, nil] dispatch record or nil if not found
|
|
47
|
+
def find_dispatch(_key, _fire_time)
|
|
48
|
+
raise NotImplementedError, "#{self.class.name} must implement #find_dispatch"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# Check if a dispatch has been logged for a specific job and fire time.
|
|
53
|
+
#
|
|
54
|
+
# @param key [String] the cron job key
|
|
55
|
+
# @param fire_time [Time] when the job was scheduled to fire
|
|
56
|
+
# @return [Boolean] true if dispatch exists, false otherwise
|
|
57
|
+
def dispatched?(key, fire_time)
|
|
58
|
+
find_dispatch(key, fire_time) ? true : false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaal
|
|
4
|
+
##
|
|
5
|
+
# Utility class for generating idempotency keys.
|
|
6
|
+
#
|
|
7
|
+
# Centralizes the key format to prevent drift between public API and internal coordinator.
|
|
8
|
+
# Format: {namespace}-{cron_key}-{fire_time_unix}
|
|
9
|
+
#
|
|
10
|
+
# @example Generate a key
|
|
11
|
+
# key = Kaal::IdempotencyKeyGenerator.call('reports:daily', Time.current, configuration: config)
|
|
12
|
+
# # => "kaal-reports:daily-1708283400"
|
|
13
|
+
class IdempotencyKeyGenerator
|
|
14
|
+
##
|
|
15
|
+
# Generate an idempotency key for a cron job.
|
|
16
|
+
#
|
|
17
|
+
# @param cron_key [String] the cron job key
|
|
18
|
+
# @param fire_time [Time] the fire time
|
|
19
|
+
# @param configuration [Configuration] the Kaal configuration instance
|
|
20
|
+
# @return [String] the formatted idempotency key
|
|
21
|
+
def self.call(cron_key, fire_time, configuration:)
|
|
22
|
+
namespace = configuration.namespace || 'kaal'
|
|
23
|
+
"#{namespace}-#{cron_key}-#{fire_time.to_i}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/kaal/railtie.rb
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
# Copyright Codevedas Inc. 2025-present
|
|
6
|
+
#
|
|
7
|
+
# This source code is licensed under the MIT license found in the
|
|
8
|
+
# LICENSE file in the root directory of this source tree.
|
|
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
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaal
|
|
4
|
+
# Register conflict handling helpers shared by Kaal singleton methods.
|
|
5
|
+
module RegisterConflictSupport
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def resolve_register_conflict(key:, cron:, enqueue:, existing_definition:, existing_entry:)
|
|
9
|
+
return unless existing_definition&.[](:source).to_s == 'file'
|
|
10
|
+
|
|
11
|
+
policy = configuration.scheduler_conflict_policy
|
|
12
|
+
case policy
|
|
13
|
+
when :error
|
|
14
|
+
raise RegistryError, "Key '#{key}' is already registered by scheduler file"
|
|
15
|
+
when :file_wins
|
|
16
|
+
configuration.logger&.warn("Skipping code registration for '#{key}' because scheduler_conflict_policy is :file_wins")
|
|
17
|
+
existing_entry
|
|
18
|
+
when :code_wins
|
|
19
|
+
replace_file_registered_definition(
|
|
20
|
+
key: key,
|
|
21
|
+
cron: cron,
|
|
22
|
+
enqueue: enqueue,
|
|
23
|
+
existing_definition: existing_definition
|
|
24
|
+
)
|
|
25
|
+
else
|
|
26
|
+
raise SchedulerConfigError, "Unsupported scheduler_conflict_policy '#{policy}'"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def replace_file_registered_definition(key:, cron:, enqueue:, existing_definition:)
|
|
31
|
+
persisted_attributes = {
|
|
32
|
+
enabled: existing_definition.fetch(:enabled, true),
|
|
33
|
+
source: 'code',
|
|
34
|
+
metadata: existing_definition.fetch(:metadata, {})
|
|
35
|
+
}
|
|
36
|
+
definition_registry.upsert_definition(key: key, cron: cron, **persisted_attributes)
|
|
37
|
+
with_registered_definition_rollback(key, existing_definition) do
|
|
38
|
+
registry.upsert(key: key, cron: cron, enqueue: enqueue)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def with_registered_definition_rollback(key, existing_definition)
|
|
43
|
+
yield
|
|
44
|
+
rescue StandardError
|
|
45
|
+
begin
|
|
46
|
+
rollback_registered_definition(key, existing_definition)
|
|
47
|
+
rescue StandardError => rollback_error
|
|
48
|
+
configuration.logger&.error("Failed to rollback persisted definition for #{key}: #{rollback_error.message}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|