Open-source project
ClosureTree/with_advisory_lock avatar
ClosureTree/with_advisory_lock

with_advisory_lock: database backed mutexes for ActiveRecord

Advisory locking for ActiveRecord

702 stars72 forksRubyNOASSERTION

At a glance

What is it?
with_advisory_lock turns a MySQL or PostgreSQL advisory lock into a Ruby block that spans hosts, with shared and transaction level modes, timeouts, and a PostgreSQL only blocking mode that hands deadlock detection to the database. It also drops SQLite and MySQL 5.7, and ships a custom licence rather than a standard one.
Who is it for?
Adopt with_advisory_lock if you run a multi host Ruby service on MySQL 8 or PostgreSQL and need a mutex that spans machines, especially if you also run PgBouncer in transaction pooling mode, where the session level default is unsafe. Do not adopt it if you are on SQLite, on MySQL 5.7, or if you need a permissive standard licence, because SQLite support has been removed, MySQL 5.7 support has been dropped, and the repository licence is a custom one rather than MIT or Apache.
Can I use it commercially?
Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
Is it still maintained?
Yes. The repository last received commits 79 days ago.
What is it written in?
Mainly Ruby, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A mutex that is owned by the database, not by the process

An advisory lock is a mutex that guarantees no two processes run some work at the same time. The useful property, as the project puts it, is that when the advisory lock is powered by your database server, your mutex spans hosts. That is the whole reason to reach for this rather than a Ruby Mutex, which only excludes other threads in one process.

The gem adds a WithAdvisoryLock module to all of your ActiveRecord models, so the call site sits on the model rather than on a separate service object:

ruby
User.with_advisory_lock(lock_name) do
  do_something_that_needs_locking
end

The semantics are worth reading twice because they are the kind of thing that surprises people. The thread waits indefinitely until the lock is acquired, so there is no default upper bound on how long a caller can block. Inside the block you exclusively own the lock, and the lock is released when the block ends, including when an exception is raised inside it.

That last point is the one that makes the pattern safe to use. Because release happens on the way out of the block rather than at the end of a successful path, a raised exception does not strand the lock, and the next caller is not blocked forever by work that crashed.

The requirements are narrow on purpose. ActiveRecord 7.2 or newer, Ruby 3.3 or newer, with jruby and truffleruby also named as supported runtimes, on MySQL or PostgreSQL.

The default wait is forever, and timeout_seconds changes that

The second parameter of with_advisory_lock is an options hash, and the option that matters first is timeout_seconds, which defaults to nil, meaning wait indefinitely for the lock.

A value of zero changes the behaviour to try once. If the lock is acquired the block is yielded to, and if the lock is currently held the block is not called at all. That is the non blocking pattern you want for a job that can be skipped rather than queued, and it is also what a timeout of zero degrades to in the newer blocking mode.

For any non nil timeout, the block is not invoked if the lock cannot be acquired in time, and the two method variants differ in how they report that. with_advisory_lock returns false, while with_advisory_lock! raises a WithAdvisoryLock::FailedToAcquireLock error. The bang version is the right choice in code that cannot meaningfully continue without the lock, because a false return that gets ignored is a bug that surfaces much later.

For backwards compatibility, the timeout value can also be passed directly as the second parameter rather than inside the hash, which matters if you are upgrading an older call site and want to change it in two steps rather than one.

The practical advice is to always pass a timeout. The indefinite default is convenient in a script and dangerous in a web request, where a blocked thread is a blocked worker.

False as a return value forces a separate result object

There is a small design trap in the return value, and the documentation walks through it carefully.

The return value of with_advisory_lock is the result of the yielded block if the lock was acquired and the block ran, or false if you supplied a timeout_seconds value and the lock was not acquired in time. That means a block that legitimately returns false is indistinguishable from a failed acquisition. The library resolves this with a second method, with_advisory_lock_result, which returns a WithAdvisoryLock::Result instance instead. That object has a lock_was_acquired? method and a result accessor method, the latter giving you the value of the block.

So there are three ways to call the lock and three corresponding behaviours: with_advisory_lock for the common case, with_advisory_lock_result when your block may validly return false and you need to tell that apart from a timeout, and with_advisory_lock! when a failed acquisition should raise.

The same care applies to the introspection helpers. You can ask whether a lock is currently held with Tag.advisory_lock_exists?("foo"), but the documentation warns that the lock can be acquired between the moment you test and the moment you try to acquire it, so that call is a snapshot, not a reservation. If you want to know whether the current thread holds a lock, Tag.current_advisory_lock returns the name of it, or nil when nothing is held.

Blocking mode hands deadlock detection to PostgreSQL

This is the most interesting engineering decision in the gem. By default, PostgreSQL advisory locks are acquired with a polling strategy that lives in Ruby: the code retries and sleeps until the lock appears. That works, but it means circular lock waits between two actors are only ever broken by one of them giving up, and the polling loop keeps the Ruby process busy.

Setting blocking: true switches to database level blocking locks, which enable PostgreSQL's own deadlock detection:

ruby
User.with_advisory_lock("lock_name", blocking: true, transaction: true) do
  # PostgreSQL will detect circular lock waits and raise an error
  # instead of sleeping forever
end

The documented benefits are deadlock detection, where PostgreSQL raises PG::TRDeadlockDetected after roughly a second and the delay is configurable through deadlock_timeout, no polling overhead because the database handles the wait queue rather than Ruby sleep and retry loops, and a clean failure that returns false on deadlock instead of retrying forever.

Combining it with a timeout is where the two options meet. blocking: true with timeout_seconds bounds the database level wait using a transaction scoped SET LOCAL lock_timeout, so the setting never leaks to the session, and a timeout inside an enclosing transaction rolls back to a savepoint instead of aborting the whole transaction:

