with_advisory_lock 4.6.0 → 5.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +80 -0
  3. data/.github/workflows/release.yml +20 -0
  4. data/.gitignore +2 -0
  5. data/.release-please-manifest.json +1 -0
  6. data/.tool-versions +1 -1
  7. data/Appraisals +34 -18
  8. data/CHANGELOG.md +31 -0
  9. data/Gemfile +0 -12
  10. data/README.md +17 -6
  11. data/gemfiles/{activerecord_6.0.gemfile → activerecord_6.1.gemfile} +4 -2
  12. data/gemfiles/{activerecord_5.2.gemfile → activerecord_7.0.gemfile} +4 -2
  13. data/gemfiles/activerecord_7.1.gemfile +14 -0
  14. data/lib/with_advisory_lock/base.rb +17 -3
  15. data/lib/with_advisory_lock/concern.rb +13 -17
  16. data/lib/with_advisory_lock/database_adapter_support.rb +4 -41
  17. data/lib/with_advisory_lock/failed_to_acquire_lock.rb +9 -0
  18. data/lib/with_advisory_lock/flock.rb +4 -3
  19. data/lib/with_advisory_lock/mysql.rb +5 -5
  20. data/lib/with_advisory_lock/postgresql.rb +9 -7
  21. data/lib/with_advisory_lock/version.rb +3 -1
  22. data/lib/with_advisory_lock.rb +8 -10
  23. data/release-please-config.json +9 -0
  24. data/test/concern_test.rb +23 -10
  25. data/test/lock_test.rb +61 -28
  26. data/test/nesting_test.rb +14 -79
  27. data/test/options_test.rb +35 -33
  28. data/test/parallelism_test.rb +35 -37
  29. data/test/shared_test.rb +93 -90
  30. data/test/test_helper.rb +52 -0
  31. data/test/test_models.rb +9 -7
  32. data/test/thread_test.rb +23 -22
  33. data/test/transaction_test.rb +34 -36
  34. data/with_advisory_lock.gemspec +29 -23
  35. metadata +32 -28
  36. data/.travis.yml +0 -38
  37. data/gemfiles/activerecord_4.2.gemfile +0 -19
  38. data/gemfiles/activerecord_5.0.gemfile +0 -19
  39. data/gemfiles/activerecord_5.1.gemfile +0 -19
  40. data/lib/with_advisory_lock/mysql_no_nesting.rb +0 -20
  41. data/lib/with_advisory_lock/nested_advisory_lock_error.rb +0 -14
  42. data/test/database.yml +0 -17
  43. data/test/minitest_helper.rb +0 -40
  44. data/tests.sh +0 -11
@@ -1,17 +1,15 @@
1
1
  require 'with_advisory_lock/version'
2
2
  require 'active_support'
3
+ require 'zeitwerk'
3
4
 
4
- module WithAdvisoryLock
5
- extend ActiveSupport::Autoload
5
+ loader = Zeitwerk::Loader.for_gem
6
+ loader.inflector.inflect(
7
+ 'mysql' => 'MySQL',
8
+ 'postgresql' => 'PostgreSQL',
9
+ )
10
+ loader.setup
6
11
 
7
- autoload :Concern
8
- autoload :Base
9
- autoload :DatabaseAdapterSupport
10
- autoload :Flock
11
- autoload :MySQL, 'with_advisory_lock/mysql'
12
- autoload :MySQLNoNesting, 'with_advisory_lock/mysql_no_nesting'
13
- autoload :NestedAdvisoryLockError
14
- autoload :PostgreSQL, 'with_advisory_lock/postgresql'
12
+ module WithAdvisoryLock
15
13
  end
16
14
 
17
15
  ActiveSupport.on_load :active_record do
@@ -0,0 +1,9 @@
1
+ {
2
+ "release-type": "ruby",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "ruby",
6
+ "package-name": "with_advisory_lock"
7
+ }
8
+ }
9
+ }
data/test/concern_test.rb CHANGED
@@ -1,20 +1,33 @@
1
- require 'minitest_helper'
1
+ # frozen_string_literal: true
2
2
 
