kaal 0.3.0 → 0.5.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +43 -11
  3. data/lib/kaal/active_record_support.rb +82 -0
  4. data/lib/kaal/backend/adapter.rb +4 -0
  5. data/lib/kaal/backend/memory_adapter.rb +5 -0
  6. data/lib/kaal/backend/mysql.rb +63 -0
  7. data/lib/kaal/backend/postgres.rb +45 -0
  8. data/lib/kaal/backend/redis_adapter.rb +5 -0
  9. data/lib/kaal/backend/sqlite.rb +45 -0
  10. data/lib/kaal/cli.rb +1 -0
  11. data/lib/kaal/config/configuration.rb +33 -2
  12. data/lib/kaal/config/delayed_job_security_policy.rb +60 -0
  13. data/lib/kaal/config.rb +1 -0
  14. data/lib/kaal/core/coordinator.rb +68 -19
  15. data/lib/kaal/definition/database_engine.rb +88 -0
  16. data/lib/kaal/delayed_job/database_engine.rb +116 -0
  17. data/lib/kaal/delayed_job/dispatch_failure_logger.rb +31 -0
  18. data/lib/kaal/delayed_job/memory_engine.rb +79 -0
  19. data/lib/kaal/delayed_job/mysql_version_support.rb +43 -0
  20. data/lib/kaal/delayed_job/redis_engine.rb +119 -0
  21. data/lib/kaal/delayed_job/registry.rb +39 -0
  22. data/lib/kaal/dispatch/database_engine.rb +120 -0
  23. data/lib/kaal/internal/active_record/base_record.rb +16 -0
  24. data/lib/kaal/internal/active_record/connection_support.rb +96 -0
  25. data/lib/kaal/internal/active_record/database_backend.rb +78 -0
  26. data/lib/kaal/internal/active_record/definition_record.rb +16 -0
  27. data/lib/kaal/internal/active_record/definition_registry.rb +81 -0
  28. data/lib/kaal/internal/active_record/delayed_job_record.rb +16 -0
  29. data/lib/kaal/internal/active_record/delayed_job_registry.rb +119 -0
  30. data/lib/kaal/internal/active_record/dispatch_record.rb +16 -0
  31. data/lib/kaal/internal/active_record/dispatch_registry.rb +100 -0
  32. data/lib/kaal/internal/active_record/lock_record.rb +16 -0
  33. data/lib/kaal/internal/active_record/migration_templates.rb +138 -0
  34. data/lib/kaal/internal/active_record/mysql_backend.rb +89 -0
  35. data/lib/kaal/internal/active_record/postgres_backend.rb +73 -0
  36. data/lib/kaal/internal/active_record.rb +19 -0
  37. data/lib/kaal/internal/sequel/database_backend.rb +79 -0
  38. data/lib/kaal/internal/sequel/mysql_backend.rb +83 -0
  39. data/lib/kaal/internal/sequel/postgres_backend.rb +71 -0
  40. data/lib/kaal/internal/sequel.rb +13 -0
  41. data/lib/kaal/job_dispatcher.rb +108 -0
  42. data/lib/kaal/persistence/database.rb +39 -0
  43. data/lib/kaal/persistence/migration_templates.rb +129 -0
  44. data/lib/kaal/registry.rb +0 -2
  45. data/lib/kaal/runtime/scheduler_boot_loader.rb +2 -0
  46. data/lib/kaal/scheduler_file/job_applier.rb +28 -53
  47. data/lib/kaal/sequel_support.rb +82 -0
  48. data/lib/kaal/version.rb +1 -1
  49. data/lib/kaal.rb +117 -0
  50. metadata +36 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54a0eb1cebfc4adc18c4b4ed105d47bb26a84d3512fbce891243a356bca5715a
4
- data.tar.gz: dd1fb18c3060c4688ea0f96c5628cd40001fb318b7745d411c46108fa06b45b3
3
+ metadata.gz: c304a5d511f122e3b14d0a6dfa88153d49fbf8bf2ddec699340545831c38e0af
4
+ data.tar.gz: c03c7fa46c7f301d100ed38bca9e9d96e3451bd69d18b801b654f8fb97bee9a8
5
5
  SHA512:
