with_advisory_lock 7.0.2 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a82d92e5b44f2cf65a9af5ac2d00baacab6d47b3b47be9fc2db82bd5677e4c7
4
- data.tar.gz: 4a9b77cd24fad228812b9eff2f65079e6be7b95130a8e8d628d71525045ef902
3
+ metadata.gz: b73961f538d06e085eda202c9acac95b86e248dfc368cefa5267947ed8f988a2
4
+ data.tar.gz: c6b025af1bc1f16ea12b104da20fcfbd6586347eb23b72de47eafd09b8c4e9f0
5
5
  SHA512:
6
- metadata.gz: 77996aa445351903666828d916b8bd02ad4357496c2240ac847f937f7b3adff7af83993b10d6af8bcdb33825992dc6391cc211f97428c36b2bd102e65e5c26b2
7
- data.tar.gz: 94ec70a26f7c336f9dba44a384bc16d9d680d7495e43cb0c9fc22fee2380317f5d5263f36d3998837ca21f3e05424c7fc0fdb37ed75daf441ce534f0b4d67339
6
+ metadata.gz: b11f600e41ed75fe00f7314d2cc090547e941ef4a9562c98e4573e892a15d3429b6609a1475f170a20f2d5197aba6dac153596e58181d6092db0d05c0adcc4fc
7
+ data.tar.gz: 24e45f293e0d2d653cc58e772b11f70d8d9691aac66a4d4be64cc81d67f322e6e00520e83d23613c0a176b74ca7137d60c5de8036b837a1b67b39eab3cad221e
@@ -36,16 +36,38 @@ jobs:
36
36
  MYSQL_PASSWORD: with_advisory_pass
37
37
  MYSQL_DATABASE: with_advisory_lock_test
38
38
  MYSQL_ROOT_HOST: '%'
39
+ mariadb:
40
+ image: mariadb:12
41
+ ports:
42
+ - 3306
43
+ env:
44
+ MARIADB_ROOT_PASSWORD: root
45
+ MARIADB_DATABASE: with_advisory_lock_trilogy_test
46
+ MARIADB_USER: with_advisory
47
+ MARIADB_PASSWORD: with_advisory_pass
48
+ MARIADB_ROOT_HOST: '%'
49
+ options: >-
50
+ --health-cmd "healthcheck.sh --su-mysql --connect --innodb_initialized"
51
+ --health-interval 10s
52
+ --health-timeout 5s
53
+ --health-retries 5
39
54
  strategy:
40
55
  fail-fast: false
41
56
  matrix:
42
57
  ruby:
43
58
  - '3.3'
44
59
  - '3.4'
60
+ - '4.0'
45
61
  - 'truffleruby'
46
62
  rails:
47
63
  - 7.2
48
64
  - "8.0"
65
+ - "8.1"
66
+ - "edge"
67
+ exclude:
68
+ # TruffleRuby doesn't support Rails edge yet
69
+ - ruby: 'truffleruby'
70
+ rails: "edge"
49
71
  env:
50
72
  ACTIVERECORD_VERSION: ${{ matrix.rails }}
51
73
  RAILS_ENV: test
@@ -60,10 +82,14 @@ jobs:
60
82
  bundler-cache: true
61
83
  rubygems: latest
62
84
 
85
+
63
86
  - name: Setup test databases
87
+ timeout-minutes: 5
64
88
  env:
65
89
  DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test
66
90
  DATABASE_URL_MYSQL: mysql2://with_advisory:with_advisory_pass@127.0.0.1:${{ job.services.mysql.ports[3306] }}/with_advisory_lock_test
91
+ # Trilogy doesn't support TruffleRuby
92
+ DATABASE_URL_TRILOGY: ${{ matrix.ruby != 'truffleruby' && format('trilogy://with_advisory:with_advisory_pass@127.0.0.1:{0}/with_advisory_lock_trilogy_test', job.services.mariadb.ports[3306]) || '' }}
67
93
  run: |
68
94
  cd test/dummy
69
95
  bundle exec rake db:test:prepare
@@ -72,5 +98,7 @@ jobs:
72
98
  env:
73
99
  DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test