3
- describe "with_advisory_lock.concern" do
4
- it "adds with_advisory_lock to ActiveRecord classes" do
5
- assert Tag.respond_to?(:with_advisory_lock)
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)
6
8
  end
7
9
 
8
- it "adds with_advisory_lock to ActiveRecord instances" do
9
- assert Label.new.respond_to?(:with_advisory_lock)
10
+ test 'adds with_advisory_lock to ActiveRecord instances' do
11
+ assert_respond_to(Label.new, :with_advisory_lock)
10
12
  end
11
13
 
12
- it "adds advisory_lock_exists? to ActiveRecord classes" do
13
- assert Tag.respond_to?(:advisory_lock_exists?)
14
+ test 'adds advisory_lock_exists? to ActiveRecord classes' do
15
+ assert_respond_to(Tag, :advisory_lock_exists?)
14
16
  end
15
17
 
16
- it "adds advisory_lock_exists? to ActiveRecord classes" do
17
- assert Label.new.respond_to?(:advisory_lock_exists?)
18
+ test 'adds advisory_lock_exists? to ActiveRecord instances' do
19
+ assert_respond_to(Label.new, :advisory_lock_exists?)
18
20
  end
21
+ end
19
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
20
33
  end
data/test/lock_test.rb CHANGED
@@ -1,47 +1,80 @@
1
- require 'minitest_helper'
1
+ # frozen_string_literal: true
2
2
 
3
- describe 'class methods' do
4
- let(:lock_name) { 'test lock' }
3
+ require 'test_helper'
5
4
 
6
- describe '.current_advisory_lock' do
7
- it 'returns nil outside an advisory lock request' do
8
- Tag.current_advisory_lock.must_be_nil
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)
9
18
  end
19
+ end
10
20
 
11
- it 'returns the name of the last lock acquired' do
12
- Tag.with_advisory_lock(lock_name) do
13
- # The lock name may have a prefix if WITH_ADVISORY_LOCK_PREFIX env is set
14
- Tag.current_advisory_lock.must_match(/#{lock_name}/)
15
- end
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)
16
25
  end
26
+ end
17
27
 
18
- it 'can obtain a lock with a name that attempts to disrupt a SQL comment' do
19
- dangerous_lock_name = 'test */ lock /*'
20
- Tag.with_advisory_lock(dangerous_lock_name) do
21
- Tag.current_advisory_lock.must_match(/#{Regexp.escape(dangerous_lock_name)}/)
22
- end
28
+ test 'returns false for an unacquired lock' do
29
+ refute(Tag.advisory_lock_exists?(@lock_name))
30
+ end
23
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))
24
35
  end
25
36
  end
26
37
 
27
- describe '.advisory_lock_exists?' do
28
- it 'returns false for an unacquired lock' do
29
- Tag.advisory_lock_exists?(lock_name).must_be_false
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
30
48
  end
31
49
 
32
- it 'returns the name of the last lock acquired' do
33
- Tag.with_advisory_lock(lock_name) do
34
- Tag.advisory_lock_exists?(lock_name).must_be_true
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 }
35
61
  end
36
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
37
70
  end
38
71
 
39
- describe 'zero timeout_seconds' do
40
- it 'attempts the lock exactly once with no timeout' do
41
- expected = SecureRandom.base64
42
- Tag.with_advisory_lock(lock_name, 0) do
43
- expected
44
- end.must_equal expected
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
45
76
  end
77
+
78
+ assert_equal(expected, actual)
46
79
  end
47
80
  end
data/test/nesting_test.rb CHANGED
@@ -1,93 +1,28 @@
1
- require 'minitest_helper'
1
+ # frozen_string_literal: true
2
2
 
3
- describe "lock nesting" do
4
- # This simplifies what we expect from the lock name:
5
- before :each do
3
+ require 'test_helper'
4
+
5
+ class LockNestingTest < GemTestCase
6
+ setup do
6
7
  @prior_prefix = ENV['WITH_ADVISORY_LOCK_PREFIX']
7
8
  ENV['WITH_ADVISORY_LOCK_PREFIX'] = nil
