kannuki 0.1.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.txt +21 -0
- data/README.md +228 -0
- data/Rakefile +8 -0
- data/docs/guide.md +377 -0
- data/lib/generators/kannuki/install_generator.rb +17 -0
- data/lib/generators/kannuki/templates/kannuki.rb +31 -0
- data/lib/kannuki/active_job_extension.rb +46 -0
- data/lib/kannuki/adapters/base.rb +51 -0
- data/lib/kannuki/adapters/mysql.rb +45 -0
- data/lib/kannuki/adapters/null.rb +44 -0
- data/lib/kannuki/adapters/postgresql.rb +82 -0
- data/lib/kannuki/adapters.rb +6 -0
- data/lib/kannuki/configuration.rb +46 -0
- data/lib/kannuki/errors.rb +36 -0
- data/lib/kannuki/instrumentation.rb +45 -0
- data/lib/kannuki/lock_key.rb +78 -0
- data/lib/kannuki/lock_manager.rb +93 -0
- data/lib/kannuki/model_extension.rb +41 -0
- data/lib/kannuki/railtie.rb +39 -0
- data/lib/kannuki/result.rb +35 -0
- data/lib/kannuki/strategies/base.rb +90 -0
- data/lib/kannuki/strategies/blocking.rb +12 -0
- data/lib/kannuki/strategies/non_blocking.rb +17 -0
- data/lib/kannuki/strategies/retry.rb +70 -0
- data/lib/kannuki/strategies.rb +6 -0
- data/lib/kannuki/testing.rb +96 -0
- data/lib/kannuki/version.rb +5 -0
- data/lib/kannuki.rb +25 -0
- metadata +102 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kannuki
|
|
4
|
+
module ActiveJobExtension
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
def with_lock(name, key: nil, **options)
|
|
9
|
+
@kannuki_config = { name: name, key: key, options: options }
|
|
10
|
+
|
|
11
|
+
around_perform do |job, block|
|
|
12
|
+
config = job.class.instance_variable_get(:@kannuki_config)
|
|
13
|
+
lock_key = LockKey.from_job(job, key: config[:key])
|
|
14
|
+
full_key = "#{config[:name]}/#{lock_key}"
|
|
15
|
+
|
|
16
|
+
result = LockManager.with_lock(full_key, **config[:options]) do
|
|
17
|
+
block.call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
raise LockNotAcquiredError, full_key if result == false && config[:options][:on_conflict] != :skip
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unique_by_lock(on_conflict: :skip, timeout: 0, **options)
|
|
25
|
+
around_perform do |job, block|
|
|
26
|
+
lock_key = "unique/#{job.class.name}/#{job.arguments.map(&:to_s).join('-')}"
|
|
27
|
+
|
|
28
|
+
result = LockManager.with_lock(lock_key, timeout: timeout, on_conflict: on_conflict, **options) do
|
|
29
|
+
block.call
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if result == false
|
|
33
|
+
case on_conflict
|
|
34
|
+
when :raise
|
|
35
|
+
raise LockNotAcquiredError, lock_key
|
|
36
|
+
when :skip
|
|
37
|
+
Kannuki.configuration.logger.info(
|
|
38
|
+
"[Kannuki] Skipping duplicate job: #{job.class.name} with args #{job.arguments}"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kannuki
|
|
4
|
+
module Adapters
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :connection
|
|
7
|
+
|
|
8
|
+
def initialize(connection)
|
|
9
|
+
@connection = connection
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def acquire_lock(key, timeout:, shared:, transaction:)
|
|
13
|
+
raise NotImplementedError, 'Subclasses must implement #acquire_lock'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def release_lock(key, transaction:)
|
|
17
|
+
raise NotImplementedError, 'Subclasses must implement #release_lock'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def lock_exists?(key)
|
|
21
|
+
raise NotImplementedError, 'Subclasses must implement #lock_exists?'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def supports_shared_locks?
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def supports_transaction_locks?
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def adapter_name
|
|
33
|
+
self.class.name.split('::').last.downcase
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def quote(value)
|
|
39
|
+
connection.quote(value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def execute(sql)
|
|
43
|
+
connection.execute(sql)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def select_value(sql)
|
|
47
|
+
connection.select_value(sql)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kannuki
|
|
4
|
+
module Adapters
|
|
5
|
+
class MySQL < Base
|
|
6
|
+
def acquire_lock(key, timeout:, shared:, transaction:)
|
|
7
|
+
raise NotSupportedError, 'MySQL does not support shared advisory locks' if shared
|
|
8
|
+
|
|
9
|
+
raise NotSupportedError, 'MySQL does not support transaction-scoped advisory locks' if transaction
|
|
10
|
+
|
|
11
|
+
string_key = key.is_a?(LockKey) ? key.normalized_key : LockKey.new(key).normalized_key
|
|
12
|
+
timeout_value = timeout || -1
|
|
13
|
+
|
|
14
|
+
result = select_value("SELECT GET_LOCK(#{quote(string_key)}, #{timeout_value})")
|
|
15
|
+
|
|
16
|
+
case result.to_i
|
|
17
|
+
when 1 then true
|
|
18
|
+
when 0 then false
|
|
19
|
+
else
|
|
20
|
+
raise LockNotAcquiredError.new(string_key), "GET_LOCK returned unexpected value: #{result}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def release_lock(key, transaction:)
|
|
25
|
+
string_key = key.is_a?(LockKey) ? key.normalized_key : LockKey.new(key).normalized_key
|
|
26
|
+
result = select_value("SELECT RELEASE_LOCK(#{quote(string_key)})")
|
|
27
|
+
result.to_i == 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def lock_exists?(key)
|
|
31
|
+
string_key = key.is_a?(LockKey) ? key.normalized_key : LockKey.new(key).normalized_key
|
|
32
|
+
result = select_value("SELECT IS_USED_LOCK(#{quote(string_key)})")
|
|
33
|
+
!result.nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def supports_shared_locks?
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def supports_transaction_locks?
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kannuki
|
|
4
|
+
module Adapters
|
|
5
|
+
class Null < Base
|
|
6
|
+
def initialize(connection = nil)
|
|
7
|
+
@connection = connection
|
|
8
|
+
@locks = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def acquire_lock(key, timeout:, shared:, transaction:)
|
|
12
|
+
string_key = key.is_a?(LockKey) ? key.normalized_key : key.to_s
|
|
13
|
+
|
|
14
|
+
return false if Kannuki::Testing.test_mode? && Kannuki::Testing.lock_held?(string_key)
|
|
15
|
+
|
|
16
|
+
@locks[string_key] = { shared: shared, transaction: transaction }
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def release_lock(key, transaction:)
|
|
21
|
+
string_key = key.is_a?(LockKey) ? key.normalized_key : key.to_s
|
|
22
|
+
@locks.delete(string_key)
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def lock_exists?(key)
|
|
27
|
+
string_key = key.is_a?(LockKey) ? key.normalized_key : key.to_s
|
|
28
|
+
@locks.key?(string_key) || (Kannuki::Testing.test_mode? && Kannuki::Testing.lock_held?(string_key))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def supports_shared_locks?
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def supports_transaction_locks?
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear!
|
|
40
|
+
@locks.clear
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kannuki
|
|
4
|
+
module Adapters
|
|
5
|
+
class PostgreSQL < Base
|
|
6
|
+
POLL_INTERVAL = 0.1
|
|
7
|
+
|
|
8
|
+
def acquire_lock(key, timeout:, shared:, transaction:)
|
|
9
|
+
numeric_key = key.is_a?(LockKey) ? key.numeric_key : LockKey.new(key).numeric_key
|
|
10
|
+
|
|
11
|
+
if transaction
|
|
12
|
+
acquire_transaction_lock(numeric_key, shared: shared)
|
|
13
|
+
else
|
|
14
|
+
acquire_session_lock(numeric_key, timeout: timeout, shared: shared)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def release_lock(key, transaction:)
|
|
19
|
+
return true if transaction
|
|
20
|
+
|
|
21
|
+
numeric_key = key.is_a?(LockKey) ? key.numeric_key : LockKey.new(key).numeric_key
|
|
22
|
+
result = select_value("SELECT pg_advisory_unlock(#{numeric_key})")
|
|
23
|
+
[true, 't'].include?(result)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def lock_exists?(key)
|
|
27
|
+
numeric_key = key.is_a?(LockKey) ? key.numeric_key : LockKey.new(key).numeric_key
|
|
28
|
+
result = select_value(<<~SQL)
|
|
29
|
+
SELECT EXISTS(
|
|
30
|
+
SELECT 1 FROM pg_locks
|
|
31
|
+
WHERE locktype = 'advisory'
|
|
32
|
+
AND classid = #{(numeric_key >> 32) & 0xFFFFFFFF}
|
|
33
|
+
AND objid = #{numeric_key & 0xFFFFFFFF}
|
|
34
|
+
)
|
|
35
|
+
SQL
|
|
36
|
+
[true, 't'].include?(result)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def supports_shared_locks?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def supports_transaction_locks?
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def acquire_session_lock(numeric_key, timeout:, shared:)
|
|
50
|
+
lock_func = shared ? 'pg_advisory_lock_shared' : 'pg_advisory_lock'
|
|
51
|
+
try_func = shared ? 'pg_try_advisory_lock_shared' : 'pg_try_advisory_lock'
|
|
52
|
+
|
|
53
|
+
if timeout.nil?
|
|
54
|
+
execute("SELECT #{lock_func}(#{numeric_key})")
|
|
55
|
+
true
|
|
56
|
+
elsif timeout.zero?
|
|
57
|
+
result = select_value("SELECT #{try_func}(#{numeric_key})")
|
|
58
|
+
[true, 't'].include?(result)
|
|
59
|
+
else
|
|
60
|
+
poll_for_lock(try_func, numeric_key, timeout)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def acquire_transaction_lock(numeric_key, shared:)
|
|
65
|
+
try_func = shared ? 'pg_try_advisory_xact_lock_shared' : 'pg_try_advisory_xact_lock'
|
|
66
|
+
result = select_value("SELECT #{try_func}(#{numeric_key})")
|
|
67
|
+
[true, 't'].include?(result)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def poll_for_lock(try_func, numeric_key, timeout)
|
|
71
|
+
deadline = Time.now + timeout
|
|
72
|
+
loop do
|
|
73
|
+
result = select_value("SELECT #{try_func}(#{numeric_key})")
|
|
74
|
+
return true if [true, 't'].include?(result)
|
|
75
|
+
return false if Time.now >= deadline
|
|
76
|
+
|
|
77
|
+
sleep(POLL_INTERVAL)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module Kannuki
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :default_timeout, :default_strategy, :key_prefix, :enable_instrumentation, :retry_attempts,
|
|
8
|
+
:retry_interval, :retry_backoff, :on_failure, :test_mode
|
|
9
|
+
attr_writer :logger
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@default_timeout = nil
|
|
13
|
+
@default_strategy = :blocking
|
|
14
|
+
@key_prefix = nil
|
|
15
|
+
@enable_instrumentation = true
|
|
16
|
+
@logger = nil
|
|
17
|
+
@retry_attempts = 3
|
|
18
|
+
@retry_interval = 0.5
|
|
19
|
+
@retry_backoff = :exponential
|
|
20
|
+
@on_failure = :return_false
|
|
21
|
+
@test_mode = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def logger
|
|
25
|
+
@logger ||= if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
26
|
+
Rails.logger
|
|
27
|
+
else
|
|
28
|
+
Logger.new($stdout)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def configuration
|
|
35
|
+
@configuration ||= Configuration.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def configure
|
|
39
|
+
yield(configuration)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reset_configuration!
|
|
43
|
+
@configuration = Configuration.new
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kannuki
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class LockNotAcquiredError < Error
|
|
7
|
+
attr_reader :lock_key, :timeout
|
|
8
|
+
|
|
9
|
+
def initialize(lock_key, timeout: nil)
|
|
10
|
+
@lock_key = lock_key
|
|
11
|
+
@timeout = timeout
|
|
12
|
+
super("Failed to acquire advisory lock: #{lock_key}" +
|
|
13
|
+
(timeout ? " (timeout: #{timeout}s)" : ''))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class LockTimeoutError < LockNotAcquiredError; end
|
|
18
|
+
|
|
19
|
+
class NotSupportedError < Error; end
|
|
20
|
+
|
|
21
|
+
class DeadlockError < Error
|
|
22
|
+
attr_reader :lock_key
|
|
23
|
+
|
|
24
|
+
def initialize(lock_key)
|
|
25
|
+
@lock_key = lock_key
|
|
26
|
+
super("Deadlock detected while acquiring lock: #{lock_key}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class NestedLockError < Error
|
|
31
|
+
def initialize(outer_lock, inner_lock)
|
|
32
|
+
super("Cannot acquire nested advisory lock '#{inner_lock}' " \
|
|
33
|
+
"while holding '#{outer_lock}' (MySQL limitation)")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/notifications'
|
|
4
|
+
|
|
5
|
+
module Kannuki
|
|
6
|
+
module Instrumentation
|
|
7
|
+
NAMESPACE = 'kannuki'
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def instrument(event, payload = {}, &block)
|
|
11
|
+
return yield unless Kannuki.configuration.enable_instrumentation
|
|
12
|
+
|
|
13
|
+
ActiveSupport::Notifications.instrument("#{event}.#{NAMESPACE}", payload, &block)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def subscribe(event, &block)
|
|
17
|
+
ActiveSupport::Notifications.subscribe("#{event}.#{NAMESPACE}", &block)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def unsubscribe(subscriber)
|
|
21
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def acquired(lock_key, adapter:, duration:)
|
|
25
|
+
instrument('acquired', lock_key: lock_key, adapter: adapter, duration: duration)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def released(lock_key, adapter:, duration:)
|
|
29
|
+
instrument('released', lock_key: lock_key, adapter: adapter, duration: duration)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def failed(lock_key, adapter:, reason:, timeout: nil)
|
|
33
|
+
instrument('failed', lock_key: lock_key, adapter: adapter, reason: reason, timeout: timeout)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def timeout(lock_key, adapter:, timeout:)
|
|
37
|
+
instrument('timeout', lock_key: lock_key, adapter: adapter, timeout: timeout)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def waiting(lock_key, adapter:)
|
|
41
|
+
instrument('waiting', lock_key: lock_key, adapter: adapter)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Kannuki
|
|
6
|
+
class LockKey
|
|
7
|
+
attr_reader :name, :normalized_key, :numeric_key
|
|
8
|
+
|
|
9
|
+
MAX_MYSQL_KEY_LENGTH = 64
|
|
10
|
+
POSTGRES_INT8_MAX = 9_223_372_036_854_775_807
|
|
11
|
+
|
|
12
|
+
def initialize(name, prefix: nil)
|
|
13
|
+
@name = name.to_s
|
|
14
|
+
@prefix = prefix || Kannuki.configuration.key_prefix
|
|
15
|
+
@normalized_key = build_normalized_key
|
|
16
|
+
@numeric_key = build_numeric_key
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
@normalized_key
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_i
|
|
24
|
+
@numeric_key
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ==(other)
|
|
28
|
+
case other
|
|
29
|
+
when LockKey
|
|
30
|
+
normalized_key == other.normalized_key
|
|
31
|
+
when String
|
|
32
|
+
normalized_key == other
|
|
33
|
+
when Integer
|
|
34
|
+
numeric_key == other
|
|
35
|
+
else
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
alias eql? ==
|
|
40
|
+
|
|
41
|
+
def hash
|
|
42
|
+
normalized_key.hash
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def from_record(record, scope: nil)
|
|
47
|
+
parts = [record.class.table_name, record.id]
|
|
48
|
+
parts.unshift(record.public_send(scope)) if scope
|
|
49
|
+
new(parts.join('/'))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def from_job(job, key: nil)
|
|
53
|
+
if key.respond_to?(:call)
|
|
54
|
+
new(key.call(job))
|
|
55
|
+
elsif key
|
|
56
|
+
new(key.to_s)
|
|
57
|
+
else
|
|
58
|
+
new("#{job.class.name}/#{job.arguments.map(&:to_s).join('-')}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def build_normalized_key
|
|
66
|
+
base = @prefix ? "#{@prefix}/#{@name}" : @name
|
|
67
|
+
if base.bytesize > MAX_MYSQL_KEY_LENGTH
|
|
68
|
+
"#{base[0, 31]}:#{Digest::MD5.hexdigest(base)}"
|
|
69
|
+
else
|
|
70
|
+
base
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_numeric_key
|
|
75
|
+
Digest::MD5.hexdigest(@normalized_key).to_i(16) % POSTGRES_INT8_MAX
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Kannuki
|
|
6
|
+
class LockManager
|
|
7
|
+
STRATEGY_MAP = {
|
|
8
|
+
blocking: Strategies::Blocking,
|
|
9
|
+
non_blocking: Strategies::NonBlocking,
|
|
10
|
+
retry: Strategies::Retry
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def with_lock(name, **options, &block)
|
|
15
|
+
lock_key = build_lock_key(name, options)
|
|
16
|
+
adapter = resolve_adapter(options[:connection])
|
|
17
|
+
strategy = build_strategy(adapter, options)
|
|
18
|
+
|
|
19
|
+
result = strategy.execute(lock_key, &block)
|
|
20
|
+
|
|
21
|
+
if result.acquired?
|
|
22
|
+
result.value
|
|
23
|
+
else
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def try_lock(name, **options, &block)
|
|
29
|
+
with_lock(name, **options.merge(timeout: 0), &block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def lock!(name, **options, &block)
|
|
33
|
+
with_lock(name, **options.merge(on_conflict: :raise), &block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def locked?(name, connection: nil)
|
|
37
|
+
lock_key = build_lock_key(name, {})
|
|
38
|
+
adapter = resolve_adapter(connection)
|
|
39
|
+
adapter.lock_exists?(lock_key)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def current_locks
|
|
43
|
+
Thread.current[:kannuki_locks] ||= Set.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def holding_lock?(name)
|
|
47
|
+
lock_key = build_lock_key(name, {})
|
|
48
|
+
current_locks.include?(lock_key.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def build_lock_key(name, options)
|
|
54
|
+
case name
|
|
55
|
+
when LockKey
|
|
56
|
+
name
|
|
57
|
+
when ActiveRecord::Base
|
|
58
|
+
LockKey.from_record(name, scope: options[:scope])
|
|
59
|
+
else
|
|
60
|
+
LockKey.new(name, prefix: options[:prefix])
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_adapter(connection)
|
|
65
|
+
return Adapters::Null.new if Kannuki.configuration.test_mode
|
|
66
|
+
|
|
67
|
+
conn = connection || ActiveRecord::Base.connection
|
|
68
|
+
adapter_name = conn.adapter_name.downcase
|
|
69
|
+
|
|
70
|
+
case adapter_name
|
|
71
|
+
when /postgresql/, /postgis/
|
|
72
|
+
Adapters::PostgreSQL.new(conn)
|
|
73
|
+
when /mysql/
|
|
74
|
+
Adapters::MySQL.new(conn)
|
|
75
|
+
else
|
|
76
|
+
Kannuki.configuration.logger.warn(
|
|
77
|
+
"[Kannuki] Unknown adapter '#{adapter_name}', falling back to Null adapter"
|
|
78
|
+
)
|
|
79
|
+
Adapters::Null.new(conn)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_strategy(adapter, options)
|
|
84
|
+
strategy_name = options.fetch(:strategy, Kannuki.configuration.default_strategy)
|
|
85
|
+
strategy_class = STRATEGY_MAP[strategy_name.to_sym]
|
|
86
|
+
|
|
87
|
+
raise ArgumentError, "Unknown strategy: #{strategy_name}" unless strategy_class
|
|
88
|
+
|
|
89
|
+
strategy_class.new(adapter, options)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kannuki
|
|
4
|
+
module ModelExtension
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
def kannuki(name, scope: nil, **options)
|
|
9
|
+
lock_name = name.to_s
|
|
10
|
+
|
|
11
|
+
define_method("with_#{lock_name}_lock") do |lock_options = {}, &block|
|
|
12
|
+
merged_options = options.merge(lock_options).merge(scope: scope)
|
|
13
|
+
lock_key = LockKey.from_record(self, scope: scope)
|
|
14
|
+
LockManager.with_lock(lock_key, **merged_options, &block)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
define_method("try_#{lock_name}_lock") do |lock_options = {}, &block|
|
|
18
|
+
merged_options = options.merge(lock_options).merge(scope: scope, timeout: 0)
|
|
19
|
+
lock_key = LockKey.from_record(self, scope: scope)
|
|
20
|
+
LockManager.with_lock(lock_key, **merged_options, &block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
define_method("#{lock_name}_lock!") do |lock_options = {}, &block|
|
|
24
|
+
merged_options = options.merge(lock_options).merge(scope: scope, on_conflict: :raise)
|
|
25
|
+
lock_key = LockKey.from_record(self, scope: scope)
|
|
26
|
+
LockManager.with_lock(lock_key, **merged_options, &block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
define_method("#{lock_name}_locked?") do
|
|
30
|
+
lock_key = LockKey.from_record(self, scope: scope)
|
|
31
|
+
LockManager.locked?(lock_key)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def with_lock(name, **options, &block)
|
|
37
|
+
lock_key = LockKey.from_record(self, scope: options[:scope])
|
|
38
|
+
LockManager.with_lock("#{lock_key}/#{name}", **options, &block)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kannuki
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer 'kannuki.configure_rails_initialization' do
|
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
|
7
|
+
include Kannuki::ModelExtension
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
ActiveSupport.on_load(:active_job) do
|
|
11
|
+
include Kannuki::ActiveJobExtension
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer 'kannuki.log_subscriber' do
|
|
16
|
+
if Kannuki.configuration.enable_instrumentation
|
|
17
|
+
ActiveSupport::Notifications.subscribe(/\.kannuki$/) do |name, start, finish, _id, payload|
|
|
18
|
+
event = name.sub('.kannuki', '')
|
|
19
|
+
duration = ((finish - start) * 1000).round(2)
|
|
20
|
+
|
|
21
|
+
message = case event
|
|
22
|
+
when 'acquired'
|
|
23
|
+
"Acquired lock: #{payload[:lock_key]} (#{duration}ms)"
|
|
24
|
+
when 'released'
|
|
25
|
+
"Released lock: #{payload[:lock_key]} (#{duration}ms)"
|
|
26
|
+
when 'failed'
|
|
27
|
+
"Failed to acquire lock: #{payload[:lock_key]} (reason: #{payload[:reason]})"
|
|
28
|
+
when 'timeout'
|
|
29
|
+
"Lock timeout: #{payload[:lock_key]} (timeout: #{payload[:timeout]}s)"
|
|
30
|
+
when 'waiting'
|
|
31
|
+
"Waiting for lock: #{payload[:lock_key]}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Kannuki.configuration.logger.debug("[Kannuki] #{message}") if message
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kannuki
|
|
4
|
+
class Result
|
|
5
|
+
attr_reader :lock_key, :value, :duration, :adapter_name
|
|
6
|
+
|
|
7
|
+
def initialize(lock_key:, acquired:, value: nil, duration: nil, adapter_name: nil)
|
|
8
|
+
@lock_key = lock_key
|
|
9
|
+
@acquired = acquired
|
|
10
|
+
@value = value
|
|
11
|
+
@duration = duration
|
|
12
|
+
@adapter_name = adapter_name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def acquired?
|
|
16
|
+
@acquired
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def success?
|
|
20
|
+
acquired?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def failed?
|
|
24
|
+
!acquired?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
if acquired?
|
|
29
|
+
"Lock acquired: #{lock_key} (#{duration&.round(3)}s)"
|
|
30
|
+
else
|
|
31
|
+
"Lock not acquired: #{lock_key}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|