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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -287
  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/active_record_support.rb +82 -0
  8. data/lib/kaal/backend/adapter.rb +0 -1
  9. data/lib/kaal/backend/dispatch_attempt_logger.rb +33 -0
  10. data/lib/kaal/backend/dispatch_logging.rb +36 -23
  11. data/lib/kaal/backend/dispatch_registry_accessor.rb +43 -0
  12. data/lib/kaal/backend/memory_adapter.rb +7 -5
  13. data/lib/kaal/backend/mysql.rb +41 -0
  14. data/lib/kaal/backend/postgres.rb +41 -0
  15. data/lib/kaal/backend/redis_adapter.rb +6 -6
  16. data/lib/kaal/backend/sqlite.rb +41 -0
  17. data/lib/kaal/cli.rb +230 -0
  18. data/lib/kaal/{configuration.rb → config/configuration.rb} +0 -1
  19. data/lib/kaal/{scheduler_config_error.rb → config/scheduler_config_error.rb} +0 -1
  20. data/lib/kaal/config/scheduler_time_zone_resolver.rb +50 -0
  21. data/lib/kaal/config.rb +19 -0
  22. data/lib/kaal/{coordinator.rb → core/coordinator.rb} +42 -62
  23. data/lib/kaal/core/enabled_entry_enumerator.rb +51 -0
  24. data/lib/kaal/core/occurrence_finder.rb +38 -0
  25. data/lib/kaal/core.rb +18 -0
  26. data/lib/kaal/definition/database_engine.rb +54 -16
  27. data/lib/kaal/definition/memory_engine.rb +11 -18
  28. data/lib/kaal/definition/persistence_helpers.rb +31 -0
  29. data/lib/kaal/definition/redis_engine.rb +9 -6
  30. data/lib/kaal/definition/registry.rb +24 -2
  31. data/lib/kaal/definitions/registration_service.rb +62 -0
  32. data/lib/kaal/definitions/registry_accessor.rb +33 -0
  33. data/lib/kaal/dispatch/database_engine.rb +87 -61
  34. data/lib/kaal/dispatch/memory_engine.rb +3 -4
  35. data/lib/kaal/dispatch/redis_engine.rb +2 -3
  36. data/lib/kaal/dispatch/registry.rb +0 -1
  37. data/lib/kaal/internal/active_record/base_record.rb +16 -0
  38. data/lib/kaal/internal/active_record/connection_support.rb +96 -0
  39. data/lib/kaal/internal/active_record/database_backend.rb +73 -0
  40. data/lib/kaal/internal/active_record/definition_record.rb +16 -0
  41. data/lib/kaal/internal/active_record/definition_registry.rb +81 -0
  42. data/lib/kaal/internal/active_record/dispatch_record.rb +16 -0
  43. data/lib/kaal/internal/active_record/dispatch_registry.rb +100 -0
  44. data/lib/kaal/internal/active_record/lock_record.rb +16 -0
  45. data/lib/kaal/internal/active_record/migration_templates.rb +108 -0
  46. data/lib/kaal/internal/active_record/mysql_backend.rb +71 -0
  47. data/lib/kaal/internal/active_record/postgres_backend.rb +69 -0
  48. data/lib/kaal/internal/active_record.rb +17 -0
  49. data/lib/kaal/internal/sequel/database_backend.rb +74 -0
  50. data/lib/kaal/internal/sequel/mysql_backend.rb +69 -0
  51. data/lib/kaal/internal/sequel/postgres_backend.rb +67 -0
  52. data/lib/kaal/internal/sequel.rb +12 -0
  53. data/lib/kaal/persistence/database.rb +35 -0
  54. data/lib/kaal/persistence/migration_templates.rb +97 -0
  55. data/lib/kaal/register_conflict_support.rb +0 -1
  56. data/lib/kaal/registry.rb +0 -3
  57. data/lib/kaal/runtime/runtime_context.rb +41 -0
  58. data/lib/kaal/runtime/scheduler_boot_loader.rb +52 -0
  59. data/lib/kaal/runtime/signal_handler_chain.rb +42 -0
  60. data/lib/kaal/runtime/signal_handler_installer.rb +39 -0
  61. data/lib/kaal/runtime.rb +20 -0
  62. data/lib/kaal/scheduler_file/hash_transform.rb +22 -0
  63. data/lib/kaal/scheduler_file/helper_bundle.rb +28 -0
  64. data/lib/kaal/scheduler_file/job_applier.rb +242 -0
  65. data/lib/kaal/scheduler_file/job_normalizer.rb +90 -0
  66. data/lib/kaal/scheduler_file/loader.rb +152 -0
  67. data/lib/kaal/scheduler_file/payload_loader.rb +95 -0
  68. data/lib/kaal/{scheduler_placeholder_support.rb → scheduler_file/placeholder_support.rb} +0 -1
  69. data/lib/kaal/scheduler_file.rb +18 -0
  70. data/lib/kaal/sequel_support.rb +82 -0
  71. data/lib/kaal/support/hash_tools.rb +93 -0
  72. data/lib/kaal/{cron_humanizer.rb → utils/cron_humanizer.rb} +19 -1
  73. data/lib/kaal/{cron_utils.rb → utils/cron_utils.rb} +0 -1
  74. data/lib/kaal/{idempotency_key_generator.rb → utils/idempotency_key_generator.rb} +3 -3
  75. data/lib/kaal/utils.rb +18 -0
  76. data/lib/kaal/version.rb +1 -2
  77. data/lib/kaal.rb +83 -397
  78. metadata +87 -42
  79. data/app/models/kaal/cron_definition.rb +0 -76
  80. data/app/models/kaal/cron_dispatch.rb +0 -50
  81. data/app/models/kaal/cron_lock.rb +0 -38
  82. data/lib/generators/kaal/install/install_generator.rb +0 -72
  83. data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +0 -21
  84. data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +0 -20
  85. data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +0 -17
  86. data/lib/generators/kaal/install/templates/kaal.rb.tt +0 -31
  87. data/lib/generators/kaal/install/templates/scheduler.yml.tt +0 -22
  88. data/lib/kaal/backend/mysql_adapter.rb +0 -170
  89. data/lib/kaal/backend/postgres_adapter.rb +0 -134
  90. data/lib/kaal/backend/sqlite_adapter.rb +0 -116
  91. data/lib/kaal/railtie.rb +0 -183
  92. data/lib/kaal/rake_tasks.rb +0 -184
  93. data/lib/kaal/scheduler_file_loader.rb +0 -321
  94. data/lib/kaal/scheduler_hash_transform.rb +0 -45
