Skip to content

Commit 13ff644

Browse files
authored
Merge pull request #33 from cardmagic/agent/supervisor-component-registry
Let an extension run inside the supervisor
2 parents 4383195 + 37774fc commit 13ff644

10 files changed

Lines changed: 535 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
# Changelog
22

3+
## 0.11.0 - 2026-08-11
4+
5+
- Add `SolidObjects.configuration.register_component`. An extension gem can now
6+
register a long running component, and the supervisor runs it beside the
7+
workers, the effect executors, the broadcast executors, and the reminder
8+
schedulers. The component joins the same supervision, replacement, and
9+
shutdown timeout. Without it, an extension has to ask an operator to run and
10+
monitor a second process for work that belongs to the same runtime. A
11+
registered component must answer `run`, `request_shutdown`, `stopped?`, and
12+
`stop`. The supervisor checks that contract when it builds the component and
13+
raises `ArgumentError` when a method is missing. Registration never calls the
14+
block, so a component may need a database connection that the application
15+
does not have while it boots.
16+
17+
- Stop the components already built when a later one fails. The supervisor
18+
builds its components one after another, so a factory that raised, or a
19+
component that failed the contract check, left the earlier ones constructed
20+
and unreachable while they still held whatever their constructors took. Each
21+
one now receives `stop`, and a failure inside that cleanup never replaces the
22+
failure that caused it.
23+
24+
- Replace a crashed component through the builder that made it. The supervisor
25+
called `component.class.new`, which discards every constructor argument, so a
26+
component built with arguments returned with its defaults after a crash. Each
27+
component now keeps its builder. The built in components take no constructor
28+
arguments, so their behavior does not change.
29+
330
## 0.10.3 - 2026-08-11
431

532
- Delete every actor-owned row in `SolidObjects::TestHelper#reset_actors!`. It

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.10.3)
4+
solid_objects (0.11.0)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -383,7 +383,7 @@ CHECKSUMS
383383
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
384384
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
385385
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
386-
solid_objects (0.10.3)
386+
solid_objects (0.11.0)
387387
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
388388
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
389389
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,39 @@ Deploy and monitor that process before enabling any feature marked as requiring
458458
a runtime role. A missing worker never makes a durable `async` message
459459
disappear, but it leaves the message pending indefinitely.
460460

461+
### Running an extension in the same process
462+
463+
An extension gem can register its own long-running component, and
464+
`solid_objects start` runs it beside the built-in roles. The component joins the
465+
same supervision, the same replacement after a crash, and the same shutdown
466+
timeout, so an operator deploys and monitors one process instead of two:
467+
468+
```ruby
469+
SolidObjects.configure do |configuration|
470+
configuration.register_component { MyExtension::FlushEngine.new }
471+
end
472+
```
473+
474+
Pass `count:` for more than one instance. The block runs once for each instance,
475+
and again when the supervisor replaces a crashed one, so no two components share
476+
an object.
477+
478+
A registered component answers four methods, the contract the built-in roles
479+
already keep:
480+
481+
| Method | Purpose |
482+
| --- | --- |
483+
| `run` | Runs the loop. The supervisor calls it in its own thread |
484+
| `request_shutdown` | Asks the loop to finish. It must make `run` return |
485+
| `stopped?` | Reports whether the component already finished |
486+
| `stop` | Forces cleanup when the shutdown timeout expires first |
487+
488+
The supervisor checks that contract when it builds the component, and a missing
489+
method raises `ArgumentError` as the supervisor starts, rather than hanging a
490+
shutdown later. Registration itself never calls the block, so a component is
491+
free to need a database connection that the application does not have while it
492+
boots.
493+
461494
## Defining an actor
462495

463496
The Durable Object class becomes an ordinary Ruby class:

lib/solid_objects/configuration.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ class Configuration
9292
:authorize_subscription,
9393
:authorize_administration
9494

