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
@@ -1,184 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Copyright Codevedas Inc. 2025-present
4
- #
5
- # This source code is licensed under the MIT license found in the
6
- # LICENSE file in the root directory of this source tree.
7
-
8
- require 'rake'
9
-
10
- module Kaal
11
- ##
12
- # Defines Kaal rake tasks on a given Rake application.
13
- module RakeTasks
14
- SIGNALS = %w[TERM INT].freeze
15
- SHUTDOWN_TIMEOUT = 30
16
- RESERVED_SIGNAL_HANDLERS = %w[DEFAULT IGNORE SYSTEM_DEFAULT EXIT].freeze
17
-
18
- module_function
19
-
20
- def install(rake_application = Rake.application)
21
- rake_application.extend(Rake::DSL)
22
-
23
- rake_application.instance_eval do
24
- namespace :kaal do
25
- Kaal::RakeTasks.define_tick_task(self)
26
- Kaal::RakeTasks.define_status_task(self)
27
- Kaal::RakeTasks.define_explain_task(self)
28
- Kaal::RakeTasks.define_start_task(self)
29
- end
30
- end
31
- end
32
-
33
- def define_tick_task(context)
34
- context.instance_eval do
35
- desc 'Perform a single scheduler tick'
36
- task tick: :environment do
37
- Kaal.tick!
38
- puts 'Kaal tick completed'
39
- rescue StandardError => e
40
- abort("kaal:tick failed: #{e.message}")
41
- end
42
- end
43
- end
44
-
45
- def define_status_task(context)
46
- context.instance_eval do
47
- desc 'Show scheduler status, configuration, and registered cron jobs'
48
- task status: :environment do
49
- puts "Kaal v#{Kaal::VERSION}"
50
- puts "Running: #{Kaal.running?}"
51
- puts "Tick interval: #{Kaal.tick_interval}s"
52
- puts "Window lookback: #{Kaal.window_lookback}s"
53
- puts "Window lookahead: #{Kaal.window_lookahead}s"
54
- puts "Lease TTL: #{Kaal.lease_ttl}s"
55
- puts "Namespace: #{Kaal.namespace}"
56
-
57
- entries = Kaal.registered
58
- puts "Registered jobs: #{entries.length}"
59
- entries.each do |entry|
60
- puts " - #{entry.key} (#{entry.cron})"
61
- end
62
- rescue StandardError => e
63
- abort("kaal:status failed: #{e.message}")
64
- end
65
- end
66
- end
67
-
68
- def define_explain_task(context)
69
- context.instance_eval do
70
- desc 'Humanize a cron expression, e.g. rake kaal:explain["*/5 * * * *"]'
71
- task :explain, [:expr] => :environment do |_task, args|
72
- expression = args[:expr].to_s.strip
73
- abort('kaal:explain requires expr argument') if expression.empty?
74
-
75
- puts Kaal.to_human(expression)
76
- rescue StandardError => e
77
- abort("kaal:explain failed: #{e.message}")
78
- end
79
- end
80
- end
81
-
82
- def define_start_task(context)
83
- context.instance_eval do
84
- desc 'Start scheduler loop in foreground (blocks until stopped)'
85
- task start: :environment do
86
- signal_state = {
87
- graceful_shutdown_started: false,
88
- shutdown_complete: false,
89
- force_exit_requested: false
90
- }
91
- previous_handlers = Kaal::RakeTasks.install_foreground_signal_handlers(signal_state)
92
-
93
- begin
94
- thread = Kaal.start!
95
- abort('kaal:start failed: scheduler is already running') unless thread
96
-
97
- puts 'Kaal scheduler started in foreground'
98
- thread.join
99
- ensure
100
- Kaal::RakeTasks.restore_signal_handlers(previous_handlers)
101
- end
102
- rescue Interrupt
103
- abort('kaal:start failed: shutdown timed out; forced exit requested') if signal_state[:force_exit_requested]
104
-
105
- Kaal::RakeTasks.shutdown_scheduler(signal: 'INT', signal_state: signal_state)
106
- rescue StandardError => e
107
- abort("kaal:start failed: #{e.message}")
108
- end
109
- end
110
- end
111
-
112
- def install_foreground_signal_handlers(signal_state)
113
- SIGNALS.each_with_object({}) do |signal, handlers|
114
- previous_handler = capture_previous_signal_handler(signal)
115
- Signal.trap(signal) do
116
- shutdown_scheduler(signal: signal, signal_state: signal_state, previous_handler: previous_handler)
117
- end
118
- handlers[signal] = previous_handler
119
- end
120
- end
121
-
122
- def capture_previous_signal_handler(signal)
123
- previous_handler = Signal.trap(signal, 'IGNORE')
124
- Signal.trap(signal, previous_handler)
125
- previous_handler
126
- end
127
-
128
- def restore_signal_handlers(previous_handlers)
129
- previous_handlers.each do |signal, handler|
130
- Signal.trap(signal, handler)
131
- rescue StandardError
132
- nil
133
- end
134
- end
135
-
136
- def shutdown_scheduler(signal:, signal_state:, previous_handler: nil)
137
- return if signal_state[:shutdown_complete]
138
-
139
- if signal_state[:graceful_shutdown_started]
140
- signal_state[:force_exit_requested] = true
141
- warn("Received #{signal} again; forcing scheduler shutdown")
142
- Thread.main.raise(Interrupt)
143
- return
144
- end
145
-
146
- signal_state[:graceful_shutdown_started] = true
147
- puts "Received #{signal}, stopping Kaal scheduler..."
148
- stopped = Kaal.stop!(timeout: SHUTDOWN_TIMEOUT)
149
- if stopped
150
- signal_state[:shutdown_complete] = true
151
- puts 'Kaal scheduler stopped'
152
- else
153
- warn('Kaal scheduler stop timed out; send TERM/INT again to force exit')
154
- end
155
- rescue StandardError => e
156
- warn("kaal:start shutdown failed: #{e.message}")
157
- ensure
158
- chain_previous_handler(signal, previous_handler)
159
- end
160
-
161
- def chain_previous_handler(signal, previous_handler)
162
- return unless previous_handler
163
-
164
- case previous_handler
165
- when Proc, Method
166
- invoke_previous_handler(previous_handler, signal)
167
- when String
168
- return if RESERVED_SIGNAL_HANDLERS.include?(previous_handler)
169
-
170
- warn("kaal:start previous #{signal} handler is a command: #{previous_handler}")
171
- end
172
- rescue StandardError
173
- nil
174
- end
175
-
176
- def invoke_previous_handler(handler, signal)
177
- signal_number = Signal.list[signal]
178
- callable_arity = handler.arity
179
- return handler.call if callable_arity.zero?
180
-
181
- handler.call(signal_number)
182
- end
183
- end
184
- end
@@ -1,321 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Copyright Codevedas Inc. 2025-present
4
- #
5
- # This source code is licensed under the MIT license found in the
6
- # LICENSE file in the root directory of this source tree.
7
-
8
- require 'erb'
9
- require 'pathname'
10
- require 'yaml'
11
- require 'active_support/core_ext/hash/deep_merge'
12
- require 'active_support/core_ext/object/deep_dup'
13
- require 'active_support/core_ext/string/inflections'
14
- require_relative 'scheduler_hash_transform'
15
- require_relative 'scheduler_placeholder_support'
16
-
17
- module Kaal
18
- # Loads scheduler definitions from config/scheduler.yml and registers them.
19
- class SchedulerFileLoader
20
- include SchedulerHashTransform
21
- include SchedulerPlaceholderSupport
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(configuration:, definition_registry:, registry:, logger:, rails_context: Rails)
32
- @configuration = configuration
33
- @definition_registry = definition_registry
34
- @registry = registry
35
- @logger = logger
36
- @rails_env = rails_context.env.to_s
37
- @rails_root = rails_context.root
38
- @placeholder_resolvers = ALLOWED_PLACEHOLDERS
39
- end
40
-
41
- def load
42
- applied_job_contexts = []
43
- path = scheduler_file_path
44
- return handle_missing_file(path) unless File.exist?(path)
45
-
46
- payload = parse_yaml(path)
47
- jobs = extract_jobs(payload)
48
- validate_unique_keys(jobs)
49
- normalized_jobs = jobs.map { |job_payload| normalize_job(job_payload) }
50
- applied_jobs = []
51
- normalized_jobs.each do |job|
52
- applied_job_context = apply_job(**job)
53
- next unless applied_job_context
54
-
55
- applied_jobs << job
56
- applied_job_contexts << applied_job_context
57
- end
58
-
59
- applied_jobs
60
- rescue StandardError
61
- rollback_applied_jobs(applied_job_contexts)
62
- raise
63
- end
64
-
65
- private
66
-
67
- def scheduler_file_path
68
- configured_path = @configuration.scheduler_config_path.to_s.strip
69
- raise SchedulerConfigError, 'scheduler_config_path cannot be blank' if configured_path.empty?
70
-
71
- path = Pathname.new(configured_path)
72
- path.absolute? ? path.to_s : @rails_root.join(path).to_s
73
- end
74
-
75
- def handle_missing_file(path)
76
- message = "Scheduler file not found at #{path}"
77
- raise SchedulerConfigError, message if @configuration.scheduler_missing_file_policy == :error
78
-
79
- @logger&.warn(message)
80
- []
81
- end
82
-
83
- def parse_yaml(path)
84
- rendered = render_yaml_erb(path)
85
- parsed = YAML.safe_load(rendered) || {}
86
- raise SchedulerConfigError, "Expected scheduler YAML root to be a mapping in #{path}" unless parsed.is_a?(Hash)
87
-
88
- stringify_keys(parsed)
89
- rescue Psych::Exception => e
90
- raise SchedulerConfigError, "Failed to parse scheduler YAML at #{path}: #{e.message}"
91
- end
92
-
93
- def render_yaml_erb(path)
94
- ERB.new(File.read(path), trim_mode: '-').result
95
- rescue StandardError, SyntaxError => e
96
- raise SchedulerConfigError, "Failed to evaluate scheduler ERB at #{path}: #{e.message}"
97
- end
98
-
99
- def extract_jobs(payload)
100
- defaults = fetch_hash(payload, 'defaults')
101
- env_payload = fetch_hash(payload, @rails_env)
102
- default_jobs = defaults.fetch('jobs', [])
103
- env_jobs = env_payload.fetch('jobs', [])
104
- raise SchedulerConfigError, "Expected 'defaults.jobs' to be an array" unless default_jobs.is_a?(Array)
105
- raise SchedulerConfigError, "Expected '#{@rails_env}.jobs' to be an array" unless env_jobs.is_a?(Array)
106
-
107
- default_jobs + env_jobs
108
- end
109
-
110
- def fetch_hash(payload, key)
111
- section = payload.fetch(key)
112
-
113
- raise SchedulerConfigError, "Expected '#{key}' section to be a mapping" unless section.is_a?(Hash)
114
-
115
- section
116
- rescue KeyError
117
- {}
118
- end
119
-
120
- def validate_unique_keys(jobs)
121
- keys = jobs.map do |job_payload|
122
- raise SchedulerConfigError, "Each jobs entry must be a mapping, got #{job_payload.class}" unless job_payload.is_a?(Hash)
123
-
124
- stringify_keys(job_payload)['key'].to_s.strip
125
- end
126
- duplicates = keys.group_by(&:itself).select { |key, arr| !key.empty? && arr.size > 1 }.keys
127
- return if duplicates.empty?
128
-
129
- raise SchedulerConfigError, "Duplicate job keys in scheduler file: #{duplicates.join(', ')}"
130
- end
131
-
132
- def normalize_job(job_payload)
133
- payload = stringify_keys(job_payload)
134
- key = payload.fetch('key', '').to_s.strip
135
- raise SchedulerConfigError, 'Job key cannot be blank' if key.empty?
136
-
137
- cron = extract_required_string(payload, field: 'cron', error_prefix: "Job cron cannot be blank for key '#{key}'")
138
- job_class_name = extract_required_string(
139
- payload, field: 'job_class', error_prefix: "Job class cannot be blank for key '#{key}'"
140
- )
141
- validate_cron(key:, cron:)
142
- options = extract_job_options(payload, key:)
143
-
144
- {
145
- key:,
146
- cron:,
147
- job_class_name:,
148
- **options
149
- }
150
- end
151
-
152
- def extract_required_string(payload, field:, error_prefix:)
153
- value = payload.fetch(field, '').to_s.strip
154
-
155
- raise SchedulerConfigError, error_prefix if value.empty?
156
-
157
- value
158
- end
159
-
160
- def validate_cron(key:, cron:)
161
- return if Kaal.valid?(cron)
162
-
163
- raise SchedulerConfigError, "Invalid cron expression '#{cron}' for key '#{key}'"
164
- end
165
-
166
- def extract_job_options(payload, key:)
167
- metadata, args, kwargs, queue, enabled_value = payload.values_at('metadata', 'args', 'kwargs', 'queue', 'enabled')
168
- args ||= []
169
- kwargs ||= {}
170
- enabled = true
171
- if payload.key?('enabled')
172
- raise SchedulerConfigError, "enabled must be a boolean for key '#{key}'" unless enabled_value.is_a?(TrueClass) || enabled_value.is_a?(FalseClass)
173
-
174
- enabled = enabled_value
175
- end
176
-
177
- raise SchedulerConfigError, "metadata must be a mapping for key '#{key}'" if metadata && !metadata.is_a?(Hash)
178
-
179
- validate_job_option_types(key:, args:, kwargs:, queue:)
180
-
181
- validate_placeholders(args, key:)
182
- validate_placeholders(kwargs, key:)
183
-
184
- { queue: queue, args: args.deep_dup, kwargs: kwargs.deep_dup, enabled: enabled, metadata: metadata ? metadata.deep_dup : {} }
185
- end
186
-
187
- def validate_job_option_types(key:, args:, kwargs:, queue:)
188
- raise SchedulerConfigError, "args must be an array for key '#{key}'" unless args.is_a?(Array)
189
- raise SchedulerConfigError, "kwargs must be a mapping for key '#{key}'" unless kwargs.is_a?(Hash)
190
- raise SchedulerConfigError, "queue must be a string for key '#{key}'" if queue && !queue.is_a?(String)
191
- return if kwargs.keys.all? { |kwargs_key| kwargs_key.is_a?(String) || kwargs_key.is_a?(Symbol) }
192
-
193
- raise SchedulerConfigError, "kwargs keys must be strings or symbols for key '#{key}'"
194
- end
195
-
196
- def apply_job(key:, cron:, job_class_name:, queue:, args:, kwargs:, enabled:, metadata:)
197
- existing_definition = @definition_registry.find_definition(key)
198
- existing_registry_entry = @registry.find(key)
199
- return if skip_due_to_conflict?(key:, existing_definition:)
200
-
201
- callback = build_callback(
202
- key: key,
203
- job_class_name: job_class_name,
204
- queue: queue,
205
- args_template: args,
206
- kwargs_template: kwargs
207
- )
208
- normalized_metadata = stringify_keys(metadata.deep_dup)
209
- persisted_metadata = normalized_metadata.deep_merge(
210
- 'execution' => {
211
- 'target' => 'active_job',
212
- 'job_class' => job_class_name,
213
- 'queue' => queue,
214
- 'args' => args,
215
- 'kwargs' => kwargs
216
- }
217
- )
218
-
219
- @definition_registry.upsert_definition(
220
- key: key,
221
- cron: cron,
222
- enabled: enabled,
223
- source: 'file',
224
- metadata: persisted_metadata
225
- )
226
-
227
- begin
228
- @registry.upsert(key: key, cron: cron, enqueue: callback)
229
- rescue StandardError
230
- rollback_applied_job(key:, existing_definition:, existing_registry_entry:)
231
- raise
232
- end
233
-
234
- { key: key, existing_definition: existing_definition, existing_registry_entry: existing_registry_entry }
235
- end
236
-
237
- def rollback_applied_jobs(applied_job_contexts = [])
238
- applied_job_contexts.reverse_each do |applied_job_context|
239
- rollback_applied_job(**applied_job_context)
240
- end
241
- end
242
-
243
- def rollback_applied_job(key:, existing_definition:, existing_registry_entry:)
244
- if existing_definition
245
- definition_attributes = existing_definition.slice(:key, :cron, :enabled, :source, :metadata)
246
- @definition_registry.upsert_definition(**definition_attributes)
247
- else
248
- @definition_registry.remove_definition(key)
249
- end
250
-
251
- @registry.remove(key) if @registry.registered?(key)
252
-
253
- return unless existing_registry_entry
254
-
255
- @registry.upsert(
256
- key: existing_registry_entry.key,
257
- cron: existing_registry_entry.cron,
258
- enqueue: existing_registry_entry.enqueue
259
- )
260
- rescue StandardError => e
261
- @logger&.error("Failed to rollback scheduler file application for #{key}: #{e.message}")
262
- end
263
-
264
- def skip_due_to_conflict?(key:, existing_definition:)
265
- existing_source = existing_definition&.[](:source)
266
- return false unless existing_source && existing_source.to_s != 'file'
267
-
268
- policy = @configuration.scheduler_conflict_policy
269
- case policy
270
- when :error
271
- raise SchedulerConfigError, "Scheduler key conflict for '#{key}' with existing source '#{existing_source}'"
272
- when :code_wins
273
- @logger&.warn("Skipping scheduler file job '#{key}' because scheduler_conflict_policy is :code_wins")
274
- true
275
- when :file_wins
276
- false
277
- else
278
- raise SchedulerConfigError, "Unsupported scheduler_conflict_policy '#{policy}'"
279
- end
280
- end
281
-
282
- def build_callback(key:, job_class_name:, queue:, args_template:, kwargs_template:)
283
- job_class = resolve_job_class(job_class_name:, key:)
284
- lambda do |fire_time:, idempotency_key:|
285
- context = {
286
- fire_time: fire_time,
287
- idempotency_key: idempotency_key,
288
- key: key
289
- }
290
- resolved_args = resolve_placeholders(args_template.deep_dup, context)
291
- raw_kwargs = resolve_placeholders(kwargs_template.deep_dup, context) || {}
292
- raise SchedulerConfigError, "kwargs for scheduler job '#{key}' must be a mapping, got #{raw_kwargs.class}" unless raw_kwargs.is_a?(Hash)
293
-
294
- keys = raw_kwargs.keys
295
- index = 0
296
- while index < keys.length
297
- kwargs_key = keys[index]
298
- unless kwargs_key.is_a?(String) || kwargs_key.is_a?(Symbol)
299
- raise SchedulerConfigError,
300
- "Invalid keyword argument key #{kwargs_key.inspect} (#{kwargs_key.class}) for scheduler job '#{key}'"
301
- end
302
-
303
- index += 1
304
- end
305
-
306
- resolved_kwargs = raw_kwargs.transform_keys(&:to_sym)
307
-
308
- target = queue ? job_class.set(queue: queue) : job_class
309
- target.perform_later(*resolved_args, **resolved_kwargs)
310
- end
311
- end
312
-
313
- def resolve_job_class(job_class_name:, key:)
314
- job_class = job_class_name.safe_constantize
315
- raise SchedulerConfigError, "Unknown job_class '#{job_class_name}' for key '#{key}'" unless job_class
316
- raise SchedulerConfigError, "job_class '#{job_class_name}' must inherit from ActiveJob::Base for key '#{key}'" unless job_class <= ActiveJob::Base
317
-
318
- job_class
319
- end
320
- end
321
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Copyright Codevedas Inc. 2025-present
4
- #
5
- # This source code is licensed under the MIT license found in the
6
- # LICENSE file in the root directory of this source tree.
7
-
8
- module Kaal
9
- # Shared deep hash key transformation helpers for scheduler payloads.
10
- module SchedulerHashTransform
11
- TO_STRING = :to_s.to_proc
12
- TO_SYMBOL = ->(key) { key.to_s.to_sym }
13
-
14
- private
15
-
16
- def stringify_keys(object)
17
- deep_transform(object, key_transform: TO_STRING)
18
- end
19
-
20
- def symbolize_keys_deep(object)
21
- deep_transform(object, key_transform: TO_SYMBOL)
22
- end
23
-
24
- def deep_transform(object, key_transform:)
25
- case object
26
- when Hash
27
- deep_transform_hash(object, key_transform:)
28
- when Array
29
- deep_transform_array(object, key_transform:)
30
- else
31
- object
32
- end
33
- end
34
-
35
- def deep_transform_hash(object, key_transform:)
36
- object.each_with_object({}) do |(key, child), memo|
37
- memo[key_transform.call(key)] = deep_transform(child, key_transform:)
38
- end
39
- end
40
-
41
- def deep_transform_array(object, key_transform:)
42
- object.map { |child| deep_transform(child, key_transform:) }
43
- end
44
- end
45
- end