kaal 0.4.0 → 0.6.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +60 -25
  3. data/config/kaal.yml +12 -0
  4. data/lib/kaal/active_record_support.rb +2 -2
  5. data/lib/kaal/backend/adapter.rb +8 -0
  6. data/lib/kaal/backend/memory_adapter.rb +5 -0
  7. data/lib/kaal/backend/mysql.rb +25 -3
  8. data/lib/kaal/backend/postgres.rb +6 -2
  9. data/lib/kaal/backend/redis_adapter.rb +5 -0
  10. data/lib/kaal/backend/sqlite.rb +4 -0
  11. data/lib/kaal/cli.rb +38 -33
  12. data/lib/kaal/config/backend_factory.rb +178 -0
  13. data/lib/kaal/config/configuration.rb +98 -9
  14. data/lib/kaal/config/delayed_job_security_policy.rb +60 -0
  15. data/lib/kaal/config/file_loader.rb +187 -0
  16. data/lib/kaal/config.rb +3 -0
  17. data/lib/kaal/core/coordinator.rb +68 -19
  18. data/lib/kaal/delayed_job/database_engine.rb +116 -0
  19. data/lib/kaal/delayed_job/dispatch_failure_logger.rb +31 -0
  20. data/lib/kaal/delayed_job/memory_engine.rb +79 -0
  21. data/lib/kaal/delayed_job/mysql_version_support.rb +43 -0
  22. data/lib/kaal/delayed_job/redis_engine.rb +119 -0
  23. data/lib/kaal/delayed_job/registry.rb +39 -0
  24. data/lib/kaal/internal/active_record/database_backend.rb +5 -0
  25. data/lib/kaal/internal/active_record/delayed_job_record.rb +16 -0
  26. data/lib/kaal/internal/active_record/delayed_job_registry.rb +119 -0
  27. data/lib/kaal/internal/active_record/migration_templates.rb +33 -3
  28. data/lib/kaal/internal/active_record/mysql_backend.rb +23 -5
  29. data/lib/kaal/internal/active_record/postgres_backend.rb +4 -0
  30. data/lib/kaal/internal/active_record.rb +2 -0
  31. data/lib/kaal/internal/sequel/database_backend.rb +5 -0
  32. data/lib/kaal/internal/sequel/mysql_backend.rb +15 -1
  33. data/lib/kaal/internal/sequel/postgres_backend.rb +4 -0
  34. data/lib/kaal/internal/sequel.rb +1 -0
  35. data/lib/kaal/job_dispatcher.rb +108 -0
  36. data/lib/kaal/persistence/database.rb +4 -0
  37. data/lib/kaal/persistence/migration_templates.rb +35 -3
  38. data/lib/kaal/runtime/scheduler_boot_loader.rb +3 -1
  39. data/lib/kaal/scheduler_file/job_applier.rb +28 -53
  40. data/lib/kaal/scheduler_file/loader.rb +1 -1
  41. data/lib/kaal/sequel_support.rb +2 -2
  42. data/lib/kaal/version.rb +1 -1
  43. data/lib/kaal.rb +118 -0
  44. data/sig/00_types.rbs +12 -0
  45. data/sig/dependencies.rbs +49 -0
  46. data/sig/kaal/active_record_support.rbs +23 -0
  47. data/sig/kaal/backend/adapter.rbs +26 -0
  48. data/sig/kaal/backend/dispatch_attempt_logger.rbs +17 -0
  49. data/sig/kaal/backend/dispatch_logging.rbs +23 -0
  50. data/sig/kaal/backend/dispatch_registry_accessor.rbs +17 -0
  51. data/sig/kaal/backend/memory_adapter.rbs +33 -0
  52. data/sig/kaal/backend/mysql.rbs +25 -0
  53. data/sig/kaal/backend/postgres.rbs +19 -0
  54. data/sig/kaal/backend/redis_adapter.rbs +41 -0
  55. data/sig/kaal/backend/sqlite.rbs +19 -0
  56. data/sig/kaal/cli.rbs +41 -0
  57. data/sig/kaal/config/backend_factory.rbs +41 -0
  58. data/sig/kaal/config/configuration.rbs +70 -0
  59. data/sig/kaal/config/delayed_job_security_policy.rbs +19 -0
  60. data/sig/kaal/config/file_loader.rbs +35 -0
  61. data/sig/kaal/config/scheduler_config_error.rbs +4 -0
  62. data/sig/kaal/config/scheduler_time_zone_resolver.rbs +19 -0
  63. data/sig/kaal/config.rbs +11 -0
  64. data/sig/kaal/core/coordinator.rbs +103 -0
  65. data/sig/kaal/core/enabled_entry_enumerator.rbs +21 -0
  66. data/sig/kaal/core/occurrence_finder.rbs +9 -0
  67. data/sig/kaal/core.rbs +9 -0
  68. data/sig/kaal/definition/database_engine.rbs +25 -0
  69. data/sig/kaal/definition/memory_engine.rbs +23 -0
  70. data/sig/kaal/definition/persistence_helpers.rbs +9 -0
  71. data/sig/kaal/definition/redis_engine.rbs +33 -0
  72. data/sig/kaal/definition/registry.rbs +29 -0
  73. data/sig/kaal/definitions/registration_service.rbs +27 -0
  74. data/sig/kaal/definitions/registry_accessor.rbs +17 -0
  75. data/sig/kaal/delayed_job/database_engine.rbs +37 -0
  76. data/sig/kaal/delayed_job/dispatch_failure_logger.rbs +7 -0
  77. data/sig/kaal/delayed_job/memory_engine.rbs +29 -0
  78. data/sig/kaal/delayed_job/mysql_version_support.rbs +15 -0
  79. data/sig/kaal/delayed_job/redis_engine.rbs +31 -0
  80. data/sig/kaal/delayed_job/registry.rbs +20 -0
  81. data/sig/kaal/dispatch/database_engine.rbs +39 -0
  82. data/sig/kaal/dispatch/memory_engine.rbs +23 -0
  83. data/sig/kaal/dispatch/redis_engine.rbs +25 -0
  84. data/sig/kaal/dispatch/registry.rbs +11 -0
  85. data/sig/kaal/internal/active_record/base_record.rbs +8 -0
  86. data/sig/kaal/internal/active_record/connection_support.rbs +25 -0
  87. data/sig/kaal/internal/active_record/database_backend.rbs +37 -0
  88. data/sig/kaal/internal/active_record/definition_record.rbs +8 -0
  89. data/sig/kaal/internal/active_record/definition_registry.rbs +27 -0
  90. data/sig/kaal/internal/active_record/delayed_job_record.rbs +8 -0
  91. data/sig/kaal/internal/active_record/delayed_job_registry.rbs +39 -0
  92. data/sig/kaal/internal/active_record/dispatch_record.rbs +8 -0
  93. data/sig/kaal/internal/active_record/dispatch_registry.rbs +43 -0
  94. data/sig/kaal/internal/active_record/lock_record.rbs +8 -0
  95. data/sig/kaal/internal/active_record/migration_templates.rbs +17 -0
  96. data/sig/kaal/internal/active_record/mysql_backend.rbs +45 -0
  97. data/sig/kaal/internal/active_record/postgres_backend.rbs +41 -0
  98. data/sig/kaal/internal/active_record.rbs +0 -0
  99. data/sig/kaal/internal/sequel/database_backend.rbs +39 -0
  100. data/sig/kaal/internal/sequel/mysql_backend.rbs +47 -0
  101. data/sig/kaal/internal/sequel/postgres_backend.rbs +43 -0
  102. data/sig/kaal/internal/sequel.rbs +0 -0
  103. data/sig/kaal/job_dispatcher.rbs +19 -0
  104. data/sig/kaal/persistence/database.rbs +19 -0
  105. data/sig/kaal/persistence/migration_templates.rbs +15 -0
  106. data/sig/kaal/register_conflict_support.rbs +11 -0
  107. data/sig/kaal/registry.rbs +44 -0
  108. data/sig/kaal/runtime/runtime_context.rbs +23 -0
  109. data/sig/kaal/runtime/scheduler_boot_loader.rbs +23 -0
  110. data/sig/kaal/runtime/signal_handler_chain.rbs +19 -0
  111. data/sig/kaal/runtime/signal_handler_installer.rbs +19 -0
  112. data/sig/kaal/runtime.rbs +11 -0
  113. data/sig/kaal/scheduler_file/hash_transform.rbs +9 -0
  114. data/sig/kaal/scheduler_file/helper_bundle.rbs +15 -0
  115. data/sig/kaal/scheduler_file/job_applier.rbs +43 -0
  116. data/sig/kaal/scheduler_file/job_normalizer.rbs +27 -0
  117. data/sig/kaal/scheduler_file/loader.rbs +69 -0
  118. data/sig/kaal/scheduler_file/payload_loader.rbs +33 -0
  119. data/sig/kaal/scheduler_file/placeholder_support.rbs +19 -0
  120. data/sig/kaal/scheduler_file.rbs +9 -0
  121. data/sig/kaal/sequel_support.rbs +25 -0
  122. data/sig/kaal/support/hash_tools.rbs +27 -0
  123. data/sig/kaal/utils/cron_humanizer.rbs +39 -0
  124. data/sig/kaal/utils/cron_utils.rbs +43 -0
  125. data/sig/kaal/utils/idempotency_key_generator.rbs +5 -0
  126. data/sig/kaal/utils.rbs +9 -0
  127. data/sig/kaal/version.rbs +3 -0
  128. data/sig/kaal.rbs +145 -0
  129. metadata +100 -3
  130. data/config/kaal.rb +0 -15
  131. /data/config/{scheduler.yml → kaal-scheduler.yml} +0 -0
