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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +340 -0
- data/Rakefile +6 -0
- data/app/models/kaal/cron_definition.rb +71 -0
- data/app/models/kaal/cron_dispatch.rb +50 -0
- data/app/models/kaal/cron_lock.rb +38 -0
- data/config/locales/en.yml +46 -0
- data/lib/generators/kaal/install/install_generator.rb +67 -0
- data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +21 -0
- data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +20 -0
- data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +17 -0
- data/lib/generators/kaal/install/templates/kaal.rb.tt +31 -0
- data/lib/generators/kaal/install/templates/scheduler.yml.tt +22 -0
- data/lib/kaal/backend/adapter.rb +147 -0
- data/lib/kaal/backend/dispatch_logging.rb +79 -0
- data/lib/kaal/backend/memory_adapter.rb +99 -0
- data/lib/kaal/backend/mysql_adapter.rb +170 -0
- data/lib/kaal/backend/postgres_adapter.rb +134 -0
- data/lib/kaal/backend/redis_adapter.rb +145 -0
- data/lib/kaal/backend/sqlite_adapter.rb +116 -0
- data/lib/kaal/configuration.rb +231 -0
- data/lib/kaal/coordinator.rb +437 -0
- data/lib/kaal/cron_humanizer.rb +182 -0
- data/lib/kaal/cron_utils.rb +233 -0
- data/lib/kaal/definition/database_engine.rb +45 -0
- data/lib/kaal/definition/memory_engine.rb +61 -0
- data/lib/kaal/definition/redis_engine.rb +93 -0
- data/lib/kaal/definition/registry.rb +46 -0
- data/lib/kaal/dispatch/database_engine.rb +94 -0
- data/lib/kaal/dispatch/memory_engine.rb +99 -0
- data/lib/kaal/dispatch/redis_engine.rb +103 -0
- data/lib/kaal/dispatch/registry.rb +62 -0
- data/lib/kaal/idempotency_key_generator.rb +26 -0
- data/lib/kaal/railtie.rb +183 -0
- data/lib/kaal/rake_tasks.rb +184 -0
- data/lib/kaal/register_conflict_support.rb +54 -0
- data/lib/kaal/registry.rb +242 -0
- data/lib/kaal/scheduler_config_error.rb +6 -0
- data/lib/kaal/scheduler_file_loader.rb +316 -0
- data/lib/kaal/scheduler_hash_transform.rb +40 -0
- data/lib/kaal/scheduler_placeholder_support.rb +80 -0
- data/lib/kaal/version.rb +10 -0
- data/lib/kaal.rb +571 -0
- data/lib/tasks/kaal_tasks.rake +10 -0
- 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
|