kaal 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +79 -287
- data/Rakefile +4 -2
- data/config/kaal.rb +15 -0
- data/config/scheduler.yml +12 -0
- data/{lib/tasks/kaal_tasks.rake → exe/kaal} +5 -3
- data/lib/kaal/active_record_support.rb +82 -0
- data/lib/kaal/backend/adapter.rb +0 -1
- data/lib/kaal/backend/dispatch_attempt_logger.rb +33 -0
- data/lib/kaal/backend/dispatch_logging.rb +36 -23
- data/lib/kaal/backend/dispatch_registry_accessor.rb +43 -0
- data/lib/kaal/backend/memory_adapter.rb +7 -5
- data/lib/kaal/backend/mysql.rb +41 -0
- data/lib/kaal/backend/postgres.rb +41 -0
- data/lib/kaal/backend/redis_adapter.rb +6 -6
- data/lib/kaal/backend/sqlite.rb +41 -0
- data/lib/kaal/cli.rb +230 -0
- data/lib/kaal/{configuration.rb → config/configuration.rb} +0 -1
- data/lib/kaal/{scheduler_config_error.rb → config/scheduler_config_error.rb} +0 -1
- data/lib/kaal/config/scheduler_time_zone_resolver.rb +50 -0
- data/lib/kaal/config.rb +19 -0
- data/lib/kaal/{coordinator.rb → core/coordinator.rb} +42 -62
- data/lib/kaal/core/enabled_entry_enumerator.rb +51 -0
- data/lib/kaal/core/occurrence_finder.rb +38 -0
- data/lib/kaal/core.rb +18 -0
- data/lib/kaal/definition/database_engine.rb +54 -16
- data/lib/kaal/definition/memory_engine.rb +11 -18
- data/lib/kaal/definition/persistence_helpers.rb +31 -0
- data/lib/kaal/definition/redis_engine.rb +9 -6
- data/lib/kaal/definition/registry.rb +24 -2
- data/lib/kaal/definitions/registration_service.rb +62 -0
- data/lib/kaal/definitions/registry_accessor.rb +33 -0
- data/lib/kaal/dispatch/database_engine.rb +87 -61
- data/lib/kaal/dispatch/memory_engine.rb +3 -4
- data/lib/kaal/dispatch/redis_engine.rb +2 -3
- data/lib/kaal/dispatch/registry.rb +0 -1
- data/lib/kaal/internal/active_record/base_record.rb +16 -0
- data/lib/kaal/internal/active_record/connection_support.rb +96 -0
- data/lib/kaal/internal/active_record/database_backend.rb +73 -0
- data/lib/kaal/internal/active_record/definition_record.rb +16 -0
- data/lib/kaal/internal/active_record/definition_registry.rb +81 -0
- data/lib/kaal/internal/active_record/dispatch_record.rb +16 -0
- data/lib/kaal/internal/active_record/dispatch_registry.rb +100 -0
- data/lib/kaal/internal/active_record/lock_record.rb +16 -0
- data/lib/kaal/internal/active_record/migration_templates.rb +108 -0
- data/lib/kaal/internal/active_record/mysql_backend.rb +71 -0
- data/lib/kaal/internal/active_record/postgres_backend.rb +69 -0
- data/lib/kaal/internal/active_record.rb +17 -0
- data/lib/kaal/internal/sequel/database_backend.rb +74 -0
- data/lib/kaal/internal/sequel/mysql_backend.rb +69 -0
- data/lib/kaal/internal/sequel/postgres_backend.rb +67 -0
- data/lib/kaal/internal/sequel.rb +12 -0
- data/lib/kaal/persistence/database.rb +35 -0
- data/lib/kaal/persistence/migration_templates.rb +97 -0
- data/lib/kaal/register_conflict_support.rb +0 -1
- data/lib/kaal/registry.rb +0 -3
- data/lib/kaal/runtime/runtime_context.rb +41 -0
- data/lib/kaal/runtime/scheduler_boot_loader.rb +52 -0
- data/lib/kaal/runtime/signal_handler_chain.rb +42 -0
- data/lib/kaal/runtime/signal_handler_installer.rb +39 -0
- data/lib/kaal/runtime.rb +20 -0
- data/lib/kaal/scheduler_file/hash_transform.rb +22 -0
- data/lib/kaal/scheduler_file/helper_bundle.rb +28 -0
- data/lib/kaal/scheduler_file/job_applier.rb +242 -0
- data/lib/kaal/scheduler_file/job_normalizer.rb +90 -0
- data/lib/kaal/scheduler_file/loader.rb +152 -0
- data/lib/kaal/scheduler_file/payload_loader.rb +95 -0
- data/lib/kaal/{scheduler_placeholder_support.rb → scheduler_file/placeholder_support.rb} +0 -1
- data/lib/kaal/scheduler_file.rb +18 -0
- data/lib/kaal/sequel_support.rb +82 -0
- data/lib/kaal/support/hash_tools.rb +93 -0
- data/lib/kaal/{cron_humanizer.rb → utils/cron_humanizer.rb} +19 -1
- data/lib/kaal/{cron_utils.rb → utils/cron_utils.rb} +0 -1
- data/lib/kaal/{idempotency_key_generator.rb → utils/idempotency_key_generator.rb} +3 -3
- data/lib/kaal/utils.rb +18 -0
- data/lib/kaal/version.rb +1 -2
- data/lib/kaal.rb +83 -397
- metadata +87 -42
- data/app/models/kaal/cron_definition.rb +0 -76
- data/app/models/kaal/cron_dispatch.rb +0 -50
- data/app/models/kaal/cron_lock.rb +0 -38
- data/lib/generators/kaal/install/install_generator.rb +0 -72
- data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +0 -21
- data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +0 -20
- data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +0 -17
- data/lib/generators/kaal/install/templates/kaal.rb.tt +0 -31
- data/lib/generators/kaal/install/templates/scheduler.yml.tt +0 -22
- data/lib/kaal/backend/mysql_adapter.rb +0 -170
- data/lib/kaal/backend/postgres_adapter.rb +0 -134
- data/lib/kaal/backend/sqlite_adapter.rb +0 -116
- data/lib/kaal/railtie.rb +0 -183
- data/lib/kaal/rake_tasks.rb +0 -184
- data/lib/kaal/scheduler_file_loader.rb +0 -321
- data/lib/kaal/scheduler_hash_transform.rb +0 -45
|
@@ -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
|
data/lib/kaal/runtime.rb
ADDED
|
@@ -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
|