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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +340 -0
  4. data/Rakefile +6 -0
  5. data/app/models/kaal/cron_definition.rb +71 -0
  6. data/app/models/kaal/cron_dispatch.rb +50 -0
  7. data/app/models/kaal/cron_lock.rb +38 -0
  8. data/config/locales/en.yml +46 -0
  9. data/lib/generators/kaal/install/install_generator.rb +67 -0
  10. data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +21 -0
  11. data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +20 -0
  12. data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +17 -0
  13. data/lib/generators/kaal/install/templates/kaal.rb.tt +31 -0
  14. data/lib/generators/kaal/install/templates/scheduler.yml.tt +22 -0
  15. data/lib/kaal/backend/adapter.rb +147 -0
  16. data/lib/kaal/backend/dispatch_logging.rb +79 -0
  17. data/lib/kaal/backend/memory_adapter.rb +99 -0
  18. data/lib/kaal/backend/mysql_adapter.rb +170 -0
  19. data/lib/kaal/backend/postgres_adapter.rb +134 -0
  20. data/lib/kaal/backend/redis_adapter.rb +145 -0
  21. data/lib/kaal/backend/sqlite_adapter.rb +116 -0
  22. data/lib/kaal/configuration.rb +231 -0
  23. data/lib/kaal/coordinator.rb +437 -0
  24. data/lib/kaal/cron_humanizer.rb +182 -0
  25. data/lib/kaal/cron_utils.rb +233 -0
  26. data/lib/kaal/definition/database_engine.rb +45 -0
  27. data/lib/kaal/definition/memory_engine.rb +61 -0
  28. data/lib/kaal/definition/redis_engine.rb +93 -0
  29. data/lib/kaal/definition/registry.rb +46 -0
  30. data/lib/kaal/dispatch/database_engine.rb +94 -0
  31. data/lib/kaal/dispatch/memory_engine.rb +99 -0
  32. data/lib/kaal/dispatch/redis_engine.rb +103 -0
  33. data/lib/kaal/dispatch/registry.rb +62 -0
  34. data/lib/kaal/idempotency_key_generator.rb +26 -0
  35. data/lib/kaal/railtie.rb +183 -0
  36. data/lib/kaal/rake_tasks.rb +184 -0
  37. data/lib/kaal/register_conflict_support.rb +54 -0
  38. data/lib/kaal/registry.rb +242 -0
  39. data/lib/kaal/scheduler_config_error.rb +6 -0
  40. data/lib/kaal/scheduler_file_loader.rb +316 -0
  41. data/lib/kaal/scheduler_hash_transform.rb +40 -0
  42. data/lib/kaal/scheduler_placeholder_support.rb +80 -0
  43. data/lib/kaal/version.rb +10 -0
  44. data/lib/kaal.rb +571 -0
  45. data/lib/tasks/kaal_tasks.rake +10 -0
  46. 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