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,95 @@
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
+ class SchedulerFileLoader
12
+ # Loads and validates scheduler YAML payloads from disk.
13
+ class PayloadLoader
14
+ def initialize(configuration:, runtime_context:, logger:, hash_transform:)
15
+ @configuration = configuration
16
+ @runtime_context = runtime_context
17
+ @logger = logger
18
+ @hash_transform = hash_transform
19
+ end
20
+
21
+ def load
22
+ path = scheduler_file_path
23
+ return [path, nil] unless File.exist?(path)
24
+
25
+ [path, parse_yaml(path)]
26
+ end
27
+
28
+ def handle_missing_file(path)
29
+ message = "Scheduler file not found at #{path}"
30
+ raise SchedulerConfigError, message if @configuration.scheduler_missing_file_policy == :error
31
+
32
+ @logger&.warn(message)
33
+ []
34
+ end
35
+
36
+ def extract_jobs(payload)
37
+ environment_name = @runtime_context.environment_name
38
+ defaults = fetch_hash(payload, 'defaults')
39
+ env_payload = fetch_hash(payload, environment_name)
40
+ default_jobs = defaults.fetch('jobs', [])
41
+ env_jobs = env_payload.fetch('jobs', [])
42
+ raise SchedulerConfigError, "Expected 'defaults.jobs' to be an array" unless default_jobs.is_a?(Array)
43
+ raise SchedulerConfigError, "Expected '#{environment_name}.jobs' to be an array" unless env_jobs.is_a?(Array)
44
+
45
+ default_jobs + env_jobs
46
+ end
47
+
48
+ def validate_unique_keys(jobs)
49
+ keys = jobs.map do |job_payload|
50
+ raise SchedulerConfigError, "Each jobs entry must be a mapping, got #{job_payload.class}" unless job_payload.is_a?(Hash)
51
+
52
+ @hash_transform.stringify_keys(job_payload)['key'].to_s.strip
53
+ end
54
+ duplicates = keys.group_by(&:itself).select { |key, arr| !key.empty? && arr.size > 1 }.keys
55
+ return if duplicates.empty?
56
+
57
+ raise SchedulerConfigError, "Duplicate job keys in scheduler file: #{duplicates.join(', ')}"
58
+ end
59
+
60
+ private
61
+
62
+ def scheduler_file_path
63
+ configured_path = @configuration.scheduler_config_path.to_s.strip
64
+ raise SchedulerConfigError, 'scheduler_config_path cannot be blank' if configured_path.empty?
65
+
66
+ @runtime_context.resolve_path(configured_path)
67
+ end
68
+
69
+ def parse_yaml(path)
70
+ rendered = render_yaml_erb(path)
71
+ parsed = YAML.safe_load(rendered) || {}
72
+ raise SchedulerConfigError, "Expected scheduler YAML root to be a mapping in #{path}" unless parsed.is_a?(Hash)
73
+
74
+ @hash_transform.stringify_keys(parsed)
75
+ rescue Psych::Exception => e
76
+ raise SchedulerConfigError, "Failed to parse scheduler YAML at #{path}: #{e.message}"
77
+ end
78
+
79
+ def render_yaml_erb(path)
80
+ ERB.new(File.read(path), trim_mode: '-').result
81
+ rescue StandardError, SyntaxError => e
82
+ raise SchedulerConfigError, "Failed to evaluate scheduler ERB at #{path}: #{e.message}"
83
+ end
84
+
85
+ def fetch_hash(payload, key)
86
+ section = payload.fetch(key)
87
+ raise SchedulerConfigError, "Expected '#{key}' section to be a mapping" unless section.is_a?(Hash)
88
+
89
+ section
90
+ rescue KeyError
91
+ {}
92
+ end
93
+ end
94
+ end
95
+ end
@@ -4,7 +4,6 @@
4
4
  #
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
-
8
7
  module Kaal
9
8
  # Placeholder parsing/resolution for scheduler args and kwargs.
10
9
  module SchedulerPlaceholderSupport
