Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ jobs:
- run: bundle lock --update && bundle install --jobs 4
- run: bundle exec rake test

redis:
runs-on: ubuntu-latest
services:
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with:
ruby-version: "3.3"
bundler-cache: true
- run: bundle exec rake test TEST=test/integration/redis_wake_up_test.rb

postgresql:
runs-on: ubuntu-latest
services:
Expand Down Expand Up @@ -116,6 +136,7 @@ jobs:
- static
- javascript
- compatibility
- redis
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
The doctor reports this as `database_server` and warns rather than failing:
refusing to run on an untested server would be a worse failure than running
on one.
- Add `SolidObjects::WakeUpAdapters::Redis`, an optional cross-process wake-up
using Redis publish and subscribe. This is the option for MySQL, which has no
notification primitive. Measured cross-process wake-up latency drops from
103.8 ms to 5.7 ms at p50. One background subscription per process fans out to
every waiting role in memory. The `redis` gem is not a dependency of this gem,
and `WakeUpAdapters.for` does not select it, so adopting Redis stays explicit.

## 0.8.0 - 2026-08-10

Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ end
group :development, :test do
gem "mysql2", ">= 0.5", require: false
gem "pg", ">= 1.5", require: false
gem "redis", ">= 5.0", require: false
gem "sqlite3", ">= 2.1", require: false
end

Expand Down
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ GEM
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
redis (6.0.0)
redis-client (= 0.30.1)
redis-client (0.30.1)
connection_pool
regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
Expand Down Expand Up @@ -282,6 +286,7 @@ DEPENDENCIES
mysql2 (>= 0.5)
pg (>= 1.5)
rbs-inline
redis (>= 5.0)
rubocop-rails-omakase
solid_objects!
sqlite3 (>= 2.1)
Expand Down Expand Up @@ -364,6 +369,8 @@ CHECKSUMS
rbs (4.1.2) sha256=050eb1d8b508f1233bed929c0f2c7052302f7adf295230d9cb314e9024078f48
rbs-inline (0.14.0) sha256=eaf47b46690d63acddab1f6804c9cc205a4bc78e637cfc421a0b1239874ef51c
rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
redis (6.0.0) sha256=de71c10edd106986b759ec7ecdd08b63b9c0ee7414a0d0c1da73d31ba2bccda6
redis-client (0.30.1) sha256=5151bc5c7bbfe48623732cdae3b900d8a22dc691cc7cdfacfb351ac55116522d
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
rubocop (1.88.2) sha256=8def251c90cd955feb4daa3edc0ab56893250c4ce90ef81e6c80c03f9a939bbf
Expand Down
17 changes: 15 additions & 2 deletions docs/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,21 @@ configuration.wake_up_adapter = SolidObjects::WakeUpAdapters.for
default on SQLite and MySQL, so the same line is safe across adapters. Name
`SolidObjects::WakeUpAdapters::Postgresql.new` directly to require it.

MySQL has no notification primitive, so MySQL applications keep polling and tune
`polling_interval`.
MySQL has no notification primitive. MySQL applications either keep polling and
tune `polling_interval`, or configure the Redis adapter:

```ruby
configuration.wake_up_adapter = SolidObjects::WakeUpAdapters::Redis.new(
url: ENV["REDIS_URL"]
)
```

Measured latency for a cross-process wake-up drops from 103.8 ms to 5.7 ms at
p50. The `redis` gem is not a dependency of this gem, so applications add it
themselves. One background subscription per process fans out to every waiting
role in memory, rather than one connection per thread, and `WakeUpAdapters.for`
does not select it: Redis is infrastructure this gem otherwise does not require,
so choosing it is explicit.