@@ -4,8 +4,7 @@
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
- require 'socket'
7
+ require_relative 'dispatch_attempt_logger'
9
8
 
10
9
  module Kaal
11
10
  module Backend
@@ -25,6 +24,10 @@ module Kaal
25
24
  # end
26
25
  # end
27
26
  module DispatchLogging
27
+ def dispatch_registry
28
+ nil
29
+ end
30
+
28
31
  ##
29
32
  # Log a dispatch attempt via the dispatch registry.
30
33
  #
@@ -33,23 +36,7 @@ module Kaal
33
36
  # @param key [String] the lock key (format: "namespace:dispatch:cron_key:fire_time")
34
37
  # @return [void]
35
38
  def log_dispatch_attempt(key)
36
- logger = nil
37
- logging_enabled = Kaal.configuration.then do |configuration|
38
- logger = configuration.logger
39
- configuration.enable_log_dispatch_registry
40
- end
41
- return unless logging_enabled
42
- return unless respond_to?(:dispatch_registry)
43
-
44
- registry = dispatch_registry
45
- return unless registry
46
-
47
- cron_key, fire_time = parse_lock_key(key)
48
- node_id = Socket.gethostname
49
-
50
- registry.log_dispatch(cron_key, fire_time, node_id, 'dispatched')
51
- rescue StandardError => e
52
- logger&.error("Failed to log dispatch for #{key}: #{e.message}")
39
+ dispatch_attempt_logger.call(key)
53
40
  end
54
41
 
55
42
  ##
@@ -67,13 +54,39 @@ module Kaal
67
54
 
68
55
  def self.parse_lock_key(key)
69
56
  parts = key.split(':')
70
- fire_time_unix = parts.pop.to_i
71
- 2.times { parts.shift } # Remove namespace and "dispatch"
72
- cron_key = parts.join(':')
73
- fire_time = Time.at(fire_time_unix)
57
+ invalid_message = "Invalid dispatch lock key format: #{key.inspect}"
58
+ dispatch_index = parts[0...-1].rindex('dispatch')
59
+ timestamp = parts[-1]
60
+ valid_key = parts.length >= 4 && dispatch_index&.positive? && timestamp.match?(/\A\d+\z/)
61
+ validate_lock_key!(valid_key, invalid_message)
62
+
63
+ fire_time_unix = timestamp.to_i
64
+ cron_key = parts[(dispatch_index + 1)...-1].join(':')
65
+ validate_lock_key!(!cron_key.empty?, invalid_message)
66
+
67
+ fire_time = Time.at(fire_time_unix).utc
74
68
 
75
69
  [cron_key, fire_time]
76
70
  end
71
+
72
+ def self.validate_lock_key!(valid, message)
73
+ invalid_dispatch_lock_key!(message) unless valid
74
+ end
75
+ private_class_method :validate_lock_key!
76
+
77
+ def self.invalid_dispatch_lock_key!(message)
78
+ raise ArgumentError, message
79
+ end
80
+ private_class_method :invalid_dispatch_lock_key!
81
+
82
+ private
83
+
84
+ def dispatch_attempt_logger
85
+ @dispatch_attempt_logger ||= DispatchAttemptLogger.new(
86
+ configuration: Kaal.configuration,
87
+ dispatch_registry_provider: -> { dispatch_registry }
88
+ )
89
+ end
77
90
  end