95+
# @rbs @additional_components: Array[untyped]
96+
attr_reader :additional_components
97+
9598
# @rbs () -> void
9699
def initialize
97100
@table_name_prefix = "solid_objects_"
@@ -142,6 +145,45 @@ def initialize
142145
@authorize_destroy = ->(**) { false }
143146
@authorize_subscription = ->(**) { false }
144147
@authorize_administration = ->(**) { false }
148+
@additional_components = []
149+
end
150+
151+
# Registers a long running component that the supervisor runs beside its own
152+
# workers. An extension gem uses this to share one process, rather than ask
153+
# an operator to run and monitor a second one.
154+
#
155+
# The block must return an object that answers `run`, `request_shutdown`,
156+
# `stopped?`, and `stop`, which is the contract the built in components
157+
# already keep. The supervisor calls the block once for each supervisor it
158+
# builds, and again when it replaces a crashed component, so two
159+
# supervisors never share one component instance.
160+
#
161+
# @rbs (?count: Integer) { () -> untyped } -> void
162+
def register_component(count: 1, &factory)
163+
raise ArgumentError, "register_component requires a block" unless factory
164+
raise ArgumentError, "count must be positive" unless count.positive?
165+
166+
count.times { @additional_components << factory }
167+
end
168+
169+
# The supervisor checks the contract here rather than at registration,
170+
# because a component often needs a database connection to exist, and
171+
# registration happens while the application boots.
172+
# @rbs () -> Array[untyped]
173+
def build_additional_components
174+
additional_components.map { |factory| factory.call.tap { |component| validate_component!(component) } }
175+
end
176+
177+
# A component that misses part of the contract would hang the supervisor at
178+
# shutdown, or crash the moment it starts. The build fails instead, where
179+
# the caller can read the reason.
180+
# @rbs (untyped) -> void
181+
def validate_component!(component)
182+
%i[run request_shutdown stopped? stop].each do |method_name|
183+
next if component.respond_to?(method_name)
184+
185+
raise ArgumentError, "a registered component must respond to #{method_name}"
186+
end
145187
end
146188

147189
# @rbs () -> self

