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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +340 -0
  4. data/Rakefile +6 -0
  5. data/app/models/kaal/cron_definition.rb +71 -0
  6. data/app/models/kaal/cron_dispatch.rb +50 -0
  7. data/app/models/kaal/cron_lock.rb +38 -0
  8. data/config/locales/en.yml +46 -0
  9. data/lib/generators/kaal/install/install_generator.rb +67 -0
  10. data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +21 -0
  11. data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +20 -0
  12. data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +17 -0
  13. data/lib/generators/kaal/install/templates/kaal.rb.tt +31 -0
  14. data/lib/generators/kaal/install/templates/scheduler.yml.tt +22 -0
  15. data/lib/kaal/backend/adapter.rb +147 -0
  16. data/lib/kaal/backend/dispatch_logging.rb +79 -0
  17. data/lib/kaal/backend/memory_adapter.rb +99 -0
  18. data/lib/kaal/backend/mysql_adapter.rb +170 -0
  19. data/lib/kaal/backend/postgres_adapter.rb +134 -0
  20. data/lib/kaal/backend/redis_adapter.rb +145 -0
  21. data/lib/kaal/backend/sqlite_adapter.rb +116 -0
  22. data/lib/kaal/configuration.rb +231 -0
  23. data/lib/kaal/coordinator.rb +437 -0
  24. data/lib/kaal/cron_humanizer.rb +182 -0
  25. data/lib/kaal/cron_utils.rb +233 -0
  26. data/lib/kaal/definition/database_engine.rb +45 -0
  27. data/lib/kaal/definition/memory_engine.rb +61 -0
  28. data/lib/kaal/definition/redis_engine.rb +93 -0
  29. data/lib/kaal/definition/registry.rb +46 -0
  30. data/lib/kaal/dispatch/database_engine.rb +94 -0
  31. data/lib/kaal/dispatch/memory_engine.rb +99 -0
  32. data/lib/kaal/dispatch/redis_engine.rb +103 -0
  33. data/lib/kaal/dispatch/registry.rb +62 -0
  34. data/lib/kaal/idempotency_key_generator.rb +26 -0
  35. data/lib/kaal/railtie.rb +183 -0
  36. data/lib/kaal/rake_tasks.rb +184 -0
  37. data/lib/kaal/register_conflict_support.rb +54 -0
  38. data/lib/kaal/registry.rb +242 -0
  39. data/lib/kaal/scheduler_config_error.rb +6 -0
  40. data/lib/kaal/scheduler_file_loader.rb +316 -0
  41. data/lib/kaal/scheduler_hash_transform.rb +40 -0
  42. data/lib/kaal/scheduler_placeholder_support.rb +80 -0
  43. data/lib/kaal/version.rb +10 -0
  44. data/lib/kaal.rb +571 -0
  45. data/lib/tasks/kaal_tasks.rake +10 -0
  46. 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
@@ -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