@@ -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
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+ require 'kaal/delayed_job/registry'
8
+ require 'kaal/support/hash_tools'
9
+
10
+ module Kaal
11
+ module DelayedJob
12
+ # In-memory delayed job store for single-process development and tests.
13
+ class MemoryEngine < Registry
14
+ include Kaal::Support::HashTools
15
+
16
+ def initialize
17
+ super
18
+ @jobs = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def enqueue(job_id:, run_at:, job_class:, args:, queue: nil, **)
23
+ @mutex.synchronize do
24
+ raise DuplicateJobError, "Delayed job #{job_id.inspect} already exists" if @jobs.key?(job_id)
25
+
26
+ job = build_job(job_id:, run_at:, job_class:, args:, queue:)
27
+ @jobs[job_id] = job
28
+ deep_dup(job)
29
+ end
30
+ end
31
+
32
+ def pop_due(now:, limit:)
33
+ @mutex.synchronize do
34
+ due_jobs = @jobs.values
35
+ .select { |job| job[:run_at] <= now }
36
+ .sort_by { |job| job.values_at(:run_at, :job_id) }
37
+ .first(limit)
38
+
39
+ due_jobs.each do |job|
40
+ job_id = job.fetch(:job_id)
41
+ @jobs.delete(job_id)
42
+ end
43
+ due_jobs.map { |job| deep_dup(job) }
44
+ end
45
+ end
46
+
47
+ def find_job(job_id)
48
+ @mutex.synchronize { deep_dup(@jobs[job_id]) }
49
+ end
50
+
51
+ def all_jobs
52
+ @mutex.synchronize do
53
+ @jobs.values.sort_by { |job| [job[:run_at], job[:job_id]] }.map { |job| deep_dup(job) }
54
+ end
55
+ end
56
+
57
+ def clear
58
+ @mutex.synchronize { @jobs.clear }
59
+ end
60
+
61
+ def claim_strategy
62
+ :atomic_pop
63
+ end
64
+
65
+ private
66
+
67
+ def build_job(job_id:, run_at:, job_class:, args:, queue:)
68
+ {
69
+ job_id: job_id,
70
+ run_at: run_at,
71
+ job_class: job_class,
72
+ args: deep_dup(args),
73
+ queue: queue,
74
+ created_at: Time.now.utc
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,43 @@
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
+ # MySQL version helper for delayed-job claim strategy selection.
10
+ module MySQLVersionSupport
11
+ MINIMUM_SKIP_LOCKED_VERSION = 800_00
12
+
13
+ module_function
14
+
15
+ def skip_locked_supported?(version_string)
16
+ version_number(version_string) >= MINIMUM_SKIP_LOCKED_VERSION
17
+ end
18
+
19
+ def version_number(version_string)
20
+ major, minor, patch = version_components(version_string)
21
+ return 0 unless major && minor && patch
22
+
23
+ (major * 10_000) + (minor * 100) + patch
24
+ end
25
+
26
+ def version_components(version_string)
27
+ major, minor, patch = version_string.to_s.split('.', 3)
28
+ [
29
+ integer_prefix(major),
30
+ integer_prefix(minor),
31
+ integer_prefix(patch)
32
+ ]
33
+ end
34
+
35
+ def integer_prefix(value)
36
+ digits = value.to_s.each_char.take_while { |character| character.between?('0', '9') }.join
37
+ return nil if digits.empty?
38
+
39
+ digits.to_i
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,119 @@
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 'time'
9
+ require 'kaal/delayed_job/registry'
10
+
11
+ module Kaal
12
+ module DelayedJob
13
+ # Redis-backed delayed-job store using a sorted set plus payload hash.
14
+ class RedisEngine < Registry
15
+ def initialize(redis, namespace: 'kaal')
16
+ super()
17
+ @redis = redis
18
+ @namespace = namespace
19
+ end
20
+
21
+ def enqueue(job_id:, run_at:, job_class:, args:, queue: nil, **)
22
+ payload = JSON.generate(
23
+ job_id: job_id,
24
+ run_at: run_at.iso8601,
25
+ job_class: job_class,
26
+ args: args,
27
+ queue: queue,
28
+ created_at: Time.now.utc.iso8601
29
+ )
30
+
31
+ script = <<~LUA
32
+ if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then
33
+ return 0
34
+ end
35
+
36
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
37
+ redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])
38
+ return 1
39
+ LUA
40
+
41
+ result = @redis.eval(script, keys: [payloads_key, schedule_key], argv: [job_id, payload, run_at.to_f])
42
+ raise DuplicateJobError, "Delayed job #{job_id.inspect} already exists" unless [1, '1', true].include?(result)
43
+
44
+ find_job(job_id)
45
+ end
46
+
47
+ def pop_due(now:, limit:)
48
+ script = <<~LUA
49
+ local job_ids = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
50
+ local payloads = {}
51
+
52
+ for _, job_id in ipairs(job_ids) do
53
+ if redis.call('ZREM', KEYS[2], job_id) == 1 then
54
+ local payload = redis.call('HGET', KEYS[1], job_id)
55
+ if payload then
56
+ redis.call('HDEL', KEYS[1], job_id)
57
+ table.insert(payloads, payload)
58
+ end
59
+ end
60
+ end
61
+
62
+ return payloads
63
+ LUA
64
+
65
+ Array(@redis.eval(script, keys: [payloads_key, schedule_key], argv: [now.to_f, limit])).filter_map do |raw|
66
+ self.class.deserialize(raw)
67
+ end
68
+ end
69
+
70
+ def find_job(job_id)
71
+ self.class.deserialize(@redis.hget(payloads_key, job_id))
72
+ end
73
+
74
+ def all_jobs
75
+ Array(@redis.zrange(schedule_key, 0, -1)).filter_map { |job_id| find_job(job_id) }
76
+ end
77
+
78
+ def claim_strategy
79
+ :atomic_pop
80
+ end
81
+
82
+ def self.deserialize(raw)
83
+ return nil unless raw
84
+
85
+ parsed = JSON.parse(raw)
86
+ run_at = parse_time(parsed['run_at'])
87
+ created_at = parse_time(parsed['created_at'])
88
+ return nil unless run_at && created_at
89
+
90
+ {
91
+ job_id: parsed['job_id'],
92
+ run_at: run_at,
93
+ job_class: parsed['job_class'],
94
+ args: parsed['args'] || [],
95
+ queue: parsed['queue'],
96
+ created_at: created_at
97
+ }
98
+ rescue JSON::ParserError
99
+ nil
100
+ end
101
+
102
+ def self.parse_time(value)
103
+ Time.iso8601(value.to_s)
104
+ rescue ArgumentError
105
+ nil
106
+ end
107
+
108
+ private
109
+
110
+ def payloads_key
111
+ "#{@namespace}:delayed_jobs:payloads"
112
+ end
113
+
114
+ def schedule_key
115
+ "#{@namespace}:delayed_jobs:schedule"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,39 @@
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
+ # Raised when a delayed job with the same identifier already exists.
10
+ class DuplicateJobError < StandardError; end
11
+
12
+ # Base abstraction for delayed-job persistence.
13
+ class Registry
14
+ def enqueue(**)
15
+ raise NotImplementedError, "#{self.class.name} must implement #enqueue"
16
+ end
17
+
18
+ def pop_due(**)
19
+ raise NotImplementedError, "#{self.class.name} must implement #pop_due"
20
+ end
21
+
22
+ def find_job(_job_id)
23
+ raise NotImplementedError, "#{self.class.name} must implement #find_job"
24
+ end
25
+
26
+ def all_jobs
27
+ raise NotImplementedError, "#{self.class.name} must implement #all_jobs"
28
+ end
29
+
30
+ def claim_strategy
31
+ raise NotImplementedError, "#{self.class.name} must implement #claim_strategy"
32
+ end
33
+
34
+ def requires_dispatch_lock?
35
+ false
36
+ end
37
+ end
38
+ end
39
+ end
@@ -6,6 +6,7 @@
6
6
  # LICENSE file in the root directory of this source tree.