6
- metadata.gz: fda7173306897889b750707be46778bfc0b4893ce87759f30f060a37879212eab2731316a31c875acdcf0e7aad9db1caddbc11e7c0b0b16e3707edbfd1b1eec3
7
- data.tar.gz: a8d64f8ca728665167e8be7ad5e1e6a417066f6016ff2b838aa47cde5aa3369045b1ea2c3aaf326ce4b00cc94d0f519ebe464978660ad13cc29f6db90cf3c834
6
+ metadata.gz: b65e18daf9353b7bdd1bf4709aa8175eb1be0e7e76b68b55f2163be96e9db800adc87c075512a7c0b0a44e34f85b56055f2f9f4db165e3187ca1067a17195775
7
+ data.tar.gz: 6b148d15f5f3092af565d47b8120c7bf52b28ba198fbbc14a5fcb2aad516aaf6de6d4814e5f583e494c00b1b7bdb283d7f2a8eb7dc8879eaf6bd1005c9584383
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Distributed cron scheduling for plain Ruby.
4
4
 
5
- `kaal` is the core engine gem. It owns scheduler/runtime behavior, the registry APIs, and the plain Ruby CLI. SQL persistence lives in adapter gems such as `kaal-sequel` and `kaal-activerecord`.
5
+ `kaal` is the core engine gem. It owns scheduler/runtime behavior, the registry APIs, the plain Ruby CLI, delayed-job dispatch, and the optional SQL backend surfaces.
6
6
 
7
7
  ## Installation
8
8
 
@@ -29,11 +29,7 @@ Supported backends:
29
29
  - `memory`
30
30
  - `redis`
31
31
 
32
- If you want SQL persistence instead, add one of:
33
-
34
- - `kaal-sequel` for Sequel-backed SQL in plain Ruby
35
- - `kaal-activerecord` for Active Record-backed SQL in plain Ruby
36
- - `kaal-rails` for Rails
32
+ If you want SQL persistence instead, add the runtime libraries your app uses, such as `sequel`, `activerecord`, `sqlite3`, `pg`, or `mysql2`, then configure one of the explicit `Kaal::Backend::*` SQL backends.
37
33
 
38
34
  ## Configuration
39
35
 
@@ -114,6 +110,8 @@ REDIS_URL=redis://127.0.0.1:6379/0 bin/rspec-e2e redis
114
110
 
115
111
  ## Runtime API
116
112
 
113
+ Recurring jobs:
114
+
117
115
  ```ruby
118
116
  Kaal.register(
119
117
  key: 'reports:daily',
@@ -126,10 +124,44 @@ Kaal.register(
126
124
  Kaal.start!
127
125
  ```
128
126
 
129
- ## Adapter Gems
127
+ Delayed jobs:
128
+
129
+ ```ruby
130
+ Kaal.enqueue_at(
131
+ at: Time.now.utc + 300,
132
+ job_class: "InvoiceReminderJob",
133
+ args: [123],
134
+ queue: "mailers",
135
+ job_id: "invoice-reminder:123"
136
+ )
137
+ ```
138
+
139
+ Rules shared by the runtime surface:
140
+
141
+ - delayed jobs use `job_id` as their identity and require it to be unique while pending
142
+ - delayed-job `args` are positional only
143
+ - recurring and delayed jobs share the same job-class dispatch rules
144
+ - string job classes are constantized and class or module values are used directly
145
+
146
+ To restrict delayed-job class names:
147
+
148
+ ```ruby
149
+ Kaal.configure do |config|
150
+ config.delayed_job_allowed_class_prefixes = ["Reports::", "Billing::"]
151
+ end
152
+ ```
153
+
154
+ An empty `delayed_job_allowed_class_prefixes` list leaves delayed-job class resolution unrestricted. That is reasonable for local or trusted deployments. On shared Redis or SQL backends in production, set a restrictive prefix list.
155
+
156
+ ## SQL Backends
157
+
158
+ Use the explicit SQL backends when you want persisted registries:
159
+
160
+ - `Kaal::Backend::SQLite`
161
+ - `Kaal::Backend::Postgres`
162
+ - `Kaal::Backend::MySQL`
163
+ - `kaal-rails` for Rails-native install and auto-wiring
130
164
 
131
- Use adapter gems when you want persisted SQL registries:
165
+ For SQL-backed deployments, run the generated migrations so `kaal_delayed_jobs` exists alongside the recurring scheduler tables.
132
166
 
