solid_objects 0.10.3 → 0.11.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 +27 -0
- data/README.md +33 -0
- data/lib/solid_objects/configuration.rb +42 -0
- data/lib/solid_objects/supervisor.rb +50 -9
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/configuration.rbs +32 -4
- data/sig/generated/lib/solid_objects/supervisor.rbs +19 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 530d40a11cbf715cc3657191124d336798d4e8efdccb129d8ee0431400ec36ff
|
|
4
|
+
data.tar.gz: 39abe950312fa41ab57aa55e7e01a088a01eb63dcfd535e0ada712ae75c03b01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: edd288ba3cc57f8ac7c241eda9e3e320385f26071a5dc1e11e3676af1106dc1ddadddfa520ff08f4ce61a0b2f73c23d1a2418199c8f6f98ebe85fe2b51b07426
|
|
7
|
+
data.tar.gz: 38e5561275048f00dcbd5e363b0d3da6a00242348094776c63da44df7e2ca694292e7bc0546d44ac5da7a8883eb70d3e237d7495c888d6d3e2dd9d286d1754f0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
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
|
+
|
|
3
30
|
## 0.10.3 - 2026-08-11
|
|
4
31
|
|
|
5
32
|
- Delete every actor-owned row in `SolidObjects::TestHelper#reset_actors!`. It
|
data/README.md
CHANGED
|
@@ -458,6 +458,39 @@ Deploy and monitor that process before enabling any feature marked as requiring
|
|
|
458
458
|
a runtime role. A missing worker never makes a durable `async` message
|
|
459
459
|
disappear, but it leaves the message pending indefinitely.
|
|
460
460
|
|
|
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
|
+
|
|
461
494
|
## Defining an actor
|
|
462
495
|
|
|
463
496
|
The Durable Object class becomes an ordinary Ruby class:
|
|
@@ -92,6 +92,9 @@ module SolidObjects
|
|
|
92
92
|
:authorize_subscription,
|
|
93
93
|
:authorize_administration
|
|
94
94
|
|
|
95
|
+
# @rbs @additional_components: Array[untyped]
|
|
96
|
+
attr_reader :additional_components
|
|
97
|
+
|
|
95
98
|
# @rbs () -> void
|
|
96
99
|
def initialize
|
|
97
100
|
@table_name_prefix = "solid_objects_"
|
|
@@ -142,6 +145,45 @@ module SolidObjects
|
|
|
142
145
|
@authorize_destroy = ->(**) { false }
|
|
143
146
|
@authorize_subscription = ->(**) { false }
|
|
144
147
|
@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
|
|
145
187
|
end
|
|
146
188
|
|
|
147
189
|
# @rbs () -> self
|
|
@@ -19,12 +19,13 @@ module SolidObjects
|
|
|
19
19
|
broadcast_worker_count: SolidObjects.configuration.broadcast_worker_count,
|
|
20
20
|
reminder_scheduler_count: SolidObjects.configuration.reminder_scheduler_count
|
|
21
21
|
)
|
|
22
|
-
@
|
|
22
|
+
@builders = component_builders(
|
|
23
23
|
worker_count:,
|
|
24
24
|
effect_worker_count:,
|
|
25
25
|
broadcast_worker_count:,
|
|
26
26
|
reminder_scheduler_count:
|
|
27
27
|
)
|
|
28
|
+
@components = build_all(@builders)
|
|
28
29
|
@threads = []
|
|
29
30
|
@monitor = nil
|
|
30
31
|
@started = false
|
|
@@ -77,7 +78,7 @@ module SolidObjects
|
|
|
77
78
|
|
|
78
79
|
private
|
|
79
80
|
|
|
80
|
-
attr_reader :components, :threads
|
|
81
|
+
attr_reader :components, :threads, :builders
|
|
81
82
|
|
|
82
83
|
# A role that raises leaves its thread dead. Without replacement the
|
|
83
84
|
# process keeps running while quietly doing less work, so the supervisor
|
|
@@ -116,7 +117,11 @@ module SolidObjects
|
|
|
116
117
|
replaced = @lifecycle.synchronize do
|
|
117
118
|
next false unless @started
|
|
118
119
|
|
|
119
|
-
|
|
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
|
|
120
125
|
components[index] = replacement
|
|
121
126
|
threads[index] = supervise(replacement)
|
|
122
127
|
replacement
|
|
@@ -250,17 +255,53 @@ module SolidObjects
|
|
|
250
255
|
nil
|
|
251
256
|
end
|
|
252
257
|
|
|
253
|
-
#
|
|
254
|
-
|
|
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(
|
|
255
295
|
worker_count:,
|
|
256
296
|
effect_worker_count:,
|
|
257
297
|
broadcast_worker_count:,
|
|
258
298
|
reminder_scheduler_count:
|
|
259
299
|
)
|
|
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
|
|
264
305
|
end
|
|
265
306
|
|
|
266
307
|
# @rbs () -> void
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Configuration
|
|
5
|
+
@process_alive_threshold: Float
|
|
6
|
+
|
|
7
|
+
@shutdown_timeout: Float
|
|
8
|
+
|
|
5
9
|
@supervisor_monitor_interval: Float
|
|
6
10
|
|
|
7
11
|
@retention_interval: Float
|
|
@@ -86,10 +90,6 @@ module SolidObjects
|
|
|
86
90
|
|
|
87
91
|
@process_heartbeat_interval: Float
|
|
88
92
|
|
|
89
|
-
@process_alive_threshold: Float
|
|
90
|
-
|
|
91
|
-
@shutdown_timeout: Float
|
|
92
|
-
|
|
93
93
|
attr_accessor table_name_prefix: untyped
|
|
94
94
|
|
|
95
95
|
attr_accessor polling_interval: untyped
|
|
@@ -178,9 +178,37 @@ module SolidObjects
|
|
|
178
178
|
|
|
179
179
|
attr_accessor authorize_administration: untyped
|
|
180
180
|
|
|
181
|
+
# @rbs @additional_components: Array[untyped]
|
|
182
|
+
attr_reader additional_components: untyped
|
|
183
|
+
|
|
181
184
|
# @rbs () -> void
|
|
182
185
|
def initialize: () -> void
|
|
183
186
|
|
|
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
|
+
|
|
184
212
|
# @rbs () -> self
|
|
185
213
|
def validate!: () -> self
|
|
186
214
|
|
|
@@ -36,6 +36,8 @@ module SolidObjects
|
|
|
36
36
|
|
|
37
37
|
attr_reader threads: untyped
|
|
38
38
|
|
|
39
|
+
attr_reader builders: untyped
|
|
40
|
+
|
|
39
41
|
# A role that raises leaves its thread dead. Without replacement the
|
|
40
42
|
# process keeps running while quietly doing less work, so the supervisor
|
|
41
43
|
# watches its threads and restarts any that stopped before shutdown.
|
|
@@ -101,8 +103,23 @@ module SolidObjects
|
|
|
101
103
|
# @rbs () -> void
|
|
102
104
|
def release_wake_up: () -> void
|
|
103
105
|
|
|
104
|
-
#
|
|
105
|
-
|
|
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]
|
|
106
123
|
|
|
107
124
|
# @rbs () -> void
|
|
108
125
|
def join_until_timeout: () -> void
|