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.
- checksums.yaml +4 -4
- data/README.md +43 -11
- data/lib/kaal/active_record_support.rb +82 -0
- data/lib/kaal/backend/adapter.rb +4 -0
- data/lib/kaal/backend/memory_adapter.rb +5 -0
- data/lib/kaal/backend/mysql.rb +63 -0
- data/lib/kaal/backend/postgres.rb +45 -0
- data/lib/kaal/backend/redis_adapter.rb +5 -0
- data/lib/kaal/backend/sqlite.rb +45 -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/definition/database_engine.rb +88 -0
- 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/dispatch/database_engine.rb +120 -0
- 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 +78 -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/delayed_job_record.rb +16 -0
- data/lib/kaal/internal/active_record/delayed_job_registry.rb +119 -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 +138 -0
- data/lib/kaal/internal/active_record/mysql_backend.rb +89 -0
- data/lib/kaal/internal/active_record/postgres_backend.rb +73 -0
- data/lib/kaal/internal/active_record.rb +19 -0
- data/lib/kaal/internal/sequel/database_backend.rb +79 -0
- data/lib/kaal/internal/sequel/mysql_backend.rb +83 -0
- data/lib/kaal/internal/sequel/postgres_backend.rb +71 -0
- data/lib/kaal/internal/sequel.rb +13 -0
- data/lib/kaal/job_dispatcher.rb +108 -0
- data/lib/kaal/persistence/database.rb +39 -0
- data/lib/kaal/persistence/migration_templates.rb +129 -0
- data/lib/kaal/registry.rb +0 -2
- 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 +82 -0
- data/lib/kaal/version.rb +1 -1
- data/lib/kaal.rb +117 -0
- metadata +36 -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,
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
For SQL-backed deployments, run the generated migrations so `kaal_delayed_jobs` exists alongside the recurring scheduler tables.
|
|
132
166
|
|
|
133
|
-
|
|
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
|
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
|
#
|
|
@@ -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.
|
|
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)
|