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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17e88ea06322a5e262d146e3d96a0b0d54d2339d46c6681a2fdb0f8d244a3640
4
- data.tar.gz: d33b76b4e741921b4e199f3f91af652c06217960606dc7e3e95a03b28d34d3db
3
+ metadata.gz: b7fee6db10af36996dbe9c8484fab405b53e2463a3fd7d2f7414310fedab68d5
4
+ data.tar.gz: 8b71bb8fbf0dc0359ce8a00097428dbbcd189e4d2b4071ba0339b9c3ec1cd933
5
5
  SHA512:
6
- metadata.gz: 0a217bfdb8454acbf7caa7a153095455e8b421aab4ebc7a16be056efa49db78bc4e0c452f50e24c04f1aad5177aafdb16ebff67a974618a0134c302581edf85a
7
- data.tar.gz: 284a15eb44417ebaad3ec0ccad3b180999ed94df9d7ca73b74fcc70ed136f3458613ecbadb99115f13e93310d684a0451a62f6887dd7b0c2bdcf1af14ade2fcf
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, so MySQL applications keep polling and tune
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 the state payload and batched refresh browser
45
- modules, run in CI with Node's test runner and jsdom, with every GitHub
46
- Actions reference pinned to a commit SHA
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 an optional Redis wake-up adapter, which is the remaining cross-process
80
- option for MySQL. The PostgreSQL notification adapter, its latency
81
- benchmark, and its concurrency tests are implemented.
82
- 2. Add result lookup by request ID and broader deadlock retry classification.
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
- 7. Expand security scanning and run compatibility CI across supported Rails and
89
- Ruby versions.
90
- 8. Benchmark all workloads under documented hardware/database settings and
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')"
@@ -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 -
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -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
 
@@ -11,6 +11,9 @@ module SolidObjects
11
11
 
12
12
  LOCK_RETRY_CONDITION: untyped
13
13
 
14
+ # @rbs () -> Gem::Version?
15
+ def minimum_server_version: () -> Gem::Version?
16
+
14
17
  # @rbs () -> String
15
18
  def current_time_expression: () -> String
16
19
 
@@ -72,6 +72,9 @@ module SolidObjects
72
72
  # @rbs () -> Check
73
73
  def check_authorization: () -> Check
74
74
 
75
+ # @rbs () -> Check
76
+ def check_database_server: () -> Check
77
+
75
78
  # @rbs () -> Check
76
79
  def check_runtime: () -> Check
77
80
 
@@ -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.8.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