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
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
#
|
|
5
5
|
# This source code is licensed under the MIT license found in the
|
|
6
6
|
# LICENSE file in the root directory of this source tree.
|
|
7
|
-
|
|
8
7
|
require_relative 'dispatch_logging'
|
|
9
8
|
require_relative '../definition/memory_engine'
|
|
10
9
|
|
|
@@ -65,10 +64,10 @@ module Kaal
|
|
|
65
64
|
acquired = @mutex.synchronize do
|
|
66
65
|
prune_expired_locks
|
|
67
66
|
expiration_time = @locks[key]
|
|
68
|
-
current_time = Time.
|
|
67
|
+
current_time = Time.now.utc
|
|
69
68
|
next false if expiration_time && expiration_time > current_time
|
|
70
69
|
|
|
71
|
-
@locks[key] = current_time + ttl
|
|
70
|
+
@locks[key] = current_time + ttl
|
|
72
71
|
true
|
|
73
72
|
end
|
|
74
73
|
|
|
@@ -84,14 +83,17 @@ module Kaal
|
|
|
84
83
|
# @return [Boolean] true if released (key was held), false if not held
|
|
85
84
|
def release(key)
|
|
86
85
|
@mutex.synchronize do
|
|
87
|
-
@locks.
|
|
86
|
+
return false unless @locks.key?(key)
|
|
87
|
+
|
|
88
|
+
@locks.delete(key)
|
|
89
|
+
true
|
|
88
90
|
end
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
private
|
|
92
94
|
|
|
93
95
|
def prune_expired_locks
|
|
94
|
-
now = Time.
|
|
96
|
+
now = Time.now.utc
|
|
95
97
|
@locks.delete_if { |_key, expiration_time| expiration_time <= now }
|
|
96
98
|
end
|
|
97
99
|
end
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
#
|
|
5
5
|
# This source code is licensed under the MIT license found in the
|
|
6
6
|
# LICENSE file in the root directory of this source tree.
|
|
7
|
-
|
|
8
7
|
require 'securerandom'
|
|
9
8
|
require_relative 'dispatch_logging'
|
|
10
9
|
require_relative '../definition/redis_engine'
|
|
@@ -83,14 +82,15 @@ module Kaal
|
|
|
83
82
|
# SET key value NX PX ttl returns OK if set, nil if not set
|
|
84
83
|
result = @redis.set(key, lock_value, nx: true, px: ttl_ms)
|
|
85
84
|
|
|
86
|
-
|
|
85
|
+
acquired = ['OK', true].include?(result)
|
|
86
|
+
|
|
87
|
+
if acquired
|
|
87
88
|
@mutex.synchronize do
|
|
88
|
-
@lock_values[key] = { value: lock_value, expires_at: Time.now + ttl }
|
|
89
|
+
@lock_values[key] = { value: lock_value, expires_at: Time.now.utc + ttl }
|
|
89
90
|
prune_expired_lock_values
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
|
|
93
|
-
acquired = result.present?
|
|
94
94
|
log_dispatch_attempt(key) if acquired
|
|
95
95
|
|
|
96
96
|
acquired
|
|
@@ -125,7 +125,7 @@ module Kaal
|
|
|
125
125
|
LUA
|
|
126
126
|
|
|
127
127
|
result = @redis.eval(script, keys: [key], argv: [lock_value])
|
|
128
|
-
|
|
128
|
+
[1, '1', true].include?(result)
|
|
129
129
|
rescue StandardError => e
|
|
130
130
|
raise LockAdapterError, "Redis release failed for #{key}: #{e.message}"
|
|
131
131
|
end
|
|
@@ -137,7 +137,7 @@ module Kaal
|
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
def prune_expired_lock_values
|
|
140
|
-
now = Time.now
|
|
140
|
+
now = Time.now.utc
|
|
141
141
|
@lock_values.delete_if { |_key, entry| entry[:expires_at] <= now }
|
|
142
142
|
end
|
|
143
143
|
end
|
data/lib/kaal/cli.rb
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
require 'thor'
|
|
8
|
+
require 'fileutils'
|
|
9
|
+
require 'fugit'
|
|
10
|
+
require 'kaal'
|
|
11
|
+
|
|
12
|
+
module Kaal
|
|
13
|
+
# Thor-powered CLI for plain-Ruby usage.
|
|
14
|
+
class CLI < Thor
|
|
15
|
+
# Internal instance helpers excluded from the public Thor command surface.
|
|
16
|
+
module Helpers
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def load_project!
|
|
20
|
+
Kaal.reset_configuration!
|
|
21
|
+
Kaal.reset_registry!
|
|
22
|
+
load config_path
|
|
23
|
+
runtime_context = RuntimeContext.default(root_path: root_path)
|
|
24
|
+
Kaal.load_scheduler_file!(runtime_context: runtime_context) if File.exist?(scheduler_path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def root_path
|
|
28
|
+
File.expand_path(options[:root])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def config_path
|
|
32
|
+
config = options[:config]
|
|
33
|
+
return File.expand_path(config) if config
|
|
34
|
+
|
|
35
|
+
File.join(root_path, 'config', 'kaal.rb')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def scheduler_path
|
|
39
|
+
File.join(root_path, 'config', 'scheduler.yml')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_config_template(backend)
|
|
43
|
+
case backend
|
|
44
|
+
when 'memory'
|
|
45
|
+
<<~RUBY
|
|
46
|
+
require 'kaal'
|
|
47
|
+
|
|
48
|
+
Kaal.configure do |config|
|
|
49
|
+
config.backend = Kaal::Backend::MemoryAdapter.new
|
|
50
|
+
config.tick_interval = 5
|
|
51
|
+
config.window_lookback = 120
|
|
52
|
+
config.lease_ttl = 125
|
|
53
|
+
config.scheduler_config_path = 'config/scheduler.yml'
|
|
54
|
+
end
|
|
55
|
+
RUBY
|
|
56
|
+
when 'redis'
|
|
57
|
+
<<~RUBY
|
|
58
|
+
require 'kaal'
|
|
59
|
+
require 'redis'
|
|
60
|
+
|
|
61
|
+
redis = Redis.new(url: ENV.fetch('REDIS_URL'))
|
|
62
|
+
|
|
63
|
+
Kaal.configure do |config|
|
|
64
|
+
config.backend = Kaal::Backend::RedisAdapter.new(redis, namespace: 'kaal')
|
|
65
|
+
config.tick_interval = 5
|
|
66
|
+
config.window_lookback = 120
|
|
67
|
+
config.lease_ttl = 125
|
|
68
|
+
config.scheduler_config_path = 'config/scheduler.yml'
|
|
69
|
+
end
|
|
70
|
+
RUBY
|
|
71
|
+
else
|
|
72
|
+
raise Thor::Error, "Unsupported backend '#{backend}'"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def scheduler_template
|
|
77
|
+
<<~YAML
|
|
78
|
+
defaults:
|
|
79
|
+
jobs:
|
|
80
|
+
- key: "example:heartbeat"
|
|
81
|
+
cron: "*/5 * * * *"
|
|
82
|
+
job_class: "ExampleHeartbeatJob"
|
|
83
|
+
enabled: true
|
|
84
|
+
args:
|
|
85
|
+
- "{{fire_time.iso8601}}"
|
|
86
|
+
kwargs:
|
|
87
|
+
idempotency_key: "{{idempotency_key}}"
|
|
88
|
+
metadata:
|
|
89
|
+
owner: "ops"
|
|
90
|
+
YAML
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
package_name 'kaal'
|
|
95
|
+
|
|
96
|
+
class_option :root, type: :string, default: Dir.pwd, desc: 'Project root'
|
|
97
|
+
class_option :config, type: :string, desc: 'Path to config/kaal.rb'
|
|
98
|
+
|
|
99
|
+
desc 'init', 'Generate config/kaal.rb and config/scheduler.yml'
|
|
100
|
+
option :backend, type: :string, default: 'memory', enum: %w[memory redis]
|
|
101
|
+
def init
|
|
102
|
+
root = File.expand_path(options[:root])
|
|
103
|
+
backend = options[:backend]
|
|
104
|
+
writer = self.class
|
|
105
|
+
FileUtils.mkdir_p(File.join(root, 'config'))
|
|
106
|
+
|
|
107
|
+
writer.write_file(File.join(root, 'config', 'kaal.rb'), render_config_template(backend))
|
|
108
|
+
writer.write_file(File.join(root, 'config', 'scheduler.yml'), scheduler_template)
|
|
109
|
+
|
|
110
|
+
say("Initialized Kaal project for #{backend} backend")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
desc 'start', 'Start the scheduler loop in the foreground'
|
|
114
|
+
def start
|
|
115
|
+
load_project!
|
|
116
|
+
|
|
117
|
+
signal_state = {
|
|
118
|
+
graceful_shutdown_started: false,
|
|
119
|
+
shutdown_complete: false,
|
|
120
|
+
force_exit_requested: false
|
|
121
|
+
}
|
|
122
|
+
previous_handlers = Kaal::CLI.install_foreground_signal_handlers(signal_state)
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
thread = Kaal.start!
|
|
126
|
+
raise Thor::Error, 'scheduler is already running' unless thread
|
|
127
|
+
|
|
128
|
+
say('Kaal scheduler started in foreground')
|
|
129
|
+
thread.join
|
|
130
|
+
rescue Interrupt
|
|
131
|
+
raise Thor::Error, 'shutdown timed out; forced exit requested' if signal_state[:force_exit_requested]
|
|
132
|
+
|
|
133
|
+
Kaal::CLI.shutdown_scheduler(signal: 'INT', signal_state: signal_state, shell: shell)
|
|
134
|
+
ensure
|
|
135
|
+
Kaal::CLI.restore_signal_handlers(previous_handlers)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
desc 'status', 'Show scheduler status and registered jobs'
|
|
140
|
+
def status
|
|
141
|
+
load_project!
|
|
142
|
+
registered = Kaal.registered
|
|
143
|
+
say("Kaal v#{Kaal::VERSION}")
|
|
144
|
+
say("Running: #{Kaal.running?}")
|
|
145
|
+
say("Tick interval: #{Kaal.tick_interval}s")
|
|
146
|
+
say("Window lookback: #{Kaal.window_lookback}s")
|
|
147
|
+
say("Window lookahead: #{Kaal.window_lookahead}s")
|
|
148
|
+
say("Lease TTL: #{Kaal.lease_ttl}s")
|
|
149
|
+
say("Namespace: #{Kaal.namespace}")
|
|
150
|
+
say("Registered jobs: #{registered.length}")
|
|
151
|
+
registered.each { |entry| say(" - #{entry.key} (#{entry.cron})") }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
desc 'tick', 'Run a single scheduler tick'
|
|
155
|
+
def tick
|
|
156
|
+
load_project!
|
|
157
|
+
Kaal.tick!
|
|
158
|
+
say('Kaal tick completed')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
desc 'explain EXPRESSION', 'Humanize a cron expression'
|
|
162
|
+
def explain(expression)
|
|
163
|
+
say(Kaal.to_human(expression))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
desc 'next EXPRESSION', 'Print upcoming fire times'
|
|
167
|
+
option :count, type: :numeric, default: 5
|
|
168
|
+
def next(expression)
|
|
169
|
+
cron = Fugit.parse_cron(expression)
|
|
170
|
+
raise Thor::Error, "Invalid cron expression: #{expression}" unless cron
|
|
171
|
+
|
|
172
|
+
current = Time.now.utc
|
|
173
|
+
options[:count].to_i.times do
|
|
174
|
+
current = cron.next_time(current).to_t.utc
|
|
175
|
+
say(current.iso8601)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def self.exit_on_failure?
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def self.write_file(path, contents)
|
|
184
|
+
return if File.exist?(path)
|
|
185
|
+
|
|
186
|
+
File.write(path, contents)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.install_foreground_signal_handlers(signal_state)
|
|
190
|
+
installer = SignalHandlerInstaller.new
|
|
191
|
+
installer.install do |signal, previous_handler|
|
|
192
|
+
shutdown_scheduler(signal: signal, signal_state: signal_state, previous_handler: previous_handler)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.restore_signal_handlers(previous_handlers)
|
|
197
|
+
previous_handlers.each do |signal, handler|
|
|
198
|
+
Signal.trap(signal, handler)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.shutdown_scheduler(signal:, signal_state:, previous_handler: nil, shell: nil)
|
|
205
|
+
shell_instance = shell || Thor::Base.shell.new
|
|
206
|
+
return if signal_state[:shutdown_complete]
|
|
207
|
+
|
|
208
|
+
if signal_state[:graceful_shutdown_started]
|
|
209
|
+
signal_state[:force_exit_requested] = true
|
|
210
|
+
shell_instance.warn("Received #{signal} again; forcing scheduler shutdown")
|
|
211
|
+
Thread.main.raise(Interrupt)
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
signal_state[:graceful_shutdown_started] = true
|
|
216
|
+
shell_instance.say("Received #{signal}, stopping Kaal scheduler...")
|
|
217
|
+
stopped = Kaal.stop!(timeout: 30)
|
|
218
|
+
if stopped
|
|
219
|
+
signal_state[:shutdown_complete] = true
|
|
220
|
+
shell_instance.say('Kaal scheduler stopped')
|
|
221
|
+
else
|
|
222
|
+
shell_instance.warn('Kaal scheduler stop timed out; send TERM/INT again to force exit')
|
|
223
|
+
end
|
|
224
|
+
ensure
|
|
225
|
+
SignalHandlerChain.new(signal: signal, previous_handler: previous_handler, logger: Kaal.logger).call(signal)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
no_commands { include Helpers }
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
#
|
|
5
5
|
# This source code is licensed under the MIT license found in the
|
|
6
6
|
# LICENSE file in the root directory of this source tree.
|
|
7
|
-
|
|
8
7
|
module Kaal
|
|
9
8
|
# Raised when scheduler file configuration is invalid or cannot be loaded.
|
|
10
9
|
class SchedulerConfigError < StandardError; end
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
require 'tzinfo'
|
|
8
|
+
|
|
9
|
+
module Kaal
|
|
10
|
+
# Resolves the configured scheduler time zone, preferring explicit config.
|
|
11
|
+
class SchedulerTimeZoneResolver
|
|
12
|
+
DEFAULT_TIME_ZONE = 'UTC'
|
|
13
|
+
|
|
14
|
+
def initialize(configuration:)
|
|
15
|
+
@configuration = configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time_zone_identifier
|
|
19
|
+
zone = begin
|
|
20
|
+
Time.zone
|
|
21
|
+
rescue NoMethodError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
configured_time_zone || zone&.tzinfo&.identifier || DEFAULT_TIME_ZONE
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def configured_time_zone
|
|
30
|
+
value = normalized_time_zone_value
|
|
31
|
+
return nil if value.empty?
|
|
32
|
+
|
|
33
|
+
TZInfo::Timezone.get(value)
|
|
34
|
+
value
|
|
35
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
36
|
+
raise ConfigurationError, "Invalid time_zone configuration: #{raw_time_zone_value.inspect} (normalized: #{value.inspect})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalized_time_zone_value
|
|
40
|
+
value = raw_time_zone_value
|
|
41
|
+
return DEFAULT_TIME_ZONE if value.casecmp?(DEFAULT_TIME_ZONE)
|
|
42
|
+
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def raw_time_zone_value
|
|
47
|
+
@configuration.time_zone.to_s.strip
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/kaal/config.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
require 'kaal/config/configuration'
|
|
8
|
+
require 'kaal/config/scheduler_config_error'
|
|
9
|
+
require 'kaal/config/scheduler_time_zone_resolver'
|
|
10
|
+
|
|
11
|
+
module Kaal
|
|
12
|
+
# Configuration-related types and validation helpers.
|
|
13
|
+
module Config
|
|
14
|
+
Configuration = ::Kaal::Configuration
|
|
15
|
+
ConfigurationError = ::Kaal::ConfigurationError
|
|
16
|
+
SchedulerConfigError = ::Kaal::SchedulerConfigError
|
|
17
|
+
SchedulerTimeZoneResolver = ::Kaal::SchedulerTimeZoneResolver
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
#
|
|
5
5
|
# This source code is licensed under the MIT license found in the
|
|
6
6
|
# LICENSE file in the root directory of this source tree.
|
|
7
|
-
|
|
8
7
|
require 'fugit'
|
|
8
|
+
require_relative 'enabled_entry_enumerator'
|
|
9
|
+
require_relative 'occurrence_finder'
|
|
10
|
+
require 'kaal/config/scheduler_time_zone_resolver'
|
|
9
11
|
|
|
10
12
|
module Kaal
|
|
11
13
|
##
|
|
@@ -172,13 +174,15 @@ module Kaal
|
|
|
172
174
|
each_enabled_entry do |entry|
|
|
173
175
|
calculate_and_dispatch_due_times(entry)
|
|
174
176
|
end
|
|
177
|
+
rescue ConfigurationError => e
|
|
178
|
+
log_configuration_error('Kaal coordinator tick failed', e)
|
|
179
|
+
raise
|
|
175
180
|
rescue StandardError => e
|
|
176
|
-
|
|
177
|
-
@configuration.logger&.error("Kaal coordinator tick failed: #{e.message}")
|
|
181
|
+
log_runtime_error('Kaal coordinator tick failed', e)
|
|
178
182
|
end
|
|
179
183
|
|
|
180
184
|
def calculate_and_dispatch_due_times(entry)
|
|
181
|
-
now = Time.
|
|
185
|
+
now = Time.now.utc
|
|
182
186
|
window_start = now - @configuration.window_lookback
|
|
183
187
|
window_end = now + @configuration.window_lookahead
|
|
184
188
|
|
|
@@ -197,7 +201,7 @@ module Kaal
|
|
|
197
201
|
end
|
|
198
202
|
|
|
199
203
|
def parse_cron(cron_expression)
|
|
200
|
-
result = Fugit.parse_cron(cron_expression)
|
|
204
|
+
result = Fugit.parse_cron("#{cron_expression} #{scheduler_time_zone_resolver.time_zone_identifier}")
|
|
201
205
|
raise ArgumentError, "Invalid cron expression: #{cron_expression}" unless result
|
|
202
206
|
|
|
203
207
|
result
|
|
@@ -207,24 +211,7 @@ module Kaal
|
|
|
207
211
|
end
|
|
208
212
|
|
|
209
213
|
def find_occurrences(cron, start_time, end_time)
|
|
210
|
-
|
|
211
|
-
occurrences = []
|
|
212
|
-
current_time = start_time
|
|
213
|
-
|
|
214
|
-
while current_time <= end_time
|
|
215
|
-
next_occurrence = cron.next_time(current_time)
|
|
216
|
-
break unless next_occurrence
|
|
217
|
-
|
|
218
|
-
break if next_occurrence > end_time
|
|
219
|
-
|
|
220
|
-
occurrences << next_occurrence
|
|
221
|
-
current_time = next_occurrence + 1.second # Move past this occurrence to find the next
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
occurrences
|
|
225
|
-
rescue StandardError => e
|
|
226
|
-
@configuration.logger&.error("Failed to calculate occurrences: #{e.message}")
|
|
227
|
-
[]
|
|
214
|
+
occurrence_finder.call(cron:, start_time:, end_time:)
|
|
228
215
|
end
|
|
229
216
|
|
|
230
217
|
def dispatch_if_due(entry, fire_time, now)
|
|
@@ -233,6 +220,7 @@ module Kaal
|
|
|
233
220
|
|
|
234
221
|
logger = @configuration.logger
|
|
235
222
|
cron_key = entry.key
|
|
223
|
+
return if Kaal.configuration.enable_log_dispatch_registry && already_dispatched?(cron_key, fire_time)
|
|
236
224
|
|
|
237
225
|
# Generate a unique backend coordination key for this fire time
|
|
238
226
|
lock_key = generate_lock_key(cron_key, fire_time)
|
|
@@ -240,8 +228,8 @@ module Kaal
|
|
|
240
228
|
# Try to acquire the coordination lease
|
|
241
229
|
if acquire_lock(lock_key)
|
|
242
230
|
dispatch_work(entry, fire_time)
|
|
243
|
-
|
|
244
|
-
logger
|
|
231
|
+
elsif logger
|
|
232
|
+
logger.debug("Failed to acquire lock for #{lock_key}")
|
|
245
233
|
end
|
|
246
234
|
rescue StandardError => e
|
|
247
235
|
cron_key ||= 'unknown'
|
|
@@ -263,7 +251,7 @@ module Kaal
|
|
|
263
251
|
jitter = rand(0..@configuration.recovery_startup_jitter)
|
|
264
252
|
sleep(jitter) if jitter.positive?
|
|
265
253
|
|
|
266
|
-
current_time = Time.
|
|
254
|
+
current_time = Time.now.utc
|
|
267
255
|
recovery_window = @configuration.recovery_window
|
|
268
256
|
recovery_start = current_time - recovery_window
|
|
269
257
|
recovery_end = current_time
|
|
@@ -281,8 +269,11 @@ module Kaal
|
|
|
281
269
|
|
|
282
270
|
# Clean up old dispatch records after recovery completes
|
|
283
271
|
cleanup_old_dispatch_records(recovery_window)
|
|
272
|
+
rescue ConfigurationError => e
|
|
273
|
+
log_configuration_error('Kaal missed-run recovery failed', e, logger:)
|
|
274
|
+
raise
|
|
284
275
|
rescue StandardError => e
|
|
285
|
-
|
|
276
|
+
log_runtime_error('Error during missed-run recovery', e, logger:)
|
|
286
277
|
end
|
|
287
278
|
|
|
288
279
|
##
|
|
@@ -307,12 +298,15 @@ module Kaal
|
|
|
307
298
|
|
|
308
299
|
# Attempt to dispatch each missed occurrence
|
|
309
300
|
occurrences.each do |fire_time|
|
|
310
|
-
dispatch_if_due(entry, fire_time, Time.
|
|
301
|
+
dispatch_if_due(entry, fire_time, Time.now.utc)
|
|
311
302
|
end
|
|
312
303
|
|
|
313
304
|
occurrences_size
|
|
305
|
+
rescue ConfigurationError => e
|
|
306
|
+
log_configuration_error("Error recovering entry #{entry_key}", e, logger:)
|
|
307
|
+
raise
|
|
314
308
|
rescue StandardError => e
|
|
315
|
-
|
|
309
|
+
log_runtime_error("Error recovering entry #{entry_key}", e, logger:)
|
|
316
310
|
0
|
|
317
311
|
end
|
|
318
312
|
|
|
@@ -368,37 +362,7 @@ module Kaal
|
|
|
368
362
|
end
|
|
369
363
|
|
|
370
364
|
def each_enabled_entry(&)
|
|
371
|
-
|
|
372
|
-
logger = @configuration.logger
|
|
373
|
-
warn_iteration_failure = ->(target, error) { logger&.warn("Failed to iterate #{target}: #{error.message}") }
|
|
374
|
-
|
|
375
|
-
begin
|
|
376
|
-
definition_registry = Kaal.definition_registry
|
|
377
|
-
return each_registry_entry(&) unless definition_registry
|
|
378
|
-
|
|
379
|
-
definitions = definition_registry.enabled_definitions || []
|
|
380
|
-
use_registry_entries = definitions.empty? && definition_registry.all_definitions.to_a.empty?
|
|
381
|
-
|
|
382
|
-
definitions
|
|
383
|
-
.filter_map { |definition| build_entry_from_definition(definition) }
|
|
384
|
-
.each(&)
|
|
385
|
-
rescue StandardError => e
|
|
386
|
-
warn_iteration_failure.call('enabled definitions', e)
|
|
387
|
-
use_registry_entries = true
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
each_registry_entry(&) if use_registry_entries
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
def build_entry_from_definition(definition)
|
|
394
|
-
key = definition[:key]
|
|
395
|
-
callback_entry = @registry.find(key)
|
|
396
|
-
unless callback_entry&.enqueue
|
|
397
|
-
@configuration.logger&.warn("No enqueue callback registered for definition '#{key}', skipping")
|
|
398
|
-
return nil
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
Registry::Entry.new(key: key, cron: definition[:cron], enqueue: callback_entry.enqueue).freeze
|
|
365
|
+
enabled_entry_enumerator.each(&)
|
|
402
366
|
end
|
|
403
367
|
|
|
404
368
|
def dispatch_work(entry, fire_time)
|
|
@@ -430,8 +394,24 @@ module Kaal
|
|
|
430
394
|
@configuration.logger&.error("Sleep interrupted: #{e.message}")
|
|
431
395
|
end
|
|
432
396
|
|
|
433
|
-
def
|
|
434
|
-
@
|
|
397
|
+
def scheduler_time_zone_resolver
|
|
398
|
+
@scheduler_time_zone_resolver ||= SchedulerTimeZoneResolver.new(configuration: @configuration)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def occurrence_finder
|
|
402
|
+
@occurrence_finder ||= OccurrenceFinder.new(configuration: @configuration)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def enabled_entry_enumerator
|
|
406
|
+
@enabled_entry_enumerator ||= EnabledEntryEnumerator.new(configuration: @configuration, registry: @registry)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def log_configuration_error(prefix, error, logger: @configuration.logger)
|
|
410
|
+
logger&.error("#{prefix} due to configuration error: #{error.message}")
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def log_runtime_error(prefix, error, logger: @configuration.logger)
|
|
414
|
+
logger&.error("#{prefix}: #{error.message}")
|
|
435
415
|
end
|
|
436
416
|
end
|
|
437
417
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
module Kaal
|
|
8
|
+
# Enumerates scheduler entries from persisted definitions or the in-memory registry.
|
|
9
|
+
class EnabledEntryEnumerator
|
|
10
|
+
def initialize(configuration:, registry:, definition_registry_provider: -> { Kaal.definition_registry })
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
@registry = registry
|
|
13
|
+
@definition_registry_provider = definition_registry_provider
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def each(&)
|
|
17
|
+
resolve_entries.each(&)
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
@configuration.logger&.warn("Failed to iterate enabled definitions: #{e.message}")
|
|
20
|
+
yield_registry_entries(&)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def yield_registry_entries(&)
|
|
26
|
+
@registry.each(&)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve_entries
|
|
30
|
+
registry_entries = @registry.to_enum
|
|
31
|
+
definition_registry = @definition_registry_provider.call
|
|
32
|
+
return registry_entries unless definition_registry
|
|
33
|
+
|
|
34
|
+
definitions = definition_registry.enabled_definitions || []
|
|
35
|
+
return registry_entries if definitions.empty? && definition_registry.all_definitions.to_a.empty?
|
|
36
|
+
|
|
37
|
+
definitions.filter_map { |definition| build_entry(definition) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_entry(definition)
|
|
41
|
+
key = definition[:key]
|
|
42
|
+
callback_entry = @registry.find(key)
|
|
43
|
+
unless callback_entry&.enqueue
|
|
44
|
+
@configuration.logger&.warn("No enqueue callback registered for definition '#{key}', skipping")
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Registry::Entry.new(key: key, cron: definition[:cron], enqueue: callback_entry.enqueue).freeze
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
module Kaal
|
|
8
|
+
# Finds all absolute fire times for a parsed cron expression within a window.
|
|
9
|
+
class OccurrenceFinder
|
|
10
|
+
def initialize(configuration:)
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(cron:, start_time:, end_time:)
|
|
15
|
+
occurrences = []
|
|
16
|
+
current_time = start_time.getutc
|
|
17
|
+
normalized_end_time = end_time.getutc
|
|
18
|
+
normalized_end_time_unix = normalized_end_time.to_f
|
|
19
|
+
|
|
20
|
+
while current_time <= normalized_end_time
|
|
21
|
+
next_occurrence = cron.next_time(current_time)
|
|
22
|
+
break unless next_occurrence
|
|
23
|
+
|
|
24
|
+
next_occurrence_unix = next_occurrence.to_f
|
|
25
|
+
break if next_occurrence_unix > normalized_end_time_unix
|
|
26
|
+
|
|
27
|
+
fire_time = Time.at(next_occurrence_unix).utc
|
|
28
|
+
occurrences << fire_time
|
|
29
|
+
current_time = Time.at(next_occurrence_unix + 1).utc
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
occurrences
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
@configuration.logger&.error("Failed to calculate occurrences: #{e.message}")
|
|
35
|
+
[]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|