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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +2 -1
- data/app/models/solid_objects/instance.rb +31 -4
- data/docs/architecture.md +4 -0
- data/docs/local-testing.md +7 -0
- data/docs/roadmap.md +3 -1
- data/lib/solid_objects/database_adapter.rb +24 -4
- data/lib/solid_objects/database_adapters/mysql.rb +17 -1
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up_adapters.rb +1 -1
- data/lib/solid_objects.rb +1 -0
- data/sig/generated/lib/solid_objects/database_adapter.rbs +10 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +10 -0
- data/sig/generated/models/solid_objects/instance.rbs +11 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28a727648d2afc1ed0cd5be03fd624617db444bf173b289327d8c69e2a4abf80
|
|
4
|
+
data.tar.gz: 0f3bb956b7dcda6f993c92d3ce7f24a3ecf93caf0435030fe2e44955eb581c22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
76
|
-
when
|
|
102
|
+
case DatabaseAdapter.family(connection)
|
|
103
|
+
when :mysql
|
|
77
104
|
"CHAR"
|
|
78
|
-
when
|
|
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
|
|
data/docs/local-testing.md
CHANGED
|
@@ -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
|
|
14
|
-
when
|
|
33
|
+
case family(connection)
|
|
34
|
+
when :postgresql
|
|
15
35
|
DatabaseAdapters::Postgresql.new(connection)
|
|
16
|
-
when
|
|
36
|
+
when :mysql
|
|
17
37
|
DatabaseAdapters::Mysql.new(connection)
|
|
18
|
-
when
|
|
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
|
|
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
|
|
@@ -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
|
|
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.
|
|
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
|