133
- - `kaal-sequel` for Sequel-backed persistence
134
- - `kaal-activerecord` for Active Record-backed persistence
135
- - `kaal-rails` for Rails plugin integration over `kaal-activerecord`
167
+ Postgres and supported MySQL versions claim due delayed jobs with `SKIP LOCKED`. Older SQL paths still preserve correctness with delete confirmation, and Kaal adds a small pre-claim jitter there to reduce multi-node contention.
@@ -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
+ # Active Record migration/install support for SQL-backed Kaal backends.
11
+ module ActiveRecord
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, time_source: -> { Time.now.utc })
27
+ class_name = normalize_migration_name(migration_name, fallback: default_migration_class_for(backend))
28
+ base_path = File.expand_path(target_dir)
29
+ FileUtils.mkdir_p(base_path)
30
+ templates = Kaal::Internal::ActiveRecord::MigrationTemplates.for_backend(backend)
31
+
32
+ templates.map.with_index do |(_name, contents), index|
33
+ suffix = underscore(class_name)
34
+ suffix = "#{suffix}_#{migration_suffixes_for(backend).fetch(index)}" if templates.length > 1
35
+ path = File.expand_path("#{(time_source.call + index).strftime('%Y%m%d%H%M%S')}_#{suffix}.rb", base_path)
36
+ File.write(path, contents)
37
+ path
38
+ end
39
+ end
40
+
41
+ def require_activerecord!
42
+ require 'active_record'
43
+ require 'active_support/inflector'
44
+ rescue LoadError => e
45
+ raise LoadError,
46
+ "#{e.message}. Add `gem 'activerecord'` to your Gemfile to use Active Record-backed Kaal SQL support.",
47
+ cause: e
48
+ end
49
+
50
+ def normalize_migration_name(name, fallback:)
51
+ normalized = name.to_s.each_char.with_object(+'') do |char, buffer|
52
+ if alphanumeric?(char)
53
+ buffer << char
54
+ elsif !buffer.empty? && !buffer.end_with?(' ')
55
+ buffer << ' '
56
+ end
57
+ end.split.map!(&:capitalize).join
58
+ normalized.empty? ? fallback : normalized
59
+ end
60
+
61
+ def underscore(value)
62
+ require_activerecord!
63
+ ::ActiveSupport::Inflector.underscore(value)
64
+ end
65
+
66
+ def default_migration_class_for(backend)
67
+ "CreateKaal#{backend.capitalize}Backend"
68
+ end
69
+
70
+ def migration_suffixes_for(backend)
71
+ return %w[dispatches locks definitions delayed_jobs] if backend.to_s == 'sqlite'
72
+
73
+ %w[dispatches definitions delayed_jobs]
74
+ end
75
+
76
+ def alphanumeric?(char)
77
+ char.between?('a', 'z') ||
78
+ char.between?('A', 'Z') ||
79
+ char.between?('0', '9')
80
+ end
81
+ end
82
+ end
@@ -94,6 +94,10 @@ module Kaal
94
94
  def definition_registry
95
95
  nil
96
96
  end
97
+
98
+ def delayed_store
99
+ nil
100
+ end
97
101
  end
98
102
 
99
103
  ##
@@ -6,6 +6,7 @@
6
6
  # LICENSE file in the root directory of this source tree.
7
7
  require_relative 'dispatch_logging'
8
8
  require_relative '../definition/memory_engine'
9
+ require_relative '../delayed_job/memory_engine'
9
10
 
10
11
  module Kaal
11
12
  module Backend
@@ -50,6 +51,10 @@ module Kaal
50
51
  @definition_registry ||= Kaal::Definition::MemoryEngine.new
51
52
  end
52
53
 
54
+ def delayed_store
55
+ @delayed_store ||= Kaal::DelayedJob::MemoryEngine.new
56
+ end
57
+
53
58
  ##
54
59
  # Attempt to acquire a lock in memory.
55
60
  #
@@ -0,0 +1,63 @@
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 Backend
9
+ # MySQL-backed backend for either Sequel or Active Record persistence.
10
+ class MySQL < Adapter
11
+ UNSET_SKIP_LOCKED_SUPPORT = Object.new.freeze
12
+
13
+ def initialize(database: nil, connection: nil, namespace: nil,
14
+ use_skip_locked: UNSET_SKIP_LOCKED_SUPPORT)
15
+ super()
16
+ backend_class = self.class
17
+ @engine = if database
18
+ Kaal::Sequel.require_sequel!
19
+ require 'kaal/internal/sequel'
20
+ backend_class.send(:build_sequel_backend, database, namespace, use_skip_locked)
21
+ else
22
+ Kaal::ActiveRecord.require_activerecord!
23
+ require 'kaal/internal/active_record'
24
+ backend_class.send(:build_active_record_backend, connection, namespace, use_skip_locked)
25
+ end
26
+ end
27
+
28
+ def dispatch_registry
29
+ @engine.dispatch_registry
30
+ end
31
+
32
+ def definition_registry
33
+ @engine.definition_registry
34
+ end
35
+
36
+ def delayed_store
37
+ @engine.delayed_store
38
+ end
39
+
40
+ def acquire(key, ttl)
41
+ @engine.acquire(key, ttl)
42
+ end
43
+
44
+ def release(key)
45
+ @engine.release(key)
46
+ end
47
+
48
+ def self.build_sequel_backend(database, namespace, use_skip_locked)
49
+ return Kaal::Internal::Sequel::MySQLBackend.new(database, namespace:) if use_skip_locked.equal?(UNSET_SKIP_LOCKED_SUPPORT)
50
+
51
+ Kaal::Internal::Sequel::MySQLBackend.new(database, namespace:, use_skip_locked:)
52
+ end
53
+ private_class_method :build_sequel_backend
54
+
55
+ def self.build_active_record_backend(connection, namespace, use_skip_locked)
56
+ return Kaal::Internal::ActiveRecord::MySQLBackend.new(connection, namespace:) if use_skip_locked.equal?(UNSET_SKIP_LOCKED_SUPPORT)
57
+
58
+ Kaal::Internal::ActiveRecord::MySQLBackend.new(connection, namespace:, use_skip_locked:)
59
+ end
60
+ private_class_method :build_active_record_backend
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,45 @@
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 Backend
9
+ # PostgreSQL-backed backend for either Sequel or Active Record persistence.
10
+ class Postgres < Adapter
11
+ def initialize(database: nil, connection: nil, namespace: nil)
12
+ super()
13
+ @engine = if database
14
+ Kaal::Sequel.require_sequel!
15
+ require 'kaal/internal/sequel'
16
+ Kaal::Internal::Sequel::PostgresBackend.new(database, namespace:)
17
+ else
18
+ Kaal::ActiveRecord.require_activerecord!
19
+ require 'kaal/internal/active_record'
20
+ Kaal::Internal::ActiveRecord::PostgresBackend.new(connection, namespace:)
21
+ end
22
+ end
23
+
24
+ def dispatch_registry
25
+ @engine.dispatch_registry
26
+ end
27
+
28
+ def definition_registry
29
+ @engine.definition_registry
30
+ end
31
+
32
+ def delayed_store
33
+ @engine.delayed_store
34
+ end
35
+
36
+ def acquire(key, ttl)
37
+ @engine.acquire(key, ttl)
38
+ end
39
+
40
+ def release(key)
41
+ @engine.release(key)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -7,6 +7,7 @@
7
7
  require 'securerandom'
8
8
  require_relative 'dispatch_logging'
9
9
  require_relative '../definition/redis_engine'
10
+ require_relative '../delayed_job/redis_engine'
10
11
 
11
12
  module Kaal
12
13
  module Backend
@@ -65,6 +66,10 @@ module Kaal
65
66
  @definition_registry ||= Kaal::Definition::RedisEngine.new(@redis, namespace: @namespace)
66
67
  end
67
68
 
69
+ def delayed_store
70
+ @delayed_store ||= Kaal::DelayedJob::RedisEngine.new(@redis, namespace: @namespace)
71
+ end
72
+
68
73
  ##
69
74
  # Attempt to acquire a distributed lock in Redis.
70
75
  #
