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
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
#
|
|
5
5
|
# This source code is licensed under the MIT license found in the
|
|
6
6
|
# LICENSE file in the root directory of this source tree.
|
|
7
|
-
|
|
8
|
-
require 'socket'
|
|
7
|
+
require_relative 'dispatch_attempt_logger'
|
|
9
8
|
|
|
10
9
|
module Kaal
|
|
11
10
|
module Backend
|
|
@@ -25,6 +24,10 @@ module Kaal
|
|
|
25
24
|
# end
|
|
26
25
|
# end
|
|
27
26
|
module DispatchLogging
|
|
27
|
+
def dispatch_registry
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
28
31
|
##
|
|
29
32
|
# Log a dispatch attempt via the dispatch registry.
|
|
30
33
|
#
|
|
@@ -33,23 +36,7 @@ module Kaal
|
|
|
33
36
|
# @param key [String] the lock key (format: "namespace:dispatch:cron_key:fire_time")
|
|
34
37
|
# @return [void]
|
|
35
38
|
def log_dispatch_attempt(key)
|
|
36
|
-
|
|
37
|
-
logging_enabled = Kaal.configuration.then do |configuration|
|
|
38
|
-
logger = configuration.logger
|
|
39
|
-
configuration.enable_log_dispatch_registry
|
|
40
|
-
end
|
|
41
|
-
return unless logging_enabled
|
|
42
|
-
return unless respond_to?(:dispatch_registry)
|
|
43
|
-
|
|
44
|
-
registry = dispatch_registry
|
|
45
|
-
return unless registry
|
|
46
|
-
|
|
47
|
-
cron_key, fire_time = parse_lock_key(key)
|
|
48
|
-
node_id = Socket.gethostname
|
|
49
|
-
|
|
50
|
-
registry.log_dispatch(cron_key, fire_time, node_id, 'dispatched')
|
|
51
|
-
rescue StandardError => e
|
|
52
|
-
logger&.error("Failed to log dispatch for #{key}: #{e.message}")
|
|
39
|
+
dispatch_attempt_logger.call(key)
|
|
53
40
|
end
|
|
54
41
|
|
|
55
42
|
##
|
|
@@ -67,13 +54,39 @@ module Kaal
|
|
|
67
54
|
|
|
68
55
|
def self.parse_lock_key(key)
|
|
69
56
|
parts = key.split(':')
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
57
|
+
invalid_message = "Invalid dispatch lock key format: #{key.inspect}"
|
|
58
|
+
dispatch_index = parts[0...-1].rindex('dispatch')
|
|
59
|
+
timestamp = parts[-1]
|
|
60
|
+
valid_key = parts.length >= 4 && dispatch_index&.positive? && timestamp.match?(/\A\d+\z/)
|
|
61
|
+
validate_lock_key!(valid_key, invalid_message)
|
|
62
|
+
|
|
63
|
+
fire_time_unix = timestamp.to_i
|
|
64
|
+
cron_key = parts[(dispatch_index + 1)...-1].join(':')
|
|
65
|
+
validate_lock_key!(!cron_key.empty?, invalid_message)
|
|
66
|
+
|
|
67
|
+
fire_time = Time.at(fire_time_unix).utc
|
|
74
68
|
|
|
75
69
|
[cron_key, fire_time]
|
|
76
70
|
end
|
|
71
|
+
|
|
72
|
+
def self.validate_lock_key!(valid, message)
|
|
73
|
+
invalid_dispatch_lock_key!(message) unless valid
|
|
74
|
+
end
|
|
75
|
+
private_class_method :validate_lock_key!
|
|
76
|
+
|
|
77
|
+
def self.invalid_dispatch_lock_key!(message)
|
|
78
|
+
raise ArgumentError, message
|
|
79
|
+
end
|
|
80
|
+
private_class_method :invalid_dispatch_lock_key!
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def dispatch_attempt_logger
|
|
85
|
+
@dispatch_attempt_logger ||= DispatchAttemptLogger.new(
|
|
86
|
+
configuration: Kaal.configuration,
|
|
87
|
+
dispatch_registry_provider: -> { dispatch_registry }
|
|
88
|
+
)
|
|
89
|
+
end
|
|
77
90
|
end
|
|
78
91
|
end
|
|
79
92
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
|
4
|
+
#
|
|
5
|
+
# This source code is licensed under the MIT license found in the
|
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
|
7
|
+
module Kaal
|
|
8
|
+
module Backend
|
|
9
|
+
# Reads dispatch registry state through the configured backend adapter.
|
|
10
|
+
class DispatchRegistryAccessor
|
|
11
|
+
def initialize(configuration:)
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dispatched?(key, fire_time)
|
|
16
|
+
registry = fetch_registry
|
|
17
|
+
return false unless registry
|
|
18
|
+
|
|
19
|
+
registry.dispatched?(key, fire_time)
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
@configuration.logger&.warn("Error checking dispatch status for #{key}: #{e.message}")
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def registry
|
|
26
|
+
fetch_registry
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
@configuration.logger&.warn("Error accessing dispatch registry: #{e.message}")
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def fetch_registry
|
|
35
|
+
adapter = @configuration.backend
|
|
36
|
+
return nil unless adapter
|
|
37
|
+
return nil unless adapter.respond_to?(:dispatch_registry)
|
|
38
|
+
|
|
39
|
+
adapter.dispatch_registry
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
#
|
|
5
5
|
# This source code is licensed under the MIT license found in the
|
|
6
6
|
# LICENSE file in the root directory of this source tree.
|
|
7
|
-
|
|
8
7
|
require_relative 'dispatch_logging'
|
|
9
8
|
require_relative '../definition/memory_engine'
|
|
10
9
|
|
|
@@ -65,10 +64,10 @@ module Kaal
|
|
|
65
64
|
acquired = @mutex.synchronize do
|
|
66
65
|
prune_expired_locks
|
|
67
66
|
expiration_time = @locks[key]
|
|
68
|
-
current_time = Time.
|
|
67
|
+
current_time = Time.now.utc
|
|
69
68
|
next false if expiration_time && expiration_time > current_time
|
|
70
69
|
|
|
71
|
-
@locks[key] = current_time + ttl
|
|
70
|
+
@locks[key] = current_time + ttl
|
|
72
71
|
true
|
|
73
72
|
end
|
|
74
73
|
|
|
@@ -84,14 +83,17 @@ module Kaal
|
|
|
84
83
|
# @return [Boolean] true if released (key was held), false if not held
|
|
85
84
|
def release(key)
|
|
86
85
|
@mutex.synchronize do
|
|
87
|
-
@locks.
|
|
86
|
+
return false unless @locks.key?(key)
|
|
87
|
+
|
|
88
|
+
@locks.delete(key)
|
|
89
|
+
true
|
|
88
90
|
end
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
private
|
|
92
94
|
|
|
93
95
|
def prune_expired_locks
|
|
94
|
-
now = Time.
|
|
96
|
+
now = Time.now.utc
|
|
95
97
|
@locks.delete_if { |_key, expiration_time| expiration_time <= now }
|
|
96
98
|
end
|
|
97
99
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
|
4
|
+
#
|
|
5
|
+
# This source code is licensed under the MIT license found in the
|
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
|
7
|
+
module Kaal
|
|
8
|
+
module Backend
|
|
9
|
+
# MySQL-backed backend for either Sequel or Active Record persistence.
|
|
10
|
+
class MySQL < Adapter
|
|
11
|
+
def initialize(database: nil, connection: nil, namespace: nil, **)
|
|
12
|
+
super()
|
|
13
|
+
@engine = if database
|
|
14
|
+
Kaal::Sequel.require_sequel!
|
|
15
|
+
require 'kaal/internal/sequel'
|
|
16
|
+
Kaal::Internal::Sequel::MySQLBackend.new(database, namespace:)
|
|
17
|
+
else
|
|
18
|
+
Kaal::ActiveRecord.require_activerecord!
|
|
19
|
+
require 'kaal/internal/active_record'
|
|
20
|
+
Kaal::Internal::ActiveRecord::MySQLBackend.new(connection, namespace:, **)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def dispatch_registry
|
|
25
|
+
@engine.dispatch_registry
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def definition_registry
|
|
29
|
+
@engine.definition_registry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def acquire(key, ttl)
|
|
33
|
+
@engine.acquire(key, ttl)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def release(key)
|
|
37
|
+
@engine.release(key)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
|
4
|
+
#
|
|
5
|
+
# This source code is licensed under the MIT license found in the
|
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
|
7
|
+
module Kaal
|
|
8
|
+
module Backend
|
|
9
|
+
# PostgreSQL-backed backend for either Sequel or Active Record persistence.
|
|
10
|
+
class Postgres < Adapter
|
|
11
|
+
def initialize(database: nil, connection: nil, namespace: nil, **)
|
|
12
|
+
super()
|
|
13
|
+
@engine = if database
|
|
14
|
+
Kaal::Sequel.require_sequel!
|
|
15
|
+
require 'kaal/internal/sequel'
|
|
16
|
+
Kaal::Internal::Sequel::PostgresBackend.new(database, namespace:)
|
|
17
|
+
else
|
|
18
|
+
Kaal::ActiveRecord.require_activerecord!
|
|
19
|
+
require 'kaal/internal/active_record'
|
|
20
|
+
Kaal::Internal::ActiveRecord::PostgresBackend.new(connection, namespace:, **)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def dispatch_registry
|
|
25
|
+
@engine.dispatch_registry
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def definition_registry
|
|
29
|
+
@engine.definition_registry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def acquire(key, ttl)
|
|
33
|
+
@engine.acquire(key, ttl)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def release(key)
|
|
37
|
+
@engine.release(key)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
#
|
|
5
5
|
# This source code is licensed under the MIT license found in the
|
|
6
6
|
# LICENSE file in the root directory of this source tree.
|
|
7
|
-
|
|
8
7
|
require 'securerandom'
|
|
9
8
|
require_relative 'dispatch_logging'
|
|
10
9
|
require_relative '../definition/redis_engine'
|
|
@@ -83,14 +82,15 @@ module Kaal
|
|
|
83
82
|
# SET key value NX PX ttl returns OK if set, nil if not set
|
|
84
83
|
result = @redis.set(key, lock_value, nx: true, px: ttl_ms)
|
|
85
84
|
|
|
86
|
-
|
|
85
|
+
acquired = ['OK', true].include?(result)
|
|
86
|
+
|
|
87
|
+
if acquired
|
|
87
88
|
@mutex.synchronize do
|
|
88
|
-
@lock_values[key] = { value: lock_value, expires_at: Time.now + ttl }
|
|
89
|
+
@lock_values[key] = { value: lock_value, expires_at: Time.now.utc + ttl }
|
|
89
90
|
prune_expired_lock_values
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
|
|
93
|
-
acquired = result.present?
|
|
94
94
|
log_dispatch_attempt(key) if acquired
|
|
95
95
|
|
|
96
96
|
acquired
|
|
@@ -125,7 +125,7 @@ module Kaal
|
|
|
125
125
|
LUA
|
|
126
126
|
|
|
127
127
|
result = @redis.eval(script, keys: [key], argv: [lock_value])
|
|
128
|
-
|
|
128
|
+
[1, '1', true].include?(result)
|
|
129
129
|
rescue StandardError => e
|
|
130
130
|
raise LockAdapterError, "Redis release failed for #{key}: #{e.message}"
|
|
131
131
|
end
|
|
@@ -137,7 +137,7 @@ module Kaal
|
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
def prune_expired_lock_values
|
|
140
|
-
now = Time.now
|
|
140
|
+
now = Time.now.utc
|
|
141
141
|
@lock_values.delete_if { |_key, entry| entry[:expires_at] <= now }
|
|
142
142
|
end
|
|
143
143
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
|
4
|
+
#
|
|
5
|
+
# This source code is licensed under the MIT license found in the
|
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
|
7
|
+
module Kaal
|
|
8
|
+
module Backend
|
|
9
|
+
# SQLite-backed backend for either Sequel or Active Record persistence.
|
|
10
|
+
class SQLite < Adapter
|
|
11
|
+
def initialize(database: nil, connection: nil, namespace: nil, **)
|
|
12
|
+
super()
|
|
13
|
+
@engine = if database
|
|
14
|
+
Kaal::Sequel.require_sequel!
|
|
15
|
+
require 'kaal/internal/sequel'
|
|
16
|
+
Kaal::Internal::Sequel::DatabaseBackend.new(database, namespace:)
|
|
17
|
+
else
|
|
18
|
+
Kaal::ActiveRecord.require_activerecord!
|
|
19
|
+
require 'kaal/internal/active_record'
|
|
20
|
+
Kaal::Internal::ActiveRecord::DatabaseBackend.new(connection, namespace:, **)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def dispatch_registry
|
|
25
|
+
@engine.dispatch_registry
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def definition_registry
|
|
29
|
+
@engine.definition_registry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def acquire(key, ttl)
|
|
33
|
+
@engine.acquire(key, ttl)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def release(key)
|
|
37
|
+
@engine.release(key)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/kaal/cli.rb
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
|
4
|
+
#
|
|
5
|
+
# This source code is licensed under the MIT license found in the
|
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
|
7
|
+
require 'thor'
|
|
8
|
+
require 'fileutils'
|
|
9
|
+
require 'fugit'
|
|
10
|
+
require 'kaal'
|
|
11
|
+
|
|
12
|
+
module Kaal
|
|
13
|
+
# Thor-powered CLI for plain-Ruby usage.
|
|
14
|
+
class CLI < Thor
|
|
15
|
+
# Internal instance helpers excluded from the public Thor command surface.
|
|
16
|
+
module Helpers
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def load_project!
|
|
20
|
+
Kaal.reset_configuration!
|
|
21
|
+
Kaal.reset_registry!
|
|
22
|
+
load config_path
|
|
23
|
+
runtime_context = RuntimeContext.default(root_path: root_path)
|
|
24
|
+
Kaal.load_scheduler_file!(runtime_context: runtime_context) if File.exist?(scheduler_path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def root_path
|
|
28
|
+
File.expand_path(options[:root])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def config_path
|
|
32
|
+
config = options[:config]
|
|
33
|
+
return File.expand_path(config) if config
|
|
34
|
+
|
|
35
|
+
File.join(root_path, 'config', 'kaal.rb')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def scheduler_path
|
|
39
|
+
File.join(root_path, 'config', 'scheduler.yml')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_config_template(backend)
|
|
43
|
+
case backend
|
|
44
|
+
when 'memory'
|
|
45
|
+
<<~RUBY
|
|
46
|
+
require 'kaal'
|
|
47
|
+
|
|
48
|
+
Kaal.configure do |config|
|
|
49
|
+
config.backend = Kaal::Backend::MemoryAdapter.new
|
|
50
|
+
config.tick_interval = 5
|
|
51
|
+
config.window_lookback = 120
|
|
52
|
+
config.lease_ttl = 125
|
|
53
|
+
config.scheduler_config_path = 'config/scheduler.yml'
|
|
54
|
+
end
|
|
55
|
+
RUBY
|
|
56
|
+
when 'redis'
|
|
57
|
+
<<~RUBY
|
|
58
|
+
require 'kaal'
|
|
59
|
+
require 'redis'
|
|
60
|
+
|
|
61
|
+
redis = Redis.new(url: ENV.fetch('REDIS_URL'))
|
|
62
|
+
|
|
63
|
+
Kaal.configure do |config|
|
|
64
|
+
config.backend = Kaal::Backend::RedisAdapter.new(redis, namespace: 'kaal')
|
|
65
|
+
config.tick_interval = 5
|
|
66
|
+
config.window_lookback = 120
|
|
67
|
+
config.lease_ttl = 125
|
|
68
|
+
config.scheduler_config_path = 'config/scheduler.yml'
|
|
69
|
+
end
|
|
70
|
+
RUBY
|
|
71
|
+
else
|
|
72
|
+
raise Thor::Error, "Unsupported backend '#{backend}'"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def scheduler_template
|
|
77
|
+
<<~YAML
|
|
78
|
+
defaults:
|
|
79
|
+
jobs:
|
|
80
|
+
- key: "example:heartbeat"
|
|
81
|
+
cron: "*/5 * * * *"
|
|
82
|
+
job_class: "ExampleHeartbeatJob"
|
|
83
|
+
enabled: true
|
|
84
|
+
args:
|
|
85
|
+
- "{{fire_time.iso8601}}"
|
|
86
|
+
kwargs:
|
|
87
|
+
idempotency_key: "{{idempotency_key}}"
|
|
88
|
+
metadata:
|
|
89
|
+
owner: "ops"
|
|
90
|
+
YAML
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
package_name 'kaal'
|
|
95
|
+
|
|
96
|
+
class_option :root, type: :string, default: Dir.pwd, desc: 'Project root'
|
|
97
|
+
class_option :config, type: :string, desc: 'Path to config/kaal.rb'
|
|
98
|
+
|
|
99
|
+
desc 'init', 'Generate config/kaal.rb and config/scheduler.yml'
|
|
100
|
+
option :backend, type: :string, default: 'memory', enum: %w[memory redis]
|
|
101
|
+
def init
|
|
102
|
+
root = File.expand_path(options[:root])
|
|
103
|
+
backend = options[:backend]
|
|
104
|
+
writer = self.class
|
|
105
|
+
FileUtils.mkdir_p(File.join(root, 'config'))
|
|
106
|
+
|
|
107
|
+
writer.write_file(File.join(root, 'config', 'kaal.rb'), render_config_template(backend))
|
|
108
|
+
writer.write_file(File.join(root, 'config', 'scheduler.yml'), scheduler_template)
|
|
109
|
+
|
|
110
|
+
say("Initialized Kaal project for #{backend} backend")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
desc 'start', 'Start the scheduler loop in the foreground'
|
|
114
|
+
def start
|
|
115
|
+
load_project!
|
|
116
|
+
|
|
117
|
+
signal_state = {
|
|
118
|
+
graceful_shutdown_started: false,
|
|
119
|
+
shutdown_complete: false,
|
|
120
|
+
force_exit_requested: false
|
|
121
|
+
}
|
|
122
|
+
previous_handlers = Kaal::CLI.install_foreground_signal_handlers(signal_state)
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
thread = Kaal.start!
|
|
126
|
+
raise Thor::Error, 'scheduler is already running' unless thread
|
|
127
|
+
|
|
128
|
+
say('Kaal scheduler started in foreground')
|
|
129
|
+
thread.join
|
|
130
|
+
rescue Interrupt
|
|
131
|
+
raise Thor::Error, 'shutdown timed out; forced exit requested' if signal_state[:force_exit_requested]
|
|
132
|
+
|
|
133
|
+
Kaal::CLI.shutdown_scheduler(signal: 'INT', signal_state: signal_state, shell: shell)
|
|
134
|
+
ensure
|
|
135
|
+
Kaal::CLI.restore_signal_handlers(previous_handlers)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
desc 'status', 'Show scheduler status and registered jobs'
|
|
140
|
+
def status
|
|
141
|
+
load_project!
|
|
142
|
+
registered = Kaal.registered
|
|
143
|
+
say("Kaal v#{Kaal::VERSION}")
|
|
144
|
+
say("Running: #{Kaal.running?}")
|
|
145
|
+
say("Tick interval: #{Kaal.tick_interval}s")
|
|
146
|
+
say("Window lookback: #{Kaal.window_lookback}s")
|
|
147
|
+
say("Window lookahead: #{Kaal.window_lookahead}s")
|
|
148
|
+
say("Lease TTL: #{Kaal.lease_ttl}s")
|
|
149
|
+
say("Namespace: #{Kaal.namespace}")
|
|
150
|
+
say("Registered jobs: #{registered.length}")
|
|
151
|
+
registered.each { |entry| say(" - #{entry.key} (#{entry.cron})") }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
desc 'tick', 'Run a single scheduler tick'
|
|
155
|
+
def tick
|
|
156
|
+
load_project!
|
|
157
|
+
Kaal.tick!
|
|
158
|
+
say('Kaal tick completed')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
desc 'explain EXPRESSION', 'Humanize a cron expression'
|
|
162
|
+
def explain(expression)
|
|
163
|
+
say(Kaal.to_human(expression))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
desc 'next EXPRESSION', 'Print upcoming fire times'
|
|
167
|
+
option :count, type: :numeric, default: 5
|
|
168
|
+
def next(expression)
|
|
169
|
+
cron = Fugit.parse_cron(expression)
|
|
170
|
+
raise Thor::Error, "Invalid cron expression: #{expression}" unless cron
|
|
171
|
+
|
|
172
|
+
current = Time.now.utc
|
|
173
|
+
options[:count].to_i.times do
|
|
174
|
+
current = cron.next_time(current).to_t.utc
|
|
175
|
+
say(current.iso8601)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def self.exit_on_failure?
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def self.write_file(path, contents)
|
|
184
|
+
return if File.exist?(path)
|
|
185
|
+
|
|
186
|
+
File.write(path, contents)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.install_foreground_signal_handlers(signal_state)
|
|
190
|
+
installer = SignalHandlerInstaller.new
|
|
191
|
+
installer.install do |signal, previous_handler|
|
|
192
|
+
shutdown_scheduler(signal: signal, signal_state: signal_state, previous_handler: previous_handler)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.restore_signal_handlers(previous_handlers)
|
|
197
|
+
previous_handlers.each do |signal, handler|
|
|
198
|
+
Signal.trap(signal, handler)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.shutdown_scheduler(signal:, signal_state:, previous_handler: nil, shell: nil)
|
|
205
|
+
shell_instance = shell || Thor::Base.shell.new
|
|
206
|
+
return if signal_state[:shutdown_complete]
|
|
207
|
+
|
|
208
|
+
if signal_state[:graceful_shutdown_started]
|
|
209
|
+
signal_state[:force_exit_requested] = true
|
|
210
|
+
shell_instance.warn("Received #{signal} again; forcing scheduler shutdown")
|
|
211
|
+
Thread.main.raise(Interrupt)
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
signal_state[:graceful_shutdown_started] = true
|
|
216
|
+
shell_instance.say("Received #{signal}, stopping Kaal scheduler...")
|
|
217
|
+
stopped = Kaal.stop!(timeout: 30)
|
|
218
|
+
if stopped
|
|
219
|
+
signal_state[:shutdown_complete] = true
|
|
220
|
+
shell_instance.say('Kaal scheduler stopped')
|
|
221
|
+
else
|
|
222
|
+
shell_instance.warn('Kaal scheduler stop timed out; send TERM/INT again to force exit')
|
|
223
|
+
end
|
|
224
|
+
ensure
|
|
225
|
+
SignalHandlerChain.new(signal: signal, previous_handler: previous_handler, logger: Kaal.logger).call(signal)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
no_commands { include Helpers }
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
#
|
|
5
5
|
# This source code is licensed under the MIT license found in the
|
|
6
6
|
# LICENSE file in the root directory of this source tree.
|
|
7
|
-
|
|
8
7
|
module Kaal
|
|
9
8
|
# Raised when scheduler file configuration is invalid or cannot be loaded.
|
|
10
9
|
class SchedulerConfigError < StandardError; end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
|
4
|
+
#
|
|
5
|
+
# This source code is licensed under the MIT license found in the
|
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
|
7
|
+
require 'tzinfo'
|
|
8
|
+
|
|
9
|
+
module Kaal
|
|
10
|
+
# Resolves the configured scheduler time zone, preferring explicit config.
|
|
11
|
+
class SchedulerTimeZoneResolver
|
|
12
|
+
DEFAULT_TIME_ZONE = 'UTC'
|
|
13
|
+
|
|
14
|
+
def initialize(configuration:)
|
|
15
|
+
@configuration = configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time_zone_identifier
|
|
19
|
+
zone = begin
|
|
20
|
+
Time.zone
|
|
21
|
+
rescue NoMethodError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
configured_time_zone || zone&.tzinfo&.identifier || DEFAULT_TIME_ZONE
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def configured_time_zone
|
|
30
|
+
value = normalized_time_zone_value
|
|
31
|
+
return nil if value.empty?
|
|
32
|
+
|
|
33
|
+
TZInfo::Timezone.get(value)
|
|
34
|
+
value
|
|
35
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
36
|
+
raise ConfigurationError, "Invalid time_zone configuration: #{raw_time_zone_value.inspect} (normalized: #{value.inspect})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalized_time_zone_value
|
|
40
|
+
value = raw_time_zone_value
|
|
41
|
+
return DEFAULT_TIME_ZONE if value.casecmp?(DEFAULT_TIME_ZONE)
|
|
42
|
+
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def raw_time_zone_value
|
|
47
|
+
@configuration.time_zone.to_s.strip
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/kaal/config.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright Codevedas Inc. 2025-present
|
|
4
|
+
#
|
|
5
|
+
# This source code is licensed under the MIT license found in the
|
|
6
|
+
# LICENSE file in the root directory of this source tree.
|
|
7
|
+
require 'kaal/config/configuration'
|
|
8
|
+
require 'kaal/config/scheduler_config_error'
|
|
9
|
+
require 'kaal/config/scheduler_time_zone_resolver'
|
|
10
|
+
|
|
11
|
+
module Kaal
|
|
12
|
+
# Configuration-related types and validation helpers.
|
|
13
|
+
module Config
|
|
14
|
+
Configuration = ::Kaal::Configuration
|
|
15
|
+
ConfigurationError = ::Kaal::ConfigurationError
|
|
16
|
+
SchedulerConfigError = ::Kaal::SchedulerConfigError
|
|
17
|
+
SchedulerTimeZoneResolver = ::Kaal::SchedulerTimeZoneResolver
|
|
18
|
+
end
|
|
19
|
+
end
|