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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kannuki
4
+ module Strategies
5
+ class Base
6
+ attr_reader :adapter, :options
7
+
8
+ def initialize(adapter, options = {})
9
+ @adapter = adapter
10
+ @options = options
11
+ end
12
+
13
+ def execute(lock_key, &block)
14
+ raise NotImplementedError, 'Subclasses must implement #execute'
15
+ end
16
+
17
+ protected
18
+
19
+ def timeout
20
+ options.fetch(:timeout, Kannuki.configuration.default_timeout)
21
+ end
22
+
23
+ def shared?
24
+ options.fetch(:shared, false)
25
+ end
26
+
27
+ def transaction?
28
+ options.fetch(:transaction, false)
29
+ end
30
+
31
+ def on_conflict
32
+ options.fetch(:on_conflict, :wait)
33
+ end
34
+
35
+ def acquire_lock(lock_key)
36
+ adapter.acquire_lock(lock_key, timeout: timeout, shared: shared?, transaction: transaction?)
37
+ end
38
+
39
+ def release_lock(lock_key)
40
+ adapter.release_lock(lock_key, transaction: transaction?)
41
+ end
42
+
43
+ def track_lock(lock_key)
44
+ Thread.current[:kannuki_locks] ||= Set.new
45
+ Thread.current[:kannuki_locks] << lock_key.to_s
46
+ end
47
+
48
+ def untrack_lock(lock_key)
49
+ Thread.current[:kannuki_locks]&.delete(lock_key.to_s)
50
+ end
51
+
52
+ def execute_with_lock(lock_key)
53
+ start_time = Time.now
54
+ acquired = acquire_lock(lock_key)
55
+
56
+ if acquired
57
+ duration = Time.now - start_time
58
+ Instrumentation.acquired(lock_key, adapter: adapter.adapter_name, duration: duration)
59
+ track_lock(lock_key)
60
+
61
+ begin
62
+ result = yield
63
+ Result.new(lock_key: lock_key, acquired: true, value: result, duration: duration,
64
+ adapter_name: adapter.adapter_name)
65
+ ensure
66
+ release_start = Time.now
67
+ release_lock(lock_key)
68
+ untrack_lock(lock_key)
69
+ Instrumentation.released(lock_key, adapter: adapter.adapter_name, duration: Time.now - release_start)
70
+ end
71
+ else
72
+ handle_lock_failure(lock_key)
73
+ end
74
+ end
75
+
76
+ def handle_lock_failure(lock_key)
77
+ Instrumentation.failed(lock_key, adapter: adapter.adapter_name, reason: :not_acquired, timeout: timeout)
78
+
79
+ case on_conflict
80
+ when :raise
81
+ raise LockNotAcquiredError.new(lock_key.to_s, timeout: timeout)
82
+ when :skip
83
+ Result.new(lock_key: lock_key, acquired: false)
84
+ else
85
+ Result.new(lock_key: lock_key, acquired: false)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kannuki
4
+ module Strategies
5
+ class Blocking < Base
6
+ def execute(lock_key, &block)
7
+ Instrumentation.waiting(lock_key, adapter: adapter.adapter_name) if timeout.nil?
8
+ execute_with_lock(lock_key, &block)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kannuki
4
+ module Strategies
5
+ class NonBlocking < Base
6
+ def execute(lock_key, &block)
7
+ execute_with_lock(lock_key, &block)
8
+ end
9
+
10
+ protected
11
+
12
+ def timeout
13
+ 0
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kannuki
4
+ module Strategies
5
+ class Retry < Base
6
+ def execute(lock_key, &block)
7
+ attempts = 0
8
+ max_attempts = retry_attempts
9
+
10
+ loop do
11
+ attempts += 1
12
+ result = try_acquire_and_execute(lock_key, &block)
13
+ return result if result.acquired?
14
+ return result if attempts >= max_attempts
15
+
16
+ sleep(calculate_interval(attempts))
17
+ end
18
+ end
19
+
20
+ protected
21
+
22
+ def retry_attempts
23
+ options.fetch(:retry_attempts, Kannuki.configuration.retry_attempts)
24
+ end
25
+
26
+ def retry_interval
27
+ options.fetch(:retry_interval, Kannuki.configuration.retry_interval)
28
+ end
29
+
30
+ def retry_backoff
31
+ options.fetch(:retry_backoff, Kannuki.configuration.retry_backoff)
32
+ end
33
+
34
+ def calculate_interval(attempt)
35
+ case retry_backoff
36
+ when :exponential
37
+ retry_interval * (2**(attempt - 1))
38
+ when :linear
39
+ retry_interval * attempt
40
+ else
41
+ retry_interval
42
+ end
43
+ end
44
+
45
+ def try_acquire_and_execute(lock_key)
46
+ acquired = adapter.acquire_lock(lock_key, timeout: 0, shared: shared?, transaction: transaction?)
47
+
48
+ if acquired
49
+ start_time = Time.now
50
+ Instrumentation.acquired(lock_key, adapter: adapter.adapter_name, duration: 0)
51
+ track_lock(lock_key)
52
+
53
+ begin
54
+ result = yield
55
+ duration = Time.now - start_time
56
+ Result.new(lock_key: lock_key, acquired: true, value: result, duration: duration,
57
+ adapter_name: adapter.adapter_name)
58
+ ensure
59
+ release_start = Time.now
60
+ release_lock(lock_key)
61
+ untrack_lock(lock_key)
62
+ Instrumentation.released(lock_key, adapter: adapter.adapter_name, duration: Time.now - release_start)
63
+ end
64
+ else
65
+ Result.new(lock_key: lock_key, acquired: false)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'strategies/base'
4
+ require_relative 'strategies/blocking'
5
+ require_relative 'strategies/non_blocking'
6
+ require_relative 'strategies/retry'
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Kannuki
6
+ module Testing
7
+ class << self
8
+ def enable!
9
+ Kannuki.configuration.test_mode = true
10
+ @held_locks = Set.new
11
+ end
12
+
13
+ def disable!
14
+ Kannuki.configuration.test_mode = false
15
+ @held_locks = nil
16
+ end
17
+
18
+ def clear!
19
+ @held_locks&.clear
20
+ end
21
+
22
+ def test_mode?
23
+ Kannuki.configuration.test_mode
24
+ end
25
+
26
+ def simulate_lock_held(name)
27
+ @held_locks ||= Set.new
28
+ @held_locks << normalize_key(name)
29
+ end
30
+
31
+ def release_simulated_lock(name)
32
+ @held_locks&.delete(normalize_key(name))
33
+ end
34
+
35
+ def lock_held?(name)
36
+ @held_locks&.include?(normalize_key(name)) || false
37
+ end
38
+
39
+ def held_locks
40
+ @held_locks.to_a
41
+ end
42
+
43
+ private
44
+
45
+ def normalize_key(name)
46
+ case name
47
+ when LockKey
48
+ name.normalized_key
49
+ else
50
+ name.to_s
51
+ end
52
+ end
53
+ end
54
+
55
+ module RSpecHelpers
56
+ def with_kannuki_test_mode
57
+ before { Kannuki::Testing.enable! }
58
+ after { Kannuki::Testing.clear! }
59
+ end
60
+ end
61
+
62
+ module Matchers
63
+ if defined?(RSpec::Matchers::DSL)
64
+ extend RSpec::Matchers::DSL
65
+
66
+ matcher :acquire_kannuki do |expected_name|
67
+ supports_block_expectations
68
+
69
+ match do |block|
70
+ acquired_locks = []
71
+
72
+ subscriber = Kannuki::Instrumentation.subscribe('acquired') do |_name, _start, _finish, _id, payload|
73
+ acquired_locks << payload[:lock_key].to_s
74
+ end
75
+
76
+ begin
77
+ block.call
78
+ ensure
79
+ Kannuki::Instrumentation.unsubscribe(subscriber)
80
+ end
81
+
82
+ acquired_locks.any? { |lock| lock.include?(expected_name.to_s) }
83
+ end
84
+
85
+ failure_message do
86
+ "expected block to acquire advisory lock matching '#{expected_name}'"
87
+ end
88
+
89
+ failure_message_when_negated do
90
+ "expected block not to acquire advisory lock matching '#{expected_name}'"
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kannuki
4
+ VERSION = '0.1.0'
5
+ end
data/lib/kannuki.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_support'
5
+ require 'active_support/concern'
6
+
7
+ require_relative 'kannuki/version'
8
+ require_relative 'kannuki/errors'
9
+ require_relative 'kannuki/configuration'
10
+ require_relative 'kannuki/lock_key'
11
+ require_relative 'kannuki/result'
12
+ require_relative 'kannuki/instrumentation'
13
+ require_relative 'kannuki/adapters'
14
+ require_relative 'kannuki/strategies'
15
+ require_relative 'kannuki/lock_manager'
16
+ require_relative 'kannuki/model_extension'
17
+ require_relative 'kannuki/active_job_extension'
18
+ require_relative 'kannuki/testing'
19
+ require_relative 'kannuki/railtie' if defined?(Rails::Railtie)
20
+
21
+ module Kannuki
22
+ class << self
23
+ delegate :with_lock, :try_lock, :lock!, :locked?, :current_locks, :holding_lock?, to: LockManager
24
+ end
25
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kannuki
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ description: Kannuki provides database-agnostic advisory locking for ActiveRecord
41
+ with support for PostgreSQL and MySQL, offering blocking/non-blocking strategies,
42
+ instrumentation, and ActiveJob integration.
43
+ email:
44
+ - t.yudai92@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE.txt
50
+ - README.md
51
+ - Rakefile
52
+ - docs/guide.md
53
+ - lib/generators/kannuki/install_generator.rb
54
+ - lib/generators/kannuki/templates/kannuki.rb
55
+ - lib/kannuki.rb
56
+ - lib/kannuki/active_job_extension.rb
57
+ - lib/kannuki/adapters.rb
58
+ - lib/kannuki/adapters/base.rb
59
+ - lib/kannuki/adapters/mysql.rb
60
+ - lib/kannuki/adapters/null.rb
61
+ - lib/kannuki/adapters/postgresql.rb
62
+ - lib/kannuki/configuration.rb
63
+ - lib/kannuki/errors.rb
64
+ - lib/kannuki/instrumentation.rb
65
+ - lib/kannuki/lock_key.rb
66
+ - lib/kannuki/lock_manager.rb
67
+ - lib/kannuki/model_extension.rb
68
+ - lib/kannuki/railtie.rb
69
+ - lib/kannuki/result.rb
70
+ - lib/kannuki/strategies.rb
71
+ - lib/kannuki/strategies/base.rb
72
+ - lib/kannuki/strategies/blocking.rb
73
+ - lib/kannuki/strategies/non_blocking.rb
74
+ - lib/kannuki/strategies/retry.rb
75
+ - lib/kannuki/testing.rb
76
+ - lib/kannuki/version.rb
77
+ homepage: https://github.com/ydah/kannuki
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ homepage_uri: https://github.com/ydah/kannuki
82
+ source_code_uri: https://github.com/ydah/kannuki
83
+ changelog_uri: https://github.com/ydah/kannuki/blob/main/CHANGELOG.md
84
+ rubygems_mfa_required: 'true'
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 3.1.0
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 4.0.4
100
+ specification_version: 4
101
+ summary: Advisory locking for ActiveRecord with modern Rails conventions
102
+ test_files: []