kaal 0.2.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +340 -0
- data/Rakefile +6 -0
- data/app/models/kaal/cron_definition.rb +71 -0
- data/app/models/kaal/cron_dispatch.rb +50 -0
- data/app/models/kaal/cron_lock.rb +38 -0
- data/config/locales/en.yml +46 -0
- data/lib/generators/kaal/install/install_generator.rb +67 -0
- data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +21 -0
- data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +20 -0
- data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +17 -0
- data/lib/generators/kaal/install/templates/kaal.rb.tt +31 -0
- data/lib/generators/kaal/install/templates/scheduler.yml.tt +22 -0
- data/lib/kaal/backend/adapter.rb +147 -0
- data/lib/kaal/backend/dispatch_logging.rb +79 -0
- data/lib/kaal/backend/memory_adapter.rb +99 -0
- data/lib/kaal/backend/mysql_adapter.rb +170 -0
- data/lib/kaal/backend/postgres_adapter.rb +134 -0
- data/lib/kaal/backend/redis_adapter.rb +145 -0
- data/lib/kaal/backend/sqlite_adapter.rb +116 -0
- data/lib/kaal/configuration.rb +231 -0
- data/lib/kaal/coordinator.rb +437 -0
- data/lib/kaal/cron_humanizer.rb +182 -0
- data/lib/kaal/cron_utils.rb +233 -0
- data/lib/kaal/definition/database_engine.rb +45 -0
- data/lib/kaal/definition/memory_engine.rb +61 -0
- data/lib/kaal/definition/redis_engine.rb +93 -0
- data/lib/kaal/definition/registry.rb +46 -0
- data/lib/kaal/dispatch/database_engine.rb +94 -0
- data/lib/kaal/dispatch/memory_engine.rb +99 -0
- data/lib/kaal/dispatch/redis_engine.rb +103 -0
- data/lib/kaal/dispatch/registry.rb +62 -0
- data/lib/kaal/idempotency_key_generator.rb +26 -0
- data/lib/kaal/railtie.rb +183 -0
- data/lib/kaal/rake_tasks.rb +184 -0
- data/lib/kaal/register_conflict_support.rb +54 -0
- data/lib/kaal/registry.rb +242 -0
- data/lib/kaal/scheduler_config_error.rb +6 -0
- data/lib/kaal/scheduler_file_loader.rb +316 -0
- data/lib/kaal/scheduler_hash_transform.rb +40 -0
- data/lib/kaal/scheduler_placeholder_support.rb +80 -0
- data/lib/kaal/version.rb +10 -0
- data/lib/kaal.rb +571 -0
- data/lib/tasks/kaal_tasks.rake +10 -0
- metadata +142 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Creates the lock table for database-backed lease coordination.
|
|
4
|
+
class CreateKaalLocks < ActiveRecord::Migration[7.0]
|
|
5
|
+
def change
|
|
6
|
+
create_table :kaal_locks do |t|
|
|
7
|
+
t.string :key, null: false, limit: 255
|
|
8
|
+
t.datetime :acquired_at, null: false
|
|
9
|
+
t.datetime :expires_at, null: false
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :kaal_locks, :key, unique: true
|
|
15
|
+
add_index :kaal_locks, :expires_at
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Kaal.configure do |config|
|
|
4
|
+
# Select the backend that matches your deployment.
|
|
5
|
+
# See the Kaal documentation for backend-specific setup and the full
|
|
6
|
+
# configuration reference.
|
|
7
|
+
#
|
|
8
|
+
# Redis (recommended for multi-node deployments):
|
|
9
|
+
# config.backend = Kaal::Backend::RedisAdapter.new(Redis.new(url: ENV.fetch('REDIS_URL')))
|
|
10
|
+
#
|
|
11
|
+
# PostgreSQL advisory locks:
|
|
12
|
+
# config.backend = Kaal::Backend::PostgresAdapter.new
|
|
13
|
+
#
|
|
14
|
+
# MySQL / SQLite database-backed coordination:
|
|
15
|
+
# config.backend = Kaal::Backend::SQLiteAdapter.new
|
|
16
|
+
|
|
17
|
+
config.tick_interval = 5
|
|
18
|
+
config.window_lookback = 120
|
|
19
|
+
# Keep lease_ttl >= window_lookback + tick_interval to prevent duplicate dispatch.
|
|
20
|
+
config.lease_ttl = 125
|
|
21
|
+
config.recovery_window = 3600
|
|
22
|
+
config.enable_dispatch_recovery = true
|
|
23
|
+
config.enable_log_dispatch_registry = false
|
|
24
|
+
|
|
25
|
+
# Scheduler file loading
|
|
26
|
+
config.scheduler_config_path = 'config/scheduler.yml'
|
|
27
|
+
# :error, :code_wins, :file_wins
|
|
28
|
+
config.scheduler_conflict_policy = :error
|
|
29
|
+
# :warn, :error
|
|
30
|
+
config.scheduler_missing_file_policy = :warn
|
|
31
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
defaults:
|
|
2
|
+
jobs:
|
|
3
|
+
- key: "reports:weekly_summary"
|
|
4
|
+
cron: "0 9 * * 1"
|
|
5
|
+
job_class: "WeeklySummaryJob"
|
|
6
|
+
enabled: true
|
|
7
|
+
queue: "default"
|
|
8
|
+
args:
|
|
9
|
+
- "{{fire_time.iso8601}}"
|
|
10
|
+
kwargs:
|
|
11
|
+
idempotency_key: "{{idempotency_key}}"
|
|
12
|
+
metadata:
|
|
13
|
+
owner: "ops"
|
|
14
|
+
|
|
15
|
+
development:
|
|
16
|
+
jobs: []
|
|
17
|
+
|
|
18
|
+
test:
|
|
19
|
+
jobs: []
|
|
20
|
+
|
|
21
|
+
production:
|
|
22
|
+
jobs: []
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
|
|
8
|
+
module Kaal
|
|
9
|
+
module Backend
|
|
10
|
+
##
|
|
11
|
+
# Abstract base class for distributed backend adapters.
|
|
12
|
+
#
|
|
13
|
+
# Backend adapters are responsible for distributed coordination (acquire/release)
|
|
14
|
+
# and may also expose cron definition and dispatch registries for persistence.
|
|
15
|
+
#
|
|
16
|
+
# @example Implementing a backend adapter
|
|
17
|
+
# class MyBackendAdapter < Kaal::Backend::Adapter
|
|
18
|
+
# def acquire(key, ttl)
|
|
19
|
+
# # Try to acquire a lock with key and TTL
|
|
20
|
+
# # Return true if acquired, false otherwise
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# def release(key)
|
|
24
|
+
# # Release the lock for key
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
class Adapter
|
|
28
|
+
##
|
|
29
|
+
# Attempt to acquire a distributed lock.
|
|
30
|
+
#
|
|
31
|
+
# @param key [String] the lock key (e.g., "namespace:cron:key:timestamp")
|
|
32
|
+
# @param ttl [Integer] time-to-live in seconds before the lock auto-expires
|
|
33
|
+
# @return [Boolean] true if lock was acquired, false if already held by another process
|
|
34
|
+
#
|
|
35
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# adapter = MyBackendAdapter.new
|
|
39
|
+
# acquired = adapter.acquire("kaal:job1:1234567890", 60)
|
|
40
|
+
# if acquired
|
|
41
|
+
# # Do work...
|
|
42
|
+
# end
|
|
43
|
+
def acquire(_key, _ttl)
|
|
44
|
+
raise NotImplementedError, 'Subclasses must implement #acquire'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# Release a previously acquired lock.
|
|
49
|
+
#
|
|
50
|
+
# @param key [String] the lock key to release
|
|
51
|
+
# @return [Boolean] true if released, false if not held
|
|
52
|
+
#
|
|
53
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# adapter.release("kaal:job1:1234567890")
|
|
57
|
+
def release(_key)
|
|
58
|
+
raise NotImplementedError, 'Subclasses must implement #release'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
##
|
|
62
|
+
# Acquire a lock, execute the block, then release the lock.
|
|
63
|
+
#
|
|
64
|
+
# This is a convenience method that ensures the lock is properly released
|
|
65
|
+
# even if the block raises an exception. If the lock cannot be acquired,
|
|
66
|
+
# returns nil without executing the block.
|
|
67
|
+
#
|
|
68
|
+
# @param key [String] the lock key
|
|
69
|
+
# @param ttl [Integer] time-to-live in seconds
|
|
70
|
+
# @yield executes the block if lock is acquired
|
|
71
|
+
# @return [Object] the result of the block if executed, nil if lock not acquired
|
|
72
|
+
#
|
|
73
|
+
# @example
|
|
74
|
+
# result = adapter.with_lock("kaal:job1:1234567890", ttl: 60) do
|
|
75
|
+
# # Do protected work
|
|
76
|
+
# 42
|
|
77
|
+
# end
|
|
78
|
+
# # result is 42 if lock acquired, nil otherwise
|
|
79
|
+
def with_lock(key, ttl:)
|
|
80
|
+
return nil unless acquire(key, ttl)
|
|
81
|
+
|
|
82
|
+
begin
|
|
83
|
+
yield
|
|
84
|
+
ensure
|
|
85
|
+
release(key)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
##
|
|
90
|
+
# Optional definition registry for persistent cron definitions.
|
|
91
|
+
#
|
|
92
|
+
# Backends may override this to provide a concrete implementation.
|
|
93
|
+
#
|
|
94
|
+
# @return [Kaal::Definition::Registry, nil]
|
|
95
|
+
def definition_registry
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
##
|
|
101
|
+
# Null backend adapter that always succeeds (useful for development/testing).
|
|
102
|
+
#
|
|
103
|
+
# This adapter provides a no-op implementation: it always returns true
|
|
104
|
+
# for acquire and does nothing on release. Use this when you want to run
|
|
105
|
+
# the scheduler without distributed coordination (e.g., single-node development).
|
|
106
|
+
#
|
|
107
|
+
# @example Using the null adapter
|
|
108
|
+
# Kaal.configure do |config|
|
|
109
|
+
# config.backend = Kaal::Backend::NullAdapter.new
|
|
110
|
+
# end
|
|
111
|
+
class NullAdapter < Adapter
|
|
112
|
+
##
|
|
113
|
+
# Always returns true (lock always "acquired").
|
|
114
|
+
#
|
|
115
|
+
# @param _key [String] unused
|
|
116
|
+
# @param _ttl [Integer] unused
|
|
117
|
+
# @return [Boolean] always true
|
|
118
|
+
def acquire(_key, _ttl) # rubocop:disable Naming/PredicateMethod
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
##
|
|
123
|
+
# No-op implementation (nothing to release).
|
|
124
|
+
#
|
|
125
|
+
# @param _key [String] unused
|
|
126
|
+
# @return [Boolean] always true
|
|
127
|
+
def release(_key) # rubocop:disable Naming/PredicateMethod
|
|
128
|
+
true
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
##
|
|
132
|
+
# Execute the block without any actual locking (always succeeds).
|
|
133
|
+
#
|
|
134
|
+
# @param key [String] unused
|
|
135
|
+
# @param ttl [Integer] unused
|
|
136
|
+
# @yield executes the block immediately
|
|
137
|
+
# @return [Object] the result of the block
|
|
138
|
+
def with_lock(_key, **)
|
|
139
|
+
yield
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
##
|
|
144
|
+
# Error raised when a backend adapter operation fails.
|
|
145
|
+
class LockAdapterError < StandardError; end
|
|
146
|
+
end
|
|
147
|
+
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
|
+
|
|
8
|
+
require 'socket'
|
|
9
|
+
|
|
10
|
+
module Kaal
|
|
11
|
+
module Backend
|
|
12
|
+
##
|
|
13
|
+
# Shared module for dispatch logging across backend adapters.
|
|
14
|
+
#
|
|
15
|
+
# Provides methods to log cron job dispatch attempts via the dispatch registry
|
|
16
|
+
# for audit and observability purposes. Adapters that support dispatch logging
|
|
17
|
+
# should include this module and implement a dispatch_registry method.
|
|
18
|
+
#
|
|
19
|
+
# @example Implementing in an adapter
|
|
20
|
+
# class MyAdapter < Adapter
|
|
21
|
+
# include DispatchLogging
|
|
22
|
+
#
|
|
23
|
+
# def dispatch_registry
|
|
24
|
+
# @dispatch_registry ||= Kaal::Dispatch::MemoryEngine.new
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
module DispatchLogging
|
|
28
|
+
##
|
|
29
|
+
# Log a dispatch attempt via the dispatch registry.
|
|
30
|
+
#
|
|
31
|
+
# Only logs if Kaal.configuration.enable_log_dispatch_registry is true.
|
|
32
|
+
#
|
|
33
|
+
# @param key [String] the lock key (format: "namespace:dispatch:cron_key:fire_time")
|
|
34
|
+
# @return [void]
|
|
35
|
+
def log_dispatch_attempt(key)
|
|
36
|
+
logger = nil
|
|
37
|
+
logging_enabled = Kaal.configuration.then do |configuration|
|
|
38
|
+
logger = configuration.logger
|
|
39
|
+
configuration.enable_log_dispatch_registry
|
|
40
|
+
end
|
|
41
|
+
return unless logging_enabled
|
|
42
|
+
return unless respond_to?(:dispatch_registry)
|
|
43
|
+
|
|
44
|
+
registry = dispatch_registry
|
|
45
|
+
return unless registry
|
|
46
|
+
|
|
47
|
+
cron_key, fire_time = parse_lock_key(key)
|
|
48
|
+
node_id = Socket.gethostname
|
|
49
|
+
|
|
50
|
+
registry.log_dispatch(cron_key, fire_time, node_id, 'dispatched')
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
logger&.error("Failed to log dispatch for #{key}: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
##
|
|
56
|
+
# Parse a lock key to extract cron job key and fire time.
|
|
57
|
+
#
|
|
58
|
+
# Lock key format: "namespace:dispatch:cron_key:fire_time"
|
|
59
|
+
# Parses by splitting on colon: removes namespace and "dispatch", then
|
|
60
|
+
# rejoins remaining parts as the cron key.
|
|
61
|
+
#
|
|
62
|
+
# @param key [String] the lock key to parse
|
|
63
|
+
# @return [Array<String, Time>] tuple of [cron_key, fire_time]
|
|
64
|
+
def parse_lock_key(key)
|
|
65
|
+
DispatchLogging.parse_lock_key(key)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.parse_lock_key(key)
|
|
69
|
+
parts = key.split(':')
|
|
70
|
+
fire_time_unix = parts.pop.to_i
|
|
71
|
+
2.times { parts.shift } # Remove namespace and "dispatch"
|
|
72
|
+
cron_key = parts.join(':')
|
|
73
|
+
fire_time = Time.at(fire_time_unix)
|
|
74
|
+
|
|
75
|
+
[cron_key, fire_time]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
|
|
8
|
+
require_relative 'dispatch_logging'
|
|
9
|
+
require_relative '../definition/memory_engine'
|
|
10
|
+
|
|
11
|
+
module Kaal
|
|
12
|
+
module Backend
|
|
13
|
+
##
|
|
14
|
+
# In-memory backend adapter using Mutex and Hash.
|
|
15
|
+
#
|
|
16
|
+
# This adapter stores locks in memory with TTL tracking. Locks are stored
|
|
17
|
+
# with an expiration time and automatically considered released if the TTL
|
|
18
|
+
# has passed.
|
|
19
|
+
#
|
|
20
|
+
# **IMPORTANT**: This adapter is suitable only for single-node deployments
|
|
21
|
+
# (development, testing). For multi-node production systems, use Redis or
|
|
22
|
+
# PostgreSQL adapters instead.
|
|
23
|
+
#
|
|
24
|
+
# @example Using the memory adapter
|
|
25
|
+
# Kaal.configure do |config|
|
|
26
|
+
# config.backend = Kaal::Backend::MemoryAdapter.new
|
|
27
|
+
# config.enable_log_dispatch_registry = true # Enable dispatch logging
|
|
28
|
+
# end
|
|
29
|
+
class MemoryAdapter < Adapter
|
|
30
|
+
include DispatchLogging
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
super
|
|
34
|
+
@locks = {}
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# Get the dispatch registry for in-memory logging.
|
|
40
|
+
#
|
|
41
|
+
# @return [Kaal::Dispatch::MemoryEngine] memory engine instance
|
|
42
|
+
def dispatch_registry
|
|
43
|
+
@dispatch_registry ||= Kaal::Dispatch::MemoryEngine.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
# Get the definition registry for in-memory definition persistence.
|
|
48
|
+
#
|
|
49
|
+
# @return [Kaal::Definition::MemoryEngine] memory engine instance
|
|
50
|
+
def definition_registry
|
|
51
|
+
@definition_registry ||= Kaal::Definition::MemoryEngine.new
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# Attempt to acquire a lock in memory.
|
|
56
|
+
#
|
|
57
|
+
# Opportunistically prunes expired locks to prevent unbounded memory growth.
|
|
58
|
+
# Since the coordinator generates unique keys per dispatch and relies on TTL
|
|
59
|
+
# expiration without calling release, this pruning is essential.
|
|
60
|
+
#
|
|
61
|
+
# @param key [String] the lock key
|
|
62
|
+
# @param ttl [Integer] time-to-live in seconds
|
|
63
|
+
# @return [Boolean] true if acquired (key was free or expired), false if held by another process
|
|
64
|
+
def acquire(key, ttl)
|
|
65
|
+
acquired = @mutex.synchronize do
|
|
66
|
+
prune_expired_locks
|
|
67
|
+
expiration_time = @locks[key]
|
|
68
|
+
current_time = Time.current
|
|
69
|
+
next false if expiration_time && expiration_time > current_time
|
|
70
|
+
|
|
71
|
+
@locks[key] = current_time + ttl.seconds
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
log_dispatch_attempt(key) if acquired
|
|
76
|
+
|
|
77
|
+
acquired
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
##
|
|
81
|
+
# Release a lock from memory.
|
|
82
|
+
#
|
|
83
|
+
# @param key [String] the lock key to release
|
|
84
|
+
# @return [Boolean] true if released (key was held), false if not held
|
|
85
|
+
def release(key)
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
@locks.delete(key).present?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def prune_expired_locks
|
|
94
|
+
now = Time.current
|
|
95
|
+
@locks.delete_if { |_key, expiration_time| expiration_time <= now }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
|
|
8
|
+
require 'socket'
|
|
9
|
+
require 'digest'
|
|
10
|
+
require_relative 'dispatch_logging'
|
|
11
|
+
require_relative '../definition/database_engine'
|
|
12
|
+
|
|
13
|
+
module Kaal
|
|
14
|
+
module Backend
|
|
15
|
+
##
|
|
16
|
+
# Distributed backend adapter using MySQL named locks (GET_LOCK/RELEASE_LOCK).
|
|
17
|
+
#
|
|
18
|
+
# This adapter uses MySQL's GET_LOCK and RELEASE_LOCK functions for
|
|
19
|
+
# distributed locking across multiple nodes. Locks are connection-based
|
|
20
|
+
# and automatically released when the database connection is closed.
|
|
21
|
+
#
|
|
22
|
+
# **IMPORTANT LIMITATIONS:**
|
|
23
|
+
# - Locks are connection-scoped: if a process crashes, the lock persists until
|
|
24
|
+
# the database connection timeout occurs (typically 28,800 seconds or 8 hours).
|
|
25
|
+
# For critical systems, consider monitoring stale locks or using a time-based
|
|
26
|
+
# fallback mechanism.
|
|
27
|
+
# - MySQL named locks have a maximum length of 64 characters. Lock keys longer than
|
|
28
|
+
# 64 characters use a deterministic hash-based shortening scheme (prefix + SHA256
|
|
29
|
+
# digest) to avoid collisions while respecting the limit.
|
|
30
|
+
# - Uses non-blocking acquisition: GET_LOCK is called with timeout=0 for immediate
|
|
31
|
+
# return (does not block waiting for the lock).
|
|
32
|
+
# - Ensure connection pooling is properly configured to release connections
|
|
33
|
+
# promptly when processes terminate.
|
|
34
|
+
#
|
|
35
|
+
# Optionally logs all dispatch attempts to the database when
|
|
36
|
+
# enable_log_dispatch_registry is enabled in configuration.
|
|
37
|
+
#
|
|
38
|
+
# @example Using the MySQL adapter
|
|
39
|
+
# Kaal.configure do |config|
|
|
40
|
+
# config.backend = Kaal::Backend::MySQLAdapter.new
|
|
41
|
+
# config.enable_log_dispatch_registry = true # Enable dispatch logging
|
|
42
|
+
# end
|
|
43
|
+
class MySQLAdapter < Adapter
|
|
44
|
+
include DispatchLogging
|
|
45
|
+
|
|
46
|
+
# MySQL named locks have a maximum length of 64 characters
|
|
47
|
+
MAX_LOCK_NAME_LENGTH = 64
|
|
48
|
+
|
|
49
|
+
def initialize
|
|
50
|
+
super
|
|
51
|
+
@lock_name_length_limit = MAX_LOCK_NAME_LENGTH
|
|
52
|
+
@false_value_pattern = /\A(0|f|false|)\z/i
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
##
|
|
56
|
+
# Initialize a new MySQL adapter.
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# Get the dispatch registry for database logging.
|
|
60
|
+
#
|
|
61
|
+
# @return [Kaal::Dispatch::DatabaseEngine] database engine instance
|
|
62
|
+
def dispatch_registry
|
|
63
|
+
@dispatch_registry ||= Kaal::Dispatch::DatabaseEngine.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# Get the definition registry for database-backed definition persistence.
|
|
68
|
+
#
|
|
69
|
+
# @return [Kaal::Definition::DatabaseEngine] database definition engine instance
|
|
70
|
+
def definition_registry
|
|
71
|
+
@definition_registry ||= Kaal::Definition::DatabaseEngine.new
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
##
|
|
75
|
+
# Attempt to acquire a distributed lock using MySQL GET_LOCK.
|
|
76
|
+
#
|
|
77
|
+
# Uses MySQL's GET_LOCK(name, timeout) function with a timeout of 0 seconds
|
|
78
|
+
# to perform non-blocking acquisition. If successful, logs the dispatch
|
|
79
|
+
# attempt when enable_log_dispatch_registry is enabled.
|
|
80
|
+
#
|
|
81
|
+
# **Note:** The +ttl+ parameter is ignored. MySQL named locks are connection-based
|
|
82
|
+
# and do not have automatic expiration. The lock will be held until explicitly
|
|
83
|
+
# released or the database connection is closed. See class documentation for
|
|
84
|
+
# limitations.
|
|
85
|
+
#
|
|
86
|
+
# @param key [String] the lock key (format: "namespace:dispatch:cron_key:fire_time")
|
|
87
|
+
# @param ttl [Integer] time-to-live in seconds (ignored; see class docs)
|
|
88
|
+
# @return [Boolean] true if acquired, false if held by another process
|
|
89
|
+
def acquire(key, _ttl)
|
|
90
|
+
lock_name = normalize_lock_name(key)
|
|
91
|
+
|
|
92
|
+
# GET_LOCK returns 1 on success, 0 on timeout, NULL on error
|
|
93
|
+
sql = ActiveRecord::Base.sanitize_sql_array(['SELECT GET_LOCK(?, 0) as lock_result', lock_name])
|
|
94
|
+
result_set = ActiveRecord::Base.connection.execute(sql)
|
|
95
|
+
# Convert result to array and get first row, then first column value
|
|
96
|
+
result_row = result_set.to_a.first
|
|
97
|
+
result_value = result_row.is_a?(Hash) ? result_row['lock_result'] : result_row&.first
|
|
98
|
+
acquired = cast_to_boolean(result_value)
|
|
99
|
+
|
|
100
|
+
log_dispatch_attempt(key) if acquired
|
|
101
|
+
|
|
102
|
+
acquired
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
raise LockAdapterError, "MySQL acquire failed for #{key}: #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# Release a distributed lock held by MySQL GET_LOCK.
|
|
109
|
+
#
|
|
110
|
+
# @param key [String] the lock key
|
|
111
|
+
# @return [Boolean] true if released, false if not held
|
|
112
|
+
def release(key)
|
|
113
|
+
lock_name = normalize_lock_name(key)
|
|
114
|
+
|
|
115
|
+
# RELEASE_LOCK returns 1 if held and released, 0 if not held, NULL on error
|
|
116
|
+
sql = ActiveRecord::Base.sanitize_sql_array(['SELECT RELEASE_LOCK(?) as lock_result', lock_name])
|
|
117
|
+
result_set = ActiveRecord::Base.connection.execute(sql)
|
|
118
|
+
# Convert result to array and get first row, then first column value
|
|
119
|
+
result_row = result_set.to_a.first
|
|
120
|
+
result_value = result_row.is_a?(Hash) ? result_row['lock_result'] : result_row&.first
|
|
121
|
+
cast_to_boolean(result_value)
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
raise LockAdapterError, "MySQL release failed for #{key}: #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def cast_to_boolean(value)
|
|
129
|
+
# MySQL GET_LOCK/RELEASE_LOCK returns 1 (success), 0 (failure), or NULL (error).
|
|
130
|
+
# Cast integer/nil to boolean: 1 => true, 0 or nil => false.
|
|
131
|
+
case value
|
|
132
|
+
when 1
|
|
133
|
+
true
|
|
134
|
+
when 0
|
|
135
|
+
false
|
|
136
|
+
when true, false
|
|
137
|
+
value
|
|
138
|
+
else
|
|
139
|
+
!value.to_s.match?(@false_value_pattern)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
##
|
|
144
|
+
# Normalize lock names to fit MySQL's 64-character limit.
|
|
145
|
+
#
|
|
146
|
+
# For keys exceeding the limit, uses a deterministic hash-based scheme
|
|
147
|
+
# (prefix + SHA256 digest) to avoid collisions.
|
|
148
|
+
#
|
|
149
|
+
# @param key [String] the lock key to normalize
|
|
150
|
+
# @return [String] normalized key (max 64 characters)
|
|
151
|
+
def normalize_lock_name(key)
|
|
152
|
+
return key if key.length <= @lock_name_length_limit
|
|
153
|
+
|
|
154
|
+
# Use SHA256 digest to ensure uniqueness while respecting the 64-char limit.
|
|
155
|
+
# Format: "prefix:hash" where hash is first 16 hex chars (~8 bytes entropy)
|
|
156
|
+
digest = Digest::SHA256.hexdigest(key)
|
|
157
|
+
# Reserve 17 chars for `:` + 16 hex chars, use remainder for prefix
|
|
158
|
+
prefix_length = @lock_name_length_limit - 17
|
|
159
|
+
normalized = "#{key[0...prefix_length]}:#{digest[0...16]}"
|
|
160
|
+
|
|
161
|
+
Kaal.logger&.warn(
|
|
162
|
+
"Lock key '#{key}' exceeds MySQL named lock limit of #{@lock_name_length_limit} characters. " \
|
|
163
|
+
"Using hash-based shortening to avoid collisions: '#{normalized}'."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
normalized
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|