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.
- checksums.yaml +4 -4
- data/README.md +79 -287
- data/Rakefile +4 -2
- data/config/kaal.rb +15 -0
- data/config/scheduler.yml +12 -0
- data/{lib/tasks/kaal_tasks.rake → exe/kaal} +5 -3
- data/lib/kaal/active_record_support.rb +82 -0
- data/lib/kaal/backend/adapter.rb +0 -1
- data/lib/kaal/backend/dispatch_attempt_logger.rb +33 -0
- data/lib/kaal/backend/dispatch_logging.rb +36 -23
- data/lib/kaal/backend/dispatch_registry_accessor.rb +43 -0
- data/lib/kaal/backend/memory_adapter.rb +7 -5
- data/lib/kaal/backend/mysql.rb +41 -0
- data/lib/kaal/backend/postgres.rb +41 -0
- data/lib/kaal/backend/redis_adapter.rb +6 -6
- data/lib/kaal/backend/sqlite.rb +41 -0
- data/lib/kaal/cli.rb +230 -0
- data/lib/kaal/{configuration.rb → config/configuration.rb} +0 -1
- data/lib/kaal/{scheduler_config_error.rb → config/scheduler_config_error.rb} +0 -1
- data/lib/kaal/config/scheduler_time_zone_resolver.rb +50 -0
- data/lib/kaal/config.rb +19 -0
- data/lib/kaal/{coordinator.rb → core/coordinator.rb} +42 -62
- data/lib/kaal/core/enabled_entry_enumerator.rb +51 -0
- data/lib/kaal/core/occurrence_finder.rb +38 -0
- data/lib/kaal/core.rb +18 -0
- data/lib/kaal/definition/database_engine.rb +54 -16
- data/lib/kaal/definition/memory_engine.rb +11 -18
- data/lib/kaal/definition/persistence_helpers.rb +31 -0
- data/lib/kaal/definition/redis_engine.rb +9 -6
- data/lib/kaal/definition/registry.rb +24 -2
- data/lib/kaal/definitions/registration_service.rb +62 -0
- data/lib/kaal/definitions/registry_accessor.rb +33 -0
- data/lib/kaal/dispatch/database_engine.rb +87 -61
- data/lib/kaal/dispatch/memory_engine.rb +3 -4
- data/lib/kaal/dispatch/redis_engine.rb +2 -3
- data/lib/kaal/dispatch/registry.rb +0 -1
- data/lib/kaal/internal/active_record/base_record.rb +16 -0
- data/lib/kaal/internal/active_record/connection_support.rb +96 -0
- data/lib/kaal/internal/active_record/database_backend.rb +73 -0
- data/lib/kaal/internal/active_record/definition_record.rb +16 -0
- data/lib/kaal/internal/active_record/definition_registry.rb +81 -0
- data/lib/kaal/internal/active_record/dispatch_record.rb +16 -0
- data/lib/kaal/internal/active_record/dispatch_registry.rb +100 -0
- data/lib/kaal/internal/active_record/lock_record.rb +16 -0
- data/lib/kaal/internal/active_record/migration_templates.rb +108 -0
- data/lib/kaal/internal/active_record/mysql_backend.rb +71 -0
- data/lib/kaal/internal/active_record/postgres_backend.rb +69 -0
- data/lib/kaal/internal/active_record.rb +17 -0
- data/lib/kaal/internal/sequel/database_backend.rb +74 -0
- data/lib/kaal/internal/sequel/mysql_backend.rb +69 -0
- data/lib/kaal/internal/sequel/postgres_backend.rb +67 -0
- data/lib/kaal/internal/sequel.rb +12 -0
- data/lib/kaal/persistence/database.rb +35 -0
- data/lib/kaal/persistence/migration_templates.rb +97 -0
- data/lib/kaal/register_conflict_support.rb +0 -1
- data/lib/kaal/registry.rb +0 -3
- data/lib/kaal/runtime/runtime_context.rb +41 -0
- data/lib/kaal/runtime/scheduler_boot_loader.rb +52 -0
- data/lib/kaal/runtime/signal_handler_chain.rb +42 -0
- data/lib/kaal/runtime/signal_handler_installer.rb +39 -0
- data/lib/kaal/runtime.rb +20 -0
- data/lib/kaal/scheduler_file/hash_transform.rb +22 -0
- data/lib/kaal/scheduler_file/helper_bundle.rb +28 -0
- data/lib/kaal/scheduler_file/job_applier.rb +242 -0
- data/lib/kaal/scheduler_file/job_normalizer.rb +90 -0
- data/lib/kaal/scheduler_file/loader.rb +152 -0
- data/lib/kaal/scheduler_file/payload_loader.rb +95 -0
- data/lib/kaal/{scheduler_placeholder_support.rb → scheduler_file/placeholder_support.rb} +0 -1
- data/lib/kaal/scheduler_file.rb +18 -0
- data/lib/kaal/sequel_support.rb +82 -0
- data/lib/kaal/support/hash_tools.rb +93 -0
- data/lib/kaal/{cron_humanizer.rb → utils/cron_humanizer.rb} +19 -1
- data/lib/kaal/{cron_utils.rb → utils/cron_utils.rb} +0 -1
- data/lib/kaal/{idempotency_key_generator.rb → utils/idempotency_key_generator.rb} +3 -3
- data/lib/kaal/utils.rb +18 -0
- data/lib/kaal/version.rb +1 -2
- data/lib/kaal.rb +83 -397
- metadata +87 -42
- data/app/models/kaal/cron_definition.rb +0 -76
- data/app/models/kaal/cron_dispatch.rb +0 -50
- data/app/models/kaal/cron_lock.rb +0 -38
- data/lib/generators/kaal/install/install_generator.rb +0 -72
- data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +0 -21
- data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +0 -20
- data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +0 -17
- data/lib/generators/kaal/install/templates/kaal.rb.tt +0 -31
- data/lib/generators/kaal/install/templates/scheduler.yml.tt +0 -22
- data/lib/kaal/backend/mysql_adapter.rb +0 -170
- data/lib/kaal/backend/postgres_adapter.rb +0 -134
- data/lib/kaal/backend/sqlite_adapter.rb +0 -116
- data/lib/kaal/railtie.rb +0 -183
- data/lib/kaal/rake_tasks.rb +0 -184
- data/lib/kaal/scheduler_file_loader.rb +0 -321
- data/lib/kaal/scheduler_hash_transform.rb +0 -45
|
@@ -1,170 +0,0 @@
|
|
|
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 'socket'
|
|
9
|
-
require 'digest'
|
|
10
|
-
require_relative 'dispatch_logging'
|
|
11
|
-
require_relative '../definition/database_engine'
|
|
12
|
-
|
|
13
|
-
module Kaal
|
|
14
|
-
module Backend
|
|
15
|
-
##
|
|
16
|
-
# Distributed backend adapter using MySQL named locks (GET_LOCK/RELEASE_LOCK).
|
|
17
|
-
#
|
|
18
|
-
# This adapter uses MySQL's GET_LOCK and RELEASE_LOCK functions 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
|
-
# - Locks are connection-scoped: if a process crashes, the lock persists until
|
|
24
|
-
# the database connection timeout occurs (typically 28,800 seconds or 8 hours).
|
|
25
|
-
# For critical systems, consider monitoring stale locks or using a time-based
|
|
26
|
-
# fallback mechanism.
|
|
27
|
-
# - MySQL named locks have a maximum length of 64 characters. Lock keys longer than
|
|
28
|
-
# 64 characters use a deterministic hash-based shortening scheme (prefix + SHA256
|
|
29
|
-
# digest) to avoid collisions while respecting the limit.
|
|
30
|
-
# - Uses non-blocking acquisition: GET_LOCK is called with timeout=0 for immediate
|
|
31
|
-
# return (does not block waiting for the lock).
|
|
32
|
-
# - Ensure connection pooling is properly configured to release connections
|
|
33
|
-
# promptly when processes terminate.
|
|
34
|
-
#
|
|
35
|
-
# Optionally logs all dispatch attempts to the database when
|
|
36
|
-
# enable_log_dispatch_registry is enabled in configuration.
|
|
37
|
-
#
|
|
38
|
-
# @example Using the MySQL adapter
|
|
39
|
-
# Kaal.configure do |config|
|
|
40
|
-
# config.backend = Kaal::Backend::MySQLAdapter.new
|
|
41
|
-
# config.enable_log_dispatch_registry = true # Enable dispatch logging
|
|
42
|
-
# end
|
|
43
|
-
class MySQLAdapter < Adapter
|
|
44
|
-
include DispatchLogging
|
|
45
|
-
|
|
46
|
-
# MySQL named locks have a maximum length of 64 characters
|
|
47
|
-
MAX_LOCK_NAME_LENGTH = 64
|
|
48
|
-
|
|
49
|
-
def initialize
|
|
50
|
-
super
|
|
51
|
-
@lock_name_length_limit = MAX_LOCK_NAME_LENGTH
|
|
52
|
-
@false_value_pattern = /\A(0|f|false|)\z/i
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
##
|
|
56
|
-
# Initialize a new MySQL adapter.
|
|
57
|
-
|
|
58
|
-
##
|
|
59
|
-
# Get the dispatch registry for database logging.
|
|
60
|
-
#
|
|
61
|
-
# @return [Kaal::Dispatch::DatabaseEngine] database engine instance
|
|
62
|
-
def dispatch_registry
|
|
63
|
-
@dispatch_registry ||= Kaal::Dispatch::DatabaseEngine.new
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
##
|
|
67
|
-
# Get the definition registry for database-backed definition persistence.
|
|
68
|
-
#
|
|
69
|
-
# @return [Kaal::Definition::DatabaseEngine] database definition engine instance
|
|
70
|
-
def definition_registry
|
|
71
|
-
@definition_registry ||= Kaal::Definition::DatabaseEngine.new
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
##
|
|
75
|
-
# Attempt to acquire a distributed lock using MySQL GET_LOCK.
|
|
76
|
-
#
|
|
77
|
-
# Uses MySQL's GET_LOCK(name, timeout) function with a timeout of 0 seconds
|
|
78
|
-
# to perform non-blocking acquisition. If successful, logs the dispatch
|
|
79
|
-
# attempt when enable_log_dispatch_registry is enabled.
|
|
80
|
-
#
|
|
81
|
-
# **Note:** The +ttl+ parameter is ignored. MySQL named locks are connection-based
|
|
82
|
-
# and do not have automatic expiration. The lock will be held until explicitly
|
|
83
|
-
# released or the database connection is closed. See class documentation for
|
|
84
|
-
# limitations.
|
|
85
|
-
#
|
|
86
|
-
# @param key [String] the lock key (format: "namespace:dispatch:cron_key:fire_time")
|
|
87
|
-
# @param ttl [Integer] time-to-live in seconds (ignored; see class docs)
|
|
88
|
-
# @return [Boolean] true if acquired, false if held by another process
|
|
89
|
-
def acquire(key, _ttl)
|
|
90
|
-
lock_name = normalize_lock_name(key)
|
|
91
|
-
|
|
92
|
-
# GET_LOCK returns 1 on success, 0 on timeout, NULL on error
|
|
93
|
-
sql = ActiveRecord::Base.sanitize_sql_array(['SELECT GET_LOCK(?, 0) as lock_result', lock_name])
|
|
94
|
-
result_set = ActiveRecord::Base.connection.execute(sql)
|
|
95
|
-
# Convert result to array and get first row, then first column value
|
|
96
|
-
result_row = result_set.to_a.first
|
|
97
|
-
result_value = result_row.is_a?(Hash) ? result_row['lock_result'] : result_row&.first
|
|
98
|
-
acquired = cast_to_boolean(result_value)
|
|
99
|
-
|
|
100
|
-
log_dispatch_attempt(key) if acquired
|
|
101
|
-
|
|
102
|
-
acquired
|
|
103
|
-
rescue StandardError => e
|
|
104
|
-
raise LockAdapterError, "MySQL acquire failed for #{key}: #{e.message}"
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
##
|
|
108
|
-
# Release a distributed lock held by MySQL GET_LOCK.
|
|
109
|
-
#
|
|
110
|
-
# @param key [String] the lock key
|
|
111
|
-
# @return [Boolean] true if released, false if not held
|
|
112
|
-
def release(key)
|
|
113
|
-
lock_name = normalize_lock_name(key)
|
|
114
|
-
|
|
115
|
-
# RELEASE_LOCK returns 1 if held and released, 0 if not held, NULL on error
|
|
116
|
-
sql = ActiveRecord::Base.sanitize_sql_array(['SELECT RELEASE_LOCK(?) as lock_result', lock_name])
|
|
117
|
-
result_set = ActiveRecord::Base.connection.execute(sql)
|
|
118
|
-
# Convert result to array and get first row, then first column value
|
|
119
|
-
result_row = result_set.to_a.first
|
|
120
|
-
result_value = result_row.is_a?(Hash) ? result_row['lock_result'] : result_row&.first
|
|
121
|
-
cast_to_boolean(result_value)
|
|
122
|
-
rescue StandardError => e
|
|
123
|
-
raise LockAdapterError, "MySQL release failed for #{key}: #{e.message}"
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
private
|
|
127
|
-
|
|
128
|
-
def cast_to_boolean(value)
|
|
129
|
-
# MySQL GET_LOCK/RELEASE_LOCK returns 1 (success), 0 (failure), or NULL (error).
|
|
130
|
-
# Cast integer/nil to boolean: 1 => true, 0 or nil => false.
|
|
131
|
-
case value
|
|
132
|
-
when 1
|
|
133
|
-
true
|
|
134
|
-
when 0
|
|
135
|
-
false
|
|
136
|
-
when true, false
|
|
137
|
-
value
|
|
138
|
-
else
|
|
139
|
-
!value.to_s.match?(@false_value_pattern)
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
##
|
|
144
|
-
# Normalize lock names to fit MySQL's 64-character limit.
|
|
145
|
-
#
|
|
146
|
-
# For keys exceeding the limit, uses a deterministic hash-based scheme
|
|
147
|
-
# (prefix + SHA256 digest) to avoid collisions.
|
|
148
|
-
#
|
|
149
|
-
# @param key [String] the lock key to normalize
|
|
150
|
-
# @return [String] normalized key (max 64 characters)
|
|
151
|
-
def normalize_lock_name(key)
|
|
152
|
-
return key if key.length <= @lock_name_length_limit
|
|
153
|
-
|
|
154
|
-
# Use SHA256 digest to ensure uniqueness while respecting the 64-char limit.
|
|
155
|
-
# Format: "prefix:hash" where hash is first 16 hex chars (~8 bytes entropy)
|
|
156
|
-
digest = Digest::SHA256.hexdigest(key)
|
|
157
|
-
# Reserve 17 chars for `:` + 16 hex chars, use remainder for prefix
|
|
158
|
-
prefix_length = @lock_name_length_limit - 17
|
|
159
|
-
normalized = "#{key[0...prefix_length]}:#{digest[0...16]}"
|
|
160
|
-
|
|
161
|
-
Kaal.logger&.warn(
|
|
162
|
-
"Lock key '#{key}' exceeds MySQL named lock limit of #{@lock_name_length_limit} characters. " \
|
|
163
|
-
"Using hash-based shortening to avoid collisions: '#{normalized}'."
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
normalized
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
end
|
|
@@ -1,134 +0,0 @@
|
|
|
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
|
|
@@ -1,116 +0,0 @@
|
|
|
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
|
data/lib/kaal/railtie.rb
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
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 'pathname'
|
|
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
|