Skip to content

Commit b7764cd

Browse files
authored
feat: Add Trilogy adapter support with MariaDB 12.0+ (#134)
release-as: 0.7.5
1 parent f7f9aff commit b7764cd

23 files changed

+405
-46
lines changed

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ jobs:
3636
MYSQL_PASSWORD: with_advisory_pass
3737
MYSQL_DATABASE: with_advisory_lock_test
3838
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
3954
strategy:
4055
fail-fast: false
4156
matrix:
@@ -48,6 +63,11 @@ jobs:
4863
- 7.2
4964
- "8.0"
5065
- "8.1"
66+
- "edge"
67+
exclude:
68+
# TruffleRuby doesn't support Rails edge yet
69+
- ruby: 'truffleruby'
70+
rails: "edge"
5171
env:
5272
ACTIVERECORD_VERSION: ${{ matrix.rails }}
5373
RAILS_ENV: test
@@ -62,10 +82,14 @@ jobs:
6282
bundler-cache: true
6383
rubygems: latest
6484

85+
6586
- name: Setup test databases
87+
timeout-minutes: 5
6688
env:
6789
DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test
6890
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]) || '' }}
6993
run: |
7094
cd test/dummy
7195
bundle exec rake db:test:prepare
@@ -74,5 +98,7 @@ jobs:
7498
env:
7599
DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test
76100
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]) || '' }}
77103
WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }}
78104
run: bin/rails test

Gemfile

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,17 @@ gem 'benchmark'
1111
gem 'logger'
1212
gem 'ostruct'
1313

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
1723

1824
gem 'dotenv'
19-
gem 'railties'
2025

2126
platforms :ruby do
2227
gem 'mysql2'

docker-compose.yml

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,28 @@ services:
22
pg:
33
image: postgres:17-alpine
44
environment:
5-
POSTGRES_USER: ${DB_USER}
6-
POSTGRES_PASSWORD: ${DB_PASSWORD}
7-
POSTGRES_DB: ${DB_NAME}
5+
POSTGRES_USER: test
6+
POSTGRES_PASSWORD: test
7+
POSTGRES_DB: with_advisory_lock_test
88
ports:
99
- "5433:5432"
1010
mysql:
1111
image: mysql:8
1212
environment:
13-
MYSQL_USER: ${DB_USER}
14-
MYSQL_PASSWORD: ${DB_PASSWORD}
15-
MYSQL_DATABASE: ${DB_NAME}
13+
MYSQL_USER: test
14+
MYSQL_PASSWORD: test
15+
MYSQL_DATABASE: with_advisory_lock_test
1616
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
1717
MYSQL_ROOT_HOST: '%'
1818
ports:
1919
- "3366:3306"
20+
mariadb:
21+
image: mariadb:12
22+
environment:
23+
MARIADB_USER: test
24+
MARIADB_PASSWORD: test
25+
MARIADB_DATABASE: with_advisory_lock_test_trilogy
26+
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
27+
MARIADB_ROOT_HOST: '%'
28+
ports:
29+
- "3368:3306"

lib/with_advisory_lock/mysql_advisory.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@ def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seco
1616
# MySQL's GET_LOCK already provides native timeout support, making the blocking
1717
# parameter redundant. MySQL doesn't have separate try/blocking functions like PostgreSQL.
1818

19-
# MySQL GET_LOCK supports native timeout:
20-
# - timeout_seconds = nil: wait indefinitely (-1)
19+
# MySQL/MariaDB GET_LOCK supports native timeout:
20+
# - timeout_seconds = nil: wait indefinitely
2121
# - timeout_seconds = 0: try once, no wait (0)
2222
# - timeout_seconds > 0: wait up to timeout_seconds
23+
#
24+
# Note: MySQL accepts -1 for infinite wait, but MariaDB does not.
25+
# Using a large value (1 year) for cross-compatibility.
2326
mysql_timeout = case timeout_seconds
24-
when nil then -1
27+
when nil then 31_536_000 # 1 year in seconds
2528
when 0 then 0
2629
else timeout_seconds.to_i
2730
end