8
9
  end
9
10
 
10
- after :each do
11
+ teardown do
11
12
  ENV['WITH_ADVISORY_LOCK_PREFIX'] = @prior_prefix
12
13
  end
13
14
 
14
- it "doesn't request the same lock twice" do
15
+ test "doesn't request the same lock twice" do
15
16
  impl = WithAdvisoryLock::Base.new(nil, nil, nil)
16
- impl.lock_stack.must_be_empty
17
- Tag.with_advisory_lock("first") do
18
- impl.lock_stack.map(&:name).must_equal %w(first)
17
+ assert_empty(impl.lock_stack)
18
+ Tag.with_advisory_lock('first') do
19
+ assert_equal(%w[first], impl.lock_stack.map(&:name))
19
20
  # Even MySQL should be OK with this:
20
- Tag.with_advisory_lock("first") do
21
- impl.lock_stack.map(&:name).must_equal %w(first)
22
- end
23
- impl.lock_stack.map(&:name).must_equal %w(first)
24
- end
25
- impl.lock_stack.must_be_empty
26
- end
27
-
28
- it "raises errors with MySQL < 5.7.5 when acquiring nested lock" do
29
- skip unless env_db == :mysql && ENV['MYSQL_VERSION'] != '5.7'
30
- exc = proc {
31
- Tag.with_advisory_lock("first") do
32
- Tag.with_advisory_lock("second") do
33
- end
34
- end
35
- }.must_raise WithAdvisoryLock::NestedAdvisoryLockError
36
- exc.lock_stack.map(&:name).must_equal %w(first)
37
- end
38
-
39
- it "does not raise errors with MySQL < 5.7.5 when acquiring nested error force enabled" do
40
- skip unless env_db == :mysql && ENV['MYSQL_VERSION'] != '5.7'
41
- impl = WithAdvisoryLock::Base.new(nil, nil, nil)
42
- impl.lock_stack.must_be_empty
43
- Tag.with_advisory_lock("first", force_nested_lock_support: true) do
44
- impl.lock_stack.map(&:name).must_equal %w(first)
45
- Tag.with_advisory_lock("second", force_nested_lock_support: true) do
46
- impl.lock_stack.map(&:name).must_equal %w(first second)
47
- Tag.with_advisory_lock("first", force_nested_lock_support: true) do
48
- # Shouldn't ask for another lock:
49
- impl.lock_stack.map(&:name).must_equal %w(first second)
50
- Tag.with_advisory_lock("second", force_nested_lock_support: true) do
51
- # Shouldn't ask for another lock:
52
- impl.lock_stack.map(&:name).must_equal %w(first second)
53
- end
54
- end
21
+ Tag.with_advisory_lock('first') do
22
+ assert_equal(%w[first], impl.lock_stack.map(&:name))
55
23
  end
56
- impl.lock_stack.map(&:name).must_equal %w(first)
24
+ assert_equal(%w[first], impl.lock_stack.map(&:name))
57
25
  end
58
- impl.lock_stack.must_be_empty
59
- end
60
-
61
- it "supports nested advisory locks with !MySQL 5.6" do
62
- skip if env_db == :mysql && ENV['MYSQL_VERSION'] != '5.7'
63
- impl = WithAdvisoryLock::Base.new(nil, nil, nil)
64
- impl.lock_stack.must_be_empty
65
- Tag.with_advisory_lock("first") do
66
- impl.lock_stack.map(&:name).must_equal %w(first)
67
- Tag.with_advisory_lock("second") do
68
- impl.lock_stack.map(&:name).must_equal %w(first second)
69
- Tag.with_advisory_lock("first") do
70
- # Shouldn't ask for another lock:
71
- impl.lock_stack.map(&:name).must_equal %w(first second)
72
- Tag.with_advisory_lock("second") do
73
- # Shouldn't ask for another lock:
74
- impl.lock_stack.map(&:name).must_equal %w(first second)
75
- end
76
- end
77
- end
78
- impl.lock_stack.map(&:name).must_equal %w(first)
79
- end
80
- impl.lock_stack.must_be_empty
81
- end
82
-
83
- it "raises with !MySQL 5.6 and nested error force disabled" do
84
- skip unless env_db == :mysql && ENV['MYSQL_VERSION'] != '5.7'
85
- exc = proc {
86
- Tag.with_advisory_lock("first", force_nested_lock_support: false) do
87
- Tag.with_advisory_lock("second", force_nested_lock_support: false) do
88
- end
89
- end
90
- }.must_raise WithAdvisoryLock::NestedAdvisoryLockError
91
- exc.lock_stack.map(&:name).must_equal %w(first)
26
+ assert_empty(impl.lock_stack)
92
27
  end
