Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ jobs:
MYSQL_PASSWORD: with_advisory_pass
MYSQL_DATABASE: with_advisory_lock_test
MYSQL_ROOT_HOST: '%'
mariadb:
image: mariadb:12
ports:
- 3306
env:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: with_advisory_lock_trilogy_test
MARIADB_USER: with_advisory
MARIADB_PASSWORD: with_advisory_pass
MARIADB_ROOT_HOST: '%'
options: >-
--health-cmd "healthcheck.sh --su-mysql --connect --innodb_initialized"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
fail-fast: false
matrix:
Expand All @@ -48,6 +63,11 @@ jobs:
- 7.2
- "8.0"
- "8.1"
- "edge"
exclude:
# TruffleRuby doesn't support Rails edge yet
- ruby: 'truffleruby'
rails: "edge"
env:
ACTIVERECORD_VERSION: ${{ matrix.rails }}
RAILS_ENV: test
Expand All @@ -62,10 +82,14 @@ jobs:
bundler-cache: true
rubygems: latest


- name: Setup test databases
timeout-minutes: 5
env:
DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test
DATABASE_URL_MYSQL: mysql2://with_advisory:with_advisory_pass@127.0.0.1:${{ job.services.mysql.ports[3306] }}/with_advisory_lock_test
# Trilogy doesn't support TruffleRuby
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]) || '' }}
run: |
cd test/dummy
bundle exec rake db:test:prepare
Expand All @@ -74,5 +98,7 @@ jobs:
env:
DATABASE_URL_PG: postgres://with_advisory:with_advisory_pass@localhost:${{ job.services.postgres.ports[5432] }}/with_advisory_lock_test
DATABASE_URL_MYSQL: mysql2://with_advisory:with_advisory_pass@127.0.0.1:${{ job.services.mysql.ports[3306] }}/with_advisory_lock_test
# Trilogy doesn't support TruffleRuby
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]) || '' }}
WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }}
run: bin/rails test
13 changes: 9 additions & 4 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ gem 'benchmark'
gem 'logger'
gem 'ostruct'

activerecord_version = ENV.fetch('ACTIVERECORD_VERSION', '7.2')

gem 'activerecord', "~> #{activerecord_version}.0"
activerecord_version = ENV.fetch('ACTIVERECORD_VERSION', '8.1')

if activerecord_version == 'edge'
gem 'activerecord', github: 'rails/rails', branch: 'main'
gem 'railties', github: 'rails/rails', branch: 'main'
else
gem 'activerecord', "~> #{activerecord_version}.0"
gem 'railties'
end

gem 'dotenv'
gem 'railties'

platforms :ruby do
gem 'mysql2'
Expand Down
22 changes: 16 additions & 6 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,28 @@ services:
pg:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: with_advisory_lock_test
ports:
- "5433:5432"
mysql:
image: mysql:8
environment:
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: test
MYSQL_PASSWORD: test
MYSQL_DATABASE: with_advisory_lock_test
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
MYSQL_ROOT_HOST: '%'
ports:
- "3366:3306"
mariadb:
image: mariadb:12
environment:
MARIADB_USER: test
MARIADB_PASSWORD: test
MARIADB_DATABASE: with_advisory_lock_test_trilogy
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
MARIADB_ROOT_HOST: '%'
ports:
- "3368:3306"
9 changes: 6 additions & 3 deletions lib/with_advisory_lock/mysql_advisory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seco
# MySQL's GET_LOCK already provides native timeout support, making the blocking
# parameter redundant. MySQL doesn't have separate try/blocking functions like PostgreSQL.

# MySQL GET_LOCK supports native timeout:
# - timeout_seconds = nil: wait indefinitely (-1)
# MySQL/MariaDB GET_LOCK supports native timeout:
# - timeout_seconds = nil: wait indefinitely
# - timeout_seconds = 0: try once, no wait (0)
# - timeout_seconds > 0: wait up to timeout_seconds
#
# Note: MySQL accepts -1 for infinite wait, but MariaDB does not.
# Using a large value (1 year) for cross-compatibility.
mysql_timeout = case timeout_seconds
when nil then -1
when nil then 31_536_000 # 1 year in seconds
when 0 then 0
else timeout_seconds.to_i
end
Expand Down
30 changes: 25 additions & 5 deletions lib/with_advisory_lock/postgresql_advisory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,20 @@ def try_advisory_lock(lock_keys, lock_name:, shared:, transaction:, timeout_seco
advisory_try_lock_function(transaction, shared)
end
execute_advisory(function, lock_keys, lock_name, blocking: blocking)
rescue ActiveRecord::Deadlocked
# Rails 8.2+ raises ActiveRecord::Deadlocked directly for PostgreSQL deadlocks
# When using blocking locks, treat deadlocks as lock acquisition failure
return false if blocking

raise
rescue ActiveRecord::StatementInvalid => e
# PostgreSQL deadlock detection raises PG::TRDeadlockDetected (SQLSTATE 40P01)
# When using blocking locks, treat deadlocks as lock acquisition failure
if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) || e.message.include?('deadlock detected'))
# When using blocking locks, treat deadlocks as lock acquisition failure.
# Rails 8.2+ may also retry after deadlock and get "current transaction is aborted"
# when the transaction was rolled back by PostgreSQL's deadlock detection.
if blocking && (e.cause.is_a?(PG::TRDeadlockDetected) ||
e.message.include?('deadlock detected') ||
e.message =~ ERROR_MESSAGE_REGEX)
false
else
raise
Expand Down Expand Up @@ -117,13 +127,23 @@ def advisory_unlock_function(shared)
end

