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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +60 -25
  3. data/config/kaal.yml +12 -0
  4. data/lib/kaal/active_record_support.rb +2 -2
  5. data/lib/kaal/backend/adapter.rb +8 -0
  6. data/lib/kaal/backend/memory_adapter.rb +5 -0
  7. data/lib/kaal/backend/mysql.rb +25 -3
  8. data/lib/kaal/backend/postgres.rb +6 -2
  9. data/lib/kaal/backend/redis_adapter.rb +5 -0
  10. data/lib/kaal/backend/sqlite.rb +4 -0
  11. data/lib/kaal/cli.rb +38 -33
  12. data/lib/kaal/config/backend_factory.rb +178 -0
  13. data/lib/kaal/config/configuration.rb +98 -9
  14. data/lib/kaal/config/delayed_job_security_policy.rb +60 -0
  15. data/lib/kaal/config/file_loader.rb +187 -0
  16. data/lib/kaal/config.rb +3 -0
  17. data/lib/kaal/core/coordinator.rb +68 -19
  18. data/lib/kaal/delayed_job/database_engine.rb +116 -0
  19. data/lib/kaal/delayed_job/dispatch_failure_logger.rb +31 -0
  20. data/lib/kaal/delayed_job/memory_engine.rb +79 -0
  21. data/lib/kaal/delayed_job/mysql_version_support.rb +43 -0
  22. data/lib/kaal/delayed_job/redis_engine.rb +119 -0
  23. data/lib/kaal/delayed_job/registry.rb +39 -0
  24. data/lib/kaal/internal/active_record/database_backend.rb +5 -0
  25. data/lib/kaal/internal/active_record/delayed_job_record.rb +16 -0
  26. data/lib/kaal/internal/active_record/delayed_job_registry.rb +119 -0
  27. data/lib/kaal/internal/active_record/migration_templates.rb +33 -3
  28. data/lib/kaal/internal/active_record/mysql_backend.rb +23 -5
  29. data/lib/kaal/internal/active_record/postgres_backend.rb +4 -0
  30. data/lib/kaal/internal/active_record.rb +2 -0
  31. data/lib/kaal/internal/sequel/database_backend.rb +5 -0
  32. data/lib/kaal/internal/sequel/mysql_backend.rb +15 -1
  33. data/lib/kaal/internal/sequel/postgres_backend.rb +4 -0
  34. data/lib/kaal/internal/sequel.rb +1 -0
  35. data/lib/kaal/job_dispatcher.rb +108 -0
  36. data/lib/kaal/persistence/database.rb +4 -0
  37. data/lib/kaal/persistence/migration_templates.rb +35 -3
  38. data/lib/kaal/runtime/scheduler_boot_loader.rb +3 -1
  39. data/lib/kaal/scheduler_file/job_applier.rb +28 -53
  40. data/lib/kaal/scheduler_file/loader.rb +1 -1
  41. data/lib/kaal/sequel_support.rb +2 -2
  42. data/lib/kaal/version.rb +1 -1
  43. data/lib/kaal.rb +118 -0
  44. data/sig/00_types.rbs +12 -0
  45. data/sig/dependencies.rbs +49 -0
  46. data/sig/kaal/active_record_support.rbs +23 -0
  47. data/sig/kaal/backend/adapter.rbs +26 -0
  48. data/sig/kaal/backend/dispatch_attempt_logger.rbs +17 -0
  49. data/sig/kaal/backend/dispatch_logging.rbs +23 -0
  50. data/sig/kaal/backend/dispatch_registry_accessor.rbs +17 -0
  51. data/sig/kaal/backend/memory_adapter.rbs +33 -0
  52. data/sig/kaal/backend/mysql.rbs +25 -0
  53. data/sig/kaal/backend/postgres.rbs +19 -0
  54. data/sig/kaal/backend/redis_adapter.rbs +41 -0
  55. data/sig/kaal/backend/sqlite.rbs +19 -0
  56. data/sig/kaal/cli.rbs +41 -0
  57. data/sig/kaal/config/backend_factory.rbs +41 -0
  58. data/sig/kaal/config/configuration.rbs +70 -0
  59. data/sig/kaal/config/delayed_job_security_policy.rbs +19 -0
  60. data/sig/kaal/config/file_loader.rbs +35 -0
  61. data/sig/kaal/config/scheduler_config_error.rbs +4 -0
  62. data/sig/kaal/config/scheduler_time_zone_resolver.rbs +19 -0
  63. data/sig/kaal/config.rbs +11 -0
  64. data/sig/kaal/core/coordinator.rbs +103 -0
  65. data/sig/kaal/core/enabled_entry_enumerator.rbs +21 -0
  66. data/sig/kaal/core/occurrence_finder.rbs +9 -0
  67. data/sig/kaal/core.rbs +9 -0
  68. data/sig/kaal/definition/database_engine.rbs +25 -0
  69. data/sig/kaal/definition/memory_engine.rbs +23 -0
  70. data/sig/kaal/definition/persistence_helpers.rbs +9 -0
  71. data/sig/kaal/definition/redis_engine.rbs +33 -0
  72. data/sig/kaal/definition/registry.rbs +29 -0
  73. data/sig/kaal/definitions/registration_service.rbs +27 -0
  74. data/sig/kaal/definitions/registry_accessor.rbs +17 -0
  75. data/sig/kaal/delayed_job/database_engine.rbs +37 -0
  76. data/sig/kaal/delayed_job/dispatch_failure_logger.rbs +7 -0
  77. data/sig/kaal/delayed_job/memory_engine.rbs +29 -0
  78. data/sig/kaal/delayed_job/mysql_version_support.rbs +15 -0
  79. data/sig/kaal/delayed_job/redis_engine.rbs +31 -0
  80. data/sig/kaal/delayed_job/registry.rbs +20 -0
  81. data/sig/kaal/dispatch/database_engine.rbs +39 -0
  82. data/sig/kaal/dispatch/memory_engine.rbs +23 -0
  83. data/sig/kaal/dispatch/redis_engine.rbs +25 -0
  84. data/sig/kaal/dispatch/registry.rbs +11 -0
  85. data/sig/kaal/internal/active_record/base_record.rbs +8 -0
  86. data/sig/kaal/internal/active_record/connection_support.rbs +25 -0
  87. data/sig/kaal/internal/active_record/database_backend.rbs +37 -0
  88. data/sig/kaal/internal/active_record/definition_record.rbs +8 -0
  89. data/sig/kaal/internal/active_record/definition_registry.rbs +27 -0
  90. data/sig/kaal/internal/active_record/delayed_job_record.rbs +8 -0
  91. data/sig/kaal/internal/active_record/delayed_job_registry.rbs +39 -0
  92. data/sig/kaal/internal/active_record/dispatch_record.rbs +8 -0
  93. data/sig/kaal/internal/active_record/dispatch_registry.rbs +43 -0
  94. data/sig/kaal/internal/active_record/lock_record.rbs +8 -0
  95. data/sig/kaal/internal/active_record/migration_templates.rbs +17 -0
  96. data/sig/kaal/internal/active_record/mysql_backend.rbs +45 -0
  97. data/sig/kaal/internal/active_record/postgres_backend.rbs +41 -0
  98. data/sig/kaal/internal/active_record.rbs +0 -0
  99. data/sig/kaal/internal/sequel/database_backend.rbs +39 -0
  100. data/sig/kaal/internal/sequel/mysql_backend.rbs +47 -0
  101. data/sig/kaal/internal/sequel/postgres_backend.rbs +43 -0
  102. data/sig/kaal/internal/sequel.rbs +0 -0
  103. data/sig/kaal/job_dispatcher.rbs +19 -0
  104. data/sig/kaal/persistence/database.rbs +19 -0
  105. data/sig/kaal/persistence/migration_templates.rbs +15 -0
  106. data/sig/kaal/register_conflict_support.rbs +11 -0
  107. data/sig/kaal/registry.rbs +44 -0
  108. data/sig/kaal/runtime/runtime_context.rbs +23 -0
  109. data/sig/kaal/runtime/scheduler_boot_loader.rbs +23 -0
  110. data/sig/kaal/runtime/signal_handler_chain.rbs +19 -0
  111. data/sig/kaal/runtime/signal_handler_installer.rbs +19 -0
  112. data/sig/kaal/runtime.rbs +11 -0
  113. data/sig/kaal/scheduler_file/hash_transform.rbs +9 -0
  114. data/sig/kaal/scheduler_file/helper_bundle.rbs +15 -0
  115. data/sig/kaal/scheduler_file/job_applier.rbs +43 -0
  116. data/sig/kaal/scheduler_file/job_normalizer.rbs +27 -0
  117. data/sig/kaal/scheduler_file/loader.rbs +69 -0
  118. data/sig/kaal/scheduler_file/payload_loader.rbs +33 -0
  119. data/sig/kaal/scheduler_file/placeholder_support.rbs +19 -0
  120. data/sig/kaal/scheduler_file.rbs +9 -0
  121. data/sig/kaal/sequel_support.rbs +25 -0
  122. data/sig/kaal/support/hash_tools.rbs +27 -0
  123. data/sig/kaal/utils/cron_humanizer.rbs +39 -0
  124. data/sig/kaal/utils/cron_utils.rbs +43 -0
  125. data/sig/kaal/utils/idempotency_key_generator.rbs +5 -0
  126. data/sig/kaal/utils.rbs +9 -0
  127. data/sig/kaal/version.rbs +3 -0
  128. data/sig/kaal.rbs +145 -0
  129. metadata +100 -3
  130. data/config/kaal.rb +0 -15
  131. /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.backend = Kaal::Backend::RedisAdapter.new(Redis.new(url: ENV["REDIS_URL"]))
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&.to_s
253
+ normalize_optional_string(value)
217
254
  when :enable_log_dispatch_registry
218
- value ? true : false
255
+ !!value
219
256
  when :scheduler_conflict_policy, :scheduler_missing_file_policy
220
- value&.to_sym
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. For each registered cron, calculates due fire times within the window
20
- # 3. Attempts to acquire a distributed lease for each due time
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 generate_idempotency_key(cron_key, fire_time)
381
- Kaal::IdempotencyKeyGenerator.call(cron_key, fire_time, configuration: @configuration)
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 generate_lock_key(cron_key, fire_time)
385
- namespace = @configuration.namespace || 'kaal'
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