7
7
  require 'kaal/backend/adapter'
8
8
  require 'kaal/backend/dispatch_logging'
9
+ require 'kaal/internal/active_record/delayed_job_registry'
9
10
 
10
11
  module Kaal
11
12
  module Internal
@@ -31,6 +32,10 @@ module Kaal
31
32
  @definition_registry ||= DefinitionRegistry.new
32
33
  end
33
34
 
35
+ def delayed_store
36
+ @delayed_store ||= DelayedJobRegistry.new
37
+ end
38
+
34
39
  def acquire(key, ttl)
35
40
  now = Time.now.utc
36
41
  expires_at = now + ttl
@@ -0,0 +1,16 @@
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 Internal
9
+ module ActiveRecord
10
+ # Active Record model for persisted delayed jobs.
11
+ class DelayedJobRecord < BaseRecord
12
+ self.table_name = 'kaal_delayed_jobs'
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,119 @@
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
+
10
+ module Kaal
11
+ module Internal
12
+ module ActiveRecord
13
+ # Active Record-backed store for delayed jobs.
14
+ class DelayedJobRegistry < Kaal::DelayedJob::Registry
15
+ def initialize(connection: nil, model: DelayedJobRecord, use_skip_locked: false)
16
+ super()
17
+ ConnectionSupport.configure!(connection)
18
+ @model = model
19
+ @use_skip_locked = use_skip_locked
20
+ end
21
+
22
+ def enqueue(job_id:, run_at:, job_class:, args:, queue: nil, connection: nil)
23
+ now = Time.now.utc
24
+ attributes = {
25
+ job_id: job_id,
26
+ run_at: run_at,
27
+ job_class: job_class,
28
+ args: JSON.generate(args),
29
+ queue: queue,
30
+ created_at: now
31
+ }
32
+
33
+ if connection
34
+ insert_with_connection(connection, attributes)
35
+ else
36
+ @model.create!(attributes)
37
+ end
38
+
39
+ self.class.normalize(@model.new(attributes))
40
+ rescue ::ActiveRecord::RecordNotUnique
41
+ raise Kaal::DelayedJob::DuplicateJobError, "Delayed job #{job_id.inspect} already exists"
42
+ end
43
+
44
+ def pop_due(now:, limit:)
45
+ return pop_due_with_skip_locked(now:, limit:) if @use_skip_locked
46
+
47
+ pop_due_with_delete_confirmation(now:, limit:)
48
+ end
49
+
50
+ private
51
+
52
+ def pop_due_with_skip_locked(now:, limit:)
53
+ @model.transaction do
54
+ due_records = @model.where('run_at <= ?', now).order(:run_at, :job_id).lock('FOR UPDATE SKIP LOCKED').limit(limit).to_a
55
+ job_ids = due_records.map(&:job_id)
56
+ @model.where(job_id: job_ids).delete_all unless job_ids.empty?
57
+ due_records.filter_map { |record| self.class.normalize(record) }
58
+ end
59
+ end
60
+
61
+ def pop_due_with_delete_confirmation(now:, limit:)
62
+ @model.transaction do
63
+ @model.where('run_at <= ?', now).order(:run_at, :job_id).limit(limit).each_with_object([]) do |record, jobs|
64
+ normalized_job = self.class.normalize(record)
65
+ jobs << normalized_job if @model.where(job_id: record.job_id).delete_all.positive? && normalized_job
66
+ end
67
+ end
68
+ end
69
+
70
+ public
71
+
72
+ def find_job(job_id)
73
+ self.class.normalize(@model.find_by(job_id: job_id))
74
+ end
75
+
76
+ def all_jobs
77
+ @model.order(:run_at, :job_id).filter_map { |record| self.class.normalize(record) }
78
+ end
79
+
80
+ def claim_strategy
81
+ @use_skip_locked ? :skip_locked : :delete_confirmation
82
+ end
83
+
84
+ def self.normalize(record)
85
+ return nil unless record
86
+
87
+ {
88
+ job_id: record.job_id,
89
+ run_at: record.run_at,
90
+ job_class: record.job_class,
91
+ args: parse_args(record.args),
92
+ queue: record.queue,
93
+ created_at: record.created_at
94
+ }
95
+ rescue JSON::ParserError
96
+ nil
97
+ end
98
+
99
+ private
100
+
101
+ def insert_with_connection(connection, attributes)
102
+ table_name = @model.table_name
103
+ columns = attributes.keys
104
+ quoted_pairs = columns.map do |column|
105
+ [connection.quote_column_name(column), connection.quote(attributes.fetch(column))]
106
+ end
107
+ quoted_columns = quoted_pairs.map(&:first).join(', ')
108
+ quoted_values = quoted_pairs.map(&:last).join(', ')
109
+ connection.execute("INSERT INTO #{connection.quote_table_name(table_name)} (#{quoted_columns}) VALUES (#{quoted_values})")
110
+ end
111
+
112
+ def self.parse_args(args_payload)
113
+ JSON.parse(args_payload || '[]')
114
+ end
115
+ private_class_method :parse_args
116
+ end
117
+ end
118
+ end
119
+ end
@@ -17,17 +17,20 @@ module Kaal
17
17
  {
18
18
  '001_create_kaal_dispatches.rb' => dispatches_template,
19
19
  '002_create_kaal_locks.rb' => locks_template,
20
- '003_create_kaal_definitions.rb' => definitions_template('sqlite')
20
+ '003_create_kaal_definitions.rb' => definitions_template('sqlite'),
21
+ '004_create_kaal_delayed_jobs.rb' => delayed_jobs_template('sqlite')
21
22
  }
