Skip to content

Commit efbe93a

Browse files
authored
Merge pull request #28 from cardmagic/agent/trilogy-adapter
fix: select the MySQL adapter for Trilogy
2 parents 8448267 + 056c565 commit efbe93a

26 files changed

Lines changed: 342 additions & 37 deletions

.github/workflows/ci.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ jobs:
9494

9595
mysql:
9696
runs-on: ubuntu-latest
97+
# Both clients speak MySQL but report different adapter names, negotiate
98+
# different connection collations, and name the same error code
99+
# differently, so passing on one proves nothing about the other.
100+
strategy:
101+
fail-fast: false
102+
matrix:
103+
client: [ mysql2, trilogy ]
97104
services:
98105
mysql:
99106
image: mysql:8.4
@@ -110,7 +117,7 @@ jobs:
110117
--health-timeout 5s
111118
--health-retries 20
112119
env:
113-
SOLID_OBJECTS_DATABASE_URL: mysql2://solid_objects:solid_objects@127.0.0.1:3306/solid_objects_test
120+
SOLID_OBJECTS_DATABASE_URL: ${{ matrix.client }}://solid_objects:solid_objects@127.0.0.1:3306/solid_objects_test
114121
steps:
115122
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
116123
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
# Changelog
22

3+
## 0.10.1 - 2026-08-10
4+
5+
- Support Trilogy. Adapter selection matched the client name rather than the
6+
protocol, and Trilogy reports `"Trilogy"`, so every Solid Objects call raised
7+
`UnsupportedDatabase: unsupported database adapter "Trilogy"` on a database
8+
the gem fully supports. Adapter names now resolve through one table of
9+
families, `DatabaseAdapter.family`, used by adapter selection, owner-id
10+
casting, and wake-up adapter selection alike, so a client cannot be accepted
11+
in one place and rejected in another.
12+
- Compare reconciliation owner ids in the column's own collation.
13+
`Instance.orphaned` cast owner primary keys to `CHAR`, and a cast result
14+
carries the connection collation rather than the column's. MySQL refuses to
15+
compare two collations, so the query raised `Illegal mix of collations`
16+
whenever the two differed. That is a property of the client rather than the
17+
schema: mysql2 negotiates the database default while Trilogy negotiates
18+
`utf8mb4_general_ci`. A mysql2 application that set `collation:` in
19+
`database.yml` could already hit this.
20+
- Recognise a statement interruption from any MySQL client. A synchronous
21+
deadline is enforced by asking the server to interrupt the statement, and the
22+
interruption was matched only through mysql2's `error_number`. Trilogy names
23+
it `error_code`, so a deadline surfaced as a raw
24+
`ActiveRecord::StatementTimeout` instead of `SyncEnqueueTimeout`. Both names
25+
are read, and Active Record's own classification is trusted first.
26+
- Run the MySQL suite against both mysql2 and Trilogy in CI, and key
27+
adapter-specific test skips to the database family rather than the client
28+
name, so a Trilogy run no longer silently skips every MySQL test.
29+
330
## 0.10.0 - 2026-08-10
431

532
- Report a denied CLI command as a policy decision rather than a crash.

Gemfile.lock

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.10.0)
4+
solid_objects (0.10.1)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -253,6 +253,8 @@ GEM
253253
unicode-display_width (>= 1.1.1, < 4)
254254
thor (1.5.0)
255255
timeout (0.6.1)
256+
trilogy (2.12.6)
257+
bigdecimal
256258
tsort (0.2.0)
257259
tzinfo (2.0.6)
258260
concurrent-ruby (~> 1.0)
@@ -292,6 +294,7 @@ DEPENDENCIES
292294
sqlite3 (>= 2.1)
293295
standard
294296
steep
297+
trilogy (>= 2.7)
295298

296299
CHECKSUMS
297300
actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b
@@ -380,7 +383,7 @@ CHECKSUMS
380383
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
381384
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
382385
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
383-
solid_objects (0.10.0)
386+
solid_objects (0.10.1)
384387
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
385388
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
386389
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
@@ -397,6 +400,7 @@ CHECKSUMS
397400
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
398401
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
399402
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
403+
trilogy (2.12.6) sha256=2e0f97092150c92fec41acc304c382ad52f5ea98e1eb11073e4f501f80159cf7
400404
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
401405
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
402406
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -956,7 +956,8 @@ shutdown, retention, and backup guidance.
956956
Solid Objects supports:
957957

958958
- PostgreSQL 14 or newer
959-
- MySQL 8.0 or newer using InnoDB
959+
- MySQL 8.0 or newer using InnoDB, through either the `mysql2` or `trilogy`
960+
client
960961
- SQLite 3.35 or newer
961962

962963
PostgreSQL and MySQL use `FOR UPDATE SKIP LOCKED` when claiming hot-table rows.

app/models/solid_objects/instance.rb

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def orphaned(actor_type:, owner:)
5656
)
5757
owner_ids = owner_relation
5858
.except(:select)
59-
.select(cast_id)
59+
.select(collated(cast_id))
6060

6161
where(actor_type:).where.not(actor_id: owner_ids)
6262
end
@@ -70,12 +70,39 @@ def states_for(actor_type:, actor_ids:)
7070

7171
private
7272

