solid_objects 0.8.0 → 0.9.0
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 +21 -0
- data/docs/local-testing.md +91 -0
- data/docs/realtime.md +15 -2
- data/docs/roadmap.md +19 -18
- data/lib/solid_objects/database_adapter.rb +37 -0
- data/lib/solid_objects/database_adapters/mysql.rb +28 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +15 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +5 -0
- data/lib/solid_objects/doctor.rb +16 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up_adapters/redis.rb +183 -0
- data/lib/solid_objects.rb +1 -0
- data/sig/generated/lib/solid_objects/database_adapter.rbs +17 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +11 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +96 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7fee6db10af36996dbe9c8484fab405b53e2463a3fd7d2f7414310fedab68d5
|
|
4
|
+
data.tar.gz: 8b71bb8fbf0dc0359ce8a00097428dbbcd189e4d2b4071ba0339b9c3ec1cd933
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 62f2ce7ecfa4899f82af3682c5a8fb97d7005e6ba9152a8a63433b2ec345d2edc3d795c29a7f6de24d25f275afd4857986ed34302359d0f3aab018f936656acd
|
|
7
|
+
data.tar.gz: 2114e1d8b7757bf598562fba6e1b8ecafeea852d6925a5161f74b1dcab6dab9b5750ca71327b0cfa23b927869cbeee348f1bd6d03cbfc5d964d2bfd53a625435
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 - 2026-08-10
|
|
4
|
+
|
|
5
|
+
- Add a browser test suite running the refresh modules against real Chromium and
|
|
6
|
+
a real Turbo build, covering `component_refresh.js`, which previously had no
|
|
7
|
+
tests at all. Every batching defect that reached production passed the jsdom
|
|
8
|
+
suite, because jsdom cannot model Turbo applying a morph, task boundaries
|
|
9
|
+
between socket deliveries, or abort semantics.
|
|
10
|
+
- Verify the database server. Each adapter reports its version against the
|
|
11
|
+
oldest one Solid Objects is exercised against, PostgreSQL 13, MySQL 8.0, and
|
|
12
|
+
SQLite 3.35, and MySQL additionally confirms that Solid Objects tables use
|
|
13
|
+
InnoDB, since a non-transactional engine would silently break fenced commits.
|
|
14
|
+
The doctor reports this as `database_server` and warns rather than failing:
|
|
15
|
+
refusing to run on an untested server would be a worse failure than running
|
|
16
|
+
on one.
|
|
17
|
+
- Add `SolidObjects::WakeUpAdapters::Redis`, an optional cross-process wake-up
|
|
18
|
+
using Redis publish and subscribe. This is the option for MySQL, which has no
|
|
19
|
+
notification primitive. Measured cross-process wake-up latency drops from
|
|
20
|
+
103.8 ms to 5.7 ms at p50. One background subscription per process fans out to
|
|
21
|
+
every waiting role in memory. The `redis` gem is not a dependency of this gem,
|
|
22
|
+
and `WakeUpAdapters.for` does not select it, so adopting Redis stays explicit.
|
|
23
|
+
|
|
3
24
|
## 0.8.0 - 2026-08-10
|
|
4
25
|
|
|
5
26
|
- Replace a supervised role whose thread died. A role that raised left its
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Local testing
|
|
2
|
+
|
|
3
|
+
The default suite runs against SQLite and needs nothing extra:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bundle exec rake
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Everything below is optional. Each adapter and each optional service skips its
|
|
10
|
+
tests when the service is absent, so a missing container degrades coverage
|
|
11
|
+
rather than breaking the run. That is convenient, and it is also a trap: a
|
|
12
|
+
skipped test looks identical to a passing one in the summary line. Check the
|
|
13
|
+
skip count when a change touches an adapter.
|
|
14
|
+
|
|
15
|
+
## PostgreSQL
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
brew services start postgresql@17
|
|
19
|
+
createuser -h 127.0.0.1 -s solid_objects
|
|
20
|
+
psql -h 127.0.0.1 -d postgres -c "ALTER USER solid_objects WITH PASSWORD 'solid_objects';"
|
|
21
|
+
createdb -h 127.0.0.1 -O solid_objects solid_objects_test
|
|
22
|
+
|
|
23
|
+
SOLID_OBJECTS_DATABASE_URL=postgresql://solid_objects:solid_objects@127.0.0.1:5432/solid_objects_test \
|
|
24
|
+
bundle exec rake test
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Running this locally is worth the setup: it is what caught the PostgreSQL
|
|
28
|
+
version comparison reading a packed integer, where `170010` compared greater
|
|
29
|
+
than any minimum and made the check useless on the adapter it mattered most for.
|
|
30
|
+
|
|
31
|
+
## MySQL and Redis in Docker
|
|
32
|
+
|
|
33
|
+
These use non-default ports so they cannot collide with a MySQL or Redis that
|
|
34
|
+
another project is already running:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
docker run -d --name so-mysql -p 3307:3306 \
|
|
38
|
+
-e MYSQL_ROOT_PASSWORD=solid_objects \
|
|
39
|
+
-e MYSQL_DATABASE=solid_objects_test \
|
|
40
|
+
-e MYSQL_USER=solid_objects \
|
|
41
|
+
-e MYSQL_PASSWORD=solid_objects \
|
|
42
|
+
mysql:8
|
|
43
|
+
|
|
44
|
+
docker run -d --name so-redis -p 6380:6379 redis:7-alpine
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
SOLID_OBJECTS_DATABASE_URL=mysql2://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
|
|
49
|
+
bundle exec rake test
|
|
50
|
+
|
|
51
|
+
SOLID_OBJECTS_REDIS_URL=redis://127.0.0.1:6380/15 \
|
|
52
|
+
bundle exec rake test TEST=test/integration/redis_wake_up_test.rb
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Stop them with `docker rm -f so-mysql so-redis`.
|
|
56
|
+
|
|
57
|
+
## Recreating a database between runs
|
|
58
|
+
|
|
59
|
+
The test helper migrates unconditionally, so a second run against a database
|
|
60
|
+
that already has the tables fails with a duplicate-table error rather than a
|
|
61
|
+
test failure. Recreate first:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
dropdb -h 127.0.0.1 solid_objects_test && createdb -h 127.0.0.1 -O solid_objects solid_objects_test
|
|
65
|
+
|
|
66
|
+
docker exec so-mysql mysql -u root -psolid_objects \
|
|
67
|
+
-e "DROP DATABASE IF EXISTS solid_objects_test; CREATE DATABASE solid_objects_test;
|
|
68
|
+
GRANT ALL ON solid_objects_test.* TO 'solid_objects'@'%';"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Rails and Ruby span
|
|
72
|
+
|
|
73
|
+
`RAILS_VERSION` pins the Rails line the gemspec advertises:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
RAILS_VERSION=8.0 bundle install
|
|
77
|
+
RAILS_VERSION=8.0 bundle exec rake test
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Browser modules
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm install
|
|
84
|
+
npm test # jsdom, fast
|
|
85
|
+
npx playwright install chromium
|
|
86
|
+
npm run test:browser # real Chromium and a real Turbo build
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The jsdom suite covers logic; the browser suite covers integration with Turbo.
|
|
90
|
+
Both matter: every batching defect that reached production passed the jsdom
|
|
91
|
+
suite alone.
|
data/docs/realtime.md
CHANGED
|
@@ -128,8 +128,21 @@ configuration.wake_up_adapter = SolidObjects::WakeUpAdapters.for
|
|
|
128
128
|
default on SQLite and MySQL, so the same line is safe across adapters. Name
|
|
129
129
|
`SolidObjects::WakeUpAdapters::Postgresql.new` directly to require it.
|
|
130
130
|
|
|
131
|
-
MySQL has no notification primitive
|
|
132
|
-
`polling_interval
|
|
131
|
+
MySQL has no notification primitive. MySQL applications either keep polling and
|
|
132
|
+
tune `polling_interval`, or configure the Redis adapter:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
configuration.wake_up_adapter = SolidObjects::WakeUpAdapters::Redis.new(
|
|
136
|
+
url: ENV["REDIS_URL"]
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Measured latency for a cross-process wake-up drops from 103.8 ms to 5.7 ms at
|
|
141
|
+
p50. The `redis` gem is not a dependency of this gem, so applications add it
|
|
142
|
+
themselves. One background subscription per process fans out to every waiting
|
|
143
|
+
role in memory, rather than one connection per thread, and `WakeUpAdapters.for`
|
|
144
|
+
does not select it: Redis is infrastructure this gem otherwise does not require,
|
|
145
|
+
so choosing it is explicit.
|
|
133
146
|
|
|
134
147
|
Measured latency for a cross-process wake-up drops from 103.7 ms to 2.9 ms at
|
|
135
148
|
p50. The adapter keeps `polling_interval` as the upper bound: a missed or failed
|
data/docs/roadmap.md
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
- Reconciliation read APIs
|
|
25
25
|
- Installation doctor, authorization reference, fit guide, and legacy-state
|
|
26
26
|
migration cookbook
|
|
27
|
+
- Database server verification: each adapter reports its version against a
|
|
28
|
+
tested minimum, MySQL confirms Solid Objects tables use InnoDB, and the
|
|
29
|
+
doctor warns rather than refusing to run on an untested server
|
|
27
30
|
- Handler Active Record write isolation, same-database commit actions, ambient
|
|
28
31
|
transaction rejection, adapter lock/query deadlines, bounded SQLite lock
|
|
29
32
|
retries outside those deadlines, structured sync timeout diagnostics, and
|
|
@@ -36,14 +39,18 @@
|
|
|
36
39
|
- SQLite, PostgreSQL, and MySQL integration suites
|
|
37
40
|
- Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
|
|
38
41
|
a listening connection per waiting thread and release on supervisor shutdown
|
|
42
|
+
- Opt-in cross-process wake-up on Redis, the option for MySQL applications,
|
|
43
|
+
measured at 103.8 ms to 5.7 ms at p50; the `redis` gem stays outside this
|
|
44
|
+
gem's dependencies
|
|
39
45
|
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
|
|
40
46
|
RuboCop policy, and a warning-free Brakeman scan
|
|
41
47
|
- Compatibility CI across the supported span: Ruby 3.3 and 3.4 against Rails 8.0
|
|
42
48
|
and 8.1, pinned through `RAILS_VERSION` so the advertised range is verified
|
|
43
49
|
rather than assumed
|
|
44
|
-
- A JavaScript suite covering
|
|
45
|
-
|
|
46
|
-
Actions reference pinned to
|
|
50
|
+
- A JavaScript suite covering every browser module, run in CI with Node's test
|
|
51
|
+
runner and jsdom, plus a browser suite running the same modules against real
|
|
52
|
+
Chromium and a real Turbo build, with every GitHub Actions reference pinned to
|
|
53
|
+
a commit SHA
|
|
47
54
|
|
|
48
55
|
## Partially implemented
|
|
49
56
|
|
|
@@ -56,7 +63,8 @@
|
|
|
56
63
|
103.7 ms to 2.9 ms at p50. It is opt-in rather than automatic: it opens a
|
|
57
64
|
connection per waiting thread outside the pool, and `LISTEN` does not survive
|
|
58
65
|
a transaction-pooling proxy such as PgBouncer. MySQL has no notification
|
|
59
|
-
primitive, so MySQL applications keep polling
|
|
66
|
+
primitive, so MySQL applications keep polling unless they configure the Redis
|
|
67
|
+
adapter.
|
|
60
68
|
- Realtime: scalar and dependency-driven keyed ERB component replacement or
|
|
61
69
|
morphing, personalized refresh authorization, revision fencing, coalescing,
|
|
62
70
|
reconnect convergence, batched refreshes, and personalized state payloads are
|
|
@@ -68,26 +76,19 @@
|
|
|
68
76
|
distributed per-actor rate limits and global admission control do not.
|
|
69
77
|
- Administration: actor and dead-letter views plus policy hooks exist; richer
|
|
70
78
|
filtering, audit records, and bulk-safe tools do not.
|
|
71
|
-
- Browser module coverage: the state payload and batched refresh modules have
|
|
72
|
-
JavaScript tests; `component_refresh.js`, which drives individual morph
|
|
73
|
-
refreshes, does not.
|
|
74
79
|
- Outboxes use portable status rows with polling indexes; future versions may
|
|
75
80
|
introduce narrow ready/claimed membership tables for very large outboxes.
|
|
76
81
|
|
|
77
82
|
## Next milestones
|
|
78
83
|
|
|
79
|
-
1. Add
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
3. Add scheduled retention and stale-process maintenance.
|
|
84
|
-
4. Add database/server-version checks and MySQL InnoDB verification at boot.
|
|
85
|
-
5. Add Turbo append intents and expand reconnect coverage in a full browser.
|
|
86
|
-
6. Add distributed rate limits, global admission hooks, and cache-capacity
|
|
84
|
+
1. Add result lookup by request ID and broader deadlock retry classification.
|
|
85
|
+
2. Add scheduled retention and stale-process maintenance.
|
|
86
|
+
3. Add Turbo append intents and expand reconnect coverage in a full browser.
|
|
87
|
+
4. Add distributed rate limits, global admission hooks, and cache-capacity
|
|
87
88
|
eviction.
|
|
88
|
-
|
|
89
|
-
Ruby
|
|
90
|
-
|
|
89
|
+
5. Expand security scanning. Compatibility CI across supported Rails and Ruby
|
|
90
|
+
versions is implemented; Ruby 4.0 is not yet in the matrix.
|
|
91
|
+
6. Benchmark all workloads under documented hardware/database settings and
|
|
91
92
|
publish adapter-specific adoption measurements. Throughput, synchronous
|
|
92
93
|
latency, query counts, and the three reactive delivery paths are measured on
|
|
93
94
|
SQLite; adapter-specific and end-to-end browser measurements are not.
|
|
@@ -32,6 +32,43 @@ module SolidObjects
|
|
|
32
32
|
@fixed_connection = connection_pool ? nil : connection
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# The oldest server the adapter has been exercised against. Reported rather
|
|
36
|
+
# than enforced: refusing to boot on an untested server would be a worse
|
|
37
|
+
# failure than running on one.
|
|
38
|
+
# @rbs () -> Gem::Version?
|
|
39
|
+
def minimum_server_version
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @rbs () -> Gem::Version
|
|
44
|
+
def server_version
|
|
45
|
+
with_connection do |connection|
|
|
46
|
+
Gem::Version.new(connection.database_version.to_s)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# One observed version decides both the status and the message. Reading it
|
|
51
|
+
# again could let a transient failure replace an already determined result.
|
|
52
|
+
# @rbs (?Gem::Version?) -> Array[String]
|
|
53
|
+
def unsupported_server_reasons(observed = nil)
|
|
54
|
+
observed ||= server_version
|
|
55
|
+
reasons = []
|
|
56
|
+
minimum = minimum_server_version
|
|
57
|
+
if minimum && observed < minimum
|
|
58
|
+
reasons << "#{self.class.name.demodulize} #{observed} is older than " \
|
|
59
|
+
"Solid Objects requires, which is #{minimum}"
|
|
60
|
+
end
|
|
61
|
+
reasons.concat(additional_server_reasons)
|
|
62
|
+
reasons
|
|
63
|
+
rescue => error
|
|
64
|
+
[ "the database server could not be verified: #{error.class}: #{error.message}" ]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @rbs () -> Array[String]
|
|
68
|
+
def additional_server_reasons
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
|
|
35
72
|
# @rbs () -> bool
|
|
36
73
|
def supports_skip_locked?
|
|
37
74
|
false
|
|
@@ -13,6 +13,34 @@ module SolidObjects
|
|
|
13
13
|
"FOR UPDATE SKIP LOCKED"
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# A non-transactional engine would silently break fenced commits, so the
|
|
17
|
+
# storage engine is verified rather than assumed.
|
|
18
|
+
# @rbs () -> Array[String]
|
|
19
|
+
def additional_server_reasons
|
|
20
|
+
tables = non_innodb_tables
|
|
21
|
+
return [] if tables.empty?
|
|
22
|
+
|
|
23
|
+
[ "these Solid Objects tables do not use InnoDB, so their commits are " \
|
|
24
|
+
"not transactional: #{tables.join(", ")}" ]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @rbs () -> Array[String]
|
|
28
|
+
def non_innodb_tables
|
|
29
|
+
names = SolidObjects::Doctor::EXPECTED_COLUMNS.keys.map { |name| SolidObjects.table_name(name) }
|
|
30
|
+
with_connection do |connection|
|
|
31
|
+
connection.select_rows(<<~SQL.squish).filter_map { |table, engine| table if engine != "InnoDB" }
|
|
32
|
+
SELECT table_name, engine FROM information_schema.tables
|
|
33
|
+
WHERE table_schema = DATABASE()
|
|
34
|
+
AND table_name IN (#{names.map { |name| connection.quote(name) }.join(", ")})
|
|
35
|
+
SQL
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @rbs () -> Gem::Version?
|
|
40
|
+
def minimum_server_version
|
|
41
|
+
Gem::Version.new("8.0")
|
|
42
|
+
end
|
|
43
|
+
|
|
16
44
|
# @rbs () -> String
|
|
17
45
|
def current_time_expression
|
|
18
46
|
"CURRENT_TIMESTAMP(6)"
|
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
module DatabaseAdapters
|
|
5
5
|
class Postgresql < DatabaseAdapter
|
|
6
|
+
# @rbs () -> Gem::Version?
|
|
7
|
+
def minimum_server_version
|
|
8
|
+
Gem::Version.new("13")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# PostgreSQL reports a packed integer, 170010 for 17.10, so comparing it
|
|
12
|
+
# directly would make every server look newer than any minimum.
|
|
13
|
+
# @rbs () -> Gem::Version
|
|
14
|
+
def server_version
|
|
15
|
+
packed = with_connection { |connection| connection.database_version.to_i }
|
|
16
|
+
return super unless packed.positive?
|
|
17
|
+
|
|
18
|
+
Gem::Version.new("#{packed / 10_000}.#{packed % 10_000}")
|
|
19
|
+
end
|
|
20
|
+
|
|
6
21
|
# @rbs () -> bool
|
|
7
22
|
def supports_skip_locked?
|
|
8
23
|
true
|
|
@@ -8,6 +8,11 @@ module SolidObjects
|
|
|
8
8
|
LOCK_RETRY_MUTEX = Thread::Mutex.new
|
|
9
9
|
LOCK_RETRY_CONDITION = Thread::ConditionVariable.new
|
|
10
10
|
|
|
11
|
+
# @rbs () -> Gem::Version?
|
|
12
|
+
def minimum_server_version
|
|
13
|
+
Gem::Version.new("3.35")
|
|
14
|
+
end
|
|
15
|
+
|
|
11
16
|
# @rbs () -> String
|
|
12
17
|
def current_time_expression
|
|
13
18
|
"STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
|
data/lib/solid_objects/doctor.rb
CHANGED
|
@@ -111,6 +111,7 @@ module SolidObjects
|
|
|
111
111
|
configuration_check,
|
|
112
112
|
schema_check,
|
|
113
113
|
check_authorization,
|
|
114
|
+
check_database_server,
|
|
114
115
|
schema_check.failed? ? skipped_runtime : check_runtime,
|
|
115
116
|
ready_for_round_trip?(configuration_check, schema_check) ?
|
|
116
117
|
check_sync_round_trip :
|
|
@@ -191,6 +192,21 @@ module SolidObjects
|
|
|
191
192
|
pass(:authorization, "#{allowed.length} of 5 policies allowed a neutral context")
|
|
192
193
|
end
|
|
193
194
|
|
|
195
|
+
# @rbs () -> Check
|
|
196
|
+
def check_database_server
|
|
197
|
+
adapter = SolidObjects.database_adapter
|
|
198
|
+
observed = adapter.server_version
|
|
199
|
+
reasons = adapter.unsupported_server_reasons(observed)
|
|
200
|
+
return warn_check(:database_server, reasons.join("; ")) unless reasons.empty?
|
|
201
|
+
|
|
202
|
+
pass(
|
|
203
|
+
:database_server,
|
|
204
|
+
"#{adapter.class.name.demodulize} #{observed} meets the tested minimum"
|
|
205
|
+
)
|
|
206
|
+
rescue => error
|
|
207
|
+
warn_check(:database_server, "#{error.class}: #{error.message}")
|
|
208
|
+
end
|
|
209
|
+
|
|
194
210
|
# @rbs () -> Check
|
|
195
211
|
def check_runtime
|
|
196
212
|
cutoff = SolidObjects.database_adapter.database_now -
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
module WakeUpAdapters
|
|
7
|
+
# Wakes runtime roles across processes using Redis publish/subscribe.
|
|
8
|
+
#
|
|
9
|
+
# MySQL has no notification primitive, so this is the cross-process option
|
|
10
|
+
# for applications that cannot use PostgreSQL notifications. It is optional
|
|
11
|
+
# in every sense: the `redis` gem is not a dependency of this gem, and the
|
|
12
|
+
# polling interval remains the upper bound, so a missed or failed
|
|
13
|
+
# notification costs latency rather than correctness.
|
|
14
|
+
class Redis
|
|
15
|
+
CHANNEL = "solid_objects_wake_up"
|
|
16
|
+
FAILED_WAIT_INTERVAL = 0.05
|
|
17
|
+
SUBSCRIBE_TIMEOUT = 5.0
|
|
18
|
+
|
|
19
|
+
# @rbs @channel: String
|
|
20
|
+
# @rbs @url: String?
|
|
21
|
+
# @rbs @client: untyped
|
|
22
|
+
# @rbs @mutex: Thread::Mutex
|
|
23
|
+
# @rbs @condition: Thread::ConditionVariable
|
|
24
|
+
# @rbs @subscriber: Thread?
|
|
25
|
+
# @rbs @subscription: untyped
|
|
26
|
+
# @rbs @signalled: Integer
|
|
27
|
+
|
|
28
|
+
attr_reader :channel
|
|
29
|
+
|
|
30
|
+
# @rbs (?channel: String, ?url: String?, ?client: untyped) -> void
|
|
31
|
+
def initialize(channel: CHANNEL, url: nil, client: nil)
|
|
32
|
+
@channel = channel
|
|
33
|
+
@url = url
|
|
34
|
+
@client = client
|
|
35
|
+
@mutex = Thread::Mutex.new
|
|
36
|
+
@condition = Thread::ConditionVariable.new
|
|
37
|
+
@subscriber = nil
|
|
38
|
+
@subscription = nil
|
|
39
|
+
@signalled = 0
|
|
40
|
+
validate_client!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @rbs () -> bool
|
|
44
|
+
def signal
|
|
45
|
+
publisher.publish(channel, "1")
|
|
46
|
+
true
|
|
47
|
+
rescue => error
|
|
48
|
+
instrument_failure(:signal, error)
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The counter is snapshotted before subscribing, and re-checked before
|
|
53
|
+
# blocking, so a signal delivered while this caller was still getting
|
|
54
|
+
# ready is observed rather than absorbed into the new baseline.
|
|
55
|
+
# @rbs (timeout: Numeric) -> bool
|
|
56
|
+
def wait(timeout:)
|
|
57
|
+
signalled = mutex.synchronize { @signalled }
|
|
58
|
+
return paced_failure(timeout) unless listen
|
|
59
|
+
|
|
60
|
+
mutex.synchronize do
|
|
61
|
+
return true unless @signalled == signalled
|
|
62
|
+
|
|
63
|
+
condition.wait(mutex, timeout.to_f)
|
|
64
|
+
@signalled != signalled
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Redis delivers to a subscribed connection only, and a subscribed
|
|
69
|
+
# connection cannot serve other callers, so one background subscription
|
|
70
|
+
# per process fans out to every waiting role in memory. Subscribing
|
|
71
|
+
# eagerly also closes the window where a signal sent during startup would
|
|
72
|
+
# be missed.
|
|
73
|
+
# @rbs () -> bool
|
|
74
|
+
def listen
|
|
75
|
+
mutex.synchronize do
|
|
76
|
+
return true if @subscriber&.alive?
|
|
77
|
+
|
|
78
|
+
ready = Queue.new
|
|
79
|
+
@subscriber = Thread.new { subscribe_loop(ready) }
|
|
80
|
+
Timeout.timeout(SUBSCRIBE_TIMEOUT) { ready.pop } == :subscribed
|
|
81
|
+
end
|
|
82
|
+
rescue => error
|
|
83
|
+
instrument_failure(:listen, error)
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @rbs () -> bool
|
|
88
|
+
def stop
|
|
89
|
+
subscriber = mutex.synchronize do
|
|
90
|
+
thread = @subscriber
|
|
91
|
+
@subscriber = nil
|
|
92
|
+
thread
|
|
93
|
+
end
|
|
94
|
+
return false unless subscriber
|
|
95
|
+
|
|
96
|
+
disconnect(@subscription)
|
|
97
|
+
subscriber.join(SUBSCRIBE_TIMEOUT)
|
|
98
|
+
subscriber.kill if subscriber.alive?
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
attr_reader :mutex, :condition, :url
|
|
105
|
+
|
|
106
|
+
# @rbs (Queue) -> void
|
|
107
|
+
def subscribe_loop(ready)
|
|
108
|
+
connection = build_client
|
|
109
|
+
@subscription = connection
|
|
110
|
+
connection.subscribe(channel) do |on|
|
|
111
|
+
on.subscribe { ready << :subscribed }
|
|
112
|
+
on.message { broadcast }
|
|
113
|
+
end
|
|
114
|
+
rescue => error
|
|
115
|
+
instrument_failure(:subscribe, error)
|
|
116
|
+
ready << :failed
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @rbs () -> void
|
|
120
|
+
def broadcast
|
|
121
|
+
mutex.synchronize do
|
|
122
|
+
@signalled += 1
|
|
123
|
+
condition.broadcast
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @rbs (Numeric) -> bool
|
|
128
|
+
def paced_failure(timeout)
|
|
129
|
+
pace_after_failure(timeout)
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @rbs () -> untyped
|
|
134
|
+
def publisher
|
|
135
|
+
@publisher ||= build_client
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @rbs () -> untyped
|
|
139
|
+
def build_client
|
|
140
|
+
return @client.call if @client.respond_to?(:call)
|
|
141
|
+
|
|
142
|
+
require "redis"
|
|
143
|
+
url ? ::Redis.new(url:) : ::Redis.new
|
|
144
|
+
rescue LoadError
|
|
145
|
+
raise ArgumentError,
|
|
146
|
+
"the redis gem is required for SolidObjects::WakeUpAdapters::Redis"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @rbs () -> void
|
|
150
|
+
def validate_client!
|
|
151
|
+
return if @client.nil? || @client.respond_to?(:call)
|
|
152
|
+
|
|
153
|
+
raise ArgumentError, "client must respond to call and return a Redis client"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @rbs (untyped) -> void
|
|
157
|
+
def disconnect(connection)
|
|
158
|
+
connection&.close
|
|
159
|
+
rescue
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @rbs (Numeric) -> void
|
|
164
|
+
def pace_after_failure(timeout)
|
|
165
|
+
interval = [ timeout.to_f, FAILED_WAIT_INTERVAL ].min
|
|
166
|
+
return unless interval.positive?
|
|
167
|
+
|
|
168
|
+
sleep interval
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @rbs (Symbol, Exception) -> void
|
|
172
|
+
def instrument_failure(operation, error)
|
|
173
|
+
SolidObjects.instrument(
|
|
174
|
+
:"wake_up.failed",
|
|
175
|
+
adapter: "redis",
|
|
176
|
+
operation: operation.to_s,
|
|
177
|
+
error_class: error.class.name,
|
|
178
|
+
error_message: error.message
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/solid_objects.rb
CHANGED
|
@@ -47,6 +47,7 @@ require "solid_objects/actor_channel"
|
|
|
47
47
|
require "solid_objects/action_cable_broadcast_adapter"
|
|
48
48
|
require "solid_objects/wake_up"
|
|
49
49
|
require "solid_objects/wake_up_adapters/postgresql"
|
|
50
|
+
require "solid_objects/wake_up_adapters/redis"
|
|
50
51
|
require "solid_objects/wake_up_adapters"
|
|
51
52
|
require "solid_objects/effect_registry"
|
|
52
53
|
require "solid_objects/commit_action_registry"
|
|
@@ -16,6 +16,23 @@ module SolidObjects
|
|
|
16
16
|
# @rbs (untyped) -> void
|
|
17
17
|
def initialize: (untyped) -> void
|
|
18
18
|
|
|
19
|
+
# The oldest server the adapter has been exercised against. Reported rather
|
|
20
|
+
# than enforced: refusing to boot on an untested server would be a worse
|
|
21
|
+
# failure than running on one.
|
|
22
|
+
# @rbs () -> Gem::Version?
|
|
23
|
+
def minimum_server_version: () -> Gem::Version?
|
|
24
|
+
|
|
25
|
+
# @rbs () -> Gem::Version
|
|
26
|
+
def server_version: () -> Gem::Version
|
|
27
|
+
|
|
28
|
+
# One observed version decides both the status and the message. Reading it
|
|
29
|
+
# again could let a transient failure replace an already determined result.
|
|
30
|
+
# @rbs (?Gem::Version?) -> Array[String]
|
|
31
|
+
def unsupported_server_reasons: (?Gem::Version?) -> Array[String]
|
|
32
|
+
|
|
33
|
+
# @rbs () -> Array[String]
|
|
34
|
+
def additional_server_reasons: () -> Array[String]
|
|
35
|
+
|
|
19
36
|
# @rbs () -> bool
|
|
20
37
|
def supports_skip_locked?: () -> bool
|
|
21
38
|
|
|
@@ -9,6 +9,17 @@ module SolidObjects
|
|
|
9
9
|
# @rbs () -> String
|
|
10
10
|
def claim_lock: () -> String
|
|
11
11
|
|
|
12
|
+
# A non-transactional engine would silently break fenced commits, so the
|
|
13
|
+
# storage engine is verified rather than assumed.
|
|
14
|
+
# @rbs () -> Array[String]
|
|
15
|
+
def additional_server_reasons: () -> Array[String]
|
|
16
|
+
|
|
17
|
+
# @rbs () -> Array[String]
|
|
18
|
+
def non_innodb_tables: () -> Array[String]
|
|
19
|
+
|
|
20
|
+
# @rbs () -> Gem::Version?
|
|
21
|
+
def minimum_server_version: () -> Gem::Version?
|
|
22
|
+
|
|
12
23
|
# @rbs () -> String
|
|
13
24
|
def current_time_expression: () -> String
|
|
14
25
|
|
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
module DatabaseAdapters
|
|
5
5
|
class Postgresql < DatabaseAdapter
|
|
6
|
+
# @rbs () -> Gem::Version?
|
|
7
|
+
def minimum_server_version: () -> Gem::Version?
|
|
8
|
+
|
|
9
|
+
# PostgreSQL reports a packed integer, 170010 for 17.10, so comparing it
|
|
10
|
+
# directly would make every server look newer than any minimum.
|
|
11
|
+
# @rbs () -> Gem::Version
|
|
12
|
+
def server_version: () -> Gem::Version
|
|
13
|
+
|
|
6
14
|
# @rbs () -> bool
|
|
7
15
|
def supports_skip_locked?: () -> bool
|
|
8
16
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Generated from lib/solid_objects/wake_up_adapters/redis.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
module WakeUpAdapters
|
|
5
|
+
# Wakes runtime roles across processes using Redis publish/subscribe.
|
|
6
|
+
#
|
|
7
|
+
# MySQL has no notification primitive, so this is the cross-process option
|
|
8
|
+
# for applications that cannot use PostgreSQL notifications. It is optional
|
|
9
|
+
# in every sense: the `redis` gem is not a dependency of this gem, and the
|
|
10
|
+
# polling interval remains the upper bound, so a missed or failed
|
|
11
|
+
# notification costs latency rather than correctness.
|
|
12
|
+
class Redis
|
|
13
|
+
CHANNEL: ::String
|
|
14
|
+
|
|
15
|
+
FAILED_WAIT_INTERVAL: ::Float
|
|
16
|
+
|
|
17
|
+
SUBSCRIBE_TIMEOUT: ::Float
|
|
18
|
+
|
|
19
|
+
@signalled: Integer
|
|
20
|
+
|
|
21
|
+
@subscription: untyped
|
|
22
|
+
|
|
23
|
+
@subscriber: Thread?
|
|
24
|
+
|
|
25
|
+
@condition: Thread::ConditionVariable
|
|
26
|
+
|
|
27
|
+
@mutex: Thread::Mutex
|
|
28
|
+
|
|
29
|
+
@client: untyped
|
|
30
|
+
|
|
31
|
+
@url: String?
|
|
32
|
+
|
|
33
|
+
@channel: String
|
|
34
|
+
|
|
35
|
+
attr_reader channel: untyped
|
|
36
|
+
|
|
37
|
+
# @rbs (?channel: String, ?url: String?, ?client: untyped) -> void
|
|
38
|
+
def initialize: (?channel: String, ?url: String?, ?client: untyped) -> void
|
|
39
|
+
|
|
40
|
+
# @rbs () -> bool
|
|
41
|
+
def signal: () -> bool
|
|
42
|
+
|
|
43
|
+
# The counter is snapshotted before subscribing, and re-checked before
|
|
44
|
+
# blocking, so a signal delivered while this caller was still getting
|
|
45
|
+
# ready is observed rather than absorbed into the new baseline.
|
|
46
|
+
# @rbs (timeout: Numeric) -> bool
|
|
47
|
+
def wait: (timeout: Numeric) -> bool
|
|
48
|
+
|
|
49
|
+
# Redis delivers to a subscribed connection only, and a subscribed
|
|
50
|
+
# connection cannot serve other callers, so one background subscription
|
|
51
|
+
# per process fans out to every waiting role in memory. Subscribing
|
|
52
|
+
# eagerly also closes the window where a signal sent during startup would
|
|
53
|
+
# be missed.
|
|
54
|
+
# @rbs () -> bool
|
|
55
|
+
def listen: () -> bool
|
|
56
|
+
|
|
57
|
+
# @rbs () -> bool
|
|
58
|
+
def stop: () -> bool
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader mutex: untyped
|
|
63
|
+
|
|
64
|
+
attr_reader condition: untyped
|
|
65
|
+
|
|
66
|
+
attr_reader url: untyped
|
|
67
|
+
|
|
68
|
+
# @rbs (Queue) -> void
|
|
69
|
+
def subscribe_loop: (Queue) -> void
|
|
70
|
+
|
|
71
|
+
# @rbs () -> void
|
|
72
|
+
def broadcast: () -> void
|
|
73
|
+
|
|
74
|
+
# @rbs (Numeric) -> bool
|
|
75
|
+
def paced_failure: (Numeric) -> bool
|
|
76
|
+
|
|
77
|
+
# @rbs () -> untyped
|
|
78
|
+
def publisher: () -> untyped
|
|
79
|
+
|
|
80
|
+
# @rbs () -> untyped
|
|
81
|
+
def build_client: () -> untyped
|
|
82
|
+
|
|
83
|
+
# @rbs () -> void
|
|
84
|
+
def validate_client!: () -> void
|
|
85
|
+
|
|
86
|
+
# @rbs (untyped) -> void
|
|
87
|
+
def disconnect: (untyped) -> void
|
|
88
|
+
|
|
89
|
+
# @rbs (Numeric) -> void
|
|
90
|
+
def pace_after_failure: (Numeric) -> void
|
|
91
|
+
|
|
92
|
+
# @rbs (Symbol, Exception) -> void
|
|
93
|
+
def instrument_failure: (Symbol, Exception) -> void
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
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.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Carlson
|
|
@@ -319,6 +319,7 @@ files:
|
|
|
319
319
|
- docs/development.md
|
|
320
320
|
- docs/fit.md
|
|
321
321
|
- docs/implementation-plan.md
|
|
322
|
+
- docs/local-testing.md
|
|
322
323
|
- docs/migrating-existing-state.md
|
|
323
324
|
- docs/operations.md
|
|
324
325
|
- docs/realtime.md
|
|
@@ -405,6 +406,7 @@ files:
|
|
|
405
406
|
- lib/solid_objects/wake_up.rb
|
|
406
407
|
- lib/solid_objects/wake_up_adapters.rb
|
|
407
408
|
- lib/solid_objects/wake_up_adapters/postgresql.rb
|
|
409
|
+
- lib/solid_objects/wake_up_adapters/redis.rb
|
|
408
410
|
- lib/solid_objects/worker.rb
|
|
409
411
|
- lib/tasks/solid_objects_tasks.rake
|
|
410
412
|
- sig/generated/controllers/solid_objects/application_controller.rbs
|
|
@@ -478,6 +480,7 @@ files:
|
|
|
478
480
|
- sig/generated/lib/solid_objects/wake_up.rbs
|
|
479
481
|
- sig/generated/lib/solid_objects/wake_up_adapters.rbs
|
|
480
482
|
- sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs
|
|
483
|
+
- sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs
|
|
481
484
|
- sig/generated/lib/solid_objects/worker.rbs
|
|
482
485
|
- sig/generated/models/solid_objects/broadcast.rbs
|
|
483
486
|
- sig/generated/models/solid_objects/claimed_message.rbs
|