Measured latency for a cross-process wake-up drops from 103.7 ms to 2.9 ms at
p50. The adapter keeps `polling_interval` as the upper bound: a missed or failed
Expand Down
21 changes: 11 additions & 10 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
- SQLite, PostgreSQL, and MySQL integration suites
- Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
a listening connection per waiting thread and release on supervisor shutdown
- Opt-in cross-process wake-up on Redis, the option for MySQL applications,
measured at 103.8 ms to 5.7 ms at p50; the `redis` gem stays outside this
gem's dependencies
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
RuboCop policy, and a warning-free Brakeman scan
- Compatibility CI across the supported span: Ruby 3.3 and 3.4 against Rails 8.0
Expand All @@ -59,7 +62,8 @@
103.7 ms to 2.9 ms at p50. It is opt-in rather than automatic: it opens a
connection per waiting thread outside the pool, and `LISTEN` does not survive
a transaction-pooling proxy such as PgBouncer. MySQL has no notification
primitive, so MySQL applications keep polling.
primitive, so MySQL applications keep polling unless they configure the Redis
adapter.
- Realtime: scalar and dependency-driven keyed ERB component replacement or
morphing, personalized refresh authorization, revision fencing, coalescing,
reconnect convergence, batched refreshes, and personalized state payloads are
Expand All @@ -79,17 +83,14 @@

## Next milestones

1. Add an optional Redis wake-up adapter, which is the remaining cross-process
option for MySQL. The PostgreSQL notification adapter, its latency
benchmark, and its concurrency tests are implemented.
2. Add result lookup by request ID and broader deadlock retry classification.
3. Add scheduled retention and stale-process maintenance.
4. Add Turbo append intents and expand reconnect coverage in a full browser.
5. Add distributed rate limits, global admission hooks, and cache-capacity
1. Add result lookup by request ID and broader deadlock retry classification.
2. Add scheduled retention and stale-process maintenance.
3. Add Turbo append intents and expand reconnect coverage in a full browser.
4. Add distributed rate limits, global admission hooks, and cache-capacity
eviction.
6. Expand security scanning. Compatibility CI across supported Rails and Ruby
5. Expand security scanning. Compatibility CI across supported Rails and Ruby
versions is implemented; Ruby 4.0 is not yet in the matrix.
7. Benchmark all workloads under documented hardware/database settings and
6. Benchmark all workloads under documented hardware/database settings and
publish adapter-specific adoption measurements. Throughput, synchronous
latency, query counts, and the three reactive delivery paths are measured on
SQLite; adapter-specific and end-to-end browser measurements are not.
Expand Down
1 change: 1 addition & 0 deletions lib/solid_objects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
require "solid_objects/action_cable_broadcast_adapter"
require "solid_objects/wake_up"
require "solid_objects/wake_up_adapters/postgresql"
require "solid_objects/wake_up_adapters/redis"
require "solid_objects/wake_up_adapters"
require "solid_objects/effect_registry"
require "solid_objects/commit_action_registry"
Expand Down
183 changes: 183 additions & 0 deletions lib/solid_objects/wake_up_adapters/redis.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# rbs_inline: enabled

require "timeout"

module SolidObjects
module WakeUpAdapters
# Wakes runtime roles across processes using Redis publish/subscribe.
#
# MySQL has no notification primitive, so this is the cross-process option
# for applications that cannot use PostgreSQL notifications. It is optional
# in every sense: the `redis` gem is not a dependency of this gem, and the
# polling interval remains the upper bound, so a missed or failed
# notification costs latency rather than correctness.
class Redis
CHANNEL = "solid_objects_wake_up"
FAILED_WAIT_INTERVAL = 0.05
SUBSCRIBE_TIMEOUT = 5.0

# @rbs @channel: String
# @rbs @url: String?
# @rbs @client: untyped
# @rbs @mutex: Thread::Mutex
# @rbs @condition: Thread::ConditionVariable
# @rbs @subscriber: Thread?
# @rbs @subscription: untyped
# @rbs @signalled: Integer

attr_reader :channel

# @rbs (?channel: String, ?url: String?, ?client: untyped) -> void
def initialize(channel: CHANNEL, url: nil, client: nil)
@channel = channel
@url = url
@client = client
@mutex = Thread::Mutex.new
@condition = Thread::ConditionVariable.new
@subscriber = nil
@subscription = nil
@signalled = 0
validate_client!
end

# @rbs () -> bool
def signal
publisher.publish(channel, "1")
true
rescue => error
instrument_failure(:signal, error)
false
end

