with_advisory_lock 5.1.0 → 7.0.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 +4 -4
- data/.github/workflows/ci.yml +36 -40
- data/.github/workflows/release.yml +1 -4
- data/.gitignore +2 -2
- data/.release-please-manifest.json +1 -1
- data/.ruby-version +2 -0
- data/.tool-versions +1 -1
- data/CHANGELOG.md +51 -0
- data/Gemfile +31 -0
- data/LICENSE.txt +4 -4
- data/Makefile +10 -0
- data/README.md +7 -35
- data/Rakefile +5 -2
- data/bin/console +11 -0
- data/bin/rails +15 -0
- data/bin/sanity +20 -0
- data/bin/sanity_check +86 -0
- data/bin/setup +8 -0
- data/bin/setup_test_db +59 -0
- data/bin/test_connections +22 -0
- data/docker-compose.yml +19 -0
- data/lib/with_advisory_lock/concern.rb +27 -16
- data/lib/with_advisory_lock/core_advisory.rb +110 -0
- data/lib/with_advisory_lock/jruby_adapter.rb +29 -0
- data/lib/with_advisory_lock/lock_stack_item.rb +6 -0
- data/lib/with_advisory_lock/mysql_advisory.rb +62 -0
- data/lib/with_advisory_lock/postgresql_advisory.rb +112 -0
- data/lib/with_advisory_lock/result.rb +14 -0
- data/lib/with_advisory_lock/version.rb +1 -1
- data/lib/with_advisory_lock.rb +38 -9
- data/test/dummy/Rakefile +8 -0
- data/test/dummy/app/controllers/application_controller.rb +7 -0
- data/test/dummy/app/models/application_record.rb +6 -0
- data/test/dummy/app/models/label.rb +4 -0
- data/test/dummy/app/models/mysql_label.rb +5 -0
- data/test/dummy/app/models/mysql_record.rb +6 -0
- data/test/dummy/app/models/mysql_tag.rb +10 -0
- data/test/dummy/app/models/mysql_tag_audit.rb +5 -0
- data/test/dummy/app/models/tag.rb +8 -0
- data/test/dummy/app/models/tag_audit.rb +4 -0
- data/test/dummy/config/application.rb +31 -0
- data/test/dummy/config/boot.rb +3 -0
- data/test/dummy/config/database.yml +13 -0
- data/test/dummy/config/environment.rb +7 -0
- data/test/dummy/config/routes.rb +4 -0
- data/test/dummy/config.ru +6 -0
- data/test/{test_models.rb → dummy/db/schema.rb} +3 -14
- data/test/dummy/db/secondary_schema.rb +15 -0
- data/test/dummy/lib/tasks/db.rake +40 -0
- data/test/sanity_check_test.rb +63 -0
- data/test/test_helper.rb +18 -37
- data/test/with_advisory_lock/concern_test.rb +79 -0
- data/test/with_advisory_lock/lock_test.rb +197 -0
- data/test/with_advisory_lock/multi_adapter_test.rb +17 -0
- data/test/with_advisory_lock/parallelism_test.rb +101 -0
- data/test/with_advisory_lock/postgresql_race_condition_test.rb +118 -0
- data/test/with_advisory_lock/shared_test.rb +129 -0
- data/test/with_advisory_lock/thread_test.rb +83 -0
- data/test/with_advisory_lock/transaction_test.rb +83 -0
- data/with_advisory_lock.gemspec +26 -6
- metadata +64 -55
- data/Appraisals +0 -45
- data/gemfiles/activerecord_6.1.gemfile +0 -21
- data/gemfiles/activerecord_7.0.gemfile +0 -21
- data/gemfiles/activerecord_7.1.gemfile +0 -14
- data/lib/with_advisory_lock/base.rb +0 -118
- data/lib/with_advisory_lock/database_adapter_support.rb +0 -26
- data/lib/with_advisory_lock/flock.rb +0 -33
- data/lib/with_advisory_lock/mysql.rb +0 -27
- data/lib/with_advisory_lock/postgresql.rb +0 -43
- data/test/concern_test.rb +0 -33
- data/test/lock_test.rb +0 -80
- data/test/nesting_test.rb +0 -28
- data/test/options_test.rb +0 -66
- data/test/parallelism_test.rb +0 -75
- data/test/shared_test.rb +0 -134
- data/test/thread_test.rb +0 -61
- data/test/transaction_test.rb +0 -68
@@ -1,118 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'zlib'
|
4
|
-
|
5
|
-
module WithAdvisoryLock
|
6
|
-
class Result
|
7
|
-
attr_reader :result
|
8
|
-
|
9
|
-
def initialize(lock_was_acquired, result = false)
|
10
|
-
@lock_was_acquired = lock_was_acquired
|
11
|
-
@result = result
|
12
|
-
end
|
13
|
-
|
14
|
-
def lock_was_acquired?
|
15
|
-
@lock_was_acquired
|
16
|
-
end
|
17
|
-
end
|
18
|
-
|
19
|
-
FAILED_TO_LOCK = Result.new(false)
|
20
|
-
|
21
|
-
LockStackItem = Struct.new(:name, :shared)
|
22
|
-
|
23
|
-
class Base
|
24
|
-
attr_reader :connection, :lock_name, :timeout_seconds, :shared, :transaction, :disable_query_cache
|
25
|
-
|
26
|
-
def initialize(connection, lock_name, options)
|
27
|
-
options = { timeout_seconds: options } unless options.respond_to?(:fetch)
|
28
|
-
options.assert_valid_keys :timeout_seconds, :shared, :transaction, :disable_query_cache
|
29
|
-
|
30
|
-
@connection = connection
|
31
|
-
@lock_name = lock_name
|
32
|
-
@timeout_seconds = options.fetch(:timeout_seconds, nil)
|
33
|
-
@shared = options.fetch(:shared, false)
|
34
|
-
@transaction = options.fetch(:transaction, false)
|
35
|
-
@disable_query_cache = options.fetch(:disable_query_cache, false)
|
36
|
-
end
|
37
|
-
|
38
|
-
def lock_str
|
39
|
-
@lock_str ||= "#{ENV['WITH_ADVISORY_LOCK_PREFIX']}#{lock_name}"
|
40
|
-
end
|
41
|
-
|
42
|
-
def lock_stack_item
|
43
|
-
@lock_stack_item ||= LockStackItem.new(lock_str, shared)
|
44
|
-
end
|
45
|
-
|
46
|
-
def self.lock_stack
|
47
|
-
# access doesn't need to be synchronized as it is only accessed by the current thread.
|
48
|
-
Thread.current[:with_advisory_lock_stack] ||= []
|
49
|
-
end
|
50
|
-
delegate :lock_stack, to: 'self.class'
|
51
|
-
|
52
|
-
def already_locked?
|
53
|
-
lock_stack.include? lock_stack_item
|
54
|
-
end
|
55
|
-
|
56
|
-
def with_advisory_lock_if_needed(&block)
|
57
|
-
if disable_query_cache
|
58
|
-
return lock_and_yield do
|
59
|
-
ActiveRecord::Base.uncached(&block)
|
60
|
-
end
|
61
|
-
end
|
62
|
-
|
63
|
-
lock_and_yield(&block)
|
64
|
-
end
|
65
|
-
|
66
|
-
def lock_and_yield(&block)
|
67
|
-
if already_locked?
|
68
|
-
Result.new(true, yield)
|
69
|
-
elsif timeout_seconds == 0
|
70
|
-
yield_with_lock(&block)
|
71
|
-
else
|
72
|
-
yield_with_lock_and_timeout(&block)
|
73
|
-
end
|
74
|
-
end
|
75
|
-
|
76
|
-
def stable_hashcode(input)
|
77
|
-
if input.is_a? Numeric
|
78
|
-
input.to_i
|
79
|
-
else
|
80
|
-
# Ruby MRI's String#hash is randomly seeded as of Ruby 1.9 so
|
81
|
-
# make sure we use a deterministic hash.
|
82
|
-
Zlib.crc32(input.to_s, 0)
|
83
|
-
end
|
84
|
-
end
|
85
|
-
|
86
|
-
def yield_with_lock_and_timeout(&block)
|
87
|
-
give_up_at = Time.now + @timeout_seconds if @timeout_seconds
|
88
|
-
while @timeout_seconds.nil? || Time.now < give_up_at
|
89
|
-
r = yield_with_lock(&block)
|
90
|
-
return r if r.lock_was_acquired?
|
91
|
-
|
92
|
-
# Randomizing sleep time may help reduce contention.
|
93
|
-
sleep(rand(0.05..0.15))
|
94
|
-
end
|
95
|
-
FAILED_TO_LOCK
|
96
|
-
end
|
97
|
-
|
98
|
-
def yield_with_lock
|
99
|
-
if try_lock
|
100
|
-
begin
|
101
|
-
lock_stack.push(lock_stack_item)
|
102
|
-
result = block_given? ? yield : nil
|
103
|
-
Result.new(true, result)
|
104
|
-
ensure
|
105
|
-
lock_stack.pop
|
106
|
-
release_lock
|
107
|
-
end
|
108
|
-
else
|
109
|
-
FAILED_TO_LOCK
|
110
|
-
end
|
111
|
-
end
|
112
|
-
|
113
|
-
# Prevent AR from caching results improperly
|
114
|
-
def unique_column_name
|
115
|
-
"t#{SecureRandom.hex}"
|
116
|
-
end
|
117
|
-
end
|
118
|
-
end
|
@@ -1,26 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module WithAdvisoryLock
|
4
|
-
class DatabaseAdapterSupport
|
5
|
-
# Caches nested lock support by MySQL reported version
|
6
|
-
@@mysql_nl_cache = {}
|
7
|
-
@@mysql_nl_cache_mutex = Mutex.new
|
8
|
-
|
9
|
-
def initialize(connection)
|
10
|
-
@connection = connection
|
11
|
-
@sym_name = connection.adapter_name.downcase.to_sym
|
12
|
-
end
|
13
|
-
|
14
|
-
def mysql?
|
15
|
-
%i[mysql2 trilogy].include? @sym_name
|
16
|
-
end
|
17
|
-
|
18
|
-
def postgresql?
|
19
|
-
%i[postgresql empostgresql postgis].include? @sym_name
|
20
|
-
end
|
21
|
-
|
22
|
-
def sqlite?
|
23
|
-
@sym_name == :sqlite3
|
24
|
-
end
|
25
|
-
end
|
26
|
-
end
|
@@ -1,33 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'fileutils'
|
4
|
-
|
5
|
-
module WithAdvisoryLock
|
6
|
-
class Flock < Base
|
7
|
-
def filename
|
8
|
-
@filename ||= begin
|
9
|
-
safe = lock_str.to_s.gsub(/[^a-z0-9]/i, '')
|
10
|
-
fn = ".lock-#{safe}-#{stable_hashcode(lock_str)}"
|
11
|
-
# Let the user specify a directory besides CWD.
|
12
|
-
ENV['FLOCK_DIR'] ? File.expand_path(fn, ENV['FLOCK_DIR']) : fn
|
13
|
-
end
|
14
|
-
end
|
15
|
-
|
16
|
-
def file_io
|
17
|
-
@file_io ||= begin
|
18
|
-
FileUtils.touch(filename)
|
19
|
-
File.open(filename, 'r+')
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
def try_lock
|
24
|
-
raise ArgumentError, 'transaction level locks are not supported on SQLite' if transaction
|
25
|
-
|
26
|
-
0 == file_io.flock((shared ? File::LOCK_SH : File::LOCK_EX) | File::LOCK_NB)
|
27
|
-
end
|
28
|
-
|
29
|
-
def release_lock
|
30
|
-
0 == file_io.flock(File::LOCK_UN)
|
31
|
-
end
|
32
|
-
end
|
33
|
-
end
|
@@ -1,27 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module WithAdvisoryLock
|
4
|
-
class MySQL < Base
|
5
|
-
# See https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
|
6
|
-
def try_lock
|
7
|
-
raise ArgumentError, 'shared locks are not supported on MySQL' if shared
|
8
|
-
raise ArgumentError, 'transaction level locks are not supported on MySQL' if transaction
|
9
|
-
|
10
|
-
execute_successful?("GET_LOCK(#{quoted_lock_str}, 0)")
|
11
|
-
end
|
12
|
-
|
13
|
-
def release_lock
|
14
|
-
execute_successful?("RELEASE_LOCK(#{quoted_lock_str})")
|
15
|
-
end
|
16
|
-
|
17
|
-
def execute_successful?(mysql_function)
|
18
|
-
sql = "SELECT #{mysql_function} AS #{unique_column_name}"
|
19
|
-
connection.select_value(sql).to_i.positive?
|
20
|
-
end
|
21
|
-
|
22
|
-
# MySQL wants a string as the lock key.
|
23
|
-
def quoted_lock_str
|
24
|
-
connection.quote(lock_str)
|
25
|
-
end
|
26
|
-
end
|
27
|
-
end
|
@@ -1,43 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module WithAdvisoryLock
|
4
|
-
class PostgreSQL < Base
|
5
|
-
# See http://www.postgresql.org/docs/9.1/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
|
6
|
-
def try_lock
|
7
|
-
pg_function = "pg_try_advisory#{transaction ? '_xact' : ''}_lock#{shared ? '_shared' : ''}"
|
8
|
-
execute_successful?(pg_function)
|
9
|
-
end
|
10
|
-
|
11
|
-
def release_lock
|
12
|
-
return if transaction
|
13
|
-
|
14
|
-
pg_function = "pg_advisory_unlock#{shared ? '_shared' : ''}"
|
15
|
-
execute_successful?(pg_function)
|
16
|
-
rescue ActiveRecord::StatementInvalid => e
|
17
|
-
raise unless e.message =~ / ERROR: +current transaction is aborted,/
|
18
|
-
|
19
|
-
begin
|
20
|
-
connection.rollback_db_transaction
|
21
|
-
execute_successful?(pg_function)
|
22
|
-
ensure
|
23
|
-
connection.begin_db_transaction
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
|
-
def execute_successful?(pg_function)
|
28
|
-
comment = lock_name.to_s.gsub(%r{(/\*)|(\*/)}, '--')
|
29
|
-
sql = "SELECT #{pg_function}(#{lock_keys.join(',')}) AS #{unique_column_name} /* #{comment} */"
|
30
|
-
result = connection.select_value(sql)
|
31
|
-
# MRI returns 't', jruby returns true. YAY!
|
32
|
-
['t', true].include?(result)
|
33
|
-
end
|
34
|
-
|
35
|
-
# PostgreSQL wants 2 32bit integers as the lock key.
|
36
|
-
def lock_keys
|
37
|
-
@lock_keys ||= [stable_hashcode(lock_name), ENV['WITH_ADVISORY_LOCK_PREFIX']].map do |ea|
|
38
|
-
# pg advisory args must be 31 bit ints
|
39
|
-
ea.to_i & 0x7fffffff
|
40
|
-
end
|
41
|
-
end
|
42
|
-
end
|
43
|
-
end
|
data/test/concern_test.rb
DELETED
@@ -1,33 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'test_helper'
|
4
|
-
|
5
|
-
class WithAdvisoryLockConcernTest < GemTestCase
|
6
|
-
test 'adds with_advisory_lock to ActiveRecord classes' do
|
7
|
-
assert_respond_to(Tag, :with_advisory_lock)
|
8
|
-
end
|
9
|
-
|
10
|
-
test 'adds with_advisory_lock to ActiveRecord instances' do
|
11
|
-
assert_respond_to(Label.new, :with_advisory_lock)
|
12
|
-
end
|
13
|
-
|
14
|
-
test 'adds advisory_lock_exists? to ActiveRecord classes' do
|
15
|
-
assert_respond_to(Tag, :advisory_lock_exists?)
|
16
|
-
end
|
17
|
-
|
18
|
-
test 'adds advisory_lock_exists? to ActiveRecord instances' do
|
19
|
-
assert_respond_to(Label.new, :advisory_lock_exists?)
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
class ActiveRecordQueryCacheTest < GemTestCase
|
24
|
-
test 'does not disable quary cache by default' do
|
25
|
-
ActiveRecord::Base.expects(:uncached).never
|
26
|
-
Tag.with_advisory_lock('lock') { Tag.first }
|
27
|
-
end
|
28
|
-
|
29
|
-
test 'can disable ActiveRecord query cache' do
|
30
|
-
ActiveRecord::Base.expects(:uncached).once
|
31
|
-
Tag.with_advisory_lock('a-lock', disable_query_cache: true) { Tag.first }
|
32
|
-
end
|
33
|
-
end
|
data/test/lock_test.rb
DELETED
@@ -1,80 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'test_helper'
|
4
|
-
|
5
|
-
class LockTest < GemTestCase
|
6
|
-
setup do
|
7
|
-
@lock_name = 'test lock'
|
8
|
-
@return_val = 1900
|
9
|
-
end
|
10
|
-
|
11
|
-
test 'returns nil outside an advisory lock request' do
|
12
|
-
assert_nil(Tag.current_advisory_lock)
|
13
|
-
end
|
14
|
-
|
15
|
-
test 'returns the name of the last lock acquired' do
|
16
|
-
Tag.with_advisory_lock(@lock_name) do
|
17
|
-
assert_match(/#{@lock_name}/, Tag.current_advisory_lock)
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
test 'can obtain a lock with a name that attempts to disrupt a SQL comment' do
|
22
|
-
dangerous_lock_name = 'test */ lock /*'
|
23
|
-
Tag.with_advisory_lock(dangerous_lock_name) do
|
24
|
-
assert_match(/#{Regexp.escape(dangerous_lock_name)}/, Tag.current_advisory_lock)
|
25
|
-
end
|
26
|
-
end
|
27
|
-
|
28
|
-
test 'returns false for an unacquired lock' do
|
29
|
-
refute(Tag.advisory_lock_exists?(@lock_name))
|
30
|
-
end
|
31
|
-
|
32
|
-
test 'returns true for an acquired lock' do
|
33
|
-
Tag.with_advisory_lock(@lock_name) do
|
34
|
-
assert(Tag.advisory_lock_exists?(@lock_name))
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
test 'returns block return value if lock successful' do
|
39
|
-
assert_equal(@return_val, Tag.with_advisory_lock!(@lock_name) { @return_val })
|
40
|
-
end
|
41
|
-
|
42
|
-
test 'returns false on lock acquisition failure' do
|
43
|
-
thread_with_lock = Thread.new do
|
44
|
-
Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) do
|
45
|
-
@locked_elsewhere = true
|
46
|
-
loop { sleep 0.01 }
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
sleep 0.01 until @locked_elsewhere
|
51
|
-
assert_not(Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) { @return_val })
|
52
|
-
|
53
|
-
thread_with_lock.kill
|
54
|
-
end
|
55
|
-
|
56
|
-
test 'raises an error on lock acquisition failure' do
|
57
|
-
thread_with_lock = Thread.new do
|
58
|
-
Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) do
|
59
|
-
@locked_elsewhere = true
|
60
|
-
loop { sleep 0.01 }
|
61
|
-
end
|
62
|
-
end
|
63
|
-
|
64
|
-
sleep 0.01 until @locked_elsewhere
|
65
|
-
assert_raises(WithAdvisoryLock::FailedToAcquireLock) do
|
66
|
-
Tag.with_advisory_lock!(@lock_name, timeout_seconds: 0) { @return_val }
|
67
|
-
end
|
68
|
-
|
69
|
-
thread_with_lock.kill
|
70
|
-
end
|
71
|
-
|
72
|
-
test 'attempts the lock exactly once with no timeout' do
|
73
|
-
expected = SecureRandom.base64
|
74
|
-
actual = Tag.with_advisory_lock(@lock_name, 0) do
|
75
|
-
expected
|
76
|
-
end
|
77
|
-
|
78
|
-
assert_equal(expected, actual)
|
79
|
-
end
|
80
|
-
end
|
data/test/nesting_test.rb
DELETED
@@ -1,28 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'test_helper'
|
4
|
-
|
5
|
-
class LockNestingTest < GemTestCase
|
6
|
-
setup do
|
7
|
-
@prior_prefix = ENV['WITH_ADVISORY_LOCK_PREFIX']
|
8
|
-
ENV['WITH_ADVISORY_LOCK_PREFIX'] = nil
|
9
|
-
end
|
10
|
-
|
11
|
-
teardown do
|
12
|
-
ENV['WITH_ADVISORY_LOCK_PREFIX'] = @prior_prefix
|
13
|
-
end
|
14
|
-
|
15
|
-
test "doesn't request the same lock twice" do
|
16
|
-
impl = WithAdvisoryLock::Base.new(nil, nil, nil)
|
17
|
-
assert_empty(impl.lock_stack)
|
18
|
-
Tag.with_advisory_lock('first') do
|
19
|
-
assert_equal(%w[first], impl.lock_stack.map(&:name))
|
20
|
-
# Even MySQL should be OK with this:
|
21
|
-
Tag.with_advisory_lock('first') do
|
22
|
-
assert_equal(%w[first], impl.lock_stack.map(&:name))
|
23
|
-
end
|
24
|
-
assert_equal(%w[first], impl.lock_stack.map(&:name))
|
25
|
-
end
|
26
|
-
assert_empty(impl.lock_stack)
|
27
|
-
end
|
28
|
-
end
|
data/test/options_test.rb
DELETED
@@ -1,66 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'test_helper'
|
4
|
-
|
5
|
-
class OptionsParsingTest < GemTestCase
|
6
|
-
def parse_options(options)
|
7
|
-
WithAdvisoryLock::Base.new(mock, mock, options)
|
8
|
-
end
|
9
|
-
|
10
|
-
test 'defaults (empty hash)' do
|
11
|
-
impl = parse_options({})
|
12
|
-
assert_nil(impl.timeout_seconds)
|
13
|
-
assert_not(impl.shared)
|
14
|
-
assert_not(impl.transaction)
|
15
|
-
end
|
16
|
-
|
17
|
-
test 'nil sets timeout to nil' do
|
18
|
-
impl = parse_options(nil)
|
19
|
-
assert_nil(impl.timeout_seconds)
|
20
|
-
assert_not(impl.shared)
|
21
|
-
assert_not(impl.transaction)
|
22
|
-
end
|
23
|
-
|
24
|
-
test 'integer sets timeout to value' do
|
25
|
-
impl = parse_options(42)
|
26
|
-
assert_equal(42, impl.timeout_seconds)
|
27
|
-
assert_not(impl.shared)
|
28
|
-
assert_not(impl.transaction)
|
29
|
-
end
|
30
|
-
|
31
|
-
test 'hash with invalid key errors' do
|
32
|
-
assert_raises(ArgumentError) do
|
33
|
-
parse_options(foo: 42)
|
34
|
-
end
|
35
|
-
end
|
36
|
-
|
37
|
-
test 'hash with timeout_seconds sets timeout to value' do
|
38
|
-
impl = parse_options(timeout_seconds: 123)
|
39
|
-
assert_equal(123, impl.timeout_seconds)
|
40
|
-
assert_not(impl.shared)
|
41
|
-
assert_not(impl.transaction)
|
42
|
-
end
|
43
|
-
|
44
|
-
test 'hash with shared option sets shared to true' do
|
45
|
-
impl = parse_options(shared: true)
|
46
|
-
assert_nil(impl.timeout_seconds)
|
47
|
-
assert(impl.shared)
|
48
|
-
assert_not(impl.transaction)
|
49
|
-
end
|
50
|
-
|
51
|
-
test 'hash with transaction option set transaction to true' do
|
52
|
-
impl = parse_options(transaction: true)
|
53
|
-
assert_nil(impl.timeout_seconds)
|
54
|
-
assert_not(impl.shared)
|
55
|
-
assert(impl.transaction)
|
56
|
-
end
|
57
|
-
|
58
|
-
test 'hash with multiple keys sets options' do
|
59
|
-
foo = mock
|
60
|
-
bar = mock
|
61
|
-
impl = parse_options(timeout_seconds: foo, shared: bar)
|
62
|
-
assert_equal(foo, impl.timeout_seconds)
|
63
|
-
assert_equal(bar, impl.shared)
|
64
|
-
assert_not(impl.transaction)
|
65
|
-
end
|
66
|
-
end
|
data/test/parallelism_test.rb
DELETED
@@ -1,75 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'test_helper'
|
4
|
-
require 'forwardable'
|
5
|
-
|
6
|
-
class FindOrCreateWorker
|
7
|
-
extend Forwardable
|
8
|
-
def_delegators :@thread, :join, :wakeup, :status, :to_s
|
9
|
-
|
10
|
-
def initialize(name, use_advisory_lock)
|
11
|
-
@name = name
|
12
|
-
@use_advisory_lock = use_advisory_lock
|
13
|
-
@thread = Thread.new { work_later }
|
14
|
-
end
|
15
|
-
|
16
|
-
def work_later
|
17
|
-
sleep
|
18
|
-
ActiveRecord::Base.connection_pool.with_connection do
|
19
|
-
if @use_advisory_lock
|
20
|
-
Tag.with_advisory_lock(@name) { work }
|
21
|
-
else
|
22
|
-
work
|
23
|
-
end
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
|
-
def work
|
28
|
-
Tag.transaction do
|
29
|
-
Tag.where(name: @name).first_or_create
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|
33
|
-
|
34
|
-
class ParallelismTest < GemTestCase
|
35
|
-
def run_workers
|
36
|
-
@names = @iterations.times.map { |iter| "iteration ##{iter}" }
|
37
|
-
@names.each do |name|
|
38
|
-
workers = @workers.times.map do
|
39
|
-
FindOrCreateWorker.new(name, @use_advisory_lock)
|
40
|
-
end
|
41
|
-
# Wait for all the threads to get ready:
|
42
|
-
sleep(0.1) until workers.all? { |ea| ea.status == 'sleep' }
|
43
|
-
# OK, GO!
|
44
|
-
workers.each(&:wakeup)
|
45
|
-
# Then wait for them to finish:
|
46
|
-
workers.each(&:join)
|
47
|
-
end
|
48
|
-
# Ensure we're still connected:
|
49
|
-
ActiveRecord::Base.connection_pool.connection
|
50
|
-
end
|
51
|
-
|
52
|
-
setup do
|
53
|
-
ActiveRecord::Base.connection.reconnect!
|
54
|
-
@workers = 10
|
55
|
-
end
|
56
|
-
|
57
|
-
test 'creates multiple duplicate rows without advisory locks' do
|
58
|
-
skip if %i[sqlite3 jdbcsqlite3].include?(env_db)
|
59
|
-
@use_advisory_lock = false
|
60
|
-
@iterations = 1
|
61
|
-
run_workers
|
62
|
-
assert_operator(Tag.all.size, :>, @iterations) # <- any duplicated rows will make me happy.
|
63
|
-
assert_operator(TagAudit.all.size, :>, @iterations) # <- any duplicated rows will make me happy.
|
64
|
-
assert_operator(Label.all.size, :>, @iterations) # <- any duplicated rows will make me happy.
|
65
|
-
end
|
66
|
-
|
67
|
-
test "doesn't create multiple duplicate rows with advisory locks" do
|
68
|
-
@use_advisory_lock = true
|
69
|
-
@iterations = 10
|
70
|
-
run_workers
|
71
|
-
assert_equal(@iterations, Tag.all.size) # <- any duplicated rows will NOT make me happy.
|
72
|
-
assert_equal(@iterations, TagAudit.all.size) # <- any duplicated rows will NOT make me happy.
|
73
|
-
assert_equal(@iterations, Label.all.size) # <- any duplicated rows will NOT make me happy.
|
74
|
-
end
|
75
|
-
end
|
data/test/shared_test.rb
DELETED
@@ -1,134 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'test_helper'
|
4
|
-
class SharedTestWorker
|
5
|
-
def initialize(shared)
|
6
|
-
@shared = shared
|
7
|
-
|
8
|
-
@locked = nil
|
9
|
-
@cleanup = false
|
10
|
-
@thread = Thread.new { work }
|
11
|
-
end
|
12
|
-
|
13
|
-
def locked?
|
14
|
-
sleep 0.01 while @locked.nil? && @thread.alive?
|
15
|
-
@locked
|
16
|
-
end
|
17
|
-
|
18
|
-
def cleanup!
|
19
|
-
@cleanup = true
|
20
|
-
@thread.join
|
21
|
-
raise if @thread.status.nil?
|
22
|
-
end
|
23
|
-
|
24
|
-
private
|
25
|
-
|
26
|
-
def work
|
27
|
-
ActiveRecord::Base.connection_pool.with_connection do
|
28
|
-
Tag.with_advisory_lock('test', timeout_seconds: 0, shared: @shared) do
|
29
|
-
@locked = true
|
30
|
-
sleep 0.01 until @cleanup
|
31
|
-
end
|
32
|
-
@locked = false
|
33
|
-
sleep 0.01 until @cleanup
|
34
|
-
end
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
class SharedLocksTest < GemTestCase
|
39
|
-
def supported?
|
40
|
-
%i[trilogy mysql2 jdbcmysql].exclude?(env_db)
|
41
|
-
end
|
42
|
-
|
43
|
-
test 'does not allow two exclusive locks' do
|
44
|
-
one = SharedTestWorker.new(false)
|
45
|
-
assert_predicate(one, :locked?)
|
46
|
-
|
47
|
-
two = SharedTestWorker.new(false)
|
48
|
-
refute(two.locked?)
|
49
|
-
|
50
|
-
one.cleanup!
|
51
|
-
two.cleanup!
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
class NotSupportedEnvironmentTest < SharedLocksTest
|
56
|
-
setup do
|
57
|
-
skip if supported?
|
58
|
-
end
|
59
|
-
|
60
|
-
test 'raises an error when attempting to use a shared lock' do
|
61
|
-
one = SharedTestWorker.new(true)
|
62
|
-
assert_nil(one.locked?)
|
63
|
-
|
64
|
-
exception = assert_raises(ArgumentError) do
|
65
|
-
one.cleanup!
|
66
|
-
end
|
67
|
-
|
68
|
-
assert_match(/#{Regexp.escape('not supported')}/, exception.message)
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
|
-
class SupportedEnvironmentTest < SharedLocksTest
|
73
|
-
setup do
|
74
|
-
skip unless supported?
|
75
|
-
end
|
76
|
-
|
77
|
-
test 'does allow two shared locks' do
|
78
|
-
one = SharedTestWorker.new(true)
|
79
|
-
assert_predicate(one, :locked?)
|
80
|
-
|
81
|
-
two = SharedTestWorker.new(true)
|
82
|
-
assert_predicate(two, :locked?)
|
83
|
-
|
84
|
-
one.cleanup!
|
85
|
-
two.cleanup!
|
86
|
-
end
|
87
|
-
|
88
|
-
test 'does not allow exclusive lock with shared lock' do
|
89
|
-
one = SharedTestWorker.new(true)
|
90
|
-
assert_predicate(one, :locked?)
|
91
|
-
|
92
|
-
two = SharedTestWorker.new(false)
|
93
|
-
refute(two.locked?)
|
94
|
-
|
95
|
-
three = SharedTestWorker.new(true)
|
96
|
-
assert_predicate(three, :locked?)
|
97
|
-
|
98
|
-
one.cleanup!
|
99
|
-
two.cleanup!
|
100
|
-
three.cleanup!
|
101
|
-
end
|
102
|
-
|
103
|
-
test 'does not allow shared lock with exclusive lock' do
|
104
|
-
one = SharedTestWorker.new(false)
|
105
|
-
assert_predicate(one, :locked?)
|
106
|
-
|
107
|
-
two = SharedTestWorker.new(true)
|
108
|
-
refute(two.locked?)
|
109
|
-
|
110
|
-
one.cleanup!
|
111
|
-
two.cleanup!
|
112
|
-
end
|
113
|
-
|
114
|
-
class PostgreSQLTest < SupportedEnvironmentTest
|
115
|
-
setup do
|
116
|
-
skip unless env_db == :postgresql
|
117
|
-
end
|
118
|
-
|
119
|
-
def pg_lock_modes
|
120
|
-
ActiveRecord::Base.connection.select_values("SELECT mode FROM pg_locks WHERE locktype = 'advisory';")
|
121
|
-
end
|
122
|
-
|
123
|
-
test 'allows shared lock to be upgraded to an exclusive lock' do
|
124
|
-
assert_empty(pg_lock_modes)
|
125
|
-
Tag.with_advisory_lock 'test', shared: true do
|
126
|
-
assert_equal(%w[ShareLock], pg_lock_modes)
|
127
|
-
Tag.with_advisory_lock 'test', shared: false do
|
128
|
-
assert_equal(%w[ShareLock ExclusiveLock], pg_lock_modes)
|
129
|
-
end
|
130
|
-
end
|
131
|
-
assert_empty(pg_lock_modes)
|
132
|
-
end
|
133
|
-
end
|
134
|
-
end
|