93
28
  end
data/test/options_test.rb CHANGED
@@ -1,64 +1,66 @@
1
- require 'minitest_helper'
1
+ # frozen_string_literal: true
2
2
 
3
- describe 'options parsing' do
3
+ require 'test_helper'
4
+
5
+ class OptionsParsingTest < GemTestCase
4
6
  def parse_options(options)
5
7
  WithAdvisoryLock::Base.new(mock, mock, options)
6
8
  end
7
9
 
8
- specify 'defaults (empty hash)' do
10
+ test 'defaults (empty hash)' do
9
11
  impl = parse_options({})
10
- impl.timeout_seconds.must_be_nil
11
- impl.shared.must_equal false
12
- impl.transaction.must_equal false
12
+ assert_nil(impl.timeout_seconds)
13
+ assert_not(impl.shared)
14
+ assert_not(impl.transaction)
13
15
  end
14
16
 
15
- specify 'nil sets timeout to nil' do
17
+ test 'nil sets timeout to nil' do
16
18
  impl = parse_options(nil)
17
- impl.timeout_seconds.must_be_nil
18
- impl.shared.must_equal false
19
- impl.transaction.must_equal false
19
+ assert_nil(impl.timeout_seconds)
20
+ assert_not(impl.shared)
21
+ assert_not(impl.transaction)
20
22
  end
21
23
 
22
- specify 'integer sets timeout to value' do
24
+ test 'integer sets timeout to value' do
23
25
  impl = parse_options(42)
24
- impl.timeout_seconds.must_equal 42
25
- impl.shared.must_equal false
26
- impl.transaction.must_equal false
26
+ assert_equal(42, impl.timeout_seconds)
27
+ assert_not(impl.shared)
28
+ assert_not(impl.transaction)
27
29
  end
28
30
 
29
- specify 'hash with invalid key errors' do
30
- proc {
31
+ test 'hash with invalid key errors' do
32
+ assert_raises(ArgumentError) do
31
33
  parse_options(foo: 42)
32
- }.must_raise ArgumentError
34
+ end
33
35
  end
34
36
 
35
- specify 'hash with timeout_seconds sets timeout to value' do
37
+ test 'hash with timeout_seconds sets timeout to value' do
36
38
  impl = parse_options(timeout_seconds: 123)
37
- impl.timeout_seconds.must_equal 123
38
- impl.shared.must_equal false
39
- impl.transaction.must_equal false
39
+ assert_equal(123, impl.timeout_seconds)
40
+ assert_not(impl.shared)
41
+ assert_not(impl.transaction)
40
42
  end
41
43
 
42
- specify 'hash with shared option sets shared to true' do
44
+ test 'hash with shared option sets shared to true' do
43
45
  impl = parse_options(shared: true)
44
- impl.timeout_seconds.must_be_nil
45
- impl.shared.must_equal true
46
- impl.transaction.must_equal false
46
+ assert_nil(impl.timeout_seconds)
47
+ assert(impl.shared)
48
+ assert_not(impl.transaction)
47
49
  end
48
50
 
49
- specify 'hash with transaction option set transaction to true' do
51
+ test 'hash with transaction option set transaction to true' do
50
52
  impl = parse_options(transaction: true)
51
- impl.timeout_seconds.must_be_nil
52
- impl.shared.must_equal false
53
- impl.transaction.must_equal true
53
+ assert_nil(impl.timeout_seconds)
54
+ assert_not(impl.shared)
55
+ assert(impl.transaction)
54
56
  end
