Skip to content

Commit 4dd6886

Browse files
cardmagicclaude
andcommitted
fix: poll rather than pretend when an adapter cannot be built
`wake_up_adapter = :postgresql` on a database with no notification channel built the adapter anyway and reported `crosses_processes: true`, so the doctor said PASS while every `LISTEN` failed. `:redis` without `SOLID_OBJECTS_REDIS_URL` reached for the client default rather than say it had no address. Each case now polls, logs `solid_objects.wake_up.unavailable` once, and records the reason in the capability, so the doctor warns. That is what the capability record exists for. Only a name that does not exist is still refused, because a typo cannot be honoured at all. `configure` validates the name now. An unknown one raised at the first wake-up, which is after a commit, rather than at boot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4d4afd7 commit 4dd6886

6 files changed

Lines changed: 144 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@
2323
- Keep the capability that a configured adapter reports about itself. A
2424
configured `SolidObjects::WakeUp` now reports `:in_process` and warns, rather
2525
than claim that it crosses processes.
26+
- Poll rather than pretend when a requested adapter cannot be built.
27+
`wake_up_adapter = :postgresql` on a database with no notification channel,
28+
`:redis` without `SOLID_OBJECTS_REDIS_URL`, and a Redis URL without the redis
29+
gem each log `solid_objects.wake_up.unavailable` once and record the reason in
30+
the capability, so the doctor warns rather than claim a cross-process wake-up
31+
that cannot happen.
32+
- Validate `wake_up_adapter` in `configure`. An unknown name raised at the first
33+
wake-up, which is after a commit, rather than at boot.
2634
- Report the resolved choice. `SolidObjects.wake_up.capability` names the
2735
adapter, whether it crosses processes, its measured floor, and why it was
2836
chosen. The doctor reports it, and the polling-only warning now fires on what

‎lib/solid_objects/configuration.rb‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ def validate!
237237
raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
238238
raise ArgumentError, "instance retention must be positive" unless retention.positive?
239239
end
240+
validate_wake_up_adapter!
240241
unless component_path_resolver.nil? || component_path_resolver.respond_to?(:call)
241242
raise ArgumentError, "component_path_resolver must respond to call"
242243
end
@@ -252,6 +253,26 @@ def validate!
252253

253254
private
254255

