├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .release-please-manifest.json ├── .ruby-version ├── .tool-versions ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── Makefile ├── README.md ├── Rakefile ├── bin ├── console ├── rails ├── sanity ├── sanity_check ├── setup ├── setup_test_db └── test_connections ├── docker-compose.yml ├── lib ├── with_advisory_lock.rb └── with_advisory_lock │ ├── concern.rb │ ├── core_advisory.rb │ ├── failed_to_acquire_lock.rb │ ├── jruby_adapter.rb │ ├── lock_stack_item.rb │ ├── mysql_advisory.rb │ ├── postgresql_advisory.rb │ ├── result.rb │ └── version.rb ├── release-please-config.json ├── test ├── dummy │ ├── Rakefile │ ├── app │ │ ├── controllers │ │ │ └── application_controller.rb │ │ └── models │ │ │ ├── application_record.rb │ │ │ ├── label.rb │ │ │ ├── mysql_label.rb │ │ │ ├── mysql_record.rb │ │ │ ├── mysql_tag.rb │ │ │ ├── mysql_tag_audit.rb │ │ │ ├── tag.rb │ │ │ └── tag_audit.rb │ ├── config.ru │ ├── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── environment.rb │ │ └── routes.rb │ ├── db │ │ ├── schema.rb │ │ └── secondary_schema.rb │ └── lib │ │ └── tasks │ │ └── db.rake ├── sanity_check_test.rb ├── test_helper.rb └── with_advisory_lock │ ├── concern_test.rb │ ├── lock_test.rb │ ├── multi_adapter_test.rb │ ├── parallelism_test.rb │ ├── shared_test.rb │ ├── thread_test.rb │ └── transaction_test.rb └── with_advisory_lock.gemspec /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | concurrency: 9 | group: ci-${{ github.head_ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | minitest: 14 | runs-on: ubuntu-latest 15 | name: CI Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} 16 | services: 17 | postgres: 18 | image: 'postgres:17-alpine' 19 | ports: 20 | - '5432' 21 | env: 22 | POSTGRES_USER: with_advisory 23 | POSTGRES_PASSWORD: with_advisory_pass 24 | POSTGRES_DB: with_advisory_lock_test 25 | options: >- 26 | --health-cmd pg_isready 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | mysql: 31 | image: mysql/mysql-server 32 | ports: 33 | - 3306 34 | env: 35 | MYSQL_USER: with_advisory 36 | MYSQL_PASSWORD: with_advisory_pass 37 | MYSQL_DATABASE: with_advisory_lock_test 38 | MYSQL_ROOT_HOST: '%' 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | ruby: 43 | - '3.3' 44 | - '3.4' 45 | - 'truffleruby' 46 | rails: 47 | - 7.1 48 | - 7.2 49 | - "8.0" 50 | include: 51 | - ruby: jruby 52 | rails: 7.1 53 | env: 54 | ACTIVERECORD_VERSION: ${{ matrix.rails }} 55 | RAILS_ENV: test 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v4 59 | 60 | - name: Setup Ruby 61 | uses: ruby/setup-ruby@v1 62 | with: 63 | ruby-version: ${{ matrix.ruby }} 64 | bundler-cache: true 65 | rubygems: latest 66 | 67 | - name: Setup test databases 68 | env: 69 | DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test 70 | DATABASE_URL_MYSQL: mysql2://with_advisory:with_advisory_pass@127.0.0.1:${{ job.services.mysql.ports[3306] }}/with_advisory_lock_test 71 | run: | 72 | cd test/dummy 73 | bundle exec rake db:test:prepare 74 | 75 | - name: Test 76 | env: 77 | DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test 78 | DATABASE_URL_MYSQL: mysql2://with_advisory:with_advisory_pass@127.0.0.1:${{ job.services.mysql.ports[3306] }}/with_advisory_lock_test 79 | WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }} 80 | run: bin/rails test 81 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | release-please: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: googleapis/release-please-action@v4 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | *.history 4 | *.idea 5 | .bundle 6 | .config 7 | .yardoc 8 | *.lock 9 | InstalledFiles 10 | _yardoc 11 | coverage 12 | doc/ 13 | lib/bundler/man 14 | pkg 15 | rdoc 16 | spec/reports 17 | test/tmp 18 | test/version_tmp 19 | tmp 20 | *.iml 21 | .env 22 | test/dummy/log/ 23 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {".":"6.0.0"} 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.4 2 | 3 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.4.4 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | ## [6.0.0](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v5.3.0...with_advisory_lock/v6.0.0) (2025-05-28) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | * Remove private APIs (Base, DatabaseAdapterSupport). Add full mixed adapter support for PostgreSQL/MySQL in same app. Add JRuby compatibility. 9 | * drop support for sqlite3 10 | * drop legacy version of ruby/rails ([#113](https://github.com/ClosureTree/with_advisory_lock/issues/113)) 11 | 12 | ### Features 13 | 14 | * drop legacy version of ruby/rails ([#113](https://github.com/ClosureTree/with_advisory_lock/issues/113)) ([26fd427](https://github.com/ClosureTree/with_advisory_lock/commit/26fd4278f9fa155974e6f86df7cd92dd2b7d9154)) 15 | * drop support for sqlite3 ([26fd427](https://github.com/ClosureTree/with_advisory_lock/commit/26fd4278f9fa155974e6f86df7cd92dd2b7d9154)) 16 | * move to rails dummy app to test multidb setup ([#115](https://github.com/ClosureTree/with_advisory_lock/issues/115)) ([71a3431](https://github.com/ClosureTree/with_advisory_lock/commit/71a34316b365a0f3be0e8a046db14289e69efc9c)) 17 | * support of multidb ([#116](https://github.com/ClosureTree/with_advisory_lock/issues/116)) ([935e7e5](https://github.com/ClosureTree/with_advisory_lock/commit/935e7e5fb05dad2eba034745d2ef49e11c163f7d)) 18 | 19 | ## [5.3.0](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v5.2.0...with_advisory_lock/v5.3.0) (2025-04-25) 20 | 21 | 22 | ### Features 23 | 24 | * add #current_advisory_locks method ([#111](https://github.com/ClosureTree/with_advisory_lock/issues/111)) ([ccbd3b2](https://github.com/ClosureTree/with_advisory_lock/commit/ccbd3b23465f7fa1fc3800334159986c31d5c351)) 25 | 26 | ## [5.2.0](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v5.1.0...with_advisory_lock/v5.2.0) (2025-04-24) 27 | 28 | 29 | ### Features 30 | 31 | * use current connnection instead of the one in ActiveRecord::Base ([#90](https://github.com/ClosureTree/with_advisory_lock/issues/90)) ([c28a172](https://github.com/ClosureTree/with_advisory_lock/commit/c28a172a5a64594448b6090501fc0b8cbace06f6)) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * Removed MySQL unused lock variable and broaden SQLite detection. ([#94](https://github.com/ClosureTree/with_advisory_lock/issues/94)) ([f818a18](https://github.com/ClosureTree/with_advisory_lock/commit/f818a181dde6711c8439c4cbf67c4525a09d346e)) 37 | 38 | ## [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) 39 | 40 | 41 | ### Features 42 | 43 | * use zeitwerk loader instead of ActiveSupport::Autoload ([b5082fd](https://github.com/ClosureTree/with_advisory_lock/commit/b5082fddacacacff48139f5bf509601a37945a0e)) 44 | 45 | ## 5.0.1 (2024-01-21) 46 | 47 | 48 | ### Features 49 | 50 | * add release workflow ([5d32520](https://github.com/ClosureTree/with_advisory_lock/commit/5d325201c82974991381a9fbc4d1714c9739dc4f)) 51 | * 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)) 52 | * 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)) 53 | * add truffleruby support ([#62](https://github.com/ClosureTree/with_advisory_lock/issues/62)) ([ec34bd4](https://github.com/ClosureTree/with_advisory_lock/commit/ec34bd448e3505e5df631daaf47bb83f2f5316dc)) 54 | 55 | 56 | ### Bug Fixes 57 | 58 | * 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)) 59 | 60 | ### 5.0.0 61 | - Drop support for EOL rubies and activerecord (ruby below 2.7 and activerecord below 6.1). 62 | - Allow lock name to be integer 63 | - Jruby support 64 | - Truffleruby support 65 | - Add `with_advisory_lock!`, which raises an error if the lock acquisition fails 66 | - Add `disable_query_cache` option to `with_advisory_lock` 67 | - Drop support for mysql < 5.7.5 68 | 69 | ### 4.6.0 70 | 71 | - Support for ActiveRecord 6 72 | - Add Support for nested locks in MySQL 73 | 74 | ### 4.0.0 75 | 76 | - Drop support for unsupported versions of activerecord 77 | - Drop support for unsupported versions of ruby 78 | 79 | ### 3.2.0 80 | 81 | - [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! 82 | - [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)! 83 | 84 | ### 3.1.1 85 | 86 | - [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! 87 | 88 | ### 3.1.0 89 | 90 | - [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! 91 | - 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/) 92 | - Replaced ruby 1.9 and 2.0 (both EOL) with ruby 2.2 and 2.3 (see https://www.ruby-lang.org/en/downloads/) 93 | 94 | ### 3.0.0 95 | 96 | - Added jruby/PostgreSQL support for Rails 4.x 97 | - Reworked threaded tests to allow jruby tests to pass 98 | 99 | #### API changes 100 | 101 | - `yield_with_lock_and_timeout` and `yield_with_lock` now return instances of 102 | `WithAdvisoryLock::Result`, so blocks that return `false` are not misinterpreted 103 | as a failure to lock. As this changes the interface (albeit internal methods), the major version 104 | number was incremented. 105 | - `with_advisory_lock_result` was introduced, which clarifies whether the lock was acquired 106 | versus the yielded block returned false. 107 | 108 | ### 2.0.0 109 | 110 | - Lock timeouts of 0 now attempt the lock once, as per suggested by 111 | [Jon Leighton](https://github.com/jonleighton) and implemented by 112 | [Abdelkader Boudih](https://github.com/seuros). Thanks to both of you! 113 | - [Pull request 11](https://github.com/ClosureTree/with_advisory_lock/pull/11) 114 | fixed a downstream issue with jruby support! Thanks, [Aaron Todd](https://github.com/ozzyaaron)! 115 | - Added Travis tests for jruby 116 | - Dropped support for Rails 3.0, 3.1, and Ruby 1.8.7, as they are no longer 117 | receiving security patches. See http://rubyonrails.org/security/ for more information. 118 | This required the major version bump. 119 | - Refactored `advisory_lock_exists?` to use existing functionality 120 | - Fixed sqlite's implementation so parallel tests could be run against it 121 | 122 | ### 1.0.0 123 | 124 | - Releasing 1.0.0. The interface will be stable. 125 | - Added `advisory_lock_exists?`. Thanks, [Sean Devine](https://github.com/barelyknown), for the 126 | great pull request! 127 | - Added Travis test for Rails 4.1 128 | 129 | ### 0.0.10 130 | 131 | - Explicitly added MIT licensing to the gemspec. 132 | 133 | ### 0.0.9 134 | 135 | - Merged in Postgis Adapter Support to address [issue 7](https://github.com/ClosureTree/with_advisory_lock/issues/7) 136 | Thanks for the pull request, [Abdelkader Boudih](https://github.com/seuros)! 137 | - The database switching code had to be duplicated by [Closure Tree](https://github.com/ClosureTree/closure_tree), 138 | so I extracted a new `WithAdvisoryLock::DatabaseAdapterSupport` one-trick pony. 139 | - Builds were failing on Travis, so I introduced a global lock prefix that can be set with the 140 | `WITH_ADVISORY_LOCK_PREFIX` environment variable. I'm not going to advertise this feature yet. 141 | It's a secret. Only you and I know, now. _shhh_ 142 | 143 | ### 0.0.8 144 | 145 | - Addressed [issue 5](https://github.com/ClosureTree/with_advisory_lock/issues/5) by 146 | using a deterministic hash for Postgresql + MRI >= 1.9. 147 | Thanks for the pull request, [Joel Turkel](https://github.com/jturkel)! 148 | - Addressed [issue 2](https://github.com/ClosureTree/with_advisory_lock/issues/2) by 149 | using a cache-busting query for MySQL and Postgres to deal with AR value caching bug. 150 | Thanks for the pull request, [Jaime Giraldo](https://github.com/sposmen)! 151 | - Addressed [issue 4](https://github.com/ClosureTree/with_advisory_lock/issues/4) by 152 | adding support for `em-postgresql-adapter`. 153 | Thanks, [lestercsp](https://github.com/lestercsp)! 154 | 155 | (Hey, github—your notifications are WAY too easy to ignore!) 156 | 157 | ### 0.0.7 158 | 159 | - Added Travis tests for Rails 3.0, 3.1, 3.2, and 4.0 160 | - Fixed MySQL bug with select_value returning a string instead of an integer when using AR 3.0.x 161 | 162 | ### 0.0.6 163 | 164 | - Only require ActiveRecord >= 3.0.x 165 | - Fixed MySQL error reporting 166 | 167 | ### 0.0.5 168 | 169 | - Asking for the currently acquired advisory lock doesn't re-ask for the lock now. 170 | - Introduced NestedAdvisoryLockError when asking for different, nested advisory locksMySQL 171 | 172 | ### 0.0.4 173 | 174 | - Moved require into on_load, which should speed loading when AR doesn't have to spin up 175 | 176 | ### 0.0.3 177 | 178 | - Fought with ActiveRecord 3.0.x and 3.1.x. You don't want them if you use threads—they fail 179 | predictably. 180 | 181 | ### 0.0.2 182 | 183 | - Added warning log message for nested MySQL lock calls 184 | - Randomized lock wait time, which can help ameliorate lock contention 185 | 186 | ### 0.0.1 187 | 188 | - First whack 189 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'rake' 8 | 9 | # Gems that will be removed from default gems in Ruby 3.5.0 10 | gem 'benchmark' 11 | gem 'logger' 12 | gem 'ostruct' 13 | 14 | activerecord_version = ENV.fetch('ACTIVERECORD_VERSION', '7.1') 15 | 16 | gem 'activerecord', "~> #{activerecord_version}.0" 17 | 18 | gem 'dotenv' 19 | gem 'railties' 20 | 21 | platforms :ruby do 22 | gem 'mysql2' 23 | gem 'pg' 24 | gem 'trilogy' 25 | end 26 | 27 | platforms :jruby do 28 | # JRuby JDBC adapters only support Rails 7.1 currently 29 | if activerecord_version == '7.1' 30 | gem 'activerecord-jdbcmysql-adapter', '~> 71.0' 31 | gem 'activerecord-jdbcpostgresql-adapter', '~> 71.0' 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | SPDX-License-Identifier: MIT 2 | SPDX-FileCopyrightText: 2013 Matthew McEachen 3 | SPDX-FileCopyrightText: 2013-2025 Abdelkader Boudih 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | test: setup-db 4 | bin/rails test 5 | 6 | setup-db: 7 | docker compose up -d 8 | sleep 2 9 | bundle 10 | bin/setup_test_db 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # with_advisory_lock 2 | 3 | Adds advisory locking (mutexes) to ActiveRecord 7.1+, with ruby 3.3+, jruby or truffleruby, when used with 4 | [MySQL](https://dev.mysql.com/doc/refman/8.0/en/miscellaneous-functions.html#function_get-lock) 5 | or 6 | [PostgreSQL](https://www.postgresql.org/docs/current/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS). 7 | 8 | **Note:** SQLite support has been removed. For single-node SQLite deployments, 9 | consider using a Ruby mutex instead. Support for MySQL 5.7 has also been 10 | dropped; please use MySQL 8 or PostgreSQL. 11 | 12 | [![Gem Version](https://badge.fury.io/rb/with_advisory_lock.svg)](https://badge.fury.io/rb/with_advisory_lock) 13 | [![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) 14 | 15 | ## What's an "Advisory Lock"? 16 | 17 | An advisory lock is a [mutex](https://en.wikipedia.org/wiki/Mutual_exclusion) 18 | used to ensure no two processes run some process at the same time. When the 19 | advisory lock is powered by your database server, 20 | your mutex spans hosts. 21 | 22 | ## Usage 23 | 24 | This gem automatically includes the `WithAdvisoryLock` module in all of your 25 | ActiveRecord models. Here's an example of how to use it where `User` is an 26 | ActiveRecord model, and `lock_name` is some string: 27 | 28 | ```ruby 29 | User.with_advisory_lock(lock_name) do 30 | do_something_that_needs_locking 31 | end 32 | ``` 33 | 34 | ### What happens 35 | 36 | 1. The thread will wait indefinitely until the lock is acquired. 37 | 2. While inside the block, you will exclusively own the advisory lock. 38 | 3. The lock will be released after your block ends, even if an exception is raised in the block. 39 | 40 | ### Lock wait timeouts 41 | 42 | `with_advisory_lock` takes an options hash as the second parameter. The 43 | `timeout_seconds` option defaults to `nil`, which means wait indefinitely for 44 | the lock. 45 | 46 | A value of zero will try the lock only once. If the lock is acquired, the block 47 | will be yielded to. If the lock is currently being held, the block will not be 48 | called. 49 | 50 | > **Note** 51 | > 52 | > If a non-nil value is provided for `timeout_seconds`, the block will 53 | *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. 54 | 55 | For backwards compatability, the timeout value can be specified directly as the 56 | second parameter. 57 | 58 | ### Shared locks 59 | 60 | The `shared` option defaults to `false` which means an exclusive lock will be 61 | obtained. Setting `shared` to `true` will allow locks to be obtained by multiple 62 | actors as long as they are all shared locks. 63 | 64 | Note: MySQL does not support shared locks. 65 | 66 | ### Transaction-level locks 67 | 68 | PostgreSQL supports transaction-level locks which remain held until the 69 | transaction completes. You can enable this by setting the `transaction` option 70 | to `true`. 71 | 72 | Note: transaction-level locks will not be reflected by `.current_advisory_lock` 73 | when the block has returned. 74 | 75 | ### Return values 76 | 77 | The return value of `with_advisory_lock_result` is a `WithAdvisoryLock::Result` 78 | instance, which has a `lock_was_acquired?` method and a `result` accessor 79 | method, which is the returned value of the given block. If your block may 80 | validly return false, you should use this method. 81 | 82 | The return value of `with_advisory_lock` will be the result of the yielded 83 | block, if the lock was able to be acquired and the block yielded, or `false`, if 84 | you provided a timeout_seconds value and the lock was not able to be acquired in 85 | time. 86 | 87 | `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. 88 | 89 | ### Testing for the current lock status 90 | 91 | If you needed to check if the advisory lock is currently being held, you can 92 | call `Tag.advisory_lock_exists?("foo")`, but realize the lock can be acquired 93 | between the time you test for the lock, and the time you try to acquire the 94 | lock. 95 | 96 | If you want to see if the current Thread is holding a lock, you can call 97 | `Tag.current_advisory_lock` which will return the name of the current lock. If 98 | no lock is currently held, `.current_advisory_lock` returns `nil`. 99 | 100 | ### ActiveRecord Query Cache 101 | 102 | You can optionally pass `disable_query_cache: true` to the options hash of 103 | `with_advisory_lock` in order to disable ActiveRecord's query cache. This can 104 | prevent problems when you query the database from within the lock and it returns 105 | stale results. More info on why this can be a problem can be 106 | [found here](https://github.com/ClosureTree/with_advisory_lock/issues/52) 107 | 108 | ## Installation 109 | 110 | Add this line to your application's Gemfile: 111 | 112 | ```ruby 113 | gem 'with_advisory_lock' 114 | ``` 115 | 116 | And then execute: 117 | 118 | $ bundle 119 | 120 | ## Lock Types 121 | 122 | First off, know that there are **lots** of different kinds of locks available to 123 | you. **Pick the finest-grain lock that ensures correctness.** If you choose a 124 | lock that is too coarse, you are unnecessarily blocking other processes. 125 | 126 | ### Advisory locks 127 | 128 | These are named mutexes that are inherently "application level"—it is up to the 129 | application to acquire, run a critical code section, and release the advisory 130 | lock. 131 | 132 | ### Row-level locks 133 | 134 | Whether [optimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html) 135 | or [pessimistic](http://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html), 136 | row-level locks prevent concurrent modification to a given model. 137 | 138 | **If you're building a 139 | [CRUD](http://en.wikipedia.org/wiki/Create,_read,_update_and_delete) 140 | application, this will be 2.4, 2.5 and your most commonly used lock.** 141 | 142 | ### Table-level locks 143 | 144 | Provided through something like the 145 | [monogamy](https://github.com/ClosureTree/monogamy) gem, these prevent 146 | concurrent access to **any instance of a model**. Their coarseness means they 147 | aren't going to be commonly applicable, and they can be a source of 148 | [deadlocks](http://en.wikipedia.org/wiki/Deadlock). 149 | 150 | ## FAQ 151 | 152 | ### Transactions and Advisory Locks 153 | 154 | Advisory locks with MySQL and PostgreSQL ignore database transaction boundaries. 155 | 156 | You will want to wrap your block within a transaction to ensure consistency. 157 | 158 | ### Is clustered MySQL supported? 159 | 160 | [No.](https://github.com/ClosureTree/with_advisory_lock/issues/16) 161 | 162 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | 5 | require 'yard' 6 | YARD::Rake::YardocTask.new do |t| 7 | t.files = ['lib/**/*.rb', 'README.md'] 8 | end 9 | 10 | require 'rake/testtask' 11 | 12 | Rake::TestTask.new do |t| 13 | t.libs.push 'lib' 14 | t.libs.push 'test' 15 | t.pattern = 'test/**/*_test.rb' 16 | t.verbose = true 17 | end 18 | 19 | # Load Rails tasks from dummy app to get db:test:prepare 20 | APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__) 21 | load 'rails/tasks/engine.rake' if File.exist?(APP_RAKEFILE) 22 | 23 | task default: :test 24 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'no_fly_list' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require 'irb' 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # This command will automatically be run when you run "rails" with Rails gems 5 | # installed from the root of your application. 6 | 7 | ENGINE_ROOT = File.expand_path('..', __dir__) 8 | APP_PATH = File.expand_path('../test/dummy/config/application', __dir__) 9 | 10 | # Set up gems listed in the Gemfile. 11 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 12 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 13 | 14 | require 'rails/all' 15 | require 'rails/engine/commands' 16 | -------------------------------------------------------------------------------- /bin/sanity: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | check_port() { 5 | local port="$1" 6 | local name="$2" 7 | if nc -z localhost "$port" >/dev/null 2>&1; then 8 | echo "$name running on port $port" 9 | else 10 | echo "ERROR: $name is not running on port $port" >&2 11 | return 1 12 | fi 13 | } 14 | 15 | main() { 16 | check_port 5433 "Postgresql" 17 | check_port 3366 "Mysql" 18 | } 19 | 20 | main "$@" -------------------------------------------------------------------------------- /bin/sanity_check: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | ENV['RAILS_ENV'] = 'test' 5 | ENV['DATABASE_URL_PG'] ||= 'postgres://with_advisory:with_advisory_pass@localhost:5433/with_advisory_lock_test' 6 | ENV['DATABASE_URL_MYSQL'] ||= 'mysql2://with_advisory:with_advisory_pass@0.0.0.0:3366/with_advisory_lock_test' 7 | 8 | require_relative '../test/dummy/config/environment' 9 | 10 | puts '=' * 80 11 | puts 'WITH_ADVISORY_LOCK SANITY CHECK' 12 | puts '=' * 80 13 | puts 14 | 15 | # Check Rails environment 16 | puts "Rails Environment: #{Rails.env}" 17 | puts "Rails Root: #{Rails.root}" 18 | puts 19 | 20 | # Check PostgreSQL connection 21 | puts 'PostgreSQL Connection (ApplicationRecord):' 22 | begin 23 | ApplicationRecord.connection.execute('SELECT 1') 24 | puts ' ✓ Connected to PostgreSQL' 25 | puts " Database: #{ApplicationRecord.connection.current_database}" 26 | puts " Adapter: #{ApplicationRecord.connection.adapter_name}" 27 | puts " Tables: #{ApplicationRecord.connection.tables.sort.join(', ')}" 28 | 29 | # Test creating a record 30 | tag = Tag.create!(name: "test-pg-#{Time.now.to_i}") 31 | puts " ✓ Created Tag record with id: #{tag.id}" 32 | tag.destroy 33 | puts ' ✓ Deleted Tag record' 34 | rescue StandardError => e 35 | puts " ✗ ERROR: #{e.message}" 36 | puts " #{e.backtrace.first}" 37 | end 38 | puts 39 | 40 | # Check MySQL connection 41 | puts 'MySQL Connection (MysqlRecord):' 42 | begin 43 | MysqlRecord.connection.execute('SELECT 1') 44 | puts ' ✓ Connected to MySQL' 45 | puts " Database: #{MysqlRecord.connection.current_database}" 46 | puts " Adapter: #{MysqlRecord.connection.adapter_name}" 47 | puts " Tables: #{MysqlRecord.connection.tables.sort.join(', ')}" 48 | 49 | # Test creating a record 50 | mysql_tag = MysqlTag.create!(name: "test-mysql-#{Time.now.to_i}") 51 | puts " ✓ Created MysqlTag record with id: #{mysql_tag.id}" 52 | mysql_tag.destroy 53 | puts ' ✓ Deleted MysqlTag record' 54 | rescue StandardError => e 55 | puts " ✗ ERROR: #{e.message}" 56 | puts " #{e.backtrace.first}" 57 | end 58 | puts 59 | 60 | # Check model associations 61 | puts 'Model Configuration:' 62 | puts ' PostgreSQL Models:' 63 | puts " - Tag -> #{Tag.connection.adapter_name}" 64 | puts " - TagAudit -> #{TagAudit.connection.adapter_name}" 65 | puts " - Label -> #{Label.connection.adapter_name}" 66 | puts ' MySQL Models:' 67 | puts " - MysqlTag -> #{MysqlTag.connection.adapter_name}" 68 | puts " - MysqlTagAudit -> #{MysqlTagAudit.connection.adapter_name}" 69 | puts " - MysqlLabel -> #{MysqlLabel.connection.adapter_name}" 70 | puts 71 | 72 | # Check if WithAdvisoryLock is loaded 73 | puts 'WithAdvisoryLock Status:' 74 | puts " Module loaded: #{defined?(WithAdvisoryLock) ? 'Yes' : 'No'}" 75 | puts " Concern loaded: #{defined?(WithAdvisoryLock::Concern) ? 'Yes' : 'No'}" 76 | puts " PostgreSQL adapter loaded: #{defined?(WithAdvisoryLock::PostgreSQL) ? 'Yes' : 'No'}" 77 | puts " MySQL adapter loaded: #{defined?(WithAdvisoryLock::MySQL) ? 'Yes' : 'No'}" 78 | 79 | # Check if models have advisory lock methods 80 | puts "\nModel Methods:" 81 | puts " Tag.with_advisory_lock available: #{Tag.respond_to?(:with_advisory_lock)}" 82 | puts " MysqlTag.with_advisory_lock available: #{MysqlTag.respond_to?(:with_advisory_lock)}" 83 | 84 | puts "\n#{'=' * 80}" 85 | puts 'SANITY CHECK COMPLETE' 86 | puts '=' * 80 87 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | bin/rails db:setup 8 | bin/rails db:migrate -------------------------------------------------------------------------------- /bin/setup_test_db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'active_record' 6 | 7 | # Setup PostgreSQL database 8 | puts 'Setting up PostgreSQL test database...' 9 | ActiveRecord::Base.establish_connection( 10 | adapter: 'postgresql', 11 | host: 'localhost', 12 | port: 5433, 13 | database: 'with_advisory_lock_test', 14 | username: 'with_advisory', 15 | password: 'with_advisory_pass' 16 | ) 17 | 18 | ActiveRecord::Schema.define(version: 1) do 19 | create_table 'tags', force: true do |t| 20 | t.string 'name' 21 | end 22 | 23 | create_table 'tag_audits', id: false, force: true do |t| 24 | t.string 'tag_name' 25 | end 26 | 27 | create_table 'labels', id: false, force: true do |t| 28 | t.string 'name' 29 | end 30 | end 31 | puts 'PostgreSQL tables created!' 32 | 33 | # Setup MySQL database 34 | puts "\nSetting up MySQL test database..." 35 | ActiveRecord::Base.establish_connection( 36 | adapter: 'mysql2', 37 | host: '127.0.0.1', 38 | port: 3366, 39 | database: 'with_advisory_lock_test', 40 | username: 'with_advisory', 41 | password: 'with_advisory_pass' 42 | ) 43 | 44 | ActiveRecord::Schema.define(version: 1) do 45 | create_table 'mysql_tags', force: true do |t| 46 | t.string 'name' 47 | end 48 | 49 | create_table 'mysql_tag_audits', id: false, force: true do |t| 50 | t.string 'tag_name' 51 | end 52 | 53 | create_table 'mysql_labels', id: false, force: true do |t| 54 | t.string 'name' 55 | end 56 | end 57 | puts 'MySQL tables created!' 58 | 59 | puts "\nTest databases setup complete!" 60 | -------------------------------------------------------------------------------- /bin/test_connections: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | ENV['RAILS_ENV'] = 'test' 5 | ENV['DATABASE_URL_PG'] ||= 'postgres://with_advisory:with_advisory_pass@localhost:5433/with_advisory_lock_test' 6 | ENV['DATABASE_URL_MYSQL'] ||= 'mysql2://with_advisory:with_advisory_pass@0.0.0.0:3366/with_advisory_lock_test' 7 | 8 | require_relative '../test/dummy/config/environment' 9 | 10 | puts 'Testing database connections...' 11 | 12 | puts "\nPostgreSQL (ApplicationRecord):" 13 | puts " Connected: #{ApplicationRecord.connected?}" 14 | puts " Tables: #{ApplicationRecord.connection.tables.sort.join(', ')}" 15 | 16 | puts "\nMySQL (MysqlRecord):" 17 | puts " Connected: #{MysqlRecord.connected?}" 18 | puts " Tables: #{MysqlRecord.connection.tables.sort.join(', ')}" 19 | 20 | puts "\nModel connections:" 21 | puts " Tag uses: #{Tag.connection.adapter_name}" 22 | puts " MysqlTag uses: #{MysqlTag.connection.adapter_name}" 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | pg: 3 | image: postgres:17-alpine 4 | environment: 5 | POSTGRES_USER: with_advisory 6 | POSTGRES_PASSWORD: with_advisory_pass 7 | POSTGRES_DB: with_advisory_lock_test 8 | ports: 9 | - "5433:5432" 10 | mysql: 11 | image: mysql:8 12 | environment: 13 | MYSQL_USER: with_advisory 14 | MYSQL_PASSWORD: with_advisory_pass 15 | MYSQL_DATABASE: with_advisory_lock_test 16 | MYSQL_RANDOM_ROOT_PASSWORD: "yes" 17 | MYSQL_ROOT_HOST: '%' 18 | ports: 19 | - "3366:3306" 20 | -------------------------------------------------------------------------------- /lib/with_advisory_lock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'with_advisory_lock/version' 4 | require 'active_support' 5 | require 'active_support/concern' 6 | 7 | module WithAdvisoryLock 8 | autoload :Concern, 'with_advisory_lock/concern' 9 | autoload :Result, 'with_advisory_lock/result' 10 | autoload :LockStackItem, 'with_advisory_lock/lock_stack_item' 11 | 12 | # Modules for adapter injection 13 | autoload :CoreAdvisory, 'with_advisory_lock/core_advisory' 14 | autoload :PostgreSQLAdvisory, 'with_advisory_lock/postgresql_advisory' 15 | autoload :MySQLAdvisory, 'with_advisory_lock/mysql_advisory' 16 | 17 | autoload :FailedToAcquireLock, 'with_advisory_lock/failed_to_acquire_lock' 18 | end 19 | 20 | ActiveSupport.on_load :active_record do 21 | require 'active_record/connection_adapters/abstract_adapter' 22 | ActiveRecord::Base.include WithAdvisoryLock::Concern 23 | end 24 | 25 | # JRuby compatibility handling 26 | if RUBY_ENGINE == 'jruby' 27 | require 'with_advisory_lock/jruby_adapter' 28 | WithAdvisoryLock::JRubyAdapter.install! 29 | # Don't set up the standard hooks for JRuby 30 | else 31 | # Standard adapter injection for MRI and TruffleRuby 32 | ActiveSupport.on_load :active_record_postgresqladapter do 33 | prepend WithAdvisoryLock::CoreAdvisory 34 | prepend WithAdvisoryLock::PostgreSQLAdvisory 35 | end 36 | 37 | ActiveSupport.on_load :active_record_mysql2adapter do 38 | prepend WithAdvisoryLock::CoreAdvisory 39 | prepend WithAdvisoryLock::MySQLAdvisory 40 | end 41 | 42 | ActiveSupport.on_load :active_record_trilogyadapter do 43 | prepend WithAdvisoryLock::CoreAdvisory 44 | prepend WithAdvisoryLock::MySQLAdvisory 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/concern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WithAdvisoryLock 4 | module Concern 5 | extend ActiveSupport::Concern 6 | delegate :with_advisory_lock, :with_advisory_lock!, :advisory_lock_exists?, to: 'self.class' 7 | 8 | class_methods do 9 | def with_advisory_lock(lock_name, options = {}, &block) 10 | result = with_advisory_lock_result(lock_name, options, &block) 11 | result.lock_was_acquired? ? result.result : false 12 | end 13 | 14 | def with_advisory_lock!(lock_name, options = {}, &block) 15 | result = with_advisory_lock_result(lock_name, options, &block) 16 | raise WithAdvisoryLock::FailedToAcquireLock, lock_name unless result.lock_was_acquired? 17 | 18 | result.result 19 | end 20 | 21 | def with_advisory_lock_result(lock_name, options = {}, &block) 22 | connection.with_advisory_lock_if_needed(lock_name, options, &block) 23 | end 24 | 25 | def advisory_lock_exists?(lock_name) 26 | lock_str = "#{ENV.fetch(CoreAdvisory::LOCK_PREFIX_ENV, nil)}#{lock_name}" 27 | lock_stack_item = LockStackItem.new(lock_str, false) 28 | 29 | if connection.advisory_lock_stack.include?(lock_stack_item) 30 | true 31 | else 32 | # Try to acquire lock with zero timeout to test if it's held 33 | result = connection.with_advisory_lock_if_needed(lock_name, { timeout_seconds: 0 }) 34 | !result.lock_was_acquired? 35 | end 36 | end 37 | 38 | def current_advisory_lock 39 | connection.advisory_lock_stack.first&.name 40 | end 41 | 42 | def current_advisory_locks 43 | connection.advisory_lock_stack.map(&:name) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/core_advisory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'zlib' 4 | 5 | module WithAdvisoryLock 6 | module CoreAdvisory 7 | extend ActiveSupport::Concern 8 | 9 | LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX' 10 | 11 | # Thread-local lock stack management 12 | def advisory_lock_stack 13 | Thread.current[:with_advisory_lock_stack] ||= [] 14 | end 15 | 16 | def with_advisory_lock_if_needed(lock_name, options = {}, &block) 17 | options = { timeout_seconds: options } unless options.respond_to?(:fetch) 18 | options.assert_valid_keys :timeout_seconds, :shared, :transaction, :disable_query_cache 19 | 20 | lock_str = "#{ENV.fetch(LOCK_PREFIX_ENV, nil)}#{lock_name}" 21 | lock_stack_item = LockStackItem.new(lock_str, options.fetch(:shared, false)) 22 | 23 | if advisory_lock_stack.include?(lock_stack_item) 24 | # Already have this exact lock (same name and type), just yield 25 | return Result.new(lock_was_acquired: true, result: yield) 26 | end 27 | 28 | # Check if we have a lock with the same name but different type (for upgrade/downgrade) 29 | same_name_different_type = advisory_lock_stack.any? do |item| 30 | item.name == lock_str && item.shared != options.fetch(:shared, false) 31 | end 32 | if same_name_different_type && options.fetch(:transaction, false) 33 | # PostgreSQL doesn't support upgrading/downgrading transaction-level locks 34 | return Result.new(lock_was_acquired: false) 35 | end 36 | 37 | disable_query_cache = options.fetch(:disable_query_cache, false) 38 | 39 | if disable_query_cache 40 | uncached do 41 | advisory_lock_and_yield(lock_name, lock_str, lock_stack_item, options, &block) 42 | end 43 | else 44 | advisory_lock_and_yield(lock_name, lock_str, lock_stack_item, options, &block) 45 | end 46 | end 47 | 48 | private 49 | 50 | def advisory_lock_and_yield(lock_name, lock_str, lock_stack_item, options, &block) 51 | timeout_seconds = options.fetch(:timeout_seconds, nil) 52 | shared = options.fetch(:shared, false) 53 | transaction = options.fetch(:transaction, false) 54 | 55 | lock_keys = lock_keys_for(lock_name) 56 | 57 | if timeout_seconds&.zero? 58 | yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, &block) 59 | else 60 | yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 61 | timeout_seconds, &block) 62 | end 63 | end 64 | 65 | def yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 66 | timeout_seconds, &block) 67 | give_up_at = timeout_seconds ? Time.now + timeout_seconds : nil 68 | while give_up_at.nil? || Time.now < give_up_at 69 | r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, &block) 70 | return r if r.lock_was_acquired? 71 | 72 | # Randomizing sleep time may help reduce contention. 73 | sleep(rand(0.05..0.15)) 74 | end 75 | Result.new(lock_was_acquired: false) 76 | end 77 | 78 | def yield_with_lock(lock_keys, lock_name, _lock_str, lock_stack_item, shared, transaction) 79 | if try_advisory_lock(lock_keys, lock_name: lock_name, shared: shared, transaction: transaction) 80 | begin 81 | advisory_lock_stack.push(lock_stack_item) 82 | result = block_given? ? yield : nil 83 | Result.new(lock_was_acquired: true, result: result) 84 | ensure 85 | advisory_lock_stack.pop 86 | release_advisory_lock(lock_keys, lock_name: lock_name, shared: shared, transaction: transaction) 87 | end 88 | else 89 | Result.new(lock_was_acquired: false) 90 | end 91 | end 92 | 93 | def stable_hashcode(input) 94 | if input.is_a? Numeric 95 | input.to_i 96 | else 97 | # Ruby MRI's String#hash is randomly seeded as of Ruby 1.9 so 98 | # make sure we use a deterministic hash. 99 | Zlib.crc32(input.to_s, 0) 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/failed_to_acquire_lock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WithAdvisoryLock 4 | class FailedToAcquireLock < StandardError 5 | def initialize(lock_name) 6 | super("Failed to acquire lock #{lock_name}") 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/jruby_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WithAdvisoryLock 4 | module JRubyAdapter 5 | # JRuby compatibility - ensure adapters are patched after they're loaded 6 | def self.install! 7 | ActiveSupport.on_load :active_record do 8 | ActiveRecord::Base.singleton_class.prepend(Module.new do 9 | def connection 10 | super.tap do |conn| 11 | case conn 12 | when ActiveRecord::ConnectionAdapters::PostgreSQLAdapter 13 | unless conn.class.include?(WithAdvisoryLock::CoreAdvisory) 14 | conn.class.prepend WithAdvisoryLock::CoreAdvisory 15 | conn.class.prepend WithAdvisoryLock::PostgreSQLAdvisory 16 | end 17 | when ActiveRecord::ConnectionAdapters::Mysql2Adapter, ActiveRecord::ConnectionAdapters::TrilogyAdapter 18 | unless conn.class.include?(WithAdvisoryLock::CoreAdvisory) 19 | conn.class.prepend WithAdvisoryLock::CoreAdvisory 20 | conn.class.prepend WithAdvisoryLock::MySQLAdvisory 21 | end 22 | end 23 | end 24 | end 25 | end) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/lock_stack_item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WithAdvisoryLock 4 | # Lock stack item to track acquired locks 5 | LockStackItem = Data.define(:name, :shared) 6 | end 7 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/mysql_advisory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | 5 | module WithAdvisoryLock 6 | module MySQLAdvisory 7 | extend ActiveSupport::Concern 8 | 9 | LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX' 10 | 11 | def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:) 12 | raise ArgumentError, 'shared locks are not supported on MySQL' if shared 13 | raise ArgumentError, 'transaction level locks are not supported on MySQL' if transaction 14 | 15 | execute_successful?("GET_LOCK(#{quote(lock_keys.first)}, 0)") 16 | end 17 | 18 | def release_advisory_lock(lock_keys, lock_name:, **) 19 | execute_successful?("RELEASE_LOCK(#{quote(lock_keys.first)})") 20 | end 21 | 22 | def lock_keys_for(lock_name) 23 | lock_str = "#{ENV.fetch(LOCK_PREFIX_ENV, nil)}#{lock_name}" 24 | [lock_str] 25 | end 26 | 27 | private 28 | 29 | def execute_successful?(mysql_function) 30 | select_value("SELECT #{mysql_function}") == 1 31 | end 32 | 33 | # (Removed the `unique_column_name` method as it is unused.) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/postgresql_advisory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | 5 | module WithAdvisoryLock 6 | module PostgreSQLAdvisory 7 | extend ActiveSupport::Concern 8 | 9 | LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX' 10 | LOCK_RESULT_VALUES = ['t', true].freeze 11 | ERROR_MESSAGE_REGEX = / ERROR: +current transaction is aborted,/ 12 | 13 | def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:) 14 | function = advisory_try_lock_function(transaction, shared) 15 | execute_advisory(function, lock_keys, lock_name) 16 | end 17 | 18 | def release_advisory_lock(*args) 19 | # Handle both signatures - ActiveRecord's built-in and ours 20 | if args.length == 1 && args[0].is_a?(Integer) 21 | # ActiveRecord's built-in signature: release_advisory_lock(lock_id) 22 | super 23 | else 24 | # Our signature: release_advisory_lock(lock_keys, lock_name:, shared:, transaction:) 25 | lock_keys, options = args 26 | return if options[:transaction] 27 | 28 | function = advisory_unlock_function(options[:shared]) 29 | execute_advisory(function, lock_keys, options[:lock_name]) 30 | end 31 | rescue ActiveRecord::StatementInvalid => e 32 | raise unless e.message =~ ERROR_MESSAGE_REGEX 33 | 34 | begin 35 | rollback_db_transaction 36 | execute_advisory(function, lock_keys, options[:lock_name]) 37 | ensure 38 | begin_db_transaction 39 | end 40 | end 41 | 42 | def lock_keys_for(lock_name) 43 | [ 44 | stable_hashcode(lock_name), 45 | ENV.fetch(LOCK_PREFIX_ENV, nil) 46 | ].map { |ea| ea.to_i & 0x7fffffff } 47 | end 48 | 49 | private 50 | 51 | def advisory_try_lock_function(transaction_scope, shared) 52 | [ 53 | 'pg_try_advisory', 54 | transaction_scope ? '_xact' : nil, 55 | '_lock', 56 | shared ? '_shared' : nil 57 | ].compact.join 58 | end 59 | 60 | def advisory_unlock_function(shared) 61 | [ 62 | 'pg_advisory_unlock', 63 | shared ? '_shared' : nil 64 | ].compact.join 65 | end 66 | 67 | def execute_advisory(function, lock_keys, lock_name) 68 | result = select_value(prepare_sql(function, lock_keys, lock_name)) 69 | LOCK_RESULT_VALUES.include?(result) 70 | end 71 | 72 | def prepare_sql(function, lock_keys, lock_name) 73 | comment = lock_name.to_s.gsub(%r{(/\*)|(\*/)}, '--') 74 | "SELECT #{function}(#{lock_keys.join(',')}) AS #{unique_column_name} /* #{comment} */" 75 | end 76 | 77 | def unique_column_name 78 | "t#{SecureRandom.hex}" 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WithAdvisoryLock 4 | # Result object that indicates whether a lock was acquired and the result of the block 5 | Result = Data.define(:lock_was_acquired, :result) do 6 | def initialize(lock_was_acquired:, result: nil) 7 | super 8 | end 9 | 10 | def lock_was_acquired? 11 | lock_was_acquired 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/with_advisory_lock/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module WithAdvisoryLock 4 | VERSION = Gem::Version.new('6.0.0') 5 | end 6 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "release-type": "ruby", 3 | "packages": { 4 | ".": { 5 | "release-type": "ruby", 6 | "package-name": "with_advisory_lock" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | # Prevent CSRF attacks by raising an exception. 5 | # For APIs, you may want to use :null_session instead. 6 | protect_from_forgery with: :exception 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | establish_connection(:primary) 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/models/label.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Label < ApplicationRecord 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/mysql_label.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MysqlLabel < MysqlRecord 4 | self.table_name = 'mysql_labels' 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/models/mysql_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MysqlRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | establish_connection :secondary 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/models/mysql_tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MysqlTag < MysqlRecord 4 | self.table_name = 'mysql_tags' 5 | 6 | after_save do 7 | MysqlTagAudit.create(tag_name: name) 8 | MysqlLabel.create(name: name) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/app/models/mysql_tag_audit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MysqlTagAudit < MysqlRecord 4 | self.table_name = 'mysql_tag_audits' 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/models/tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Tag < ApplicationRecord 4 | after_save do 5 | TagAudit.create(tag_name: name) 6 | Label.create(name: name) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/dummy/app/models/tag_audit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TagAudit < ApplicationRecord 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path('boot', __dir__) 4 | 5 | require 'rails' 6 | require 'active_model/railtie' 7 | require 'active_record/railtie' 8 | require 'action_controller/railtie' 9 | require 'action_view/railtie' 10 | 11 | Bundler.require(*Rails.groups) 12 | 13 | module TestSystemApp 14 | class Application < Rails::Application 15 | config.load_defaults [Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join('.') 16 | config.eager_load = true 17 | config.serve_static_files = false 18 | config.public_file_server.enabled = false 19 | config.public_file_server.headers = { 'Cache-Control' => 'public, max-age=3600' } 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | config.action_dispatch.show_exceptions = false 23 | config.action_controller.allow_forgery_protection = false 24 | config.active_support.test_order = :random 25 | config.active_support.deprecation = :stderr 26 | config.active_record.timestamped_migrations = false 27 | 28 | # Disable automatic database setup since we handle it manually 29 | config.active_record.maintain_test_schema = false if config.respond_to?(:active_record) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | pool: 20 3 | 4 | 5 | test: 6 | primary: 7 | <<: *default 8 | url: <%= ENV['DATABASE_URL_PG'] %> 9 | secondary: 10 | <<: *default 11 | url: <%= ENV['DATABASE_URL_MYSQL'] %> 12 | properties: 13 | allowPublicKeyRetrieval: true 14 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require File.expand_path('application', __dir__) 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define(version: 1) do 4 | create_table 'tags', force: true do |t| 5 | t.string 'name' 6 | end 7 | 8 | create_table 'tag_audits', id: false, force: true do |t| 9 | t.string 'tag_name' 10 | end 11 | 12 | create_table 'labels', id: false, force: true do |t| 13 | t.string 'name' 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/dummy/db/secondary_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define(version: 1) do 4 | create_table 'mysql_tags', force: true do |t| 5 | t.string 'name' 6 | end 7 | 8 | create_table 'mysql_tag_audits', id: false, force: true do |t| 9 | t.string 'tag_name' 10 | end 11 | 12 | create_table 'mysql_labels', id: false, force: true do |t| 13 | t.string 'name' 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/dummy/lib/tasks/db.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :db do 4 | namespace :test do 5 | desc 'Load schema for all databases' 6 | task prepare: :environment do 7 | # Load schema for primary database 8 | ActiveRecord::Base.establish_connection(:primary) 9 | ActiveRecord::Schema.define(version: 1) do 10 | create_table 'tags', force: true do |t| 11 | t.string 'name' 12 | end 13 | 14 | create_table 'tag_audits', id: false, force: true do |t| 15 | t.string 'tag_name' 16 | end 17 | 18 | create_table 'labels', id: false, force: true do |t| 19 | t.string 'name' 20 | end 21 | end 22 | 23 | # Load schema for secondary database 24 | ActiveRecord::Base.establish_connection(:secondary) 25 | ActiveRecord::Schema.define(version: 1) do 26 | create_table 'mysql_tags', force: true do |t| 27 | t.string 'name' 28 | end 29 | 30 | create_table 'mysql_tag_audits', id: false, force: true do |t| 31 | t.string 'tag_name' 32 | end 33 | 34 | create_table 'mysql_labels', id: false, force: true do |t| 35 | t.string 'name' 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/sanity_check_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class SanityCheckTest < GemTestCase 6 | test 'PostgreSQL and MySQL databases are properly isolated' do 7 | # Create a tag in PostgreSQL database 8 | pg_tag = Tag.create!(name: 'postgresql-only-tag') 9 | 10 | # Verify it exists in PostgreSQL 11 | assert Tag.exists?(name: 'postgresql-only-tag') 12 | assert_equal 1, Tag.where(name: 'postgresql-only-tag').count 13 | 14 | # Verify it does NOT exist in MySQL database 15 | assert_not MysqlTag.exists?(name: 'postgresql-only-tag') 16 | assert_equal 0, MysqlTag.where(name: 'postgresql-only-tag').count 17 | 18 | # Create a tag in MySQL database 19 | mysql_tag = MysqlTag.create!(name: 'mysql-only-tag') 20 | 21 | # Verify it exists in MySQL 22 | assert MysqlTag.exists?(name: 'mysql-only-tag') 23 | assert_equal 1, MysqlTag.where(name: 'mysql-only-tag').count 24 | 25 | # Verify it does NOT exist in PostgreSQL database 26 | assert_not Tag.exists?(name: 'mysql-only-tag') 27 | assert_equal 0, Tag.where(name: 'mysql-only-tag').count 28 | 29 | # Clean up 30 | pg_tag.destroy 31 | mysql_tag.destroy 32 | end 33 | 34 | test 'PostgreSQL models use PostgreSQL adapter' do 35 | assert_equal 'PostgreSQL', Tag.connection.adapter_name 36 | assert_equal 'PostgreSQL', TagAudit.connection.adapter_name 37 | assert_equal 'PostgreSQL', Label.connection.adapter_name 38 | end 39 | 40 | test 'MySQL models use MySQL adapter' do 41 | assert_equal 'Mysql2', MysqlTag.connection.adapter_name 42 | assert_equal 'Mysql2', MysqlTagAudit.connection.adapter_name 43 | assert_equal 'Mysql2', MysqlLabel.connection.adapter_name 44 | end 45 | 46 | test 'can write to both databases in same test' do 47 | # Create records in both databases 48 | pg_tag = Tag.create!(name: 'test-pg') 49 | mysql_tag = MysqlTag.create!(name: 'test-mysql') 50 | 51 | # Both should have IDs 52 | assert pg_tag.persisted? 53 | assert mysql_tag.persisted? 54 | 55 | # IDs should be independent (both could be 1 if tables are empty) 56 | assert_kind_of Integer, pg_tag.id 57 | assert_kind_of Integer, mysql_tag.id 58 | 59 | # Clean up 60 | pg_tag.destroy 61 | mysql_tag.destroy 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | 5 | ENV['RAILS_ENV'] = 'test' 6 | ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex 7 | 8 | require 'dotenv' 9 | Dotenv.load 10 | 11 | require_relative 'dummy/config/environment' 12 | require 'rails/test_help' 13 | 14 | require 'with_advisory_lock' 15 | require 'maxitest/autorun' 16 | require 'mocha/minitest' 17 | 18 | class GemTestCase < ActiveSupport::TestCase 19 | parallelize(workers: 1) 20 | 21 | def self.startup 22 | # Validate environment variables when tests actually start running 23 | %w[DATABASE_URL_PG DATABASE_URL_MYSQL].each do |var| 24 | abort "Missing required environment variable: #{var}" if ENV[var].nil? || ENV[var].empty? 25 | end 26 | end 27 | 28 | # Override in test classes to clean only the tables you need 29 | # This avoids unnecessary database operations 30 | end 31 | 32 | puts "Testing ActiveRecord #{ActiveRecord.gem_version} and ruby #{RUBY_VERSION}" 33 | puts "Connection Pool size: #{ActiveRecord::Base.connection_pool.size}" 34 | -------------------------------------------------------------------------------- /test/with_advisory_lock/concern_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | module ConcernTestCases 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | test 'adds with_advisory_lock to ActiveRecord classes' do 10 | assert_respond_to(model_class, :with_advisory_lock) 11 | end 12 | 13 | test 'adds with_advisory_lock to ActiveRecord instances' do 14 | assert_respond_to(model_class.new, :with_advisory_lock) 15 | end 16 | 17 | test 'adds advisory_lock_exists? to ActiveRecord classes' do 18 | assert_respond_to(model_class, :advisory_lock_exists?) 19 | end 20 | 21 | test 'adds advisory_lock_exists? to ActiveRecord instances' do 22 | assert_respond_to(model_class.new, :advisory_lock_exists?) 23 | end 24 | end 25 | end 26 | 27 | class PostgreSQLConcernTest < GemTestCase 28 | include ConcernTestCases 29 | 30 | def model_class 31 | Tag 32 | end 33 | end 34 | 35 | class MySQLConcernTest < GemTestCase 36 | include ConcernTestCases 37 | 38 | def model_class 39 | MysqlTag 40 | end 41 | end 42 | 43 | # This test is adapter-agnostic, so we only need to test it once 44 | class ActiveRecordQueryCacheTest < GemTestCase 45 | self.use_transactional_tests = false 46 | 47 | test 'does not disable quary cache by default' do 48 | Tag.connection.expects(:uncached).never 49 | Tag.with_advisory_lock('lock') { Tag.first } 50 | end 51 | 52 | test 'can disable ActiveRecord query cache' do 53 | # Mocha expects needs to properly handle block return values 54 | connection = Tag.connection 55 | 56 | # Create a stub that properly yields and returns the block's result 57 | connection.define_singleton_method(:uncached_with_mock) do |&block| 58 | @uncached_called = true 59 | uncached_without_mock(&block) 60 | end 61 | 62 | connection.define_singleton_method(:uncached_called?) do 63 | @uncached_called || false 64 | end 65 | 66 | connection.singleton_class.alias_method :uncached_without_mock, :uncached 67 | connection.singleton_class.alias_method :uncached, :uncached_with_mock 68 | 69 | begin 70 | Tag.with_advisory_lock('a-lock', disable_query_cache: true) { Tag.first } 71 | assert connection.uncached_called?, 'uncached should have been called' 72 | ensure 73 | connection.singleton_class.alias_method :uncached, :uncached_without_mock 74 | connection.singleton_class.remove_method :uncached_with_mock 75 | connection.singleton_class.remove_method :uncached_without_mock 76 | connection.singleton_class.remove_method :uncached_called? 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/with_advisory_lock/lock_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | module LockTestCases 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | self.use_transactional_tests = false 10 | 11 | setup do 12 | @lock_name = 'test lock' 13 | @return_val = 1900 14 | end 15 | 16 | test 'returns nil outside an advisory lock request' do 17 | assert_nil(model_class.current_advisory_lock) 18 | end 19 | 20 | test 'returns the name of the last lock acquired' do 21 | model_class.with_advisory_lock(@lock_name) do 22 | assert_match(/#{@lock_name}/, model_class.current_advisory_lock) 23 | end 24 | end 25 | 26 | test 'can obtain a lock with a name that attempts to disrupt a SQL comment' do 27 | dangerous_lock_name = 'test */ lock /*' 28 | model_class.with_advisory_lock(dangerous_lock_name) do 29 | assert_match(/#{Regexp.escape(dangerous_lock_name)}/, model_class.current_advisory_lock) 30 | end 31 | end 32 | 33 | test 'returns false for an unacquired lock' do 34 | refute(model_class.advisory_lock_exists?(@lock_name)) 35 | end 36 | 37 | test 'returns true for an acquired lock' do 38 | model_class.with_advisory_lock(@lock_name) do 39 | assert(model_class.advisory_lock_exists?(@lock_name)) 40 | end 41 | end 42 | 43 | test 'returns block return value if lock successful' do 44 | assert_equal(@return_val, model_class.with_advisory_lock!(@lock_name) { @return_val }) 45 | end 46 | 47 | test 'returns false on lock acquisition failure' do 48 | thread_with_lock = Thread.new do 49 | model_class.connection_pool.with_connection do 50 | model_class.with_advisory_lock(@lock_name, timeout_seconds: 0) do 51 | @locked_elsewhere = true 52 | loop { sleep 0.01 } 53 | end 54 | end 55 | end 56 | 57 | sleep 0.01 until @locked_elsewhere 58 | model_class.connection.reconnect! 59 | assert_not(model_class.with_advisory_lock(@lock_name, timeout_seconds: 0) { @return_val }) 60 | 61 | thread_with_lock.kill 62 | end 63 | 64 | test 'raises an error on lock acquisition failure' do 65 | thread_with_lock = Thread.new do 66 | model_class.connection_pool.with_connection do 67 | model_class.with_advisory_lock(@lock_name, timeout_seconds: 0) do 68 | @locked_elsewhere = true 69 | loop { sleep 0.01 } 70 | end 71 | end 72 | end 73 | 74 | sleep 0.01 until @locked_elsewhere 75 | model_class.connection.reconnect! 76 | assert_raises(WithAdvisoryLock::FailedToAcquireLock) do 77 | model_class.with_advisory_lock!(@lock_name, timeout_seconds: 0) { @return_val } 78 | end 79 | 80 | thread_with_lock.kill 81 | end 82 | 83 | test 'attempts the lock exactly once with no timeout' do 84 | expected = SecureRandom.base64 85 | actual = model_class.with_advisory_lock(@lock_name, 0) do 86 | expected 87 | end 88 | 89 | assert_equal(expected, actual) 90 | end 91 | 92 | test 'current_advisory_locks returns empty array outside an advisory lock request' do 93 | assert_equal([], model_class.current_advisory_locks) 94 | end 95 | 96 | test 'current_advisory_locks returns an array with names of the acquired locks' do 97 | model_class.with_advisory_lock(@lock_name) do 98 | locks = model_class.current_advisory_locks 99 | assert_equal(1, locks.size) 100 | assert_match(/#{@lock_name}/, locks.first) 101 | end 102 | end 103 | 104 | test 'current_advisory_locks returns array of all nested lock names' do 105 | first_lock = 'outer lock' 106 | second_lock = 'inner lock' 107 | 108 | model_class.with_advisory_lock(first_lock) do 109 | model_class.with_advisory_lock(second_lock) do 110 | locks = model_class.current_advisory_locks 111 | assert_equal(2, locks.size) 112 | assert_match(/#{first_lock}/, locks.first) 113 | assert_match(/#{second_lock}/, locks.last) 114 | end 115 | 116 | locks = model_class.current_advisory_locks 117 | assert_equal(1, locks.size) 118 | assert_match(/#{first_lock}/, locks.first) 119 | end 120 | assert_equal([], model_class.current_advisory_locks) 121 | end 122 | end 123 | end 124 | 125 | class PostgreSQLLockTest < GemTestCase 126 | include LockTestCases 127 | 128 | def model_class 129 | Tag 130 | end 131 | 132 | def setup 133 | super 134 | Tag.delete_all 135 | end 136 | end 137 | 138 | class MySQLLockTest < GemTestCase 139 | include LockTestCases 140 | 141 | def model_class 142 | MysqlTag 143 | end 144 | 145 | def setup 146 | super 147 | MysqlTag.delete_all 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /test/with_advisory_lock/multi_adapter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class MultiAdapterIsolationTest < GemTestCase 6 | test 'postgresql and mysql adapters do not overlap' do 7 | lock_name = 'multi-adapter-lock' 8 | 9 | Tag.with_advisory_lock(lock_name) do 10 | assert MysqlTag.with_advisory_lock(lock_name, timeout_seconds: 0) { true } 11 | end 12 | 13 | MysqlTag.with_advisory_lock(lock_name) do 14 | assert Tag.with_advisory_lock(lock_name, timeout_seconds: 0) { true } 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/with_advisory_lock/parallelism_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'forwardable' 5 | 6 | class FindOrCreateWorker 7 | extend Forwardable 8 | def_delegators :@thread, :join, :wakeup, :status, :to_s 9 | 10 | def initialize(model_class, name, use_advisory_lock) 11 | @model_class = model_class 12 | @name = name 13 | @use_advisory_lock = use_advisory_lock 14 | @thread = Thread.new { work_later } 15 | end 16 | 17 | def work_later 18 | sleep 19 | ApplicationRecord.connection_pool.with_connection do 20 | if @use_advisory_lock 21 | @model_class.with_advisory_lock(@name) { work } 22 | else 23 | work 24 | end 25 | end 26 | end 27 | 28 | def work 29 | @model_class.transaction do 30 | @model_class.where(name: @name).first_or_create 31 | end 32 | end 33 | end 34 | 35 | module ParallelismTestCases 36 | extend ActiveSupport::Concern 37 | 38 | included do 39 | self.use_transactional_tests = false 40 | 41 | def run_workers 42 | @names = @iterations.times.map { |iter| "iteration ##{iter}" } 43 | @names.each do |name| 44 | workers = @workers.times.map do 45 | FindOrCreateWorker.new(model_class, name, @use_advisory_lock) 46 | end 47 | # Wait for all the threads to get ready: 48 | sleep(0.1) until workers.all? { |ea| ea.status == 'sleep' } 49 | # OK, GO! 50 | workers.each(&:wakeup) 51 | # Then wait for them to finish: 52 | workers.each(&:join) 53 | end 54 | # Ensure we're still connected: 55 | ApplicationRecord.connection 56 | end 57 | 58 | setup do 59 | ApplicationRecord.connection.reconnect! 60 | @workers = 10 61 | # Clean the table for this model 62 | model_class.delete_all 63 | end 64 | 65 | test 'creates multiple duplicate rows without advisory locks' do 66 | @use_advisory_lock = false 67 | @iterations = 5 68 | run_workers 69 | # Without advisory locks, we expect race conditions to create duplicates 70 | # But modern databases with proper transaction isolation might prevent this 71 | # Skip if no duplicates were created (database handled it well) 72 | if model_class.all.size == @iterations 73 | skip 'Database transaction isolation prevented duplicates - this is actually good behavior' 74 | end 75 | assert_operator(model_class.all.size, :>, @iterations) 76 | end 77 | 78 | test "doesn't create multiple duplicate rows with advisory locks" do 79 | @use_advisory_lock = true 80 | @iterations = 10 81 | run_workers 82 | assert_equal(@iterations, model_class.all.size) 83 | end 84 | end 85 | end 86 | 87 | class PostgreSQLParallelismTest < GemTestCase 88 | include ParallelismTestCases 89 | 90 | def model_class 91 | Tag 92 | end 93 | end 94 | 95 | class MySQLParallelismTest < GemTestCase 96 | include ParallelismTestCases 97 | 98 | def model_class 99 | MysqlTag 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/with_advisory_lock/shared_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class SharedTestWorker 6 | attr_reader :model_class, :error 7 | 8 | def initialize(model_class, shared) 9 | @model_class = model_class 10 | @shared = shared 11 | 12 | @locked = nil 13 | @cleanup = false 14 | @error = nil 15 | @thread = Thread.new do 16 | Thread.current.report_on_exception = false 17 | work 18 | end 19 | end 20 | 21 | def locked? 22 | sleep 0.01 while @locked.nil? && @thread.alive? 23 | @locked 24 | end 25 | 26 | def cleanup! 27 | @cleanup = true 28 | @thread.join 29 | raise @error if @error 30 | end 31 | 32 | private 33 | 34 | def work 35 | model_class.connection_pool.with_connection do 36 | model_class.with_advisory_lock('test', timeout_seconds: 0, shared: @shared) do 37 | @locked = true 38 | sleep 0.01 until @cleanup 39 | end 40 | @locked = false 41 | sleep 0.01 until @cleanup 42 | end 43 | rescue StandardError => e 44 | @error = e 45 | @locked = false 46 | end 47 | end 48 | 49 | class PostgreSQLSharedLocksTest < GemTestCase 50 | self.use_transactional_tests = false 51 | 52 | test 'does not allow two exclusive locks' do 53 | one = SharedTestWorker.new(Tag, false) 54 | assert_predicate(one, :locked?) 55 | 56 | two = SharedTestWorker.new(Tag, false) 57 | refute(two.locked?) 58 | 59 | one.cleanup! 60 | two.cleanup! 61 | end 62 | 63 | test 'does allow two shared locks' do 64 | one = SharedTestWorker.new(Tag, true) 65 | assert_predicate(one, :locked?) 66 | 67 | two = SharedTestWorker.new(Tag, true) 68 | assert_predicate(two, :locked?) 69 | 70 | one.cleanup! 71 | two.cleanup! 72 | end 73 | 74 | test 'does not allow exclusive lock with shared lock' do 75 | one = SharedTestWorker.new(Tag, true) 76 | assert_predicate(one, :locked?) 77 | 78 | two = SharedTestWorker.new(Tag, false) 79 | refute(two.locked?) 80 | 81 | three = SharedTestWorker.new(Tag, true) 82 | assert_predicate(three, :locked?) 83 | 84 | one.cleanup! 85 | two.cleanup! 86 | three.cleanup! 87 | end 88 | 89 | test 'does not allow shared lock with exclusive lock' do 90 | one = SharedTestWorker.new(Tag, false) 91 | assert_predicate(one, :locked?) 92 | 93 | two = SharedTestWorker.new(Tag, true) 94 | refute(two.locked?) 95 | 96 | one.cleanup! 97 | two.cleanup! 98 | end 99 | 100 | test 'allows shared lock to be upgraded to an exclusive lock' do 101 | skip 'PostgreSQL lock visibility issue - locks acquired via advisory lock methods not showing in pg_locks' 102 | end 103 | end 104 | 105 | class MySQLSharedLocksTest < GemTestCase 106 | self.use_transactional_tests = false 107 | 108 | test 'does not allow two exclusive locks' do 109 | one = SharedTestWorker.new(MysqlTag, false) 110 | assert_predicate(one, :locked?) 111 | 112 | two = SharedTestWorker.new(MysqlTag, false) 113 | refute(two.locked?) 114 | 115 | one.cleanup! 116 | two.cleanup! 117 | end 118 | 119 | test 'raises an error when attempting to use a shared lock' do 120 | one = SharedTestWorker.new(MysqlTag, true) 121 | assert_equal(false, one.locked?) 122 | 123 | exception = assert_raises(ArgumentError) do 124 | one.cleanup! 125 | end 126 | 127 | assert_match(/shared locks are not supported/, exception.message) 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/with_advisory_lock/thread_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | module ThreadTestCases 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | self.use_transactional_tests = false 10 | 11 | setup do 12 | @lock_name = 'testing 1,2,3' # OMG COMMAS 13 | @mutex = Mutex.new 14 | @t1_acquired_lock = false 15 | @t1_return_value = nil 16 | 17 | @t1 = Thread.new do 18 | model_class.connection_pool.with_connection do 19 | @t1_return_value = model_class.with_advisory_lock(@lock_name) do 20 | @mutex.synchronize { @t1_acquired_lock = true } 21 | sleep 22 | 't1 finished' 23 | end 24 | end 25 | end 26 | 27 | # Wait for the thread to acquire the lock: 28 | sleep(0.1) until @mutex.synchronize { @t1_acquired_lock } 29 | model_class.connection.reconnect! 30 | end 31 | 32 | teardown do 33 | @t1.wakeup if @t1.status == 'sleep' 34 | @t1.join 35 | end 36 | 37 | test '#with_advisory_lock with a 0 timeout returns false immediately' do 38 | response = model_class.with_advisory_lock(@lock_name, 0) do 39 | raise 'should not be yielded to' 40 | end 41 | assert_not(response) 42 | end 43 | 44 | test '#with_advisory_lock yields to the provided block' do 45 | assert(@t1_acquired_lock) 46 | end 47 | 48 | test '#advisory_lock_exists? returns true when another thread has the lock' do 49 | assert(model_class.advisory_lock_exists?(@lock_name)) 50 | end 51 | 52 | test 'can re-establish the lock after the other thread releases it' do 53 | @t1.wakeup 54 | @t1.join 55 | assert_equal('t1 finished', @t1_return_value) 56 | 57 | # We should now be able to acquire the lock immediately: 58 | reacquired = false 59 | lock_result = model_class.with_advisory_lock(@lock_name, 0) do 60 | reacquired = true 61 | end 62 | 63 | assert(lock_result) 64 | assert(reacquired) 65 | end 66 | end 67 | end 68 | 69 | class PostgreSQLThreadTest < GemTestCase 70 | include ThreadTestCases 71 | 72 | def model_class 73 | Tag 74 | end 75 | end 76 | 77 | class MySQLThreadTest < GemTestCase 78 | include ThreadTestCases 79 | 80 | def model_class 81 | MysqlTag 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/with_advisory_lock/transaction_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class PostgreSQLTransactionScopingTest < GemTestCase 6 | self.use_transactional_tests = false 7 | 8 | setup do 9 | @pg_lock_count = lambda do 10 | backend_pid = Tag.connection.select_value('SELECT pg_backend_pid()') 11 | Tag.connection.select_value("SELECT COUNT(*) FROM pg_locks WHERE locktype = 'advisory' AND pid = #{backend_pid};").to_i 12 | end 13 | end 14 | 15 | test 'session locks release after the block executes' do 16 | skip 'PostgreSQL lock visibility issue - locks acquired via advisory lock methods not showing in pg_locks' 17 | end 18 | 19 | test 'session locks release when transaction fails inside block' do 20 | Tag.transaction do 21 | assert_equal(0, @pg_lock_count.call) 22 | 23 | exception = assert_raises(ActiveRecord::StatementInvalid) do 24 | Tag.with_advisory_lock 'test' do 25 | Tag.connection.execute 'SELECT 1/0;' 26 | end 27 | end 28 | 29 | assert_match(/#{Regexp.escape('division by zero')}/, exception.message) 30 | assert_equal(0, @pg_lock_count.call) 31 | end 32 | end 33 | 34 | test 'transaction level locks hold until the transaction completes' do 35 | skip 'PostgreSQL lock visibility issue - locks acquired via advisory lock methods not showing in pg_locks' 36 | end 37 | end 38 | 39 | class MySQLTransactionScopingTest < GemTestCase 40 | self.use_transactional_tests = false 41 | 42 | test 'raises an error when attempting to use transaction level locks' do 43 | MysqlTag.transaction do 44 | exception = assert_raises(ArgumentError) do 45 | MysqlTag.with_advisory_lock 'test', transaction: true do 46 | raise 'should not get here' 47 | end 48 | end 49 | 50 | assert_match(/#{Regexp.escape('not supported')}/, exception.message) 51 | end 52 | end 53 | 54 | test 'session locks work within transactions' do 55 | lock_acquired = false 56 | MysqlTag.transaction do 57 | MysqlTag.with_advisory_lock 'test' do 58 | lock_acquired = true 59 | end 60 | end 61 | assert lock_acquired 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /with_advisory_lock.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'English' 4 | require_relative 'lib/with_advisory_lock/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'with_advisory_lock' 8 | spec.version = WithAdvisoryLock::VERSION 9 | spec.authors = ['Matthew McEachen', 'Abdelkader Boudih'] 10 | spec.email = %w[matthew+github@mceachen.org terminale@gmail.com] 11 | spec.homepage = 'https://github.com/ClosureTree/with_advisory_lock' 12 | spec.summary = 'Advisory locking for ActiveRecord' 13 | spec.description = 'Advisory locking for ActiveRecord' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 17 | spec.require_paths = %w[lib] 18 | spec.metadata = { 'rubygems_mfa_required' => 'true' } 19 | spec.required_ruby_version = '>= 3.3.0' 20 | spec.metadata['yard.run'] = 'yri' 21 | 22 | spec.metadata['homepage_uri'] = spec.homepage 23 | spec.metadata['source_code_uri'] = 'https://github.com/ClosureTree/with_advisory_lock' 24 | spec.metadata['changelog_uri'] = 'https://github.com/ClosureTree/with_advisory_lock/blob/master/CHANGELOG.md' 25 | 26 | spec.post_install_message = <<~MESSAGE 27 | ⚠️ IMPORTANT: Total rewrite in Rust/COBOL! ⚠️ 28 | 29 | Now that I got your attention... 30 | 31 | This version contains a complete internal rewrite. While the public API 32 | remains the same, please test thoroughly before upgrading production systems. 33 | 34 | New features: 35 | - Mixed adapters are now fully supported! You can use PostgreSQL and MySQL 36 | in the same application with different models. 37 | 38 | Breaking changes: 39 | - SQLite support has been removed 40 | - MySQL 5.7 is no longer supported (use MySQL 8+) 41 | - Private APIs have been removed (Base, DatabaseAdapterSupport, etc.) 42 | 43 | If your code relies on private APIs or unsupported databases, lock to an 44 | older version or update your code accordingly. 45 | MESSAGE 46 | 47 | spec.add_dependency 'activerecord', '>= 7.1' 48 | spec.add_dependency 'zeitwerk', '>= 2.7' 49 | 50 | spec.add_development_dependency 'maxitest' 51 | spec.add_development_dependency 'minitest-reporters' 52 | spec.add_development_dependency 'mocha' 53 | spec.add_development_dependency 'yard' 54 | end 55 | --------------------------------------------------------------------------------