@@ -0,0 +1,45 @@
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 Backend
9
+ # SQLite-backed backend for either Sequel or Active Record persistence.
10
+ class SQLite < Adapter
11
+ def initialize(database: nil, connection: nil, namespace: nil, **)
12
+ super()
13
+ @engine = if database
14
+ Kaal::Sequel.require_sequel!
15
+ require 'kaal/internal/sequel'
16
+ Kaal::Internal::Sequel::DatabaseBackend.new(database, namespace:)
17
+ else
18
+ Kaal::ActiveRecord.require_activerecord!
19
+ require 'kaal/internal/active_record'
20
+ Kaal::Internal::ActiveRecord::DatabaseBackend.new(connection, namespace:, **)
21
+ end
22
+ end
23
+
24
+ def dispatch_registry
25
+ @engine.dispatch_registry
26
+ end
27
+
28
+ def definition_registry
29
+ @engine.definition_registry
30
+ end
31
+
32
+ def delayed_store
33
+ @engine.delayed_store
34
+ end
35
+
36
+ def acquire(key, ttl)
37
+ @engine.acquire(key, ttl)
38
+ end
39
+
40
+ def release(key)
41
+ @engine.release(key)
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/kaal/cli.rb CHANGED
@@ -20,6 +20,7 @@ module Kaal
20
20
  Kaal.reset_configuration!
21
21
  Kaal.reset_registry!
22
22
  load config_path
23
+ Kaal.warn_on_risky_configuration!
23
24
  runtime_context = RuntimeContext.default(root_path: root_path)
24
25
  Kaal.load_scheduler_file!(runtime_context: runtime_context) if File.exist?(scheduler_path)
25
26
  end
@@ -31,7 +31,8 @@ module Kaal
31
31
  recovery_startup_jitter: 5, # max random delay in seconds
32
32
  scheduler_config_path: 'config/scheduler.yml',
33
33
  scheduler_conflict_policy: :error,
34
- scheduler_missing_file_policy: :warn
34
+ scheduler_missing_file_policy: :warn,
35
+ delayed_job_allowed_class_prefixes: []
35
36
  }.freeze
36
37
 
37
38
  ##
@@ -69,6 +70,15 @@ module Kaal
69
70
  validation_errors
70
71
  end
71
72
 
73
+ # Non-fatal configuration warnings.
74
+ #
75
+ # @return [Array<String>] warning messages
76
+ def validation_warnings
77
+ warnings = []
78
+ add_delayed_job_security_warning(warnings)
79
+ warnings
80
+ end
81
+
72
82
  ##
73
83
  # Validate the configuration settings.
74
84
  # Raises errors if required settings are invalid.
@@ -79,6 +89,10 @@ module Kaal
79
89
  errors = validation_errors
80
90
  raise ConfigurationError, errors.join('; ') if errors.any?
81
91
 
92
+ validation_warnings.each do |warning|
93
+ @values[:logger]&.warn(warning)
94
+ end
95
+
82
96
  self
83
97
  end
84
98
 
@@ -105,7 +119,8 @@ module Kaal
105
119
  recovery_startup_jitter: @values[:recovery_startup_jitter],
106
120
  scheduler_config_path: @values[:scheduler_config_path],
107
121
  scheduler_conflict_policy: @values[:scheduler_conflict_policy],
108
- scheduler_missing_file_policy: @values[:scheduler_missing_file_policy]
122
+ scheduler_missing_file_policy: @values[:scheduler_missing_file_policy],
123
+ delayed_job_allowed_class_prefixes: @values[:delayed_job_allowed_class_prefixes]
109
124
  }
110
125
  end
111
126
 
@@ -191,6 +206,13 @@ module Kaal
191
206
  errors << 'scheduler_missing_file_policy must be :warn or :error'
192
207
  end
193
208
 
209
+ def add_delayed_job_security_warning(warnings)
210
+ warning = Kaal::Config::DelayedJobSecurityPolicy.warning_for(self)
211
+ return unless warning
212
+
213
+ warnings << warning
214
+ end
215
+
194
216
  def handle_known_key(method_name)
195
217
  name = method_name.to_s
196
218
  setter = name.end_with?('=')
@@ -218,10 +240,19 @@ module Kaal
218
240
  value ? true : false
219
241
  when :scheduler_conflict_policy, :scheduler_missing_file_policy
220
242
  value&.to_sym
243
+ when :delayed_job_allowed_class_prefixes
244
+ normalize_delayed_job_allowed_class_prefixes(value)
221
245
  else