@@ -0,0 +1,18 @@
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/scheduler_file/loader'
8
+ require 'kaal/scheduler_file/hash_transform'
9
+ require 'kaal/scheduler_file/placeholder_support'
10
+
11
+ module Kaal
12
+ # Scheduler file loading and payload helpers.
13
+ module SchedulerFile
14
+ Loader = ::Kaal::SchedulerFileLoader
15
+ HashTransform = ::Kaal::SchedulerHashTransform
16
+ PlaceholderSupport = ::Kaal::SchedulerPlaceholderSupport
17
+ end
18
+ end
@@ -0,0 +1,82 @@
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 'fileutils'
8
+
9
+ module Kaal
10
+ # Sequel migration/install support for SQL-backed Kaal backends.
11
+ module Sequel
12
+ module_function
13
+
14
+ def install_postgres_migration(target_dir:, migration_name: 'create_kaal_postgres_backend')
15
+ install_migrations(target_dir:, backend: 'postgres', migration_name:)
16
+ end
17
+
18
+ def install_mysql_migration(target_dir:, migration_name: 'create_kaal_mysql_backend')
19
+ install_migrations(target_dir:, backend: 'mysql', migration_name:)
20
+ end
21
+
22
+ def install_sqlite_migration(target_dir:, migration_name: 'create_kaal_sqlite_backend')
23
+ install_migrations(target_dir:, backend: 'sqlite', migration_name:)
24
+ end
25
+
26
+ def install_migrations(target_dir:, backend:, migration_name: nil)
27
+ require_sequel!
28
+
29
+ normalized_name = normalize_migration_name(migration_name, fallback: default_migration_name_for(backend))
30
+ base_path = File.expand_path(target_dir)
31
+ FileUtils.mkdir_p(base_path)
32
+
33
+ Kaal::Persistence::MigrationTemplates.for_backend(backend).map.with_index do |(_name, contents), index|
34
+ suffix = migration_suffixes_for(backend).fetch(index)
35
+ path = File.expand_path("#{timestamp(index)}_#{normalized_name}_#{suffix}.rb", base_path)
36
+ File.write(path, contents)
37
+ path
38
+ end
39
+ end
40
+
41
+ def require_sequel!
42
+ require 'sequel'
43
+ rescue LoadError => e
44
+ raise LoadError,
45
+ "#{e.message}. Add `gem 'sequel'` to your Gemfile to use Sequel-backed Kaal SQL support.",
46
+ cause: e
47
+ end
48
+
49
+ def normalize_migration_name(name, fallback:)
50
+ normalized = name.to_s.each_char.with_object(+'') do |char, buffer|
51
+ if letter?(char) || digit?(char)
52
+ buffer << char.downcase
53
+ elsif !buffer.empty? && !buffer.end_with?('_')
54
+ buffer << '_'
55
+ end
56
+ end.delete_suffix('_')
57
+ normalized.empty? ? fallback : normalized
58
+ end
59
+
60
+ def default_migration_name_for(backend)
61
+ "create_kaal_#{backend}_backend"
62
+ end
63
+
64
+ def migration_suffixes_for(backend)
65
+ return %w[dispatches locks definitions] if backend.to_s == 'sqlite'
66
+
67
+ %w[dispatches definitions]
68
+ end
69
+
70
+ def timestamp(offset = 0)
71
+ (Time.now.utc + offset).strftime('%Y%m%d%H%M%S')
72
+ end
73
+
74
+ def letter?(char)
75
+ char.between?('a', 'z') || char.between?('A', 'Z')
76
+ end
77
+
78
+ def digit?(char)
79
+ char.between?('0', '9')
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,93 @@
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 Support
9
+ # Small deep-copy and key-normalization helpers used across config and scheduler loading.
10
+ module HashTools
11
+ module_function
12
+
13
+ def deep_dup(value)
14
+ case value
15
+ when Hash
16
+ value.each_with_object({}) do |(key, child), memo|
17
+ duplicated_pair = [deep_dup(key), deep_dup(child)]
18
+ memo[duplicated_pair[0]] = duplicated_pair[1]
19
+ end
20
+ when Array
21
+ value.map { |child| duplicate_child(child) }
22
+ else
23
+ duplicable?(value) ? value.dup : value
24
+ end
25
+ end
26
+
27
+ def stringify_keys(value)
28
+ transform_keys(value, &:to_s)
29
+ end
30
+
31
+ def symbolize_keys(value)
32
+ transform_keys(value) { |key| key.to_s.to_sym }
33
+ end
34
+
35
+ def deep_merge(left, right)
36
+ left.merge(right) do |_key, left_value, right_value|
37
+ if left_value.is_a?(Hash) && right_value.is_a?(Hash)
38
+ deep_merge(left_value, right_value)
39
+ else
40
+ deep_dup(right_value)
41
+ end
42
+ end
43
+ end
44
+
45
+ def constantize(name)
46
+ name.to_s.split('::').reject(&:empty?).reduce(Object) { |scope, part| scope.const_get(part) }
47
+ end
48
+
49
+ def duplicable?(value)
50
+ !value.is_a?(NilClass) &&
51
+ !value.is_a?(FalseClass) &&
52
+ !value.is_a?(TrueClass) &&
53
+ !value.is_a?(Symbol) &&
54
+ !value.is_a?(Numeric) &&
55
+ !value.is_a?(Method) &&
56
+ !value.is_a?(Proc)
57
+ end
58
+
59
+ def transform_keys(value, &)
60
+ case value
61
+ when Hash
62
+ transform_hash_keys(value, &)
63
+ when Array
64
+ transform_array_keys(value, &)
65
+ else
66
+ value
67
+ end
68
+ end
69
+
70
+ def duplicate_child(child)
71
+ deep_dup(child)
72
+ end
73
+
74
+ def transform_child_keys(child, &)
75
+ transform_keys(child, &)
76
+ end
77
+
78
+ def transform_hash_keys(value, &)
79
+ value.each_with_object({}) do |(key, child), memo|
80
+ transformed_pair = [yield(key), transform_child_keys(child, &)]
81
+ memo[transformed_pair[0]] = transformed_pair[1]
82
+ end
83
+ end
84
+
85
+ def transform_array_keys(value, &)
86
+ value.map { |child| transform_child_keys(child, &) }
87
+ end
88
+
89
+ private_class_method :duplicate_child, :transform_child_keys, :transform_hash_keys, :transform_array_keys
90
+ private_class_method :transform_keys
91
+ end
92
+ end
93
+ end
@@ -4,14 +4,15 @@
4
4
  #
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
-
8
7
  require 'fugit'
