with_transactional_lock 1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 39e6b2f7021e9656666cecc940c89da9b7e8152b
4
+ data.tar.gz: aa13d311218556caa23e1ea7f8212f6cfc920d55
5
+ SHA512:
6
+ metadata.gz: 478fd8a135e213bc74a9b1bd3d446a8091761685b19b1936bc129bafe9491dd9d8bc6d0b99ad412bedc314b3aaebfdba042c5a7aaa7c27e3b2d2201dde556aca
7
+ data.tar.gz: adc45942dd45574a4b6a1a1f9b14df677b3c9f350dccdd2bf82d3a4ee388e526572c6c8f19bbc50e0f0c1027ecece4407eaf008f8bebc5cbcefec6a17739869a
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016-2018 Betterment
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # with_transactional_lock
2
+
3
+ [![Build Status](https://travis-ci.com/Betterment/with_transactional_lock.svg?token=6b6DErRMUHX47kEoBZ3t&branch=master)](https://travis-ci.com/Betterment/with_transactional_lock)
4
+
5
+ A simple extension to ActiveRecord for performing advisory locking on
6
+ MySQL and PostgreSQL.
7
+
8
+ An advisory lock is a database-level mutex that can be used to prevent
9
+ concurrent access to a shared resource or to prevent two workers from
10
+ performing the same process concurrently.
11
+
12
+ This gem is different from other advisory locking gems because it
13
+ uses advisory transaction locks instead of advisory session locks.
14
+
15
+ ## Why transactional?
16
+
17
+ At Betterment correctness is paramount. As such, we chose to use
18
+ transaction-level locks because we find them to be more trustworthy for
19
+ our production use.
20
+
21
+ Some types of advisory locks can be held for the lifetime of a database
22
+ session. In a typical database connection pooling configuration, like
23
+ that of ActiveRecord, a connection and its associated session are not
24
+ reset when the connection is returned to the pool. In that case, if you
25
+ do not properly release a session-level lock, it will leak and become
26
+ effectively unreleasable. In our Betterment production systems, that
27
+ type of risk is unacceptable. Fortunately, because ActiveRecord
28
+ doesn't leak open transactions back into the connection pool, we can
29
+ use transactional locks as our safety device rather than becoming
30
+ familiar with the nuances necessary to be confident that we are
31
+ preventing leaks.
32
+
33
+ Additionally, application developers tend to think about discrete units
34
+ of database work in terms of transactions. By leveraging the transaction
35
+ boundary, we ensure the advisory lock is released at the earliest
36
+ possible moment that it can be and no sooner.
37
+
38
+ ## Lock acquisition efficiency & fairness
39
+
40
+ Some libraries providing advisory locking use try-lock semantics. This
41
+ library uses a blocking strategy for lock acquisition. It will wait
42
+ until the lock can be acquired instead of immediately returning false
43
+ and forcing the application layer to manage retry behavior.
44
+ Additionally, by waiting in line (in the database) for locks that cannot
45
+ be immediately acquired, you get fairness in the acquisition sequence.
46
+
47
+ Notably, this library performs lock-waiting in the database rather than
48
+ using `Timeout.timeout`. That means that the waiting is bounded by your
49
+ database timeout rather than a library-specific option. We see this as a
50
+ strength of the library. In practice, we have found that if there is a
51
+ chance of contention that you can't afford to wait for, you should
52
+ perform the operation that requires the lock asynchronously and/or
53
+ reduce the time spent in your critical section so that you can afford to
54
+ wait.
55
+
56
+ In contrast, when using a try-based strategy your ability to acquire a
57
+ lock can get worse at higher levels of concurrency. You may spend more
58
+ time spinning in application code issuing requests for a lock instead of
59
+ waiting on I/O (possibly allowing another thread to use the CPU).
60
+
61
+ ## Installation
62
+
63
+ Add this line to your application's Gemfile:
64
+
65
+ ``` ruby
66
+ gem 'with_transactional_lock'
67
+ ```
68
+
69
+ And then bundle install:
70
+
71
+ ```
72
+ $ bundle install
73
+ ```
74
+
75
+ And then if you're using MySQL, you will need to run the installer:
76
+
77
+ ```
78
+ $ rails g with_transactional_lock:install
79
+ ```
80
+
81
+ This will create a migration that will add an
82
+ `transactional_advisory_locks` table to your database.
83
+
84
+ ## Usage
85
+
86
+ Because transactional locks are meaningless outside of the context of a
87
+ transaction, we provide an interface that wraps your work in a
88
+ transaction and acquires the lock.
89
+
90
+ ```ruby
91
+ ActiveRecord::Base.with_transactional_lock('name_of_a_resource') do
92
+ # do something critical in here
93
+ # this block is already inside a transaction
94
+ end
95
+ ```
96
+
97
+ This call will attempt to acquire an exclusive lock using the provided
98
+ lock name. It will wait indefinitely for that lock -- or at least as
99
+ long as your database connection timeout is willing to allow. Once the
100
+ lock is acquired you will have exclusive ownership of the advisory lock
101
+ with the name that you provided. Your block is free to execute its
102
+ critical work. Upon completion of your transaction, the lock will be
103
+ released.
104
+
105
+ ## Supported databases
106
+
107
+ ### PostgreSQL
108
+
109
+ PostgreSQL has first-class support for transactional advisory locks via
110
+ `pg_advisory_xact_lock`. This is an exclusive lock that is held for the
111
+ duration of a given transaction and automatically released upon
112
+ transaction commit.
113
+
114
+ ### MySQL
115
+
116
+ MySQL does not have built-in support for transactional advisory locks.
117
+ So, MySQL gets a special treatment. We emulate the behavior of PostgreSQL
118
+ using a special `transactional_advisory_locks` table with a unique index
119
+ on the `lock_id` column. This allows us to provide the same transactional
120
+ and mutual exclusivity guarantees as PostgreSQL. The trade-off is that
121
+ you need to add another table to your database.
122
+
123
+ ## License
124
+
125
+ Any contributions made to this project are covered under the MIT License, found [here](LICENSE)
data/Rakefile ADDED
@@ -0,0 +1,41 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'WithTransactionalLock'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.rdoc')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
18
+ load 'rails/tasks/engine.rake'
19
+ load 'rails/tasks/statistics.rake'
20
+
21
+ Bundler::GemHelper.install_tasks
22
+
23
+ if Rails.env.development? || Rails.env.test?
24
+ if defined? Dummy
25
+ require 'rspec/core'
26
+ require 'rspec/core/rake_task'
27
+ require 'rubocop/rake_task'
28
+
29
+ RuboCop::RakeTask.new
30
+ RSpec::Core::RakeTask.new(:spec)
31
+
32
+ task(:default).clear
33
+ if ENV['APPRAISAL_INITIALIZED'] || ENV['TRAVIS']
34
+ task default: %i(rubocop spec)
35
+ else
36
+ require 'appraisal'
37
+ Appraisal::Task.new
38
+ task default: :appraisal
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ require 'rails/generators/base'
2
+ require 'rails/generators/active_record'
3
+
4
+ module WithTransactionalLock
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+ source_root File.expand_path('../templates', __FILE__)
9
+
10
+ def show_readme
11
+ readme 'README'
12
+ end
13
+
14
+ def create_with_transactional_lock_migration
15
+ if mysql?
16
+ migration_template(
17
+ 'db/migrate/create_transactional_advisory_locks.rb',
18
+ 'db/migrate/create_transactional_advisory_locks.rb'
19
+ )
20
+ end
21
+ end
22
+
23
+ def self.next_migration_number(dir)
24
+ ActiveRecord::Generators::Base.next_migration_number(dir)
25
+ end
26
+
27
+ private
28
+
29
+ def mysql?
30
+ ActiveRecord::Base.connection.adapter_name.downcase =~ /mysql/
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,8 @@
1
+ *******************************************************************************
2
+
3
+ If you're using MySql, then run `rake db:migrate` to setup the `transactional_advisory_locks`
4
+ table.
5
+
6
+ If you're using Postgresql, then you're all set!
7
+
8
+ *******************************************************************************
@@ -0,0 +1,8 @@
1
+ class CreateTransactionalAdvisoryLocks < ActiveRecord::Migration
2
+ def change
3
+ create_table :transactional_advisory_locks, id: false do |t|
4
+ t.integer :lock_id, null: false, limit: 8
5
+ end
6
+ add_index(:transactional_advisory_locks, :lock_id, unique: true, name: 'index_transactional_advisory_locks_on_lock_id')
7
+ end
8
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :with_transactional_lock do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,11 @@
1
+ require "with_transactional_lock/engine"
2
+
3
+ module WithTransactionalLock
4
+ extend ActiveSupport::Autoload
5
+ autoload :Mixin
6
+ autoload :MySqlHelper
7
+ end
8
+
9
+ ActiveSupport.on_load :active_record do
10
+ ActiveRecord::Base.send :include, WithTransactionalLock::Mixin
11
+ end
@@ -0,0 +1,4 @@
1
+ module WithTransactionalLock
2
+ class Engine < ::Rails::Engine
3
+ end
4
+ end
@@ -0,0 +1,72 @@
1
+ require 'active_support/concern'
2
+
3
+ module WithTransactionalLock
4
+ module Mixin
5
+ extend ActiveSupport::Concern
6
+ delegate :with_transactional_lock, to: :class
7
+
8
+ module ClassMethods
9
+ def with_transactional_lock(lock_name, &block)
10
+ _advisory_lock_class.new(connection, lock_name).yield_with_lock(&block)
11
+ end
12
+
13
+ private
14
+
15
+ def _advisory_lock_class
16
+ @_advisory_lock_class ||= AdvisoryLockClassLocator.locate(connection)
17
+ end
18
+ end
19
+
20
+ module AdvisoryLockClassLocator
21
+ def self.locate(connection)
22
+ adapter = connection.adapter_name.downcase.to_sym
23
+ case adapter
24
+ when :mysql, :mysql2
25
+ MySqlAdvisoryLock
26
+ when :postgresql
27
+ PostgresAdvisoryLock
28
+ else
29
+ raise "adapter not supported: #{adapter}"
30
+ end
31
+ end
32
+ end
33
+
34
+ class AdvisoryLockBase
35
+ attr_reader :connection, :lock_name
36
+
37
+ def initialize(connection, lock_name)
38
+ @connection = connection
39
+ @lock_name = lock_name
40
+ end
41
+
42
+ def yield_with_lock
43
+ connection.transaction do
44
+ acquire_lock
45
+ yield
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def db_lock_name
52
+ @db_lock_name ||= Digest::SHA256.digest(lock_name)[0, 8].unpack('q').first
53
+ end
54
+ end
55
+
56
+ class MySqlAdvisoryLock < AdvisoryLockBase
57
+ private
58
+
59
+ def acquire_lock
60
+ connection.execute("insert into transactional_advisory_locks values (#{connection.quote(db_lock_name)}) on duplicate key update lock_id = lock_id")
61
+ end
62
+ end
63
+
64
+ class PostgresAdvisoryLock < AdvisoryLockBase
65
+ private
66
+
67
+ def acquire_lock
68
+ connection.execute("select pg_advisory_xact_lock(#{connection.quote(db_lock_name)})")
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,13 @@
1
+ module WithTransactionalLock
2
+ class MySqlHelper
3
+ def self.cleanup(klass = ActiveRecord::Base)
4
+ klass.connection_pool.with_connection do |conn|
5
+ target_count = conn.select_value('select count(1) from transactional_advisory_locks')
6
+ count = 0
7
+ until count >= target_count
8
+ count += conn.delete('delete from transactional_advisory_locks limit 1000')
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module WithTransactionalLock
2
+ VERSION = "1.0.0"
3
+ end
metadata ADDED
@@ -0,0 +1,174 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: with_transactional_lock
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Sam Moore
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-05-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '6'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '4.2'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '6'
33
+ - !ruby/object:Gem::Dependency
34
+ name: appraisal
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 2.2.0
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 2.2.0
47
+ - !ruby/object:Gem::Dependency
48
+ name: database_cleaner
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: mime-types
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "<"
66
+ - !ruby/object:Gem::Version
67
+ version: '3'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "<"
73
+ - !ruby/object:Gem::Version
74
+ version: '3'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rspec-rails
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.1'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.1'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rspec-retry
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rubocop-betterment
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ - !ruby/object:Gem::Dependency
118
+ name: travis
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ description: Advisory locking support for MySQL and Postgresql done right.
132
+ email:
133
+ - sam@betterment.com
134
+ executables: []
135
+ extensions: []
136
+ extra_rdoc_files: []
137
+ files:
138
+ - LICENSE
139
+ - README.md
140
+ - Rakefile
141
+ - lib/generators/with_transactional_lock/install/install_generator.rb
142
+ - lib/generators/with_transactional_lock/install/templates/README
143
+ - lib/generators/with_transactional_lock/install/templates/db/migrate/create_transactional_advisory_locks.rb
144
+ - lib/tasks/with_transactional_lock_tasks.rake
145
+ - lib/with_transactional_lock.rb
146
+ - lib/with_transactional_lock/engine.rb
147
+ - lib/with_transactional_lock/mixin.rb
148
+ - lib/with_transactional_lock/my_sql_helper.rb
149
+ - lib/with_transactional_lock/version.rb
150
+ homepage: https://github.com/Betterment/with_transactional_lock
151
+ licenses:
152
+ - MIT
153
+ metadata: {}
154
+ post_install_message:
155
+ rdoc_options: []
156
+ require_paths:
157
+ - lib
158
+ required_ruby_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '0'
163
+ required_rubygems_version: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: '0'
168
+ requirements: []
169
+ rubyforge_project:
170
+ rubygems_version: 2.5.1
171
+ signing_key:
172
+ specification_version: 4
173
+ summary: Transactional advisory locks for ActiveRecord
174
+ test_files: []