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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
8 changes: 6 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -292,6 +294,7 @@ DEPENDENCIES
sqlite3 (>= 2.1)
standard
steep
trilogy (>= 2.7)

CHECKSUMS
actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 31 additions & 4 deletions app/models/solid_objects/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions docs/local-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/solid_objects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 24 additions & 4 deletions lib/solid_objects/database_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
18 changes: 17 additions & 1 deletion lib/solid_objects/database_adapters/mysql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
module SolidObjects
module DatabaseAdapters
class Mysql < DatabaseAdapter
MAXIMUM_EXECUTION_TIME_EXCEEDED = 3024

# @rbs () -> bool
def supports_skip_locked?
true
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/solid_objects/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# rbs_inline: enabled

module SolidObjects
VERSION = "0.10.0"
VERSION = "0.10.1"
end
2 changes: 1 addition & 1 deletion lib/solid_objects/wake_up_adapters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions sig/generated/lib/solid_objects/database_adapter.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions sig/generated/lib/solid_objects/database_adapters/mysql.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
module SolidObjects
module DatabaseAdapters
class Mysql < DatabaseAdapter
MAXIMUM_EXECUTION_TIME_EXCEEDED: ::Integer

# @rbs () -> bool
def supports_skip_locked?: () -> bool

Expand All @@ -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
11 changes: 11 additions & 0 deletions sig/generated/models/solid_objects/instance.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions solid_objects.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions test/database_test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading