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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -287
  3. data/Rakefile +4 -2
  4. data/config/kaal.rb +15 -0
  5. data/config/scheduler.yml +12 -0
  6. data/{lib/tasks/kaal_tasks.rake → exe/kaal} +5 -3
  7. data/lib/kaal/active_record_support.rb +82 -0
  8. data/lib/kaal/backend/adapter.rb +0 -1
  9. data/lib/kaal/backend/dispatch_attempt_logger.rb +33 -0
  10. data/lib/kaal/backend/dispatch_logging.rb +36 -23
  11. data/lib/kaal/backend/dispatch_registry_accessor.rb +43 -0
  12. data/lib/kaal/backend/memory_adapter.rb +7 -5
  13. data/lib/kaal/backend/mysql.rb +41 -0
  14. data/lib/kaal/backend/postgres.rb +41 -0
  15. data/lib/kaal/backend/redis_adapter.rb +6 -6
  16. data/lib/kaal/backend/sqlite.rb +41 -0
  17. data/lib/kaal/cli.rb +230 -0
  18. data/lib/kaal/{configuration.rb → config/configuration.rb} +0 -1
  19. data/lib/kaal/{scheduler_config_error.rb → config/scheduler_config_error.rb} +0 -1
  20. data/lib/kaal/config/scheduler_time_zone_resolver.rb +50 -0
  21. data/lib/kaal/config.rb +19 -0
  22. data/lib/kaal/{coordinator.rb → core/coordinator.rb} +42 -62
  23. data/lib/kaal/core/enabled_entry_enumerator.rb +51 -0
  24. data/lib/kaal/core/occurrence_finder.rb +38 -0
  25. data/lib/kaal/core.rb +18 -0
  26. data/lib/kaal/definition/database_engine.rb +54 -16
  27. data/lib/kaal/definition/memory_engine.rb +11 -18
  28. data/lib/kaal/definition/persistence_helpers.rb +31 -0
  29. data/lib/kaal/definition/redis_engine.rb +9 -6
  30. data/lib/kaal/definition/registry.rb +24 -2
  31. data/lib/kaal/definitions/registration_service.rb +62 -0
  32. data/lib/kaal/definitions/registry_accessor.rb +33 -0
  33. data/lib/kaal/dispatch/database_engine.rb +87 -61
  34. data/lib/kaal/dispatch/memory_engine.rb +3 -4
  35. data/lib/kaal/dispatch/redis_engine.rb +2 -3
  36. data/lib/kaal/dispatch/registry.rb +0 -1
  37. data/lib/kaal/internal/active_record/base_record.rb +16 -0
  38. data/lib/kaal/internal/active_record/connection_support.rb +96 -0
  39. data/lib/kaal/internal/active_record/database_backend.rb +73 -0
  40. data/lib/kaal/internal/active_record/definition_record.rb +16 -0
  41. data/lib/kaal/internal/active_record/definition_registry.rb +81 -0
  42. data/lib/kaal/internal/active_record/dispatch_record.rb +16 -0
  43. data/lib/kaal/internal/active_record/dispatch_registry.rb +100 -0
  44. data/lib/kaal/internal/active_record/lock_record.rb +16 -0
  45. data/lib/kaal/internal/active_record/migration_templates.rb +108 -0
  46. data/lib/kaal/internal/active_record/mysql_backend.rb +71 -0
  47. data/lib/kaal/internal/active_record/postgres_backend.rb +69 -0
  48. data/lib/kaal/internal/active_record.rb +17 -0
  49. data/lib/kaal/internal/sequel/database_backend.rb +74 -0
  50. data/lib/kaal/internal/sequel/mysql_backend.rb +69 -0
  51. data/lib/kaal/internal/sequel/postgres_backend.rb +67 -0
  52. data/lib/kaal/internal/sequel.rb +12 -0
  53. data/lib/kaal/persistence/database.rb +35 -0
  54. data/lib/kaal/persistence/migration_templates.rb +97 -0
  55. data/lib/kaal/register_conflict_support.rb +0 -1
  56. data/lib/kaal/registry.rb +0 -3
  57. data/lib/kaal/runtime/runtime_context.rb +41 -0
  58. data/lib/kaal/runtime/scheduler_boot_loader.rb +52 -0
  59. data/lib/kaal/runtime/signal_handler_chain.rb +42 -0
  60. data/lib/kaal/runtime/signal_handler_installer.rb +39 -0
  61. data/lib/kaal/runtime.rb +20 -0
  62. data/lib/kaal/scheduler_file/hash_transform.rb +22 -0
  63. data/lib/kaal/scheduler_file/helper_bundle.rb +28 -0
  64. data/lib/kaal/scheduler_file/job_applier.rb +242 -0
  65. data/lib/kaal/scheduler_file/job_normalizer.rb +90 -0
  66. data/lib/kaal/scheduler_file/loader.rb +152 -0
  67. data/lib/kaal/scheduler_file/payload_loader.rb +95 -0
  68. data/lib/kaal/{scheduler_placeholder_support.rb → scheduler_file/placeholder_support.rb} +0 -1
  69. data/lib/kaal/scheduler_file.rb +18 -0
  70. data/lib/kaal/sequel_support.rb +82 -0
  71. data/lib/kaal/support/hash_tools.rb +93 -0
  72. data/lib/kaal/{cron_humanizer.rb → utils/cron_humanizer.rb} +19 -1
  73. data/lib/kaal/{cron_utils.rb → utils/cron_utils.rb} +0 -1
  74. data/lib/kaal/{idempotency_key_generator.rb → utils/idempotency_key_generator.rb} +3 -3
  75. data/lib/kaal/utils.rb +18 -0
  76. data/lib/kaal/version.rb +1 -2
  77. data/lib/kaal.rb +83 -397
  78. metadata +87 -42
  79. data/app/models/kaal/cron_definition.rb +0 -76
  80. data/app/models/kaal/cron_dispatch.rb +0 -50
  81. data/app/models/kaal/cron_lock.rb +0 -38
  82. data/lib/generators/kaal/install/install_generator.rb +0 -72
  83. data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +0 -21
  84. data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +0 -20
  85. data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +0 -17
  86. data/lib/generators/kaal/install/templates/kaal.rb.tt +0 -31
  87. data/lib/generators/kaal/install/templates/scheduler.yml.tt +0 -22
  88. data/lib/kaal/backend/mysql_adapter.rb +0 -170
  89. data/lib/kaal/backend/postgres_adapter.rb +0 -134
  90. data/lib/kaal/backend/sqlite_adapter.rb +0 -116
  91. data/lib/kaal/railtie.rb +0 -183
  92. data/lib/kaal/rake_tasks.rb +0 -184
  93. data/lib/kaal/scheduler_file_loader.rb +0 -321
  94. data/lib/kaal/scheduler_hash_transform.rb +0 -45