78
91
  end
79
92
  end
@@ -0,0 +1,43 @@
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
+ module Backend
9
+ # Reads dispatch registry state through the configured backend adapter.
10
+ class DispatchRegistryAccessor
11
+ def initialize(configuration:)
12
+ @configuration = configuration
13
+ end
14
+
15
+ def dispatched?(key, fire_time)
16
+ registry = fetch_registry
17
+ return false unless registry
18
+
19
+ registry.dispatched?(key, fire_time)
20
+ rescue StandardError => e
21
+ @configuration.logger&.warn("Error checking dispatch status for #{key}: #{e.message}")
22
+ false
23
+ end
24
+
25
+ def registry
26
+ fetch_registry
27
+ rescue StandardError => e
28
+ @configuration.logger&.warn("Error accessing dispatch registry: #{e.message}")
29
+ nil
30
+ end
31
+
32
+ private
33
+
34
+ def fetch_registry
35
+ adapter = @configuration.backend
36
+ return nil unless adapter
37
+ return nil unless adapter.respond_to?(:dispatch_registry)
38
+
39
+ adapter.dispatch_registry
40
+ end
41
+ end
42
+ end
43
+ 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_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
@@ -0,0 +1,41 @@
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
+ module Backend
9
+ # MySQL-backed backend for either Sequel or Active Record persistence.
10
+ class MySQL < Adapter
11
+ def initialize(database: nil, connection: nil, namespace: nil, **)
12
+ super()
13
+ @engine = if database
14
+ Kaal::Sequel.require_sequel!
15
+ require 'kaal/internal/sequel'
16
+ Kaal::Internal::Sequel::MySQLBackend.new(database, namespace:)
17
+ else
18
+ Kaal::ActiveRecord.require_activerecord!
19
+ require 'kaal/internal/active_record'
20
+ Kaal::Internal::ActiveRecord::MySQLBackend.new(connection, namespace:, **)
21
+ end
22
+ end
23
+
24
+ def dispatch_registry
25
+ @engine.dispatch_registry
26
+ end
27
+
28
+ def definition_registry
29
+ @engine.definition_registry
30
+ end
31
+
32
+ def acquire(key, ttl)
33
+ @engine.acquire(key, ttl)
34
+ end
35
+
36
+ def release(key)
37
+ @engine.release(key)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
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
+ module Backend
9
+ # PostgreSQL-backed backend for either Sequel or Active Record persistence.
10
+ class Postgres < Adapter
11
+ def initialize(database: nil, connection: nil, namespace: nil, **)
12
+ super()
13
+ @engine = if database
14
+ Kaal::Sequel.require_sequel!
15
+ require 'kaal/internal/sequel'
16
+ Kaal::Internal::Sequel::PostgresBackend.new(database, namespace:)
17
+ else
18
+ Kaal::ActiveRecord.require_activerecord!
19
+ require 'kaal/internal/active_record'
20
+ Kaal::Internal::ActiveRecord::PostgresBackend.new(connection, namespace:, **)
21
+ end
22
+ end
23
+
24
+ def dispatch_registry
25
+ @engine.dispatch_registry
26
+ end
27
+
28
+ def definition_registry
29
+ @engine.definition_registry
30
+ end
31
+
32
+ def acquire(key, ttl)
33
+ @engine.acquire(key, ttl)
34
+ end
35
+
36
+ def release(key)
37
+ @engine.release(key)
38
+ end
39
+ end
40
+ end
41
+ 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
@@ -0,0 +1,41 @@
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
+ module Backend
9
+ # SQLite-backed backend for either Sequel or Active Record persistence.
10
+ class SQLite < Adapter
11
+ def initialize(database: nil, connection: nil, namespace: nil, **)
12
+ super()
13
+ @engine = if database
14
+ Kaal::Sequel.require_sequel!
15
+ require 'kaal/internal/sequel'
16
+ Kaal::Internal::Sequel::DatabaseBackend.new(database, namespace:)
17
+ else
18
+ Kaal::ActiveRecord.require_activerecord!
19
+ require 'kaal/internal/active_record'
20
+ Kaal::Internal::ActiveRecord::DatabaseBackend.new(connection, namespace:, **)
21
+ end
22
+ end
23
+
24
+ def dispatch_registry
25
+ @engine.dispatch_registry
26
+ end
27
+
28
+ def definition_registry
29
+ @engine.definition_registry
30
+ end
31
+
32
+ def acquire(key, ttl)
33
+ @engine.acquire(key, ttl)
34
+ end
35
+
36
+ def release(key)
37
+ @engine.release(key)
38
+ end
39
+ end
40
+ end
41
+ 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