22
23
  when 'postgres'
23
24
  {
24
25
  '001_create_kaal_dispatches.rb' => dispatches_template,
25
- '002_create_kaal_definitions.rb' => definitions_template('postgres')
26
+ '002_create_kaal_definitions.rb' => definitions_template('postgres'),
27
+ '003_create_kaal_delayed_jobs.rb' => delayed_jobs_template('postgres')
26
28
  }
27
29
  when 'mysql'
28
30
  {
29
31
  '001_create_kaal_dispatches.rb' => dispatches_template,
30
- '002_create_kaal_definitions.rb' => definitions_template('mysql')
32
+ '002_create_kaal_definitions.rb' => definitions_template('mysql'),
33
+ '003_create_kaal_delayed_jobs.rb' => delayed_jobs_template('mysql')
31
34
  }
32
35
  else
33
36
  {}
@@ -102,6 +105,33 @@ module Kaal
102
105
  end
103
106
  RUBY
104
107
  end
108
+
109
+ def delayed_jobs_template(backend)
110
+ args_definition =
111
+ if backend == 'mysql'
112
+ 't.text :args, null: false'
113
+ else
114
+ "t.text :args, null: false, default: '[]'"
115
+ end
116
+
117
+ <<~RUBY
118
+ class CreateKaalDelayedJobs < ActiveRecord::Migration[7.1]
119
+ def change
120
+ create_table :kaal_delayed_jobs do |t|
121
+ t.string :job_id, null: false
122
+ t.datetime :run_at, null: false
123
+ t.string :job_class, null: false
124
+ #{args_definition}
125
+ t.string :queue
126
+ t.datetime :created_at, null: false
127
+ end
128
+
129
+ add_index :kaal_delayed_jobs, :job_id, unique: true
130
+ add_index :kaal_delayed_jobs, :run_at
131
+ end
132
+ end
133
+ RUBY
134
+ end
105
135
  end
