kaal 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -286
  3. data/Rakefile +4 -2
  4. data/config/kaal.rb +15 -0
  5. data/config/scheduler.yml +12 -0
  6. data/{lib/tasks/kaal_tasks.rake → exe/kaal} +5 -3
  7. data/lib/kaal/backend/adapter.rb +0 -1
  8. data/lib/kaal/backend/dispatch_attempt_logger.rb +33 -0
  9. data/lib/kaal/backend/dispatch_logging.rb +36 -23
  10. data/lib/kaal/backend/dispatch_registry_accessor.rb +43 -0
  11. data/lib/kaal/backend/memory_adapter.rb +7 -5
  12. data/lib/kaal/backend/redis_adapter.rb +6 -6
  13. data/lib/kaal/cli.rb +230 -0
  14. data/lib/kaal/{configuration.rb → config/configuration.rb} +0 -1
  15. data/lib/kaal/{scheduler_config_error.rb → config/scheduler_config_error.rb} +0 -1
  16. data/lib/kaal/config/scheduler_time_zone_resolver.rb +50 -0
  17. data/lib/kaal/config.rb +19 -0
  18. data/lib/kaal/{coordinator.rb → core/coordinator.rb} +42 -62
  19. data/lib/kaal/core/enabled_entry_enumerator.rb +51 -0
  20. data/lib/kaal/core/occurrence_finder.rb +38 -0
  21. data/lib/kaal/core.rb +18 -0
  22. data/lib/kaal/definition/memory_engine.rb +11 -18
  23. data/lib/kaal/definition/persistence_helpers.rb +31 -0
  24. data/lib/kaal/definition/redis_engine.rb +9 -6
  25. data/lib/kaal/definition/registry.rb +24 -2
  26. data/lib/kaal/definitions/registration_service.rb +62 -0
  27. data/lib/kaal/definitions/registry_accessor.rb +33 -0
  28. data/lib/kaal/dispatch/memory_engine.rb +3 -4
  29. data/lib/kaal/dispatch/redis_engine.rb +2 -3
  30. data/lib/kaal/dispatch/registry.rb +0 -1
  31. data/lib/kaal/register_conflict_support.rb +0 -1
  32. data/lib/kaal/registry.rb +0 -1
  33. data/lib/kaal/runtime/runtime_context.rb +41 -0
  34. data/lib/kaal/runtime/scheduler_boot_loader.rb +52 -0
  35. data/lib/kaal/runtime/signal_handler_chain.rb +42 -0
  36. data/lib/kaal/runtime/signal_handler_installer.rb +39 -0
  37. data/lib/kaal/runtime.rb +20 -0
  38. data/lib/kaal/scheduler_file/hash_transform.rb +22 -0
  39. data/lib/kaal/scheduler_file/helper_bundle.rb +28 -0
  40. data/lib/kaal/scheduler_file/job_applier.rb +242 -0
  41. data/lib/kaal/scheduler_file/job_normalizer.rb +90 -0
  42. data/lib/kaal/scheduler_file/loader.rb +152 -0
  43. data/lib/kaal/scheduler_file/payload_loader.rb +95 -0
  44. data/lib/kaal/{scheduler_placeholder_support.rb → scheduler_file/placeholder_support.rb} +0 -1
  45. data/lib/kaal/scheduler_file.rb +18 -0
  46. data/lib/kaal/support/hash_tools.rb +93 -0
  47. data/lib/kaal/{cron_humanizer.rb → utils/cron_humanizer.rb} +19 -1
  48. data/lib/kaal/{cron_utils.rb → utils/cron_utils.rb} +0 -1
  49. data/lib/kaal/{idempotency_key_generator.rb → utils/idempotency_key_generator.rb} +3 -3
  50. data/lib/kaal/utils.rb +18 -0
  51. data/lib/kaal/version.rb +1 -2
  52. data/lib/kaal.rb +77 -397
  53. metadata +64 -44
  54. data/app/models/kaal/cron_definition.rb +0 -76
  55. data/app/models/kaal/cron_dispatch.rb +0 -50
  56. data/app/models/kaal/cron_lock.rb +0 -38
  57. data/lib/generators/kaal/install/install_generator.rb +0 -72
  58. data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +0 -21
  59. data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +0 -20
  60. data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +0 -17
  61. data/lib/generators/kaal/install/templates/kaal.rb.tt +0 -31
  62. data/lib/generators/kaal/install/templates/scheduler.yml.tt +0 -22
  63. data/lib/kaal/backend/mysql_adapter.rb +0 -170
  64. data/lib/kaal/backend/postgres_adapter.rb +0 -134
  65. data/lib/kaal/backend/sqlite_adapter.rb +0 -116
  66. data/lib/kaal/definition/database_engine.rb +0 -50
  67. data/lib/kaal/dispatch/database_engine.rb +0 -94
  68. data/lib/kaal/railtie.rb +0 -183
  69. data/lib/kaal/rake_tasks.rb +0 -184
  70. data/lib/kaal/scheduler_file_loader.rb +0 -321
  71. data/lib/kaal/scheduler_hash_transform.rb +0 -45
@@ -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.current
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.seconds
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.delete(key).present?
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.current
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
- if result
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
- result.present? && result.positive?
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
  ##
10
9
  # Configuration class for Kaal
@@ -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
@@ -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
- # Log error but continue the loop
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.current
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
- # Use fugit to find all occurrences between start and end times
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
- else
244
- logger&.debug("Failed to acquire lock for #{lock_key}")
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.current
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
- logger&.error("Error during missed-run recovery: #{e.message}")
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.current)
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
- logger&.error("Error recovering entry #{entry_key}: #{e.message}")
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
- use_registry_entries = false
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 each_registry_entry(&)
434
- @registry.each(&)
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