lib/with_advisory_lock/postgresql_advisory.rb

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,20 @@ def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seco
1919
advisory_try_lock_function(transaction, shared)
2020
end
2121
execute_advisory(function, lock_keys, lock_name, blocking: blocking)
22+
rescue ActiveRecord::Deadlocked
23+
# Rails 8.2+ raises ActiveRecord::Deadlocked directly for PostgreSQL deadlocks
24+
# When using blocking locks, treat deadlocks as lock acquisition failure
25+
return false if blocking
26+
27+
raise
2228
rescue ActiveRecord::StatementInvalid => e
2329
# PostgreSQL deadlock detection raises PG::TRDeadlockDetected (SQLSTATE 40P01)
24-
# When using blocking locks, treat deadlocks as lock acquisition failure
25-
if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) || e.message.include?('deadlock detected'))
30+
# When using blocking locks, treat deadlocks as lock acquisition failure.
31+
# Rails 8.2+ may also retry after deadlock and get "current transaction is aborted"
32+
# when the transaction was rolled back by PostgreSQL's deadlock detection.
33+
if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) ||
34+
e.message.include?('deadlock detected') ||
35+
e.message =~ ERROR_MESSAGE_REGEX)
2636
false
2737
else
2838
raise
@@ -117,13 +127,23 @@ def advisory_unlock_function(shared)
117127
end
118128

119129
def execute_advisory(function, lock_keys, lock_name, blocking: false)
130+
sql = prepare_sql(function, lock_keys, lock_name)
120131
if blocking
121-
# Blocking locks return void - if the query executes successfully, the lock was acquired
122-
query_value(prepare_sql(function, lock_keys, lock_name))
132+
# Blocking locks return void - if the query executes successfully, the lock was acquired.
133+
# Rails 8.2+ uses lazy transaction materialization. We must use materialize_transactions: true
134+
# to ensure the transaction is started on the database before acquiring the lock,
135+
# otherwise the lock won't actually block other connections.
136+
if respond_to?(:internal_exec_query, true)
137+
# Rails < 8.2
138+
query_value(sql)
139+
else
140+
# Rails 8.2+ - use query_all with materialize_transactions: true
141+
send(:query_all, sql, 'AdvisoryLock', materialize_transactions: true)
142+
end
123143
true
124144
else
125145
# Non-blocking try locks return boolean
126-
result = query_value(prepare_sql(function, lock_keys, lock_name))
146+
result = query_value(sql)
127147
LOCK_RESULT_VALUES.include?(result)
128148
end
129149
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class TrilogyLabel < TrilogyRecord
4+
self.table_name = 'trilogy_labels'
5+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# frozen_string_literal: true
2+
3+
class TrilogyTag < TrilogyRecord
4+
self.table_name = 'trilogy_tags'
5+
6+
after_save do
7+
TrilogyTagAudit.create(tag_name: name)
8+
TrilogyLabel.create(name: name)
9+
end
10+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class TrilogyTagAudit < TrilogyRecord
4+
self.table_name = 'trilogy_tag_audits'
5+
end

test/dummy/config/application.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ module TestSystemApp
1414
class Application < Rails::Application
1515
config.load_defaults [Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join('.')
1616
config.eager_load = true
17+
18+
# Ignore trilogy models when DATABASE_URL_TRILOGY is not set (e.g., TruffleRuby)
19+
unless ENV['DATABASE_URL_TRILOGY'] && !ENV['DATABASE_URL_TRILOGY'].empty?
20+
config.autoload_lib(ignore: %w[])
21+
initializer 'ignore_trilogy_models', before: :set_autoload_paths do |app|
22+
trilogy_models = %w[trilogy_record trilogy_tag trilogy_tag_audit trilogy_label]
23+
trilogy_models.each do |model|
24+
Rails.autoloaders.main.ignore(Rails.root.join('app', 'models', "#{model}.rb"))
25+
end
26+
end
27+
end
1728
config.serve_static_files = false
1829
config.public_file_server.enabled = false
1930
config.public_file_server.headers = { 'Cache-Control' => 'public, max-age=3600' }

0 commit comments

Comments
 (0)