# The counter is snapshotted before subscribing, and re-checked before
# blocking, so a signal delivered while this caller was still getting
# ready is observed rather than absorbed into the new baseline.
# @rbs (timeout: Numeric) -> bool
def wait(timeout:)
signalled = mutex.synchronize { @signalled }
return paced_failure(timeout) unless listen

mutex.synchronize do
return true unless @signalled == signalled

condition.wait(mutex, timeout.to_f)
@signalled != signalled
Comment on lines +58 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Signal counter absorbs wake-ups

When a Redis notification arrives after listen releases the mutex but before wait snapshots @signalled, the increment becomes the new baseline and the condition broadcast occurs before the waiter is registered, causing the runtime role to sleep until polling_interval expires.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/solid_objects/wake_up_adapters/redis.rb
Line: 54-59

Comment:
**Signal counter absorbs wake-ups**

When a Redis notification arrives after `listen` releases the mutex but before `wait` snapshots `@signalled`, the increment becomes the new baseline and the condition broadcast occurs before the waiter is registered, causing the runtime role to sleep until `polling_interval` expires.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and it is the same class of bug as the one my own test caught earlier in this adapter: a window where a signal is delivered to nobody.

A notification arriving after listen released the mutex but before wait snapshotted @signalled became the new baseline, and its broadcast reached no registered waiter, so the role slept out its polling interval.

The counter is now snapshotted before subscribing and re-checked before blocking, so a signal delivered while a waiter was still getting ready is observed rather than swallowed. A test publishes from inside listen to model exactly that interleaving; it fails against the previous code and passes now.

end
end

# Redis delivers to a subscribed connection only, and a subscribed
# connection cannot serve other callers, so one background subscription
# per process fans out to every waiting role in memory. Subscribing
# eagerly also closes the window where a signal sent during startup would
# be missed.
# @rbs () -> bool
def listen
mutex.synchronize do
return true if @subscriber&.alive?

ready = Queue.new
@subscriber = Thread.new { subscribe_loop(ready) }
Timeout.timeout(SUBSCRIBE_TIMEOUT) { ready.pop } == :subscribed
end
rescue => error
instrument_failure(:listen, error)
false
end

# @rbs () -> bool
def stop
subscriber = mutex.synchronize do
thread = @subscriber
@subscriber = nil
thread
end
return false unless subscriber

disconnect(@subscription)
subscriber.join(SUBSCRIBE_TIMEOUT)
subscriber.kill if subscriber.alive?
true
end

private

attr_reader :mutex, :condition, :url

# @rbs (Queue) -> void
def subscribe_loop(ready)
connection = build_client
@subscription = connection
connection.subscribe(channel) do |on|
on.subscribe { ready << :subscribed }
on.message { broadcast }
end
rescue => error
instrument_failure(:subscribe, error)
ready << :failed
end

# @rbs () -> void
def broadcast
mutex.synchronize do
@signalled += 1
condition.broadcast
end
end

# @rbs (Numeric) -> bool
def paced_failure(timeout)
pace_after_failure(timeout)
false
end

# @rbs () -> untyped
def publisher
@publisher ||= build_client
end

# @rbs () -> untyped
def build_client
return @client.call if @client.respond_to?(:call)

require "redis"
url ? ::Redis.new(url:) : ::Redis.new
rescue LoadError
raise ArgumentError,
"the redis gem is required for SolidObjects::WakeUpAdapters::Redis"
end

# @rbs () -> void
def validate_client!
return if @client.nil? || @client.respond_to?(:call)

raise ArgumentError, "client must respond to call and return a Redis client"
end

# @rbs (untyped) -> void
def disconnect(connection)
connection&.close
rescue
nil
end

# @rbs (Numeric) -> void
def pace_after_failure(timeout)
interval = [ timeout.to_f, FAILED_WAIT_INTERVAL ].min
return unless interval.positive?

sleep interval
end

# @rbs (Symbol, Exception) -> void
def instrument_failure(operation, error)
SolidObjects.instrument(
:"wake_up.failed",
adapter: "redis",
operation: operation.to_s,
error_class: error.class.name,
error_message: error.message
)
end
end
end
end
Loading
Loading