def execute_advisory(function, lock_keys, lock_name, blocking: false)
sql = prepare_sql(function, lock_keys, lock_name)
if blocking
# Blocking locks return void - if the query executes successfully, the lock was acquired
query_value(prepare_sql(function, lock_keys, lock_name))
# Blocking locks return void - if the query executes successfully, the lock was acquired.
# Rails 8.2+ uses lazy transaction materialization. We must use materialize_transactions: true
# to ensure the transaction is started on the database before acquiring the lock,
# otherwise the lock won't actually block other connections.
if respond_to?(:internal_exec_query, true)
# Rails < 8.2
query_value(sql)
else
# Rails 8.2+ - use query_all with materialize_transactions: true
send(:query_all, sql, 'AdvisoryLock', materialize_transactions: true)
end
true
else
# Non-blocking try locks return boolean
result = query_value(prepare_sql(function, lock_keys, lock_name))
result = query_value(sql)
LOCK_RESULT_VALUES.include?(result)
end
end
Expand Down
5 changes: 5 additions & 0 deletions test/dummy/app/models/trilogy_label.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class TrilogyLabel < TrilogyRecord
self.table_name = 'trilogy_labels'
end
6 changes: 6 additions & 0 deletions test/dummy/app/models/trilogy_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

class TrilogyRecord < ActiveRecord::Base
self.abstract_class = true
establish_connection :trilogy
end
10 changes: 10 additions & 0 deletions test/dummy/app/models/trilogy_tag.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

class TrilogyTag < TrilogyRecord
self.table_name = 'trilogy_tags'