lib/solid_objects/supervisor.rb

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ def initialize(
1919
broadcast_worker_count: SolidObjects.configuration.broadcast_worker_count,
2020
reminder_scheduler_count: SolidObjects.configuration.reminder_scheduler_count
2121
)
22-
@components = build_components(
22+
@builders = component_builders(
2323
worker_count:,
2424
effect_worker_count:,
2525
broadcast_worker_count:,
2626
reminder_scheduler_count:
2727
)
28+
@components = build_all(@builders)
2829
@threads = []
2930
@monitor = nil
3031
@started = false
@@ -77,7 +78,7 @@ def stop
7778

7879
private
7980

80-
attr_reader :components, :threads
81+
attr_reader :components, :threads, :builders
8182

8283
# A role that raises leaves its thread dead. Without replacement the
8384
# process keeps running while quietly doing less work, so the supervisor
@@ -116,7 +117,11 @@ def replace_dead_roles
116117
replaced = @lifecycle.synchronize do
117118
next false unless @started
118119

119-
replacement = component.class.new
120+
# A component built by this supervisor has a builder, which carries
121+
# whatever the constructor was given. A component put in place by
122+
# other means has none, so the class is the only thing left to go on.
123+
builder = builders[index] || -> { component.class.new }
124+
replacement = builder.call
120125
components[index] = replacement
121126
threads[index] = supervise(replacement)
122127
replacement
@@ -250,17 +255,53 @@ def release_wake_up
250255
nil
251256
end
252257

253-
# @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
254-
def build_components(
258+
# A constructor can take a resource, and a later builder can raise. Without
259+
# this, the components built first would be dropped while still holding
260+
# whatever they took, and nothing would ever give it back.
261+
# @rbs (Array[^() -> untyped]) -> Array[untyped]
262+
def build_all(builders)
263+
built = []
264+
builders.each do |builder|
265+
# The component joins the list before the contract check, so a
266+
# component that fails the check is stopped along with the rest.
267+
built << (component = builder.call)
268+
SolidObjects.configuration.validate_component!(component)
269+
end
270+
built
271+
rescue Exception # rubocop:disable Lint/RescueException
272+
built.each { |component| stop_after_failed_build(component) }
273+
raise
274+
end
275+
276+
# The failure that stopped the build is the one worth reporting, so a
277+
# failure inside the cleanup never replaces it.
278+
# @rbs (untyped) -> void
279+
def stop_after_failed_build(component)
280+
component.stop if component.respond_to?(:stop)
281+
rescue Exception => error # rubocop:disable Lint/RescueException
282+
SolidObjects.instrument(
283+
:"supervisor.component_cleanup_failed",
284+
role: component.class.name,
285+
error_class: error.class.name
286+
)
287+
end
288+
289+
# Each component keeps the builder that made it, so a replacement after a
290+
# crash is built the same way as the original. Components registered
291+
# through the configuration run beside the built in ones, under the same
292+
# supervision, restart, and shutdown timeout.
293+
# @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[^() -> untyped]
294+
def component_builders(
255295
worker_count:,
256296
effect_worker_count:,
257297
broadcast_worker_count:,
258298
reminder_scheduler_count:
259299
)
260-
Array.new(worker_count) { Worker.new } +
261-
Array.new(effect_worker_count) { EffectExecutor.new } +
262-
Array.new(broadcast_worker_count) { BroadcastExecutor.new } +
263-
Array.new(reminder_scheduler_count) { ReminderScheduler.new }
300+
Array.new(worker_count) { -> { Worker.new } } +
301+
Array.new(effect_worker_count) { -> { EffectExecutor.new } } +
302+
Array.new(broadcast_worker_count) { -> { BroadcastExecutor.new } } +
303+
Array.new(reminder_scheduler_count) { -> { ReminderScheduler.new } } +
304+
SolidObjects.configuration.additional_components
264305
end
265306

266307
# @rbs () -> void

lib/solid_objects/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.10.3"
4+
VERSION = "0.11.0"
55
end

sig/generated/lib/solid_objects/configuration.rbs

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

33
module SolidObjects
44
class Configuration
5+
@process_alive_threshold: Float
6+
7+
@shutdown_timeout: Float
8+
59
@supervisor_monitor_interval: Float
610

711
@retention_interval: Float
@@ -86,10 +90,6 @@ module SolidObjects
8690

8791
@process_heartbeat_interval: Float
8892

89-
@process_alive_threshold: Float
90-
91-
@shutdown_timeout: Float
92-
9393
attr_accessor table_name_prefix: untyped
9494

9595
attr_accessor polling_interval: untyped
@@ -178,9 +178,37 @@ module SolidObjects
178178

179179
attr_accessor authorize_administration: untyped
180180

181+
# @rbs @additional_components: Array[untyped]
182+
attr_reader additional_components: untyped
183+
181184
# @rbs () -> void
182185
def initialize: () -> void
183186

187+
# Registers a long running component that the supervisor runs beside its own
188+
# workers. An extension gem uses this to share one process, rather than ask
189+
# an operator to run and monitor a second one.
190+
#
191+
# The block must return an object that answers `run`, `request_shutdown`,
192+
# `stopped?`, and `stop`, which is the contract the built in components
193+
# already keep. The supervisor calls the block once for each supervisor it
194+
# builds, and again when it replaces a crashed component, so two
195+
# supervisors never share one component instance.
196+
#
197+
# @rbs (?count: Integer) { () -> untyped } -> void
198+
def register_component: (?count: Integer) { () -> untyped } -> void
199+
200+
# The supervisor checks the contract here rather than at registration,
201+
# because a component often needs a database connection to exist, and
202+
# registration happens while the application boots.
203+
# @rbs () -> Array[untyped]
204+
def build_additional_components: () -> Array[untyped]
205+
206+
# A component that misses part of the contract would hang the supervisor at
207+
# shutdown, or crash the moment it starts. The build fails instead, where
208+
# the caller can read the reason.
209+
# @rbs (untyped) -> void
210+
def validate_component!: (untyped) -> void
211+
184212
# @rbs () -> self
185213
def validate!: () -> self
186214

sig/generated/lib/solid_objects/supervisor.rbs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ module SolidObjects
3636

3737
attr_reader threads: untyped
3838

39+
attr_reader builders: untyped
40+
3941
# A role that raises leaves its thread dead. Without replacement the
4042
# process keeps running while quietly doing less work, so the supervisor
4143
# watches its threads and restarts any that stopped before shutdown.
@@ -101,8 +103,23 @@ module SolidObjects
101103
# @rbs () -> void
102104
def release_wake_up: () -> void
103105

104-
# @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
105-
def build_components: (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
106+
# A constructor can take a resource, and a later builder can raise. Without
107+
# this, the components built first would be dropped while still holding
108+
# whatever they took, and nothing would ever give it back.
109+
# @rbs (Array[^() -> untyped]) -> Array[untyped]
110+
def build_all: (Array[^() -> untyped]) -> Array[untyped]
111+
112+
# The failure that stopped the build is the one worth reporting, so a
113+
# failure inside the cleanup never replaces it.
114+
# @rbs (untyped) -> void
115+
def stop_after_failed_build: (untyped) -> void
116+
117+
# Each component keeps the builder that made it, so a replacement after a
118+
# crash is built the same way as the original. Components registered
119+
# through the configuration run beside the built in ones, under the same
120+
# supervision, restart, and shutdown timeout.
121+
# @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[^() -> untyped]
122+
def component_builders: (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[^() -> untyped]
106123

107124
# @rbs () -> void
108125
def join_until_timeout: () -> void

0 commit comments

Comments
 (0)