256+
# @rbs () -> void
257+
def validate_wake_up_adapter!
258+
return if wake_up_adapter.nil?
259+
return validate_wake_up_object! unless wake_up_adapter.is_a?(Symbol)
260+
return if WakeUpAdapters::NAMES.include?(wake_up_adapter)
261+
262+
raise ArgumentError,
263+
"unknown wake_up_adapter #{wake_up_adapter.inspect}, " \
264+
"expected one of #{WakeUpAdapters::NAMES.join(", ")} or an adapter"
265+
end
266+
267+
# @rbs () -> void
268+
def validate_wake_up_object!
269+
%i[signal wait watch].each do |method_name|
270+
next if wake_up_adapter.respond_to?(method_name)
271+
272+
raise ArgumentError, "wake_up_adapter must respond to #{method_name}"
273+
end
274+
end
275+
255276
# @rbs () -> Hash[Symbol, Numeric]
256277
def positive_values
257278
{

‎lib/solid_objects/wake_up_adapters.rb‎

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,62 @@ def build(setting)
3232
def named(name)
3333
case name
3434
when :in_process then labelled(WakeUp.new, :in_process, false, nil, "in-process signalling was requested")
35-
when :postgresql then labelled(Postgresql.new, :postgresql_notify, true, POSTGRESQL_FLOOR_MS, "PostgreSQL LISTEN was requested")
36-
when :redis then labelled(Redis.new(url: redis_url), :redis, true, REDIS_FLOOR_MS, "Redis was requested")
35+
when :postgresql then requested_postgresql
36+
when :redis then requested_redis
3737
else
3838
raise ArgumentError, "unknown wake_up_adapter #{name.inspect}, expected one of #{NAMES.join(", ")} or an adapter"
3939
end
4040
end
4141

42+
# @rbs () -> untyped
43+
def requested_postgresql
44+
family = DatabaseAdapter.family(Record.connection)
45+
unless family == :postgresql
46+
return unavailable_selection(
47+
"wake_up_adapter :postgresql needs a database with a notification " \
48+
"channel, and #{family || "this database"} provides none"
49+
)
50+
end
51+
52+
labelled(Postgresql.new, :postgresql_notify, true, POSTGRESQL_FLOOR_MS, "PostgreSQL LISTEN was requested")
53+
end
54+
55+
# @rbs () -> untyped
56+
def requested_redis
57+
url = redis_url
58+
unless url
59+
return unavailable_selection(
60+
"wake_up_adapter :redis needs #{REDIS_URL_VARIABLE}, which is not set"
61+
)
62+
end
63+
64+
redis_adapter(url, "Redis was requested")
65+
end
66+
67+
# @rbs (String, String) -> untyped
68+
def redis_adapter(url, reason)
69+
return unavailable_selection("#{REDIS_URL_VARIABLE} is set, and the redis gem is not installed") unless redis_installed?
70+
71+
labelled(Redis.new(url:), :redis, true, REDIS_FLOOR_MS, reason)
72+
end
73+
74+
# @rbs () -> bool
75+
def redis_installed?
76+
require "redis"
77+
true
78+
rescue LoadError
79+
false
80+
end
81+
82+
# @rbs (String) -> untyped
83+
def unavailable_selection(reason)
84+
SolidObjects.configuration.logger.warn(
85+
event: "solid_objects.wake_up.unavailable",
86+
reason:
87+
)
88+
polling_adapter(reason)
89+
end
90+
4291
# @rbs (untyped) -> untyped
4392
def configured(adapter)
4493
return adapter unless adapter.respond_to?(:capability=)
@@ -111,8 +160,8 @@ def redis_url
111160

112161
# @rbs (String) -> untyped
113162
def redis_selection(url)
114-
labelled(
115-
Redis.new(url:), :redis, true, REDIS_FLOOR_MS,
163+
redis_adapter(
164+
url,
116165
"#{REDIS_URL_VARIABLE} is set, so Redis carries the signal between processes"
117166
)
118167
end

‎sig/generated/lib/solid_objects/configuration.rbs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module SolidObjects
44
class Configuration
5+
@process_heartbeat_interval: Float
6+
57
@process_alive_threshold: Float
68

79
@shutdown_timeout: Float
@@ -96,8 +98,6 @@ module SolidObjects
9698

9799
@lock_retry_attempts: Integer
98100

99-
@process_heartbeat_interval: Float
100-
101101
attr_accessor table_name_prefix: untyped
102102

103103
attr_accessor polling_interval: untyped
@@ -230,6 +230,12 @@ module SolidObjects
230230

231231
private
232232

233+
# @rbs () -> void
234+
def validate_wake_up_adapter!: () -> void
235+
236+
# @rbs () -> void
237+
def validate_wake_up_object!: () -> void
238+
233239
# @rbs () -> Hash[Symbol, Numeric]
234240
def positive_values: () -> Hash[Symbol, Numeric]
235241

‎sig/generated/lib/solid_objects/wake_up_adapters.rbs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ module SolidObjects
2323
# @rbs (Symbol) -> untyped
2424
def self?.named: (Symbol) -> untyped
2525

26+
# @rbs () -> untyped
27+
def self?.requested_postgresql: () -> untyped
28+
29+
# @rbs () -> untyped
30+
def self?.requested_redis: () -> untyped
31+
32+
# @rbs (String, String) -> untyped
33+
def self?.redis_adapter: (String, String) -> untyped
34+
35+
# @rbs () -> bool
36+
def self?.redis_installed?: () -> bool
37+
38+
# @rbs (String) -> untyped
39+
def self?.unavailable_selection: (String) -> untyped
40+
2641
# @rbs (untyped) -> untyped
2742
def self?.configured: (untyped) -> untyped
2843

‎test/integration/wake_up_selection_test.rb‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,52 @@ def wait(timeout:) = false
9393
end
9494

9595
test "a name selects that adapter without probing" do
96+
ENV["SOLID_OBJECTS_REDIS_URL"] = "redis://127.0.0.1:6379/15"
9697
SolidObjects.configuration.wake_up_adapter = :redis
9798

9899
capability = SolidObjects.wake_up.capability
99100
assert_equal :redis, capability.adapter
100101
assert_match(/requested/i, capability.reason)
101102
end
102103

104+
test "a requested postgresql adapter polls when the database has no channel" do
105+
skip if database_family == :postgresql
106+
warnings = []
107+
SolidObjects.configuration.logger = Logger.new(IO::NULL).tap do |logger|
108+
logger.define_singleton_method(:warn) { |payload| warnings << payload }
109+
end
110+
SolidObjects.configuration.wake_up_adapter = :postgresql
111+
112+
capability = SolidObjects.wake_up.capability
113+
assert_equal :polling, capability.adapter
114+
assert_not capability.crosses_processes
115+
assert_match(/notification channel/i, capability.reason)
116+
assert_equal [ "solid_objects.wake_up.unavailable" ],
117+
warnings.map { |payload| payload[:event].to_s }
118+
end
119+
120+
test "a requested redis adapter polls when no url is set" do
121+
warnings = []
122+
SolidObjects.configuration.logger = Logger.new(IO::NULL).tap do |logger|
123+
logger.define_singleton_method(:warn) { |payload| warnings << payload }
124+
end
125+
SolidObjects.configuration.wake_up_adapter = :redis
126+
127+
capability = SolidObjects.wake_up.capability
128+
assert_equal :polling, capability.adapter
129+
assert_match(/SOLID_OBJECTS_REDIS_URL/, capability.reason)
130+
assert_equal [ "solid_objects.wake_up.unavailable" ],
131+
warnings.map { |payload| payload[:event].to_s }
132+
end
133+
134+
test "an unknown name is refused when the configuration is validated" do
135+
SolidObjects.configuration.wake_up_adapter = :carrier_pigeon
136+
137+
error = assert_raises(ArgumentError) { SolidObjects.configuration.validate! }
138+
assert_match(/carrier_pigeon/, error.message)
139+
assert_match(/automatic/, error.message)
140+
end
141+
103142
test "an unknown name is refused rather than silently polling" do
104143
SolidObjects.configuration.wake_up_adapter = :carrier_pigeon
105144

0 commit comments

Comments
 (0)