@@ -0,0 +1,52 @@
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
+ # Loads scheduler.yml at framework boot time while respecting missing-file policy.
9
+ class SchedulerBootLoader
10
+ def initialize(configuration_provider:, logger:, runtime_context:, load_scheduler_file:)
11
+ @configuration_provider = configuration_provider
12
+ @logger = logger
13
+ @runtime_context = runtime_context
14
+ @load_scheduler_file = load_scheduler_file
15
+ end
16
+
17
+ def load_on_boot
18
+ load_on_boot!
19
+ end
20
+
21
+ def load_on_boot!
22
+ configuration = fetch_configuration
23
+ return unless configuration
24
+
25
+ return load_scheduler_file if configuration.scheduler_missing_file_policy == :error
26
+
27
+ scheduler_path = configuration.scheduler_config_path.to_s.strip
28
+ return if scheduler_path.empty?
29
+
30
+ absolute_path = @runtime_context.resolve_path(scheduler_path)
31
+ unless File.exist?(absolute_path)
32
+ @logger&.warn("Scheduler file not found at #{absolute_path}")
33
+ return
34
+ end
35
+
36
+ load_scheduler_file
37
+ end
38
+
39
+ private
40
+
41
+ def load_scheduler_file
42
+ @load_scheduler_file.call
43
+ end
44
+
45
+ def fetch_configuration
46
+ @configuration_provider.call
47
+ rescue NameError => e
48
+ @logger&.debug("Skipping scheduler file boot load due to configuration error: #{e.message}")
49
+ nil
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,42 @@
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
+ # Invokes a previously registered signal handler when it is safe to do so.
9
+ class SignalHandlerChain
10
+ RESERVED_COMMAND_HANDLERS = %w[DEFAULT IGNORE].freeze
11
+
12
+ def initialize(signal:, previous_handler:, logger:)
13
+ @signal = signal
14
+ @previous_handler = previous_handler
15
+ @logger = logger
16
+ end
17
+
18
+ def call(...)
19
+ return unless @previous_handler
20
+
21
+ case @previous_handler
22
+ when Proc, Method
23
+ invoke_callable(...)
24
+ when String
25
+ return if RESERVED_COMMAND_HANDLERS.include?(@previous_handler)
26
+
27
+ @logger&.debug("Previous #{@signal} handler was a command: #{@previous_handler}")
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def invoke_callable(*args)
34
+ arity = @previous_handler.arity
35
+ return @previous_handler.call if arity.zero?
36
+
37
+ argument_length = args.length
38
+ argument_count = arity.negative? ? argument_length : [arity, argument_length].min
39
+ @previous_handler.call(*args.first(argument_count))
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,39 @@
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
+ # Installs signal handlers while preserving the previous handlers for chaining.
9
+ class SignalHandlerInstaller
10
+ SIGNALS = %w[TERM INT].freeze
11
+ IGNORE_HANDLER = 'IGNORE'
12
+
13
+ def initialize(signal_module: Signal)
14
+ @signal_module = signal_module
15
+ end
16
+
17
+ def install(signals: SIGNALS)
18
+ signals.each_with_object({}) do |signal, previous_handlers|
19
+ previous_handler = capture_previous_handler(signal)
20
+ @signal_module.trap(signal) { yield(signal, previous_handler) }
21
+ previous_handlers[signal] = previous_handler
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def capture_previous_handler(signal)
28
+ previous_handler = @signal_module.trap(signal, IGNORE_HANDLER)
29
+ restore_previous_handler(signal, previous_handler)
30
+ previous_handler
31
+ end
32
+
33
+ def restore_previous_handler(signal, previous_handler)
34
+ return unless previous_handler && previous_handler != IGNORE_HANDLER
35
+
36
+ @signal_module.trap(signal, previous_handler)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,20 @@
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/runtime/runtime_context'
8
+ require 'kaal/runtime/scheduler_boot_loader'
9
+ require 'kaal/runtime/signal_handler_chain'
10
+ require 'kaal/runtime/signal_handler_installer'
11
+
12
+ module Kaal
13
+ # Runtime wiring and lifecycle helpers.
14
+ module Runtime
15
+ RuntimeContext = ::Kaal::RuntimeContext
16
+ SchedulerBootLoader = ::Kaal::SchedulerBootLoader
17
+ SignalHandlerChain = ::Kaal::SignalHandlerChain
18
+ SignalHandlerInstaller = ::Kaal::SignalHandlerInstaller
19
+ end
20
+ end
@@ -0,0 +1,22 @@
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/support/hash_tools'
8
+
9
+ module Kaal
10
+ # Shared deep hash key transformation helpers for scheduler payloads.
11
+ module SchedulerHashTransform
12
+ private
13
+
14
+ def stringify_keys(object)
15
+ Kaal::Support::HashTools.stringify_keys(object)
16
+ end
17
+
18
+ def symbolize_keys_deep(object)
19
+ Kaal::Support::HashTools.symbolize_keys(object)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,28 @@
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
+ class SchedulerFileLoader
9
+ # Exposes hash/placeholder helpers to extracted scheduler-file collaborators.
10
+ class HelperBundle
11
+ def initialize(loader:)
12
+ @loader = loader
13
+ end
14
+
15
+ def stringify_keys(payload)
16
+ @loader.send(:stringify_keys, payload)
17
+ end
18
+
19
+ def resolve_placeholders(value, context)
20
+ @loader.send(:resolve_placeholders, value, context)
21
+ end
22
+
23
+ def validate_placeholders(value, key:)
24
+ @loader.send(:validate_placeholders, value, key:)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,242 @@
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/support/hash_tools'
8
+
9
+ module Kaal
10
+ class SchedulerFileLoader
11
+ # Applies normalized scheduler jobs and rolls them back on failure.
12
+ class JobApplier
13
+ include Kaal::Support::HashTools
14
+
15
+ def initialize(configuration:, definition_registry:, registry:, logger:, helper_bundle:)
16
+ @configuration = configuration
17
+ @definition_registry = definition_registry
18
+ @registry = registry
19
+ @logger = logger
20
+ @helper_bundle = helper_bundle
21
+ end
22
+
23
+ def apply(job)
24
+ key = job.fetch(:key)
25
+ cron = job.fetch(:cron)
26
+ job_class_name = job.fetch(:job_class_name)
27
+ queue = job.fetch(:queue)
28
+ existing_definition = @definition_registry.find_definition(key)
29
+ existing_registry_entry = @registry.find(key)
30
+ return nil if conflict?(key:, existing_definition:)
31
+
32
+ job_class = resolved_job_class(job_class_name:, key:, queue:)
33
+ callback = callback_for(
34
+ key: key,
35
+ job_class_name: job_class_name,
36
+ queue: queue,
37
+ args_template: job.fetch(:args),
38
+ kwargs_template: job.fetch(:kwargs)
39
+ )
40
+ persisted_metadata = persisted_metadata(job, job_class)
41
+
42
+ @definition_registry.upsert_definition(
43
+ key: key,
44
+ cron: cron,
45
+ enabled: job.fetch(:enabled),
46
+ source: 'file',
47
+ metadata: persisted_metadata
48
+ )
49
+
50
+ begin
51
+ @registry.upsert(key: key, cron: cron, enqueue: callback)
52
+ rescue StandardError
53
+ rollback_job(key:, existing_definition:, existing_registry_entry:)
54
+ raise
55
+ end
56
+
57
+ { key: key, existing_definition: existing_definition, existing_registry_entry: existing_registry_entry }
58
+ end
59
+
60
+ def rollback_jobs(applied_job_contexts)
61
+ applied_job_contexts.reverse_each do |applied_job_context|
62
+ rollback_job(**applied_job_context)
63
+ end
64
+ end
65
+
66
+ def callback_for(key:, job_class_name:, queue:, args_template:, kwargs_template:)
67
+ job_class = resolved_job_class(job_class_name:, key:, queue:)
68
+ build_callback(
69
+ {
70
+ key: key,
71
+ queue: queue,
72
+ args: args_template,
73
+ kwargs: kwargs_template
74
+ },
75
+ job_class
76
+ )
77
+ end
78
+
79
+ def resolved_job_class(job_class_name:, key:, queue: nil)
80
+ resolve_job_class(job_class_name:, key:, queue:)
81
+ end
82
+
83
+ def conflict?(key:, existing_definition:)
84
+ existing_source = existing_definition&.[](:source)
85
+ return false unless existing_source && existing_source.to_s != 'file'
86
+
87
+ policy = @configuration.scheduler_conflict_policy
88
+ case policy
89
+ when :error
90
+ raise SchedulerConfigError, "Scheduler key conflict for '#{key}' with existing source '#{existing_source}'"
91
+ when :code_wins
92
+ @logger&.warn("Skipping scheduler file job '#{key}' because scheduler_conflict_policy is :code_wins")
93
+ true
94
+ when :file_wins
95
+ false
96
+ else
97
+ raise SchedulerConfigError, "Unsupported scheduler_conflict_policy '#{policy}'"
98
+ end
99
+ end
100
+
101
+ def rollback_job(key:, existing_definition:, existing_registry_entry:)
102
+ if existing_definition
103
+ @definition_registry.upsert_definition(
104
+ **Definition::AttributeHelpers.definition_attributes(existing_definition), enabled: existing_definition[:enabled]
105
+ )
106
+ else
107
+ @definition_registry.remove_definition(key)
108
+ end
109
+
110
+ @registry.remove(key) if @registry.registered?(key)
111
+
112
+ return unless existing_registry_entry
113
+
114
+ @registry.upsert(
115
+ key: existing_registry_entry.key,
116
+ cron: existing_registry_entry.cron,
117
+ enqueue: existing_registry_entry.enqueue
118
+ )
119
+ rescue StandardError => e
120
+ @logger&.error("Failed to rollback scheduler file application for #{key}: #{e.message}")
121
+ end
122
+
123
+ private
124
+
125
+ def persisted_metadata(job, job_class)
126
+ metadata, job_class_name, queue, args, kwargs =
127
+ job.values_at(:metadata, :job_class_name, :queue, :args, :kwargs)
128
+ normalized_metadata = @helper_bundle.stringify_keys(deep_dup(metadata || {}))
129
+ Kaal::Support::HashTools.deep_merge(
130
+ normalized_metadata,
131
+ 'execution' => {
132
+ 'target' => active_job_dispatch?(job_class, queue) ? 'active_job' : 'ruby',
133
+ 'job_class' => job_class_name,
134
+ 'queue' => queue,
135
+ 'args' => args,
136
+ 'kwargs' => kwargs
137
+ }
138
+ )
139
+ end
140
+
141
+ def build_callback(job, job_class)
142
+ key = job.fetch(:key)
143
+ queue = job.fetch(:queue)
144
+ args_template = job.fetch(:args)
145
+ kwargs_template = job.fetch(:kwargs)
146
+
147
+ lambda do |fire_time:, idempotency_key:|
148
+ context = {
149
+ fire_time: fire_time,
150
+ idempotency_key: idempotency_key,
151
+ key: key
152
+ }
153
+ resolved_args = @helper_bundle.resolve_placeholders(deep_dup(args_template), context)
154
+ raw_kwargs = @helper_bundle.resolve_placeholders(deep_dup(kwargs_template), context) || {}
155
+ raise SchedulerConfigError, "kwargs for scheduler job '#{key}' must be a mapping, got #{raw_kwargs.class}" unless raw_kwargs.is_a?(Hash)
156
+
157
+ validate_keyword_keys(raw_kwargs, key)
158
+
159
+ resolved_kwargs = raw_kwargs.transform_keys(&:to_sym)
160
+ dispatch_job(job_class, queue, resolved_args, resolved_kwargs)
161
+ end
162
+ end
163
+
164
+ def validate_keyword_keys(raw_kwargs, key)
165
+ keys = raw_kwargs.keys
166
+ index = 0
167
+ while index < keys.length
168
+ kwargs_key = keys[index]
169
+ if kwargs_key.is_a?(String) || kwargs_key.is_a?(Symbol)
170
+ index += 1
171
+ next
172
+ end
173
+
174
+ raise SchedulerConfigError,
175
+ "Invalid keyword argument key #{kwargs_key.inspect} (#{kwargs_key.class}) for scheduler job '#{key}'"
176
+ end
177
+
178
+ nil
179
+ end
180
+
181
+ def resolve_job_class(job_class_name:, key:, queue: nil)
182
+ normalized_job_class_name = job_class_name.to_s.strip
183
+ raise SchedulerConfigError, "Job class cannot be blank for key '#{key}'" if normalized_job_class_name.empty?
184
+
185
+ error_message = "Unknown job_class #{normalized_job_class_name.inspect} for key '#{key}'"
186
+ job_class = begin
187
+ Kaal::Support::HashTools.constantize(normalized_job_class_name)
188
+ rescue NameError
189
+ nil
190
+ end
191
+
192
+ return validate_dispatch_interface(job_class, key, queue) if job_class
193
+
194
+ raise_unknown_job_class(error_message)
195
+ end
196
+
197
+ private :build_callback, :resolve_job_class
198
+
199
+ def dispatch_job(job_class, queue, args, kwargs)
200
+ job_class_name = job_class.name
201
+
202
+ if queue && !job_class.respond_to?(:set)
203
+ raise SchedulerConfigError,
204
+ "job_class '#{job_class_name}' must respond to .set to use queue #{queue.inspect}"
205
+ end
206
+
207
+ if queue
208
+ job_class.set(queue: queue).perform_later(*args, **kwargs)
209
+ elsif job_class.respond_to?(:perform_later)
210
+ job_class.perform_later(*args, **kwargs)
211
+ elsif job_class.respond_to?(:perform)
212
+ job_class.perform(*args, **kwargs)
213
+ else
214
+ raise SchedulerConfigError,
215
+ "job_class '#{job_class_name}' must respond to .perform, .perform_later, or .set(...).perform_later"
216
+ end
217
+ end
218
+
219
+ def raise_unknown_job_class(error_message)
220
+ raise SchedulerConfigError, error_message
221
+ end
222
+
223
+ def validate_dispatch_interface(job_class, key, queue)
224
+ queue_present = !queue.nil?
225
+ supports_set = job_class.respond_to?(:set)
226
+ supports_perform_later = job_class.respond_to?(:perform_later)
227
+ supports_perform = job_class.respond_to?(:perform)
228
+
229
+ return job_class if queue_present && supports_set
230
+ return job_class if !queue_present && supports_perform_later
231
+ return job_class if !queue_present && supports_perform
232
+
233
+ raise SchedulerConfigError,
234
+ "job_class '#{job_class.name}' for key '#{key}' must respond to .perform, .perform_later, or .set(...).perform_later"
235
+ end
236
+
237
+ def active_job_dispatch?(job_class, queue)
238
+ (queue && job_class.respond_to?(:set)) || job_class.respond_to?(:perform_later)
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,90 @@
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/support/hash_tools'
8
+
9
+ module Kaal
10
+ class SchedulerFileLoader
11
+ # Normalizes scheduler job payloads into application-ready hashes.
12
+ class JobNormalizer
13
+ include Kaal::Support::HashTools
14
+
15
+ def initialize(hash_transform:, placeholder_support:, cron_validator:)
16
+ @hash_transform = hash_transform
17
+ @placeholder_support = placeholder_support
18
+ @cron_validator = cron_validator
19
+ end
20
+
21
+ def call(job_payload)
22
+ payload = @hash_transform.stringify_keys(job_payload)
23
+ key = payload.fetch('key', '').to_s.strip
24
+ raise SchedulerConfigError, 'Job key cannot be blank' if key.empty?
25
+
26
+ cron = required_string(payload, field: 'cron', error_prefix: "Job cron cannot be blank for key '#{key}'")
27
+ job_class_name = required_string(payload, field: 'job_class', error_prefix: "Job class cannot be blank for key '#{key}'")
28
+ validate_cron(key:, cron:)
29
+ options = extract_job_options(payload, key:)
30
+
31
+ {
32
+ key: key,
33
+ cron: cron,
34
+ job_class_name: job_class_name,
35
+ **options
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def required_string(payload, field:, error_prefix:)
42
+ value = payload.fetch(field, '').to_s.strip
43
+ raise SchedulerConfigError, error_prefix if value.empty?
44
+
45
+ value
46
+ end
47
+
48
+ def validate_cron(key:, cron:)
49
+ return if @cron_validator.call(cron)
50
+
51
+ raise SchedulerConfigError, "Invalid cron expression '#{cron}' for key '#{key}'"
52
+ end
53
+
54
+ def extract_job_options(payload, key:)
55
+ metadata, args, kwargs, queue, enabled_value = payload.values_at('metadata', 'args', 'kwargs', 'queue', 'enabled')
56
+ args ||= []
57
+ kwargs ||= {}
58
+ enabled = true
59
+ if payload.key?('enabled')
60
+ raise SchedulerConfigError, "enabled must be a boolean for key '#{key}'" unless enabled_value.is_a?(TrueClass) || enabled_value.is_a?(FalseClass)
61
+
62
+ enabled = enabled_value
63
+ end
64
+
65
+ raise SchedulerConfigError, "metadata must be a mapping for key '#{key}'" if metadata && !metadata.is_a?(Hash)
66
+
67
+ validate_job_option_types(key:, args:, kwargs:, queue:)
68
+ @placeholder_support.validate_placeholders(args, key:)
69
+ @placeholder_support.validate_placeholders(kwargs, key:)
70
+
71
+ {
72
+ queue: queue,
73
+ args: deep_dup(args),
74
+ kwargs: deep_dup(kwargs),
75
+ enabled: enabled,
76
+ metadata: metadata ? deep_dup(metadata) : {}
77
+ }
78
+ end
79
+
80
+ def validate_job_option_types(key:, args:, kwargs:, queue:)
81
+ raise SchedulerConfigError, "args must be an array for key '#{key}'" unless args.is_a?(Array)
82
+ raise SchedulerConfigError, "kwargs must be a mapping for key '#{key}'" unless kwargs.is_a?(Hash)
83
+ raise SchedulerConfigError, "queue must be a string for key '#{key}'" if queue && !queue.is_a?(String)
84
+ return if kwargs.keys.all? { |kwargs_key| kwargs_key.is_a?(String) || kwargs_key.is_a?(Symbol) }
85
+
86
+ raise SchedulerConfigError, "kwargs keys must be strings or symbols for key '#{key}'"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,152 @@
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/runtime/runtime_context'
8
+ require 'kaal/scheduler_file/hash_transform'
9
+ require 'kaal/scheduler_file/placeholder_support'
10
+ require 'kaal/support/hash_tools'
11
+ require_relative 'helper_bundle'
12
+ require_relative 'payload_loader'
13
+ require_relative 'job_normalizer'
14
+ require_relative 'job_applier'
15
+
16
+ module Kaal
17
+ # Loads scheduler definitions from config/scheduler.yml and registers them.
18
+ class SchedulerFileLoader
19
+ include SchedulerHashTransform
20
+ include SchedulerPlaceholderSupport
21
+ include Kaal::Support::HashTools
22
+
23
+ PLACEHOLDER_PATTERN = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/
24
+ ALLOWED_PLACEHOLDERS = {
25
+ 'fire_time.iso8601' => ->(ctx) { ctx.fetch(:fire_time).iso8601 },
26
+ 'fire_time.unix' => ->(ctx) { ctx.fetch(:fire_time).to_i },
27
+ 'idempotency_key' => ->(ctx) { ctx.fetch(:idempotency_key) },
28
+ 'key' => ->(ctx) { ctx.fetch(:key) }
29
+ }.freeze
30
+
31
+ def initialize(
32
+ configuration:,
33
+ definition_registry:,
34
+ registry:,
35
+ logger:,
36
+ runtime_context: RuntimeContext.default
37
+ )
38
+ @configuration = configuration
39
+ @definition_registry = definition_registry
40
+ @registry = registry
41
+ @logger = logger
42
+ @runtime_context = runtime_context
43
+ @placeholder_resolvers = ALLOWED_PLACEHOLDERS
44
+ end
45
+
46
+ def load
47
+ applied_job_contexts = []
48
+ path, payload = payload_loader.load
49
+ return handle_missing_file(path) unless payload
50
+
51
+ jobs = extract_jobs(payload)
52
+ validate_unique_keys(jobs)
53
+ normalized_jobs = jobs.map { |job_payload| normalize_job(job_payload) }
54
+ applied_jobs = []
55
+ normalized_jobs.each do |job|
56
+ applied_job_context = apply_job(job)
57
+ next unless applied_job_context
58
+
59
+ applied_jobs << job
60
+ applied_job_contexts << applied_job_context
61
+ end
62
+
63
+ applied_jobs
64
+ rescue StandardError
65
+ rollback_applied_jobs(applied_job_contexts)
66
+ raise
67
+ end
68
+
69
+ private
70
+
71
+ def handle_missing_file(path)
72
+ payload_loader.handle_missing_file(path)
73
+ end
74
+
75
+ def extract_jobs(payload)
76
+ payload_loader.extract_jobs(payload)
77
+ end
78
+
79
+ def validate_unique_keys(jobs)
80
+ payload_loader.validate_unique_keys(jobs)
81
+ end
82
+
83
+ def normalize_job(job_payload)
84
+ job_normalizer.call(job_payload)
85
+ end
86
+
87
+ def extract_job_options(payload, key:)
88
+ job_normalizer.send(:extract_job_options, payload, key:)
89
+ end
90
+
91
+ def apply_job(job)
92
+ job_applier.apply(job)
93
+ end
94
+
95
+ def rollback_applied_jobs(applied_job_contexts = [])
96
+ job_applier.rollback_jobs(applied_job_contexts)
97
+ end
98
+
99
+ def rollback_applied_job(key:, existing_definition:, existing_registry_entry:)
100
+ job_applier.rollback_job(key:, existing_definition:, existing_registry_entry:)
101
+ end
102
+
103
+ def skip_due_to_conflict?(key:, existing_definition:)
104
+ job_applier.conflict?(key:, existing_definition:)
105
+ end
106
+
107
+ def build_callback(key:, job_class_name:, queue:, args_template:, kwargs_template:)
108
+ job_applier.callback_for(
109
+ key: key,
110
+ job_class_name: job_class_name,
111
+ queue: queue,
112
+ args_template: args_template,
113
+ kwargs_template: kwargs_template
114
+ )
115
+ end
116
+
117
+ def resolve_job_class(job_class_name:, key:, queue: nil)
118
+ job_applier.resolved_job_class(job_class_name:, key:, queue:)
119
+ end
120
+
121
+ def payload_loader
122
+ @payload_loader ||= PayloadLoader.new(
123
+ configuration: @configuration,
124
+ runtime_context: @runtime_context,
125
+ logger: @logger,
126
+ hash_transform: helper_bundle
127
+ )
128
+ end
129
+
130
+ def job_normalizer
131
+ @job_normalizer ||= JobNormalizer.new(
132
+ hash_transform: helper_bundle,
133
+ placeholder_support: helper_bundle,
134
+ cron_validator: ->(cron) { Kaal.valid?(cron) }
135
+ )
136
+ end
137
+
138
+ def job_applier
139
+ @job_applier ||= JobApplier.new(
140
+ configuration: @configuration,
141
+ definition_registry: @definition_registry,
142
+ registry: @registry,
143
+ logger: @logger,
144
+ helper_bundle: helper_bundle
145
+ )
146
+ end
147
+
148
+ def helper_bundle
149
+ @helper_bundle ||= HelperBundle.new(loader: self)
150
+ end
151
+ end
152
+ end