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,134 @@
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 'digest'
9
+ require 'socket'
10
+ require_relative 'dispatch_logging'
11
+ require_relative '../definition/database_engine'
12
+
13
+ module Kaal
14
+ module Backend
15
+ ##
16
+ # Distributed backend adapter using PostgreSQL advisory locks.
17
+ #
18
+ # This adapter uses PostgreSQL's pg_try_advisory_lock function for
19
+ # distributed locking across multiple nodes. Locks are connection-based
20
+ # and automatically released when the database connection is closed.
21
+ #
22
+ # **IMPORTANT LIMITATIONS:**
23
+ # - The +ttl+ parameter is ignored. Locks do not auto-expire based on time;
24
+ # they persist until the database connection terminates.
25
+ # - If a process crashes while holding a lock, the lock will remain held until
26
+ # the connection timeout occurs (typically 30-60 minutes). For critical systems,
27
+ # consider monitoring stale locks or using a time-based fallback mechanism.
28
+ # - Ensure connection pooling is properly configured to release connections
29
+ # promptly when processes terminate.
30
+ #
31
+ # Optionally logs all dispatch attempts to the database when
32
+ # enable_log_dispatch_registry is enabled in configuration.
33
+ #
34
+ # @example Using the PostgreSQL adapter
35
+ # Kaal.configure do |config|
36
+ # config.backend = Kaal::Backend::PostgresAdapter.new
37
+ # config.enable_log_dispatch_registry = true # Enable dispatch logging
38
+ # end
39
+ class PostgresAdapter < Adapter
40
+ include DispatchLogging
41
+
42
+ ##
43
+ # Initialize a new PostgreSQL adapter.
44
+ def initialize
45
+ super
46
+ @signed_64_max = 9_223_372_036_854_775_807
47
+ @unsigned_64_range = 18_446_744_073_709_551_616
48
+ @false_value_pattern = /\A(f|false|0|)\z/i
49
+ end
50
+
51
+ ##
52
+ # Get the dispatch registry for database logging.
53
+ #
54
+ # @return [Kaal::Dispatch::DatabaseEngine] database engine instance
55
+ def dispatch_registry
56
+ @dispatch_registry ||= Kaal::Dispatch::DatabaseEngine.new
57
+ end
58
+
59
+ ##
60
+ # Get the definition registry for database-backed definition persistence.
61
+ #
62
+ # @return [Kaal::Definition::DatabaseEngine] database definition engine instance
63
+ def definition_registry
64
+ @definition_registry ||= Kaal::Definition::DatabaseEngine.new
65
+ end
66
+
67
+ ##
68
+ # Attempt to acquire a distributed lock using PostgreSQL advisory lock.
69
+ #
70
+ # Converts the lock key to a deterministic 64-bit integer hash and attempts
71
+ # to acquire the advisory lock. If successful, logs the dispatch attempt
72
+ # when enable_log_dispatch_registry is enabled.
73
+ #
74
+ # **Note:** The +ttl+ parameter is ignored. PostgreSQL advisory locks are
75
+ # connection-based and do not auto-expire. The lock will be held until the
76
+ # database connection is closed. See class documentation for limitations.
77
+ #
78
+ # @param key [String] the lock key (format: "namespace:dispatch:cron_key:fire_time")
79
+ # @param ttl [Integer] time-to-live in seconds (ignored; see class docs)
80
+ # @return [Boolean] true if acquired, false if held by another process
81
+ def acquire(key, _ttl)
82
+ lock_id = calculate_lock_id(key)
83
+
84
+ sql = ActiveRecord::Base.sanitize_sql_array(['SELECT pg_try_advisory_lock(?)', lock_id])
85
+ acquired = cast_to_boolean(ActiveRecord::Base.connection.execute(sql).first['pg_try_advisory_lock'])
86
+
87
+ log_dispatch_attempt(key) if acquired
88
+
89
+ acquired
90
+ rescue StandardError => e
91
+ raise LockAdapterError, "PostgreSQL acquire failed for #{key}: #{e.message}"
92
+ end
93
+
94
+ ##
95
+ # Release a distributed lock held by PostgreSQL advisory lock.
96
+ #
97
+ # @param key [String] the lock key
98
+ # @return [Boolean] true if released, false if not held
99
+ def release(key)
100
+ lock_id = calculate_lock_id(key)
101
+
102
+ sql = ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_unlock(?)', lock_id])
103
+ cast_to_boolean(ActiveRecord::Base.connection.execute(sql).first['pg_advisory_unlock'])
104
+ rescue StandardError => e
105
+ raise LockAdapterError, "PostgreSQL release failed for #{key}: #{e.message}"
106
+ end
107
+
108
+ private
109
+
110
+ def cast_to_boolean(value)
111
+ # PostgreSQL's `.execute` returns "t"/"f" strings for boolean columns,
112
+ # not Ruby true/false. Explicitly cast to boolean for proper semantics.
113
+ case value
114
+ when 't'
115
+ true
116
+ when 'f'
117
+ false
118
+ when true, false
119
+ value
120
+ else
121
+ !value.to_s.match?(@false_value_pattern)
122
+ end
123
+ end
124
+
125
+ def calculate_lock_id(key)
126
+ # Use MD5 hash of the key and convert to 64-bit signed integer
127
+ # Ensure it's in the range of a signed 64-bit integer
128
+ hash = Digest::MD5.digest(key).unpack1('Q>')
129
+ # Convert to signed 64-bit integer
130
+ hash > @signed_64_max ? hash - @unsigned_64_range : hash
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,145 @@
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 'securerandom'
9
+ require_relative 'dispatch_logging'
10
+ require_relative '../definition/redis_engine'
11
+
12
+ module Kaal
13
+ module Backend
14
+ ##
15
+ # Distributed backend adapter using Redis.
16
+ #
17
+ # This adapter uses Redis SET command with NX (only set if not exists) and
18
+ # PX (expire in milliseconds) options to implement atomic lock acquisition
19
+ # with automatic TTL-based expiration.
20
+ #
21
+ # The lock value is a unique identifier (UUID) to allow safe release by
22
+ # preventing deletion of locks acquired by other processes.
23
+ #
24
+ # @example Using the Redis adapter
25
+ # redis = Redis.new(url: ENV["REDIS_URL"])
26
+ # Kaal.configure do |config|
27
+ # config.backend = Kaal::Backend::RedisAdapter.new(redis)
28
+ # config.enable_log_dispatch_registry = true # Enable dispatch logging
29
+ # end
30
+ class RedisAdapter < Adapter
31
+ include DispatchLogging
32
+
33
+ ##
34
+ # Initialize a new Redis adapter.
35
+ #
36
+ # @param redis [Object] a Redis-compatible client instance
37
+ # @param namespace [String] namespace prefix for dispatch registry keys
38
+ # @raise [ArgumentError] if redis is not provided or does not implement the required interface
39
+ def initialize(redis, namespace: 'kaal')
40
+ super()
41
+ raise ArgumentError, 'redis client must respond to :set and :eval' unless redis.respond_to?(:set) && redis.respond_to?(:eval)
42
+
43
+ @redis = redis
44
+ @namespace = namespace
45
+ @lock_value_generator = -> { SecureRandom.uuid }
46
+ # Store lock values with expiration timestamps to enable safe release and prevent unbounded memory growth.
47
+ # Since lock keys include fire_time.to_i, each dispatch creates a unique key. In the coordinator's
48
+ # normal flow, release is never called (TTL is relied upon), so we must expire local entries.
49
+ @lock_values = {}
50
+ @mutex = Mutex.new
51
+ end
52
+
53
+ ##
54
+ # Get the dispatch registry for Redis logging.
55
+ #
56
+ # @return [Kaal::Dispatch::RedisEngine] Redis engine instance
57
+ def dispatch_registry
58
+ @dispatch_registry ||= Kaal::Dispatch::RedisEngine.new(@redis, namespace: @namespace)
59
+ end
60
+
61
+ ##
62
+ # Get the definition registry for Redis-backed definition persistence.
63
+ #
64
+ # @return [Kaal::Definition::RedisEngine] redis definition engine instance
65
+ def definition_registry
66
+ @definition_registry ||= Kaal::Definition::RedisEngine.new(@redis, namespace: @namespace)
67
+ end
68
+
69
+ ##
70
+ # Attempt to acquire a distributed lock in Redis.
71
+ #
72
+ # Uses SET key value NX PX ttl to atomically acquire the lock with TTL.
73
+ # Stores the lock value locally with an expiration time to enable safe release
74
+ # while preventing unbounded memory growth.
75
+ #
76
+ # @param key [String] the lock key
77
+ # @param ttl [Integer] time-to-live in seconds
78
+ # @return [Boolean] true if acquired, false if held by another process
79
+ def acquire(key, ttl)
80
+ lock_value = generate_lock_value
81
+ ttl_ms = ttl * 1000
82
+
83
+ # SET key value NX PX ttl returns OK if set, nil if not set
84
+ result = @redis.set(key, lock_value, nx: true, px: ttl_ms)
85
+
86
+ if result
87
+ @mutex.synchronize do
88
+ @lock_values[key] = { value: lock_value, expires_at: Time.now + ttl }
89
+ prune_expired_lock_values
90
+ end
91
+ end
92
+
93
+ acquired = result.present?
94
+ log_dispatch_attempt(key) if acquired
95
+
96
+ acquired
97
+ rescue StandardError => e
98
+ raise LockAdapterError, "Redis acquire failed for #{key}: #{e.message}"
99
+ end
100
+
101
+ ##
102
+ # Release a distributed lock from Redis.
103
+ #
104
+ # Safely deletes the lock only if the stored value matches the value
105
+ # we set during acquire. This prevents releasing locks acquired by other processes.
106
+ #
107
+ # @param key [String] the lock key to release
108
+ # @return [Boolean] true if released (key was held with our value), false otherwise
109
+ def release(key)
110
+ lock_entry = @mutex.synchronize do
111
+ @lock_values.delete(key)
112
+ end
113
+
114
+ return false unless lock_entry
115
+
116
+ lock_value = lock_entry[:value]
117
+
118
+ # Use a Lua script to delete only if value matches
119
+ script = <<~LUA
120
+ if redis.call('get', KEYS[1]) == ARGV[1] then
121
+ return redis.call('del', KEYS[1])
122
+ else
123
+ return 0
124
+ end
125
+ LUA
126
+
127
+ result = @redis.eval(script, keys: [key], argv: [lock_value])
128
+ result.present? && result.positive?
129
+ rescue StandardError => e
130
+ raise LockAdapterError, "Redis release failed for #{key}: #{e.message}"
131
+ end
132
+
133
+ private
134
+
135
+ def generate_lock_value
136
+ @lock_value_generator.call
137
+ end
138
+
139
+ def prune_expired_lock_values
140
+ now = Time.now
141
+ @lock_values.delete_if { |_key, entry| entry[:expires_at] <= now }
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,116 @@
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_relative 'dispatch_logging'
9
+ require_relative '../definition/database_engine'
10
+
11
+ module Kaal
12
+ module Backend
13
+ ##
14
+ # Distributed backend adapter using any ActiveRecord-backed SQL database.
15
+ #
16
+ # Despite the "SQLiteAdapter" name, this adapter works with any SQL database
17
+ # supported by Rails (SQLite, PostgreSQL, MySQL, etc.) via ActiveRecord. It stores
18
+ # locks in a database table with TTL-based expiration and uses a UNIQUE constraint
19
+ # on the key column to ensure atomicity.
20
+ #
21
+ # Suitable for single-server or development environments. For production
22
+ # multi-node deployments, use Redis or PostgreSQL adapters instead.
23
+ #
24
+ # @example Using the adapter with any SQL database
25
+ # Kaal.configure do |config|
26
+ # config.backend = Kaal::Backend::SQLiteAdapter.new
27
+ # config.enable_log_dispatch_registry = true # Enable dispatch logging
28
+ # end
29
+ class SQLiteAdapter < Adapter
30
+ include DispatchLogging
31
+
32
+ ##
33
+ # Initialize a new database-backed adapter.
34
+ #
35
+ # @return [SQLiteAdapter] a new instance
36
+ # (Note: Despite the class name, this works with any ActiveRecord SQL database)
37
+
38
+ ##
39
+ # Get the dispatch registry for database logging.
40
+ #
41
+ # @return [Kaal::Dispatch::DatabaseEngine] database engine instance
42
+ def dispatch_registry
43
+ @dispatch_registry ||= Kaal::Dispatch::DatabaseEngine.new
44
+ end
45
+
46
+ ##
47
+ # Get the definition registry for database-backed definition persistence.
48
+ #
49
+ # @return [Kaal::Definition::DatabaseEngine] database definition engine instance
50
+ def definition_registry
51
+ @definition_registry ||= Kaal::Definition::DatabaseEngine.new
52
+ end
53
+
54
+ ##
55
+ # Attempt to acquire a distributed lock in the database.
56
+ #
57
+ # Attempts to insert a new lock record. If the key already exists, cleans up
58
+ # any expired locks and retries once. This avoids unnecessary cleanup in the
59
+ # common case and reduces the window for race conditions.
60
+ #
61
+ # @param key [String] the lock key
62
+ # @param ttl [Integer] time-to-live in seconds
63
+ # @return [Boolean] true if acquired, false if held by another process
64
+ def acquire(key, ttl)
65
+ now = Time.current
66
+ expires_at = now + ttl.seconds
67
+ acquired = false
68
+ attempt_cleanup = false
69
+
70
+ 2.times do
71
+ Kaal::CronLock.cleanup_expired if attempt_cleanup
72
+ begin
73
+ Kaal::CronLock.create!(
74
+ key: key,
75
+ acquired_at: now,
76
+ expires_at: expires_at
77
+ )
78
+ acquired = true
79
+ break
80
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
81
+ attempt_cleanup = true
82
+ rescue ActiveRecord::StatementInvalid => e
83
+ raise unless wrapped_contention_error?(e)
84
+
85
+ attempt_cleanup = true
86
+ end
87
+ end
88
+
89
+ log_dispatch_attempt(key) if acquired
90
+
91
+ acquired
92
+ rescue StandardError => e
93
+ raise LockAdapterError, "SQLite acquire failed for #{key}: #{e.message}"
94
+ end
95
+
96
+ ##
97
+ # Release a previously acquired lock.
98
+ #
99
+ # @param key [String] the lock key to release
100
+ # @return [Boolean] true if released (key existed and was deleted), false if not held
101
+ def release(key)
102
+ deleted = Kaal::CronLock.where(key: key).delete_all
103
+ deleted.positive?
104
+ rescue StandardError => e
105
+ raise LockAdapterError, "SQLite release failed for #{key}: #{e.message}"
106
+ end
107
+
108
+ private
109
+
110
+ def wrapped_contention_error?(error)
111
+ cause = error.cause
112
+ cause.is_a?(ActiveRecord::RecordNotUnique) || error.message.match?(/unique|duplicate/i)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,231 @@
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
+ ##
10
+ # Configuration class for Kaal
11
+ # Holds all settings for the scheduler, tick intervals, locks, and more.
12
+ #
13
+ # @example Basic configuration
14
+ # Kaal.configure do |config|
15
+ # config.tick_interval = 5
16
+ # config.backend = Kaal::Backend::RedisAdapter.new(Redis.new(url: ENV["REDIS_URL"]))
17
+ # end
18
+ class Configuration
19
+ # Default values for all configuration options
20
+ DEFAULTS = {
21
+ tick_interval: 5,
22
+ window_lookback: 120,
23
+ window_lookahead: 0,
24
+ lease_ttl: 125, # Must be >= window_lookback + tick_interval (120 + 5 = 125)
25
+ namespace: 'kaal',
26
+ backend: nil,
27
+ logger: nil,
28
+ time_zone: nil,
29
+ enable_log_dispatch_registry: false,
30
+ enable_dispatch_recovery: true,
31
+ recovery_window: 86_400, # 24 hours in seconds
32
+ recovery_startup_jitter: 5, # max random delay in seconds
33
+ scheduler_config_path: 'config/scheduler.yml',
34
+ scheduler_conflict_policy: :error,
35
+ scheduler_missing_file_policy: :warn
36
+ }.freeze
37
+
38
+ ##
39
+ # Initialize a new Configuration instance with default values.
40
+ #
41
+ # @return [Configuration] a new instance with all defaults set
42
+ def initialize
43
+ @values = DEFAULTS.dup
44
+ end
45
+
46
+ ##
47
+ # Retrieve or assign configuration values by method name.
48
+ def method_missing(method_name, *args)
49
+ handled, value = handle_known_key(method_name) do |key, setter|
50
+ setter ? set_value(key, args.first) : @values[key]
51
+ end
52
+
53
+ return value if handled
54
+
55
+ super
56
+ end
57
+
58
+ ##
59
+ # Advertise supported configuration keys for respond_to?.
60
+ def respond_to_missing?(method_name, include_private = false)
61
+ handled, value = handle_known_key(method_name) { true }
62
+ (handled && value) || super
63
+ end
64
+
65
+ ##
66
+ # Validate configuration without raising.
67
+ #
68
+ # @return [Array<String>] validation error messages
69
+ def validate
70
+ validation_errors
71
+ end
72
+
73
+ ##
74
+ # Validate the configuration settings.
75
+ # Raises errors if required settings are invalid.
76
+ #
77
+ # @raise [ConfigurationError] if validation fails
78
+ # @return [Configuration] self if validation passes
79
+ def validate!
80
+ errors = validation_errors
81
+ raise ConfigurationError, errors.join('; ') if errors.any?
82
+
83
+ self
84
+ end
85
+
86
+ ##
87
+ # Get a hash representation of the current configuration.
88
+ #
89
+ # @return [Hash] configuration as a hash
90
+ def to_h
91
+ backend = @values[:backend]
92
+ logger = @values[:logger]
93
+
94
+ {
95
+ tick_interval: @values[:tick_interval],
96
+ window_lookback: @values[:window_lookback],
97
+ window_lookahead: @values[:window_lookahead],
98
+ lease_ttl: @values[:lease_ttl],
99
+ namespace: @values[:namespace],
100
+ backend: backend&.class&.name,
101
+ logger: logger&.class&.name,
102
+ time_zone: @values[:time_zone],
103
+ enable_log_dispatch_registry: @values[:enable_log_dispatch_registry],
104
+ enable_dispatch_recovery: @values[:enable_dispatch_recovery],
105
+ recovery_window: @values[:recovery_window],
106
+ recovery_startup_jitter: @values[:recovery_startup_jitter],
107
+ scheduler_config_path: @values[:scheduler_config_path],
108
+ scheduler_conflict_policy: @values[:scheduler_conflict_policy],
109
+ scheduler_missing_file_policy: @values[:scheduler_missing_file_policy]
110
+ }
111
+ end
112
+
113
+ private
114
+
115
+ def validation_errors
116
+ errors = []
117
+ add_tick_interval_error(errors)
118
+ add_window_lookback_error(errors)
119
+ add_window_lookahead_error(errors)
120
+ add_lease_ttl_error(errors)
121
+ add_namespace_error(errors)
122
+ add_lease_ttl_window_error(errors)
123
+ add_scheduler_config_path_error(errors)
124
+ add_scheduler_conflict_policy_error(errors)
125
+ add_scheduler_missing_file_policy_error(errors)
126
+ errors
127
+ end
128
+
129
+ def add_tick_interval_error(errors)
130
+ value = @values[:tick_interval]
131
+ return unless value.to_i <= 0
132
+
133
+ errors << "tick_interval must be greater than 0, got: #{value}"
134
+ end
135
+
136
+ def add_window_lookback_error(errors)
137
+ value = @values[:window_lookback]
138
+ return unless value.to_i.negative?
139
+
140
+ errors << "window_lookback must be greater than or equal to 0, got: #{value}"
141
+ end
142
+
143
+ def add_window_lookahead_error(errors)
144
+ value = @values[:window_lookahead]
145
+ return unless value.to_i.negative?
146
+
147
+ errors << "window_lookahead must be greater than or equal to 0, got: #{value}"
148
+ end
149
+
150
+ def add_lease_ttl_error(errors)
151
+ value = @values[:lease_ttl]
152
+ return unless value.to_i <= 0
153
+
154
+ errors << "lease_ttl must be greater than 0, got: #{value}"
155
+ end
156
+
157
+ def add_namespace_error(errors)
158
+ return unless @values[:namespace].to_s.strip.empty?
159
+
160
+ errors << 'namespace cannot be blank'
161
+ end
162
+
163
+ def add_lease_ttl_window_error(errors)
164
+ lease_ttl = @values[:lease_ttl].to_i
165
+ window_lookback = @values[:window_lookback].to_i
166
+ tick_interval = @values[:tick_interval].to_i
167
+
168
+ # Skip if individual validations already failed
169
+ return if lease_ttl <= 0 || window_lookback.negative? || tick_interval <= 0
170
+
171
+ minimum_ttl = window_lookback + tick_interval
172
+ return unless lease_ttl < minimum_ttl
173
+
174
+ errors << "lease_ttl (#{lease_ttl}s) must be >= window_lookback + tick_interval (#{minimum_ttl}s) to prevent duplicate dispatch"
175
+ end
176
+
177
+ def add_scheduler_config_path_error(errors)
178
+ return unless @values[:scheduler_config_path].to_s.strip.empty?
179
+
180
+ errors << 'scheduler_config_path cannot be blank'
181
+ end
182
+
183
+ def add_scheduler_conflict_policy_error(errors)
184
+ return if %i[error code_wins file_wins].include?(@values[:scheduler_conflict_policy])
185
+
186
+ errors << 'scheduler_conflict_policy must be :error, :code_wins, or :file_wins'
187
+ end
188
+
189
+ def add_scheduler_missing_file_policy_error(errors)
190
+ return if %i[warn error].include?(@values[:scheduler_missing_file_policy])
191
+
192
+ errors << 'scheduler_missing_file_policy must be :warn or :error'
193
+ end
194
+
195
+ def handle_known_key(method_name)
196
+ name = method_name.to_s
197
+ setter = name.end_with?('=')
198
+ key = setter ? name.delete_suffix('=').to_sym : method_name.to_sym
199
+ return [false, nil] unless @values.key?(key)
200
+
201
+ [true, yield(key, setter)]
202
+ end
203
+
204
+ def set_value(key, value)
205
+ @values[key] = normalize_value(key, value)
206
+ end
207
+
208
+ def normalize_value(key, value)
209
+ return value unless @values.key?(key)
210
+
211
+ case key
212
+ when :tick_interval, :window_lookback, :window_lookahead, :lease_ttl
213
+ value.to_i
214
+ when :namespace, :scheduler_config_path
215
+ value.to_s
216
+ when :time_zone
217
+ value&.to_s
218
+ when :enable_log_dispatch_registry
219
+ value ? true : false
220
+ when :scheduler_conflict_policy, :scheduler_missing_file_policy
221
+ value&.to_sym
222
+ else
223
+ value
224
+ end
225
+ end
226
+ end
227
+
228
+ ##
229
+ # Error raised when configuration is invalid.
230
+ class ConfigurationError < StandardError; end
231
+ end