74
100
  DATABASE_URL_MYSQL: mysql2://with_advisory:with_advisory_pass@127.0.0.1:${{ job.services.mysql.ports[3306] }}/with_advisory_lock_test
101
+ # Trilogy doesn't support TruffleRuby
102
+ DATABASE_URL_TRILOGY: ${{ matrix.ruby != 'truffleruby' && format('trilogy://with_advisory:with_advisory_pass@127.0.0.1:{0}/with_advisory_lock_trilogy_test', job.services.mariadb.ports[3306]) || '' }}
75
103
  WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }}
76
104
  run: bin/rails test
@@ -1 +1 @@
1
- {".":"7.0.2"}
1
+ {".":"7.6.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  ## Changelog
2
2
 
3
+ ## [7.6.0](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v7.5.0...with_advisory_lock/v7.6.0) (2026-08-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * honor timeout_seconds with blocking: true on PostgreSQL ([88abf06](https://github.com/ClosureTree/with_advisory_lock/commit/88abf0689b2ac7045c1617fe5fcac0bedff61adf)), closes [#147](https://github.com/ClosureTree/with_advisory_lock/issues/147)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * Remove ruby-version files ([#145](https://github.com/ClosureTree/with_advisory_lock/issues/145)) ([681fd66](https://github.com/ClosureTree/with_advisory_lock/commit/681fd66f1d45800897f2d77ac8905474910a90ff))
14
+ * scope advisory lock stack per connection ([8c39439](https://github.com/ClosureTree/with_advisory_lock/commit/8c394392872f0d9d6ab6b83d1185eeb291de2409))
15
+
16
+ ## [7.5.0](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v7.0.2...with_advisory_lock/v7.5.0) (2026-01-21)
17
+
18
+
19
+ ### Features
20
+
21
+ * add blocking advisory locks with deadlock detection for PostgreSQL ([#140](https://github.com/ClosureTree/with_advisory_lock/issues/140)) ([f7f9aff](https://github.com/ClosureTree/with_advisory_lock/commit/f7f9aff545381107a632a25511e8fc08654a28b6))
22
+ * Add Trilogy adapter support with MariaDB 12.0+ ([#134](https://github.com/ClosureTree/with_advisory_lock/issues/134)) ([b7764cd](https://github.com/ClosureTree/with_advisory_lock/commit/b7764cd9432b25b37c6da9160f980da29a5cdaa6))
23
+ * bump version for new features ([9a8c4be](https://github.com/ClosureTree/with_advisory_lock/commit/9a8c4be5cf51147e60df7e5733360e3dfd8d009e))
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * Use monotonic clock so postgres timeouts are unaffected by system clock changes ([#141](https://github.com/ClosureTree/with_advisory_lock/issues/141)) ([929e010](https://github.com/ClosureTree/with_advisory_lock/commit/929e0103e5ffc4c66f9088942441f764de1bc650))
29
+
3
30
  ## [7.0.2](https://github.com/ClosureTree/with_advisory_lock/compare/with_advisory_lock/v7.0.1...with_advisory_lock/v7.0.2) (2025-09-20)
4
31
 
5
32
 
@@ -58,7 +85,7 @@
58
85
 
59
86
  ### Features
60
87
 
61
- * 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))
88
+ * use current connection 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))
62
89
 
63
90
 
64
91
  ### Bug Fixes
data/Gemfile CHANGED
@@ -11,12 +11,17 @@ gem 'benchmark'
11
11
  gem 'logger'
12
12
  gem 'ostruct'
13
13
 
14
- activerecord_version = ENV.fetch('ACTIVERECORD_VERSION', '7.2')
15
-
16
- gem 'activerecord', "~> #{activerecord_version}.0"
14
+ activerecord_version = ENV.fetch('ACTIVERECORD_VERSION', '8.1')
15
+
16
+ if activerecord_version == 'edge'
17
+ gem 'activerecord', github: 'rails/rails', branch: 'main'
18
+ gem 'railties', github: 'rails/rails', branch: 'main'
19
+ else
20
+ gem 'activerecord', "~> #{activerecord_version}.0"
21
+ gem 'railties'
22
+ end
17
23
 
18
24
  gem 'dotenv'
19
- gem 'railties'
20
25
 
21
26
  platforms :ruby do
22
27
  gem 'mysql2'
data/README.md CHANGED
@@ -48,11 +48,11 @@ will be yielded to. If the lock is currently being held, the block will not be
48
48
  called.
49
49
 
50
50
  > **Note**
51
- >
51
+ >
52
52
  > If a non-nil value is provided for `timeout_seconds`, the block will
53
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
54
 
55
- For backwards compatability, the timeout value can be specified directly as the
55
+ For backwards compatibility, the timeout value can be specified directly as the
56
56
  second parameter.
57
57
 
58
58
  ### Shared locks
@@ -72,6 +72,54 @@ to `true`.
72
72
  Note: transaction-level locks will not be reflected by `.current_advisory_lock`
73
73
  when the block has returned.
74
74
 
75
+ ### Blocking locks (PostgreSQL only)
76
+
77
+ By default, PostgreSQL advisory locks use a polling strategy with Ruby-level
78
+ retries and sleeps. Setting `blocking: true` switches to database-level blocking
79
+ locks that enable PostgreSQL's deadlock detection:
80
+
81
+ ```ruby
82
+ User.with_advisory_lock("lock_name", blocking: true, transaction: true) do
83
+ # PostgreSQL will detect circular lock waits and raise an error
84
+ # instead of sleeping forever
85
+ end
86
+ ```
87
+
88
+ **Benefits:**
89
+ - **Deadlock detection**: PostgreSQL detects circular waits and raises `PG::TRDeadlockDetected` after ~1 second (configurable via `deadlock_timeout`)
90
+ - **No polling overhead**: The database handles the wait queue instead of Ruby sleep/retry loops
91
+ - **Clean failure**: Returns `false` on deadlock instead of infinite retries
92
+
93
+ **When to use:**
94
+ - When acquiring multiple locks in your application (risk of deadlock)
95
+ - When you need PostgreSQL to detect and break circular lock dependencies
96
+ - When you want to avoid Ruby-level polling overhead
97
+
98
+ **Combining with a timeout:** `blocking: true` together with `timeout_seconds`
99
+ bounds the database-level wait using a transaction-scoped
100
+ `SET LOCAL lock_timeout`, returning `false` if the lock cannot be acquired in
101
+ time. The setting never leaks to the session, and a timeout inside an enclosing
102
+ transaction rolls back to a savepoint instead of aborting your transaction.
103
+ A `timeout_seconds` of `0` degrades to the non-blocking try path.
104
+
105
+ ```ruby
106
+ User.with_advisory_lock("lock_name", blocking: true, timeout_seconds: 5) do
107
+ # waits up to 5 seconds at the database level, no Ruby polling
108
+ end
109
+ ```
110
+
111
+ **Note:** MySQL ignores this option since `GET_LOCK` already provides native
112
+ timeout and deadlock detection via the MDL subsystem.
113
+
114
+ ### Connection poolers (PgBouncer)
115
+
116
+ Session-level advisory locks bind to the PostgreSQL backend connection, so they
117
+ are incompatible with PgBouncer in *transaction pooling* mode: statements
118
+ outside a transaction may run on different backends, stranding or leaking
119
+ locks. Under transaction pooling, use `transaction: true` locks, which stay
120
+ pinned to one backend for the duration of the transaction. Session pooling and
121
+ direct connections are unaffected.
122
+
75
123
  ### Return values
76
124
 
77
125
  The return value of `with_advisory_lock_result` is a `WithAdvisoryLock::Result`
@@ -84,7 +132,7 @@ block, if the lock was able to be acquired and the block yielded, or `false`, if
84
132
  you provided a timeout_seconds value and the lock was not able to be acquired in
85
133
  time.
86
134
 
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.
135
+ `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
136
 
89
137
  ### Testing for the current lock status
90
138
 
@@ -147,6 +195,16 @@ concurrent access to **any instance of a model**. Their coarseness means they
147
195
  aren't going to be commonly applicable, and they can be a source of
148
196
  [deadlocks](http://en.wikipedia.org/wiki/Deadlock).
149
197
 
198
+ ## Running Tests
199
+
200
+ To setup the project and run the whole test suite:
201
+
202
+ 1. Have Docker running
203
+ 2. `echo -e "DB_USER=with_advisory\nDB_PASSWORD=with_advisory_pass\nDB_NAME=with_advisory_lock_test\nDATABASE_URL_PG=postgres://\$DB_USER:\$DB_PASSWORD@localhost:5433/\$DB_NAME\nDATABASE_URL_MYSQL=mysql2://\$DB_USER:\$DB_PASSWORD@127.0.0.1:3366/\$DB_NAME" > .env`
204
+ 3. `make`
205
+
206
+ Alternatively to `make`, run `bin/rails test` to skip database and dependency setup.
207
+
150
208
  ## FAQ
151
209
 
152
210
  ### Transactions and Advisory Locks
@@ -158,4 +216,3 @@ You will want to wrap your block within a transaction to ensure consistency.
158
216
  ### Is clustered MySQL supported?
159
217
 
160
218
  [No.](https://github.com/ClosureTree/with_advisory_lock/issues/16)
161
-
data/bin/setup_test_db CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  require 'bundler/setup'
5
5
  require 'active_record'
6
+ require 'dotenv'
7
+ Dotenv.load
6
8
 
7
9
  # Setup PostgreSQL database
8
10
  puts 'Setting up PostgreSQL test database...'
@@ -10,9 +12,9 @@ ActiveRecord::Base.establish_connection(
10
12
  adapter: 'postgresql',
11
13
  host: 'localhost',
12
14
  port: 5433,
13
- database: 'with_advisory_lock_test',
14
- username: 'with_advisory',
15
- password: 'with_advisory_pass'
15
+ database: ENV['DB_NAME'],
16
+ username: ENV['DB_USER'],
17
+ password: ENV['DB_PASSWORD']
16
18
  )
17
19
 
18
20
  ActiveRecord::Schema.define(version: 1) do
@@ -36,9 +38,9 @@ ActiveRecord::Base.establish_connection(
36
38
  adapter: 'mysql2',
37
39
  host: '127.0.0.1',
38
40
  port: 3366,
39
- database: 'with_advisory_lock_test',
40
- username: 'with_advisory',
41
- password: 'with_advisory_pass'
41
+ database: ENV['DB_NAME'],
42
+ username: ENV['DB_USER'],
43
+ password: ENV['DB_PASSWORD']
42
44
  )
43
45
 
44
46
  ActiveRecord::Schema.define(version: 1) do
data/docker-compose.yml CHANGED
@@ -17,3 +17,13 @@ services:
17
17
  MYSQL_ROOT_HOST: '%'
18
18
  ports:
19
19
  - "3366:3306"
20
+ mariadb:
21
+ image: mariadb:12
22
+ environment:
23
+ MARIADB_USER: with_advisory
24
+ MARIADB_PASSWORD: with_advisory_pass
25
+ MARIADB_DATABASE: with_advisory_lock_trilogy_test
26
+ MARIADB_RANDOM_ROOT_PASSWORD: "yes"
27
+ MARIADB_ROOT_HOST: '%'
28
+ ports:
29
+ - "3368:3306"
@@ -24,22 +24,22 @@ module WithAdvisoryLock
24
24
  end
25
25
  end
26
26
 
27
- def advisory_lock_exists?(lock_name)
27
+ def advisory_lock_exists?(lock_name, shared: false)
28
28
  with_connection do |conn|
29
29
  lock_str = "#{ENV.fetch(CoreAdvisory::LOCK_PREFIX_ENV, nil)}#{lock_name}"
30
- lock_stack_item = LockStackItem.new(lock_str, false)
30
+ lock_stack_item = LockStackItem.new(lock_str, shared)
31
31
 
32
32
  if conn.advisory_lock_stack.include?(lock_stack_item)
33
33
  true
34
34
  else
35
- # For PostgreSQL, try non-blocking query first to avoid race conditions
35
+ # Prefer the adapter's non-blocking existence check
36
36
  if conn.respond_to?(:advisory_lock_exists_for?)
37
- query_result = conn.advisory_lock_exists_for?(lock_name)
37
+ query_result = conn.advisory_lock_exists_for?(lock_name, shared: shared)
38
38
  return query_result unless query_result.nil?
39
39
  end
40
40
 
41
41
  # Fall back to the original implementation
42
- result = conn.with_advisory_lock_if_needed(lock_name, { timeout_seconds: 0 })
42
+ result = conn.with_advisory_lock_if_needed(lock_name, { timeout_seconds: 0, shared: shared })
43
43
  !result.lock_was_acquired?
44
44
  end
45
45
  end
@@ -8,14 +8,14 @@ module WithAdvisoryLock
8
8
 
9
9
  LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'
10
10
 
11
- # Thread-local lock stack management
11
+ # Per-connection lock stack management
12
12
  def advisory_lock_stack
13
- Thread.current[:with_advisory_lock_stack] ||= []
13
+ @advisory_lock_stack ||= []
14
14
  end
15
15
 
16
16
  def with_advisory_lock_if_needed(lock_name, options = {}, &block)
17
17
  options = { timeout_seconds: options } unless options.respond_to?(:fetch)
18
- options.assert_valid_keys :timeout_seconds, :shared, :transaction, :disable_query_cache
18
+ options.assert_valid_keys :timeout_seconds, :shared, :transaction, :disable_query_cache, :blocking
19
19
 
20
20
  # Validate transaction-level locks are used within a transaction
21
21
  if options.fetch(:transaction, false) && !transaction_open?
@@ -56,12 +56,16 @@ module WithAdvisoryLock
56
56
  timeout_seconds = options.fetch(:timeout_seconds, nil)
57
57
  shared = options.fetch(:shared, false)
58
58
  transaction = options.fetch(:transaction, false)
59
+ blocking = options.fetch(:blocking, false)
60
+ # An explicit zero timeout means try-once, so blocking degrades to the try path
61
+ blocking = false if timeout_seconds&.zero?
59
62
 
60
63
  lock_keys = lock_keys_for(lock_name)
61
64
 
62
65
  # MySQL supports database-level timeout in GET_LOCK, skip Ruby-level polling
63
- if supports_database_timeout? || timeout_seconds&.zero?
64
- yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, timeout_seconds, &)
66
+ # PostgreSQL blocking locks also skip polling and let the database handle waiting
67
+ if supports_database_timeout? || timeout_seconds&.zero? || blocking
68
+ yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, timeout_seconds, blocking, &)
65
69
  else
66
70
  yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction,
67
71
  timeout_seconds, &)
@@ -70,9 +74,9 @@ module WithAdvisoryLock
70
74
 
71
75
  def yield_with_lock_and_timeout(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction,
72
76
  timeout_seconds, &)
73
- give_up_at = timeout_seconds ? Time.now + timeout_seconds : nil
74
- while give_up_at.nil? || Time.now < give_up_at
75
- r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 0, &)
77
+ give_up_at = timeout_seconds ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds : nil
78
+ while give_up_at.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) < give_up_at
79
+ r = yield_with_lock(lock_keys, lock_name, lock_str, lock_stack_item, shared, transaction, 0, false, &)
76
80
  return r if r.lock_was_acquired?
77
81
 
78
82
  # Randomizing sleep time may help reduce contention.
@@ -81,9 +85,9 @@ module WithAdvisoryLock
81
85
  Result.new(lock_was_acquired: false)
82
86
  end
83
87
 
84
- def yield_with_lock(lock_keys, lock_name, _lock_str, lock_stack_item, shared, transaction, timeout_seconds = nil)
88
+ def yield_with_lock(lock_keys, lock_name, _lock_str, lock_stack_item, shared, transaction, timeout_seconds = nil, blocking = false)
85
89
  if try_advisory_lock(lock_keys, lock_name: lock_name, shared: shared, transaction: transaction,
86
- timeout_seconds: timeout_seconds)
90
+ timeout_seconds: timeout_seconds, blocking: blocking)
87
91
  begin
88
92
  advisory_lock_stack.push(lock_stack_item)
89
93
  result = block_given? ? yield : nil
@@ -6,18 +6,23 @@ module WithAdvisoryLock
6
6
  module MySQLAdvisory
7
7
  extend ActiveSupport::Concern
8
8
 
9
- LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'
10
-
11
- def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil)
9
+ def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil, blocking: false)
12
10
  raise ArgumentError, 'shared locks are not supported on MySQL' if shared
13
11
  raise ArgumentError, 'transaction level locks are not supported on MySQL' if transaction
14
12
 
15
- # MySQL GET_LOCK supports native timeout:
16
- # - timeout_seconds = nil: wait indefinitely (-1)
13
+ # Note: blocking parameter is accepted for API compatibility but ignored for MySQL
14
+ # MySQL's GET_LOCK already provides native timeout support, making the blocking
15
+ # parameter redundant. MySQL doesn't have separate try/blocking functions like PostgreSQL.
16
+
17
+ # MySQL/MariaDB GET_LOCK supports native timeout:
18
+ # - timeout_seconds = nil: wait indefinitely
17
19
  # - timeout_seconds = 0: try once, no wait (0)
18
20
  # - timeout_seconds > 0: wait up to timeout_seconds
21
+ #
22
+ # Note: MySQL accepts -1 for infinite wait, but MariaDB does not.
23
+ # Using a large value (1 year) for cross-compatibility.
19
24
  mysql_timeout = case timeout_seconds
20
- when nil then -1
25
+ when nil then 31_536_000 # 1 year in seconds
21
26
  when 0 then 0
22
27
  else timeout_seconds.to_i
23
28
  end
@@ -39,22 +44,11 @@ module WithAdvisoryLock
39
44
  rescue ActiveRecord::StatementInvalid => e
40
45
  # If the connection is broken, the lock is automatically released by MySQL
41
46
  # No need to fail the release operation
42
- connection_lost = case e.cause
43
- when defined?(Mysql2::Error::ConnectionError) && Mysql2::Error::ConnectionError
44
- true
45
- when defined?(Trilogy::ConnectionError) && Trilogy::ConnectionError
46
- true
47
- else
48
- e.message =~ /Lost connection|MySQL server has gone away|Connection refused/i
49
- end
50
-
51
- return if connection_lost
52
-
53
- raise
47
+ raise unless connection_lost_error?(e)
54
48
  end
55
49
 
56
50
  def lock_keys_for(lock_name)
57
- lock_str = "#{ENV.fetch(LOCK_PREFIX_ENV, nil)}#{lock_name}"
51
+ lock_str = "#{ENV.fetch(CoreAdvisory::LOCK_PREFIX_ENV, nil)}#{lock_name}"
58
52
  [lock_str]
59
53
  end
60
54
 
@@ -62,8 +56,26 @@ module WithAdvisoryLock
62
56
  true
63
57
  end
64
58
 
59
+ # Non-blocking check via IS_USED_LOCK, no lock is acquired as a side effect
60
+ def advisory_lock_exists_for?(lock_name, shared: false)
61
+ raise ArgumentError, 'shared locks are not supported on MySQL' if shared
62
+
63
+ lock_keys = lock_keys_for(lock_name)
64
+ !query_value("SELECT IS_USED_LOCK(#{quote(lock_keys.first)})").nil?
65
+ rescue ActiveRecord::StatementInvalid
66
+ nil
67
+ end
68
+
65
69
  private
66
70
 
71
+ def connection_lost_error?(error)
72
+ cause = error.cause
73
+ return true if defined?(Mysql2::Error::ConnectionError) && cause.is_a?(Mysql2::Error::ConnectionError)
74
+ return true if defined?(Trilogy::ConnectionError) && cause.is_a?(Trilogy::ConnectionError)
75
+
76
+ error.message.match?(/Lost connection|MySQL server has gone away|Connection refused/i)
77
+ end
78
+
67
79
  def execute_successful?(mysql_function)
68
80
  query_value("SELECT #{mysql_function}") == 1
69
81
  end
@@ -6,49 +6,72 @@ module WithAdvisoryLock
6
6
  module PostgreSQLAdvisory
7
7
  extend ActiveSupport::Concern
8
8
 
9
- LOCK_PREFIX_ENV = 'WITH_ADVISORY_LOCK_PREFIX'
10
9
  LOCK_RESULT_VALUES = ['t', true].freeze
11
10
  ERROR_MESSAGE_REGEX = / ERROR: +current transaction is aborted,/
12
11
 
13
- def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil)
14
- # timeout_seconds is accepted for compatibility but ignored - PostgreSQL doesn't support
15
- # native timeouts with pg_try_advisory_lock, requiring Ruby-level polling instead
16
- function = advisory_try_lock_function(transaction, shared)
17
- execute_advisory(function, lock_keys, lock_name)
18
- end
12
+ def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seconds: nil, blocking: false)
13
+ function = if blocking
14
+ advisory_lock_function(transaction, shared)
15
+ else
16
+ advisory_try_lock_function(transaction, shared)
17
+ end
19
18
 
20
- def release_advisory_lock(*args)
21
- # Handle both signatures - ActiveRecord's built-in and ours
22
- if args.length == 1 && args[0].is_a?(Integer)
23
- # ActiveRecord's built-in signature: release_advisory_lock(lock_id)
24
- super
19
+ if blocking && timeout_seconds&.positive?
20
+ blocking_lock_with_timeout(function, lock_keys, lock_name, timeout_seconds)
25
21
  else
26
- # Our signature: release_advisory_lock(lock_keys, lock_name:, shared:, transaction:)
27
- lock_keys, options = args
28
- return if options[:transaction]
29
-
30
- function = advisory_unlock_function(options[:shared])
31
- execute_advisory(function, lock_keys, options[:lock_name])
22
+ execute_advisory(function, lock_keys, lock_name, blocking: blocking)
32
23
  end
24
+ rescue ActiveRecord::Deadlocked
25
+ # Rails 8.2+ raises ActiveRecord::Deadlocked directly for PostgreSQL deadlocks
26
+ # When using blocking locks, treat deadlocks as lock acquisition failure
27
+ return false if blocking
28
+
29
+ raise
33
30
  rescue ActiveRecord::StatementInvalid => e
34
- # If the connection is broken, the lock is automatically released by PostgreSQL
35
- # No need to fail the release operation
36
- return if e.cause.is_a?(PG::ConnectionBad) || e.message =~ /PG::ConnectionBad/
31
+ # PostgreSQL deadlock detection raises PG::TRDeadlockDetected (SQLSTATE 40P01)
32
+ # When using blocking locks, treat deadlocks as lock acquisition failure.
33
+ # Rails 8.2+ may also retry after deadlock and get "current transaction is aborted"
34
+ # when the transaction was rolled back by PostgreSQL's deadlock detection.
35
+ if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) ||
36
+ e.message.include?('deadlock detected') ||
37
+ e.message =~ ERROR_MESSAGE_REGEX)
38
+ false
39
+ else
40
+ raise
41
+ end
42
+ end
43
+
44
+ def release_advisory_lock(*args)
45
+ # ActiveRecord's built-in signature: release_advisory_lock(lock_id)
46
+ return super if args.length == 1 && args[0].is_a?(Integer)
37
47
 
38
- raise unless e.message =~ ERROR_MESSAGE_REGEX
48
+ # Our signature: release_advisory_lock(lock_keys, lock_name:, shared:, transaction:)
49
+ lock_keys, options = args
50
+ return if options[:transaction]
39
51
 
52
+ function = advisory_unlock_function(options[:shared])
40
53
  begin
41
- rollback_db_transaction
42
54
  execute_advisory(function, lock_keys, options[:lock_name])
43
- ensure
44
- begin_db_transaction
55
+ rescue ActiveRecord::StatementInvalid => e
56
+ # If the connection is broken, the lock is automatically released by PostgreSQL
57
+ # No need to fail the release operation
58
+ return if e.cause.is_a?(PG::ConnectionBad) || e.message =~ /PG::ConnectionBad/
59
+
60
+ raise unless e.message =~ ERROR_MESSAGE_REGEX
61
+
62
+ begin
63
+ rollback_db_transaction
64
+ execute_advisory(function, lock_keys, options[:lock_name])
65
+ ensure
66
+ begin_db_transaction
67
+ end
45
68
  end
46
69
  end
47
70
 
48
71
  def lock_keys_for(lock_name)
49
72
  [
50
73
  stable_hashcode(lock_name),
51
- ENV.fetch(LOCK_PREFIX_ENV, nil)
74
+ ENV.fetch(CoreAdvisory::LOCK_PREFIX_ENV, nil)
52
75
  ].map { |ea| ea.to_i & 0x7fffffff }
53
76
  end
54
77
 
@@ -68,6 +91,7 @@ module WithAdvisoryLock
68
91
  AND classid = #{lock_keys.first}
69
92
  AND objid = #{lock_keys.last}
70
93
  AND mode = '#{shared ? 'ShareLock' : 'ExclusiveLock'}'
94
+ AND granted
71
95
  LIMIT 1
72
96
  SQL
73
97
 
@@ -79,6 +103,23 @@ module WithAdvisoryLock
79
103
 
80
104
  private
81
105
 
106
+ # Bounded blocking acquisition via SET LOCAL lock_timeout. The setting is
107
+ # transaction-scoped so it never leaks to the session (pooler-safe), and
108
+ # the savepoint keeps a timeout from aborting an enclosing transaction.
109
+ # Session-level locks acquired here survive the wrapping COMMIT.
110
+ def blocking_lock_with_timeout(function, lock_keys, lock_name, timeout_seconds)
111
+ transaction(requires_new: true) do
112
+ previous_timeout = query_value('SHOW lock_timeout')
113
+ execute("SET LOCAL lock_timeout = '#{(timeout_seconds * 1000).to_i}ms'")
114
+ execute_advisory(function, lock_keys, lock_name, blocking: true)
115
+ # SET LOCAL survives a released savepoint, so restore the outer value
116
+ execute("SET LOCAL lock_timeout = #{quote(previous_timeout)}")
117
+ true
118
+ end
119
+ rescue ActiveRecord::LockWaitTimeout
120
+ false
121
+ end
122
+
82
123
  def advisory_try_lock_function(transaction_scope, shared)
83
124
  [
84
125
  'pg_try_advisory',
@@ -88,6 +129,15 @@ module WithAdvisoryLock
88
129
  ].compact.join
89
130
  end
90
131
 
132
+ def advisory_lock_function(transaction_scope, shared)
133
+ [
134
+ 'pg_advisory',
135
+ transaction_scope ? '_xact' : nil,
136
+ '_lock',
137
+ shared ? '_shared' : nil
138
+ ].compact.join
139
+ end
140
+
91
141
  def advisory_unlock_function(shared)
92
142
  [
93
143
  'pg_advisory_unlock',
@@ -95,9 +145,26 @@ module WithAdvisoryLock
95
145
  ].compact.join
96
146
  end
97
147
 
98
- def execute_advisory(function, lock_keys, lock_name)
99
- result = query_value(prepare_sql(function, lock_keys, lock_name))
100
- LOCK_RESULT_VALUES.include?(result)
148
+ def execute_advisory(function, lock_keys, lock_name, blocking: false)
149
+ sql = prepare_sql(function, lock_keys, lock_name)
150
+ if blocking
151
+ # Blocking locks return void - if the query executes successfully, the lock was acquired.
152
+ # Rails 8.2+ uses lazy transaction materialization. We must use materialize_transactions: true
153
+ # to ensure the transaction is started on the database before acquiring the lock,
154
+ # otherwise the lock won't actually block other connections.
155
+ if respond_to?(:internal_exec_query, true)
156
+ # Rails < 8.2
157
+ query_value(sql)
158
+ else
159
+ # Rails 8.2+ - use query_all with materialize_transactions: true
160
+ send(:query_all, sql, 'AdvisoryLock', materialize_transactions: true)
161
+ end
162
+ true
163
+ else
164
+ # Non-blocking try locks return boolean
165
+ result = query_value(sql)
166
+ LOCK_RESULT_VALUES.include?(result)
167
+ end
101
168
  end
102
169
 
103
170
  def prepare_sql(function, lock_keys, lock_name)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithAdvisoryLock
4
- VERSION = Gem::Version.new('7.0.2')
4
+ VERSION = Gem::Version.new('7.6.0')
5
5
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TrilogyLabel < TrilogyRecord
4
+ self.table_name = 'trilogy_labels'
5
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TrilogyRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ establish_connection :trilogy
6
+ end