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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'adapters/base'
4
+ require_relative 'adapters/postgresql'
5
+ require_relative 'adapters/mysql'
6
+ require_relative 'adapters/null'
@@ -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