222
246
  value
223
247
  end
224
248
  end
249
+
250
+ def normalize_delayed_job_allowed_class_prefixes(value)
251
+ Array(value).filter_map do |entry|
252
+ normalized_entry = entry.to_s.strip
253
+ normalized_entry unless normalized_entry.empty?
254
+ end
255
+ end
225
256
  end
226
257
 
227
258
  ##
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+ module Kaal
8
+ module Config
9
+ # Evaluates whether delayed-job class resolution is too open for the
10
+ # current deployment shape and returns the matching warning message.
11
+ module DelayedJobSecurityPolicy
12
+ NON_SHARED_BACKEND_CLASS_NAMES = ['NilClass', 'Kaal::Backend::MemoryAdapter', 'Kaal::Backend::NullAdapter'].freeze
13
+ WARNING_MESSAGE = 'Delayed jobs resolve stored job_class values at dispatch time. ' \
14
+ 'delayed_job_allowed_class_prefixes is empty, so class resolution is unrestricted on this shared backend. ' \
15
+ 'Configure a restrictive delayed_job_allowed_class_prefixes list for production deployments.'
16
+
17
+ module_function
18
+
19
+ def warning_for(configuration)
20
+ return unless production_like_environment?
21
+ return unless shared_delayed_job_backend?(configuration.backend)
22
+ return unless Array(configuration.delayed_job_allowed_class_prefixes).empty?
23
+
24
+ WARNING_MESSAGE
25
+ end
26
+
27
+ def production_like_environment?(env: ENV, rails: current_rails)
28
+ rails_env = rails_environment(rails)
29
+ return rails_env.production? if rails_env
30
+
31
+ %w[RACK_ENV HANAMI_ENV APP_ENV RAILS_ENV RUBY_ENV].any? do |key|
32
+ env.fetch(key, nil).to_s.strip == 'production'
33
+ end
34
+ rescue StandardError
35
+ false
36
+ end
37
+
38
+ def shared_delayed_job_backend?(backend)
39
+ backend_class = backend.class
40
+ return false if NON_SHARED_BACKEND_CLASS_NAMES.include?(backend_class.name)
41
+
42
+ backend_class.instance_method(:delayed_store).owner.name != 'Kaal::Backend::Adapter'
43
+ rescue StandardError
44
+ false
45
+ end
46
+
47
+ def current_rails
48
+ return unless defined?(::Rails)
49
+
50
+ ::Rails
51
+ end
52
+
53
+ def rails_environment(rails)
54
+ rails.env
55
+ rescue StandardError
56
+ nil
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/kaal/config.rb CHANGED
@@ -5,6 +5,7 @@
5
5
  # This source code is licensed under the MIT license found in the
6
6
  # LICENSE file in the root directory of this source tree.
7
7
  require 'kaal/config/configuration'
8
+ require 'kaal/config/delayed_job_security_policy'
8
9
  require 'kaal/config/scheduler_config_error'
9
10
  require 'kaal/config/scheduler_time_zone_resolver'
10
11
 
@@ -16,21 +16,12 @@ module Kaal
16
16
  #
17
17
  # The coordinator:
18
18
  # 1. Runs a background thread on tick_interval
19
- # 2. For each registered cron, calculates due fire times within the window
20
- # 3. Attempts to acquire a distributed lease for each due time
21
- # 4. Calls the enqueue callback if the lease is acquired
22
- # 5. Supports graceful shutdown and re-entrancy for testing
23
- #
24
- # @example Start the coordinator
25
- # coordinator = Kaal::Coordinator.new
26
- # coordinator.start!
27
- #
28
- # @example Manual tick execution (for testing)
29
- # coordinator.tick!
30
- #
31
- # @example Stop the coordinator
32
- # coordinator.stop!
19
+ # 2. Calculates due cron fire times and acquires distributed leases for them
20
+ # 3. Dispatches claimed work and supports graceful shutdown and test re-entrancy
33
21
  class Coordinator
22
+ DELAYED_JOB_BATCH_SIZE = 100
23
+ DELAYED_JOB_MAX_BATCHES_PER_TICK = 10
24
+ DELAYED_JOB_DELETE_CONFIRMATION_JITTER_MAX = 0.05
34
25
  ##
35
26
  # Initialize a new Coordinator instance.
