solid_objects 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 935a2d2bb12fbc5b39121fe44004d0cc0671c3bef82012cf9b024089c3546ac9
4
- data.tar.gz: 15de97ea8547bfb552ae848b2ae523fdd6c48a557313ba3fd87d931ee770f9b1
3
+ metadata.gz: 28a727648d2afc1ed0cd5be03fd624617db444bf173b289327d8c69e2a4abf80
4
+ data.tar.gz: 0f3bb956b7dcda6f993c92d3ce7f24a3ecf93caf0435030fe2e44955eb581c22
5
5
  SHA512:
6
- metadata.gz: 6633846db212684a9e6d687f76e559cd51448171bd9e067534024e9a067534f1ff7eab223e3c1ab80b7a02765b909a324f2dbb9b1f527e5846d933de5b3639c7
7
- data.tar.gz: ce930198b48379439c4947dd41ea5d3a0577ad8ce49eb1513715075ccf23c03b23237bcee77f06a898ea5460b83e3bf6b0c8a54f7b9c16e03c159c2572a03306
6
+ metadata.gz: 6d5ffbb3074eff11b32966560a2ca42dca37dc7c8c9cf1725a9965a40815752379be88f18b6b0b184592931d180f7248d6c01e5344be2b79a29cdde5377ab23f
7
+ data.tar.gz: ca700bcc8d86a6939c462be3003b0e649c6a2611289b12064dda9d0f6e3f844cc332e479b355b248e0a97c9073e537b1b99a8ae0cfd7ac454b170bd03bbd9a71
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
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
+
3
30
  ## 0.10.0 - 2026-08-10
4
31
 
5
32
  - Report a denied CLI command as a policy decision rather than a crash.
data/README.md CHANGED
@@ -956,7 +956,8 @@ shutdown, retention, and backup guidance.
956
956
  Solid Objects supports:
957
957
 
958
958
  - 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
960
961
  - SQLite 3.35 or newer
961
962
 
962
963
  PostgreSQL and MySQL use `FOR UPDATE SKIP LOCKED` when claiming hot-table rows.
@@ -56,7 +56,7 @@ module SolidObjects
56
56
  )
57
57
  owner_ids = owner_relation
58
58
  .except(:select)
59
- .select(cast_id)
59
+ .select(collated(cast_id))
60
60
 
61
61
  where(actor_type:).where.not(actor_id: owner_ids)
62
62
  end
@@ -70,12 +70,39 @@ module SolidObjects
70
70
 
71
71
  private
72
72
 
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
+
73
100
  # @rbs () -> String
74
101
  def owner_id_cast_type
75
- case connection.adapter_name
76
- when /mysql/i
102
+ case DatabaseAdapter.family(connection)
103
+ when :mysql
77
104
  "CHAR"
78
- when /postgres/i
105
+ when :postgresql
79
106
  "VARCHAR"
80
107
  else
81
108
  "TEXT"
data/docs/architecture.md CHANGED
@@ -41,6 +41,10 @@ The host application owns:
41
41
  ## Database coordination adapters
42
42
 
43
43
  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.
44
48
 
45
49
  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.
46
50
 
@@ -48,10 +48,17 @@ docker run -d --name so-redis -p 6380:6379 redis:7-alpine
48
48
  SOLID_OBJECTS_DATABASE_URL=mysql2://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
49
49
  bundle exec rake test
50
50
 
51
+ SOLID_OBJECTS_DATABASE_URL=trilogy://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
52
+ bundle exec rake test
53
+
51
54
  SOLID_OBJECTS_REDIS_URL=redis://127.0.0.1:6380/15 \
52
55
  bundle exec rake test TEST=test/integration/redis_wake_up_test.rb
