kaal 0.4.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.
- checksums.yaml +4 -4
- data/README.md +36 -1
- data/lib/kaal/active_record_support.rb +2 -2
- data/lib/kaal/backend/adapter.rb +4 -0
- data/lib/kaal/backend/memory_adapter.rb +5 -0
- data/lib/kaal/backend/mysql.rb +25 -3
- data/lib/kaal/backend/postgres.rb +6 -2
- data/lib/kaal/backend/redis_adapter.rb +5 -0
- data/lib/kaal/backend/sqlite.rb +4 -0
- data/lib/kaal/cli.rb +1 -0
- data/lib/kaal/config/configuration.rb +33 -2
- data/lib/kaal/config/delayed_job_security_policy.rb +60 -0
- data/lib/kaal/config.rb +1 -0
- data/lib/kaal/core/coordinator.rb +68 -19
- data/lib/kaal/delayed_job/database_engine.rb +116 -0
- data/lib/kaal/delayed_job/dispatch_failure_logger.rb +31 -0
- data/lib/kaal/delayed_job/memory_engine.rb +79 -0
- data/lib/kaal/delayed_job/mysql_version_support.rb +43 -0
- data/lib/kaal/delayed_job/redis_engine.rb +119 -0
- data/lib/kaal/delayed_job/registry.rb +39 -0
- data/lib/kaal/internal/active_record/database_backend.rb +5 -0
- data/lib/kaal/internal/active_record/delayed_job_record.rb +16 -0
- data/lib/kaal/internal/active_record/delayed_job_registry.rb +119 -0
- data/lib/kaal/internal/active_record/migration_templates.rb +33 -3
- data/lib/kaal/internal/active_record/mysql_backend.rb +23 -5
- data/lib/kaal/internal/active_record/postgres_backend.rb +4 -0
- data/lib/kaal/internal/active_record.rb +2 -0
- data/lib/kaal/internal/sequel/database_backend.rb +5 -0
- data/lib/kaal/internal/sequel/mysql_backend.rb +15 -1
- data/lib/kaal/internal/sequel/postgres_backend.rb +4 -0
- data/lib/kaal/internal/sequel.rb +1 -0
- data/lib/kaal/job_dispatcher.rb +108 -0
- data/lib/kaal/persistence/database.rb +4 -0
- data/lib/kaal/persistence/migration_templates.rb +35 -3
- data/lib/kaal/runtime/scheduler_boot_loader.rb +2 -0
- data/lib/kaal/scheduler_file/job_applier.rb +28 -53
- data/lib/kaal/sequel_support.rb +2 -2
- data/lib/kaal/version.rb +1 -1
- data/lib/kaal.rb +111 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c304a5d511f122e3b14d0a6dfa88153d49fbf8bf2ddec699340545831c38e0af
|
|
4
|
+
data.tar.gz: c03c7fa46c7f301d100ed38bca9e9d96e3451bd69d18b801b654f8fb97bee9a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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, the plain Ruby CLI, and the optional SQL backend surfaces.
|
|
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
|
|
|
@@ -110,6 +110,8 @@ REDIS_URL=redis://127.0.0.1:6379/0 bin/rspec-e2e redis
|
|
|
110
110
|
|
|
111
111
|
## Runtime API
|
|
112
112
|
|
|
113
|
+
Recurring jobs:
|
|
114
|
+
|
|
113
115
|
```ruby
|
|
114
116
|
Kaal.register(
|
|
115
117
|
key: 'reports:daily',
|
|
@@ -122,6 +124,35 @@ Kaal.register(
|
|
|
122
124
|
Kaal.start!
|
|
123
125
|
```
|
|
124
126
|
|
|
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
|
+
|
|
125
156
|
## SQL Backends
|
|
126
157
|
|
|
127
158
|
Use the explicit SQL backends when you want persisted registries:
|
|
@@ -130,3 +161,7 @@ Use the explicit SQL backends when you want persisted registries:
|
|
|
130
161
|
- `Kaal::Backend::Postgres`
|
|
131
162
|
- `Kaal::Backend::MySQL`
|
|
132
163
|
- `kaal-rails` for Rails-native install and auto-wiring
|
|
164
|
+
|
|
165
|
+
For SQL-backed deployments, run the generated migrations so `kaal_delayed_jobs` exists alongside the recurring scheduler tables.
|
|
166
|
+
|
|
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.
|
|
@@ -68,9 +68,9 @@ module Kaal
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
def migration_suffixes_for(backend)
|
|
71
|
-
return %w[dispatches locks definitions] if backend.to_s == 'sqlite'
|
|
71
|
+
return %w[dispatches locks definitions delayed_jobs] if backend.to_s == 'sqlite'
|
|
72
72
|
|
|
73
|
-
%w[dispatches definitions]
|
|
73
|
+
%w[dispatches definitions delayed_jobs]
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def alphanumeric?(char)
|
data/lib/kaal/backend/adapter.rb
CHANGED
|
@@ -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
|
#
|
data/lib/kaal/backend/mysql.rb
CHANGED
|
@@ -8,16 +8,20 @@ module Kaal
|
|
|
8
8
|
module Backend
|
|
9
9
|
# MySQL-backed backend for either Sequel or Active Record persistence.
|
|
10
10
|
class MySQL < Adapter
|
|
11
|
-
|
|
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)
|
|
12
15
|
super()
|
|
16
|
+
backend_class = self.class
|
|
13
17
|
@engine = if database
|
|
14
18
|
Kaal::Sequel.require_sequel!
|
|
15
19
|
require 'kaal/internal/sequel'
|
|
16
|
-
|
|
20
|
+
backend_class.send(:build_sequel_backend, database, namespace, use_skip_locked)
|
|
17
21
|
else
|
|
18
22
|
Kaal::ActiveRecord.require_activerecord!
|
|
19
23
|
require 'kaal/internal/active_record'
|
|
20
|
-
|
|
24
|
+
backend_class.send(:build_active_record_backend, connection, namespace, use_skip_locked)
|
|
21
25
|
end
|
|
22
26
|
end
|
|
23
27
|
|
|
@@ -29,6 +33,10 @@ module Kaal
|
|
|
29
33
|
@engine.definition_registry
|
|
30
34
|
end
|
|
31
35
|
|
|
36
|
+
def delayed_store
|
|
37
|
+
@engine.delayed_store
|
|
38
|
+
end
|
|
39
|
+
|
|
32
40
|
def acquire(key, ttl)
|
|
33
41
|
@engine.acquire(key, ttl)
|
|
34
42
|
end
|
|
@@ -36,6 +44,20 @@ module Kaal
|
|
|
36
44
|
def release(key)
|
|
37
45
|
@engine.release(key)
|
|
38
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
|
|
39
61
|
end
|
|
40
62
|
end
|
|
41
63
|
end
|
|
@@ -8,7 +8,7 @@ module Kaal
|
|
|
8
8
|
module Backend
|
|
9
9
|
# PostgreSQL-backed backend for either Sequel or Active Record persistence.
|
|
10
10
|
class Postgres < Adapter
|
|
11
|
-
def initialize(database: nil, connection: nil, namespace: nil
|
|
11
|
+
def initialize(database: nil, connection: nil, namespace: nil)
|
|
12
12
|
super()
|
|
13
13
|
@engine = if database
|
|
14
14
|
Kaal::Sequel.require_sequel!
|
|
@@ -17,7 +17,7 @@ module Kaal
|
|
|
17
17
|
else
|
|
18
18
|
Kaal::ActiveRecord.require_activerecord!
|
|
19
19
|
require 'kaal/internal/active_record'
|
|
20
|
-
Kaal::Internal::ActiveRecord::PostgresBackend.new(connection, namespace
|
|
20
|
+
Kaal::Internal::ActiveRecord::PostgresBackend.new(connection, namespace:)
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -29,6 +29,10 @@ module Kaal
|
|
|
29
29
|
@engine.definition_registry
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def delayed_store
|
|
33
|
+
@engine.delayed_store
|
|
34
|
+
end
|
|
35
|
+
|
|
32
36
|
def acquire(key, ttl)
|
|
33
37
|
@engine.acquire(key, ttl)
|
|
34
38
|
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
|
#
|
data/lib/kaal/backend/sqlite.rb
CHANGED
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.
|
|
20
|
-
# 3.
|
|
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
|
|
381
|
-
|
|
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
|
|
385
|
-
|
|
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)
|
|
@@ -0,0 +1,116 @@
|
|
|
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 'json'
|
|
8
|
+
require 'kaal/delayed_job/registry'
|
|
9
|
+
require 'kaal/persistence/database'
|
|
10
|
+
|
|
11
|
+
module Kaal
|
|
12
|
+
module DelayedJob
|
|
13
|
+
# Sequel-backed delayed-job store persisted in kaal_delayed_jobs.
|
|
14
|
+
class DatabaseEngine < Registry
|
|
15
|
+
def initialize(database:, use_skip_locked: false)
|
|
16
|
+
super()
|
|
17
|
+
@database = Kaal::Persistence::Database.new(database)
|
|
18
|
+
@use_skip_locked = use_skip_locked
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def enqueue(job_id:, run_at:, job_class:, args:, queue: nil, connection: nil)
|
|
22
|
+
now = Time.now.utc
|
|
23
|
+
payload = {
|
|
24
|
+
job_id: job_id,
|
|
25
|
+
run_at: run_at,
|
|
26
|
+
job_class: job_class,
|
|
27
|
+
args: JSON.generate(args),
|
|
28
|
+
queue: queue,
|
|
29
|
+
created_at: now
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
dataset_for(connection).insert(payload)
|
|
33
|
+
self.class.normalize_row(payload)
|
|
34
|
+
rescue ::Sequel::UniqueConstraintViolation
|
|
35
|
+
raise DuplicateJobError, "Delayed job #{job_id.inspect} already exists"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pop_due(now:, limit:)
|
|
39
|
+
return pop_due_with_skip_locked(now:, limit:) if @use_skip_locked
|
|
40
|
+
|
|
41
|
+
pop_due_with_delete_confirmation(now:, limit:)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_job(job_id, connection: @database.connection)
|
|
45
|
+
self.class.normalize_row(connection[:kaal_delayed_jobs].where(job_id: job_id).first)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def all_jobs
|
|
49
|
+
connection[:kaal_delayed_jobs].order(:run_at, :job_id).filter_map { |row| self.class.normalize_row(row) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def claim_strategy
|
|
53
|
+
@use_skip_locked ? :skip_locked : :delete_confirmation
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.normalize_row(row)
|
|
57
|
+
return nil unless row
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
job_id: row[:job_id],
|
|
61
|
+
run_at: row[:run_at],
|
|
62
|
+
job_class: row[:job_class],
|
|
63
|
+
args: parse_args(row[:args]),
|
|
64
|
+
queue: row[:queue],
|
|
65
|
+
created_at: row[:created_at]
|
|
66
|
+
}
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def pop_due_with_skip_locked(now:, limit:)
|
|
74
|
+
connection.transaction do
|
|
75
|
+
delayed_jobs_dataset = connection[:kaal_delayed_jobs]
|
|
76
|
+
due_rows = delayed_jobs_dataset.where { run_at <= now }.order(:run_at, :job_id).for_update.skip_locked.limit(limit).all
|
|
77
|
+
job_ids = due_rows.map { |row| row[:job_id] }
|
|
78
|
+
normalized_jobs = due_rows.filter_map { |row| self.class.normalize_row(row) }
|
|
79
|
+
delayed_jobs_dataset.where(job_id: job_ids).delete unless job_ids.empty?
|
|
80
|
+
normalized_jobs
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pop_due_with_delete_confirmation(now:, limit:)
|
|
85
|
+
connection.transaction do
|
|
86
|
+
delayed_jobs_dataset = connection[:kaal_delayed_jobs]
|
|
87
|
+
due_rows = delayed_jobs_dataset.where { run_at <= now }.order(:run_at, :job_id).limit(limit).all
|
|
88
|
+
due_rows.each_with_object([]) do |row, jobs|
|
|
89
|
+
deleted = delayed_jobs_dataset.where(job_id: row[:job_id]).delete
|
|
90
|
+
normalized_job = self.class.normalize_row(row)
|
|
91
|
+
jobs << normalized_job if deleted.positive? && normalized_job
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.parse_args(args_payload)
|
|
97
|
+
JSON.parse(args_payload || '[]')
|
|
98
|
+
end
|
|
99
|
+
private_class_method :parse_args
|
|
100
|
+
|
|
101
|
+
def dataset_for(connection)
|
|
102
|
+
return dataset unless connection
|
|
103
|
+
|
|
104
|
+
Kaal::Persistence::Database.new(connection).delayed_jobs_dataset
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def dataset
|
|
108
|
+
@database.delayed_jobs_dataset
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def connection
|
|
112
|
+
@database.connection
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
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 DelayedJob
|
|
9
|
+
# Shared delayed-dispatch failure logging for at-most-once dispatches.
|
|
10
|
+
module DispatchFailureLogger
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def log_claimed_dispatch_failure(logger:, job:, error:)
|
|
14
|
+
return unless logger
|
|
15
|
+
|
|
16
|
+
message = "Delayed job #{job.fetch(:job_id)} dispatch failed after claim; " \
|
|
17
|
+
"job_class=#{job.fetch(:job_class).inspect} " \
|
|
18
|
+
"queue=#{job[:queue].inspect} " \
|
|
19
|
+
"run_at=#{job.fetch(:run_at)} " \
|
|
20
|
+
'job was already claimed and will not be retried: ' \
|
|
21
|
+
"#{error.class}: #{error.message}"
|
|
22
|
+
|
|
23
|
+
if logger.respond_to?(:fatal)
|
|
24
|
+
logger.fatal(message)
|
|
25
|
+
else
|
|
26
|
+
logger.error(message)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|