36
27
  #
@@ -174,6 +165,7 @@ module Kaal
174
165
  each_enabled_entry do |entry|
175
166
  calculate_and_dispatch_due_times(entry)
176
167
  end
168
+ dispatch_due_delayed_jobs
177
169
  rescue ConfigurationError => e
178
170
  log_configuration_error('Kaal coordinator tick failed', e)
179
171
  raise
@@ -377,15 +369,72 @@ module Kaal
377
369
  logger&.error("Work dispatch failed for #{cron_key}: #{e.message}")
378
370
  end
379
371
 
380
- def generate_idempotency_key(cron_key, fire_time)
381
- Kaal::IdempotencyKeyGenerator.call(cron_key, fire_time, configuration: @configuration)
372
+ def dispatch_due_delayed_jobs
373
+ delayed_store = delayed_store_for_tick
374
+ return unless delayed_store
375
+
376
+ DELAYED_JOB_MAX_BATCHES_PER_TICK.times do
377
+ break if stop_delayed_dispatch?
378
+
379
+ apply_delayed_job_claim_jitter_if_needed(delayed_store)
380
+ due_jobs = delayed_store.pop_due(now: Time.now.utc, limit: DELAYED_JOB_BATCH_SIZE)
381
+ break if due_jobs.empty?
382
+
383
+ due_jobs.each do |job|
384
+ break if stop_delayed_dispatch?
385
+
386
+ dispatch_delayed_job(job, delayed_store)
387
+ end
388
+ end
389
+ rescue StandardError => e
390
+ @configuration.logger&.error("Delayed job dispatch failed: #{e.message}")
391
+ end
392
+
393
+ def dispatch_delayed_job(job, delayed_store)
394
+ if delayed_store.requires_dispatch_lock?
395
+ lock_key = generate_delayed_lock_key(job.fetch(:job_id))
396
+ return unless acquire_lock(lock_key)
397
+ end
398
+
399
+ job_class = Kaal::JobDispatcher.resolve_job_class(
400
+ job_class_name: job.fetch(:job_class),
401
+ key: job.fetch(:job_id),
402
+ queue: job[:queue]
403
+ )
404
+ Kaal::JobDispatcher.dispatch(job_class:, queue: job[:queue], args: job.fetch(:args))
405
+ @configuration.logger&.debug("Dispatched delayed job #{job.fetch(:job_id)} for #{job.fetch(:run_at)}")
406
+ true
407
+ rescue StandardError => e
408
+ Kaal::DelayedJob::DispatchFailureLogger.log_claimed_dispatch_failure(
409
+ logger: @configuration.logger,
410
+ job:,
411
+ error: e
412
+ )
413
+ nil
414
+ end
415
+
416
+ def delayed_store_for_tick
417
+ backend = @configuration.backend
418
+ backend.respond_to?(:delayed_store) ? backend.delayed_store : nil
382
419
  end
383
420
 
384
- def generate_lock_key(cron_key, fire_time)
385
- namespace = @configuration.namespace || 'kaal'
386
- "#{namespace}:dispatch:#{cron_key}:#{fire_time.to_i}"
421
+ def stop_delayed_dispatch?
422
+ stop_requested?
387
423
  end
388
424
 
425
+ def apply_delayed_job_claim_jitter_if_needed(delayed_store)
426
+ return unless delayed_store.claim_strategy == :delete_confirmation
427
+
428
+ jitter = rand * DELAYED_JOB_DELETE_CONFIRMATION_JITTER_MAX
429
+ sleep(jitter) if jitter.positive?
430
+ end
431
+
432
+ def generate_idempotency_key(cron_key, fire_time) = Kaal::IdempotencyKeyGenerator.call(cron_key, fire_time, configuration: @configuration)
433
+
434
+ def generate_lock_key(cron_key, fire_time) = "#{@configuration.namespace || 'kaal'}:dispatch:#{cron_key}:#{fire_time.to_i}"
435
+
436
+ def generate_delayed_lock_key(job_id) = "#{@configuration.namespace || 'kaal'}:delayed_dispatch:#{job_id}"
437
+
389
438
  def sleep_until_next_tick
390
439
  @mutex.synchronize do
391
440
  @tick_cv.wait(@mutex, @configuration.tick_interval)