106
136
  end
107
137
  end
@@ -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 'digest'
8
+ require 'kaal/delayed_job/mysql_version_support'
8
9
 
9
10
  module Kaal
10
11
  module Internal
@@ -14,13 +15,16 @@ module Kaal
14
15
  include Kaal::Backend::DispatchLogging
15
16
 
16
17
  MAX_LOCK_NAME_LENGTH = 64
18
+ UNSET_SKIP_LOCKED_SUPPORT = Object.new.freeze
17
19
 
18
- def initialize(connection = nil, dispatch_registry: nil, definition_registry: nil, namespace: nil)
20
+ def initialize(connection = nil, dispatch_registry: nil, definition_registry: nil, namespace: nil,
21
+ use_skip_locked: UNSET_SKIP_LOCKED_SUPPORT)
19
22
  super()
20
23
  ConnectionSupport.configure!(connection)
21
24
  @dispatch_registry = dispatch_registry
22
25
  @definition_registry = definition_registry
23
26
  @namespace = namespace
27
+ @use_skip_locked = use_skip_locked
24
28
  end
25
29
 
26
30
  def dispatch_registry
@@ -31,6 +35,10 @@ module Kaal
31
35
  @definition_registry ||= DefinitionRegistry.new
32
36
  end
33
37
 
