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,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,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,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
|
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: []
|