55
57
 
56
- specify 'hash with multiple keys sets options' do
58
+ test 'hash with multiple keys sets options' do
57
59
  foo = mock
58
60
  bar = mock
59
61
  impl = parse_options(timeout_seconds: foo, shared: bar)
60
- impl.timeout_seconds.must_equal foo
61
- impl.shared.must_equal bar
62
- impl.transaction.must_equal false
62
+ assert_equal(foo, impl.timeout_seconds)
63
+ assert_equal(bar, impl.shared)
64
+ assert_not(impl.transaction)
63
65
  end
64
66
  end
@@ -1,35 +1,37 @@
1
- require 'minitest_helper'
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
2
4
  require 'forwardable'
3
5
 
4
- describe 'parallelism' do
5
- class FindOrCreateWorker
6
- extend Forwardable
7
- def_delegators :@thread, :join, :wakeup, :status, :to_s
6
+ class FindOrCreateWorker
7
+ extend Forwardable
8
+ def_delegators :@thread, :join, :wakeup, :status, :to_s
8
9
 
9
- def initialize(name, use_advisory_lock)
10
- @name = name
11
- @use_advisory_lock = use_advisory_lock
12
- @thread = Thread.new { work_later }
13
- end
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
14
15
 
15
- def work_later
16
- sleep
17
- ActiveRecord::Base.connection_pool.with_connection do
18
- if @use_advisory_lock
19
- Tag.with_advisory_lock(@name) { work }
20
- else
21
- work
22
- end
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
23
  end
24
24
  end
25
+ end
25
26
 
26
- def work
27
- Tag.transaction do
28
- Tag.where(name: @name).first_or_create
29
- end
27
+ def work
28
+ Tag.transaction do
29
+ Tag.where(name: @name).first_or_create
30
30
  end
31
31
  end
32
+ end
32
33
 
34
+ class ParallelismTest < GemTestCase
33
35
  def run_workers
34
36
  @names = @iterations.times.map { |iter| "iteration ##{iter}" }
35
37
  @names.each do |name|
@@ -37,9 +39,7 @@ describe 'parallelism' do
37
39
  FindOrCreateWorker.new(name, @use_advisory_lock)
38
40
  end
39
41
  # Wait for all the threads to get ready:
40
- until workers.all? { |ea| ea.status == 'sleep' }
41
- sleep(0.1)
42
- end
42
+ sleep(0.1) until workers.all? { |ea| ea.status == 'sleep' }
43
43
  # OK, GO!
44
44
  workers.each(&:wakeup)
45
45
  # Then wait for them to finish:
@@ -49,29 +49,27 @@ describe 'parallelism' do
49
49
  ActiveRecord::Base.connection_pool.connection
50
50
  end
51
51
 
52
- before :each do
52
+ setup do
53
53
  ActiveRecord::Base.connection.reconnect!
54
54
  @workers = 10
55
55
  end
56
56
 
57
- # < SQLite, understandably, throws "The database file is locked (database is locked)"
58
-
59
- it 'creates multiple duplicate rows without advisory locks' do
60
- skip if env_db == :sqlite
57
+ test 'creates multiple duplicate rows without advisory locks' do
58
+ skip if %i[sqlite3 jdbcsqlite3].include?(env_db)
61
59
  @use_advisory_lock = false
62
60
  @iterations = 1
63
61
  run_workers
64
- Tag.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy.
65
- TagAudit.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy.
66
- Label.all.size.must_be :>, @iterations # <- any duplicated rows will make me happy.
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.
67
65
  end
68
66
 
69
- it "doesn't create multiple duplicate rows with advisory locks" do
67
+ test "doesn't create multiple duplicate rows with advisory locks" do
70
68
  @use_advisory_lock = true
71
69
  @iterations = 10
72
70
  run_workers
73
- Tag.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy.
74
- TagAudit.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy.
75
- Label.all.size.must_equal @iterations # <- any duplicated rows will NOT make me happy.
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.
76
74
  end
77
75
  end