38
+ def delayed_store
39
+ @delayed_store ||= DelayedJobRegistry.new(use_skip_locked: supports_skip_locked?)
40
+ end
41
+
34
42
  def acquire(key, _ttl)
35
43
  acquired = scalar('SELECT GET_LOCK(?, 0) AS lock_result', self.class.normalize_lock_name(key)) == 1
36
44
  log_dispatch_attempt(key) if acquired
@@ -55,16 +63,26 @@ module Kaal
55
63
 
56
64
  private
57
65
 
58
- def scalar(sql, value)
59
- result = BaseRecord.connection.exec_query(
60
- BaseRecord.send(:sanitize_sql_array, [sql, value])
61
- )
66
+ def scalar(sql, *binds)
67
+ sanitized_sql = if binds.empty?
68
+ sql
69
+ else
70
+ BaseRecord.send(:sanitize_sql_array, [sql, *binds])
71
+ end
72
+ result = BaseRecord.connection.exec_query(sanitized_sql)
62
73
  result.first.values.first
63
74
  end
64
75
 
65
76
  def resolved_namespace
66
77
  @namespace || Kaal.configuration.namespace
67
78
  end
79
+
80
+ def supports_skip_locked?
81
+ return @use_skip_locked unless @use_skip_locked.equal?(UNSET_SKIP_LOCKED_SUPPORT)
82
+
83
+ version_string = scalar('SELECT VERSION() AS version')
84
+ Kaal::DelayedJob::MySQLVersionSupport.skip_locked_supported?(version_string)
85
+ end
68
86
  end
