diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d1d4dd..fffa6e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,13 @@ jobs: mysql: runs-on: ubuntu-latest + # Both clients speak MySQL but report different adapter names, negotiate + # different connection collations, and name the same error code + # differently, so passing on one proves nothing about the other. + strategy: + fail-fast: false + matrix: + client: [ mysql2, trilogy ] services: mysql: image: mysql:8.4 @@ -110,7 +117,7 @@ jobs: --health-timeout 5s --health-retries 20 env: - SOLID_OBJECTS_DATABASE_URL: mysql2://solid_objects:solid_objects@127.0.0.1:3306/solid_objects_test + SOLID_OBJECTS_DATABASE_URL: ${{ matrix.client }}://solid_objects:solid_objects@127.0.0.1:3306/solid_objects_test steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9633a2a..53f4855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.10.1 - 2026-08-10 + +- Support Trilogy. Adapter selection matched the client name rather than the + protocol, and Trilogy reports `"Trilogy"`, so every Solid Objects call raised + `UnsupportedDatabase: unsupported database adapter "Trilogy"` on a database + the gem fully supports. Adapter names now resolve through one table of + families, `DatabaseAdapter.family`, used by adapter selection, owner-id + casting, and wake-up adapter selection alike, so a client cannot be accepted + in one place and rejected in another. +- Compare reconciliation owner ids in the column's own collation. + `Instance.orphaned` cast owner primary keys to `CHAR`, and a cast result + carries the connection collation rather than the column's. MySQL refuses to + compare two collations, so the query raised `Illegal mix of collations` + whenever the two differed. That is a property of the client rather than the + schema: mysql2 negotiates the database default while Trilogy negotiates + `utf8mb4_general_ci`. A mysql2 application that set `collation:` in + `database.yml` could already hit this. +- Recognise a statement interruption from any MySQL client. A synchronous + deadline is enforced by asking the server to interrupt the statement, and the + interruption was matched only through mysql2's `error_number`. Trilogy names + it `error_code`, so a deadline surfaced as a raw + `ActiveRecord::StatementTimeout` instead of `SyncEnqueueTimeout`. Both names + are read, and Active Record's own classification is trusted first. +- Run the MySQL suite against both mysql2 and Trilogy in CI, and key + adapter-specific test skips to the database family rather than the client + name, so a Trilogy run no longer silently skips every MySQL test. + ## 0.10.0 - 2026-08-10 - Report a denied CLI command as a policy decision rather than a crash. diff --git a/Gemfile.lock b/Gemfile.lock index 9768291..b261ae2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.10.0) + solid_objects (0.10.1) actioncable (>= 8.0) actionpack (>= 8.0) actionview (>= 8.0) @@ -253,6 +253,8 @@ GEM unicode-display_width (>= 1.1.1, < 4) thor (1.5.0) timeout (0.6.1) + trilogy (2.12.6) + bigdecimal tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -292,6 +294,7 @@ DEPENDENCIES sqlite3 (>= 2.1) standard steep + trilogy (>= 2.7) CHECKSUMS actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b @@ -380,7 +383,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - solid_objects (0.10.0) + solid_objects (0.10.1) sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b @@ -397,6 +400,7 @@ CHECKSUMS terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + trilogy (2.12.6) sha256=2e0f97092150c92fec41acc304c382ad52f5ea98e1eb11073e4f501f80159cf7 tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 diff --git a/README.md b/README.md index 1600496..4dbfc04 100644 --- a/README.md +++ b/README.md @@ -956,7 +956,8 @@ shutdown, retention, and backup guidance. Solid Objects supports: - PostgreSQL 14 or newer -- MySQL 8.0 or newer using InnoDB +- MySQL 8.0 or newer using InnoDB, through either the `mysql2` or `trilogy` + client - SQLite 3.35 or newer PostgreSQL and MySQL use `FOR UPDATE SKIP LOCKED` when claiming hot-table rows. diff --git a/app/models/solid_objects/instance.rb b/app/models/solid_objects/instance.rb index 327f275..cd9560e 100644 --- a/app/models/solid_objects/instance.rb +++ b/app/models/solid_objects/instance.rb @@ -56,7 +56,7 @@ def orphaned(actor_type:, owner:) ) owner_ids = owner_relation .except(:select) - .select(cast_id) + .select(collated(cast_id)) where(actor_type:).where.not(actor_id: owner_ids) end @@ -70,12 +70,39 @@ def states_for(actor_type:, actor_ids:) private + # A cast result carries the connection collation, not the column's, and + # MySQL refuses to compare two collations. Which collation a connection + # uses is a property of the client rather than the schema: mysql2 + # negotiates the database default while Trilogy negotiates + # utf8mb4_general_ci, so the comparison is pinned to the column's own. + # @rbs (untyped) -> untyped + def collated(node) + collation = owner_id_collation + return node unless collation + + Arel::Nodes::InfixOperation.new( + "COLLATE", + node, + Arel::Nodes::SqlLiteral.new(collation) + ) + end + + # @rbs () -> String? + def owner_id_collation + return nil unless DatabaseAdapter.family(connection) == :mysql + + collation = columns_hash["actor_id"]&.collation + return nil unless collation&.match?(/\A[a-zA-Z0-9_]+\z/) + + collation + end + # @rbs () -> String def owner_id_cast_type - case connection.adapter_name - when /mysql/i + case DatabaseAdapter.family(connection) + when :mysql "CHAR" - when /postgres/i + when :postgresql "VARCHAR" else "TEXT" diff --git a/docs/architecture.md b/docs/architecture.md index abdd422..ebd379d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,6 +41,10 @@ The host application owns: ## Database coordination adapters Solid Objects supports PostgreSQL 14+, MySQL 8.0+ with InnoDB, and SQLite 3.35+. +MySQL is reached through either the `mysql2` or `trilogy` client. Adapter names +are client names rather than protocol names, so every decision that depends on +the database resolves through `DatabaseAdapter.family` rather than matching an +adapter name in place. One adapter capability object is selected from the Active Record connection. It supplies claim locking and database-time expressions. Unsupported adapter families fail when first used. Minimum server-version and storage-engine checks are documented operating requirements; automatic boot-time enforcement and classified contention retries remain hardening work. diff --git a/docs/local-testing.md b/docs/local-testing.md index b9a28c1..93491fc 100644 --- a/docs/local-testing.md +++ b/docs/local-testing.md @@ -48,10 +48,17 @@ docker run -d --name so-redis -p 6380:6379 redis:7-alpine SOLID_OBJECTS_DATABASE_URL=mysql2://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \ bundle exec rake test +SOLID_OBJECTS_DATABASE_URL=trilogy://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \ + bundle exec rake test + SOLID_OBJECTS_REDIS_URL=redis://127.0.0.1:6380/15 \ bundle exec rake test TEST=test/integration/redis_wake_up_test.rb ``` +Run both MySQL clients. They report different adapter names, negotiate +different connection collations, and name the same error code differently, so a +pass on one says nothing about the other. Recreate the database between them. + Stop them with `docker rm -f so-mysql so-redis`. ## Recreating a database between runs diff --git a/docs/roadmap.md b/docs/roadmap.md index 66ab92b..a20ad43 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -45,7 +45,9 @@ shutdown is requested, and dead process records plus expired message and process history are pruned on their own intervals without an application scheduling its own job -- SQLite, PostgreSQL, and MySQL integration suites +- SQLite, PostgreSQL, and MySQL integration suites, with MySQL run against both + the `mysql2` and `trilogy` clients because an adapter name, a connection + collation, and an error code name all differ between them - Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with a listening connection per waiting thread and release on supervisor shutdown - Opt-in cross-process wake-up on Redis, the option for MySQL applications, diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index f86a9e9..c28a6af 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -46,6 +46,7 @@ require "solid_objects/actor_view" require "solid_objects/actor_channel" require "solid_objects/action_cable_broadcast_adapter" +require "solid_objects/database_adapter" require "solid_objects/wake_up" require "solid_objects/wake_up_adapters/postgresql" require "solid_objects/wake_up_adapters/redis" diff --git a/lib/solid_objects/database_adapter.rb b/lib/solid_objects/database_adapter.rb index 5c3314f..0d9c07e 100644 --- a/lib/solid_objects/database_adapter.rb +++ b/lib/solid_objects/database_adapter.rb @@ -7,15 +7,35 @@ class DatabaseAdapter TRANSACTION_CLOCK = :solid_objects_transaction_clock TRANSACTION_CLOCK_SCOPE = :solid_objects_transaction_clock_scope + # An adapter name is a client name, not a protocol name. Trilogy reports + # "Trilogy" while speaking MySQL, so a pattern that only knows the mysql2 + # gem rejects a database Solid Objects fully supports. Every decision that + # depends on the database goes through this one table, so a client cannot + # be accepted in one place and rejected in another. + FAMILIES = { + postgresql: /postgres/i, + mysql: /mysql|trilogy/i, + sqlite: /sqlite/i + }.freeze + class << self + # @rbs (untyped) -> Symbol? + def family(connection) + adapter_name = connection.adapter_name + FAMILIES.each do |family, pattern| + return family if adapter_name.match?(pattern) + end + nil + end + # @rbs (untyped) -> DatabaseAdapter def for(connection) - case connection.adapter_name - when /postgres/i + case family(connection) + when :postgresql DatabaseAdapters::Postgresql.new(connection) - when /mysql/i + when :mysql DatabaseAdapters::Mysql.new(connection) - when /sqlite/i + when :sqlite DatabaseAdapters::Sqlite.new(connection) else raise UnsupportedDatabase, "unsupported database adapter #{connection.adapter_name.inspect}" diff --git a/lib/solid_objects/database_adapters/mysql.rb b/lib/solid_objects/database_adapters/mysql.rb index e816828..d91c0ec 100644 --- a/lib/solid_objects/database_adapters/mysql.rb +++ b/lib/solid_objects/database_adapters/mysql.rb @@ -3,6 +3,8 @@ module SolidObjects module DatabaseAdapters class Mysql < DatabaseAdapter + MAXIMUM_EXECUTION_TIME_EXCEEDED = 3024 + # @rbs () -> bool def supports_skip_locked? true @@ -78,19 +80,33 @@ def with_transaction_deadline(connection) end end + # A deadline is enforced by asking the server to interrupt the statement, + # so recognising that interruption is what turns it back into a timeout + # the caller asked for. Active Record classifies it for every client, and + # the raw code is the fallback: mysql2 names it `error_number` and + # Trilogy names it `error_code`, so both are read. # @rbs (Exception) -> bool def deadline_error?(error) return false unless SyncDeadline.active? return true if error.is_a?(ActiveRecord::LockWaitTimeout) + return true if error.is_a?(ActiveRecord::StatementTimeout) cause = error while cause - return true if cause.respond_to?(:error_number) && cause.error_number == 3024 + return true if error_code(cause) == MAXIMUM_EXECUTION_TIME_EXCEEDED cause = cause.cause end false end + + # @rbs (Exception) -> Integer? + def error_code(error) + return error.error_number if error.respond_to?(:error_number) + return error.error_code if error.respond_to?(:error_code) + + nil + end end end end diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index ebda327..a16de9c 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.10.0" + VERSION = "0.10.1" end diff --git a/lib/solid_objects/wake_up_adapters.rb b/lib/solid_objects/wake_up_adapters.rb index d4f3d50..a0b79e1 100644 --- a/lib/solid_objects/wake_up_adapters.rb +++ b/lib/solid_objects/wake_up_adapters.rb @@ -15,7 +15,7 @@ module WakeUpAdapters # # @rbs (?untyped) -> untyped def for(connection = Record.connection) - return Postgresql.new if connection.adapter_name.match?(/postgres/i) + return Postgresql.new if DatabaseAdapter.family(connection) == :postgresql WakeUp.new end diff --git a/sig/generated/lib/solid_objects/database_adapter.rbs b/sig/generated/lib/solid_objects/database_adapter.rbs index 72d8362..82bddcc 100644 --- a/sig/generated/lib/solid_objects/database_adapter.rbs +++ b/sig/generated/lib/solid_objects/database_adapter.rbs @@ -6,6 +6,16 @@ module SolidObjects TRANSACTION_CLOCK_SCOPE: ::Symbol + # An adapter name is a client name, not a protocol name. Trilogy reports + # "Trilogy" while speaking MySQL, so a pattern that only knows the mysql2 + # gem rejects a database Solid Objects fully supports. Every decision that + # depends on the database goes through this one table, so a client cannot + # be accepted in one place and rejected in another. + FAMILIES: untyped + + # @rbs (untyped) -> Symbol? + def self.family: (untyped) -> Symbol? + # @rbs (untyped) -> DatabaseAdapter def self.for: (untyped) -> DatabaseAdapter diff --git a/sig/generated/lib/solid_objects/database_adapters/mysql.rbs b/sig/generated/lib/solid_objects/database_adapters/mysql.rbs index c6af5be..6ad899e 100644 --- a/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +++ b/sig/generated/lib/solid_objects/database_adapters/mysql.rbs @@ -3,6 +3,8 @@ module SolidObjects module DatabaseAdapters class Mysql < DatabaseAdapter + MAXIMUM_EXECUTION_TIME_EXCEEDED: ::Integer + # @rbs () -> bool def supports_skip_locked?: () -> bool @@ -28,8 +30,16 @@ module SolidObjects # @rbs (untyped) { () -> untyped } -> untyped def with_transaction_deadline: (untyped) { () -> untyped } -> untyped + # A deadline is enforced by asking the server to interrupt the statement, + # so recognising that interruption is what turns it back into a timeout + # the caller asked for. Active Record classifies it for every client, and + # the raw code is the fallback: mysql2 names it `error_number` and + # Trilogy names it `error_code`, so both are read. # @rbs (Exception) -> bool def deadline_error?: (Exception) -> bool + + # @rbs (Exception) -> Integer? + def error_code: (Exception) -> Integer? end end end diff --git a/sig/generated/models/solid_objects/instance.rbs b/sig/generated/models/solid_objects/instance.rbs index edadbf9..bfeb55f 100644 --- a/sig/generated/models/solid_objects/instance.rbs +++ b/sig/generated/models/solid_objects/instance.rbs @@ -14,6 +14,17 @@ module SolidObjects # @rbs (actor_type: String, actor_ids: Array[String]) -> Hash[String, Hash[String, untyped]] def self.states_for: (actor_type: String, actor_ids: Array[String]) -> Hash[String, Hash[String, untyped]] + # A cast result carries the connection collation, not the column's, and + # MySQL refuses to compare two collations. Which collation a connection + # uses is a property of the client rather than the schema: mysql2 + # negotiates the database default while Trilogy negotiates + # utf8mb4_general_ci, so the comparison is pinned to the column's own. + # @rbs (untyped) -> untyped + private def self.collated: (untyped) -> untyped + + # @rbs () -> String? + private def self.owner_id_collation: () -> String? + # @rbs () -> String private def self.owner_id_cast_type: () -> String diff --git a/solid_objects.gemspec b/solid_objects.gemspec index 6e61204..610bbc9 100644 --- a/solid_objects.gemspec +++ b/solid_objects.gemspec @@ -53,4 +53,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "sqlite3", ">= 2.1" spec.add_development_dependency "standard" spec.add_development_dependency "steep" + spec.add_development_dependency "trilogy", ">= 2.7" end diff --git a/test/database_test_helper.rb b/test/database_test_helper.rb index 4de539d..e9a8ab4 100644 --- a/test/database_test_helper.rb +++ b/test/database_test_helper.rb @@ -57,6 +57,13 @@ class ActiveSupport::TestCase SolidObjectsTestDomainRecord.delete_all end + # Skips keyed to a client name silently lose their coverage the moment a + # different client for the same database is used, and a skipped test looks + # exactly like a passing one in the summary line. + def database_family + SolidObjects::DatabaseAdapter.family(SolidObjects::Record.connection) + end + def with_immediate_sqlite_lock_failure(&block) SolidObjects::Record.connection_pool.with_connection do |connection| suspend_sqlite_busy_wait(connection, &block) diff --git a/test/integration/database_version_test.rb b/test/integration/database_version_test.rb index 6555ce3..a856eea 100644 --- a/test/integration/database_version_test.rb +++ b/test/integration/database_version_test.rb @@ -16,7 +16,7 @@ class DatabaseVersionTest < ActiveSupport::TestCase end test "an old PostgreSQL server is still detected" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/postgres/i) + skip unless database_family == :postgresql adapter = SolidObjects.database_adapter adapter.define_singleton_method(:with_connection) { |&block| block.call(Struct.new(:database_version).new(90_600)) } @@ -96,13 +96,13 @@ class DatabaseVersionTest < ActiveSupport::TestCase end test "MySQL verifies that Solid Objects tables use InnoDB" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/mysql/i) + skip unless database_family == :mysql assert_empty SolidObjects.database_adapter.unsupported_server_reasons end test "MySQL reports a non-transactional storage engine" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/mysql/i) + skip unless database_family == :mysql adapter = SolidObjects.database_adapter adapter.define_singleton_method(:non_innodb_tables) { [ "solid_objects_messages" ] } diff --git a/test/integration/doctor_test.rb b/test/integration/doctor_test.rb index 12e4f45..b669c58 100644 --- a/test/integration/doctor_test.rb +++ b/test/integration/doctor_test.rb @@ -49,7 +49,7 @@ class DoctorTest < ActiveSupport::TestCase end test "reports a failed round trip instead of raising while the database stays locked" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + skip unless database_family == :sqlite lock = hold_sqlite_write_lock report = with_immediate_sqlite_lock_failure { SolidObjects::Doctor.new.call } diff --git a/test/integration/enqueue_lock_retry_test.rb b/test/integration/enqueue_lock_retry_test.rb index 1e64585..3aad2f5 100644 --- a/test/integration/enqueue_lock_retry_test.rb +++ b/test/integration/enqueue_lock_retry_test.rb @@ -92,7 +92,7 @@ def add(product_id:) private def sqlite? - SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + database_family == :sqlite end # Holds the write lock long enough that an enqueue must wait, then releases diff --git a/test/integration/postgresql_wake_up_test.rb b/test/integration/postgresql_wake_up_test.rb index 60933b8..ad0af08 100644 --- a/test/integration/postgresql_wake_up_test.rb +++ b/test/integration/postgresql_wake_up_test.rb @@ -171,7 +171,7 @@ class PostgresqlWakeUpTest < ActiveSupport::TestCase private def postgresql? - SolidObjects::Record.connection.adapter_name.match?(/postgres/i) + database_family == :postgresql end def monotonic_now diff --git a/test/integration/synchronous_invocation_test.rb b/test/integration/synchronous_invocation_test.rb index 59259ca..54c5fc9 100644 --- a/test/integration/synchronous_invocation_test.rb +++ b/test/integration/synchronous_invocation_test.rb @@ -474,7 +474,7 @@ def wait(timeout:) end test "sync bounds SQLite contention while registering its caller process" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + skip unless database_family == :sqlite message_reference = SolidObjects::Mailbox.new.enqueue( LockRetryActor.ref("registration"), @@ -503,7 +503,7 @@ def wait(timeout:) end test "sync does not re-read the SQLite busy wait it already knows how to restore" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + skip unless database_family == :sqlite CounterActor.ref("pragma-warm").increment statements = [] subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |event| @@ -553,7 +553,7 @@ def wait(timeout:) end test "a retry wait whose deadline already expired does not raise" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + skip unless database_family == :sqlite adapter = SolidObjects.database_adapter SolidObjects::SyncDeadline.with(timeout: -1) do @@ -562,7 +562,7 @@ def wait(timeout:) end test "sync discovers the configured SQLite busy wait it has to restore" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + skip unless database_family == :sqlite SolidObjects::Record.connection_pool.with_connection do |connection| discovered = SolidObjects @@ -576,7 +576,7 @@ def wait(timeout:) end test "sync leaves an unrestorable busy wait alone" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + skip unless database_family == :sqlite database_adapter = SolidObjects.database_adapter database_adapter.define_singleton_method(:configured_busy_handler_timeout) { |_connection| nil } @@ -592,7 +592,7 @@ def wait(timeout:) end test "sync restores the SQLite busy handler it suspended for the deadline" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + skip unless database_family == :sqlite SolidObjects::Record.connection_pool.with_connection do CounterActor.ref("busy-handler").increment @@ -604,7 +604,7 @@ def wait(timeout:) end test "sync bounds SQLite contention while reusing and heartbeating its caller process" do - skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + skip unless database_family == :sqlite SolidObjects.configuration.process_heartbeat_interval = 0 process_record = SolidObjects.caller_process.process_registry.process_record diff --git a/test/models/instance_reconciliation_test.rb b/test/models/instance_reconciliation_test.rb index 88f5c06..1beab3c 100644 --- a/test/models/instance_reconciliation_test.rb +++ b/test/models/instance_reconciliation_test.rb @@ -68,8 +68,49 @@ class InstanceReconciliationTest < ActiveSupport::TestCase assert_equal [ orphan.id ], relation.pluck(:id) end + # A cast result carries the connection collation while the column carries the + # schema's. MySQL refuses to compare two collations, and which one the + # connection uses is a property of the client: mysql2 negotiates the database + # default, Trilogy negotiates utf8mb4_general_ci. + test "compares owner ids in the column's own collation" do + skip unless database_family == :mysql + + sql = SolidObjects::Instance.orphaned( + actor_type: "UserNudgeActor", + owner: SolidObjects::Process.all + ).to_sql + collation = SolidObjects::Instance.columns_hash["actor_id"].collation + + assert_includes sql, "COLLATE #{collation}", + "the cast must be pinned to the column's collation, not the connection's" + end + + # `CAST(x AS TEXT)` is not valid MySQL, so a MySQL client that falls through + # to the default produces a SQL error rather than a wrong answer. + test "casts owner ids per database family rather than per client name" do + expected = { + "Mysql2" => "CHAR", + "Trilogy" => "CHAR", + "PostgreSQL" => "VARCHAR", + "SQLite" => "TEXT" + } + + expected.each do |adapter_name, cast_type| + assert_equal cast_type, cast_type_for(adapter_name), + "#{adapter_name} should cast owner ids as #{cast_type}" + end + end + private + def cast_type_for(adapter_name) + connection = Data.define(:adapter_name).new(adapter_name:) + SolidObjects::Instance.define_singleton_method(:connection) { connection } + SolidObjects::Instance.send(:owner_id_cast_type) + ensure + SolidObjects::Instance.singleton_class.send(:remove_method, :connection) + end + def create_instance(actor_id, actor_type: "UserNudgeActor", last_used_at: Time.current) SolidObjects::Instance.create!(actor_type:, actor_id:, last_used_at:) end diff --git a/test/unit/database_adapter_test.rb b/test/unit/database_adapter_test.rb index c85215b..446c9fa 100644 --- a/test/unit/database_adapter_test.rb +++ b/test/unit/database_adapter_test.rb @@ -7,14 +7,11 @@ class DatabaseAdapterTest < ActiveSupport::TestCase test "selects the coordination adapter for the active database" do adapter = SolidObjects::DatabaseAdapter.for(ActiveRecord::Base.connection) - expected_class = case ActiveRecord::Base.connection.adapter_name - when /postgres/i - SolidObjects::DatabaseAdapters::Postgresql - when /mysql/i - SolidObjects::DatabaseAdapters::Mysql - else - SolidObjects::DatabaseAdapters::Sqlite - end + expected_class = { + postgresql: SolidObjects::DatabaseAdapters::Postgresql, + mysql: SolidObjects::DatabaseAdapters::Mysql, + sqlite: SolidObjects::DatabaseAdapters::Sqlite + }.fetch(database_family) assert_instance_of expected_class, adapter end @@ -35,6 +32,35 @@ class DatabaseAdapterTest < ActiveSupport::TestCase assert_equal "FOR UPDATE SKIP LOCKED", adapter.claim_lock end + # Trilogy speaks the MySQL protocol but reports "Trilogy", so a pattern that + # only knows the gem name rejects a database that is fully supported. + test "selects the MySQL coordination adapter for Trilogy" do + adapter = SolidObjects::DatabaseAdapter.for(Connection.new(adapter_name: "Trilogy")) + + assert_instance_of SolidObjects::DatabaseAdapters::Mysql, adapter + assert adapter.supports_skip_locked? + assert_equal "FOR UPDATE SKIP LOCKED", adapter.claim_lock + end + + test "names the family behind every MySQL client" do + %w[Mysql2 Trilogy].each do |name| + assert_equal :mysql, + SolidObjects::DatabaseAdapter.family(Connection.new(adapter_name: name)), + "#{name} speaks the MySQL protocol" + end + end + + test "names the family for PostgreSQL and SQLite" do + assert_equal :postgresql, + SolidObjects::DatabaseAdapter.family(Connection.new(adapter_name: "PostgreSQL")) + assert_equal :sqlite, + SolidObjects::DatabaseAdapter.family(Connection.new(adapter_name: "SQLite")) + end + + test "names no family for an unsupported adapter" do + assert_nil SolidObjects::DatabaseAdapter.family(Connection.new(adapter_name: "Oracle")) + end + test "rejects unsupported databases" do assert_raises(SolidObjects::UnsupportedDatabase) do SolidObjects::DatabaseAdapter.for(Connection.new(adapter_name: "Oracle")) diff --git a/test/unit/deadline_error_test.rb b/test/unit/deadline_error_test.rb new file mode 100644 index 0000000..983dcca --- /dev/null +++ b/test/unit/deadline_error_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "database_test_helper" + +# A synchronous deadline is enforced by asking the database to interrupt the +# statement, so recognising that interruption is what turns it back into a +# SyncEnqueueTimeout for the caller. Each client reports it differently, and a +# client whose report is not recognised surfaces a raw database error instead. +class DeadlineErrorTest < ActiveSupport::TestCase + MAX_EXECUTION_TIME_EXCEEDED = 3024 + + # mysql2 exposes `error_number`; Trilogy exposes `error_code`. + Mysql2Error = Class.new(StandardError) do + def error_number = MAX_EXECUTION_TIME_EXCEEDED + end + + TrilogyError = Class.new(StandardError) do + def error_code = MAX_EXECUTION_TIME_EXCEEDED + end + + setup do + @adapter = SolidObjects::DatabaseAdapters::Mysql.new(SolidObjects::Record.connection) + end + + test "recognises the interruption reported by mysql2" do + with_deadline do + assert @adapter.send(:deadline_error?, wrapped(Mysql2Error.new("interrupted"))) + end + end + + test "recognises the interruption reported by Trilogy" do + with_deadline do + assert @adapter.send(:deadline_error?, wrapped(TrilogyError.new("interrupted"))), + "a client that names the code error_code must still be recognised" + end + end + + test "recognises a statement timeout whatever the client called it" do + with_deadline do + assert @adapter.send(:deadline_error?, ActiveRecord::StatementTimeout.new("timeout")) + end + end + + test "recognises a lock wait timeout" do + with_deadline do + assert @adapter.send(:deadline_error?, ActiveRecord::LockWaitTimeout.new("waited")) + end + end + + test "leaves an unrelated database error alone" do + unrelated = Class.new(StandardError) do + def error_code = 1146 + end + + with_deadline do + refute @adapter.send(:deadline_error?, wrapped(unrelated.new("no such table"))) + end + end + + test "claims nothing outside a synchronous deadline" do + refute @adapter.send(:deadline_error?, ActiveRecord::StatementTimeout.new("timeout")) + end + + private + + # Active Record wraps the client error, so the code is only reachable through + # the cause chain. + def wrapped(client_error) + raise_wrapped(client_error) + rescue ActiveRecord::StatementInvalid => wrapper + wrapper + end + + def raise_wrapped(client_error) + raise client_error + rescue + raise ActiveRecord::StatementInvalid, "wrapped" + end + + def with_deadline(&block) + SolidObjects::SyncDeadline.with(timeout: 5, &block) + end +end