with-advisory-lock-5.1.0/0000775000175000017500000000000014704252310014312 5ustar sorensorenwith-advisory-lock-5.1.0/.github/0000775000175000017500000000000014704252310015652 5ustar sorensorenwith-advisory-lock-5.1.0/.github/workflows/0000775000175000017500000000000014704252310017707 5ustar sorensorenwith-advisory-lock-5.1.0/.github/workflows/ci.yml0000664000175000017500000000420514704252310021026 0ustar sorensoren--- name: CI on: pull_request: branches: - master jobs: minitest: runs-on: ubuntu-latest services: mysql: image: mysql/mysql-server:5.7 ports: - "3306:3306" env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: with_advisory_lock_test MYSQL_ROOT_HOST: '%' postgres: image: 'postgres:14-alpine' ports: ['5432:5432'] env: POSTGRES_USER: closure_tree POSTGRES_PASSWORD: closure_tree POSTGRES_DB: with_advisory_lock_test options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 strategy: fail-fast: false matrix: ruby: - '3.2' - '3.1' - '3.0' - '2.7' - 'truffleruby' rails: - activerecord_7.1 - activerecord_7.0 - activerecord_6.1 adapter: - sqlite3:///tmp/test.sqlite3 - mysql2://root:root@0/with_advisory_lock_test - trilogy://root:root@0/with_advisory_lock_test - postgres://closure_tree:closure_tree@0/with_advisory_lock_test include: - ruby: jruby rails: activerecord_6.1 adapter: jdbcmysql://root:root@0/with_advisory_lock_test - ruby: jruby rails: activerecord_6.1 adapter: jdbcsqlite3:///tmp/test.sqlite3 - ruby: jruby rails: activerecord_6.1 adapter: jdbcpostgresql://closure_tree:closure_tree@0/with_advisory_lock_test steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true rubygems: latest env: BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - name: Test env: BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile DATABASE_URL: ${{ matrix.adapter }} WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }} run: bundle exec rake with-advisory-lock-5.1.0/.github/workflows/release.yml0000664000175000017500000000050514704252310022052 0ustar sorensorenname: release-please on: push: branches: - master workflow_dispatch: permissions: contents: write pull-requests: write jobs: release-please: runs-on: ubuntu-latest steps: - uses: google-github-actions/release-please-action@v4 id: release with: command: manifest with-advisory-lock-5.1.0/.gitignore0000664000175000017500000000027614704252310016307 0ustar sorensoren*.gem *.rbc *.history *.idea .bundle .config .yardoc *.lock InstalledFiles _yardoc coverage doc/ lib/bundler/man pkg rdoc spec/reports test/tmp test/version_tmp tmp *.iml test/sqlite.db .envwith-advisory-lock-5.1.0/.release-please-manifest.json0000664000175000017500000000001614704252310021753 0ustar sorensoren{".":"5.1.0"} with-advisory-lock-5.1.0/.tool-versions0000664000175000017500000000001314704252310017130 0ustar sorensorenruby 3.0.5 with-advisory-lock-5.1.0/Appraisals0000664000175000017500000000162014704252310016333 0ustar sorensoren# frozen_string_literal: true appraise 'activerecord-7.1' do gem 'activerecord', '~> 7.1.0' platforms :ruby do gem 'sqlite3' gem 'mysql2' gem 'trilogy' gem 'pg' end end appraise 'activerecord-7.0' do gem 'activerecord', '~> 7.0.0' platforms :ruby do gem 'sqlite3' gem 'mysql2' gem 'trilogy' gem "activerecord-trilogy-adapter" gem 'pg' end platforms :jruby do gem "activerecord-jdbcmysql-adapter" gem "activerecord-jdbcpostgresql-adapter" gem "activerecord-jdbcsqlite3-adapter" end end appraise 'activerecord-6.1' do gem 'activerecord', '~> 6.1.0' platforms :ruby do gem 'sqlite3' gem 'mysql2' gem 'trilogy' gem "activerecord-trilogy-adapter" gem 'pg' end platforms :jruby do gem "activerecord-jdbcmysql-adapter" gem "activerecord-jdbcpostgresql-adapter" gem "activerecord-jdbcsqlite3-adapter" end end with-advisory-lock-5.1.0/CHANGELOG.md0000664000175000017500000001521614704252310016130 0ustar sorensoren## Changelog ## [5.1.0](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v5.0.1...with_advisory_lock/v5.1.0) (2024-01-21) ### Features * use zeitwerk loader instead of ActiveSupport::Autoload ([b5082fd](https://github.com/ClosureTree/with_advisory_lock/commit/b5082fddacacacff48139f5bf509601a37945a0e)) ## 5.0.1 (2024-01-21) ### Features * add release workflow ([5d32520](https://github.com/ClosureTree/with_advisory_lock/commit/5d325201c82974991381a9fbc4d1714c9739dc4f)) * add ruby 3.1 test/support ([#60](https://github.com/ClosureTree/with_advisory_lock/issues/60)) ([514f042](https://github.com/ClosureTree/with_advisory_lock/commit/514f0420d957ef30911a00d54685385bec5867c3)) * Add testing for activerecord 7.1 and support for trilogy adapter ([#77](https://github.com/ClosureTree/with_advisory_lock/issues/77)) ([69c23fe](https://github.com/ClosureTree/with_advisory_lock/commit/69c23fe09887fc5d97ac7b0194825c21efe244a5)) * add truffleruby support ([#62](https://github.com/ClosureTree/with_advisory_lock/issues/62)) ([ec34bd4](https://github.com/ClosureTree/with_advisory_lock/commit/ec34bd448e3505e5df631daaf47bb83f2f5316dc)) ### Bug Fixes * User may sometimes pass in non-strings, such as integers ([#55](https://github.com/ClosureTree/with_advisory_lock/issues/55)) ([9885597](https://github.com/ClosureTree/with_advisory_lock/commit/988559747363ef00958fcf782317e76c40ffa2a3)) ### 5.0.0 - Drop support for EOL rubies and activerecord (ruby below 2.7 and activerecord below 6.1). - Allow lock name to be integer - Jruby support - Truffleruby support - Add `with_advisory_lock!`, which raises an error if the lock acquisition fails - Add `disable_query_cache` option to `with_advisory_lock` - Drop support for mysql < 5.7.5 ### 4.6.0 - Support for ActiveRecord 6 - Add Support for nested locks in MySQL ### 4.0.0 - Drop support for unsupported versions of activerecord - Drop support for unsupported versions of ruby ### 3.2.0 - [Joshua Flanagan](https://github.com/joshuaflanagan) [added a SQL comment to the lock query for PostgreSQL](https://github.com/ClosureTree/with_advisory_lock/pull/28). Thanks! - [Fernando Luizão](https://github.com/fernandoluizao) found a spurious requirement for `thread_safe`. Thanks for the [fix](https://github.com/ClosureTree/with_advisory_lock/pull/27)! ### 3.1.1 - [Joel Turkel](https://github.com/jturkel) added `require 'active_support'` (it was required, but relied on downstream gems to pull in active_support before pulling in with_advisory_lock). Thanks! ### 3.1.0 - [Jason Weathered](https://github.com/jasoncodes) Added new shared and transaction-level lock options ([Pull request 21](https://github.com/ClosureTree/with_advisory_lock/pull/21)). Thanks! - Added ActiveRecord 5.0 to build matrix. Dropped 3.2, 4.0, and 4.1 (which no longer get security updates: http://rubyonrails.org/security/) - Replaced ruby 1.9 and 2.0 (both EOL) with ruby 2.2 and 2.3 (see https://www.ruby-lang.org/en/downloads/) ### 3.0.0 - Added jruby/PostgreSQL support for Rails 4.x - Reworked threaded tests to allow jruby tests to pass #### API changes - `yield_with_lock_and_timeout` and `yield_with_lock` now return instances of `WithAdvisoryLock::Result`, so blocks that return `false` are not misinterpreted as a failure to lock. As this changes the interface (albeit internal methods), the major version number was incremented. - `with_advisory_lock_result` was introduced, which clarifies whether the lock was acquired versus the yielded block returned false. ### 2.0.0 - Lock timeouts of 0 now attempt the lock once, as per suggested by [Jon Leighton](https://github.com/jonleighton) and implemented by [Abdelkader Boudih](https://github.com/seuros). Thanks to both of you! - [Pull request 11](https://github.com/ClosureTree/with_advisory_lock/pull/11) fixed a downstream issue with jruby support! Thanks, [Aaron Todd](https://github.com/ozzyaaron)! - Added Travis tests for jruby - Dropped support for Rails 3.0, 3.1, and Ruby 1.8.7, as they are no longer receiving security patches. See http://rubyonrails.org/security/ for more information. This required the major version bump. - Refactored `advisory_lock_exists?` to use existing functionality - Fixed sqlite's implementation so parallel tests could be run against it ### 1.0.0 - Releasing 1.0.0. The interface will be stable. - Added `advisory_lock_exists?`. Thanks, [Sean Devine](https://github.com/barelyknown), for the great pull request! - Added Travis test for Rails 4.1 ### 0.0.10 - Explicitly added MIT licensing to the gemspec. ### 0.0.9 - Merged in Postgis Adapter Support to address [issue 7](https://github.com/ClosureTree/with_advisory_lock/issues/7) Thanks for the pull request, [Abdelkader Boudih](https://github.com/seuros)! - The database switching code had to be duplicated by [Closure Tree](https://github.com/ClosureTree/closure_tree), so I extracted a new `WithAdvisoryLock::DatabaseAdapterSupport` one-trick pony. - Builds were failing on Travis, so I introduced a global lock prefix that can be set with the `WITH_ADVISORY_LOCK_PREFIX` environment variable. I'm not going to advertise this feature yet. It's a secret. Only you and I know, now. _shhh_ ### 0.0.8 - Addressed [issue 5](https://github.com/ClosureTree/with_advisory_lock/issues/5) by using a deterministic hash for Postgresql + MRI >= 1.9. Thanks for the pull request, [Joel Turkel](https://github.com/jturkel)! - Addressed [issue 2](https://github.com/ClosureTree/with_advisory_lock/issues/2) by using a cache-busting query for MySQL and Postgres to deal with AR value caching bug. Thanks for the pull request, [Jaime Giraldo](https://github.com/sposmen)! - Addressed [issue 4](https://github.com/ClosureTree/with_advisory_lock/issues/4) by adding support for `em-postgresql-adapter`. Thanks, [lestercsp](https://github.com/lestercsp)! (Hey, github—your notifications are WAY too easy to ignore!) ### 0.0.7 - Added Travis tests for Rails 3.0, 3.1, 3.2, and 4.0 - Fixed MySQL bug with select_value returning a string instead of an integer when using AR 3.0.x ### 0.0.6 - Only require ActiveRecord >= 3.0.x - Fixed MySQL error reporting ### 0.0.5 - Asking for the currently acquired advisory lock doesn't re-ask for the lock now. - Introduced NestedAdvisoryLockError when asking for different, nested advisory locksMySQL ### 0.0.4 - Moved require into on_load, which should speed loading when AR doesn't have to spin up ### 0.0.3 - Fought with ActiveRecord 3.0.x and 3.1.x. You don't want them if you use threads—they fail predictably. ### 0.0.2 - Added warning log message for nested MySQL lock calls - Randomized lock wait time, which can help ameliorate lock contention ### 0.0.1 - First whack with-advisory-lock-5.1.0/Gemfile0000664000175000017500000000004714704252310015606 0ustar sorensorensource 'https://rubygems.org' gemspec with-advisory-lock-5.1.0/LICENSE.txt0000664000175000017500000000206014704252310016133 0ustar sorensorenCopyright (c) 2013 Matthew McEachen MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.with-advisory-lock-5.1.0/README.md0000664000175000017500000001613514704252310015577 0ustar sorensoren# with_advisory_lock Adds advisory locking (mutexes) to ActiveRecord 6.0+, with ruby 2.7+, jruby or truffleruby, when used with [MySQL](https://dev.mysql.com/doc/refman/8.0/en/miscellaneous-functions.html#function_get-lock) or [PostgreSQL](https://www.postgresql.org/docs/current/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS). SQLite resorts to file locking. [![Gem Version](https://badge.fury.io/rb/with_advisory_lock.svg)](https://badge.fury.io/rb/with_advisory_lock) [![CI](https://github.com/ClosureTree/with_advisory_lock/actions/workflows/ci.yml/badge.svg)](https://github.com/ClosureTree/with_advisory_lock/actions/workflows/ci.yml) ## What's an "Advisory Lock"? An advisory lock is a [mutex](https://en.wikipedia.org/wiki/Mutual_exclusion) used to ensure no two processes run some process at the same time. When the advisory lock is powered by your database server, as long as it isn't SQLite, your mutex spans hosts. ## Usage This gem automatically includes the `WithAdvisoryLock` module in all of your ActiveRecord models. Here's an example of how to use it where `User` is an ActiveRecord model, and `lock_name` is some string: ```ruby User.with_advisory_lock(lock_name) do do_something_that_needs_locking end ``` ### What happens 1. The thread will wait indefinitely until the lock is acquired. 2. While inside the block, you will exclusively own the advisory lock. 3. The lock will be released after your block ends, even if an exception is raised in the block. ### Lock wait timeouts `with_advisory_lock` takes an options hash as the second parameter. The `timeout_seconds` option defaults to `nil`, which means wait indefinitely for the lock. A value of zero will try the lock only once. If the lock is acquired, the block will be yielded to. If the lock is currently being held, the block will not be called. > **Note** > > If a non-nil value is provided for `timeout_seconds`, the block will *not* be invoked if the lock cannot be acquired within that time-frame. In this case, `with_advisory_lock` will return `false`, while `with_advisory_lock!` will raise a `WithAdvisoryLock::FailedToAcquireLock` error. For backwards compatability, the timeout value can be specified directly as the second parameter. ### Shared locks The `shared` option defaults to `false` which means an exclusive lock will be obtained. Setting `shared` to `true` will allow locks to be obtained by multiple actors as long as they are all shared locks. Note: MySQL does not support shared locks. ### Transaction-level locks PostgreSQL supports transaction-level locks which remain held until the transaction completes. You can enable this by setting the `transaction` option to `true`. Note: transaction-level locks will not be reflected by `.current_advisory_lock` when the block has returned. ### Return values The return value of `with_advisory_lock_result` is a `WithAdvisoryLock::Result` instance, which has a `lock_was_acquired?` method and a `result` accessor method, which is the returned value of the given block. If your block may validly return false, you should use this method. The return value of `with_advisory_lock` will be the result of the yielded block, if the lock was able to be acquired and the block yielded, or `false`, if you provided a timeout_seconds value and the lock was not able to be acquired in time. `with_advisory_lock!` is similar to `with_advisory_lock`, but raises a `WithAdvisoryLock::FailedToAcquireLock` error if the lock was not able to be acquired in time. ### Testing for the current lock status If you needed to check if the advisory lock is currently being held, you can call `Tag.advisory_lock_exists?("foo")`, but realize the lock can be acquired between the time you test for the lock, and the time you try to acquire the lock. If you want to see if the current Thread is holding a lock, you can call `Tag.current_advisory_lock` which will return the name of the current lock. If no lock is currently held, `.current_advisory_lock` returns `nil`. ### ActiveRecord Query Cache You can optionally pass `disable_query_cache: true` to the options hash of `with_advisory_lock` in order to disable ActiveRecord's query cache. This can prevent problems when you query the database from within the lock and it returns stale results. More info on why this can be a problem can be [found here](https://github.com/ClosureTree/with_advisory_lock/issues/52) ## Installation Add this line to your application's Gemfile: ```ruby gem 'with_advisory_lock' ``` And then execute: $ bundle ## Lock Types First off, know that there are **lots** of different kinds of locks available to you. **Pick the finest-grain lock that ensures correctness.** If you choose a lock that is too coarse, you are unnecessarily blocking other processes. ### Advisory locks These are named mutexes that are inherently "application level"—it is up to the application to acquire, run a critical code section, and release the advisory lock. ### Row-level locks Whether [optimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html) or [pessimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html), row-level locks prevent concurrent modification to a given model. **If you're building a [CRUD](http://en.wikipedia.org/wiki/Create,_read,_update_and_delete) application, this will be 2.4, 2.5 and your most commonly used lock.** ### Table-level locks Provided through something like the [monogamy](https://github.com/ClosureTree/monogamy) gem, these prevent concurrent access to **any instance of a model**. Their coarseness means they aren't going to be commonly applicable, and they can be a source of [deadlocks](http://en.wikipedia.org/wiki/Deadlock). ## FAQ ### Transactions and Advisory Locks Advisory locks with MySQL and PostgreSQL ignore database transaction boundaries. You will want to wrap your block within a transaction to ensure consistency. ### MySQL < 5.7.5 doesn't support nesting With MySQL < 5.7.5, if you ask for a _different_ advisory lock within a `with_advisory_lock` block, you will be releasing the parent lock (!!!). A `NestedAdvisoryLockError`will be raised in this case. If you ask for the same lock name, `with_advisory_lock` won't ask for the lock again, and the block given will be yielded to. This is not an issue in MySQL >= 5.7.5, and no error will be raised for nested lock usage. You can override this by passing `force_nested_lock_support: true` or `force_nested_lock_support: false` to the `with_advisory_lock` options. ### Is clustered MySQL supported? [No.](https://github.com/ClosureTree/with_advisory_lock/issues/16) ### There are many `lock-*` files in my project directory after test runs This is expected if you aren't using MySQL or Postgresql for your tests. See [issue 3](https://github.com/ClosureTree/with_advisory_lock/issues/3). SQLite doesn't have advisory locks, so we resort to file locking, which will only work if the `FLOCK_DIR` is set consistently for all ruby processes. In your `spec_helper.rb` or `minitest_helper.rb`, add a `before` and `after` block: ```ruby before do ENV['FLOCK_DIR'] = Dir.mktmpdir end after do FileUtils.remove_entry_secure ENV['FLOCK_DIR'] end ``` with-advisory-lock-5.1.0/Rakefile0000664000175000017500000000045114704252310015757 0ustar sorensorenrequire "bundler/gem_tasks" require 'yard' YARD::Rake::YardocTask.new do |t| t.files = ['lib/**/*.rb', 'README.md'] end require 'rake/testtask' Rake::TestTask.new do |t| t.libs.push 'lib' t.libs.push 'test' t.pattern = 'test/**/*_test.rb' t.verbose = true end task :default => :test with-advisory-lock-5.1.0/gemfiles/0000775000175000017500000000000014704252310016105 5ustar sorensorenwith-advisory-lock-5.1.0/gemfiles/activerecord_6.1.gemfile0000664000175000017500000000060714704252310022500 0ustar sorensoren# This file was generated by Appraisal source "https://rubygems.org" gem "activerecord", "~> 6.1.0" platforms :ruby do gem "sqlite3" gem "mysql2" gem "trilogy" gem "activerecord-trilogy-adapter" gem "pg" end platforms :jruby do gem "activerecord-jdbcmysql-adapter" gem "activerecord-jdbcpostgresql-adapter" gem "activerecord-jdbcsqlite3-adapter" end gemspec path: "../" with-advisory-lock-5.1.0/gemfiles/activerecord_7.0.gemfile0000664000175000017500000000060714704252310022500 0ustar sorensoren# This file was generated by Appraisal source "https://rubygems.org" gem "activerecord", "~> 7.0.0" platforms :ruby do gem "sqlite3" gem "mysql2" gem "trilogy" gem "activerecord-trilogy-adapter" gem "pg" end platforms :jruby do gem "activerecord-jdbcmysql-adapter" gem "activerecord-jdbcpostgresql-adapter" gem "activerecord-jdbcsqlite3-adapter" end gemspec path: "../" with-advisory-lock-5.1.0/gemfiles/activerecord_7.1.gemfile0000664000175000017500000000031514704252310022475 0ustar sorensoren# This file was generated by Appraisal source "https://rubygems.org" gem "activerecord", "~> 7.1.0" platforms :ruby do gem "sqlite3" gem "mysql2" gem "trilogy" gem "pg" end gemspec path: "../" with-advisory-lock-5.1.0/lib/0000775000175000017500000000000014704252310015060 5ustar sorensorenwith-advisory-lock-5.1.0/lib/with_advisory_lock.rb0000664000175000017500000000050314704252310021306 0ustar sorensorenrequire 'with_advisory_lock/version' require 'active_support' require 'zeitwerk' loader = Zeitwerk::Loader.for_gem loader.inflector.inflect( 'mysql' => 'MySQL', 'postgresql' => 'PostgreSQL', ) loader.setup module WithAdvisoryLock end ActiveSupport.on_load :active_record do include WithAdvisoryLock::Concern end with-advisory-lock-5.1.0/lib/with_advisory_lock/0000775000175000017500000000000014704252310020763 5ustar sorensorenwith-advisory-lock-5.1.0/lib/with_advisory_lock/base.rb0000664000175000017500000000574714704252310022237 0ustar sorensoren# frozen_string_literal: true require 'zlib' module WithAdvisoryLock class Result attr_reader :result def initialize(lock_was_acquired, result = false) @lock_was_acquired = lock_was_acquired @result = result end def lock_was_acquired? @lock_was_acquired end end FAILED_TO_LOCK = Result.new(false) LockStackItem = Struct.new(:name, :shared) class Base attr_reader :connection, :lock_name, :timeout_seconds, :shared, :transaction, :disable_query_cache def initialize(connection, lock_name, options) options = { timeout_seconds: options } unless options.respond_to?(:fetch) options.assert_valid_keys :timeout_seconds, :shared, :transaction, :disable_query_cache @connection = connection @lock_name = lock_name @timeout_seconds = options.fetch(:timeout_seconds, nil) @shared = options.fetch(:shared, false) @transaction = options.fetch(:transaction, false) @disable_query_cache = options.fetch(:disable_query_cache, false) end def lock_str @lock_str ||= "#{ENV['WITH_ADVISORY_LOCK_PREFIX']}#{lock_name}" end def lock_stack_item @lock_stack_item ||= LockStackItem.new(lock_str, shared) end def self.lock_stack # access doesn't need to be synchronized as it is only accessed by the current thread. Thread.current[:with_advisory_lock_stack] ||= [] end delegate :lock_stack, to: 'self.class' def already_locked? lock_stack.include? lock_stack_item end def with_advisory_lock_if_needed(&block) if disable_query_cache return lock_and_yield do ActiveRecord::Base.uncached(&block) end end lock_and_yield(&block) end def lock_and_yield(&block) if already_locked? Result.new(true, yield) elsif timeout_seconds == 0 yield_with_lock(&block) else yield_with_lock_and_timeout(&block) end end def stable_hashcode(input) if input.is_a? Numeric input.to_i else # Ruby MRI's String#hash is randomly seeded as of Ruby 1.9 so # make sure we use a deterministic hash. Zlib.crc32(input.to_s, 0) end end def yield_with_lock_and_timeout(&block) give_up_at = Time.now + @timeout_seconds if @timeout_seconds while @timeout_seconds.nil? || Time.now < give_up_at r = yield_with_lock(&block) return r if r.lock_was_acquired? # Randomizing sleep time may help reduce contention. sleep(rand(0.05..0.15)) end FAILED_TO_LOCK end def yield_with_lock if try_lock begin lock_stack.push(lock_stack_item) result = block_given? ? yield : nil Result.new(true, result) ensure lock_stack.pop release_lock end else FAILED_TO_LOCK end end # Prevent AR from caching results improperly def unique_column_name "t#{SecureRandom.hex}" end end end with-advisory-lock-5.1.0/lib/with_advisory_lock/concern.rb0000664000175000017500000000301514704252310022736 0ustar sorensoren# frozen_string_literal: true module WithAdvisoryLock module Concern extend ActiveSupport::Concern delegate :with_advisory_lock, :with_advisory_lock!, :advisory_lock_exists?, to: 'self.class' class_methods do def with_advisory_lock(lock_name, options = {}, &block) result = with_advisory_lock_result(lock_name, options, &block) result.lock_was_acquired? ? result.result : false end def with_advisory_lock!(lock_name, options = {}, &block) result = with_advisory_lock_result(lock_name, options, &block) raise WithAdvisoryLock::FailedToAcquireLock, lock_name unless result.lock_was_acquired? result.result end def with_advisory_lock_result(lock_name, options = {}, &block) impl = impl_class.new(connection, lock_name, options) impl.with_advisory_lock_if_needed(&block) end def advisory_lock_exists?(lock_name) impl = impl_class.new(connection, lock_name, 0) impl.already_locked? || !impl.yield_with_lock.lock_was_acquired? end def current_advisory_lock lock_stack_key = WithAdvisoryLock::Base.lock_stack.first lock_stack_key && lock_stack_key[0] end private def impl_class adapter = WithAdvisoryLock::DatabaseAdapterSupport.new(connection) if adapter.postgresql? WithAdvisoryLock::PostgreSQL elsif adapter.mysql? WithAdvisoryLock::MySQL else WithAdvisoryLock::Flock end end end end end with-advisory-lock-5.1.0/lib/with_advisory_lock/database_adapter_support.rb0000664000175000017500000000107014704252310026346 0ustar sorensoren# frozen_string_literal: true module WithAdvisoryLock class DatabaseAdapterSupport # Caches nested lock support by MySQL reported version @@mysql_nl_cache = {} @@mysql_nl_cache_mutex = Mutex.new def initialize(connection) @connection = connection @sym_name = connection.adapter_name.downcase.to_sym end def mysql? %i[mysql2 trilogy].include? @sym_name end def postgresql? %i[postgresql empostgresql postgis].include? @sym_name end def sqlite? @sym_name == :sqlite3 end end end with-advisory-lock-5.1.0/lib/with_advisory_lock/failed_to_acquire_lock.rb0000664000175000017500000000030614704252310025756 0ustar sorensoren# frozen_string_literal: true module WithAdvisoryLock class FailedToAcquireLock < StandardError def initialize(lock_name) super("Failed to acquire lock #{lock_name}") end end end with-advisory-lock-5.1.0/lib/with_advisory_lock/flock.rb0000664000175000017500000000145114704252310022407 0ustar sorensoren# frozen_string_literal: true require 'fileutils' module WithAdvisoryLock class Flock < Base def filename @filename ||= begin safe = lock_str.to_s.gsub(/[^a-z0-9]/i, '') fn = ".lock-#{safe}-#{stable_hashcode(lock_str)}" # Let the user specify a directory besides CWD. ENV['FLOCK_DIR'] ? File.expand_path(fn, ENV['FLOCK_DIR']) : fn end end def file_io @file_io ||= begin FileUtils.touch(filename) File.open(filename, 'r+') end end def try_lock raise ArgumentError, 'transaction level locks are not supported on SQLite' if transaction 0 == file_io.flock((shared ? File::LOCK_SH : File::LOCK_EX) | File::LOCK_NB) end def release_lock 0 == file_io.flock(File::LOCK_UN) end end end with-advisory-lock-5.1.0/lib/with_advisory_lock/mysql.rb0000664000175000017500000000145514704252310022462 0ustar sorensoren# frozen_string_literal: true module WithAdvisoryLock class MySQL < Base # See https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock def try_lock raise ArgumentError, 'shared locks are not supported on MySQL' if shared raise ArgumentError, 'transaction level locks are not supported on MySQL' if transaction execute_successful?("GET_LOCK(#{quoted_lock_str}, 0)") end def release_lock execute_successful?("RELEASE_LOCK(#{quoted_lock_str})") end def execute_successful?(mysql_function) sql = "SELECT #{mysql_function} AS #{unique_column_name}" connection.select_value(sql).to_i.positive? end # MySQL wants a string as the lock key. def quoted_lock_str connection.quote(lock_str) end end end with-advisory-lock-5.1.0/lib/with_advisory_lock/postgresql.rb0000664000175000017500000000257414704252310023523 0ustar sorensoren# frozen_string_literal: true module WithAdvisoryLock class PostgreSQL < Base # See http://www.postgresql.org/docs/9.1/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS def try_lock pg_function = "pg_try_advisory#{transaction ? '_xact' : ''}_lock#{shared ? '_shared' : ''}" execute_successful?(pg_function) end def release_lock return if transaction pg_function = "pg_advisory_unlock#{shared ? '_shared' : ''}" execute_successful?(pg_function) rescue ActiveRecord::StatementInvalid => e raise unless e.message =~ / ERROR: +current transaction is aborted,/ begin connection.rollback_db_transaction execute_successful?(pg_function) ensure connection.begin_db_transaction end end def execute_successful?(pg_function) comment = lock_name.to_s.gsub(%r{(/\*)|(\*/)}, '--') sql = "SELECT #{pg_function}(#{lock_keys.join(',')}) AS #{unique_column_name} /* #{comment} */" result = connection.select_value(sql) # MRI returns 't', jruby returns true. YAY! ['t', true].include?(result) end # PostgreSQL wants 2 32bit integers as the lock key. def lock_keys @lock_keys ||= [stable_hashcode(lock_name), ENV['WITH_ADVISORY_LOCK_PREFIX']].map do |ea| # pg advisory args must be 31 bit ints ea.to_i & 0x7fffffff end end end end with-advisory-lock-5.1.0/lib/with_advisory_lock/version.rb0000664000175000017500000000014114704252310022771 0ustar sorensoren# frozen_string_literal: true module WithAdvisoryLock VERSION = Gem::Version.new('5.1.0') end with-advisory-lock-5.1.0/release-please-config.json0000664000175000017500000000021414704252310021334 0ustar sorensoren{ "release-type": "ruby", "packages": { ".": { "release-type": "ruby", "package-name": "with_advisory_lock" } } } with-advisory-lock-5.1.0/test/0000775000175000017500000000000014704252310015271 5ustar sorensorenwith-advisory-lock-5.1.0/test/concern_test.rb0000664000175000017500000000172514704252310020311 0ustar sorensoren# frozen_string_literal: true require 'test_helper' class WithAdvisoryLockConcernTest < GemTestCase test 'adds with_advisory_lock to ActiveRecord classes' do assert_respond_to(Tag, :with_advisory_lock) end test 'adds with_advisory_lock to ActiveRecord instances' do assert_respond_to(Label.new, :with_advisory_lock) end test 'adds advisory_lock_exists? to ActiveRecord classes' do assert_respond_to(Tag, :advisory_lock_exists?) end test 'adds advisory_lock_exists? to ActiveRecord instances' do assert_respond_to(Label.new, :advisory_lock_exists?) end end class ActiveRecordQueryCacheTest < GemTestCase test 'does not disable quary cache by default' do ActiveRecord::Base.expects(:uncached).never Tag.with_advisory_lock('lock') { Tag.first } end test 'can disable ActiveRecord query cache' do ActiveRecord::Base.expects(:uncached).once Tag.with_advisory_lock('a-lock', disable_query_cache: true) { Tag.first } end end with-advisory-lock-5.1.0/test/lock_test.rb0000664000175000017500000000421514704252310017607 0ustar sorensoren# frozen_string_literal: true require 'test_helper' class LockTest < GemTestCase setup do @lock_name = 'test lock' @return_val = 1900 end test 'returns nil outside an advisory lock request' do assert_nil(Tag.current_advisory_lock) end test 'returns the name of the last lock acquired' do Tag.with_advisory_lock(@lock_name) do assert_match(/#{@lock_name}/, Tag.current_advisory_lock) end end test 'can obtain a lock with a name that attempts to disrupt a SQL comment' do dangerous_lock_name = 'test */ lock /*' Tag.with_advisory_lock(dangerous_lock_name) do assert_match(/#{Regexp.escape(dangerous_lock_name)}/, Tag.current_advisory_lock) end end test 'returns false for an unacquired lock' do refute(Tag.advisory_lock_exists?(@lock_name)) end test 'returns true for an acquired lock' do Tag.with_advisory_lock(@lock_name) do assert(Tag.advisory_lock_exists?(@lock_name)) end end test 'returns block return value if lock successful' do assert_equal(@return_val, Tag.with_advisory_lock!(@lock_name) { @return_val }) end test 'returns false on lock acquisition failure' do thread_with_lock = Thread.new do Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) do @locked_elsewhere = true loop { sleep 0.01 } end end sleep 0.01 until @locked_elsewhere assert_not(Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) { @return_val }) thread_with_lock.kill end test 'raises an error on lock acquisition failure' do thread_with_lock = Thread.new do Tag.with_advisory_lock(@lock_name, timeout_seconds: 0) do @locked_elsewhere = true loop { sleep 0.01 } end end sleep 0.01 until @locked_elsewhere assert_raises(WithAdvisoryLock::FailedToAcquireLock) do Tag.with_advisory_lock!(@lock_name, timeout_seconds: 0) { @return_val } end thread_with_lock.kill end test 'attempts the lock exactly once with no timeout' do expected = SecureRandom.base64 actual = Tag.with_advisory_lock(@lock_name, 0) do expected end assert_equal(expected, actual) end end with-advisory-lock-5.1.0/test/nesting_test.rb0000664000175000017500000000141114704252310020321 0ustar sorensoren# frozen_string_literal: true require 'test_helper' class LockNestingTest < GemTestCase setup do @prior_prefix = ENV['WITH_ADVISORY_LOCK_PREFIX'] ENV['WITH_ADVISORY_LOCK_PREFIX'] = nil end teardown do ENV['WITH_ADVISORY_LOCK_PREFIX'] = @prior_prefix end test "doesn't request the same lock twice" do impl = WithAdvisoryLock::Base.new(nil, nil, nil) assert_empty(impl.lock_stack) Tag.with_advisory_lock('first') do assert_equal(%w[first], impl.lock_stack.map(&:name)) # Even MySQL should be OK with this: Tag.with_advisory_lock('first') do assert_equal(%w[first], impl.lock_stack.map(&:name)) end assert_equal(%w[first], impl.lock_stack.map(&:name)) end assert_empty(impl.lock_stack) end end with-advisory-lock-5.1.0/test/options_test.rb0000664000175000017500000000325514704252310020355 0ustar sorensoren# frozen_string_literal: true require 'test_helper' class OptionsParsingTest < GemTestCase def parse_options(options) WithAdvisoryLock::Base.new(mock, mock, options) end test 'defaults (empty hash)' do impl = parse_options({}) assert_nil(impl.timeout_seconds) assert_not(impl.shared) assert_not(impl.transaction) end test 'nil sets timeout to nil' do impl = parse_options(nil) assert_nil(impl.timeout_seconds) assert_not(impl.shared) assert_not(impl.transaction) end test 'integer sets timeout to value' do impl = parse_options(42) assert_equal(42, impl.timeout_seconds) assert_not(impl.shared) assert_not(impl.transaction) end test 'hash with invalid key errors' do assert_raises(ArgumentError) do parse_options(foo: 42) end end test 'hash with timeout_seconds sets timeout to value' do impl = parse_options(timeout_seconds: 123) assert_equal(123, impl.timeout_seconds) assert_not(impl.shared) assert_not(impl.transaction) end test 'hash with shared option sets shared to true' do impl = parse_options(shared: true) assert_nil(impl.timeout_seconds) assert(impl.shared) assert_not(impl.transaction) end test 'hash with transaction option set transaction to true' do impl = parse_options(transaction: true) assert_nil(impl.timeout_seconds) assert_not(impl.shared) assert(impl.transaction) end test 'hash with multiple keys sets options' do foo = mock bar = mock impl = parse_options(timeout_seconds: foo, shared: bar) assert_equal(foo, impl.timeout_seconds) assert_equal(bar, impl.shared) assert_not(impl.transaction) end end with-advisory-lock-5.1.0/test/parallelism_test.rb0000664000175000017500000000427414704252310021171 0ustar sorensoren# frozen_string_literal: true require 'test_helper' require 'forwardable' class FindOrCreateWorker extend Forwardable def_delegators :@thread, :join, :wakeup, :status, :to_s def initialize(name, use_advisory_lock) @name = name @use_advisory_lock = use_advisory_lock @thread = Thread.new { work_later } end def work_later sleep ActiveRecord::Base.connection_pool.with_connection do if @use_advisory_lock Tag.with_advisory_lock(@name) { work } else work end end end def work Tag.transaction do Tag.where(name: @name).first_or_create end end end class ParallelismTest < GemTestCase def run_workers @names = @iterations.times.map { |iter| "iteration ##{iter}" } @names.each do |name| workers = @workers.times.map do FindOrCreateWorker.new(name, @use_advisory_lock) end # Wait for all the threads to get ready: sleep(0.1) until workers.all? { |ea| ea.status == 'sleep' } # OK, GO! workers.each(&:wakeup) # Then wait for them to finish: workers.each(&:join) end # Ensure we're still connected: ActiveRecord::Base.connection_pool.connection end setup do ActiveRecord::Base.connection.reconnect! @workers = 10 end test 'creates multiple duplicate rows without advisory locks' do skip if %i[sqlite3 jdbcsqlite3].include?(env_db) @use_advisory_lock = false @iterations = 1 run_workers assert_operator(Tag.all.size, :>, @iterations) # <- any duplicated rows will make me happy. assert_operator(TagAudit.all.size, :>, @iterations) # <- any duplicated rows will make me happy. assert_operator(Label.all.size, :>, @iterations) # <- any duplicated rows will make me happy. end test "doesn't create multiple duplicate rows with advisory locks" do @use_advisory_lock = true @iterations = 10 run_workers assert_equal(@iterations, Tag.all.size) # <- any duplicated rows will NOT make me happy. assert_equal(@iterations, TagAudit.all.size) # <- any duplicated rows will NOT make me happy. assert_equal(@iterations, Label.all.size) # <- any duplicated rows will NOT make me happy. end end with-advisory-lock-5.1.0/test/shared_test.rb0000664000175000017500000000560614704252310020132 0ustar sorensoren# frozen_string_literal: true require 'test_helper' class SharedTestWorker def initialize(shared) @shared = shared @locked = nil @cleanup = false @thread = Thread.new { work } end def locked? sleep 0.01 while @locked.nil? && @thread.alive? @locked end def cleanup! @cleanup = true @thread.join raise if @thread.status.nil? end private def work ActiveRecord::Base.connection_pool.with_connection do Tag.with_advisory_lock('test', timeout_seconds: 0, shared: @shared) do @locked = true sleep 0.01 until @cleanup end @locked = false sleep 0.01 until @cleanup end end end class SharedLocksTest < GemTestCase def supported? %i[trilogy mysql2 jdbcmysql].exclude?(env_db) end test 'does not allow two exclusive locks' do one = SharedTestWorker.new(false) assert_predicate(one, :locked?) two = SharedTestWorker.new(false) refute(two.locked?) one.cleanup! two.cleanup! end end class NotSupportedEnvironmentTest < SharedLocksTest setup do skip if supported? end test 'raises an error when attempting to use a shared lock' do one = SharedTestWorker.new(true) assert_nil(one.locked?) exception = assert_raises(ArgumentError) do one.cleanup! end assert_match(/#{Regexp.escape('not supported')}/, exception.message) end end class SupportedEnvironmentTest < SharedLocksTest setup do skip unless supported? end test 'does allow two shared locks' do one = SharedTestWorker.new(true) assert_predicate(one, :locked?) two = SharedTestWorker.new(true) assert_predicate(two, :locked?) one.cleanup! two.cleanup! end test 'does not allow exclusive lock with shared lock' do one = SharedTestWorker.new(true) assert_predicate(one, :locked?) two = SharedTestWorker.new(false) refute(two.locked?) three = SharedTestWorker.new(true) assert_predicate(three, :locked?) one.cleanup! two.cleanup! three.cleanup! end test 'does not allow shared lock with exclusive lock' do one = SharedTestWorker.new(false) assert_predicate(one, :locked?) two = SharedTestWorker.new(true) refute(two.locked?) one.cleanup! two.cleanup! end class PostgreSQLTest < SupportedEnvironmentTest setup do skip unless env_db == :postgresql end def pg_lock_modes ActiveRecord::Base.connection.select_values("SELECT mode FROM pg_locks WHERE locktype = 'advisory';") end test 'allows shared lock to be upgraded to an exclusive lock' do assert_empty(pg_lock_modes) Tag.with_advisory_lock 'test', shared: true do assert_equal(%w[ShareLock], pg_lock_modes) Tag.with_advisory_lock 'test', shared: false do assert_equal(%w[ShareLock ExclusiveLock], pg_lock_modes) end end assert_empty(pg_lock_modes) end end end with-advisory-lock-5.1.0/test/test_helper.rb0000664000175000017500000000242514704252310020137 0ustar sorensoren# frozen_string_literal: true require 'erb' require 'active_record' require 'with_advisory_lock' require 'tmpdir' require 'securerandom' begin require 'activerecord-trilogy-adapter' ActiveSupport.on_load(:active_record) do require "trilogy_adapter/connection" ActiveRecord::Base.public_send :extend, TrilogyAdapter::Connection end rescue LoadError # do nothing end ActiveRecord::Base.configurations = { default_env: { url: ENV.fetch('DATABASE_URL', "sqlite3://#{Dir.tmpdir}/#{SecureRandom.hex}.sqlite3"), properties: { allowPublicKeyRetrieval: true } # for JRuby madness } } ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex ActiveRecord::Base.establish_connection def env_db @env_db ||= ActiveRecord::Base.connection_db_config.adapter.to_sym end ActiveRecord::Migration.verbose = false require 'test_models' require 'minitest' require 'maxitest/autorun' require 'mocha/minitest' class GemTestCase < ActiveSupport::TestCase setup do ENV['FLOCK_DIR'] = Dir.mktmpdir Tag.delete_all TagAudit.delete_all Label.delete_all end teardown do FileUtils.remove_entry_secure ENV['FLOCK_DIR'] end end puts "Testing with #{env_db} database, ActiveRecord #{ActiveRecord.gem_version} and #{RUBY_ENGINE} #{RUBY_ENGINE_VERSION} as #{RUBY_VERSION}" with-advisory-lock-5.1.0/test/test_models.rb0000664000175000017500000000100614704252310020135 0ustar sorensoren# frozen_string_literal: true ActiveRecord::Schema.define(version: 0) do create_table 'tags', force: true do |t| t.string 'name' end create_table 'tag_audits', id: false, force: true do |t| t.string 'tag_name' end create_table 'labels', id: false, force: true do |t| t.string 'name' end end class Tag < ActiveRecord::Base after_save do TagAudit.create(tag_name: name) Label.create(name: name) end end class TagAudit < ActiveRecord::Base end class Label < ActiveRecord::Base end with-advisory-lock-5.1.0/test/thread_test.rb0000664000175000017500000000305514704252310020127 0ustar sorensoren# frozen_string_literal: true require 'test_helper' class SeparateThreadTest < GemTestCase setup do @lock_name = 'testing 1,2,3' # OMG COMMAS @mutex = Mutex.new @t1_acquired_lock = false @t1_return_value = nil @t1 = Thread.new do ActiveRecord::Base.connection_pool.with_connection do @t1_return_value = Label.with_advisory_lock(@lock_name) do @mutex.synchronize { @t1_acquired_lock = true } sleep 't1 finished' end end end # Wait for the thread to acquire the lock: sleep(0.1) until @mutex.synchronize { @t1_acquired_lock } ActiveRecord::Base.connection.reconnect! end teardown do @t1.wakeup if @t1.status == 'sleep' @t1.join end test '#with_advisory_lock with a 0 timeout returns false immediately' do response = Label.with_advisory_lock(@lock_name, 0) do raise 'should not be yielded to' end assert_not(response) end test '#with_advisory_lock yields to the provided block' do assert(@t1_acquired_lock) end test '#advisory_lock_exists? returns true when another thread has the lock' do assert(Tag.advisory_lock_exists?(@lock_name)) end test 'can re-establish the lock after the other thread releases it' do @t1.wakeup @t1.join assert_equal('t1 finished', @t1_return_value) # We should now be able to acquire the lock immediately: reacquired = false lock_result = Label.with_advisory_lock(@lock_name, 0) do reacquired = true end assert(lock_result) assert(reacquired) end end with-advisory-lock-5.1.0/test/transaction_test.rb0000664000175000017500000000371114704252310021204 0ustar sorensoren# frozen_string_literal: true require 'test_helper' class TransactionScopingTest < GemTestCase def supported? %i[postgresql jdbcpostgresql].include?(env_db) end test 'raises an error when attempting to use transaction level locks if not supported' do skip if supported? Tag.transaction do exception = assert_raises(ArgumentError) do Tag.with_advisory_lock 'test', transaction: true do raise 'should not get here' end end assert_match(/#{Regexp.escape('not supported')}/, exception.message) end end class PostgresqlTest < TransactionScopingTest setup do skip unless env_db == :postgresql @pg_lock_count = lambda do ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM pg_locks WHERE locktype = 'advisory';").to_i end end test 'session locks release after the block executes' do Tag.transaction do assert_equal(0, @pg_lock_count.call) Tag.with_advisory_lock 'test' do assert_equal(1, @pg_lock_count.call) end assert_equal(0, @pg_lock_count.call) end end test 'session locks release when transaction fails inside block' do Tag.transaction do assert_equal(0, @pg_lock_count.call) exception = assert_raises(ActiveRecord::StatementInvalid) do Tag.with_advisory_lock 'test' do Tag.connection.execute 'SELECT 1/0;' end end assert_match(/#{Regexp.escape('division by zero')}/, exception.message) assert_equal(0, @pg_lock_count.call) end end test 'transaction level locks hold until the transaction completes' do Tag.transaction do assert_equal(0, @pg_lock_count.call) Tag.with_advisory_lock 'test', transaction: true do assert_equal(1, @pg_lock_count.call) end assert_equal(1, @pg_lock_count.call) end assert_equal(0, @pg_lock_count.call) end end end with-advisory-lock-5.1.0/with_advisory_lock.gemspec0000664000175000017500000000267714704252310021576 0ustar sorensoren# frozen_string_literal: true require 'English' require_relative 'lib/with_advisory_lock/version' Gem::Specification.new do |spec| spec.name = 'with_advisory_lock' spec.version = WithAdvisoryLock::VERSION spec.authors = ['Matthew McEachen', 'Abdelkader Boudih'] spec.email = %w[matthew+github@mceachen.org terminale@gmail.com] spec.homepage = 'https://github.com/ClosureTree/with_advisory_lock' spec.summary = 'Advisory locking for ActiveRecord' spec.description = 'Advisory locking for ActiveRecord' spec.license = 'MIT' spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) spec.test_files = spec.files.grep(%r{^test/}) spec.require_paths = %w[lib] spec.metadata = { 'rubyspecs_mfa_required' => 'true' } spec.required_ruby_version = '>= 2.7.0' spec.metadata['yard.run'] = 'yri' spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://github.com/ClosureTree/with_advisory_lock' spec.metadata['changelog_uri'] = 'https://github.com/ClosureTree/with_advisory_lock/blob/master/CHANGELOG.md' spec.add_runtime_dependency 'activerecord', '>= 6.1' spec.add_runtime_dependency 'zeitwerk', '>= 2.6' spec.add_development_dependency 'appraisal' spec.add_development_dependency 'maxitest' spec.add_development_dependency 'minitest-reporters' spec.add_development_dependency 'mocha' spec.add_development_dependency 'yard' end