kaal 0.4.0 → 0.6.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 +60 -25
- data/config/kaal.yml +12 -0
- data/lib/kaal/active_record_support.rb +2 -2
- data/lib/kaal/backend/adapter.rb +8 -0
- data/lib/kaal/backend/memory_adapter.rb +5 -0
- data/lib/kaal/backend/mysql.rb +25 -3
- data/lib/kaal/backend/postgres.rb +6 -2
- data/lib/kaal/backend/redis_adapter.rb +5 -0
- data/lib/kaal/backend/sqlite.rb +4 -0
- data/lib/kaal/cli.rb +38 -33
- data/lib/kaal/config/backend_factory.rb +178 -0
- data/lib/kaal/config/configuration.rb +98 -9
- data/lib/kaal/config/delayed_job_security_policy.rb +60 -0
- data/lib/kaal/config/file_loader.rb +187 -0
- data/lib/kaal/config.rb +3 -0
- data/lib/kaal/core/coordinator.rb +68 -19
- data/lib/kaal/delayed_job/database_engine.rb +116 -0
- data/lib/kaal/delayed_job/dispatch_failure_logger.rb +31 -0
- data/lib/kaal/delayed_job/memory_engine.rb +79 -0
- data/lib/kaal/delayed_job/mysql_version_support.rb +43 -0
- data/lib/kaal/delayed_job/redis_engine.rb +119 -0
- data/lib/kaal/delayed_job/registry.rb +39 -0
- data/lib/kaal/internal/active_record/database_backend.rb +5 -0
- data/lib/kaal/internal/active_record/delayed_job_record.rb +16 -0
- data/lib/kaal/internal/active_record/delayed_job_registry.rb +119 -0
- data/lib/kaal/internal/active_record/migration_templates.rb +33 -3
- data/lib/kaal/internal/active_record/mysql_backend.rb +23 -5
- data/lib/kaal/internal/active_record/postgres_backend.rb +4 -0
- data/lib/kaal/internal/active_record.rb +2 -0
- data/lib/kaal/internal/sequel/database_backend.rb +5 -0
- data/lib/kaal/internal/sequel/mysql_backend.rb +15 -1
- data/lib/kaal/internal/sequel/postgres_backend.rb +4 -0
- data/lib/kaal/internal/sequel.rb +1 -0
- data/lib/kaal/job_dispatcher.rb +108 -0
- data/lib/kaal/persistence/database.rb +4 -0
- data/lib/kaal/persistence/migration_templates.rb +35 -3
- data/lib/kaal/runtime/scheduler_boot_loader.rb +3 -1
- data/lib/kaal/scheduler_file/job_applier.rb +28 -53
- data/lib/kaal/scheduler_file/loader.rb +1 -1
- data/lib/kaal/sequel_support.rb +2 -2
- data/lib/kaal/version.rb +1 -1
- data/lib/kaal.rb +118 -0
- data/sig/00_types.rbs +12 -0
- data/sig/dependencies.rbs +49 -0
- data/sig/kaal/active_record_support.rbs +23 -0
- data/sig/kaal/backend/adapter.rbs +26 -0
- data/sig/kaal/backend/dispatch_attempt_logger.rbs +17 -0
- data/sig/kaal/backend/dispatch_logging.rbs +23 -0
- data/sig/kaal/backend/dispatch_registry_accessor.rbs +17 -0
- data/sig/kaal/backend/memory_adapter.rbs +33 -0
- data/sig/kaal/backend/mysql.rbs +25 -0
- data/sig/kaal/backend/postgres.rbs +19 -0
- data/sig/kaal/backend/redis_adapter.rbs +41 -0
- data/sig/kaal/backend/sqlite.rbs +19 -0
- data/sig/kaal/cli.rbs +41 -0
- data/sig/kaal/config/backend_factory.rbs +41 -0
- data/sig/kaal/config/configuration.rbs +70 -0
- data/sig/kaal/config/delayed_job_security_policy.rbs +19 -0
- data/sig/kaal/config/file_loader.rbs +35 -0
- data/sig/kaal/config/scheduler_config_error.rbs +4 -0
- data/sig/kaal/config/scheduler_time_zone_resolver.rbs +19 -0
- data/sig/kaal/config.rbs +11 -0
- data/sig/kaal/core/coordinator.rbs +103 -0
- data/sig/kaal/core/enabled_entry_enumerator.rbs +21 -0
- data/sig/kaal/core/occurrence_finder.rbs +9 -0
- data/sig/kaal/core.rbs +9 -0
- data/sig/kaal/definition/database_engine.rbs +25 -0
- data/sig/kaal/definition/memory_engine.rbs +23 -0
- data/sig/kaal/definition/persistence_helpers.rbs +9 -0
- data/sig/kaal/definition/redis_engine.rbs +33 -0
- data/sig/kaal/definition/registry.rbs +29 -0
- data/sig/kaal/definitions/registration_service.rbs +27 -0
- data/sig/kaal/definitions/registry_accessor.rbs +17 -0
- data/sig/kaal/delayed_job/database_engine.rbs +37 -0
- data/sig/kaal/delayed_job/dispatch_failure_logger.rbs +7 -0
- data/sig/kaal/delayed_job/memory_engine.rbs +29 -0
- data/sig/kaal/delayed_job/mysql_version_support.rbs +15 -0
- data/sig/kaal/delayed_job/redis_engine.rbs +31 -0
- data/sig/kaal/delayed_job/registry.rbs +20 -0
- data/sig/kaal/dispatch/database_engine.rbs +39 -0
- data/sig/kaal/dispatch/memory_engine.rbs +23 -0
- data/sig/kaal/dispatch/redis_engine.rbs +25 -0
- data/sig/kaal/dispatch/registry.rbs +11 -0
- data/sig/kaal/internal/active_record/base_record.rbs +8 -0
- data/sig/kaal/internal/active_record/connection_support.rbs +25 -0
- data/sig/kaal/internal/active_record/database_backend.rbs +37 -0
- data/sig/kaal/internal/active_record/definition_record.rbs +8 -0
- data/sig/kaal/internal/active_record/definition_registry.rbs +27 -0
- data/sig/kaal/internal/active_record/delayed_job_record.rbs +8 -0
- data/sig/kaal/internal/active_record/delayed_job_registry.rbs +39 -0
- data/sig/kaal/internal/active_record/dispatch_record.rbs +8 -0
- data/sig/kaal/internal/active_record/dispatch_registry.rbs +43 -0
- data/sig/kaal/internal/active_record/lock_record.rbs +8 -0
- data/sig/kaal/internal/active_record/migration_templates.rbs +17 -0
- data/sig/kaal/internal/active_record/mysql_backend.rbs +45 -0
- data/sig/kaal/internal/active_record/postgres_backend.rbs +41 -0
- data/sig/kaal/internal/active_record.rbs +0 -0
- data/sig/kaal/internal/sequel/database_backend.rbs +39 -0
- data/sig/kaal/internal/sequel/mysql_backend.rbs +47 -0
- data/sig/kaal/internal/sequel/postgres_backend.rbs +43 -0
- data/sig/kaal/internal/sequel.rbs +0 -0
- data/sig/kaal/job_dispatcher.rbs +19 -0
- data/sig/kaal/persistence/database.rbs +19 -0
- data/sig/kaal/persistence/migration_templates.rbs +15 -0
- data/sig/kaal/register_conflict_support.rbs +11 -0
- data/sig/kaal/registry.rbs +44 -0
- data/sig/kaal/runtime/runtime_context.rbs +23 -0
- data/sig/kaal/runtime/scheduler_boot_loader.rbs +23 -0
- data/sig/kaal/runtime/signal_handler_chain.rbs +19 -0
- data/sig/kaal/runtime/signal_handler_installer.rbs +19 -0
- data/sig/kaal/runtime.rbs +11 -0
- data/sig/kaal/scheduler_file/hash_transform.rbs +9 -0
- data/sig/kaal/scheduler_file/helper_bundle.rbs +15 -0
- data/sig/kaal/scheduler_file/job_applier.rbs +43 -0
- data/sig/kaal/scheduler_file/job_normalizer.rbs +27 -0
- data/sig/kaal/scheduler_file/loader.rbs +69 -0
- data/sig/kaal/scheduler_file/payload_loader.rbs +33 -0
- data/sig/kaal/scheduler_file/placeholder_support.rbs +19 -0
- data/sig/kaal/scheduler_file.rbs +9 -0
- data/sig/kaal/sequel_support.rbs +25 -0
- data/sig/kaal/support/hash_tools.rbs +27 -0
- data/sig/kaal/utils/cron_humanizer.rbs +39 -0
- data/sig/kaal/utils/cron_utils.rbs +43 -0
- data/sig/kaal/utils/idempotency_key_generator.rbs +5 -0
- data/sig/kaal/utils.rbs +9 -0
- data/sig/kaal/version.rbs +3 -0
- data/sig/kaal.rbs +145 -0
- metadata +100 -3
- data/config/kaal.rb +0 -15
- /data/config/{scheduler.yml → kaal-scheduler.yml} +0 -0
|
@@ -12,7 +12,8 @@ module Kaal
|
|
|
12
12
|
# @example Basic configuration
|
|
13
13
|
# Kaal.configure do |config|
|
|
14
14
|
# config.tick_interval = 5
|
|
15
|
-
# config.
|
|
15
|
+
# config.backend_config = { url: ENV['KAAL_BACKEND_URL'] }
|
|
16
|
+
# config.backend = :redis
|
|
16
17
|
# end
|
|
17
18
|
class Configuration
|
|
18
19
|
# Default values for all configuration options
|
|
@@ -23,15 +24,17 @@ module Kaal
|
|
|
23
24
|
lease_ttl: 125, # Must be >= window_lookback + tick_interval (120 + 5 = 125)
|
|
24
25
|
namespace: 'kaal',
|
|
25
26
|
backend: nil,
|
|
27
|
+
backend_config: {},
|
|
26
28
|
logger: nil,
|
|
27
29
|
time_zone: nil,
|
|
28
30
|
enable_log_dispatch_registry: false,
|
|
29
31
|
enable_dispatch_recovery: true,
|
|
30
32
|
recovery_window: 86_400, # 24 hours in seconds
|
|
31
33
|
recovery_startup_jitter: 5, # max random delay in seconds
|
|
32
|
-
scheduler_config_path: 'config/scheduler.yml',
|
|
34
|
+
scheduler_config_path: 'config/kaal-scheduler.yml',
|
|
33
35
|
scheduler_conflict_policy: :error,
|
|
34
|
-
scheduler_missing_file_policy: :warn
|
|
36
|
+
scheduler_missing_file_policy: :warn,
|
|
37
|
+
delayed_job_allowed_class_prefixes: []
|
|
35
38
|
}.freeze
|
|
36
39
|
|
|
37
40
|
##
|
|
@@ -40,6 +43,8 @@ module Kaal
|
|
|
40
43
|
# @return [Configuration] a new instance with all defaults set
|
|
41
44
|
def initialize
|
|
42
45
|
@values = DEFAULTS.dup
|
|
46
|
+
@backend_name = nil
|
|
47
|
+
@backend_runtime_context = nil
|
|
43
48
|
end
|
|
44
49
|
|
|
45
50
|
##
|
|
@@ -69,6 +74,15 @@ module Kaal
|
|
|
69
74
|
validation_errors
|
|
70
75
|
end
|
|
71
76
|
|
|
77
|
+
# Non-fatal configuration warnings.
|
|
78
|
+
#
|
|
79
|
+
# @return [Array<String>] warning messages
|
|
80
|
+
def validation_warnings
|
|
81
|
+
warnings = []
|
|
82
|
+
add_delayed_job_security_warning(warnings)
|
|
83
|
+
warnings
|
|
84
|
+
end
|
|
85
|
+
|
|
72
86
|
##
|
|
73
87
|
# Validate the configuration settings.
|
|
74
88
|
# Raises errors if required settings are invalid.
|
|
@@ -79,6 +93,10 @@ module Kaal
|
|
|
79
93
|
errors = validation_errors
|
|
80
94
|
raise ConfigurationError, errors.join('; ') if errors.any?
|
|
81
95
|
|
|
96
|
+
validation_warnings.each do |warning|
|
|
97
|
+
@values[:logger]&.warn(warning)
|
|
98
|
+
end
|
|
99
|
+
|
|
82
100
|
self
|
|
83
101
|
end
|
|
84
102
|
|
|
@@ -97,6 +115,7 @@ module Kaal
|
|
|
97
115
|
lease_ttl: @values[:lease_ttl],
|
|
98
116
|
namespace: @values[:namespace],
|
|
99
117
|
backend: backend&.class&.name,
|
|
118
|
+
backend_config: Kaal::Support::HashTools.deep_dup(@values[:backend_config]),
|
|
100
119
|
logger: logger&.class&.name,
|
|
101
120
|
time_zone: @values[:time_zone],
|
|
102
121
|
enable_log_dispatch_registry: @values[:enable_log_dispatch_registry],
|
|
@@ -105,7 +124,8 @@ module Kaal
|
|
|
105
124
|
recovery_startup_jitter: @values[:recovery_startup_jitter],
|
|
106
125
|
scheduler_config_path: @values[:scheduler_config_path],
|
|
107
126
|
scheduler_conflict_policy: @values[:scheduler_conflict_policy],
|
|
108
|
-
scheduler_missing_file_policy: @values[:scheduler_missing_file_policy]
|
|
127
|
+
scheduler_missing_file_policy: @values[:scheduler_missing_file_policy],
|
|
128
|
+
delayed_job_allowed_class_prefixes: @values[:delayed_job_allowed_class_prefixes]
|
|
109
129
|
}
|
|
110
130
|
end
|
|
111
131
|
|
|
@@ -118,6 +138,7 @@ module Kaal
|
|
|
118
138
|
add_window_lookahead_error(errors)
|
|
119
139
|
add_lease_ttl_error(errors)
|
|
120
140
|
add_namespace_error(errors)
|
|
141
|
+
add_backend_config_error(errors)
|
|
121
142
|
add_lease_ttl_window_error(errors)
|
|
122
143
|
add_scheduler_config_path_error(errors)
|
|
123
144
|
add_scheduler_conflict_policy_error(errors)
|
|
@@ -159,6 +180,12 @@ module Kaal
|
|
|
159
180
|
errors << 'namespace cannot be blank'
|
|
160
181
|
end
|
|
161
182
|
|
|
183
|
+
def add_backend_config_error(errors)
|
|
184
|
+
return if @values[:backend_config].is_a?(Hash)
|
|
185
|
+
|
|
186
|
+
errors << 'backend_config must be a hash'
|
|
187
|
+
end
|
|
188
|
+
|
|
162
189
|
def add_lease_ttl_window_error(errors)
|
|
163
190
|
lease_ttl = @values[:lease_ttl].to_i
|
|
164
191
|
window_lookback = @values[:window_lookback].to_i
|
|
@@ -191,6 +218,13 @@ module Kaal
|
|
|
191
218
|
errors << 'scheduler_missing_file_policy must be :warn or :error'
|
|
192
219
|
end
|
|
193
220
|
|
|
221
|
+
def add_delayed_job_security_warning(warnings)
|
|
222
|
+
warning = Kaal::Config::DelayedJobSecurityPolicy.warning_for(self)
|
|
223
|
+
return unless warning
|
|
224
|
+
|
|
225
|
+
warnings << warning
|
|
226
|
+
end
|
|
227
|
+
|
|
194
228
|
def handle_known_key(method_name)
|
|
195
229
|
name = method_name.to_s
|
|
196
230
|
setter = name.end_with?('=')
|
|
@@ -202,26 +236,81 @@ module Kaal
|
|
|
202
236
|
|
|
203
237
|
def set_value(key, value)
|
|
204
238
|
@values[key] = normalize_value(key, value)
|
|
239
|
+
rebuild_symbolic_backend_if_needed(key)
|
|
205
240
|
end
|
|
206
241
|
|
|
207
242
|
def normalize_value(key, value)
|
|
208
|
-
return value unless @values.key?(key)
|
|
209
|
-
|
|
210
243
|
case key
|
|
244
|
+
when :backend
|
|
245
|
+
normalize_backend(value)
|
|
246
|
+
when :backend_config
|
|
247
|
+
value.is_a?(Hash) ? Kaal::Support::HashTools.symbolize_keys(Kaal::Support::HashTools.deep_dup(value)) : (value || {})
|
|
211
248
|
when :tick_interval, :window_lookback, :window_lookahead, :lease_ttl
|
|
212
249
|
value.to_i
|
|
213
250
|
when :namespace, :scheduler_config_path
|
|
214
251
|
value.to_s
|
|
215
252
|
when :time_zone
|
|
216
|
-
value
|
|
253
|
+
normalize_optional_string(value)
|
|
217
254
|
when :enable_log_dispatch_registry
|
|
218
|
-
value
|
|
255
|
+
!!value
|
|
219
256
|
when :scheduler_conflict_policy, :scheduler_missing_file_policy
|
|
220
|
-
value
|
|
257
|
+
normalize_optional_symbol(value)
|
|
258
|
+
when :delayed_job_allowed_class_prefixes
|
|
259
|
+
normalize_delayed_job_allowed_class_prefixes(value)
|
|
221
260
|
else
|
|
222
261
|
value
|
|
223
262
|
end
|
|
224
263
|
end
|
|
264
|
+
|
|
265
|
+
def normalize_backend(value)
|
|
266
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
267
|
+
@backend_name = nil
|
|
268
|
+
return value
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
normalized_backend_name = Kaal::Config::BackendFactory.normalize_name(value)
|
|
272
|
+
backend = Kaal::Config::BackendFactory.build(
|
|
273
|
+
normalized_backend_name,
|
|
274
|
+
backend_config: @values[:backend_config],
|
|
275
|
+
namespace: @values[:namespace],
|
|
276
|
+
runtime_context: @backend_runtime_context
|
|
277
|
+
)
|
|
278
|
+
@backend_name = normalized_backend_name
|
|
279
|
+
backend
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def apply_backend_runtime_context(runtime_context)
|
|
283
|
+
@backend_runtime_context = runtime_context
|
|
284
|
+
end
|
|
285
|
+
public :apply_backend_runtime_context
|
|
286
|
+
|
|
287
|
+
def rebuild_symbolic_backend_if_needed(key)
|
|
288
|
+
return unless @backend_name
|
|
289
|
+
return unless %i[backend_config namespace backend].include?(key)
|
|
290
|
+
return if key == :backend
|
|
291
|
+
|
|
292
|
+
@values[:backend] = Kaal::Config::BackendFactory.build(
|
|
293
|
+
@backend_name,
|
|
294
|
+
backend_config: @values[:backend_config],
|
|
295
|
+
namespace: @values[:namespace],
|
|
296
|
+
runtime_context: @backend_runtime_context
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def normalize_delayed_job_allowed_class_prefixes(value)
|
|
301
|
+
Array(value).filter_map do |entry|
|
|
302
|
+
normalized_entry = entry.to_s.strip
|
|
303
|
+
normalized_entry unless normalized_entry.empty?
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def normalize_optional_string(value)
|
|
308
|
+
value&.to_s
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def normalize_optional_symbol(value)
|
|
312
|
+
value&.to_sym
|
|
313
|
+
end
|
|
225
314
|
end
|
|
226
315
|
|
|
227
316
|
##
|
|
@@ -0,0 +1,60 @@
|
|
|
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 Config
|
|
9
|
+
# Evaluates whether delayed-job class resolution is too open for the
|
|
10
|
+
# current deployment shape and returns the matching warning message.
|
|
11
|
+
module DelayedJobSecurityPolicy
|
|
12
|
+
NON_SHARED_BACKEND_CLASS_NAMES = ['NilClass', 'Kaal::Backend::MemoryAdapter', 'Kaal::Backend::NullAdapter'].freeze
|
|
13
|
+
WARNING_MESSAGE = 'Delayed jobs resolve stored job_class values at dispatch time. ' \
|
|
14
|
+
'delayed_job_allowed_class_prefixes is empty, so class resolution is unrestricted on this shared backend. ' \
|
|
15
|
+
'Configure a restrictive delayed_job_allowed_class_prefixes list for production deployments.'
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def warning_for(configuration)
|
|
20
|
+
return unless production_like_environment?
|
|
21
|
+
return unless shared_delayed_job_backend?(configuration.backend)
|
|
22
|
+
return unless Array(configuration.delayed_job_allowed_class_prefixes).empty?
|
|
23
|
+
|
|
24
|
+
WARNING_MESSAGE
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def production_like_environment?(env: ENV, rails: current_rails)
|
|
28
|
+
rails_env = rails_environment(rails)
|
|
29
|
+
return rails_env.production? if rails_env
|
|
30
|
+
|
|
31
|
+
%w[RACK_ENV HANAMI_ENV APP_ENV RAILS_ENV RUBY_ENV].any? do |key|
|
|
32
|
+
env.fetch(key, nil).to_s.strip == 'production'
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def shared_delayed_job_backend?(backend)
|
|
39
|
+
backend_class = backend.class
|
|
40
|
+
return false if NON_SHARED_BACKEND_CLASS_NAMES.include?(backend_class.name)
|
|
41
|
+
|
|
42
|
+
backend_class.instance_method(:delayed_store).owner.name != 'Kaal::Backend::Adapter'
|
|
43
|
+
rescue StandardError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def current_rails
|
|
48
|
+
return unless defined?(::Rails)
|
|
49
|
+
|
|
50
|
+
::Rails
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def rails_environment(rails)
|
|
54
|
+
rails.env
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
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 'erb'
|
|
8
|
+
require 'yaml'
|
|
9
|
+
|
|
10
|
+
module Kaal
|
|
11
|
+
module Config
|
|
12
|
+
# Loads Kaal runtime configuration from config/kaal.yml and KAAL_* env vars.
|
|
13
|
+
class FileLoader
|
|
14
|
+
# Normalizes a single environment variable value into the requested config type.
|
|
15
|
+
class EnvValue
|
|
16
|
+
def initialize(value)
|
|
17
|
+
@value = value.to_s.strip
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def coerce(key:, env_name:)
|
|
21
|
+
case key
|
|
22
|
+
when :tick_interval, :window_lookback, :window_lookahead, :lease_ttl, :recovery_window, :recovery_startup_jitter
|
|
23
|
+
coerce_integer(env_name)
|
|
24
|
+
when :enable_log_dispatch_registry, :enable_dispatch_recovery
|
|
25
|
+
coerce_boolean(env_name)
|
|
26
|
+
when :scheduler_conflict_policy, :scheduler_missing_file_policy
|
|
27
|
+
@value.to_sym
|
|
28
|
+
when :delayed_job_allowed_class_prefixes
|
|
29
|
+
@value.split(',').map(&:strip).reject(&:empty?)
|
|
30
|
+
else
|
|
31
|
+
@value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def coerce_boolean(env_name)
|
|
38
|
+
normalized_value = @value.downcase
|
|
39
|
+
return true if %w[1 true yes on].include?(normalized_value)
|
|
40
|
+
return false if %w[0 false no off].include?(normalized_value)
|
|
41
|
+
|
|
42
|
+
raise Kaal::ConfigurationError, "ENV #{env_name} must be a boolean"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def coerce_integer(env_name)
|
|
46
|
+
raise Kaal::ConfigurationError, "ENV #{env_name} must be an integer" unless @value.match?(/\A-?\d+\z/)
|
|
47
|
+
|
|
48
|
+
@value.to_i
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
ENV_KEY_MAP = {
|
|
53
|
+
'KAAL_BACKEND' => :backend,
|
|
54
|
+
'KAAL_NAMESPACE' => :namespace,
|
|
55
|
+
'KAAL_TICK_INTERVAL' => :tick_interval,
|
|
56
|
+
'KAAL_WINDOW_LOOKBACK' => :window_lookback,
|
|
57
|
+
'KAAL_WINDOW_LOOKAHEAD' => :window_lookahead,
|
|
58
|
+
'KAAL_LEASE_TTL' => :lease_ttl,
|
|
59
|
+
'KAAL_SCHEDULER_CONFIG_PATH' => :scheduler_config_path,
|
|
60
|
+
'KAAL_ENABLE_LOG_DISPATCH_REGISTRY' => :enable_log_dispatch_registry,
|
|
61
|
+
'KAAL_ENABLE_DISPATCH_RECOVERY' => :enable_dispatch_recovery,
|
|
62
|
+
'KAAL_RECOVERY_WINDOW' => :recovery_window,
|
|
63
|
+
'KAAL_RECOVERY_STARTUP_JITTER' => :recovery_startup_jitter,
|
|
64
|
+
'KAAL_TIME_ZONE' => :time_zone,
|
|
65
|
+
'KAAL_SCHEDULER_CONFLICT_POLICY' => :scheduler_conflict_policy,
|
|
66
|
+
'KAAL_SCHEDULER_MISSING_FILE_POLICY' => :scheduler_missing_file_policy,
|
|
67
|
+
'KAAL_DELAYED_JOB_ALLOWED_CLASS_PREFIXES' => :delayed_job_allowed_class_prefixes
|
|
68
|
+
}.freeze
|
|
69
|
+
CONFIG_KEY_TO_ENV_KEY = ENV_KEY_MAP.invert.freeze
|
|
70
|
+
CONFIGURATION_ASSIGNERS = {
|
|
71
|
+
namespace: ->(config, value) { config.namespace = value },
|
|
72
|
+
tick_interval: ->(config, value) { config.tick_interval = value },
|
|
73
|
+
window_lookback: ->(config, value) { config.window_lookback = value },
|
|
74
|
+
window_lookahead: ->(config, value) { config.window_lookahead = value },
|
|
75
|
+
lease_ttl: ->(config, value) { config.lease_ttl = value },
|
|
76
|
+
scheduler_config_path: ->(config, value) { config.scheduler_config_path = value },
|
|
77
|
+
enable_log_dispatch_registry: ->(config, value) { config.enable_log_dispatch_registry = value },
|
|
78
|
+
enable_dispatch_recovery: ->(config, value) { config.enable_dispatch_recovery = value },
|
|
79
|
+
recovery_window: ->(config, value) { config.recovery_window = value },
|
|
80
|
+
recovery_startup_jitter: ->(config, value) { config.recovery_startup_jitter = value },
|
|
81
|
+
time_zone: ->(config, value) { config.time_zone = value },
|
|
82
|
+
scheduler_conflict_policy: ->(config, value) { config.scheduler_conflict_policy = value },
|
|
83
|
+
scheduler_missing_file_policy: ->(config, value) { config.scheduler_missing_file_policy = value },
|
|
84
|
+
delayed_job_allowed_class_prefixes: ->(config, value) { config.delayed_job_allowed_class_prefixes = value },
|
|
85
|
+
logger: ->(config, value) { config.logger = value }
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
def initialize(configuration:, runtime_context:, env: ENV)
|
|
89
|
+
@configuration = configuration
|
|
90
|
+
@runtime_context = runtime_context
|
|
91
|
+
@env = env
|
|
92
|
+
@config_key_to_env_key = CONFIG_KEY_TO_ENV_KEY
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def load(path: 'config/kaal.yml')
|
|
96
|
+
absolute_path = @runtime_context.resolve_path(path)
|
|
97
|
+
payload = File.exist?(absolute_path) ? parse_yaml(absolute_path) : {}
|
|
98
|
+
merged = merge_environment_config(payload)
|
|
99
|
+
merged = apply_env_overrides(merged)
|
|
100
|
+
apply_configuration(merged)
|
|
101
|
+
@configuration.validate!
|
|
102
|
+
@configuration
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def parse_yaml(path)
|
|
108
|
+
rendered = render_yaml(path)
|
|
109
|
+
parsed = YAML.safe_load(rendered, aliases: true) || {}
|
|
110
|
+
raise Kaal::ConfigurationError, "Expected Kaal config YAML root to be a mapping in #{path}" unless parsed.is_a?(Hash)
|
|
111
|
+
|
|
112
|
+
Kaal::Support::HashTools.stringify_keys(parsed)
|
|
113
|
+
rescue Psych::Exception => e
|
|
114
|
+
raise Kaal::ConfigurationError, "Failed to parse Kaal config YAML at #{path}: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def render_yaml(path)
|
|
118
|
+
ERB.new(File.read(path), trim_mode: '-').result
|
|
119
|
+
rescue StandardError, SyntaxError => e
|
|
120
|
+
raise Kaal::ConfigurationError, "Failed to evaluate Kaal config ERB at #{path}: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def merge_environment_config(payload)
|
|
124
|
+
defaults = hash_section(payload['defaults'])
|
|
125
|
+
environment = hash_section(payload[@runtime_context.environment_name])
|
|
126
|
+
|
|
127
|
+
Kaal::Support::HashTools.deep_merge(defaults, environment)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def hash_section(value)
|
|
131
|
+
case value
|
|
132
|
+
in Hash
|
|
133
|
+
Kaal::Support::HashTools.stringify_keys(Kaal::Support::HashTools.deep_dup(value))
|
|
134
|
+
in nil
|
|
135
|
+
{}
|
|
136
|
+
else
|
|
137
|
+
raise Kaal::ConfigurationError, 'Kaal config sections must be mappings'
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def apply_env_overrides(config)
|
|
142
|
+
merged = Kaal::Support::HashTools.deep_dup(config)
|
|
143
|
+
|
|
144
|
+
ENV_KEY_MAP.each do |env_key, config_key|
|
|
145
|
+
next unless @env.key?(env_key)
|
|
146
|
+
|
|
147
|
+
merged[config_key.to_s] = coerce_env_value(config_key, @env.fetch(env_key))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
backend_url = @env['KAAL_BACKEND_URL']&.to_s&.strip
|
|
151
|
+
if @env.key?('KAAL_BACKEND_URL')
|
|
152
|
+
backend_config = hash_section(merged['backend_config'])
|
|
153
|
+
if backend_config.key?('connection')
|
|
154
|
+
backend_config['connection'] = backend_url
|
|
155
|
+
else
|
|
156
|
+
backend_config['url'] = backend_url
|
|
157
|
+
end
|
|
158
|
+
merged['backend_config'] = backend_config
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
merged
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def coerce_env_value(key, value)
|
|
165
|
+
EnvValue.new(value).coerce(key:, env_name: @config_key_to_env_key.fetch(key))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def apply_configuration(config)
|
|
169
|
+
normalized = Kaal::Support::HashTools.symbolize_keys(config)
|
|
170
|
+
backend_config = normalized.delete(:backend_config) || {}
|
|
171
|
+
backend_name = normalized.delete(:backend)
|
|
172
|
+
@configuration.apply_backend_runtime_context(@runtime_context)
|
|
173
|
+
|
|
174
|
+
normalized.each do |key, value|
|
|
175
|
+
apply_configuration_value(key, value)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@configuration.backend_config = backend_config
|
|
179
|
+
@configuration.backend = backend_name if backend_name
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def apply_configuration_value(key, value)
|
|
183
|
+
CONFIGURATION_ASSIGNERS[key]&.call(@configuration, value)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/kaal/config.rb
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
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
7
|
require 'kaal/config/configuration'
|
|
8
|
+
require 'kaal/config/backend_factory'
|
|
9
|
+
require 'kaal/config/delayed_job_security_policy'
|
|
10
|
+
require 'kaal/config/file_loader'
|
|
8
11
|
require 'kaal/config/scheduler_config_error'
|
|
9
12
|
require 'kaal/config/scheduler_time_zone_resolver'
|
|
10
13
|
|
|
@@ -16,21 +16,12 @@ module Kaal
|
|
|
16
16
|
#
|
|
17
17
|
# The coordinator:
|
|
18
18
|
# 1. Runs a background thread on tick_interval
|
|
19
|
-
# 2.
|
|
20
|
-
# 3.
|
|
21
|
-
# 4. Calls the enqueue callback if the lease is acquired
|
|
22
|
-
# 5. Supports graceful shutdown and re-entrancy for testing
|
|
23
|
-
#
|
|
24
|
-
# @example Start the coordinator
|
|
25
|
-
# coordinator = Kaal::Coordinator.new
|
|
26
|
-
# coordinator.start!
|
|
27
|
-
#
|
|
28
|
-
# @example Manual tick execution (for testing)
|
|
29
|
-
# coordinator.tick!
|
|
30
|
-
#
|
|
31
|
-
# @example Stop the coordinator
|
|
32
|
-
# coordinator.stop!
|
|
19
|
+
# 2. Calculates due cron fire times and acquires distributed leases for them
|
|
20
|
+
# 3. Dispatches claimed work and supports graceful shutdown and test re-entrancy
|
|
33
21
|
class Coordinator
|
|
22
|
+
DELAYED_JOB_BATCH_SIZE = 100
|
|
23
|
+
DELAYED_JOB_MAX_BATCHES_PER_TICK = 10
|
|
24
|
+
DELAYED_JOB_DELETE_CONFIRMATION_JITTER_MAX = 0.05
|
|
34
25
|
##
|
|
35
26
|
# Initialize a new Coordinator instance.
|
|
36
27
|
#
|
|
@@ -174,6 +165,7 @@ module Kaal
|
|
|
174
165
|
each_enabled_entry do |entry|
|
|
175
166
|
calculate_and_dispatch_due_times(entry)
|
|
176
167
|
end
|
|
168
|
+
dispatch_due_delayed_jobs
|
|
177
169
|
rescue ConfigurationError => e
|
|
178
170
|
log_configuration_error('Kaal coordinator tick failed', e)
|
|
179
171
|
raise
|
|
@@ -377,15 +369,72 @@ module Kaal
|
|
|
377
369
|
logger&.error("Work dispatch failed for #{cron_key}: #{e.message}")
|
|
378
370
|
end
|
|
379
371
|
|
|
380
|
-
def
|
|
381
|
-
|
|
372
|
+
def dispatch_due_delayed_jobs
|
|
373
|
+
delayed_store = delayed_store_for_tick
|
|
374
|
+
return unless delayed_store
|
|
375
|
+
|
|
376
|
+
DELAYED_JOB_MAX_BATCHES_PER_TICK.times do
|
|
377
|
+
break if stop_delayed_dispatch?
|
|
378
|
+
|
|
379
|
+
apply_delayed_job_claim_jitter_if_needed(delayed_store)
|
|
380
|
+
due_jobs = delayed_store.pop_due(now: Time.now.utc, limit: DELAYED_JOB_BATCH_SIZE)
|
|
381
|
+
break if due_jobs.empty?
|
|
382
|
+
|
|
383
|
+
due_jobs.each do |job|
|
|
384
|
+
break if stop_delayed_dispatch?
|
|
385
|
+
|
|
386
|
+
dispatch_delayed_job(job, delayed_store)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
rescue StandardError => e
|
|
390
|
+
@configuration.logger&.error("Delayed job dispatch failed: #{e.message}")
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def dispatch_delayed_job(job, delayed_store)
|
|
394
|
+
if delayed_store.requires_dispatch_lock?
|
|
395
|
+
lock_key = generate_delayed_lock_key(job.fetch(:job_id))
|
|
396
|
+
return unless acquire_lock(lock_key)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
job_class = Kaal::JobDispatcher.resolve_job_class(
|
|
400
|
+
job_class_name: job.fetch(:job_class),
|
|
401
|
+
key: job.fetch(:job_id),
|
|
402
|
+
queue: job[:queue]
|
|
403
|
+
)
|
|
404
|
+
Kaal::JobDispatcher.dispatch(job_class:, queue: job[:queue], args: job.fetch(:args))
|
|
405
|
+
@configuration.logger&.debug("Dispatched delayed job #{job.fetch(:job_id)} for #{job.fetch(:run_at)}")
|
|
406
|
+
true
|
|
407
|
+
rescue StandardError => e
|
|
408
|
+
Kaal::DelayedJob::DispatchFailureLogger.log_claimed_dispatch_failure(
|
|
409
|
+
logger: @configuration.logger,
|
|
410
|
+
job:,
|
|
411
|
+
error: e
|
|
412
|
+
)
|
|
413
|
+
nil
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def delayed_store_for_tick
|
|
417
|
+
backend = @configuration.backend
|
|
418
|
+
backend.respond_to?(:delayed_store) ? backend.delayed_store : nil
|
|
382
419
|
end
|
|
383
420
|
|
|
384
|
-
def
|
|
385
|
-
|
|
386
|
-
"#{namespace}:dispatch:#{cron_key}:#{fire_time.to_i}"
|
|
421
|
+
def stop_delayed_dispatch?
|
|
422
|
+
stop_requested?
|
|
387
423
|
end
|
|
388
424
|
|
|
425
|
+
def apply_delayed_job_claim_jitter_if_needed(delayed_store)
|
|
426
|
+
return unless delayed_store.claim_strategy == :delete_confirmation
|
|
427
|
+
|
|
428
|
+
jitter = rand * DELAYED_JOB_DELETE_CONFIRMATION_JITTER_MAX
|
|
429
|
+
sleep(jitter) if jitter.positive?
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def generate_idempotency_key(cron_key, fire_time) = Kaal::IdempotencyKeyGenerator.call(cron_key, fire_time, configuration: @configuration)
|
|
433
|
+
|
|
434
|
+
def generate_lock_key(cron_key, fire_time) = "#{@configuration.namespace || 'kaal'}:dispatch:#{cron_key}:#{fire_time.to_i}"
|
|
435
|
+
|
|
436
|
+
def generate_delayed_lock_key(job_id) = "#{@configuration.namespace || 'kaal'}:delayed_dispatch:#{job_id}"
|
|
437
|
+
|
|
389
438
|
def sleep_until_next_tick
|
|
390
439
|
@mutex.synchronize do
|
|
391
440
|
@tick_cv.wait(@mutex, @configuration.tick_interval)
|
|
@@ -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
|
+
require 'json'
|
|
8
|
+
require 'kaal/delayed_job/registry'
|
|
9
|
+
require 'kaal/persistence/database'
|
|
10
|
+
|
|
11
|
+
module Kaal
|
|
12
|
+
module DelayedJob
|
|
13
|
+
# Sequel-backed delayed-job store persisted in kaal_delayed_jobs.
|
|
14
|
+
class DatabaseEngine < Registry
|
|
15
|
+
def initialize(database:, use_skip_locked: false)
|
|
16
|
+
super()
|
|
17
|
+
@database = Kaal::Persistence::Database.new(database)
|
|
18
|
+
@use_skip_locked = use_skip_locked
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def enqueue(job_id:, run_at:, job_class:, args:, queue: nil, connection: nil)
|
|
22
|
+
now = Time.now.utc
|
|
23
|
+
payload = {
|
|
24
|
+
job_id: job_id,
|
|
25
|
+
run_at: run_at,
|
|
26
|
+
job_class: job_class,
|
|
27
|
+
args: JSON.generate(args),
|
|
28
|
+
queue: queue,
|
|
29
|
+
created_at: now
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
dataset_for(connection).insert(payload)
|
|
33
|
+
self.class.normalize_row(payload)
|
|
34
|
+
rescue ::Sequel::UniqueConstraintViolation
|
|
35
|
+
raise DuplicateJobError, "Delayed job #{job_id.inspect} already exists"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pop_due(now:, limit:)
|
|
39
|
+
return pop_due_with_skip_locked(now:, limit:) if @use_skip_locked
|
|
40
|
+
|
|
41
|
+
pop_due_with_delete_confirmation(now:, limit:)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_job(job_id, connection: @database.connection)
|
|
45
|
+
self.class.normalize_row(connection[:kaal_delayed_jobs].where(job_id: job_id).first)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def all_jobs
|
|
49
|
+
connection[:kaal_delayed_jobs].order(:run_at, :job_id).filter_map { |row| self.class.normalize_row(row) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def claim_strategy
|
|
53
|
+
@use_skip_locked ? :skip_locked : :delete_confirmation
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.normalize_row(row)
|
|
57
|
+
return nil unless row
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
job_id: row[:job_id],
|
|
61
|
+
run_at: row[:run_at],
|
|
62
|
+
job_class: row[:job_class],
|
|
63
|
+
args: parse_args(row[:args]),
|
|
64
|
+
queue: row[:queue],
|
|
65
|
+
created_at: row[:created_at]
|
|
66
|
+
}
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def pop_due_with_skip_locked(now:, limit:)
|
|
74
|
+
connection.transaction do
|
|
75
|
+
delayed_jobs_dataset = connection[:kaal_delayed_jobs]
|
|
76
|
+
due_rows = delayed_jobs_dataset.where { run_at <= now }.order(:run_at, :job_id).for_update.skip_locked.limit(limit).all
|
|
77
|
+
job_ids = due_rows.map { |row| row[:job_id] }
|
|
78
|
+
normalized_jobs = due_rows.filter_map { |row| self.class.normalize_row(row) }
|
|
79
|
+
delayed_jobs_dataset.where(job_id: job_ids).delete unless job_ids.empty?
|
|
80
|
+
normalized_jobs
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pop_due_with_delete_confirmation(now:, limit:)
|
|
85
|
+
connection.transaction do
|
|
86
|
+
delayed_jobs_dataset = connection[:kaal_delayed_jobs]
|
|
87
|
+
due_rows = delayed_jobs_dataset.where { run_at <= now }.order(:run_at, :job_id).limit(limit).all
|
|
88
|
+
due_rows.each_with_object([]) do |row, jobs|
|
|
89
|
+
deleted = delayed_jobs_dataset.where(job_id: row[:job_id]).delete
|
|
90
|
+
normalized_job = self.class.normalize_row(row)
|
|
91
|
+
jobs << normalized_job if deleted.positive? && normalized_job
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.parse_args(args_payload)
|
|
97
|
+
JSON.parse(args_payload || '[]')
|
|
98
|
+
end
|
|
99
|
+
private_class_method :parse_args
|
|
100
|
+
|
|
101
|
+
def dataset_for(connection)
|
|
102
|
+
return dataset unless connection
|
|
103
|
+
|
|
104
|
+
Kaal::Persistence::Database.new(connection).delayed_jobs_dataset
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def dataset
|
|
108
|
+
@database.delayed_jobs_dataset
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def connection
|
|
112
|
+
@database.connection
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|