69
87
  end
70
88
  end
@@ -32,6 +32,10 @@ module Kaal
32
32
  @definition_registry ||= DefinitionRegistry.new
33
33
  end
34
34
 
35
+ def delayed_store
36
+ @delayed_store ||= DelayedJobRegistry.new(use_skip_locked: true)
37
+ end
38
+
35
39
  def acquire(key, _ttl)
36
40
  acquired = scalar('SELECT pg_try_advisory_lock(?) AS acquired', self.class.calculate_lock_id(key)) == true
37
41
  log_dispatch_attempt(key) if acquired
@@ -8,9 +8,11 @@ require 'kaal/internal/active_record/base_record'
8
8
  require 'kaal/internal/active_record/connection_support'
9
9
  require 'kaal/internal/active_record/definition_record'
10
10
  require 'kaal/internal/active_record/dispatch_record'
11
+ require 'kaal/internal/active_record/delayed_job_record'
11
12
  require 'kaal/internal/active_record/lock_record'
12
13
  require 'kaal/internal/active_record/definition_registry'
13
14
  require 'kaal/internal/active_record/dispatch_registry'
15
+ require 'kaal/internal/active_record/delayed_job_registry'
14
16
  require 'kaal/internal/active_record/database_backend'
15
17
  require 'kaal/internal/active_record/postgres_backend'