ruby
User.with_advisory_lock("lock_name", blocking: true, timeout_seconds: 5) do
  # waits up to 5 seconds at the database level, no Ruby polling
end

The limitation is explicit: MySQL ignores this option, because GET_LOCK already provides native timeout and deadlock detection through the MDL subsystem. The feature is PostgreSQL only, and the gem is honest about the portability cost.

PgBouncer in transaction pooling mode breaks the default

The most likely way to get into trouble with this gem is not a bug in the gem but a mismatch between the lock scope and your connection pooler.

Session level advisory locks bind to the PostgreSQL backend connection. Under PgBouncer in transaction pooling mode, statements outside a transaction may run on different backends, which strands or leaks the locks. The session that took the lock and the session that later released it may not be the same server process, and the lock outlives its usefulness.

The documented fix is to use transaction: true locks under transaction pooling, since those stay pinned to one backend for the duration of the transaction. Session pooling and direct connections are unaffected.

This connects to the other PostgreSQL only option, transaction level locks, which remain held until the transaction completes and which you enable by setting the transaction option to true. One consequence is that transaction level locks are not reflected by .current_advisory_lock once the block has returned, so a status check that runs after the block will not see a lock that the database still holds until commit.

For MySQL, shared locks are the other asymmetry. The shared option defaults to false, meaning an exclusive lock is obtained, and setting shared to true allows multiple actors to hold the lock as long as all of them are shared. MySQL does not support shared locks, so that option is meaningless there.

SQLite and MySQL 5.7 are gone, and the licence is not standard

Two removals are stated up front, and they narrow who can use this at all. SQLite support has been removed, with the suggestion that single node SQLite deployments should use a Ruby mutex instead, which is the right advice since a SQLite file is not shared across hosts anyway. Support for MySQL 5.7 has also been dropped, with a request to use MySQL 8 or PostgreSQL.

If your fleet is still on MySQL 5.7, this gem is not an option and the choice is between a Redis based lock, a row level SELECT FOR UPDATE, and the database's own lock table.

The licence deserves attention too, because it is not one of the standard permissive licences. The repository carries LICENSE.txt rather than a LICENSE, and the project is described as using a custom licence that GitHub cannot classify, so the terms have to be read directly rather than assumed. That is a real difference from most gems in this space, and it should be settled before the gem reaches a legal review rather than after.

Installation itself is minimal, one line in the Gemfile and a bundle:

ruby
gem 'with_advisory_lock'

The release history shows active work, with with_advisory_lock/v7.6.0 published on 2026-08-05, v7.5.0 on 2026-01-21 and v7.0.2 on 2025-09-22, and the last push was on 2026-08-05.

The query cache can hand you stale rows inside a lock

One option exists for a specific problem: disable_query_cache: true in the options hash of with_advisory_lock. It turns off ActiveRecord's query cache for the duration of the block.

The reason is a correctness issue rather than a performance one. If you query the database from inside the lock and get a stale result, the lock has not done its job, because the whole point of taking a lock is that nobody else changed the data you are about to read. The project links to a specific issue in its tracker where the interaction is explained in more detail.

This is the kind of detail that is easy to miss and hard to debug, since the symptom appears as a data race rather than as a locking failure. The mitigation is one extra key in an options hash.

There is a further note about how locks should be chosen, and it is worth taking seriously. The documentation starts the lock types section by pointing out that there are lots of different kinds of locks available and advising you to pick the finest grain lock that works. A global lock around a whole job is simple and correct, but it serialises work that could have run in parallel, and in a multi host deployment it turns a throughput problem into a queueing problem.

Editorial conclusion

Adopt with_advisory_lock if you run a multi host Ruby service on MySQL 8 or PostgreSQL and need a mutex that spans machines, especially if you also run PgBouncer in transaction pooling mode, where the session level default is unsafe. Do not adopt it if you are on SQLite, on MySQL 5.7, or if you need a permissive standard licence, because SQLite support has been removed, MySQL 5.7 support has been dropped, and the repository licence is a custom one rather than MIT or Apache. Before you commit, read LICENSE.txt yourself, set a timeout_seconds value rather than waiting indefinitely, and confirm your connection pooler mode against the transaction level option.

Frequently asked questions

How do I take an advisory lock in with_advisory_lock?

Call with_advisory_lock on your ActiveRecord model with a lock name and a block, for example User.with_advisory_lock(lock_name) do ... end. The thread waits until the lock is acquired and the lock is released when the block ends, even if an exception is raised inside it.

What happens when the lock cannot be acquired in with_advisory_lock?

With a non nil timeout_seconds the block is not invoked, with_advisory_lock returns false and with_advisory_lock! raises a WithAdvisoryLock::FailedToAcquireLock error. A timeout_seconds of zero tries the lock only once. The default timeout is nil, which waits indefinitely.

Does with_advisory_lock work with SQLite or MySQL 5.7?

No. SQLite support has been removed, and the project suggests a Ruby mutex for single node SQLite deployments. Support for MySQL 5.7 has also been dropped, so you need MySQL 8 or PostgreSQL.

Is with_advisory_lock compatible with PgBouncer?

Only with care. Session level advisory locks bind to the PostgreSQL backend connection, so under PgBouncer transaction pooling mode you should use transaction: true locks, which stay pinned to one backend for the transaction. Session pooling and direct connections are unaffected.

What licence is with_advisory_lock released under?

The repository carries a custom licence in LICENSE.txt rather than one of the standard permissive licences, and GitHub cannot classify it, so the terms have to be read in that file directly.

Official sources

  1. ClosureTree/with_advisory_lock on GitHub
  2. Issues
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/closuretree-with-advisory-lock.svg)](https://hysenlabs.com/projects/closuretree-with-advisory-lock)

Was this useful?