53
56
  ```
54
57
 
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
+
55
62
  Stop them with `docker rm -f so-mysql so-redis`.
56
63
 
57
64
  ## Recreating a database between runs
data/docs/roadmap.md CHANGED
@@ -45,7 +45,9 @@
45
45
  shutdown is requested, and dead process records plus expired message and
46
46
  process history are pruned on their own intervals without an application
47
47
  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
49
51
  - Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
50
52
  a listening connection per waiting thread and release on supervisor shutdown
51
53
  - Opt-in cross-process wake-up on Redis, the option for MySQL applications,
@@ -7,15 +7,35 @@ module SolidObjects
7
7
  TRANSACTION_CLOCK = :solid_objects_transaction_clock
8
8
  TRANSACTION_CLOCK_SCOPE = :solid_objects_transaction_clock_scope
9
9
 
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
+
10
21
  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
+
11
31
  # @rbs (untyped) -> DatabaseAdapter
12
32
  def for(connection)
13
- case connection.adapter_name
14
- when /postgres/i
33
+ case family(connection)
34
+ when :postgresql
15
35
  DatabaseAdapters::Postgresql.new(connection)
16
- when /mysql/i
36
+ when :mysql
17
37
  DatabaseAdapters::Mysql.new(connection)
18
- when /sqlite/i
38
+ when :sqlite
19
39
  DatabaseAdapters::Sqlite.new(connection)
20
40
  else
21
41
  raise UnsupportedDatabase, "unsupported database adapter #{connection.adapter_name.inspect}"
@@ -3,6 +3,8 @@
3
3
  module SolidObjects
4
4
  module DatabaseAdapters
5
5
  class Mysql < DatabaseAdapter
6
+ MAXIMUM_EXECUTION_TIME_EXCEEDED = 3024
7
+
6
8
  # @rbs () -> bool
7
9
  def supports_skip_locked?
8
10
  true
@@ -78,19 +80,33 @@ module SolidObjects
78
80
  end
79
81
  end
80
82
 
83
+ # A deadline is enforced by asking the server to interrupt the statement,
84
+ # so recognising that interruption is what turns it back into a timeout
85
+ # the caller asked for. Active Record classifies it for every client, and
86
+ # the raw code is the fallback: mysql2 names it `error_number` and
87
+ # Trilogy names it `error_code`, so both are read.
81
88
  # @rbs (Exception) -> bool
82
89
  def deadline_error?(error)
83
90
  return false unless SyncDeadline.active?
84
91
  return true if error.is_a?(ActiveRecord::LockWaitTimeout)
92
+ return true if error.is_a?(ActiveRecord::StatementTimeout)
85
93
 
86
94
  cause = error
87
95
  while cause
88
- return true if cause.respond_to?(:error_number) && cause.error_number == 3024
96
+ return true if error_code(cause) == MAXIMUM_EXECUTION_TIME_EXCEEDED
89
97
 
90
98
  cause = cause.cause
91
99
  end
92
100
  false
93
101
  end
102
+
103
+ # @rbs (Exception) -> Integer?
104
+ def error_code(error)
105
+ return error.error_number if error.respond_to?(:error_number)
106
+ return error.error_code if error.respond_to?(:error_code)
107
+
108
+ nil
109
+ end
94
110
  end
95
111
  end
96
112
  end
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.10.0"
4
+ VERSION = "0.10.1"
5
5
  end
@@ -15,7 +15,7 @@ module SolidObjects
15
15
  #
16
16
  # @rbs (?untyped) -> untyped
17
17
  def for(connection = Record.connection)
18
- return Postgresql.new if connection.adapter_name.match?(/postgres/i)
18
+ return Postgresql.new if DatabaseAdapter.family(connection) == :postgresql
19
19
 
20
20
  WakeUp.new
21
21
  end
data/lib/solid_objects.rb CHANGED
@@ -46,6 +46,7 @@ require "solid_objects/state_snapshot"
46
46
  require "solid_objects/actor_view"
47
47
  require "solid_objects/actor_channel"
48
48
  require "solid_objects/action_cable_broadcast_adapter"
49
+ require "solid_objects/database_adapter"
49
50
  require "solid_objects/wake_up"
50
51
  require "solid_objects/wake_up_adapters/postgresql"
51
52
  require "solid_objects/wake_up_adapters/redis"
@@ -6,6 +6,16 @@ module SolidObjects
6
6
 
7
7
  TRANSACTION_CLOCK_SCOPE: ::Symbol
8
8
 
9
+ # An adapter name is a client name, not a protocol name. Trilogy reports
10
+ # "Trilogy" while speaking MySQL, so a pattern that only knows the mysql2
11
+ # gem rejects a database Solid Objects fully supports. Every decision that
12
+ # depends on the database goes through this one table, so a client cannot
13
+ # be accepted in one place and rejected in another.
14
+ FAMILIES: untyped
15
+
16
+ # @rbs (untyped) -> Symbol?
17
+ def self.family: (untyped) -> Symbol?
18
+
9
19
  # @rbs (untyped) -> DatabaseAdapter
10
20
  def self.for: (untyped) -> DatabaseAdapter
11
21
 
@@ -3,6 +3,8 @@
3
3
  module SolidObjects
4
4
  module DatabaseAdapters
5
5
  class Mysql < DatabaseAdapter
6
+ MAXIMUM_EXECUTION_TIME_EXCEEDED: ::Integer
7
+
6
8
  # @rbs () -> bool
7
9
  def supports_skip_locked?: () -> bool
8
10
 
@@ -28,8 +30,16 @@ module SolidObjects
28
30
  # @rbs (untyped) { () -> untyped } -> untyped
29
31
  def with_transaction_deadline: (untyped) { () -> untyped } -> untyped
30
32
 
33
+ # A deadline is enforced by asking the server to interrupt the statement,
34
+ # so recognising that interruption is what turns it back into a timeout
35
+ # the caller asked for. Active Record classifies it for every client, and
36
+ # the raw code is the fallback: mysql2 names it `error_number` and
37
+ # Trilogy names it `error_code`, so both are read.
31
38
  # @rbs (Exception) -> bool
32
39
  def deadline_error?: (Exception) -> bool
40
+
41
+ # @rbs (Exception) -> Integer?
42
+ def error_code: (Exception) -> Integer?
33
43
  end
34
44
  end
35
45
  end
@@ -14,6 +14,17 @@ module SolidObjects
14
14
  # @rbs (actor_type: String, actor_ids: Array[String]) -> Hash[String, Hash[String, untyped]]
15
15
  def self.states_for: (actor_type: String, actor_ids: Array[String]) -> Hash[String, Hash[String, untyped]]
16
16
 
17
+ # A cast result carries the connection collation, not the column's, and
18
+ # MySQL refuses to compare two collations. Which collation a connection
19
+ # uses is a property of the client rather than the schema: mysql2
20
+ # negotiates the database default while Trilogy negotiates
21
+ # utf8mb4_general_ci, so the comparison is pinned to the column's own.
22
+ # @rbs (untyped) -> untyped
23
+ private def self.collated: (untyped) -> untyped
24
+
25
+ # @rbs () -> String?
26
+ private def self.owner_id_collation: () -> String?
27
+
17
28
  # @rbs () -> String
18
29
  private def self.owner_id_cast_type: () -> String
19
30
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
@@ -248,6 +248,20 @@ dependencies:
248
248
  - - ">="
249
249
  - !ruby/object:Gem::Version
250
250
  version: '0'
251
+ - !ruby/object:Gem::Dependency
252
+ name: trilogy
253
+ requirement: !ruby/object:Gem::Requirement
254
+ requirements:
255
+ - - ">="
256
+ - !ruby/object:Gem::Version
257
+ version: '2.7'
258
+ type: :development
259
+ prerelease: false
260
+ version_requirements: !ruby/object:Gem::Requirement
261
+ requirements:
262
+ - - ">="
263
+ - !ruby/object:Gem::Version
264
+ version: '2.7'
251
265
  description: 'The Cloudflare Durable Objects programming model for Rails: addressable
252
266
  objects with durable state, ordered mailboxes, fenced activation, per-object alarms,
253
267
  transactional effects, and reactive ERB. It runs on MySQL, PostgreSQL, and SQLite