9
8
  require 'i18n'
9
+ require 'yaml'
10
10
 
11
11
  module Kaal
12
12
  ##
13
13
  # Human-friendly cron phrase generation with i18n support.
14
14
  module CronHumanizer
15
+ I18N_LOAD_MUTEX = Mutex.new
15
16
  MACRO_PHRASES = {
16
17
  '@yearly' => 'phrases.yearly',
17
18
  '@monthly' => 'phrases.monthly',
@@ -23,6 +24,8 @@ module Kaal
23
24
  module_function
24
25
 
25
26
  def to_human(expression, locale: nil)
27
+ ensure_i18n_loaded!
28
+
26
29
  normalized = CronUtils.safe_normalize_expression(expression)
27
30
  raise ArgumentError, CronUtils.invalid_expression_error_message('') unless normalized
28
31
  raise ArgumentError, CronUtils.invalid_expression_error_message(normalized) if normalized.empty?
@@ -178,5 +181,20 @@ module Kaal
178
181
  I18n.t("kaal.#{key}", **)
179
182
  end
180
183
  private_class_method :translate_phrase
184
+
185
+ def ensure_i18n_loaded!
186
+ locale_file = File.expand_path('../../../config/locales/en.yml', __dir__)
187
+ return if I18n.load_path.include?(locale_file)
188
+
189
+ I18N_LOAD_MUTEX.synchronize do
190
+ return if I18n.load_path.include?(locale_file)
191
+
192
+ I18n.load_path << locale_file
193
+ locales = YAML.safe_load_file(locale_file, aliases: true) || {}
194
+ I18n.available_locales = Array(I18n.available_locales) | locales.keys.map(&:to_sym)
195
+ I18n.backend.load_translations
196
+ end
197
+ end
198
+ private_class_method :ensure_i18n_loaded!
181
199
  end
182
200
  end
@@ -4,7 +4,6 @@
4
4
  #
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
-
8
7
  require 'fugit'
9
8
 
10
9
  module Kaal
@@ -4,7 +4,6 @@
4
4
  #
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
-
8
7
  module Kaal
9
8
  ##
10
9
  # Utility class for generating idempotency keys.
@@ -13,7 +12,7 @@ module Kaal
13
12
  # Format: {namespace}-{cron_key}-{fire_time_unix}
14
13
  #
15
14
  # @example Generate a key
16
- # key = Kaal::IdempotencyKeyGenerator.call('reports:daily', Time.current, configuration: config)
15
+ # key = Kaal::IdempotencyKeyGenerator.call('reports:daily', Time.now.utc, configuration: config)
17
16
  # # => "kaal-reports:daily-1708283400"
18
17
  class IdempotencyKeyGenerator
19
18
  ##
@@ -24,7 +23,8 @@ module Kaal
24
23
  # @param configuration [Configuration] the Kaal configuration instance
25
24
  # @return [String] the formatted idempotency key
26
25
  def self.call(cron_key, fire_time, configuration:)
27
- namespace = configuration.namespace || 'kaal'
26
+ namespace = configuration.namespace.to_s.strip
27
+ namespace = 'kaal' if namespace.empty?
28
28
  "#{namespace}-#{cron_key}-#{fire_time.to_i}"
29
29
  end
30
30
  end
data/lib/kaal/utils.rb ADDED
@@ -0,0 +1,18 @@
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/utils/cron_utils'
8
+ require 'kaal/utils/cron_humanizer'
9
+ require 'kaal/utils/idempotency_key_generator'
10
+
11
+ module Kaal
12
+ # Utility functions and pure helpers.
13
+ module Utils
14
+ CronUtils = ::Kaal::CronUtils
15
+ CronHumanizer = ::Kaal::CronHumanizer
16
+ IdempotencyKeyGenerator = ::Kaal::IdempotencyKeyGenerator
17
+ end
18
+ end
data/lib/kaal/version.rb CHANGED
@@ -4,7 +4,6 @@
4
4
  #
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
-
8
7
  module Kaal
9
- VERSION = '0.2.1'
8
+ VERSION = '0.4.0'
10
9
  end