db_lock 0.8.3 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bf2cc97847ee6ffd808862521722c813feaddd2c901b3d7ae2f3048b5f110c6
4
- data.tar.gz: a1d6c63848525ac3c5525859ae6ab8de36b4207f316235367bb048ea4d056a25
3
+ metadata.gz: fd0ec2c4c8c31af04618335c09676033260ebb4929a6ab5e8759390d046ae974
4
+ data.tar.gz: fcdbcae4ecb7dbb85c25e983df499f0d7e49c7ebb79c3a5eb83ea9a9616c8c99
5
5
  SHA512:
6
- metadata.gz: 7d86fd46f20a9e2395d5c9c933cc72de655b9639f310ace64d73c1d840eaf3c0ac8e0586cf7eca419aeb093c50aee1856d81df2a08d56bebbc8be8274ae150b2
7
- data.tar.gz: 50bbbba20692ca761eecbd696abd5bd942dd8b61db07f4c86687f0adc7a284c4d35753e4f2e07fe53eecf1fcda0db47486125bc3aebe85891a32beb9c8e50eb1
6
+ metadata.gz: 280d593a366fd356eebfa39de6aa9ad6951c7216d5cd48bdf09e4739da0170e240d847bcac9b0bcf897b1b7908326ef782aaa0d7a2d9d2bca9fa6e2317b68497
7
+ data.tar.gz: 7291f1295722acc8ff32ade9f53503a8f190bba4aba65ff2477fa7925bb5991b686125d3d298288b8d05344d2e606c5827885328c8e0af102c0fa738832332d7
data/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # DBLock
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/db_lock.svg)](https://badge.fury.io/rb/db_lock)
4
- [![Tests](https://github.com/mkon/db_lock/actions/workflows/test.yml/badge.svg)](https://github.com/mkon/db_lock/actions/workflows/test.yml)
4
+ [![Tests](https://github.com/mkon/db_lock/actions/workflows/main.yml/badge.svg)](https://github.com/mkon/db_lock/actions/workflows/main.yml)
5
5
 
6
6
  Gem to obtain and release manual db locks. This can be utilized for example to make sure that certain rake tasks do not run in parallel on the same database (for example when cron jobs run for too long or are accidentally started multiple times). Currently only supports:
7
7
 
8
8
  - MySQL
9
9
  - Microsoft SQL Server
10
+ - Postgres
10
11
 
11
12
  ## Installation
12
13
 
@@ -19,16 +20,24 @@ then run `bundle`
19
20
  ## Usage
20
21
 
21
22
  ```ruby
22
- DBLock::Lock.get('name_of_lock', 5) do
23
+ DBLock.with_lock('name_of_lock', 5) do
23
24
  # code here
24
25
  end
25
26
  ```
26
27
 
27
- Before the code block is executed, it will attempt to acquire a mysql db lock for X seconds (5 in this example). If this fails it will raise an `DBLock::AlreadyLocked` error. The lock is released after the block is executed, even if the block raised an error itself.
28
+ Before the code block is executed, it will attempt to acquire a db lock for X seconds (5 in this example). If this fails it will raise an `DBLock::AlreadyLocked` error. The lock is released after the block is executed, even if the block raised an error itself.
28
29
 
29
- The current implementation uses a class variable to store lock state so it is not thread-safe when using multiple threads to acquire/release locks.
30
+ The locking will already fail with an error if the current thread already holds a lock via this gem.
30
31
 
31
- ## Smart lock name
32
+ Locks are achieved on the database via:
33
+
34
+ | Database | Locking method |
35
+ |-----------|--------------------|
36
+ | MySQL | `GET_LOCK` |
37
+ | Postgres | `pg_advisory_lock` |
38
+ | SQLServer | `sp_getapplock` |
39
+
40
+ ## Dynamic lock name
32
41
 
33
42
  If you prefix the lock with a `.` in a Rails application, `.` will be automatically replaced with `YourAppName.environment` (production/development/etc).
34
43
  If the lock name exceeds 64 characters, it will be replaced with a lock name of 64 characters, that consists of a pre- and suffix from the original lock name and a middle MD5 checksum.
@@ -3,14 +3,41 @@ module DBLock
3
3
  class Base
4
4
  include Singleton
5
5
 
6
+ def execute(*args)
7
+ run_sanitized :execute, args
8
+ end
9
+
10
+ def select_one(*args)
11
+ run_sanitized :select_one, args
12
+ end
13
+
14
+ def select_value(*args)
15
+ run_sanitized :select_value, args
16
+ end
17
+
6
18
  private
7
19
 
8
20
  def connection
9
21
  DBLock.db_handler.connection
10
22
  end
11
23
 
24
+ def pool
25
+ DBLock.db_handler.connection_pool
26
+ end
27
+
28
+ def logger
29
+ DBLock.db_handler.logger
30
+ end
31
+
12
32
  def sanitize_sql_array(*args)
13
- DBLock.db_handler.send(:sanitize_sql_array, args)
33
+ DBLock.db_handler.sanitize_sql_array args
34
+ end
35
+
36
+ def run_sanitized(command, args)
37
+ options = args.extract_options!
38
+ con = options[:connection] || connection
39
+ sql = sanitize_sql_array(*args)
40
+ con.public_send(command, sql)
14
41
  end
15
42
  end
16
43
  end
@@ -2,15 +2,13 @@ module DBLock
2
2
  module Adapter
3
3
  class MYSQL < Base
4
4
  def lock(name, timeout = 0)
5
- sql = sanitize_sql_array 'SELECT GET_LOCK(?, ?)', name, timeout
6
- res = connection.select_one sql
7
- (res && res.values.first == 1)
5
+ res = select_value 'SELECT GET_LOCK(?, ?)', name, timeout
6
+ res == 1
8
7
  end
9
8
 
10
9
  def release(name)
11
- sql = sanitize_sql_array 'SELECT RELEASE_LOCK(?)', name
12
- res = connection.select_one sql
13
- (res && res.values.first == 1)
10
+ res = select_value 'SELECT RELEASE_LOCK(?)', name
11
+ res == 1
14
12
  end
15
13
  end
16
14
  end
@@ -0,0 +1,66 @@
1
+ require 'timeout'
2
+
3
+ module DBLock
4
+ module Adapter
5
+ LockTimeout = Class.new(Timeout::Error)
6
+
7
+ class Postgres < Base
8
+ def lock(name, timeout = 0)
9
+ pid = connection_pid(connection)
10
+ Timeout.timeout(timeout, LockTimeout) do
11
+ execute lock_query(name)
12
+ # Sadly this returns void in postgres
13
+ true
14
+ end
15
+ rescue LockTimeout
16
+ logger&.info 'DBLock: Recovering from expired lock query'
17
+ recover_from_timeout pid, name
18
+ end
19
+
20
+ def release(name)
21
+ res = select_value 'SELECT pg_advisory_unlock(hashtext(?))', name
22
+ res == true
23
+ end
24
+
25
+ private
26
+
27
+ def lock_query(name)
28
+ sanitize_sql_array 'SELECT pg_advisory_lock(hashtext(?))', name
29
+ end
30
+
31
+ def connection_pid(con)
32
+ select_value 'SELECT pg_backend_pid()', connection: con
33
+ end
34
+
35
+ # We have to manually kill the lock query.
36
+ # Connection pool keeps it alive blocking one connection.
37
+ # Also it would eventually acquire the lock.
38
+ # returns true if lock was acquired
39
+ def recover_from_timeout(pid, name)
40
+ with_dedicated_connection do |con|
41
+ lock = select_one(<<~SQL, pid, name, connection: con)
42
+ SELECT locktype, objid, pid, granted FROM pg_locks \
43
+ WHERE pid = ? AND locktype = 'advisory' AND objid = hashtext(?)
44
+ SQL
45
+ return false unless lock
46
+
47
+ if lock['granted']
48
+ logger&.info 'DBLock: Lock was acquired after all'
49
+ true
50
+ else
51
+ res = select_value 'SELECT pg_cancel_backend(?)', pid, connection: con
52
+ logger&.warn 'DBLock: Failed to cancel ungranted lock query' unless res == true
53
+ false
54
+ end
55
+ end
56
+ end
57
+
58
+ def with_dedicated_connection
59
+ con = pool.checkout
60
+ yield con
61
+ ensure
62
+ pool.checkin con
63
+ end
64
+ end
65
+ end
66
+ end
@@ -4,6 +4,7 @@ module DBLock
4
4
 
5
5
  autoload :Base, 'db_lock/adapter/base'
6
6
  autoload :MYSQL, 'db_lock/adapter/mysql'
7
+ autoload :Postgres, 'db_lock/adapter/postgres'
7
8
  autoload :Sqlserver, 'db_lock/adapter/sqlserver'
8
9
 
9
10
  delegate :lock, :release, to: :implementation
@@ -12,6 +13,8 @@ module DBLock
12
13
  case DBLock.db_handler.connection.adapter_name.downcase
13
14
  when 'mysql2'
14
15
  MYSQL.instance
16
+ when 'postgresql'
17
+ Postgres.instance
15
18
  when 'sqlserver'
16
19
  Sqlserver.instance
17
20
  else
data/lib/db_lock/lock.rb CHANGED
@@ -4,48 +4,12 @@ module DBLock
4
4
  module Lock
5
5
  extend self
6
6
 
7
- # rubocop:disable Metrics/AbcSize
8
- def get(name, timeout = 0)
9
- timeout = timeout.to_f # catches nil
10
- timeout = 0 if timeout.negative?
11
-
12
- raise "Invalid lock name: #{name.inspect}" if name.empty?
13
- raise AlreadyLocked, 'Already lock in progress' if locked?
14
-
15
- name = generate_lock_name(name)
16
-
17
- if Adapter.lock(name, timeout)
18
- @locked = true
19
- yield
20
- else
21
- raise AlreadyLocked, "Unable to obtain lock '#{name}' within #{timeout} seconds" unless locked?
22
- end
23
- ensure
24
- Adapter.release(name) if locked?
25
- @locked = false
7
+ def get(name, timeout = 0, &block)
8
+ DBLock.with_lock(name, timeout, &block)
26
9
  end
27
- # rubocop:enable Metrics/AbcSize
28
10
 
29
11
  def locked?
30
- @locked ||= false
31
- end
32
-
33
- private
34
-
35
- def generate_lock_name(name)
36
- name = "#{rails_app_name}.#{Rails.env}#{name}" if name[0] == '.' && defined? Rails
37
- # reduce lock names of > 64 chars in size
38
- # MySQL 5.7 only supports 64 chars max, there might be similar limitations elsewhere
39
- name = "#{name.chars.first(15).join}-#{Digest::MD5.hexdigest(name)}-#{name.chars.last(15).join}" if name.length > 64
40
- name
41
- end
42
-
43
- def rails_app_name
44
- if Gem::Version.new(Rails.version) >= Gem::Version.new('6.0.0')
45
- Rails.application.class.module_parent_name
46
- else
47
- Rails.application.class.parent_name
48
- end
12
+ DBLock.send(:locked?)
49
13
  end
50
14
  end
51
15
  end
@@ -0,0 +1,47 @@
1
+ require 'digest/md5'
2
+
3
+ module DBLock
4
+ module Locking
5
+ def with_lock(name, timeout = 0)
6
+ timeout = timeout.to_f # catches nil
7
+ timeout = 0 if timeout.negative?
8
+
9
+ raise ArgumentError, "Invalid lock name: #{name.inspect}" if name.empty?
10
+ raise AlreadyLocked, 'Already lock in progress' if locked?
11
+
12
+ name = generate_lock_name(name)
13
+
14
+ if Adapter.lock(name, timeout)
15
+ @locked = true
16
+ yield
17
+ else
18
+ raise AlreadyLocked, "Unable to obtain lock '#{name}' within #{timeout} seconds" unless locked?
19
+ end
20
+ ensure
21
+ Adapter.release(name) if locked?
22
+ @locked = false
23
+ end
24
+
25
+ private
26
+
27
+ def locked?
28
+ @locked ||= false
29
+ end
30
+
31
+ def generate_lock_name(name)
32
+ name = "#{rails_app_name}.#{Rails.env}#{name}" if name[0] == '.' && defined? Rails
33
+ # reduce lock names of > 64 chars in size
34
+ # MySQL 5.7 only supports 64 chars max, there might be similar limitations elsewhere
35
+ name = "#{name.chars.first(15).join}-#{Digest::MD5.hexdigest(name)}-#{name.chars.last(15).join}" if name.length > 64
36
+ name
37
+ end
38
+
39
+ def rails_app_name
40
+ if Gem::Version.new(Rails.version) >= Gem::Version.new('6.0.0')
41
+ Rails.application.class.module_parent_name
42
+ else
43
+ Rails.application.class.parent_name
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/db_lock.rb CHANGED
@@ -1,17 +1,25 @@
1
+ require 'active_support'
1
2
  require 'digest/md5'
2
3
 
3
4
  module DBLock
4
- extend self
5
-
6
5
  autoload :Adapter, 'db_lock/adapter'
7
6
  autoload :Lock, 'db_lock/lock'
7
+ autoload :Locking, 'db_lock/locking'
8
+
9
+ extend Locking
8
10
 
9
11
  class AlreadyLocked < StandardError; end
10
12
 
11
- attr_accessor :db_handler
13
+ attr_writer :db_handler
12
14
 
13
- def db_handler
15
+ def self.db_handler
14
16
  # this must be an active record base object or subclass
15
17
  @db_handler || ActiveRecord::Base
16
18
  end
19
+
20
+ custom_deprecator = ActiveSupport::Deprecation.new('1.0.0', 'DBLock')
21
+ ActiveSupport::Deprecation.deprecate_methods(DBLock::Lock, get: 'use DBLock.with_lock instead',
22
+ deprecator: custom_deprecator)
23
+ ActiveSupport::Deprecation.deprecate_methods(DBLock::Lock, locked?: 'will be removed without replacement',
24
+ deprecator: custom_deprecator)
17
25
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: db_lock
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.3
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mkon
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-05-26 00:00:00.000000000 Z
11
+ date: 2022-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -50,28 +50,28 @@ dependencies:
50
50
  requirements:
51
51
  - - '='
52
52
  - !ruby/object:Gem::Version
53
- version: 1.29.1
53
+ version: 1.36.0
54
54
  type: :development
55
55
  prerelease: false
56
56
  version_requirements: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - '='
59
59
  - !ruby/object:Gem::Version
60
- version: 1.29.1
60
+ version: 1.36.0
61
61
  - !ruby/object:Gem::Dependency
62
62
  name: rubocop-rspec
63
63
  requirement: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - '='
66
66
  - !ruby/object:Gem::Version
67
- version: 2.11.1
67
+ version: 2.12.1
68
68
  type: :development
69
69
  prerelease: false
70
70
  version_requirements: !ruby/object:Gem::Requirement
71
71
  requirements:
72
72
  - - '='
73
73
  - !ruby/object:Gem::Version
74
- version: 2.11.1
74
+ version: 2.12.1
75
75
  - !ruby/object:Gem::Dependency
76
76
  name: simplecov
77
77
  requirement: !ruby/object:Gem::Requirement
@@ -100,8 +100,10 @@ files:
100
100
  - lib/db_lock/adapter.rb
101
101
  - lib/db_lock/adapter/base.rb
102
102
  - lib/db_lock/adapter/mysql.rb
103
+ - lib/db_lock/adapter/postgres.rb
103
104
  - lib/db_lock/adapter/sqlserver.rb
104
105
  - lib/db_lock/lock.rb
106
+ - lib/db_lock/locking.rb
105
107
  homepage: https://github.com/mkon/db_lock
106
108
  licenses:
107
109
  - MIT