73+
# A cast result carries the connection collation, not the column's, and
74+
# MySQL refuses to compare two collations. Which collation a connection
75+
# uses is a property of the client rather than the schema: mysql2
76+
# negotiates the database default while Trilogy negotiates
77+
# utf8mb4_general_ci, so the comparison is pinned to the column's own.
78+
# @rbs (untyped) -> untyped
79+
def collated(node)
80+
collation = owner_id_collation
81+
return node unless collation
82+
83+
Arel::Nodes::InfixOperation.new(
84+
"COLLATE",
85+
node,
86+
Arel::Nodes::SqlLiteral.new(collation)
87+
)
88+
end
89+
90+
# @rbs () -> String?
91+
def owner_id_collation
92+
return nil unless DatabaseAdapter.family(connection) == :mysql
93+
94+
collation = columns_hash["actor_id"]&.collation
95+
return nil unless collation&.match?(/\A[a-zA-Z0-9_]+\z/)
96+
97+
collation
98+
end
99+
73100
# @rbs () -> String
74101
def owner_id_cast_type
75-
case connection.adapter_name
76-
when /mysql/i
102+
case DatabaseAdapter.family(connection)
103+
when :mysql
77104
"CHAR"
78-
when /postgres/i
105+
when :postgresql
79106
"VARCHAR"
80107
else
81108
"TEXT"

docs/architecture.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ The host application owns:
4141
## Database coordination adapters
4242

4343
Solid Objects supports PostgreSQL 14+, MySQL 8.0+ with InnoDB, and SQLite 3.35+.
44+
MySQL is reached through either the `mysql2` or `trilogy` client. Adapter names
45+
are client names rather than protocol names, so every decision that depends on
46+
the database resolves through `DatabaseAdapter.family` rather than matching an
47+
adapter name in place.
4448

4549
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.
4650

docs/local-testing.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,17 @@ docker run -d --name so-redis -p 6380:6379 redis:7-alpine
4848
SOLID_OBJECTS_DATABASE_URL=mysql2://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
4949
bundle exec rake test
5050

51+
SOLID_OBJECTS_DATABASE_URL=trilogy://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
52+
bundle exec rake test
53+
5154
SOLID_OBJECTS_REDIS_URL=redis://127.0.0.1:6380/15 \
5255
bundle exec rake test TEST=test/integration/redis_wake_up_test.rb
5356
```
5457

58+
Run both MySQL clients. They report different adapter names, negotiate
59+
different connection collations, and name the same error code differently, so a
60+
pass on one says nothing about the other. Recreate the database between them.
61+
5562
Stop them with `docker rm -f so-mysql so-redis`.
5663

5764
## Recreating a database between runs

docs/roadmap.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@
4545
shutdown is requested, and dead process records plus expired message and
4646
process history are pruned on their own intervals without an application
4747
scheduling its own job
48-
- SQLite, PostgreSQL, and MySQL integration suites
48+
- SQLite, PostgreSQL, and MySQL integration suites, with MySQL run against both
49+
the `mysql2` and `trilogy` clients because an adapter name, a connection
50+
collation, and an error code name all differ between them
4951
- Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
5052
a listening connection per waiting thread and release on supervisor shutdown
5153
- Opt-in cross-process wake-up on Redis, the option for MySQL applications,

lib/solid_objects.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
require "solid_objects/actor_view"
4747
require "solid_objects/actor_channel"
4848
require "solid_objects/action_cable_broadcast_adapter"
49+
require "solid_objects/database_adapter"
4950
require "solid_objects/wake_up"
5051
require "solid_objects/wake_up_adapters/postgresql"
5152
require "solid_objects/wake_up_adapters/redis"

lib/solid_objects/database_adapter.rb

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,35 @@ class DatabaseAdapter
77
TRANSACTION_CLOCK = :solid_objects_transaction_clock
88
TRANSACTION_CLOCK_SCOPE = :solid_objects_transaction_clock_scope
99

10+
# An adapter name is a client name, not a protocol name. Trilogy reports
11+
# "Trilogy" while speaking MySQL, so a pattern that only knows the mysql2
12+
# gem rejects a database Solid Objects fully supports. Every decision that
13+
# depends on the database goes through this one table, so a client cannot
14+
# be accepted in one place and rejected in another.
15+
FAMILIES = {
16+
postgresql: /postgres/i,
17+
mysql: /mysql|trilogy/i,
18+
sqlite: /sqlite/i
19+
}.freeze
20+
1021
class << self
22+
# @rbs (untyped) -> Symbol?
23+
def family(connection)
24+
adapter_name = connection.adapter_name
25+
FAMILIES.each do |family, pattern|
26+
return family if adapter_name.match?(pattern)
27+
end
28+
nil
29+
end
30+
1131
# @rbs (untyped) -> DatabaseAdapter
1232
def for(connection)
13-
case connection.adapter_name
14-
when /postgres/i
33+
case family(connection)
34+
when :postgresql
1535
DatabaseAdapters::Postgresql.new(connection)
16-
when /mysql/i
36+
when :mysql
1737
DatabaseAdapters::Mysql.new(connection)
18-
when /sqlite/i
38+
when :sqlite
1939
DatabaseAdapters::Sqlite.new(connection)
2040
else
2141
raise UnsupportedDatabase, "unsupported database adapter #{connection.adapter_name.inspect}"

0 commit comments

Comments
 (0)