after_save do
TrilogyTagAudit.create(tag_name: name)
TrilogyLabel.create(name: name)
end
end
5 changes: 5 additions & 0 deletions test/dummy/app/models/trilogy_tag_audit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class TrilogyTagAudit < TrilogyRecord
self.table_name = 'trilogy_tag_audits'
end
11 changes: 11 additions & 0 deletions test/dummy/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ module TestSystemApp
class Application < Rails::Application
config.load_defaults [Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join('.')
config.eager_load = true

# Ignore trilogy models when DATABASE_URL_TRILOGY is not set (e.g., TruffleRuby)
unless ENV['DATABASE_URL_TRILOGY'] && !ENV['DATABASE_URL_TRILOGY'].empty?
config.autoload_lib(ignore: %w[])
initializer 'ignore_trilogy_models', before: :set_autoload_paths do |app|
trilogy_models = %w[trilogy_record trilogy_tag trilogy_tag_audit trilogy_label]
trilogy_models.each do |model|
Rails.autoloaders.main.ignore(Rails.root.join('app', 'models', "#{model}.rb"))
end
end
end
config.serve_static_files = false
config.public_file_server.enabled = false
config.public_file_server.headers = { 'Cache-Control' => 'public, max-age=3600' }
Expand Down
8 changes: 8 additions & 0 deletions test/dummy/config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ test:
url: "<%= ENV['DATABASE_URL_MYSQL'] %>"
properties:
allowPublicKeyRetrieval: true
<% if ENV['DATABASE_URL_TRILOGY'] && !ENV['DATABASE_URL_TRILOGY'].empty? %>
trilogy:
<<: *default
url: "<%= ENV['DATABASE_URL_TRILOGY'] %>"
adapter: trilogy
properties:
allowPublicKeyRetrieval: true
<% end %>
15 changes: 15 additions & 0 deletions test/dummy/db/trilogy_schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

ActiveRecord::Schema.define(version: 1) do
create_table 'trilogy_tags', force: true do |t|
t.string 'name'
end

create_table 'trilogy_tag_audits', id: false, force: true do |t|
t.string 'tag_name'
end

create_table 'trilogy_labels', id: false, force: true do |t|
t.string 'name'
end
end
44 changes: 18 additions & 26 deletions test/dummy/lib/tasks/db.rake
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,29 @@ namespace :db do
namespace :test do
desc 'Load schema for all databases'
task prepare: :environment do
# Load schema for primary database
# Setup PostgreSQL database
ActiveRecord::Base.establish_connection(:primary)
ActiveRecord::Schema.define(version: 1) do
create_table 'tags', force: true do |t|
t.string 'name'
end
load Rails.root.join('db', 'schema.rb')
puts 'PostgreSQL database schema loaded'

create_table 'tag_audits', id: false, force: true do |t|
t.string 'tag_name'
end

create_table 'labels', id: false, force: true do |t|
t.string 'name'
end
end

# Load schema for secondary database
# Setup MySQL database
ActiveRecord::Base.establish_connection(:secondary)
ActiveRecord::Schema.define(version: 1) do
create_table 'mysql_tags', force: true do |t|
t.string 'name'
end
load Rails.root.join('db', 'secondary_schema.rb')
puts 'MySQL database schema loaded'

create_table 'mysql_tag_audits', id: false, force: true do |t|
t.string 'tag_name'
end

create_table 'mysql_labels', id: false, force: true do |t|
t.string 'name'
end
# Setup Trilogy database (MariaDB) - optional, not supported on TruffleRuby
if ENV['DATABASE_URL_TRILOGY'] && !ENV['DATABASE_URL_TRILOGY'].empty?
ActiveRecord::Base.establish_connection(:trilogy)
load Rails.root.join('db', 'trilogy_schema.rb')
puts 'Trilogy database schema loaded'
else
puts 'Skipping Trilogy database (DATABASE_URL_TRILOGY not set)'
end

puts 'All test databases prepared successfully'
rescue StandardError => e
puts "Error preparing test databases: #{e.message}"
raise e
end
end
end
49 changes: 48 additions & 1 deletion test/sanity_check_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class SanityCheckTest < GemTestCase
assert_equal 'Mysql2', MysqlLabel.connection.adapter_name
end

test 'can write to both databases in same test' do
test 'can write to PostgreSQL and MySQL databases in same test' do
# Create records in both databases
pg_tag = Tag.create!(name: 'test-pg')
mysql_tag = MysqlTag.create!(name: 'test-mysql')
Expand All @@ -61,3 +61,50 @@ class SanityCheckTest < GemTestCase
mysql_tag.destroy
end
end

if GemTestCase.trilogy_available?
class TrilogySanityCheckTest < GemTestCase
test 'Trilogy database is isolated from PostgreSQL and MySQL' do
# Create tags in all databases
pg_tag = Tag.create!(name: 'pg-isolation-test')
mysql_tag = MysqlTag.create!(name: 'mysql-isolation-test')
trilogy_tag = TrilogyTag.create!(name: 'trilogy-isolation-test')

# Verify Trilogy tag exists only in Trilogy
assert TrilogyTag.exists?(name: 'trilogy-isolation-test')
assert_not Tag.exists?(name: 'trilogy-isolation-test')
assert_not MysqlTag.exists?(name: 'trilogy-isolation-test')

# Verify PostgreSQL tag doesn't exist in Trilogy
assert_not TrilogyTag.exists?(name: 'pg-isolation-test')

# Verify MySQL tag doesn't exist in Trilogy
assert_not TrilogyTag.exists?(name: 'mysql-isolation-test')

# Clean up
pg_tag.destroy
mysql_tag.destroy
trilogy_tag.destroy
end

test 'Trilogy models use Trilogy adapter' do
assert_equal 'Trilogy', TrilogyTag.connection.adapter_name
assert_equal 'Trilogy', TrilogyTagAudit.connection.adapter_name
assert_equal 'Trilogy', TrilogyLabel.connection.adapter_name
end

test 'can write to all three databases in same test' do
pg_tag = Tag.create!(name: 'test-pg')
mysql_tag = MysqlTag.create!(name: 'test-mysql')
trilogy_tag = TrilogyTag.create!(name: 'test-trilogy')

assert pg_tag.persisted?
assert mysql_tag.persisted?
assert trilogy_tag.persisted?

pg_tag.destroy
mysql_tag.destroy
trilogy_tag.destroy
end
end
end
11 changes: 10 additions & 1 deletion test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,19 @@ class GemTestCase < ActiveSupport::TestCase
parallelize(workers: 1)

def self.startup
# Validate environment variables when tests actually start running
# Validate required environment variables
%w[DATABASE_URL_PG DATABASE_URL_MYSQL].each do |var|
abort "Missing required environment variable: #{var}" if ENV[var].nil? || ENV[var].empty?
end

# Trilogy is optional (not supported on TruffleRuby)
if ENV['DATABASE_URL_TRILOGY'].nil? || ENV['DATABASE_URL_TRILOGY'].empty?
puts 'DATABASE_URL_TRILOGY not set, skipping Trilogy tests'
end
end

def self.trilogy_available?
ENV['DATABASE_URL_TRILOGY'] && !ENV['DATABASE_URL_TRILOGY'].empty?
end

# Override in test classes to clean only the tables you need
Expand Down
Loading
Loading