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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -1
  3. data/lib/kaal/active_record_support.rb +2 -2
  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 +25 -3
  7. data/lib/kaal/backend/postgres.rb +6 -2
  8. data/lib/kaal/backend/redis_adapter.rb +5 -0
  9. data/lib/kaal/backend/sqlite.rb +4 -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/delayed_job/database_engine.rb +116 -0
  16. data/lib/kaal/delayed_job/dispatch_failure_logger.rb +31 -0
  17. data/lib/kaal/delayed_job/memory_engine.rb +79 -0
  18. data/lib/kaal/delayed_job/mysql_version_support.rb +43 -0
  19. data/lib/kaal/delayed_job/redis_engine.rb +119 -0
  20. data/lib/kaal/delayed_job/registry.rb +39 -0
  21. data/lib/kaal/internal/active_record/database_backend.rb +5 -0
  22. data/lib/kaal/internal/active_record/delayed_job_record.rb +16 -0
  23. data/lib/kaal/internal/active_record/delayed_job_registry.rb +119 -0
  24. data/lib/kaal/internal/active_record/migration_templates.rb +33 -3
  25. data/lib/kaal/internal/active_record/mysql_backend.rb +23 -5
  26. data/lib/kaal/internal/active_record/postgres_backend.rb +4 -0
  27. data/lib/kaal/internal/active_record.rb +2 -0
  28. data/lib/kaal/internal/sequel/database_backend.rb +5 -0
  29. data/lib/kaal/internal/sequel/mysql_backend.rb +15 -1
  30. data/lib/kaal/internal/sequel/postgres_backend.rb +4 -0
  31. data/lib/kaal/internal/sequel.rb +1 -0
  32. data/lib/kaal/job_dispatcher.rb +108 -0
  33. data/lib/kaal/persistence/database.rb +4 -0
  34. data/lib/kaal/persistence/migration_templates.rb +35 -3
  35. data/lib/kaal/runtime/scheduler_boot_loader.rb +2 -0
  36. data/lib/kaal/scheduler_file/job_applier.rb +28 -53
  37. data/lib/kaal/sequel_support.rb +2 -2
  38. data/lib/kaal/version.rb +1 -1
  39. data/lib/kaal.rb +111 -0
  40. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f078489fc2106826b98a1890f15dd5aad38a627ef9db230bd11db3785dc66f86
4
- data.tar.gz: 10ec39fb62e2082d7a8191153af335950fb14d584c0ed33737298baaa2b2f270
3
+ metadata.gz: c304a5d511f122e3b14d0a6dfa88153d49fbf8bf2ddec699340545831c38e0af
4
+ data.tar.gz: c03c7fa46c7f301d100ed38bca9e9d96e3451bd69d18b801b654f8fb97bee9a8
5
5
  SHA512:
6
- metadata.gz: 5be3591420c49e0149f58e6d76abdb3bfc7a7c10f2249754fb427f89e9ce414bad044d44fc17ed8016b4766182558a2a513598dcc212a0d899872f31007da19b
7
- data.tar.gz: 60857c850fe9ca808fd198bcd8480ee63962bc1df5de69bab5953168aa7641f1a739c74d970c67b20a7f71d1858abe2a043ac004191b473a94aee7380cf8ce50
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)
@@ -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
  #
@@ -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
- def initialize(database: nil, connection: nil, namespace: nil, **)
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
- Kaal::Internal::Sequel::MySQLBackend.new(database, namespace:)
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
- Kaal::Internal::ActiveRecord::MySQLBackend.new(connection, namespace:, **)
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
  #
@@ -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
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)
@@ -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