16
18
  require 'kaal/internal/active_record/mysql_backend'
@@ -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/backend/dispatch_logging'
8
+ require 'kaal/delayed_job/database_engine'
8
9
  require 'kaal/persistence/database'
9
10
 
10
11
  module Kaal
@@ -28,6 +29,10 @@ module Kaal
28
29
  @definition_registry ||= Kaal::Definition::DatabaseEngine.new(database: @database.connection)
29
30
  end
30
31
 
32
+ def delayed_store
33
+ @delayed_store ||= Kaal::DelayedJob::DatabaseEngine.new(database: @database.connection)
34
+ end
35
+
31
36
  def acquire(key, ttl)
32
37
  now = Time.now.utc
33
38
  expires_at = now + ttl
@@ -6,6 +6,7 @@
6
6
  # LICENSE file in the root directory of this source tree.
7
7
  require 'digest'
8
8
  require 'kaal/backend/dispatch_logging'
9
+ require 'kaal/delayed_job/mysql_version_support'
9
10
  require 'kaal/persistence/database'
10
11
 
11
12
  module Kaal
@@ -16,11 +17,13 @@ module Kaal
16
17
  include Kaal::Backend::DispatchLogging
17
18
 
18
19
  MAX_LOCK_NAME_LENGTH = 64
20
+ UNSET_SKIP_LOCKED_SUPPORT = Object.new.freeze
19
21
 
20
- def initialize(database, namespace: nil)
22
+ def initialize(database, namespace: nil, use_skip_locked: UNSET_SKIP_LOCKED_SUPPORT)
21
23
  super()
22
24
  @database = Kaal::Persistence::Database.new(database)
23
25
  @namespace = namespace
26
+ @use_skip_locked = use_skip_locked
24
27
  end
25
28
 
26
29
  def dispatch_registry
@@ -31,6 +34,10 @@ module Kaal
31
34
  @definition_registry ||= Kaal::Definition::DatabaseEngine.new(database: @database.connection)
32
35
  end
33
36
 
37
+ def delayed_store
38
+ @delayed_store ||= Kaal::DelayedJob::DatabaseEngine.new(database: @database.connection, use_skip_locked: supports_skip_locked?)
39
+ end
40
+
34
41
  def acquire(key, _ttl)
35
42
  acquired = scalar('SELECT GET_LOCK(?, 0) AS lock_result', self.class.normalize_lock_name(key)) == 1
36
43
  log_dispatch_attempt(key) if acquired
@@ -63,6 +70,13 @@ module Kaal
63
70
  def resolved_namespace
64
71
  @namespace || Kaal.configuration.namespace
65
72
  end
73
+
74
+ def supports_skip_locked?
75
+ return @use_skip_locked unless @use_skip_locked.equal?(UNSET_SKIP_LOCKED_SUPPORT)
76
+
77
+ version_string = scalar('SELECT VERSION() AS version')
78
+ Kaal::DelayedJob::MySQLVersionSupport.skip_locked_supported?(version_string)
79
+ end
66
80
  end
67
81
  end
68
82
  end